@smbcloud / cli / commits / 11b3c25

Add tenant CRUD

Seto Elkahfi committed Jul 20, 2026 at 22:57 UTC 11b3c25bc31c99a527802dbbb119dd7250381047
10 files changed +167 -14
crates/cli/src/cli/mod.rs
+6 -1
@@ -1,5 +1,5 @@
1 use {
2 - crate::{account, cloud_auth, mail, project},
2 + crate::{account, cloud_auth, mail, project, tenant},
3 clap::{Parser, Subcommand},
4 smbcloud_network::environment::Environment,
5 spinners::Spinner,
@@ -81,6 +81,11 @@ pub enum Commands {
81 #[clap(subcommand)]
82 command: project::cli::Commands,
83 },
84 + #[clap(about = "Manage your tenants.")]
85 + Tenant {
86 + #[clap(subcommand)]
87 + command: tenant::cli::Commands,
88 + },
89 #[clap(about = "Manage smbCloud Mail.")]
90 Mail {
91 #[clap(subcommand)]
crates/cli/src/lib.rs
+1
@@ -12,6 +12,7 @@ pub mod interface;
12 pub mod mail;
13 pub mod mcp;
14 pub mod project;
15 +pub mod tenant;
16 mod token;
17 pub mod ui;
18
crates/cli/src/main.rs
+3
@@ -10,6 +10,7 @@ use {
10 deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
11 mail::process::process_mail,
12 project::{crud_create::process_project_init, process::process_project},
13 + tenant::process::process_tenant,
14 },
15 smbcloud_network::{environment::Environment, network::check_internet_connection},
16 std::{
@@ -180,6 +181,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
181 | Some(Commands::Mail { .. })
182 | Some(Commands::Auth { .. })
183 | Some(Commands::Project { .. })
184 + | Some(Commands::Tenant { .. })
185 | Some(Commands::Migrate {})
186 | None => true,
187 Some(Commands::Init {}) => true,
@@ -202,6 +204,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
204 Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
205 Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await,
206 Some(Commands::Project { command }) => process_project(cli.environment, command).await,
207 + Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await,
208 Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
209 None => process_deploy(cli.environment, None).await,
210 }
crates/cli/src/project/crud_delete.rs
+13 -5
@@ -3,11 +3,13 @@ use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 account::lib::is_logged_in,
5 cli::CommandResult,
6 - ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
6 + ui::{fail_message, fail_symbol, prompt::confirm_delete_typed, succeed_message, succeed_symbol},
7 };
8 use anyhow::{anyhow, Result};
9 use smbcloud_network::environment::Environment;
10 -use smbcloud_networking_project::crud_project_delete::delete_project;
10 +use smbcloud_networking_project::{
11 + crud_project_delete::delete_project, crud_project_read::get_project,
12 +};
13 use spinners::Spinner;
14
15 pub async fn process_project_delete(env: Environment, id: String) -> Result<CommandResult> {
@@ -15,9 +17,16 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
17 return Err(anyhow!(fail_message("Please log in with `smb init`.")));
18 }
19
18 - let confirmed = confirm_delete(
20 + let access_token = get_smb_token(env)?;
21 + let project = get_project(env, client(), access_token.clone(), id.clone()).await?;
22 +
23 + let confirmed = confirm_delete_typed(
24 "Project deletion confirmation",
20 - &format!("Delete project #{id}"),
25 + &format!(
26 + "This permanently deletes project #{} and everything under it — repos, apps, and deployment history.",
27 + project.id
28 + ),
29 + &project.name,
30 )?;
31
32 let spinner = Spinner::new(
@@ -32,7 +41,6 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
41 msg: succeed_message("Cancelled."),
42 });
43 }
35 - let access_token = get_smb_token(env)?;
44 match delete_project(env, client(), access_token, id).await {
45 Ok(_) => Ok(CommandResult {
46 spinner,
crates/cli/src/tenant/mod.rs new
+6
@@ -0,0 +1,6 @@
1 +pub mod cli;
2 +pub mod process;
3 +mod render;
4 +mod request;
5 +mod tenant_client;
6 +mod url_builder;
crates/cli/src/tenant/process.rs
+23 -5
@@ -7,10 +7,10 @@ use crate::{
7 tenant_client::{create_tenant, delete_tenant, get_tenant, get_tenants, update_tenant},
8 },
9 token::get_smb_token::get_smb_token,
10 - ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
10 + ui::{fail_message, fail_symbol, prompt::confirm_delete_double, succeed_message, succeed_symbol},
11 };
12 use anyhow::{anyhow, Result};
13 -use smbcloud_model::tenant::{TenantCreate, TenantUpdate};
13 +use smbcloud_model::tenant::{TenantCreate, TenantKind, TenantUpdate};
14 use smbcloud_network::environment::Environment;
15 use spinners::Spinner;
16
@@ -98,16 +98,34 @@ async fn process_tenant_update(env: Environment, id: String, name: String) -> Re
98
99 async fn process_tenant_delete(env: Environment, id: String) -> Result<CommandResult> {
100 let tenant_id = normalize_required("Tenant id", id)?;
101 - let confirmed = confirm_delete(
101 + let access_token = get_smb_token(env)?;
102 +
103 + let tenant = get_tenant(env, client(), access_token.clone(), tenant_id.clone())
104 + .await
105 + .map_err(api_error)?;
106 +
107 + // The personal tenant is bootstrapped on signup and isn't a
108 + // user-manageable resource — mirror the API-side guard client-side so
109 + // the failure is immediate instead of after two confirmation prompts.
110 + if matches!(tenant.kind, TenantKind::Personal) {
111 + return Err(anyhow!("The personal tenant can't be deleted."));
112 + }
113 +
114 + let warning = format!(
115 + "This permanently deletes \"{}\" and everything it owns — {} project(s), plus its auth apps, mail apps, and domains.",
116 + tenant.name, tenant.projects_count
117 + );
118 + let confirmed = confirm_delete_double(
119 "Tenant deletion confirmation",
103 - &format!("Delete tenant #{tenant_id}"),
120 + &warning,
121 + &tenant.slug,
122 + "delete my tenant",
123 )?;
124
125 if !confirmed {
126 return Ok(done_result("Cancelled."));
127 }
128
110 - let access_token = get_smb_token(env)?;
129 let spinner = loading_spinner("Deleting tenant");
130
131 match delete_tenant(env, client(), access_token, tenant_id).await {
crates/cli/src/ui/prompt.rs
+77
@@ -20,6 +20,7 @@ use {
20 ui::{confirm_dialog::confirm_delete_tui, fail_message},
21 },
22 anyhow::{anyhow, Result},
23 + console::style,
24 dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Password, Select},
25 };
26
@@ -45,6 +46,82 @@ pub fn confirm_delete(what: &str, message: &str) -> Result<bool> {
46 confirm(message, false)
47 }
48
49 +fn print_danger_warning(warning: &str) {
50 + println!();
51 + println!(
52 + "{}",
53 + style("⚠ This action cannot be undone.").red().bold()
54 + );
55 + println!("{warning}");
56 + println!();
57 +}
58 +
59 +/// Type-to-confirm deletion, mirroring the "type the resource name to
60 +/// confirm" pattern used for irreversible deletes with real blast radius
61 +/// (Vercel's project/team deletion, GitHub's repo deletion, …). A plain y/n
62 +/// is too easy to reflex through; requiring the caller to type the resource's
63 +/// own identifier back forces them to read it. Refuses in CI, like
64 +/// [`confirm_delete`] — a typed confirmation can't be safely defaulted. Under
65 +/// `--tui`, falls back to the boolean danger-zone dialog (no TUI text-input
66 +/// widget yet).
67 +pub fn confirm_delete_typed(what: &str, warning: &str, resource_identifier: &str) -> Result<bool> {
68 + if is_ci() {
69 + return Err(ci_required(what));
70 + }
71 + if is_tui() {
72 + return confirm_delete_tui(warning).map_err(|e| anyhow!(fail_message(&e.to_string())));
73 + }
74 +
75 + print_danger_warning(warning);
76 +
77 + let typed = Input::<String>::with_theme(&ColorfulTheme::default())
78 + .with_prompt(format!("Type \"{resource_identifier}\" to confirm"))
79 + .allow_empty(true)
80 + .interact()
81 + .map_err(io_error)?;
82 +
83 + Ok(typed.trim() == resource_identifier)
84 +}
85 +
86 +/// Two-step type-to-confirm deletion for the highest-blast-radius actions —
87 +/// ones that cascade across many owned resources (a tenant owns projects,
88 +/// auth apps, mail apps, domains, …). First the caller types the resource's
89 +/// own identifier (proves they're looking at the right thing), then a fixed
90 +/// intent phrase (proves they mean to delete it, not just rename/inspect it).
91 +/// Same CI/`--tui` handling as [`confirm_delete_typed`].
92 +pub fn confirm_delete_double(
93 + what: &str,
94 + warning: &str,
95 + resource_identifier: &str,
96 + intent_phrase: &str,
97 +) -> Result<bool> {
98 + if is_ci() {
99 + return Err(ci_required(what));
100 + }
101 + if is_tui() {
102 + return confirm_delete_tui(warning).map_err(|e| anyhow!(fail_message(&e.to_string())));
103 + }
104 +
105 + print_danger_warning(warning);
106 +
107 + let typed_identifier = Input::<String>::with_theme(&ColorfulTheme::default())
108 + .with_prompt(format!("Type \"{resource_identifier}\" to confirm"))
109 + .allow_empty(true)
110 + .interact()
111 + .map_err(io_error)?;
112 + if typed_identifier.trim() != resource_identifier {
113 + return Ok(false);
114 + }
115 +
116 + let typed_phrase = Input::<String>::with_theme(&ColorfulTheme::default())
117 + .with_prompt(format!("Type \"{intent_phrase}\" to confirm"))
118 + .allow_empty(true)
119 + .interact()
120 + .map_err(io_error)?;
121 +
122 + Ok(typed_phrase.trim() == intent_phrase)
123 +}
124 +
125 /// Yes/no confirmation. In CI mode returns `default` without prompting.
126 pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
127 if is_ci() {
crates/smbcloud-networking-project/src/crud_project_delete.rs
+3 -3
@@ -1,7 +1,7 @@
1 -use crate::url_builder::build_project_url_with_id;
1 +use crate::{request::request_empty, url_builder::build_project_url_with_id};
2 use reqwest::Client;
3 use smbcloud_model::error_codes::ErrorResponse;
4 -use smbcloud_network::{environment::Environment, network::request};
4 +use smbcloud_network::environment::Environment;
5 use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
6
7 pub async fn delete_project(
@@ -14,5 +14,5 @@ pub async fn delete_project(
14 .delete(build_project_url_with_id(env, client, id))
15 .header("Authorization", access_token)
16 .header("User-agent", SMB_USER_AGENT);
17 - request(builder).await
17 + request_empty(builder).await
18 }
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -10,4 +10,5 @@ pub mod crud_project_deployment_read;
10 pub mod crud_project_deployment_update;
11 pub mod crud_project_read;
12 pub mod crud_project_update;
13 +mod request;
14 mod url_builder;
crates/smbcloud-networking-project/src/request.rs new
+34
@@ -0,0 +1,34 @@
1 +use reqwest::{RequestBuilder, StatusCode};
2 +use serde_json::Value;
3 +use smbcloud_model::error_codes::{ErrorCode, ErrorResponse};
4 +use smbcloud_network::network::{check_internet_connection, parse_error_response};
5 +
6 +/// Like `smbcloud_network::network::request`, but for endpoints that reply
7 +/// with an empty body (e.g. `DELETE` returning `204 No Content`). The generic
8 +/// `request` only treats `200`/`201` as success and then tries to parse the
9 +/// body as JSON, so it fails on a bare `204`.
10 +pub(crate) async fn request_empty(builder: RequestBuilder) -> Result<(), ErrorResponse> {
11 + if !check_internet_connection().await {
12 + return Err(ErrorResponse::Error {
13 + error_code: ErrorCode::NetworkError,
14 + message: "No internet connection. Please check your network settings and try again."
15 + .to_string(),
16 + });
17 + }
18 +
19 + let response = builder.send().await.map_err(|_| ErrorResponse::Error {
20 + error_code: ErrorCode::NetworkError,
21 + message: ErrorCode::NetworkError.message(None).to_string(),
22 + })?;
23 +
24 + match response.status() {
25 + StatusCode::OK | StatusCode::CREATED | StatusCode::NO_CONTENT => Ok(()),
26 + _ => match parse_error_response::<Value>(response).await {
27 + Ok(_) => Err(ErrorResponse::Error {
28 + error_code: ErrorCode::ParseError,
29 + message: ErrorCode::ParseError.message(None).to_string(),
30 + }),
31 + Err(error_response) => Err(error_response),
32 + },
33 + }
34 +}