Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

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