From 69286d3b32d0d7712d7bc58744818a6f2da040c8 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 4 May 2026 08:08:26 +0800 Subject: [PATCH 1/5] feat: mirrorstack app module init (closes #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new top-level `app module init` subcommand that registers a developer-owned module on the platform via POST /v1/modules. Interactive flow: $ mirrorstack app module init Module name: Analytics Slug [analytics]: Module: Analytics Slug: @owen/analytics Create this module? [Y/n] y ✓ created @owen/analytics id: 7c4b... Non-interactive flow (for CI / automation): mirrorstack app module init --name Analytics --yes [--slug analytics] [--used] Pre-flight refuses to POST when the user hasn't claimed a username (modules are namespaced `@/`), pointing the user at account.mirrorstack.ai/me. Slug format check mirrors the platform regex (3-40, must start with a letter, end with letter/digit) — manual char walk to avoid pulling in `regex` for one pattern. `--used` makes 409 slug_taken behave as success — re-running init in CI no longer needs conditional logic. 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + src/api.rs | 152 +++++++++++++++++++++- src/commands/app.rs | 306 ++++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 4 + 4 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 src/commands/app.rs 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/src/api.rs b/src/api.rs index aab64e2..4704073 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,53 @@ pub fn me(api_base: &str, access_token: &str) -> Result { }) } +/// POST /v1/modules — create a developer-owned module. +pub fn create_module( + api_base: &str, + access_token: &str, + input: &CreateModuleInput, +) -> Result { + let endpoint = format!("{}/v1/modules", api_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 +198,68 @@ 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 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/app.rs b/src/commands/app.rs new file mode 100644 index 0000000..fcffa9f --- /dev/null +++ b/src/commands/app.rs @@ -0,0 +1,306 @@ +//! `mirrorstack app module …` — developer module management. +//! +//! Today: only `app 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::{self, BufRead, Write}; + +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; + +use crate::api::{self, ApiError, CreateModuleInput}; +use crate::credentials::{self, LoadError}; + +use super::{DEFAULT_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_WEB_URL}; + +#[derive(Args)] +pub struct AppArgs { + #[command(subcommand)] + command: AppCommand, +} + +#[derive(Subcommand)] +enum AppCommand { + /// Manage developer modules (the per-developer reusable units installed + /// into apps). + Module(ModuleArgs), +} + +#[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: AppArgs) -> Result<()> { + match args.command { + AppCommand::Module(m) => match m.command { + ModuleCommand::Init(i) => init(i), + }, + } +} + +fn init(args: InitArgs) -> Result<()> { + 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 = 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()); + + // 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(anyhow!( + "session expired. Run `mirrorstack login` to sign in again." + )); + } + 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(&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." + )); + } + + if !args.yes { + println!(); + println!(" Module: {name}"); + println!(" Slug: @{username}/{slug}"); + if !confirm("Create this module?", true)? { + println!("aborted."); + return Ok(()); + } + } + + match api::create_module( + &api_base, + &creds.access_token, + &CreateModuleInput { + name: &name, + slug: &slug, + }, + ) { + Ok(m) => { + println!("✓ created @{username}/{}", m.slug); + println!(" id: {}", m.id); + Ok(()) + } + Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { + println!("✓ @{username}/{slug} already exists; --used set, continuing."); + Ok(()) + } + Err(ApiError::Server { code, message, .. }) => Err(anyhow!( + "{code}: {message}{hint}", + hint = slug_error_hint(&code) + )), + Err(ApiError::Unauthenticated) => Err(anyhow!( + "session expired. Run `mirrorstack login` to sign in again." + )), + Err(e) => Err(e.into()), + } +} + +fn collect_name_and_slug(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 { + prompt("Module name: ")? + }; + + 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 { + let prompt_text = format!("Slug [{suggested}]: "); + let raw = prompt(&prompt_text)?; + if raw.is_empty() { suggested } else { raw } + } + }; + + Ok((name, slug)) +} + +/// 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" => "", + _ => "", + } +} + +fn prompt(label: &str) -> Result { + print!("{label}"); + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + Ok(line.trim().to_string()) +} + +fn confirm(label: &str, default_yes: bool) -> Result { + let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; + let raw = prompt(&format!("{label} {suffix} "))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(default_yes); + } + match trimmed.to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Ok(default_yes), + } +} + +#[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/mod.rs b/src/commands/mod.rs index 9b79f69..639e649 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; +mod app; mod login; mod whoami; @@ -31,6 +32,8 @@ enum Command { Login(login::LoginArgs), /// Print the currently signed-in user. Whoami(whoami::WhoamiArgs), + /// Manage MirrorStack apps and modules. + App(app::AppArgs), } impl Cli { @@ -38,6 +41,7 @@ impl Cli { match self.command { Command::Login(args) => login::run(args), Command::Whoami(args) => whoami::run(args), + Command::App(args) => app::run(args), } } } From d21251d526257746e053507b7cac95520159cc69 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 4 May 2026 13:39:38 +0800 Subject: [PATCH 2/5] refactor: flatten `app module *` to top-level `module *` The `app` wrapper had no other subcommands beyond `module`, so it was pure nesting noise. `mirrorstack module init` reads cleaner and matches how the web surface refers to modules (no parent "app" namespace). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +--- src/commands/mod.rs | 9 +++++---- src/commands/{app.rs => module.rs} | 23 ++++------------------- 3 files changed, 10 insertions(+), 26 deletions(-) rename src/commands/{app.rs => module.rs} (94%) 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/commands/mod.rs b/src/commands/mod.rs index 639e649..15d876a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,8 +4,8 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -mod app; mod login; +mod module; mod whoami; /// Default api-platform host. Override per-invocation with @@ -32,8 +32,9 @@ enum Command { Login(login::LoginArgs), /// Print the currently signed-in user. Whoami(whoami::WhoamiArgs), - /// Manage MirrorStack apps and modules. - App(app::AppArgs), + /// Manage developer modules (the per-developer reusable units installed + /// into apps). + Module(module::ModuleArgs), } impl Cli { @@ -41,7 +42,7 @@ impl Cli { match self.command { Command::Login(args) => login::run(args), Command::Whoami(args) => whoami::run(args), - Command::App(args) => app::run(args), + Command::Module(args) => module::run(args), } } } diff --git a/src/commands/app.rs b/src/commands/module.rs similarity index 94% rename from src/commands/app.rs rename to src/commands/module.rs index fcffa9f..80dcbd3 100644 --- a/src/commands/app.rs +++ b/src/commands/module.rs @@ -1,6 +1,6 @@ -//! `mirrorstack app module …` — developer module management. +//! `mirrorstack module …` — developer module management. //! -//! Today: only `app module init` (register a module on the platform). Filesystem +//! 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 @@ -16,19 +16,6 @@ use crate::credentials::{self, LoadError}; use super::{DEFAULT_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_WEB_URL}; -#[derive(Args)] -pub struct AppArgs { - #[command(subcommand)] - command: AppCommand, -} - -#[derive(Subcommand)] -enum AppCommand { - /// Manage developer modules (the per-developer reusable units installed - /// into apps). - Module(ModuleArgs), -} - #[derive(Args)] pub struct ModuleArgs { #[command(subcommand)] @@ -61,11 +48,9 @@ pub struct InitArgs { used: bool, } -pub fn run(args: AppArgs) -> Result<()> { +pub fn run(args: ModuleArgs) -> Result<()> { match args.command { - AppCommand::Module(m) => match m.command { - ModuleCommand::Init(i) => init(i), - }, + ModuleCommand::Init(i) => init(i), } } From d555c04bfeebbde4d017d819307ebff47cd94fd8 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 4 May 2026 13:47:17 +0800 Subject: [PATCH 3/5] feat(module init): apps-service routing, pre-flight slug check, rich UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues from local dev testing: 1. **404 on POST /v1/modules**: the CLI was talking to the account service (8081), but modules live on the applications service (8082). Split out `MIRRORSTACK_APPS_API_URL` (default `https://apps-api.mirrorstack.ai`, local `http://localhost:8082`) and route `create_module` / `get_module` through it. The `me` lookup still goes to the account service. 2. **No availability check before POST**: easy to forget you already created `media` last week and end up with a confusing 409. New `GET /v1/modules/{slug}` pre-flight catches the common already-owned-by-caller case with a clear error before we POST. Reserved/format checks still happen server-side (no equivalent endpoint, and not worth adding for one CLI consumer). 3. **Plain-text prompts**: replaced ad-hoc `prompt`/`confirm` helpers with `dialoguer` (themed Input + Confirm), `console` (styled output: bold cyan slugs, dim labels, green ✓), and `indicatif` (steady-tick spinner during network calls). `--yes` mode unchanged — no prompts, no spinner-suppression needed since indicatif already detects non-tty stderr. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 1 + Cargo.lock | 109 +++++++++++++++++++++++++-- Cargo.toml | 3 + src/api.rs | 83 ++++++++++++++++++++- src/commands/mod.rs | 8 +- src/commands/module.rs | 164 +++++++++++++++++++++++++++++------------ 6 files changed, 314 insertions(+), 54 deletions(-) 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/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/src/api.rs b/src/api.rs index 4704073..1624eac 100644 --- a/src/api.rs +++ b/src/api.rs @@ -108,13 +108,57 @@ 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( - api_base: &str, + apps_base: &str, access_token: &str, input: &CreateModuleInput, ) -> Result { - let endpoint = format!("{}/v1/modules", api_base.trim_end_matches('/')); + let endpoint = format!("{}/v1/modules", apps_base.trim_end_matches('/')); let http = http::client(Duration::from_secs(15))?; let resp = http @@ -234,6 +278,41 @@ mod tests { 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(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 15d876a..bf9ed28 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,14 +8,20 @@ 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 +/// here (separate Lambda from the account service). Override with +/// `MIRRORSTACK_APPS_API_URL`. +pub(crate) const DEFAULT_APPS_API_BASE: &str = "https://apps-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"; /// Official command-line tool for the MirrorStack platform. diff --git a/src/commands/module.rs b/src/commands/module.rs index 80dcbd3..404cf2c 100644 --- a/src/commands/module.rs +++ b/src/commands/module.rs @@ -6,15 +6,21 @@ //! tooling the developer prefers, and `mirrorstack dev` (future) opens a WSS //! tunnel into the platform. -use std::io::{self, BufRead, Write}; +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_WEB_BASE, ENV_API_URL, ENV_WEB_URL}; +use super::{ + DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL, + ENV_WEB_URL, +}; #[derive(Args)] pub struct ModuleArgs { @@ -55,6 +61,8 @@ pub fn run(args: ModuleArgs) -> Result<()> { } fn init(args: InitArgs) -> Result<()> { + let theme = ColorfulTheme::default(); + let creds = match credentials::load() { Ok(c) => c, Err(LoadError::NotFound) => { @@ -66,6 +74,8 @@ fn init(args: InitArgs) -> Result<()> { }; let api_base = std::env::var(ENV_API_URL).unwrap_or_else(|_| DEFAULT_API_BASE.into()); + let apps_base = + std::env::var(ENV_APPS_API_URL).unwrap_or_else(|_| DEFAULT_APPS_API_BASE.into()); let web_base = std::env::var(ENV_WEB_URL).unwrap_or_else(|_| DEFAULT_WEB_BASE.into()); // The platform stores ownership by user id, but the CLI surfaces the @@ -86,7 +96,7 @@ fn init(args: InitArgs) -> Result<()> { )); }; - let (name, slug) = collect_name_and_slug(&args)?; + let (name, slug) = collect_name_and_slug(&theme, &args)?; if !slug_valid(&slug) { return Err(anyhow!( @@ -94,31 +104,68 @@ fn init(args: InitArgs) -> Result<()> { )); } + // 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, &existing.id); + return Ok(()); + } + Ok(Some(_)) => { + return Err(anyhow!( + "@{username}/{slug} already exists{hint}", + hint = slug_error_hint("slug_taken") + )); + } + Ok(None) => {} + Err(ApiError::Unauthenticated) => { + return Err(anyhow!( + "session expired. Run `mirrorstack login` to sign in again." + )); + } + Err(e) => return Err(e.into()), + } + if !args.yes { - println!(); - println!(" Module: {name}"); - println!(" Slug: @{username}/{slug}"); - if !confirm("Create this module?", true)? { - println!("aborted."); + 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(()); } } - match api::create_module( - &api_base, - &creds.access_token, - &CreateModuleInput { - name: &name, - slug: &slug, - }, - ) { + 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) => { - println!("✓ created @{username}/{}", m.slug); - println!(" id: {}", m.id); + print_created(username, &m.slug, &m.id); Ok(()) } Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { - println!("✓ @{username}/{slug} already exists; --used set, continuing."); + print_already_exists(username, &slug, "(unknown id)"); Ok(()) } Err(ApiError::Server { code, message, .. }) => Err(anyhow!( @@ -132,7 +179,7 @@ fn init(args: InitArgs) -> Result<()> { } } -fn collect_name_and_slug(args: &InitArgs) -> Result<(String, String)> { +fn collect_name_and_slug(theme: &ColorfulTheme, args: &InitArgs) -> Result<(String, String)> { let name = if let Some(n) = args .name .as_deref() @@ -145,7 +192,11 @@ fn collect_name_and_slug(args: &InitArgs) -> Result<(String, String)> { "--yes requires --name (cannot prompt in non-interactive mode)" )); } else { - prompt("Module name: ")? + Input::::with_theme(theme) + .with_prompt("Module name") + .interact_text()? + .trim() + .to_string() }; let slug = if let Some(s) = args @@ -162,15 +213,58 @@ fn collect_name_and_slug(args: &InitArgs) -> Result<(String, String)> { // downstream the caller sees a clear error. suggested } else { - let prompt_text = format!("Slug [{suggested}]: "); - let raw = prompt(&prompt_text)?; - if raw.is_empty() { suggested } else { raw } + 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. Spinner is suppressed +/// when stderr isn't a TTY so CI logs stay clean. +fn with_spinner(message: &str, f: F) -> T +where + F: FnOnce() -> T, +{ + 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: &str) { + eprintln!( + "{} {} already exists; {} continuing.", + style("✓").green().bold(), + style(format!("@{username}/{slug}")).cyan().bold(), + style("--used set,").dim(), + ); + if id != "(unknown 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. @@ -223,28 +317,6 @@ fn slug_error_hint(code: &str) -> &'static str { } } -fn prompt(label: &str) -> Result { - print!("{label}"); - io::stdout().flush()?; - let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; - Ok(line.trim().to_string()) -} - -fn confirm(label: &str, default_yes: bool) -> Result { - let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; - let raw = prompt(&format!("{label} {suffix} "))?; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(default_yes); - } - match trimmed.to_ascii_lowercase().as_str() { - "y" | "yes" => Ok(true), - "n" | "no" => Ok(false), - _ => Ok(default_yes), - } -} - #[cfg(test)] mod tests { use super::*; From 5962908a80dc2c962899bb67d80da06ec0e13a7f Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 4 May 2026 13:56:26 +0800 Subject: [PATCH 4/5] refactor(module init): /simplify pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TTY check on `with_spinner`: short-circuit before spinner setup when stderr isn't a terminal. The doc comment promised this but the code wasn't actually implementing it; CI logs would have stuttered with the spinner's terminal escapes. - `print_already_exists` takes `Option<&str>` for id instead of the `"(unknown id)"` sentinel string. - `session_expired()` helper dedupes the same `anyhow!` message at three call sites in `init`. - Inline the `slug_taken` hint in the pre-flight error path — re-looking it up via `slug_error_hint("slug_taken")` was awkward when the literal string was already known at the call site. - `resolve_base(env, default)` helper in `commands/mod.rs` replaces the six `std::env::var(ENV_X).unwrap_or_else(|_| DEFAULT_X.into())` repetitions across `module.rs`, `whoami.rs`, and `login.rs`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/login.rs | 6 ++--- src/commands/mod.rs | 5 +++++ src/commands/module.rs | 50 ++++++++++++++++++++---------------------- src/commands/whoami.rs | 4 ++-- 4 files changed, 34 insertions(+), 31 deletions(-) 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 bf9ed28..bf32aec 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -24,6 +24,11 @@ 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)] diff --git a/src/commands/module.rs b/src/commands/module.rs index 404cf2c..0f629d2 100644 --- a/src/commands/module.rs +++ b/src/commands/module.rs @@ -6,6 +6,7 @@ //! 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}; @@ -19,7 +20,7 @@ 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, + ENV_WEB_URL, resolve_base, }; #[derive(Args)] @@ -73,21 +74,16 @@ fn init(args: InitArgs) -> Result<()> { Err(e) => return Err(e.into()), }; - let api_base = std::env::var(ENV_API_URL).unwrap_or_else(|_| DEFAULT_API_BASE.into()); - let apps_base = - std::env::var(ENV_APPS_API_URL).unwrap_or_else(|_| DEFAULT_APPS_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 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(anyhow!( - "session expired. Run `mirrorstack login` to sign in again." - )); - } + 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 { @@ -112,21 +108,16 @@ fn init(args: InitArgs) -> Result<()> { }); match pre_check { Ok(Some(existing)) if args.used => { - print_already_exists(username, &existing.slug, &existing.id); + print_already_exists(username, &existing.slug, Some(&existing.id)); return Ok(()); } Ok(Some(_)) => { return Err(anyhow!( - "@{username}/{slug} already exists{hint}", - hint = slug_error_hint("slug_taken") + "@{username}/{slug} already exists (pass --used to ignore when re-running)" )); } Ok(None) => {} - Err(ApiError::Unauthenticated) => { - return Err(anyhow!( - "session expired. Run `mirrorstack login` to sign in again." - )); - } + Err(ApiError::Unauthenticated) => return Err(session_expired()), Err(e) => return Err(e.into()), } @@ -165,20 +156,22 @@ fn init(args: InitArgs) -> Result<()> { Ok(()) } Err(ApiError::Server { code, .. }) if code == "slug_taken" && args.used => { - print_already_exists(username, &slug, "(unknown id)"); + 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(anyhow!( - "session expired. Run `mirrorstack login` to sign in again." - )), + 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 @@ -225,12 +218,17 @@ fn collect_name_and_slug(theme: &ColorfulTheme, args: &InitArgs) -> Result<(Stri Ok((name, slug)) } -/// Wrap a blocking call with a tick-driven spinner. Spinner is suppressed -/// when stderr isn't a TTY so CI logs stay clean. +/// 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}") @@ -253,14 +251,14 @@ fn print_created(username: &str, slug: &str, id: &str) { eprintln!(" {} {}", style("id:").dim(), id); } -fn print_already_exists(username: &str, slug: &str, id: &str) { +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 id != "(unknown id)" { + if let Some(id) = id { eprintln!(" {} {}", style("id:").dim(), id); } } 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) => { From 89ab54ebfae0e7af84eb050c4c63495943a511a9 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 4 May 2026 14:00:17 +0800 Subject: [PATCH 5/5] fix(module init): point apps-API prod default at api.mirrorstack.ai Both account and applications services are exposed under the same `api.mirrorstack.ai` hostname in prod (path-routed at the ingress); the separate `apps-api.mirrorstack.ai` was a guess that didn't match how the platform is actually deployed. Local dev keeps the 8082 split via `.env`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bf32aec..62ffe55 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,9 +13,11 @@ mod whoami; pub(crate) const DEFAULT_API_BASE: &str = "https://api.mirrorstack.ai"; /// Default api-platform applications-service host. Modules and apps live -/// here (separate Lambda from the account service). Override with -/// `MIRRORSTACK_APPS_API_URL`. -pub(crate) const DEFAULT_APPS_API_BASE: &str = "https://apps-api.mirrorstack.ai"; +/// 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";