Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

1//! `cloud tenant` subcommand: manage local and cloud tenants.
2//!
3//! Exposes [`TenantCommands`] (create, list, show, delete, edit, rotate
4//! credentials, cancel) with an interactive operation menu when no subcommand
5//! is supplied. Persists tenant records through the cloud `TenantStore`.
6
7mod cancel;
8mod create;
9mod create_flow;
10pub(super) mod delete;
11mod docker;
12mod edit;
13mod list;
14mod rotate;
15mod select;
16mod show;
17mod validation;
18
19pub use cancel::cancel_subscription;
20pub use create::{
21    create_cloud_tenant, create_external_tenant, create_local_tenant, swap_to_external_host,
22};
23pub use delete::delete_tenant;
24pub(in crate::commands::cloud) use docker::wait_for_postgres_healthy;
25pub use edit::edit_tenant;
26pub use list::list_tenants;
27pub use rotate::rotate_credentials;
28pub use select::{get_credentials, resolve_tenant_id};
29pub use show::show_tenant;
30pub use validation::check_build_ready;
31
32use anyhow::Result;
33use clap::{Args, Subcommand};
34use dialoguer::Select;
35use dialoguer::theme::ColorfulTheme;
36use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
37use systemprompt_logging::CliService;
38
39use crate::context::CommandContext;
40use crate::shared::render_result;
41use create_flow::tenant_create;
42
43#[derive(Debug, Subcommand)]
44pub enum TenantCommands {
45    #[command(about = "Create a new tenant (local or cloud)")]
46    Create {
47        #[arg(long, default_value = "iad")]
48        region: String,
49    },
50
51    #[command(
52        about = "List all tenants",
53        after_help = "EXAMPLES:\n  systemprompt cloud tenant list\n  systemprompt cloud tenant \
54                      list --json"
55    )]
56    List,
57
58    #[command(about = "Show tenant details")]
59    Show { id: Option<String> },
60
61    #[command(about = "Delete a tenant")]
62    Delete(TenantDeleteArgs),
63
64    #[command(about = "Edit tenant configuration")]
65    Edit { id: Option<String> },
66
67    #[command(about = "Rotate database credentials")]
68    RotateCredentials(TenantRotateArgs),
69
70    #[command(about = "Cancel subscription and destroy tenant (IRREVERSIBLE)")]
71    Cancel(TenantCancelArgs),
72}
73
74#[derive(Debug, Args)]
75pub struct TenantRotateArgs {
76    pub id: Option<String>,
77
78    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
79    pub yes: bool,
80}
81
82#[derive(Debug, Args)]
83pub struct TenantDeleteArgs {
84    pub id: Option<String>,
85
86    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
87    pub yes: bool,
88}
89
90#[derive(Debug, Args)]
91pub struct TenantCancelArgs {
92    pub id: Option<String>,
93}
94
95pub async fn execute(cmd: Option<TenantCommands>, ctx: &CommandContext) -> Result<()> {
96    if let Some(cmd) = cmd {
97        execute_command(cmd, ctx).await.map(drop)
98    } else {
99        if !ctx.cli.is_interactive() {
100            return Err(anyhow::anyhow!(
101                "Tenant subcommand required in non-interactive mode"
102            ));
103        }
104        while let Some(cmd) = select_operation()? {
105            if execute_command(cmd, ctx).await? {
106                break;
107            }
108        }
109        Ok(())
110    }
111}
112
113async fn execute_command(cmd: TenantCommands, ctx: &CommandContext) -> Result<bool> {
114    match cmd {
115        TenantCommands::Create { region } => tenant_create(&region, &ctx.cli).await.map(|()| true),
116        TenantCommands::List => {
117            let result = list_tenants(&ctx.cli).await?;
118            render_result(&result, &ctx.cli);
119            Ok(false)
120        },
121        TenantCommands::Show { id } => {
122            let result = show_tenant(id.as_ref(), &ctx.cli)?;
123            render_result(&result, &ctx.cli);
124            Ok(false)
125        },
126        TenantCommands::Delete(args) => {
127            let result = delete_tenant(args, &ctx.cli).await?;
128            render_result(&result, &ctx.cli);
129            Ok(false)
130        },
131        TenantCommands::Edit { id } => {
132            let result = edit_tenant(id, &ctx.cli)?;
133            render_result(&result, &ctx.cli);
134            Ok(false)
135        },
136        TenantCommands::RotateCredentials(args) => {
137            let result =
138                rotate_credentials(args.id, args.yes || !ctx.cli.is_interactive(), &ctx.cli)
139                    .await?;
140            render_result(&result, &ctx.cli);
141            Ok(false)
142        },
143        TenantCommands::Cancel(args) => {
144            let result = cancel_subscription(args, &ctx.cli).await?;
145            render_result(&result, &ctx.cli);
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_owned()
162    } else {
163        "Edit (unavailable - no tenants configured)".to_owned()
164    };
165    let delete_label = if has_tenants {
166        "Delete".to_owned()
167    } else {
168        "Delete (unavailable - no tenants configured)".to_owned()
169    };
170
171    let operations = vec![
172        "Create".to_owned(),
173        "List".to_owned(),
174        edit_label,
175        delete_label,
176        "Done".to_owned(),
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_owned(),
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}