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