@smbcloud / cli / commits / a813dab

wip tenant

Seto Elkahfi committed Jul 20, 2026 at 18:48 UTC a813dabd52d0074a257de4920ea85c2449a91c38
8 files changed +501
crates/cli/src/tenant/cli.rs new
+29
@@ -0,0 +1,29 @@
1 +use clap::Subcommand;
2 +
3 +#[derive(Subcommand)]
4 +pub enum Commands {
5 + #[clap(about = "List your tenants.")]
6 + List {},
7 + #[clap(about = "Show a tenant.")]
8 + Show {
9 + #[clap(short, long, required = true)]
10 + id: String,
11 + },
12 + #[clap(about = "Create an organization tenant.")]
13 + New {
14 + #[clap(short, long, required = true)]
15 + name: String,
16 + },
17 + #[clap(about = "Rename a tenant.")]
18 + Update {
19 + #[clap(short, long, required = true)]
20 + id: String,
21 + #[clap(short, long, required = true)]
22 + name: String,
23 + },
24 + #[clap(about = "Delete an organization tenant.")]
25 + Delete {
26 + #[clap(short, long, required = true)]
27 + id: String,
28 + },
29 +}
crates/cli/src/tenant/process.rs new
+163
@@ -0,0 +1,163 @@
1 +use crate::{
2 + cli::CommandResult,
3 + client,
4 + tenant::{
5 + cli::Commands,
6 + render::{print_tenant_detail, print_tenants},
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},
11 +};
12 +use anyhow::{anyhow, Result};
13 +use smbcloud_model::tenant::{TenantCreate, TenantUpdate};
14 +use smbcloud_network::environment::Environment;
15 +use spinners::Spinner;
16 +
17 +pub async fn process_tenant(env: Environment, commands: Commands) -> Result<CommandResult> {
18 + match commands {
19 + Commands::List {} => process_tenant_list(env).await,
20 + Commands::Show { id } => process_tenant_show(env, id).await,
21 + Commands::New { name } => process_tenant_new(env, name).await,
22 + Commands::Update { id, name } => process_tenant_update(env, id, name).await,
23 + Commands::Delete { id } => process_tenant_delete(env, id).await,
24 + }
25 +}
26 +
27 +async fn process_tenant_list(env: Environment) -> Result<CommandResult> {
28 + let access_token = get_smb_token(env)?;
29 + let mut spinner = loading_spinner("Loading tenants");
30 +
31 + let tenants = get_tenants(env, client(), access_token)
32 + .await
33 + .map_err(api_error)?;
34 +
35 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
36 + print_tenants(&tenants);
37 +
38 + Ok(done_result(if tenants.is_empty() {
39 + "No tenants found."
40 + } else {
41 + "Done."
42 + }))
43 +}
44 +
45 +async fn process_tenant_show(env: Environment, id: String) -> Result<CommandResult> {
46 + let access_token = get_smb_token(env)?;
47 + let tenant_id = normalize_required("Tenant id", id)?;
48 + let mut spinner = loading_spinner("Loading tenant");
49 +
50 + let tenant = get_tenant(env, client(), access_token, tenant_id)
51 + .await
52 + .map_err(api_error)?;
53 +
54 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
55 + print_tenant_detail(&tenant);
56 +
57 + Ok(done_result("Done."))
58 +}
59 +
60 +async fn process_tenant_new(env: Environment, name: String) -> Result<CommandResult> {
61 + let access_token = get_smb_token(env)?;
62 + let tenant = TenantCreate {
63 + name: normalize_required("name", name)?,
64 + };
65 + let mut spinner = loading_spinner("Creating tenant");
66 +
67 + let tenant = create_tenant(env, client(), access_token, tenant)
68 + .await
69 + .map_err(api_error)?;
70 +
71 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Created."));
72 + print_tenant_detail(&tenant);
73 +
74 + Ok(done_result("Tenant created."))
75 +}
76 +
77 +async fn process_tenant_update(env: Environment, id: String, name: String) -> Result<CommandResult> {
78 + let access_token = get_smb_token(env)?;
79 + let tenant_id = normalize_required("Tenant id", id)?;
80 + let update = TenantUpdate {
81 + name: normalize_optional(Some(name)),
82 + };
83 +
84 + if update.is_empty() {
85 + return Err(anyhow!("Specify `--name`."));
86 + }
87 +
88 + let mut spinner = loading_spinner("Updating tenant");
89 + let tenant = update_tenant(env, client(), access_token, tenant_id, update)
90 + .await
91 + .map_err(api_error)?;
92 +
93 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Updated."));
94 + print_tenant_detail(&tenant);
95 +
96 + Ok(done_result("Tenant updated."))
97 +}
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(
102 + "Tenant deletion confirmation",
103 + &format!("Delete tenant #{tenant_id}"),
104 + )?;
105 +
106 + if !confirmed {
107 + return Ok(done_result("Cancelled."));
108 + }
109 +
110 + let access_token = get_smb_token(env)?;
111 + let spinner = loading_spinner("Deleting tenant");
112 +
113 + match delete_tenant(env, client(), access_token, tenant_id).await {
114 + Ok(()) => Ok(CommandResult {
115 + spinner,
116 + symbol: succeed_symbol(),
117 + msg: succeed_message("Done. Tenant has been deleted."),
118 + }),
119 + Err(error) => Ok(CommandResult {
120 + spinner,
121 + symbol: fail_symbol(),
122 + msg: fail_message(&error.to_string()),
123 + }),
124 + }
125 +}
126 +
127 +fn loading_spinner(message: &str) -> Spinner {
128 + Spinner::new(
129 + spinners::Spinners::SimpleDotsScrolling,
130 + succeed_message(message),
131 + )
132 +}
133 +
134 +fn done_result(message: &str) -> CommandResult {
135 + CommandResult {
136 + spinner: loading_spinner("Done"),
137 + symbol: succeed_symbol(),
138 + msg: succeed_message(message),
139 + }
140 +}
141 +
142 +fn normalize_required(label: &str, value: String) -> Result<String> {
143 + let normalized_value = value.trim().to_string();
144 + if normalized_value.is_empty() {
145 + return Err(anyhow!("{} cannot be empty.", label));
146 + }
147 + Ok(normalized_value)
148 +}
149 +
150 +fn normalize_optional(value: Option<String>) -> Option<String> {
151 + value.and_then(|value| {
152 + let normalized_value = value.trim().to_string();
153 + if normalized_value.is_empty() {
154 + None
155 + } else {
156 + Some(normalized_value)
157 + }
158 + })
159 +}
160 +
161 +fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
162 + anyhow!(error.to_string())
163 +}
crates/cli/src/tenant/render.rs new
+50
@@ -0,0 +1,50 @@
1 +use console::style;
2 +use smbcloud_model::tenant::Tenant;
3 +
4 +fn print_heading(title: &str) {
5 + println!("\n{}", style(title).bold().underlined());
6 +}
7 +
8 +fn print_field(label: &str, value: impl AsRef<str>) {
9 + println!("{} {}", style(format!("{label}:")).cyan(), value.as_ref());
10 +}
11 +
12 +pub(crate) fn print_tenants(tenants: &[Tenant]) {
13 + print_heading("Tenants");
14 +
15 + if tenants.is_empty() {
16 + println!("No tenants found.");
17 + return;
18 + }
19 +
20 + for tenant in tenants {
21 + let marker = if tenant.current { "*" } else { " " };
22 + println!(
23 + "{}#{} {} [{}] slug={} role={} projects={}",
24 + marker,
25 + tenant.id,
26 + style(&tenant.name).bold(),
27 + tenant.kind,
28 + tenant.slug,
29 + tenant.role,
30 + tenant.projects_count
31 + );
32 + }
33 +}
34 +
35 +pub(crate) fn print_tenant_detail(tenant: &Tenant) {
36 + print_heading("Tenant");
37 + print_field("ID", tenant.id.to_string());
38 + print_field("Name", &tenant.name);
39 + print_field("Slug", &tenant.slug);
40 + print_field("Kind", tenant.kind.to_string());
41 + print_field("Your role", tenant.role.to_string());
42 + print_field("Projects", tenant.projects_count.to_string());
43 + if let Some(default_project) = &tenant.default_project {
44 + print_field(
45 + "Default project",
46 + format!("#{} {}", default_project.id, default_project.name),
47 + );
48 + }
49 + print_field("Created at", tenant.created_at.to_rfc3339());
50 +}
crates/cli/src/tenant/request.rs new
+30
@@ -0,0 +1,30 @@
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 +pub(crate) async fn request_empty(builder: RequestBuilder) -> Result<(), ErrorResponse> {
7 + if !check_internet_connection().await {
8 + return Err(ErrorResponse::Error {
9 + error_code: ErrorCode::NetworkError,
10 + message: "No internet connection. Please check your network settings and try again."
11 + .to_string(),
12 + });
13 + }
14 +
15 + let response = builder.send().await.map_err(|_| ErrorResponse::Error {
16 + error_code: ErrorCode::NetworkError,
17 + message: ErrorCode::NetworkError.message(None).to_string(),
18 + })?;
19 +
20 + match response.status() {
21 + StatusCode::OK | StatusCode::CREATED | StatusCode::NO_CONTENT => Ok(()),
22 + _ => match parse_error_response::<Value>(response).await {
23 + Ok(_) => Err(ErrorResponse::Error {
24 + error_code: ErrorCode::ParseError,
25 + message: ErrorCode::ParseError.message(None).to_string(),
26 + }),
27 + Err(error_response) => Err(error_response),
28 + },
29 + }
30 +}
crates/cli/src/tenant/tenant_client.rs new
+84
@@ -0,0 +1,84 @@
1 +use crate::tenant::{
2 + request::request_empty,
3 + url_builder::{build_tenant_url, build_tenants_url},
4 +};
5 +use reqwest::Client;
6 +use serde::Serialize;
7 +use smbcloud_model::{
8 + error_codes::ErrorResponse,
9 + tenant::{Tenant, TenantCreate, TenantUpdate},
10 +};
11 +use smbcloud_network::{environment::Environment, network::request};
12 +use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
13 +
14 +#[derive(Serialize)]
15 +struct TenantEnvelope<T> {
16 + tenant: T,
17 +}
18 +
19 +pub async fn get_tenants(
20 + env: Environment,
21 + client: (&SmbClient, &str),
22 + access_token: String,
23 +) -> Result<Vec<Tenant>, ErrorResponse> {
24 + let builder = Client::new()
25 + .get(build_tenants_url(env, client))
26 + .header("Authorization", access_token)
27 + .header("User-agent", SMB_USER_AGENT);
28 + request(builder).await
29 +}
30 +
31 +pub async fn get_tenant(
32 + env: Environment,
33 + client: (&SmbClient, &str),
34 + access_token: String,
35 + tenant_id: String,
36 +) -> Result<Tenant, ErrorResponse> {
37 + let builder = Client::new()
38 + .get(build_tenant_url(env, client, &tenant_id))
39 + .header("Authorization", access_token)
40 + .header("User-agent", SMB_USER_AGENT);
41 + request(builder).await
42 +}
43 +
44 +pub async fn create_tenant(
45 + env: Environment,
46 + client: (&SmbClient, &str),
47 + access_token: String,
48 + tenant: TenantCreate,
49 +) -> Result<Tenant, ErrorResponse> {
50 + let builder = Client::new()
51 + .post(build_tenants_url(env, client))
52 + .json(&TenantEnvelope { tenant })
53 + .header("Authorization", access_token)
54 + .header("User-agent", SMB_USER_AGENT);
55 + request(builder).await
56 +}
57 +
58 +pub async fn update_tenant(
59 + env: Environment,
60 + client: (&SmbClient, &str),
61 + access_token: String,
62 + tenant_id: String,
63 + tenant: TenantUpdate,
64 +) -> Result<Tenant, ErrorResponse> {
65 + let builder = Client::new()
66 + .patch(build_tenant_url(env, client, &tenant_id))
67 + .json(&TenantEnvelope { tenant })
68 + .header("Authorization", access_token)
69 + .header("User-agent", SMB_USER_AGENT);
70 + request(builder).await
71 +}
72 +
73 +pub async fn delete_tenant(
74 + env: Environment,
75 + client: (&SmbClient, &str),
76 + access_token: String,
77 + tenant_id: String,
78 +) -> Result<(), ErrorResponse> {
79 + let builder = Client::new()
80 + .delete(build_tenant_url(env, client, &tenant_id))
81 + .header("Authorization", access_token)
82 + .header("User-agent", SMB_USER_AGENT);
83 + request_empty(builder).await
84 +}
crates/cli/src/tenant/url_builder.rs new
+21
@@ -0,0 +1,21 @@
1 +use {
2 + smbcloud_network::environment::Environment,
3 + smbcloud_networking::{smb_base_url_builder, smb_client::SmbClient},
4 +};
5 +
6 +pub(crate) fn build_tenants_url(env: Environment, client: (&SmbClient, &str)) -> String {
7 + let mut url_builder = smb_base_url_builder(env, client);
8 + url_builder.add_route("v1/tenants");
9 + url_builder.build()
10 +}
11 +
12 +pub(crate) fn build_tenant_url(
13 + env: Environment,
14 + client: (&SmbClient, &str),
15 + tenant_id: &str,
16 +) -> String {
17 + let mut url_builder = smb_base_url_builder(env, client);
18 + url_builder.add_route("v1/tenants");
19 + url_builder.add_route(tenant_id);
20 + url_builder.build()
21 +}
crates/smbcloud-model/src/lib.rs
+1
@@ -13,6 +13,7 @@ pub mod repository;
13 pub mod reset_password_response;
14 pub mod runner;
15 pub mod signup;
16 +pub mod tenant;
17
18 pub mod ar_date_format {
19 use chrono::{DateTime, Utc};
crates/smbcloud-model/src/tenant.rs new
+123
@@ -0,0 +1,123 @@
1 +use crate::ar_date_format;
2 +use chrono::{DateTime, Utc};
3 +use serde::{Deserialize, Serialize};
4 +use serde_repr::{Deserialize_repr, Serialize_repr};
5 +use std::fmt::Display;
6 +use tsync::tsync;
7 +
8 +/// Personal — bootstrapped on signup, one per user, never created/deleted via
9 +/// the API. Organization — a team workspace created explicitly.
10 +#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
11 +#[repr(u8)]
12 +#[tsync]
13 +pub enum TenantKind {
14 + #[default]
15 + Personal = 0,
16 + Organization = 1,
17 +}
18 +
19 +impl Display for TenantKind {
20 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 + match self {
22 + TenantKind::Personal => write!(f, "personal"),
23 + TenantKind::Organization => write!(f, "organization"),
24 + }
25 + }
26 +}
27 +
28 +/// The current user's role within a tenant, as returned alongside the tenant
29 +/// in list/show responses (kept off the bare `Tenant` record itself).
30 +#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)]
31 +#[repr(u8)]
32 +#[tsync]
33 +pub enum TenantRole {
34 + Owner = 0,
35 + Admin = 1,
36 + Member = 2,
37 +}
38 +
39 +impl Display for TenantRole {
40 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 + match self {
42 + TenantRole::Owner => write!(f, "owner"),
43 + TenantRole::Admin => write!(f, "admin"),
44 + TenantRole::Member => write!(f, "member"),
45 + }
46 + }
47 +}
48 +
49 +#[derive(Serialize, Deserialize, Debug, Clone)]
50 +#[tsync]
51 +pub struct TenantProject {
52 + pub id: i32,
53 + pub name: String,
54 +}
55 +
56 +#[derive(Serialize, Deserialize, Debug, Clone)]
57 +#[tsync]
58 +pub struct Tenant {
59 + pub id: i64,
60 + pub name: String,
61 + pub slug: String,
62 + pub kind: TenantKind,
63 + /// The requesting user's role in this tenant.
64 + pub role: TenantRole,
65 + pub projects_count: i64,
66 + pub default_project: Option<TenantProject>,
67 + /// Whether this is the tenant currently selected for the CLI session.
68 + #[serde(default)]
69 + pub current: bool,
70 + #[serde(with = "ar_date_format")]
71 + pub created_at: DateTime<Utc>,
72 +}
73 +
74 +impl Display for Tenant {
75 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 + write!(f, "ID: {}, Name: {}", self.id, self.name)
77 + }
78 +}
79 +
80 +/// Payload for creating an organization tenant. Personal tenants are
81 +/// bootstrapped on signup and can't be created through this endpoint.
82 +#[derive(Serialize, Deserialize, Debug, Clone)]
83 +#[tsync]
84 +pub struct TenantCreate {
85 + pub name: String,
86 +}
87 +
88 +#[derive(Serialize, Deserialize, Debug, Clone, Default)]
89 +#[tsync]
90 +pub struct TenantUpdate {
91 + #[serde(skip_serializing_if = "Option::is_none")]
92 + pub name: Option<String>,
93 +}
94 +
95 +impl TenantUpdate {
96 + pub fn is_empty(&self) -> bool {
97 + self.name.is_none()
98 + }
99 +}
100 +
101 +#[cfg(test)]
102 +mod tests {
103 + use super::*;
104 + use serde_json::json;
105 +
106 + #[test]
107 + fn test_tenant_create() {
108 + let tenant_create = TenantCreate {
109 + name: "Acme".to_owned(),
110 + };
111 + let json = json!({ "name": "Acme" });
112 + assert_eq!(serde_json::to_value(tenant_create).unwrap(), json);
113 + }
114 +
115 + #[test]
116 + fn test_tenant_update_is_empty() {
117 + assert!(TenantUpdate::default().is_empty());
118 + assert!(!TenantUpdate {
119 + name: Some("Acme".to_owned())
120 + }
121 + .is_empty());
122 + }
123 +}