Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

1//! `cloud tenant` subcommand: manage local and cloud tenants.
2//!
3//! Exposes [`TenantCommands`] (create, list, show, delete, edit, rotate
4//! credentials) with an interactive operation menu when no subcommand
5//! is supplied. Persists tenant records through the cloud `TenantStore`.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10pub mod create;
11mod create_flow;
12pub(super) mod delete;
13pub mod docker;
14mod edit;
15mod list;
16mod rotate;
17pub mod select;
18mod show;
19
20pub use create::{create_external_tenant, create_local_tenant};
21pub use delete::delete_tenant;
22pub(in crate::commands::cloud) use docker::container::wait_for_postgres_healthy;
23pub use edit::edit_tenant;
24pub use list::list_tenants;
25pub use rotate::rotate_credentials;
26pub use select::{get_credentials, resolve_tenant_id, select_tenant};
27pub use show::show_tenant;
28
29use anyhow::Result;
30use clap::{Args, Subcommand};
31use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
32use systemprompt_identifiers::TenantId;
33use systemprompt_logging::CliService;
34
35use crate::context::CommandContext;
36use crate::interactive::Prompter;
37use crate::shared::{parse_tenant_id, render_result};
38use create_flow::tenant_create;
39
40#[derive(Debug, Subcommand)]
41pub enum TenantCommands {
42    #[command(about = "Create a new tenant")]
43    Create,
44
45    #[command(
46        about = "List all tenants",
47        after_help = "EXAMPLES:\n  systemprompt cloud tenant list\n  systemprompt cloud tenant \
48                      list --json"
49    )]
50    List,
51
52    #[command(about = "Show tenant details")]
53    Show {
54        #[arg(value_parser = parse_tenant_id)]
55        id: Option<TenantId>,
56    },
57
58    #[command(about = "Delete a tenant")]
59    Delete(TenantDeleteArgs),
60
61    #[command(about = "Edit tenant configuration")]
62    Edit {
63        #[arg(value_parser = parse_tenant_id)]
64        id: Option<TenantId>,
65    },
66
67    #[command(about = "Rotate database credentials")]
68    RotateCredentials(TenantRotateArgs),
69}
70
71#[derive(Debug, Args)]
72pub struct TenantRotateArgs {
73    #[arg(value_parser = parse_tenant_id)]
74    pub id: Option<TenantId>,
75
76    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
77    pub yes: bool,
78}
79
80#[derive(Debug, Args)]
81pub struct TenantDeleteArgs {
82    #[arg(value_parser = parse_tenant_id)]
83    pub id: Option<TenantId>,
84
85    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
86    pub yes: bool,
87}
88
89pub async fn execute(cmd: Option<TenantCommands>, ctx: &CommandContext) -> Result<()> {
90    if let Some(cmd) = cmd {
91        execute_command(cmd, ctx).await.map(drop)
92    } else {
93        if !ctx.cli.is_interactive() {
94            return Err(anyhow::anyhow!(
95                "Tenant subcommand required in non-interactive mode"
96            ));
97        }
98        while let Some(cmd) = select_operation(ctx.prompter())? {
99            if execute_command(cmd, ctx).await? {
100                break;
101            }
102        }
103        Ok(())
104    }
105}
106
107async fn execute_command(cmd: TenantCommands, ctx: &CommandContext) -> Result<bool> {
108    match cmd {
109        TenantCommands::Create => tenant_create(ctx.prompter(), &ctx.cli).await.map(|()| true),
110        TenantCommands::List => {
111            let result = list_tenants(ctx.prompter(), &ctx.cli).await?;
112            render_result(&result, &ctx.cli);
113            Ok(false)
114        },
115        TenantCommands::Show { id } => {
116            let result = show_tenant(ctx.prompter(), id.as_ref(), &ctx.cli)?;
117            render_result(&result, &ctx.cli);
118            Ok(false)
119        },
120        TenantCommands::Delete(args) => {
121            let result = delete_tenant(args, ctx.prompter(), &ctx.cli).await?;
122            render_result(&result, &ctx.cli);
123            Ok(false)
124        },
125        TenantCommands::Edit { id } => {
126            let result = edit_tenant(id, ctx.prompter(), &ctx.cli)?;
127            render_result(&result, &ctx.cli);
128            Ok(false)
129        },
130        TenantCommands::RotateCredentials(args) => {
131            let result = rotate_credentials(
132                args.id,
133                args.yes || !ctx.cli.is_interactive(),
134                ctx.prompter(),
135                &ctx.cli,
136            )
137            .await?;
138            render_result(&result, &ctx.cli);
139            Ok(false)
140        },
141    }
142}
143
144fn select_operation(prompter: &dyn Prompter) -> Result<Option<TenantCommands>> {
145    let cloud_paths = get_cloud_paths();
146    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
147    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
148        CliService::warning(&format!("Failed to load tenant store: {}", e));
149        TenantStore::default()
150    });
151    let has_tenants = !store.tenants.is_empty();
152
153    choose_tenant_operation(prompter, has_tenants)
154}
155
156pub fn choose_tenant_operation(
157    prompter: &dyn Prompter,
158    has_tenants: bool,
159) -> Result<Option<TenantCommands>> {
160    let edit_label = if has_tenants {
161        "Edit".to_owned()
162    } else {
163        "Edit (unavailable - no tenants configured)".to_owned()
164    };
165    let delete_label = if has_tenants {
166        "Delete".to_owned()
167    } else {
168        "Delete (unavailable - no tenants configured)".to_owned()
169    };
170
171    let operations = vec![
172        "Create".to_owned(),
173        "List".to_owned(),
174        edit_label,
175        delete_label,
176        "Done".to_owned(),
177    ];
178
179    let selection = prompter.select("Tenant operation", &operations)?;
180
181    let cmd = match selection {
182        0 => Some(TenantCommands::Create),
183        1 => Some(TenantCommands::List),
184        2 | 3 if !has_tenants => {
185            CliService::warning("No tenants configured");
186            CliService::info("Run 'systemprompt cloud tenant create' to create one.");
187            return Ok(Some(TenantCommands::List));
188        },
189        2 => Some(TenantCommands::Edit { id: None }),
190        3 => Some(TenantCommands::Delete(TenantDeleteArgs {
191            id: None,
192            yes: false,
193        })),
194        4 => None,
195        other => return Err(anyhow::anyhow!("unexpected menu selection: {other}")),
196    };
197
198    Ok(cmd)
199}