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, cancel) with an interactive operation menu when no subcommand
5//! is supplied. Persists tenant records through the cloud `TenantStore`.
6
7mod cancel;
8mod create;
9pub(super) mod delete;
10mod docker;
11mod edit;
12mod list;
13mod rotate;
14mod select;
15mod show;
16mod validation;
17
18pub use cancel::cancel_subscription;
19pub use create::{
20    create_cloud_tenant, create_external_tenant, create_local_tenant, swap_to_external_host,
21};
22pub use delete::delete_tenant;
23pub(in crate::commands::cloud) use docker::wait_for_postgres_healthy;
24pub use edit::edit_tenant;
25pub use list::list_tenants;
26pub use rotate::rotate_credentials;
27pub use select::{get_credentials, resolve_tenant_id};
28pub use show::show_tenant;
29pub use validation::{check_build_ready, find_services_config};
30
31use anyhow::Result;
32use clap::{Args, Subcommand};
33use dialoguer::Select;
34use dialoguer::theme::ColorfulTheme;
35use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
36use systemprompt_logging::CliService;
37
38use crate::cli_settings::CliConfig;
39use crate::shared::render_result;
40
41#[derive(Debug, Subcommand)]
42pub enum TenantCommands {
43    #[command(about = "Create a new tenant (local or cloud)")]
44    Create {
45        #[arg(long, default_value = "iad")]
46        region: String,
47    },
48
49    #[command(
50        about = "List all tenants",
51        after_help = "EXAMPLES:\n  systemprompt cloud tenant list\n  systemprompt cloud tenant \
52                      list --json"
53    )]
54    List,
55
56    #[command(about = "Show tenant details")]
57    Show { id: Option<String> },
58
59    #[command(about = "Delete a tenant")]
60    Delete(TenantDeleteArgs),
61
62    #[command(about = "Edit tenant configuration")]
63    Edit { id: Option<String> },
64
65    #[command(about = "Rotate database credentials")]
66    RotateCredentials(TenantRotateArgs),
67
68    #[command(about = "Cancel subscription and destroy tenant (IRREVERSIBLE)")]
69    Cancel(TenantCancelArgs),
70}
71
72#[derive(Debug, Args)]
73pub struct TenantRotateArgs {
74    pub id: Option<String>,
75
76    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
77    pub yes: bool,
78}
79
80#[derive(Debug, Args)]
81pub struct TenantDeleteArgs {
82    pub id: Option<String>,
83
84    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
85    pub yes: bool,
86}
87
88#[derive(Debug, Args)]
89pub struct TenantCancelArgs {
90    pub id: Option<String>,
91}
92
93pub async fn execute(cmd: Option<TenantCommands>, config: &CliConfig) -> Result<()> {
94    if let Some(cmd) = cmd {
95        execute_command(cmd, config).await.map(drop)
96    } else {
97        if !config.is_interactive() {
98            return Err(anyhow::anyhow!(
99                "Tenant subcommand required in non-interactive mode"
100            ));
101        }
102        while let Some(cmd) = select_operation()? {
103            if execute_command(cmd, config).await? {
104                break;
105            }
106        }
107        Ok(())
108    }
109}
110
111async fn execute_command(cmd: TenantCommands, config: &CliConfig) -> Result<bool> {
112    match cmd {
113        TenantCommands::Create { region } => tenant_create(&region, config).await.map(|()| true),
114        TenantCommands::List => {
115            let result = list_tenants(config).await?;
116            render_result(&result);
117            Ok(false)
118        },
119        TenantCommands::Show { id } => {
120            let result = show_tenant(id.as_ref(), config)?;
121            render_result(&result);
122            Ok(false)
123        },
124        TenantCommands::Delete(args) => {
125            let result = delete_tenant(args, config).await?;
126            render_result(&result);
127            Ok(false)
128        },
129        TenantCommands::Edit { id } => {
130            let result = edit_tenant(id, config)?;
131            render_result(&result);
132            Ok(false)
133        },
134        TenantCommands::RotateCredentials(args) => {
135            let result =
136                rotate_credentials(args.id, args.yes || !config.is_interactive(), config).await?;
137            render_result(&result);
138            Ok(false)
139        },
140        TenantCommands::Cancel(args) => {
141            let result = cancel_subscription(args, config).await?;
142            render_result(&result);
143            Ok(false)
144        },
145    }
146}
147
148fn select_operation() -> Result<Option<TenantCommands>> {
149    let cloud_paths = get_cloud_paths();
150    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
151    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
152        CliService::warning(&format!("Failed to load tenant store: {}", e));
153        TenantStore::default()
154    });
155    let has_tenants = !store.tenants.is_empty();
156
157    let edit_label = if has_tenants {
158        "Edit".to_owned()
159    } else {
160        "Edit (unavailable - no tenants configured)".to_owned()
161    };
162    let delete_label = if has_tenants {
163        "Delete".to_owned()
164    } else {
165        "Delete (unavailable - no tenants configured)".to_owned()
166    };
167
168    let operations = vec![
169        "Create".to_owned(),
170        "List".to_owned(),
171        edit_label,
172        delete_label,
173        "Done".to_owned(),
174    ];
175
176    let selection = Select::with_theme(&ColorfulTheme::default())
177        .with_prompt("Tenant operation")
178        .items(&operations)
179        .default(0)
180        .interact()?;
181
182    let cmd = match selection {
183        0 => Some(TenantCommands::Create {
184            region: "iad".to_owned(),
185        }),
186        1 => Some(TenantCommands::List),
187        2 | 3 if !has_tenants => {
188            CliService::warning("No tenants configured");
189            CliService::info(
190                "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
191            );
192            return Ok(Some(TenantCommands::List));
193        },
194        2 => Some(TenantCommands::Edit { id: None }),
195        3 => Some(TenantCommands::Delete(TenantDeleteArgs {
196            id: None,
197            yes: false,
198        })),
199        4 => None,
200        _ => unreachable!(),
201    };
202
203    Ok(cmd)
204}
205
206async fn tenant_create(default_region: &str, config: &CliConfig) -> Result<()> {
207    if !config.is_interactive() {
208        return Err(anyhow::anyhow!(
209            "Tenant creation requires interactive mode.\nUse specific tenant type commands in \
210             non-interactive mode (not yet implemented)."
211        ));
212    }
213
214    CliService::section("Create Tenant");
215
216    let creds = get_credentials()?;
217
218    let build_result = check_build_ready();
219    let cloud_option = match &build_result {
220        Ok(()) => "Cloud (requires subscription at systemprompt.io)".to_owned(),
221        Err(_) => "Cloud (unavailable - release build required)".to_owned(),
222    };
223
224    let options = vec![
225        "Local (creates PostgreSQL container automatically)".to_owned(),
226        cloud_option,
227    ];
228
229    let selection = Select::with_theme(&ColorfulTheme::default())
230        .with_prompt("Tenant type")
231        .items(&options)
232        .default(0)
233        .interact()?;
234
235    let tenant = match selection {
236        0 => {
237            let db_options = vec![
238                "Docker (creates PostgreSQL container automatically)",
239                "External PostgreSQL (use your own database)",
240            ];
241
242            let db_selection = Select::with_theme(&ColorfulTheme::default())
243                .with_prompt("Database source")
244                .items(&db_options)
245                .default(0)
246                .interact()?;
247
248            match db_selection {
249                0 => create_local_tenant().await?,
250                _ => create_external_tenant().await?,
251            }
252        },
253        _ if build_result.is_err() => {
254            CliService::warning("Cloud tenant creation requires a release build.");
255            CliService::info("");
256            CliService::info("Run the following command to build:");
257            CliService::info("  cargo build --release --workspace");
258            CliService::info("");
259            if let Err(err) = &build_result {
260                CliService::info("Specific issue:");
261                CliService::error(err);
262            }
263            return Ok(());
264        },
265        _ => create_cloud_tenant(&creds, default_region).await?,
266    };
267
268    let cloud_paths = get_cloud_paths();
269    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
270    let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
271        CliService::warning(&format!("Failed to load tenant store: {}", e));
272        TenantStore::default()
273    });
274
275    if let Some(existing) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
276        *existing = tenant.clone();
277    } else {
278        store.tenants.push(tenant.clone());
279    }
280    store.save_to_path(&tenants_path)?;
281
282    CliService::success("Tenant created");
283    CliService::key_value("ID", &tenant.id);
284    CliService::key_value("Name", &tenant.name);
285    CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
286
287    if let Some(ref url) = tenant.database_url {
288        if !url.is_empty() {
289            CliService::key_value("Database URL", url);
290        }
291    }
292
293    Ok(())
294}