systemprompt_cli/commands/cloud/tenant/
mod.rs1pub mod create;
11mod create_flow;
12pub(super) mod delete;
13pub mod docker;
14mod edit;
15mod list;
16mod rotate;
17pub mod select;
18mod show;
19
20pub use create::{create_external_tenant, create_local_tenant};
21pub use delete::delete_tenant;
22pub(in crate::commands::cloud) use docker::container::wait_for_postgres_healthy;
23pub use edit::edit_tenant;
24pub use list::list_tenants;
25pub use rotate::rotate_credentials;
26pub use select::{get_credentials, resolve_tenant_id, select_tenant};
27pub use show::show_tenant;
28
29use anyhow::Result;
30use clap::{Args, Subcommand};
31use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
32use systemprompt_logging::CliService;
33
34use crate::context::CommandContext;
35use crate::interactive::Prompter;
36use crate::shared::render_result;
37use create_flow::tenant_create;
38
39#[derive(Debug, Subcommand)]
40pub enum TenantCommands {
41 #[command(about = "Create a new tenant")]
42 Create,
43
44 #[command(
45 about = "List all tenants",
46 after_help = "EXAMPLES:\n systemprompt cloud tenant list\n systemprompt cloud tenant \
47 list --json"
48 )]
49 List,
50
51 #[command(about = "Show tenant details")]
52 Show { id: Option<String> },
53
54 #[command(about = "Delete a tenant")]
55 Delete(TenantDeleteArgs),
56
57 #[command(about = "Edit tenant configuration")]
58 Edit { id: Option<String> },
59
60 #[command(about = "Rotate database credentials")]
61 RotateCredentials(TenantRotateArgs),
62}
63
64#[derive(Debug, Args)]
65pub struct TenantRotateArgs {
66 pub id: Option<String>,
67
68 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
69 pub yes: bool,
70}
71
72#[derive(Debug, Args)]
73pub struct TenantDeleteArgs {
74 pub id: Option<String>,
75
76 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
77 pub yes: bool,
78}
79
80pub async fn execute(cmd: Option<TenantCommands>, ctx: &CommandContext) -> Result<()> {
81 if let Some(cmd) = cmd {
82 execute_command(cmd, ctx).await.map(drop)
83 } else {
84 if !ctx.cli.is_interactive() {
85 return Err(anyhow::anyhow!(
86 "Tenant subcommand required in non-interactive mode"
87 ));
88 }
89 while let Some(cmd) = select_operation(ctx.prompter())? {
90 if execute_command(cmd, ctx).await? {
91 break;
92 }
93 }
94 Ok(())
95 }
96}
97
98async fn execute_command(cmd: TenantCommands, ctx: &CommandContext) -> Result<bool> {
99 match cmd {
100 TenantCommands::Create => tenant_create(ctx.prompter(), &ctx.cli).await.map(|()| true),
101 TenantCommands::List => {
102 let result = list_tenants(ctx.prompter(), &ctx.cli).await?;
103 render_result(&result, &ctx.cli);
104 Ok(false)
105 },
106 TenantCommands::Show { id } => {
107 let result = show_tenant(ctx.prompter(), id.as_ref(), &ctx.cli)?;
108 render_result(&result, &ctx.cli);
109 Ok(false)
110 },
111 TenantCommands::Delete(args) => {
112 let result = delete_tenant(args, ctx.prompter(), &ctx.cli).await?;
113 render_result(&result, &ctx.cli);
114 Ok(false)
115 },
116 TenantCommands::Edit { id } => {
117 let result = edit_tenant(id, ctx.prompter(), &ctx.cli)?;
118 render_result(&result, &ctx.cli);
119 Ok(false)
120 },
121 TenantCommands::RotateCredentials(args) => {
122 let result = rotate_credentials(
123 args.id,
124 args.yes || !ctx.cli.is_interactive(),
125 ctx.prompter(),
126 &ctx.cli,
127 )
128 .await?;
129 render_result(&result, &ctx.cli);
130 Ok(false)
131 },
132 }
133}
134
135fn select_operation(prompter: &dyn Prompter) -> Result<Option<TenantCommands>> {
136 let cloud_paths = get_cloud_paths();
137 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
138 let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
139 CliService::warning(&format!("Failed to load tenant store: {}", e));
140 TenantStore::default()
141 });
142 let has_tenants = !store.tenants.is_empty();
143
144 choose_tenant_operation(prompter, has_tenants)
145}
146
147pub fn choose_tenant_operation(
148 prompter: &dyn Prompter,
149 has_tenants: bool,
150) -> Result<Option<TenantCommands>> {
151 let edit_label = if has_tenants {
152 "Edit".to_owned()
153 } else {
154 "Edit (unavailable - no tenants configured)".to_owned()
155 };
156 let delete_label = if has_tenants {
157 "Delete".to_owned()
158 } else {
159 "Delete (unavailable - no tenants configured)".to_owned()
160 };
161
162 let operations = vec![
163 "Create".to_owned(),
164 "List".to_owned(),
165 edit_label,
166 delete_label,
167 "Done".to_owned(),
168 ];
169
170 let selection = prompter.select("Tenant operation", &operations)?;
171
172 let cmd = match selection {
173 0 => Some(TenantCommands::Create),
174 1 => Some(TenantCommands::List),
175 2 | 3 if !has_tenants => {
176 CliService::warning("No tenants configured");
177 CliService::info(
178 "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
179 );
180 return Ok(Some(TenantCommands::List));
181 },
182 2 => Some(TenantCommands::Edit { id: None }),
183 3 => Some(TenantCommands::Delete(TenantDeleteArgs {
184 id: None,
185 yes: false,
186 })),
187 4 => None,
188 other => return Err(anyhow::anyhow!("unexpected menu selection: {other}")),
189 };
190
191 Ok(cmd)
192}