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