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