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(_) => "Cloud (unavailable - release build required)".to_string(),
217    };
218
219    let options = vec![
220        "Local (creates PostgreSQL container automatically)".to_string(),
221        cloud_option,
222    ];
223
224    let selection = Select::with_theme(&ColorfulTheme::default())
225        .with_prompt("Tenant type")
226        .items(&options)
227        .default(0)
228        .interact()?;
229
230    let tenant = match selection {
231        0 => {
232            let db_options = vec![
233                "Docker (creates PostgreSQL container automatically)",
234                "External PostgreSQL (use your own database)",
235            ];
236
237            let db_selection = Select::with_theme(&ColorfulTheme::default())
238                .with_prompt("Database source")
239                .items(&db_options)
240                .default(0)
241                .interact()?;
242
243            match db_selection {
244                0 => create_local_tenant().await?,
245                _ => create_external_tenant().await?,
246            }
247        },
248        _ if build_result.is_err() => {
249            CliService::warning("Cloud tenant creation requires a release build.");
250            CliService::info("");
251            CliService::info("Run the following command to build:");
252            CliService::info("  cargo build --release --workspace");
253            CliService::info("");
254            if let Err(err) = &build_result {
255                CliService::info("Specific issue:");
256                CliService::error(err);
257            }
258            return Ok(());
259        },
260        _ => create_cloud_tenant(&creds, default_region).await?,
261    };
262
263    let cloud_paths = get_cloud_paths()?;
264    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
265    let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
266        CliService::warning(&format!("Failed to load tenant store: {}", e));
267        TenantStore::default()
268    });
269
270    if let Some(existing) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
271        *existing = tenant.clone();
272    } else {
273        store.tenants.push(tenant.clone());
274    }
275    store.save_to_path(&tenants_path)?;
276
277    CliService::success("Tenant created");
278    CliService::key_value("ID", &tenant.id);
279    CliService::key_value("Name", &tenant.name);
280    CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
281
282    if let Some(ref url) = tenant.database_url {
283        if !url.is_empty() {
284            CliService::key_value("Database URL", url);
285        }
286    }
287
288    Ok(())
289}