Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

1//! `cloud tenant` subcommand: manage local and cloud tenants.
2//!
3//! Exposes [`TenantCommands`] (create, list, show, delete, edit, rotate
4//! credentials) with an interactive operation menu when no subcommand
5//! is supplied. Persists tenant records through the cloud `TenantStore`.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10pub 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}