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