Skip to main content

systemprompt_cli/commands/cloud/tenant/
crud.rs

1use anyhow::{anyhow, bail, Result};
2use chrono::Utc;
3use dialoguer::theme::ColorfulTheme;
4use dialoguer::{Confirm, Input, Select};
5use systemprompt_cloud::{
6    get_cloud_paths, CloudApiClient, CloudPath, StoredTenant, TenantStore, TenantType,
7};
8use systemprompt_logging::CliService;
9
10use super::docker::{
11    drop_database_for_tenant, load_shared_config, save_shared_config, stop_shared_container,
12};
13use super::select::{get_credentials, select_tenant};
14use crate::cli_settings::CliConfig;
15use crate::cloud::tenant::{TenantCancelArgs, TenantDeleteArgs};
16use crate::cloud::types::{TenantDetailOutput, TenantListOutput, TenantSummary};
17use crate::shared::{CommandResult, SuccessOutput};
18
19pub async fn list_tenants(config: &CliConfig) -> Result<CommandResult<TenantListOutput>> {
20    let cloud_paths = get_cloud_paths()?;
21    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
22
23    let store = sync_and_load_tenants(&tenants_path).await;
24
25    let summaries: Vec<TenantSummary> = store
26        .tenants
27        .iter()
28        .map(|t| TenantSummary {
29            id: t.id.clone(),
30            name: t.name.clone(),
31            tenant_type: format!("{:?}", t.tenant_type).to_lowercase(),
32            has_database: t.has_database_url(),
33        })
34        .collect();
35
36    let output = TenantListOutput {
37        total: summaries.len(),
38        tenants: summaries,
39    };
40
41    if store.tenants.is_empty() {
42        if !config.is_json_output() {
43            CliService::section("Tenants");
44            CliService::info("No tenants configured.");
45            CliService::info(
46                "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
47            );
48        }
49        return Ok(CommandResult::table(output)
50            .with_title("Tenants")
51            .with_columns(vec![
52                "id".to_string(),
53                "name".to_string(),
54                "tenant_type".to_string(),
55                "has_database".to_string(),
56            ]));
57    }
58
59    if !config.is_json_output() {
60        if config.is_interactive() {
61            let options: Vec<String> = store
62                .tenants
63                .iter()
64                .map(|t| {
65                    let type_str = match t.tenant_type {
66                        TenantType::Local => "local",
67                        TenantType::Cloud => "cloud",
68                    };
69                    let db_status = if t.has_database_url() {
70                        "✓ db"
71                    } else {
72                        "✗ db"
73                    };
74                    format!("{} ({}) [{}]", t.name, type_str, db_status)
75                })
76                .chain(std::iter::once("Back".to_string()))
77                .collect();
78
79            loop {
80                CliService::section("Tenants");
81                CliService::info("Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f");
82                CliService::info("");
83
84                let selection = Select::with_theme(&ColorfulTheme::default())
85                    .with_prompt("Select tenant")
86                    .items(&options)
87                    .default(0)
88                    .interact()?;
89
90                if selection == store.tenants.len() {
91                    break;
92                }
93
94                display_tenant_details(&store.tenants[selection]);
95            }
96        } else {
97            CliService::section("Tenants");
98            CliService::info("Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f");
99            CliService::info("");
100            for tenant in &store.tenants {
101                let type_str = match tenant.tenant_type {
102                    TenantType::Local => "local",
103                    TenantType::Cloud => "cloud",
104                };
105                let db_status = if tenant.has_database_url() {
106                    "✓ db"
107                } else {
108                    "✗ db"
109                };
110                CliService::info(&format!("{} ({}) [{}]", tenant.name, type_str, db_status));
111            }
112        }
113    }
114
115    Ok(CommandResult::table(output)
116        .with_title("Tenants")
117        .with_columns(vec![
118            "id".to_string(),
119            "name".to_string(),
120            "tenant_type".to_string(),
121            "has_database".to_string(),
122        ]))
123}
124
125fn display_tenant_details(tenant: &StoredTenant) {
126    CliService::section(&format!("Tenant: {}", tenant.name));
127    CliService::key_value("ID", &tenant.id);
128    CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
129
130    if let Some(ref app_id) = tenant.app_id {
131        CliService::key_value("App ID", app_id);
132    }
133
134    if let Some(ref hostname) = tenant.hostname {
135        CliService::key_value("Hostname", hostname);
136    }
137
138    if let Some(ref region) = tenant.region {
139        CliService::key_value("Region", region);
140    }
141
142    CliService::key_value(
143        "Database",
144        if tenant.has_database_url() {
145            "configured"
146        } else {
147            "not configured"
148        },
149    );
150}
151
152pub async fn show_tenant(
153    id: Option<String>,
154    config: &CliConfig,
155) -> Result<CommandResult<TenantDetailOutput>> {
156    let cloud_paths = get_cloud_paths()?;
157    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
158    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
159        if !config.is_json_output() {
160            CliService::warning(&format!("Failed to load tenant store: {}", e));
161        }
162        TenantStore::default()
163    });
164
165    let tenant = match id {
166        Some(ref id) => store
167            .find_tenant(id)
168            .ok_or_else(|| anyhow!("Tenant not found: {}", id))?,
169        None if config.is_interactive() => {
170            if store.tenants.is_empty() {
171                bail!("No tenants configured.");
172            }
173            select_tenant(&store.tenants)?
174        },
175        None => bail!("--id is required in non-interactive mode for tenant show"),
176    };
177
178    let output = TenantDetailOutput {
179        id: tenant.id.clone(),
180        name: tenant.name.clone(),
181        tenant_type: format!("{:?}", tenant.tenant_type).to_lowercase(),
182        app_id: tenant.app_id.clone(),
183        hostname: tenant.hostname.clone(),
184        region: tenant.region.clone(),
185        has_database: tenant.has_database_url(),
186    };
187
188    if !config.is_json_output() {
189        CliService::section(&format!("Tenant: {}", tenant.name));
190        CliService::key_value("ID", &tenant.id);
191        CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
192
193        if let Some(ref app_id) = tenant.app_id {
194            CliService::key_value("App ID", app_id);
195        }
196
197        if let Some(ref hostname) = tenant.hostname {
198            CliService::key_value("Hostname", hostname);
199        }
200
201        if let Some(ref region) = tenant.region {
202            CliService::key_value("Region", region);
203        }
204
205        if tenant.has_database_url() {
206            CliService::key_value("Database", "configured");
207        } else {
208            CliService::key_value("Database", "not configured");
209        }
210    }
211
212    Ok(CommandResult::card(output).with_title(format!("Tenant: {}", tenant.name)))
213}
214
215pub async fn delete_tenant(
216    args: TenantDeleteArgs,
217    config: &CliConfig,
218) -> Result<CommandResult<SuccessOutput>> {
219    let cloud_paths = get_cloud_paths()?;
220    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
221    let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
222        if !config.is_json_output() {
223            CliService::warning(&format!("Failed to load tenant store: {}", e));
224        }
225        TenantStore::default()
226    });
227
228    let tenant_id = if let Some(id) = args.id {
229        id
230    } else {
231        if !config.is_interactive() {
232            return Err(anyhow::anyhow!(
233                "--id is required in non-interactive mode for tenant delete"
234            ));
235        }
236        if store.tenants.is_empty() {
237            bail!("No tenants configured.");
238        }
239        select_tenant(&store.tenants)?.id.clone()
240    };
241
242    let tenant = store
243        .tenants
244        .iter()
245        .find(|t| t.id == tenant_id)
246        .ok_or_else(|| anyhow!("Tenant not found: {}", tenant_id))?
247        .clone();
248
249    let is_cloud = tenant.tenant_type == TenantType::Cloud;
250
251    if !args.yes {
252        if !config.is_interactive() {
253            return Err(anyhow::anyhow!(
254                "--yes is required in non-interactive mode for tenant delete"
255            ));
256        }
257
258        let prompt = if is_cloud {
259            format!(
260                "Delete cloud tenant '{}'? This will cancel your subscription and delete all data.",
261                tenant.name
262            )
263        } else {
264            format!("Delete tenant '{}'?", tenant.name)
265        };
266
267        let confirm = Confirm::with_theme(&ColorfulTheme::default())
268            .with_prompt(prompt)
269            .default(false)
270            .interact()?;
271
272        if !confirm {
273            let output = SuccessOutput::new("Cancelled");
274            if !config.is_json_output() {
275                CliService::info("Cancelled");
276            }
277            return Ok(CommandResult::text(output).with_title("Delete Tenant"));
278        }
279    }
280
281    if is_cloud {
282        let creds = get_credentials()?;
283        let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
284
285        if config.is_json_output() {
286            client.delete_tenant(&tenant_id).await?;
287        } else {
288            let spinner = CliService::spinner("Deleting cloud tenant...");
289            client.delete_tenant(&tenant_id).await?;
290            spinner.finish_and_clear();
291        }
292    } else if tenant.uses_shared_container() {
293        cleanup_shared_container_tenant(&tenant, config).await?;
294    }
295
296    store.tenants.retain(|t| t.id != tenant_id);
297    store.save_to_path(&tenants_path)?;
298
299    let output = SuccessOutput::new(format!("Deleted tenant: {}", tenant_id));
300
301    if !config.is_json_output() {
302        CliService::success(&format!("Deleted tenant: {}", tenant_id));
303    }
304
305    Ok(CommandResult::text(output).with_title("Delete Tenant"))
306}
307
308async fn cleanup_shared_container_tenant(tenant: &StoredTenant, config: &CliConfig) -> Result<()> {
309    let Some(ref db_name) = tenant.shared_container_db else {
310        return Ok(());
311    };
312
313    let Some(mut shared_config) = load_shared_config()? else {
314        CliService::warning("Shared container config not found, skipping database cleanup");
315        return Ok(());
316    };
317
318    let spinner = CliService::spinner(&format!("Dropping database '{}'...", db_name));
319    match drop_database_for_tenant(&shared_config.admin_password, shared_config.port, db_name).await
320    {
321        Ok(()) => {
322            spinner.finish_and_clear();
323            CliService::success(&format!("Database '{}' dropped", db_name));
324        },
325        Err(e) => {
326            spinner.finish_and_clear();
327            CliService::warning(&format!("Failed to drop database '{}': {}", db_name, e));
328        },
329    }
330
331    shared_config.remove_tenant(&tenant.id);
332    save_shared_config(&shared_config)?;
333
334    if shared_config.tenant_databases.is_empty() {
335        let should_remove = if config.is_interactive() {
336            Confirm::with_theme(&ColorfulTheme::default())
337                .with_prompt("No local tenants remain. Remove shared PostgreSQL container?")
338                .default(true)
339                .interact()?
340        } else {
341            false
342        };
343
344        if should_remove {
345            stop_shared_container()?;
346        } else {
347            CliService::info(
348                "Shared container kept. Remove manually with 'docker compose -f \
349                 .systemprompt/docker/shared.yaml down -v'",
350            );
351        }
352    }
353
354    Ok(())
355}
356
357pub async fn edit_tenant(
358    id: Option<String>,
359    config: &CliConfig,
360) -> Result<CommandResult<TenantDetailOutput>> {
361    if !config.is_interactive() {
362        return Err(anyhow::anyhow!(
363            "Tenant edit requires interactive mode.\nUse specific commands to modify tenant \
364             settings in non-interactive mode."
365        ));
366    }
367
368    let cloud_paths = get_cloud_paths()?;
369    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
370    let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
371        CliService::warning(&format!("Failed to load tenant store: {}", e));
372        TenantStore::default()
373    });
374
375    let tenant_id = if let Some(id) = id {
376        id
377    } else {
378        if store.tenants.is_empty() {
379            bail!("No tenants configured.");
380        }
381        select_tenant(&store.tenants)?.id.clone()
382    };
383
384    let tenant = store
385        .tenants
386        .iter_mut()
387        .find(|t| t.id == tenant_id)
388        .ok_or_else(|| anyhow!("Tenant not found: {}", tenant_id))?;
389
390    CliService::section(&format!("Edit Tenant: {}", tenant.name));
391
392    let new_name: String = Input::with_theme(&ColorfulTheme::default())
393        .with_prompt("Tenant name")
394        .default(tenant.name.clone())
395        .interact_text()?;
396
397    if new_name.is_empty() {
398        bail!("Tenant name cannot be empty");
399    }
400    tenant.name.clone_from(&new_name);
401
402    if tenant.tenant_type == TenantType::Local {
403        edit_local_tenant_database(tenant)?;
404    }
405
406    if tenant.tenant_type == TenantType::Cloud {
407        display_readonly_cloud_fields(tenant);
408    }
409
410    let output = TenantDetailOutput {
411        id: tenant.id.clone(),
412        name: tenant.name.clone(),
413        tenant_type: format!("{:?}", tenant.tenant_type).to_lowercase(),
414        app_id: tenant.app_id.clone(),
415        hostname: tenant.hostname.clone(),
416        region: tenant.region.clone(),
417        has_database: tenant.has_database_url(),
418    };
419
420    store.save_to_path(&tenants_path)?;
421    CliService::success(&format!("Tenant '{}' updated", new_name));
422
423    Ok(CommandResult::card(output)
424        .with_title(format!("Tenant: {}", new_name))
425        .with_skip_render())
426}
427
428fn edit_local_tenant_database(tenant: &mut StoredTenant) -> Result<()> {
429    if let Some(current_url) = tenant.database_url.clone() {
430        let edit_db = Confirm::with_theme(&ColorfulTheme::default())
431            .with_prompt("Edit database URL?")
432            .default(false)
433            .interact()?;
434
435        if edit_db {
436            let new_url: String = Input::with_theme(&ColorfulTheme::default())
437                .with_prompt("Database URL")
438                .default(current_url)
439                .interact_text()?;
440            tenant.database_url = if new_url.is_empty() {
441                None
442            } else {
443                Some(new_url)
444            };
445        }
446    }
447    Ok(())
448}
449
450fn display_readonly_cloud_fields(tenant: &StoredTenant) {
451    if let Some(ref region) = tenant.region {
452        CliService::info(&format!("Region: {} (cannot be changed)", region));
453    }
454    if let Some(ref hostname) = tenant.hostname {
455        CliService::info(&format!("Hostname: {} (cannot be changed)", hostname));
456    }
457}
458
459async fn sync_and_load_tenants(tenants_path: &std::path::Path) -> TenantStore {
460    let mut local_store =
461        TenantStore::load_from_path(tenants_path).unwrap_or_else(|_| TenantStore::default());
462
463    let Ok(creds) = get_credentials() else {
464        return local_store;
465    };
466
467    let Ok(client) = CloudApiClient::new(&creds.api_url, &creds.api_token) else {
468        return local_store;
469    };
470
471    let cloud_tenant_infos = match client.get_user().await {
472        Ok(response) => response.tenants,
473        Err(e) => {
474            CliService::warning(&format!("Failed to sync cloud tenants: {}", e));
475            return local_store;
476        },
477    };
478
479    for cloud_info in &cloud_tenant_infos {
480        if let Some(existing) = local_store
481            .tenants
482            .iter_mut()
483            .find(|t| t.id == cloud_info.id)
484        {
485            existing.update_from_tenant_info(cloud_info);
486        } else {
487            local_store
488                .tenants
489                .push(StoredTenant::from_tenant_info(cloud_info));
490        }
491    }
492
493    local_store.synced_at = Utc::now();
494
495    if let Err(e) = local_store.save_to_path(tenants_path) {
496        CliService::warning(&format!("Failed to save synced tenants: {}", e));
497    }
498
499    local_store
500}
501
502pub async fn cancel_subscription(
503    args: TenantCancelArgs,
504    config: &CliConfig,
505) -> Result<CommandResult<crate::cloud::types::CancelSubscriptionOutput>> {
506    if !config.is_interactive() {
507        bail!(
508            "Subscription cancellation requires interactive mode for safety.\nThis is an \
509             irreversible operation that destroys all data."
510        );
511    }
512
513    let cloud_paths = get_cloud_paths()?;
514    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
515    let store =
516        TenantStore::load_from_path(&tenants_path).unwrap_or_else(|_| TenantStore::default());
517
518    let cloud_tenants: Vec<&StoredTenant> = store
519        .tenants
520        .iter()
521        .filter(|t| t.tenant_type == TenantType::Cloud)
522        .collect();
523
524    if cloud_tenants.is_empty() {
525        bail!("No cloud tenants found. Only cloud tenants have subscriptions.");
526    }
527
528    let tenant = if let Some(ref id) = args.id {
529        store
530            .tenants
531            .iter()
532            .find(|t| t.id == *id && t.tenant_type == TenantType::Cloud)
533            .ok_or_else(|| anyhow!("Cloud tenant not found: {}", id))?
534    } else {
535        let options: Vec<String> = cloud_tenants
536            .iter()
537            .map(|t| format!("{} ({})", t.name, t.id))
538            .collect();
539
540        let selection = Select::with_theme(&ColorfulTheme::default())
541            .with_prompt("Select cloud tenant to cancel")
542            .items(&options)
543            .default(0)
544            .interact()?;
545
546        cloud_tenants[selection]
547    };
548
549    CliService::section("⚠️  CANCEL SUBSCRIPTION");
550    CliService::error("THIS ACTION IS IRREVERSIBLE");
551    CliService::info("");
552    CliService::info("This will:");
553    CliService::info("  • Cancel your subscription immediately");
554    CliService::info("  • Stop and destroy the Fly.io machine");
555    CliService::info("  • Delete ALL data in the database");
556    CliService::info("  • Remove all deployed code and configuration");
557    CliService::info("");
558    CliService::warning("Your data CANNOT be recovered after this operation.");
559    CliService::info("");
560
561    CliService::key_value("Tenant", &tenant.name);
562    CliService::key_value("ID", &tenant.id);
563    if let Some(ref hostname) = tenant.hostname {
564        CliService::key_value("URL", &format!("https://{}", hostname));
565    }
566    CliService::info("");
567
568    let confirmation: String = Input::with_theme(&ColorfulTheme::default())
569        .with_prompt(format!("Type '{}' to confirm cancellation", tenant.name))
570        .interact_text()?;
571
572    if confirmation != tenant.name {
573        CliService::info("Cancellation aborted. Tenant name did not match.");
574        let output = crate::cloud::types::CancelSubscriptionOutput {
575            tenant_id: tenant.id.clone(),
576            tenant_name: tenant.name.clone(),
577            message: "Cancellation aborted. Tenant name did not match.".to_string(),
578        };
579        return Ok(CommandResult::text(output)
580            .with_title("Cancel Subscription")
581            .with_skip_render());
582    }
583
584    let creds = get_credentials()?;
585    let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
586
587    let spinner = CliService::spinner("Cancelling subscription...");
588    client.cancel_subscription(&tenant.id).await?;
589    spinner.finish_and_clear();
590
591    CliService::success("Subscription cancelled");
592    CliService::info("Your tenant will be suspended and all data will be destroyed.");
593    CliService::info("You will not be charged for future billing periods.");
594    CliService::info("");
595    CliService::info(
596        "Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f",
597    );
598
599    let output = crate::cloud::types::CancelSubscriptionOutput {
600        tenant_id: tenant.id.clone(),
601        tenant_name: tenant.name.clone(),
602        message: "Subscription cancelled. Your tenant will be suspended and all data will be \
603                  destroyed."
604            .to_string(),
605    };
606
607    Ok(CommandResult::text(output)
608        .with_title("Cancel Subscription")
609        .with_skip_render())
610}