Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

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