diff --git a/.env.example b/.env.example index 727b709..f237576 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ # Process env vars override .env if both are set. MIRRORSTACK_API_URL=http://localhost:8081 +MIRRORSTACK_APPS_API_URL=http://localhost:8082 MIRRORSTACK_WEB_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index a7763c6..816f885 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /target dist/ .env +.plan/ diff --git a/Cargo.lock b/Cargo.lock index 862f7d0..ad72e7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -212,6 +225,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror 1.0.69", +] + [[package]] name = "digest" version = "0.10.7" @@ -260,6 +284,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -674,6 +704,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -811,8 +854,11 @@ dependencies = [ "anyhow", "base64", "clap", + "console", + "dialoguer", "dirs", "dotenvy", + "indicatif", "mockito", "open", "rand", @@ -821,7 +867,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror", + "thiserror 2.0.18", "url", ] @@ -850,6 +896,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.4" @@ -920,6 +972,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -971,7 +1029,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -992,7 +1050,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1079,7 +1137,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1309,6 +1367,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1405,13 +1469,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1570,6 +1654,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1778,6 +1868,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 1903db7..4775695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ anyhow = "1" thiserror = "2" tempfile = "3" dotenvy = "0.15" +dialoguer = { version = "0.11", default-features = false } +console = "0.15" +indicatif = "0.17" [dev-dependencies] mockito = "1.6" diff --git a/README.md b/README.md index 3fac61a..e6727bc 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,11 @@ Pre-built releases (brew, scoop, deb) coming with the next milestone. ```bash mirrorstack login # Sign in via OAuth (PKCE) mirrorstack whoami # Print the currently signed-in user +mirrorstack module init # Register a new module on the platform mirrorstack --help # Show help mirrorstack --version # Show CLI version ``` -Module scaffolding (`app module init`) is tracked separately and lands -in a follow-up PR. - ## Local development Build: diff --git a/src/api.rs b/src/api.rs index aab64e2..1624eac 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,7 +4,7 @@ use std::io::Read; use std::time::Duration; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::http; @@ -33,6 +33,45 @@ pub enum ApiError { Decode(#[from] serde_json::Error), #[error("api: unexpected response {status}: {body}")] Unexpected { status: u16, body: String }, + /// Server returned a structured error envelope. The `code` is the + /// machine-readable identifier (e.g. `slug_taken`); callers branch on it. + #[error("api: {code}: {message}")] + Server { + status: u16, + code: String, + message: String, + }, +} + +/// Subset of the platform's structured error body. The platform consistently +/// wraps errors as `{"error": {"code": "...", "message": "..."}}` for any +/// 4xx the application layer produces; we only need those two fields. +#[derive(Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} +#[derive(Deserialize)] +struct ErrorBody { + code: String, + message: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // name / owner_id / created_at are part of the API surface +pub struct Module { + pub id: String, + pub name: String, + pub slug: String, + #[serde(default)] + pub owner_id: Option, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateModuleInput<'a> { + pub name: &'a str, + pub slug: &'a str, } /// GET /v1/auth/me — returns the authenticated user's identity. @@ -69,6 +108,97 @@ pub fn me(api_base: &str, access_token: &str) -> Result { }) } +/// GET /v1/modules/{slug} — returns the caller's module by slug. +/// `Ok(None)` on 404 (caller has no module with that slug). Note this +/// only checks ownership by the *current* user — module slugs are scoped +/// per-owner, so 404 here does NOT mean the platform-wide name is unique +/// (reserved/format checks still happen on POST). +pub fn get_module( + apps_base: &str, + access_token: &str, + slug: &str, +) -> Result, ApiError> { + // Slug is pre-validated against `^[a-z][a-z0-9-]{1,38}[a-z0-9]$` before + // this call, so it's URL-safe with no encoding needed. + let endpoint = format!("{}/v1/modules/{}", apps_base.trim_end_matches('/'), slug); + let http = http::client(Duration::from_secs(15))?; + + let resp = http + .get(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(Some(resp.json::()?)); + } + if status == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ApiError::Unauthenticated); + } + let mut body = Vec::with_capacity(1024); + resp.take(MAX_RESPONSE_BYTES) + .read_to_end(&mut body) + .map_err(|e| ApiError::Unexpected { + status: status.as_u16(), + body: format!("(read body failed: {e})"), + })?; + Err(ApiError::Unexpected { + status: status.as_u16(), + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +/// POST /v1/modules — create a developer-owned module. +pub fn create_module( + apps_base: &str, + access_token: &str, + input: &CreateModuleInput, +) -> Result { + let endpoint = format!("{}/v1/modules", apps_base.trim_end_matches('/')); + let http = http::client(Duration::from_secs(15))?; + + let resp = http + .post(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .json(input) + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?); + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(ApiError::Unauthenticated); + } + + // 4xx with platform error-envelope: surface code + message so callers + // can branch on `slug_taken` / `slug_reserved` / `slug_invalid` without + // re-parsing the body. + let mut body = Vec::with_capacity(1024); + resp.take(MAX_RESPONSE_BYTES) + .read_to_end(&mut body) + .map_err(|e| ApiError::Unexpected { + status: status.as_u16(), + body: format!("(read body failed: {e})"), + })?; + if let Ok(env) = serde_json::from_slice::(&body) { + return Err(ApiError::Server { + status: status.as_u16(), + code: env.error.code, + message: env.error.message, + }); + } + Err(ApiError::Unexpected { + status: status.as_u16(), + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +242,103 @@ mod tests { let err = me(&server.url(), "expired").unwrap_err(); assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}"); } + + #[test] + fn create_module_success() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules") + .match_header("authorization", "Bearer AT") + .match_body(mockito::Matcher::JsonString( + r#"{"name":"Media","slug":"media"}"#.into(), + )) + .with_status(201) + .with_body( + json!({ + "id": "m-1", + "name": "Media", + "slug": "media", + "owner_id": "u-1", + "created_at": "2026-05-04T00:00:00Z" + }) + .to_string(), + ) + .create(); + + let m = create_module( + &server.url(), + "AT", + &CreateModuleInput { + name: "Media", + slug: "media", + }, + ) + .expect("ok"); + assert_eq!(m.slug, "media"); + assert_eq!(m.id, "m-1"); + } + + #[test] + fn get_module_200_returns_some() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/modules/media") + .match_header("authorization", "Bearer AT") + .with_status(200) + .with_body( + json!({ + "id": "m-1", + "name": "Media", + "slug": "media", + "owner_id": "u-1", + }) + .to_string(), + ) + .create(); + + let m = get_module(&server.url(), "AT", "media").expect("ok"); + assert!(m.is_some()); + assert_eq!(m.unwrap().slug, "media"); + } + + #[test] + fn get_module_404_returns_none() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/modules/none") + .with_status(404) + .create(); + + let m = get_module(&server.url(), "AT", "none").expect("ok"); + assert!(m.is_none()); + } + + #[test] + fn create_module_409_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules") + .with_status(409) + .with_body( + r#"{"error":{"code":"slug_taken","message":"slug already taken for this owner"}}"#, + ) + .create(); + + let err = create_module( + &server.url(), + "AT", + &CreateModuleInput { + name: "Media", + slug: "media", + }, + ) + .unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 409); + assert_eq!(code, "slug_taken"); + } + other => panic!("expected Server, got {other:?}"), + } + } } diff --git a/src/commands/login.rs b/src/commands/login.rs index fff5a0e..0f14209 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -17,14 +17,14 @@ use crate::browser; use crate::credentials::{self, Credentials}; use crate::http; -use super::{DEFAULT_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_WEB_URL}; +use super::{DEFAULT_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_WEB_URL, resolve_base}; #[derive(Args)] pub struct LoginArgs {} pub fn run(_args: LoginArgs) -> Result<()> { - let api_base = std::env::var(ENV_API_URL).unwrap_or_else(|_| DEFAULT_API_BASE.into()); - let web_base = std::env::var(ENV_WEB_URL).unwrap_or_else(|_| DEFAULT_WEB_BASE.into()); + let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE); + let web_base = resolve_base(ENV_WEB_URL, DEFAULT_WEB_BASE); let pkce = auth::Pkce::generate()?; let state = auth::random_state()?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9b79f69..62ffe55 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,18 +5,32 @@ use anyhow::Result; use clap::{Parser, Subcommand}; mod login; +mod module; mod whoami; -/// Default api-platform host. Override per-invocation with +/// Default api-platform account-service host. Override per-invocation with /// `MIRRORSTACK_API_URL` (or via `.env`). pub(crate) const DEFAULT_API_BASE: &str = "https://api.mirrorstack.ai"; +/// Default api-platform applications-service host. Modules and apps live +/// on a separate Lambda from the account service, but in prod both are +/// exposed under the same `api.mirrorstack.ai` hostname (path-routed at +/// the ingress). Local dev uses port 8082 — set via `.env`. Override +/// with `MIRRORSTACK_APPS_API_URL`. +pub(crate) const DEFAULT_APPS_API_BASE: &str = "https://api.mirrorstack.ai"; + /// Default web-account host. Override with `MIRRORSTACK_WEB_URL`. pub(crate) const DEFAULT_WEB_BASE: &str = "https://account.mirrorstack.ai"; pub(crate) const ENV_API_URL: &str = "MIRRORSTACK_API_URL"; +pub(crate) const ENV_APPS_API_URL: &str = "MIRRORSTACK_APPS_API_URL"; pub(crate) const ENV_WEB_URL: &str = "MIRRORSTACK_WEB_URL"; +/// Look up a base URL from `env_var`, falling back to `default` when unset. +pub(crate) fn resolve_base(env_var: &str, default: &str) -> String { + std::env::var(env_var).unwrap_or_else(|_| default.into()) +} + /// Official command-line tool for the MirrorStack platform. #[derive(Parser)] #[command(name = "mirrorstack", version, about, long_about = None)] @@ -31,6 +45,9 @@ enum Command { Login(login::LoginArgs), /// Print the currently signed-in user. Whoami(whoami::WhoamiArgs), + /// Manage developer modules (the per-developer reusable units installed + /// into apps). + Module(module::ModuleArgs), } impl Cli { @@ -38,6 +55,7 @@ impl Cli { match self.command { Command::Login(args) => login::run(args), Command::Whoami(args) => whoami::run(args), + Command::Module(args) => module::run(args), } } } diff --git a/src/commands/module.rs b/src/commands/module.rs new file mode 100644 index 0000000..0f629d2 --- /dev/null +++ b/src/commands/module.rs @@ -0,0 +1,361 @@ +//! `mirrorstack module …` — developer module management. +//! +//! Today: only `module init` (register a module on the platform). Filesystem +//! scaffolding (templates, SDK pull) is intentionally out of scope per the +//! current product direction — modules are developed locally with whatever +//! tooling the developer prefers, and `mirrorstack dev` (future) opens a WSS +//! tunnel into the platform. + +use std::io::IsTerminal; +use std::time::Duration; + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use console::style; +use dialoguer::{Confirm, Input, theme::ColorfulTheme}; +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::api::{self, ApiError, CreateModuleInput}; +use crate::credentials::{self, LoadError}; + +use super::{ + DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL, + ENV_WEB_URL, resolve_base, +}; + +#[derive(Args)] +pub struct ModuleArgs { + #[command(subcommand)] + command: ModuleCommand, +} + +#[derive(Subcommand)] +enum ModuleCommand { + /// Register a new module on the platform. Interactive by default; + /// pass --yes for non-interactive use. + Init(InitArgs), +} + +#[derive(Args)] +pub struct InitArgs { + /// Module name (human-readable). When omitted, prompts interactively. + #[arg(long)] + name: Option, + /// URL slug. When omitted, derived from --name. The platform regex is + /// 3-40 chars, must start with a letter, end with a letter or digit, + /// lowercase + hyphen only. + #[arg(long)] + slug: Option, + /// Skip prompts. Requires --name; --slug optional (derived from name). + #[arg(long, short)] + yes: bool, + /// If the slug is already registered to you, treat that as success and + /// continue. Useful for re-running init in CI without conditional logic. + #[arg(long)] + used: bool, +} + +pub fn run(args: ModuleArgs) -> Result<()> { + match args.command { + ModuleCommand::Init(i) => init(i), + } +} + +fn init(args: InitArgs) -> Result<()> { + let theme = ColorfulTheme::default(); + + let creds = match credentials::load() { + Ok(c) => c, + Err(LoadError::NotFound) => { + return Err(anyhow!( + "not signed in. Run `mirrorstack login` to sign in." + )); + } + Err(e) => return Err(e.into()), + }; + + let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE); + let apps_base = resolve_base(ENV_APPS_API_URL, DEFAULT_APPS_API_BASE); + let web_base = resolve_base(ENV_WEB_URL, DEFAULT_WEB_BASE); + + // The platform stores ownership by user id, but the CLI surfaces the + // full namespaced `@/` — so we refuse to POST until the + // caller has claimed a username, and point them at the web flow. + let identity = match api::me(&api_base, &creds.access_token) { + Ok(id) => id, + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + }; + let Some(username) = identity.slug.as_deref().filter(|s| !s.is_empty()) else { + return Err(anyhow!( + "no username set on this account. Visit {web_base}/me to claim one before creating modules." + )); + }; + + let (name, slug) = collect_name_and_slug(&theme, &args)?; + + if !slug_valid(&slug) { + return Err(anyhow!( + "slug '{slug}' is invalid: must be 3-40 chars, start with a letter, end with a letter or digit, lowercase + hyphen only." + )); + } + + // Pre-flight: reject early if the caller already owns this slug. Catches + // the common "I forgot I made this last week" case before we POST. + // Reserved/invalid still surface server-side from the POST below. + let pre_check = with_spinner("Checking availability…", || { + api::get_module(&apps_base, &creds.access_token, &slug) + }); + match pre_check { + Ok(Some(existing)) if args.used => { + print_already_exists(username, &existing.slug, Some(&existing.id)); + return Ok(()); + } + Ok(Some(_)) => { + return Err(anyhow!( + "@{username}/{slug} already exists (pass --used to ignore when re-running)" + )); + } + Ok(None) => {} + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + } + + if !args.yes { + eprintln!(); + eprintln!(" {} {}", style("Module:").dim(), style(&name).bold()); + eprintln!( + " {} {}", + style("Slug:").dim(), + style(format!("@{username}/{slug}")).cyan().bold() + ); + let confirmed = Confirm::with_theme(&theme) + .with_prompt("Create this module?") + .default(true) + .interact()?; + if !confirmed { + eprintln!("{}", style("aborted.").yellow()); + return Ok(()); + } + } + + let create_result = with_spinner("Creating module…", || { + api::create_module( + &apps_base, + &creds.access_token, + &CreateModuleInput { + name: &name, + slug: &slug, + }, + ) + }); + + match create_result { + Ok(m) => { + print_created(username, &m.slug, &m.id); + Ok(()) + } + Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { + print_already_exists(username, &slug, None); + Ok(()) + } + Err(ApiError::Server { code, message, .. }) => Err(anyhow!( + "{code}: {message}{hint}", + hint = slug_error_hint(&code) + )), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + +fn session_expired() -> anyhow::Error { + anyhow!("session expired. Run `mirrorstack login` to sign in again.") +} + +fn collect_name_and_slug(theme: &ColorfulTheme, args: &InitArgs) -> Result<(String, String)> { + let name = if let Some(n) = args + .name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + n.to_string() + } else if args.yes { + return Err(anyhow!( + "--yes requires --name (cannot prompt in non-interactive mode)" + )); + } else { + Input::::with_theme(theme) + .with_prompt("Module name") + .interact_text()? + .trim() + .to_string() + }; + + let slug = if let Some(s) = args + .slug + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + s.to_string() + } else { + let suggested = derive_slug(&name); + if args.yes { + // Non-interactive: trust the derivation. If the format check fails + // downstream the caller sees a clear error. + suggested + } else { + Input::::with_theme(theme) + .with_prompt("Slug") + .default(suggested) + .interact_text()? + .trim() + .to_string() + } + }; + + Ok((name, slug)) +} + +/// Wrap a blocking call with a tick-driven spinner. Skipped entirely when +/// stderr isn't a TTY so CI logs and piped output stay clean — indicatif +/// can detect a non-tty target but `enable_steady_tick` still spawns the +/// timer thread, so we short-circuit before that. +fn with_spinner(message: &str, f: F) -> T +where + F: FnOnce() -> T, +{ + if !std::io::stderr().is_terminal() { + return f(); + } + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), + ); + pb.enable_steady_tick(Duration::from_millis(80)); + pb.set_message(message.to_string()); + let result = f(); + pb.finish_and_clear(); + result +} + +fn print_created(username: &str, slug: &str, id: &str) { + eprintln!( + "{} created {}", + style("✓").green().bold(), + style(format!("@{username}/{slug}")).cyan().bold(), + ); + eprintln!(" {} {}", style("id:").dim(), id); +} + +fn print_already_exists(username: &str, slug: &str, id: Option<&str>) { + eprintln!( + "{} {} already exists; {} continuing.", + style("✓").green().bold(), + style(format!("@{username}/{slug}")).cyan().bold(), + style("--used set,").dim(), + ); + if let Some(id) = id { + eprintln!(" {} {}", style("id:").dim(), id); + } +} + +/// Same regex as the platform service and api-client-shared: +/// `^[a-z][a-z0-9-]{1,38}[a-z0-9]$`. Inlined as a manual check to avoid a +/// `regex` dependency for one pattern — the rule is small enough. +fn slug_valid(s: &str) -> bool { + let len = s.len(); + if !(3..=40).contains(&len) { + return false; + } + let bytes = s.as_bytes(); + if !bytes[0].is_ascii_lowercase() { + return false; + } + let last = bytes[len - 1]; + if !(last.is_ascii_lowercase() || last.is_ascii_digit()) { + return false; + } + bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') +} + +/// Lowercase, replace runs of non-[a-z0-9] with single hyphens, trim leading +/// and trailing hyphens. Mirrors the web client's auto-suggest in +/// `useCreateModuleForm` so CLI users see the same default as web users. +fn derive_slug(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_dash = true; // skip leading hyphens + for c in name.chars() { + let lc = c.to_ascii_lowercase(); + if lc.is_ascii_lowercase() || lc.is_ascii_digit() { + out.push(lc); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + out +} + +fn slug_error_hint(code: &str) -> &'static str { + match code { + "slug_taken" => " (pass --used to ignore when re-running)", + "slug_reserved" => " (try a different slug — this name is reserved)", + "slug_invalid" => "", + _ => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_valid_accepts() { + for s in ["media", "my-mod", "abc123", "a1-b2-c3", "ana-lytics"] { + assert!(slug_valid(s), "expected {s:?} valid"); + } + } + + #[test] + fn slug_valid_rejects() { + for s in ["", "ab", "AB", "1abc", "media-", "-media", "ab_cd"] { + assert!(!slug_valid(s), "expected {s:?} invalid"); + } + } + + #[test] + fn slug_valid_rejects_too_long() { + let s = "a".repeat(41); + assert!(!slug_valid(&s)); + } + + #[test] + fn derive_slug_basic() { + assert_eq!(derive_slug("Analytics"), "analytics"); + assert_eq!(derive_slug("My Cool Module"), "my-cool-module"); + assert_eq!(derive_slug(" Media!! "), "media"); + assert_eq!(derive_slug("foo___bar"), "foo-bar"); + assert_eq!(derive_slug("foo--bar"), "foo-bar"); + } + + #[test] + fn derive_slug_strips_leading_and_trailing_hyphens() { + assert_eq!(derive_slug("--media"), "media"); + assert_eq!(derive_slug("media--"), "media"); + } + + #[test] + fn slug_error_hint_for_taken() { + assert!(slug_error_hint("slug_taken").contains("--used")); + } +} diff --git a/src/commands/whoami.rs b/src/commands/whoami.rs index b5dd5cd..a85e2be 100644 --- a/src/commands/whoami.rs +++ b/src/commands/whoami.rs @@ -11,7 +11,7 @@ use clap::Args; use crate::api::{self, ApiError}; use crate::credentials::{self, LoadError}; -use super::{DEFAULT_API_BASE, ENV_API_URL}; +use super::{DEFAULT_API_BASE, ENV_API_URL, resolve_base}; #[derive(Args)] pub struct WhoamiArgs {} @@ -27,7 +27,7 @@ pub fn run(_args: WhoamiArgs) -> Result<()> { Err(e) => return Err(e.into()), }; - let api_base = std::env::var(ENV_API_URL).unwrap_or_else(|_| DEFAULT_API_BASE.into()); + let api_base = resolve_base(ENV_API_URL, DEFAULT_API_BASE); match api::me(&api_base, &creds.access_token) { Ok(id) => {