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