diff --git a/.derived/codebase-index/by-spec/003-auth-api-client.json b/.derived/codebase-index/by-spec/003-auth-api-client.json index 70d289f..41898d1 100644 --- a/.derived/codebase-index/by-spec/003-auth-api-client.json +++ b/.derived/codebase-index/by-spec/003-auth-api-client.json @@ -49,5 +49,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "e6fd7efd240ef302be413f7448761f2261659e888da91930484cbc6439de53bb" + "shardHash": "c5fef062ac8d9429215bef31321e37f07fd7c74fdb018ea77bca55f76230d39f" } diff --git a/.derived/codebase-index/by-spec/004-governance-verbs.json b/.derived/codebase-index/by-spec/004-governance-verbs.json index 5871c71..10e51b1 100644 --- a/.derived/codebase-index/by-spec/004-governance-verbs.json +++ b/.derived/codebase-index/by-spec/004-governance-verbs.json @@ -1,21 +1,25 @@ { - "diagnostics": { - "errors": [], - "warnings": [ - { - "code": "W-001", - "message": "spec '004-governance-verbs' symbol unit 'stagecraft_cli::verbs' did not resolve" - } - ] - }, "mapping": { "dependsOn": [ "003-auth-api-client" ], - "implementingPaths": [], + "implementingPaths": [ + { + "path": "src/main.rs", + "source": "spec-edge" + } + ], "resolvedUnits": [ { - "locations": [], + "locations": [ + { + "file": "src/main.rs", + "span": { + "endLine": 12, + "startLine": 12 + } + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -28,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "f755522484b253a2461f9038878b9ff0940af5284ac65cb95dd00b90a721b1c7" + "shardHash": "3314a5176288efdc33f1dcb517735e6ff3c09f8074431c7a51d77ff9fb1c39fc" } diff --git a/.derived/spec-registry/by-spec/004-governance-verbs.json b/.derived/spec-registry/by-spec/004-governance-verbs.json index 5df74b3..e24d008 100644 --- a/.derived/spec-registry/by-spec/004-governance-verbs.json +++ b/.derived/spec-registry/by-spec/004-governance-verbs.json @@ -11,19 +11,25 @@ } ], "id": "004-governance-verbs", - "implementation": "pending", + "implementation": "in-progress", "sectionHeadings": [ "004: Governance verbs", "1. Cross-repo dependency", "2. Verb surface (all support --output json)", "3. Acceptance", - "4. Out of scope" + "4. Out of scope", + "5. Endpoint map and JSON envelope (2026-07-15 amendment)", + "5.1 Endpoint map (all paths under the plane's `/api/v1`)", + "5.2 JSON envelope (`--output json`)", + "5.3 Response-shape expectations (human rendering only)", + "5.4 Watch semantics (`stamp status --watch`)", + "6. Status (2026-07-15)" ], "specPath": "specs/004-governance-verbs/spec.md", "status": "approved", "summary": "The CLI face becomes useful: the stub commands from spec 002 gain real implementations over the API client, mirroring the control plane's tenants (stagecraft spec 004), factory (spec 005), and fleet (spec 006) services. Every verb has a stable JSON output shape, because spec 005 exposes these same verbs as MCP tools and the JSON is the shared contract between both faces.\n", "title": "Governance verbs v1: tenants, stamps, fleet" }, - "shardHash": "2f0b2011cc2ce356f66a5ecc59537186442dcf760a74cce56bbb37146c63f922", + "shardHash": "da3bdd371187d7af89428ee04e060365b9583c4a3f7cfa3b2605d4a2e8cf012c", "specVersion": "1.1.0" } diff --git a/specs/004-governance-verbs/spec.md b/specs/004-governance-verbs/spec.md index d1eb84e..af3eb72 100644 --- a/specs/004-governance-verbs/spec.md +++ b/specs/004-governance-verbs/spec.md @@ -3,7 +3,7 @@ id: "004-governance-verbs" title: "Governance verbs v1: tenants, stamps, fleet" status: approved created: "2026-07-14" -implementation: pending +implementation: in-progress depends_on: - "003-auth-api-client" establishes: @@ -74,3 +74,111 @@ target plane must surface as "not enabled on this control plane" - Local certificate verification: when the signed-cert path lands (enrahitu spec 012 §7, born-with certVersion 2), verification is repo-local via pinned tenant-tail; the CLI does not wrap it. + +## 5. Endpoint map and JSON envelope (2026-07-15 amendment) + +§2 named the verbs but left the concrete HTTP mapping, the request +bodies, and the envelope's error arm implicit. This section inlines +them, read off stagecraft specs 004 (tenants), 005 (factory), and 006 +(fleet) on 2026-07-15. §1's divergence rule still governs: if a live +plane differs, the platform spec wins and this section is re-amended, +never coded around. + +### 5.1 Endpoint map (all paths under the plane's `/api/v1`) + +| Verb | Method + path | Request body | +| --- | --- | --- | +| `tenants list` | GET `/tenants` | (none) | +| `tenants show ` | GET `/tenants/:id` | (none) | +| `tenants install-url ` | GET `/tenants/:id/github/install-url` | (none) | +| `stamp new` | POST `/tenants/:id/stamps` | `{appName, targetOrg, posture, frontend?}` | +| `stamp status ` | GET `/stamps/:jobId` | (none) | +| `fleet list --tenant ` | GET `/tenants/:id/fleet` | (none) | +| `fleet deploy` | POST `/tenants/:id/fleet` | `{name, image}` | +| `fleet update ` | POST `/fleet/:appId/update` | `{image}` | +| `fleet backup ` | POST `/fleet/:appId/backup` | `{}` | +| `fleet remove ` | DELETE `/fleet/:appId` | `{confirm}` | + +Every mapping above is verbatim from the platform specs except one: +`fleet list` -> GET `/tenants/:id/fleet`. Stagecraft spec 006 defines +the fleet collection's deploy (POST `/tenants/:id/fleet`) and the +per-app status/update/backup/remove, but names no list endpoint. The +CLI inlines the symmetric collection GET as its expectation, mirroring +the factory's `GET /tenants/:id/stamps` list; if the plane lands a +different list shape, §1 governs. `stamp new`'s `posture` is a required +field the caller must supply: the factory rejects a defaulted posture +by design (stagecraft spec 005 §3, pipeline step 3), so the CLI makes +`--posture` a required flag with no default and forwards it as +`posture`. `fleet remove`'s `confirm` carries the literal app name the +operator echoes (stagecraft spec 006 §3, destructive guard); there is +no `--force`/`--yes` shortcut. + +### 5.2 JSON envelope (`--output json`) + +Both faces (spec 005 reuses this) consume one stable envelope: + +- Success: `{"ok": true, "data": }`. The CLI does not reshape the plane's payload; it wraps + it, so the envelope stays stable as platform fields grow. +- Failure: `{"ok": false, "error": {"kind": , "message": , + "status"?: }}`, where `kind` is one of `network`, + `unauthenticated`, `api`, `server`, `decode` (the spec 003 taxonomy). + The envelope is emitted on stdout and the process still exits 1. + +In human mode, success renders as an aligned table (ids, names, status, +age); failure prints the taxonomy message to stderr and exits 1 (the +spec 002 exit-code contract). Only the request taxonomy above is +enveloped. A pre-flight failure the CLI detects before any request is +reported plainly (stderr) in both formats, never wrapped: a missing +base URL is usage (exit 2), and a missing stored credential is +operational (exit 1) with the `run login` hint, matching `whoami`. + +### 5.3 Response-shape expectations (human rendering only) + +The passthrough envelope means `--output json` is faithful to whatever +the plane returns. Human tables need named fields, so the CLI reads a +tolerant subset (unknown fields ignored, absent optional fields blank): +list endpoints return a JSON array of records; `tenants show` returns +one tenant carrying an `installations` array; stamp and fleet records +carry at least `id` and `status`. These are the CLI's expectation per +§1; a shape the human renderer cannot read surfaces as a `decode` +error (exit 1), which is the intended drift signal. + +### 5.4 Watch semantics (`stamp status --watch`) + +Poll GET `/stamps/:jobId` starting at 2s and backing off toward a 10s +cap, emitting each observed state change (a line in human mode, an +envelope in JSON mode) rather than every poll. A terminal `green` +exits 0; a terminal `failed` exits 1 (the final state is already on +stdout, so no additional stderr line). A transport error mid-poll +aborts through the normal taxonomy after the client's GET retries are +exhausted. + +## 6. Status (2026-07-15) + +Implemented: the full §2/§5.1 verb surface (tenants list/show/ +install-url, stamp new/status with `--watch`, fleet list/deploy/update/ +backup/remove) in the new `stagecraft_cli::verbs` module over the spec +003 client, which grew GET/POST/DELETE returning a passthrough +`serde_json::Value`. Human output is aligned tables; `--output json` +emits the §5.2 `{ok, data|error}` envelope. The `--posture` guard is +required with no default and the `fleet remove` confirm-name guard is +surfaced verbatim, both per §2. A missing service's 404 surfaces as +"not enabled on this control plane" (exit 1, not a crash) per §1. + +Covered by 70 tests (58 unit + 12 integration): per-verb happy path, +api-error surfacing, missing-service 404 mapping, watch-mode terminal +states, and a JSON envelope snapshot per verb family. The release +binary was additionally driven end-to-end against a mock plane: every +verb renders in both formats with the right exit codes, the +non-idempotent DELETE is not retried, and `--debug` never leaks the +token. + +Outstanding: the §3 manual e2e against a **live** control plane cannot +run. Stagecraft's tenants/factory/fleet services (its specs 004/005/006) +are all `implementation: pending`, so no reachable plane serves these +endpoints; the CLI is built for exactly that case (a missing service is +a clean exit-1 404, §1). This spec stays `implementation: in-progress` +until a live plane exists; at that point the only open item is the +live e2e (login, then a governed read/write against real tenants, +factory, and fleet services), which drops in with no code change. diff --git a/src/api.rs b/src/api.rs index a03cfa4..b5eddd7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,6 +12,7 @@ use std::time::Duration; use reqwest::{Method, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::config::ResolvedConfig; use crate::error::{AppError, AppResult}; @@ -62,6 +63,28 @@ impl std::fmt::Display for ApiError { impl std::error::Error for ApiError {} +impl ApiError { + /// The stable `kind` token for the JSON error envelope (spec 004 §5.2). One + /// token per taxonomy variant; the MCP face keys off these too (spec 005). + pub fn kind(&self) -> &'static str { + match self { + ApiError::Network(_) => "network", + ApiError::Unauthenticated => "unauthenticated", + ApiError::Api { .. } => "api", + ApiError::Server { .. } => "server", + ApiError::Decode(_) => "decode", + } + } + + /// The HTTP status the error carries, when it originated from a response. + pub fn status(&self) -> Option { + match self { + ApiError::Api { status, .. } | ApiError::Server { status } => Some(*status), + _ => None, + } + } +} + /// Every API failure is an operational failure (exit 1), including 401: an /// unauthenticated call is a runtime condition the operator resolves by /// logging in, not a misuse of the command line. @@ -130,16 +153,53 @@ impl ApiClient { format!("{}/{}", self.base_url, path.trim_start_matches('/')) } - /// Send a retrying request, then map status + body onto the taxonomy. + /// GET a path and deserialize the 2xx body into `T`: spec 003's typed path, + /// used by `fetch_identity`. Routes through the same request pipeline as the + /// spec 004 passthrough verbs. async fn get_json(&self, path: &str) -> Result { - let (status, body) = self.send_retrying(Method::GET, path).await?; + let value = self.get_value(path).await?; + serde_json::from_value(value).map_err(|e| ApiError::Decode(e.to_string())) + } + + /// GET a path as an opaque JSON value: the spec 004 passthrough, wrapped by + /// the verb layer's `{ok,data}` envelope without reshaping the payload. + pub async fn get_value(&self, path: &str) -> Result { + self.send_json(Method::GET, path, None).await + } + + /// POST a JSON `body` to `path` and return the 2xx response value. + pub async fn post_value(&self, path: &str, body: Value) -> Result { + self.send_json(Method::POST, path, Some(&body)).await + } + + /// DELETE `path` carrying a JSON `body` (the fleet remove confirm guard, + /// spec 004 §5.1) and return the 2xx response value. + pub async fn delete_value(&self, path: &str, body: Value) -> Result { + self.send_json(Method::DELETE, path, Some(&body)).await + } + + /// Send a retrying request, then map status + body onto the taxonomy. A 2xx + /// with an empty body (a 204-style response) decodes to JSON null so the + /// passthrough envelope still has a `data` value. + async fn send_json( + &self, + method: Method, + path: &str, + body: Option<&Value>, + ) -> Result { + let (status, text) = self.send_retrying(method, path, body).await?; if status.as_u16() == 401 { return Err(ApiError::Unauthenticated); } if status.is_client_error() { + let message = if status.as_u16() == 404 { + not_found_message(&text) + } else { + server_message(&text) + }; return Err(ApiError::Api { status: status.as_u16(), - message: server_message(&body), + message, }); } if status.is_server_error() { @@ -147,17 +207,30 @@ impl ApiClient { status: status.as_u16(), }); } - serde_json::from_str(&body).map_err(|e| ApiError::Decode(e.to_string())) + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(trimmed).map_err(|e| ApiError::Decode(e.to_string())) } /// One HTTP attempt: send, optionally emit `--debug` metadata, return the - /// status and body text. The Authorization header is never logged. - async fn attempt(&self, method: Method, path: &str) -> Result<(StatusCode, String), ApiError> { + /// status and body text. Neither the Authorization header nor the request + /// body is ever logged. + async fn attempt( + &self, + method: Method, + path: &str, + body: Option<&Value>, + ) -> Result<(StatusCode, String), ApiError> { let url = self.url(path); let mut req = self.http.request(method.clone(), url.as_str()); if let Some(token) = &self.token { req = req.bearer_auth(token); } + if let Some(body) = body { + req = req.json(body); + } let start = std::time::Instant::now(); let resp = req .send() @@ -171,11 +244,11 @@ impl ApiClient { start.elapsed().as_millis() ); } - let body = resp + let text = resp .text() .await .map_err(|e| ApiError::Network(e.to_string()))?; - Ok((status, body)) + Ok((status, text)) } /// Retry idempotent GETs on transient failure (network error or 5xx) with @@ -184,18 +257,19 @@ impl ApiClient { &self, method: Method, path: &str, + body: Option<&Value>, ) -> Result<(StatusCode, String), ApiError> { let retryable = is_retryable_method(&method); let mut attempt = 0u32; loop { attempt += 1; - match self.attempt(method.clone(), path).await { - Ok((status, body)) => { + match self.attempt(method.clone(), path, body).await { + Ok((status, text)) => { if status.is_server_error() && retryable && attempt < MAX_ATTEMPTS { backoff(attempt).await; continue; } - return Ok((status, body)); + return Ok((status, text)); } Err(err) => { if retryable && attempt < MAX_ATTEMPTS { @@ -293,6 +367,19 @@ fn jitter_ms() -> u64 { .unwrap_or(0) } +/// A 404 on a governance endpoint means either the service is not enabled on +/// this control plane (spec 004 §1: a service that does not exist yet) or the +/// addressed resource is gone. Prefer the plane's own message; when it gives +/// none, name both possibilities rather than a bare "returned 404". +fn not_found_message(body: &str) -> String { + let server = server_message(body); + if server == "(no message)" { + "not enabled on this control plane, or the resource does not exist".to_string() + } else { + server + } +} + /// Extract a human-facing message from an error body: the chassis `message` /// field when present, else the trimmed body, else a placeholder. fn server_message(body: &str) -> String { @@ -342,6 +429,40 @@ mod tests { assert_eq!(server_message(" "), "(no message)"); } + #[test] + fn not_found_message_names_the_not_enabled_case() { + // A bodyless 404 (a missing/unmounted service) names both possibilities. + assert!(not_found_message("").contains("not enabled on this control plane")); + // A 404 the plane explains keeps the plane's own message. + assert_eq!( + not_found_message(r#"{"message":"tenant t_9 not found"}"#), + "tenant t_9 not found" + ); + } + + #[test] + fn missing_service_404_maps_to_exit_one_not_a_crash() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants"); + then.status(404); // an unmounted service: no body + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + // Returning an Err (never panicking) is the "not a crash" half; the + // ApiError -> operational exit-code (1) half is `api_errors_map_to_...`. + let err = block_on(client.get_value("/api/v1/tenants")) + .unwrap() + .unwrap_err(); + match err { + ApiError::Api { status, message } => { + assert_eq!(status, 404); + assert!(message.contains("not enabled on this control plane")); + } + other => panic!("expected Api 404, got {other:?}"), + } + } + #[test] fn unauthenticated_message_hints_login() { assert!(ApiError::Unauthenticated @@ -450,7 +571,7 @@ mod tests { }); let client = ApiClient::new(server.base_url(), None, false).unwrap(); - let (status, _) = block_on(client.send_retrying(Method::GET, "/boom")) + let (status, _) = block_on(client.send_retrying(Method::GET, "/boom", None)) .unwrap() .unwrap(); @@ -467,7 +588,7 @@ mod tests { }); let client = ApiClient::new(server.base_url(), None, false).unwrap(); - let (status, _) = block_on(client.send_retrying(Method::POST, "/boom")) + let (status, _) = block_on(client.send_retrying(Method::POST, "/boom", None)) .unwrap() .unwrap(); diff --git a/src/cli.rs b/src/cli.rs index 781bd7e..d71d7de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use crate::output::OutputFormat; @@ -79,32 +79,117 @@ pub enum Command { #[derive(Debug, Subcommand)] pub enum TenantsCommand { - /// List tenants (spec 004). + /// List the tenants you own (spec 004). List, - /// Show one tenant (spec 004). - Show, + /// Show one tenant, including its installations (spec 004). + Show { + /// Tenant id. + id: String, + }, + /// Print the GitHub App install URL for a tenant (spec 004). + InstallUrl { + /// Tenant id. + id: String, + /// Open the URL in the default browser instead of only printing it. + #[arg(long)] + open: bool, + }, +} + +/// Governance posture for a stamp. Required with no default: the platform +/// rejects a defaulted posture, so the CLI never invents one (spec 004 §5.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum Posture { + /// No autonomous action; every step is operator-driven. + None, + /// Agent may act with a human in the loop. + Assisted, + /// Agent may act autonomously within its guardrails. + Autonomous, +} + +impl Posture { + /// The wire token sent to the control plane (`posture` field). + pub fn as_wire(self) -> &'static str { + match self { + Posture::None => "none", + Posture::Assisted => "assisted", + Posture::Autonomous => "autonomous", + } + } } #[derive(Debug, Subcommand)] pub enum StampCommand { - /// Request a new governance stamp (spec 004). - New, - /// Check stamp status (spec 004). - Status, + /// Request a new governance stamp: born-green repo in a customer org (spec 004). + New { + /// Tenant id the stamp is charged to. + #[arg(long)] + tenant: String, + /// Application name for the stamped repo. + #[arg(long)] + app: String, + /// Target GitHub org the repo is created in. + #[arg(long)] + org: String, + /// Optional frontend flavor slot (e.g. `vue`). + #[arg(long)] + frontend: Option, + /// Governance posture (required; the platform never defaults it). + #[arg(long, value_enum)] + posture: Posture, + }, + /// Check stamp status; `--watch` streams until the job settles (spec 004). + Status { + /// Stamp job id. + job_id: String, + /// Poll until the job reaches green or failed, streaming state changes. + #[arg(long)] + watch: bool, + }, } #[derive(Debug, Subcommand)] pub enum FleetCommand { - /// List fleet members (spec 004). - List, - /// Deploy to the fleet (spec 004). - Deploy, - /// Update fleet members (spec 004). - Update, - /// Back up fleet state (spec 004). - Backup, - /// Remove a fleet member (spec 004). - Remove, + /// List a tenant's fleet apps (spec 004). + List { + /// Tenant id whose fleet is listed. + #[arg(long)] + tenant: String, + }, + /// Deploy an image as a new fleet app (spec 004). + Deploy { + /// Tenant id the app belongs to. + #[arg(long)] + tenant: String, + /// Fleet app name. + #[arg(long)] + app: String, + /// Container image reference to place. + #[arg(long)] + image: String, + }, + /// Roll a fleet app to a new image (spec 004). + Update { + /// Fleet app id. + app_id: String, + /// New container image reference. + #[arg(long)] + image: String, + }, + /// Back up a fleet app's volume (spec 004). + Backup { + /// Fleet app id. + app_id: String, + }, + /// Remove a fleet app; `--confirm ` must echo the app name (spec 004). + Remove { + /// Fleet app id. + app_id: String, + /// The app's literal name, echoed to authorize the teardown. + #[arg(long)] + confirm: String, + }, } #[derive(Debug, Subcommand)] diff --git a/src/commands.rs b/src/commands.rs index 4f31409..1d543ee 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,8 +1,10 @@ //! Command dispatch and handlers (spec 002 §2). //! -//! Stub verbs return [`AppError::NotImplemented`] (exit 2, spec named). The -//! three working verbs (`version`, `config show`, `completions`) render through -//! the shared output layer so `--output json` is honored uniformly. +//! `mcp` is the last stub, returning [`AppError::NotImplemented`] (exit 2, spec +//! named) until spec 005. The governance verbs (spec 004) live in +//! [`crate::verbs`]; the local verbs (`version`, `config show`, `completions`) +//! render through the shared output layer so `--output json` is honored +//! uniformly. use std::io; use std::path::PathBuf; @@ -23,9 +25,9 @@ pub fn dispatch(cli: Cli) -> AppResult<()> { match &cli.command { Command::Login => crate::auth::run_login(&resolved, format, cli.debug), Command::Whoami => crate::api::run_whoami(&resolved, format, cli.debug), - Command::Tenants { command } => tenants(command), - Command::Stamp { command } => stamp(command), - Command::Fleet { command } => fleet(command), + Command::Tenants { command } => tenants(command, &resolved, format, cli.debug), + Command::Stamp { command } => stamp(command, &resolved, format, cli.debug), + Command::Fleet { command } => fleet(command, &resolved, format, cli.debug), Command::Mcp => Err(AppError::not_implemented("mcp", "005-mcp-server")), Command::Version => { version(format); @@ -58,42 +60,74 @@ fn load_config(cli: &Cli) -> AppResult { Ok(config::resolve(file, env, flags)) } -// --- stub verbs (spec 004) ------------------------------------------------- +// --- governance verbs (spec 004) ------------------------------------------- -fn tenants(command: &TenantsCommand) -> AppResult<()> { - let verb = match command { - TenantsCommand::List => "list", - TenantsCommand::Show => "show", - }; - Err(AppError::not_implemented( - format!("tenants {verb}"), - "004-governance-verbs", - )) +fn tenants( + command: &TenantsCommand, + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, +) -> AppResult<()> { + use crate::verbs::tenants; + match command { + TenantsCommand::List => tenants::list(resolved, format, debug), + TenantsCommand::Show { id } => tenants::show(resolved, format, debug, id), + TenantsCommand::InstallUrl { id, open } => { + tenants::install_url(resolved, format, debug, id, *open) + } + } } -fn stamp(command: &StampCommand) -> AppResult<()> { - let verb = match command { - StampCommand::New => "new", - StampCommand::Status => "status", - }; - Err(AppError::not_implemented( - format!("stamp {verb}"), - "004-governance-verbs", - )) +fn stamp( + command: &StampCommand, + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, +) -> AppResult<()> { + use crate::verbs::stamp; + match command { + StampCommand::New { + tenant, + app, + org, + frontend, + posture, + } => stamp::new( + resolved, + format, + debug, + tenant, + app, + org, + frontend.as_deref(), + *posture, + ), + StampCommand::Status { job_id, watch } => { + stamp::status(resolved, format, debug, job_id, *watch) + } + } } -fn fleet(command: &FleetCommand) -> AppResult<()> { - let verb = match command { - FleetCommand::List => "list", - FleetCommand::Deploy => "deploy", - FleetCommand::Update => "update", - FleetCommand::Backup => "backup", - FleetCommand::Remove => "remove", - }; - Err(AppError::not_implemented( - format!("fleet {verb}"), - "004-governance-verbs", - )) +fn fleet( + command: &FleetCommand, + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, +) -> AppResult<()> { + use crate::verbs::fleet; + match command { + FleetCommand::List { tenant } => fleet::list(resolved, format, debug, tenant), + FleetCommand::Deploy { tenant, app, image } => { + fleet::deploy(resolved, format, debug, tenant, app, image) + } + FleetCommand::Update { app_id, image } => { + fleet::update(resolved, format, debug, app_id, image) + } + FleetCommand::Backup { app_id } => fleet::backup(resolved, format, debug, app_id), + FleetCommand::Remove { app_id, confirm } => { + fleet::remove(resolved, format, debug, app_id, confirm) + } + } } // --- working verbs --------------------------------------------------------- diff --git a/src/error.rs b/src/error.rs index 1dfb9b8..7e5f84e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,6 +23,12 @@ pub enum AppError { #[error("{0}")] Usage(String), + /// A failure already written to stdout as the JSON `{ok:false,error}` + /// envelope (spec 004 §5.2). `main` exits with `code` and prints nothing + /// more, so the envelope is not doubled by a stderr line. + #[error("")] + Rendered { code: u8 }, + /// Any operational failure; carries anyhow context up to `main`. #[error(transparent)] Operational(#[from] anyhow::Error), @@ -33,6 +39,7 @@ impl AppError { pub fn code(&self) -> u8 { match self { AppError::NotImplemented { .. } | AppError::Usage(_) => EXIT_USAGE, + AppError::Rendered { code } => *code, AppError::Operational(_) => EXIT_OPERATIONAL, } } diff --git a/src/main.rs b/src/main.rs index f013c7d..166291f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,13 +9,14 @@ mod commands; mod config; mod error; mod output; +mod verbs; use std::process::ExitCode; use clap::Parser; use crate::cli::Cli; -use crate::error::EXIT_OK; +use crate::error::{AppError, EXIT_OK}; fn main() -> ExitCode { // clap handles `--help`/`--version` and its own usage errors (exit 2) @@ -24,6 +25,9 @@ fn main() -> ExitCode { match commands::dispatch(cli) { Ok(()) => ExitCode::from(EXIT_OK), + // A `Rendered` failure already wrote the JSON error envelope to stdout + // (spec 004 §5.2); exit with its code without a second, stderr report. + Err(AppError::Rendered { code }) => ExitCode::from(code), Err(err) => { eprintln!("error: {err}"); ExitCode::from(err.code()) diff --git a/src/verbs/fleet.rs b/src/verbs/fleet.rs new file mode 100644 index 0000000..4d04c5b --- /dev/null +++ b/src/verbs/fleet.rs @@ -0,0 +1,296 @@ +//! The `fleet` verb family (spec 004 §5.1): list, deploy, update, backup, +//! remove. `remove` carries the confirm-name guard verbatim (stagecraft spec +//! 006 §3); there is deliberately no `--force`/`--yes` shortcut. + +use std::fmt::Write; + +use serde_json::{json, Map, Value}; + +use super::{as_array, client_for, field, render, require_field, table}; +use crate::api::block_on; +use crate::config::ResolvedConfig; +use crate::error::AppResult; +use crate::output::OutputFormat; + +/// `fleet list --tenant ` -> GET /api/v1/tenants/:id/fleet. +pub fn list( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + tenant: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/tenants/{tenant}/fleet"); + let result = block_on(client.get_value(&path))?; + render(format, result, render_list) +} + +/// `fleet deploy --tenant --app --image ` +/// -> POST /api/v1/tenants/:id/fleet. +pub fn deploy( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + tenant: &str, + app: &str, + image: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/tenants/{tenant}/fleet"); + let mut body = Map::new(); + body.insert("name".to_string(), Value::String(app.to_string())); + body.insert("image".to_string(), Value::String(image.to_string())); + let result = block_on(client.post_value(&path, Value::Object(body)))?; + render(format, result, render_app) +} + +/// `fleet update --image ` -> POST /api/v1/fleet/:appId/update. +pub fn update( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + app_id: &str, + image: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/fleet/{app_id}/update"); + let body = json!({ "image": image }); + let result = block_on(client.post_value(&path, body))?; + render(format, result, render_app) +} + +/// `fleet backup ` -> POST /api/v1/fleet/:appId/backup. +pub fn backup( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + app_id: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/fleet/{app_id}/backup"); + let result = block_on(client.post_value(&path, json!({})))?; + render(format, result, render_op) +} + +/// `fleet remove --confirm ` -> DELETE /api/v1/fleet/:appId. The +/// confirm name is echoed in the body (stagecraft spec 006 §3); the platform +/// rejects a mismatch, so the CLI forwards it as-is rather than pre-judging. +pub fn remove( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + app_id: &str, + confirm: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/fleet/{app_id}"); + let body = json!({ "confirm": confirm }); + let result = block_on(client.delete_value(&path, body))?; + render(format, result, render_app) +} + +fn render_list(v: &Value) -> AppResult { + let apps = as_array(v)?; + if apps.is_empty() { + return Ok("no fleet apps".to_string()); + } + let rows: Vec> = apps + .iter() + .map(|a| { + vec![ + field(a, "id"), + field(a, "name"), + field(a, "status"), + field(a, "image"), + field(a, "createdAt"), + ] + }) + .collect(); + Ok(table(&["ID", "NAME", "STATUS", "IMAGE", "CREATED"], &rows)) +} + +/// A single fleet app (deploy/update/remove responses). A 204-style empty body +/// decodes to null; render it as a plain acknowledgement. +fn render_app(v: &Value) -> AppResult { + if v.is_null() { + return Ok("done".to_string()); + } + let mut out = String::new(); + let _ = write!(out, "app {}", require_field(v, "id")?); + let name = field(v, "name"); + if !name.is_empty() { + let _ = write!(out, " {name}"); + } + let _ = write!(out, " status: {}", require_field(v, "status")?); + let image = field(v, "image"); + if !image.is_empty() { + let _ = write!(out, " image: {image}"); + } + let host = field(v, "host"); + if !host.is_empty() { + let _ = write!(out, " host: {host}"); + } + Ok(out) +} + +/// A fleet operation record (backup responses): id, kind, status, artifact. +fn render_op(v: &Value) -> AppResult { + if v.is_null() { + return Ok("done".to_string()); + } + let mut out = String::new(); + let _ = write!( + out, + "op {} {} status: {}", + require_field(v, "id")?, + field(v, "kind"), + require_field(v, "status")? + ); + let log = field(v, "log"); + if !log.is_empty() { + let _ = write!(out, "\n {log}"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + use serde_json::json; + + use crate::api::ApiClient; + + #[test] + fn list_renders_a_table() { + let v = json!([ + {"id": "a_1", "name": "smoke", "status": "running", "image": "ghcr.io/x:1", "createdAt": "2026-07-01"} + ]); + let out = render_list(&v).unwrap(); + assert!(out.contains("STATUS")); + assert!(out.contains("running")); + assert!(out.contains("ghcr.io/x:1")); + } + + #[test] + fn app_renders_a_null_body_as_done() { + assert_eq!(render_app(&Value::Null).unwrap(), "done"); + } + + #[test] + fn remove_sends_confirm_in_the_body() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api/v1/fleet/a_1") + .json_body(json!({ "confirm": "smoke" })); + then.status(200) + .json_body(json!({"id": "a_1", "name": "smoke", "status": "removed"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = + block_on(client.delete_value("/api/v1/fleet/a_1", json!({ "confirm": "smoke" }))) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value["status"], "removed"); + } + + #[test] + fn deploy_posts_name_and_image() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/tenants/t_1/fleet") + .json_body(json!({ "name": "smoke", "image": "ghcr.io/x:1" })); + then.status(200) + .json_body(json!({"id": "a_1", "name": "smoke", "status": "placing"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = block_on(client.post_value( + "/api/v1/tenants/t_1/fleet", + json!({ "name": "smoke", "image": "ghcr.io/x:1" }), + )) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value["status"], "placing"); + } + + #[test] + fn list_gets_the_tenant_fleet() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants/t_1/fleet"); + then.status(200) + .json_body(json!([{"id": "a_1", "name": "smoke", "status": "running"}])); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = block_on(client.get_value("/api/v1/tenants/t_1/fleet")) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value[0]["status"], "running"); + } + + #[test] + fn backup_posts_to_the_backup_endpoint() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/api/v1/fleet/a_1/backup"); + then.status(200) + .json_body(json!({"id": "op_1", "kind": "backup", "status": "done"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = block_on(client.post_value("/api/v1/fleet/a_1/backup", json!({}))) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value["kind"], "backup"); + } + + #[test] + fn list_surfaces_an_api_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants/t_1/fleet"); + then.status(403).json_body(json!({"message": "forbidden"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/tenants/t_1/fleet")) + .unwrap() + .unwrap_err(); + assert!(matches!(err, crate::api::ApiError::Api { status: 403, .. })); + } + + #[test] + fn list_maps_a_missing_service_404() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants/t_1/fleet"); + then.status(404); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/tenants/t_1/fleet")) + .unwrap() + .unwrap_err(); + assert!(matches!(err, crate::api::ApiError::Api { status: 404, .. })); + } + + #[test] + fn fleet_envelope_snapshot() { + let data = json!({"id": "a_1", "name": "smoke", "status": "placing"}); + let env = crate::verbs::success_envelope(&data); + assert_eq!(env, json!({ "ok": true, "data": data })); + } +} diff --git a/src/verbs/mod.rs b/src/verbs/mod.rs new file mode 100644 index 0000000..daf40ff --- /dev/null +++ b/src/verbs/mod.rs @@ -0,0 +1,328 @@ +//! The governance verbs (spec 004): the CLI face over the control-plane API. +//! +//! Each verb resolves the base URL and stored token, calls one control-plane +//! endpoint through the spec 003 client, and renders the result two ways: the +//! stable `{ok, data|error}` JSON envelope for `--output json` (spec 004 §5.2, +//! the shared contract the MCP face reuses in spec 005), or an aligned human +//! table on a TTY. The JSON envelope is passthrough: the plane's payload is +//! wrapped, never reshaped, so the contract stays stable as fields grow. +//! +//! Submodules ([`tenants`], [`stamp`], [`fleet`]) hold one verb family each and +//! call the private helpers here directly (a child module sees its parent's +//! private items). + +pub mod fleet; +pub mod stamp; +pub mod tenants; + +use serde::Serialize; +use serde_json::Value; + +use crate::api::{self, ApiClient, ApiError}; +use crate::config::ResolvedConfig; +use crate::error::{AppError, AppResult, EXIT_OPERATIONAL}; +use crate::output::OutputFormat; + +/// The success/failure envelope both faces consume (spec 004 §5.2). `data` +/// borrows the passthrough value; exactly one of `data`/`error` is present. +#[derive(Serialize)] +struct Envelope<'a> { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// The `error` arm: the spec 003 taxonomy projected onto stable JSON fields. +#[derive(Serialize)] +struct ErrorBody { + kind: &'static str, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, +} + +/// Resolve the base URL and stored token, then build an API client: the shared +/// preamble every verb runs (it mirrors `whoami`). A missing token is an +/// operational failure (exit 1) with the login hint, not a usage error: the +/// command was invoked correctly, the plane precondition is just unmet. +fn client_for(resolved: &ResolvedConfig, debug: bool) -> AppResult { + let base_url = api::require_base_url(resolved)?; + let token = crate::auth::load_token(&base_url)?.ok_or_else(|| { + AppError::Operational(anyhow::anyhow!( + "not authenticated for {base_url}; run `stagecraft login`" + )) + })?; + Ok(ApiClient::new(base_url, Some(token), debug)?) +} + +/// Render a completed call: the passthrough envelope for JSON, a table for +/// human output. Delegates success to [`emit_ok`] and failure to [`emit_err`]. +fn render( + format: OutputFormat, + result: Result, + human: impl FnOnce(&Value) -> AppResult, +) -> AppResult<()> { + match result { + Ok(value) => emit_ok(format, &value, human), + Err(err) => Err(emit_err(format, err)), + } +} + +/// Emit a success: the `{ok:true,data}` envelope in JSON mode, the `human` +/// closure's table on a TTY. The closure is never called in JSON mode, so the +/// passthrough value is serialized untouched. +fn emit_ok( + format: OutputFormat, + value: &Value, + human: impl FnOnce(&Value) -> AppResult, +) -> AppResult<()> { + match format { + OutputFormat::Json => { + let env = Envelope { + ok: true, + data: Some(value), + error: None, + }; + println!("{}", to_pretty(&env)); + Ok(()) + } + OutputFormat::Human => { + println!("{}", human(value)?); + Ok(()) + } + } +} + +/// Turn an API failure into the error the caller returns to `main`. In JSON +/// mode it writes the `{ok:false,error}` envelope to stdout and returns +/// [`AppError::Rendered`] so `main` exits 1 without a second stderr line; in +/// human mode it returns the taxonomy error for `main` to print to stderr. +fn emit_err(format: OutputFormat, err: ApiError) -> AppError { + match format { + OutputFormat::Json => { + let env = Envelope { + ok: false, + data: None, + error: Some(ErrorBody { + kind: err.kind(), + message: err.to_string(), + status: err.status(), + }), + }; + println!("{}", to_pretty(&env)); + AppError::Rendered { + code: EXIT_OPERATIONAL, + } + } + OutputFormat::Human => AppError::from(err), + } +} + +/// Pretty-print an owned envelope. Serializing our own payload cannot fail in +/// practice, so a failure is a programming error, not an operational one. +fn to_pretty(value: &T) -> String { + serde_json::to_string_pretty(value).expect("serializing an owned envelope cannot fail") +} + +/// Interpret a passthrough value as a JSON array (list endpoints), or a decode +/// error naming what came back instead. Human path only; JSON passthrough never +/// calls this. +fn as_array(v: &Value) -> AppResult<&Vec> { + v.as_array().ok_or_else(|| { + AppError::Operational(anyhow::anyhow!( + "expected a JSON array from the control plane, got {}", + kind_of(v) + )) + }) +} + +/// A human name for a JSON value's shape, for decode-error messages. +fn kind_of(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + } +} + +/// One optional field of a JSON object rendered for a table cell: strings +/// verbatim, numbers and booleans stringified, anything absent or null as empty. +fn field(v: &Value, key: &str) -> String { + match v.get(key) { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(b)) => b.to_string(), + _ => String::new(), + } +} + +/// A required scalar field for human rendering. Absent or non-scalar means the +/// plane returned a record the CLI cannot read (spec 004 §5.3: stamp and fleet +/// records carry at least `id` and `status`): a decode error (exit 1) that +/// signals drift rather than rendering a silent blank. +fn require_field(v: &Value, key: &str) -> AppResult { + match v.get(key) { + Some(Value::String(s)) => Ok(s.clone()), + Some(Value::Number(n)) => Ok(n.to_string()), + Some(Value::Bool(b)) => Ok(b.to_string()), + _ => Err(AppError::Operational(anyhow::anyhow!( + "unexpected response from the control plane: missing `{key}`" + ))), + } +} + +/// Render an aligned text table: a header row over body rows, each column +/// padded to its widest cell (the last column is not padded). Callers handle +/// the empty case, so this is only given non-empty `rows`. +fn table(headers: &[&str], rows: &[Vec]) -> String { + let mut widths: Vec = headers.iter().map(|h| h.chars().count()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + if let Some(w) = widths.get_mut(i) { + *w = (*w).max(cell.chars().count()); + } + } + } + let render_row = |cells: &[String]| -> String { + cells + .iter() + .enumerate() + .map(|(i, cell)| { + let last = i + 1 == cells.len(); + let width = widths.get(i).copied().unwrap_or(0); + if last { + cell.clone() + } else { + format!("{cell:>() + .join(" ") + }; + + let header_cells: Vec = headers.iter().map(|h| (*h).to_string()).collect(); + let mut lines = vec![render_row(&header_cells)]; + for row in rows { + lines.push(render_row(row)); + } + lines.join("\n") +} + +/// Open `url` in the operator's default browser (the `install-url --open` +/// path). Best-effort and platform-specific; no new dependency is pulled in for +/// it (CLAUDE.md keeps the tree lean). The URL is already printed by the time +/// this runs, so a failed or non-zero launch warns on stderr rather than losing +/// the operator the URL or flipping the exit code. +fn open_in_browser(url: &str) { + match browser_command(url).status() { + Ok(status) if status.success() => {} + Ok(status) => eprintln!("warning: browser launcher exited with status {status}"), + Err(e) => eprintln!("warning: failed to launch a browser: {e}"), + } +} + +#[cfg(target_os = "macos")] +fn browser_command(url: &str) -> std::process::Command { + let mut command = std::process::Command::new("open"); + command.arg(url); + command +} + +#[cfg(target_os = "windows")] +fn browser_command(url: &str) -> std::process::Command { + let mut command = std::process::Command::new("cmd"); + // The empty "" is `start`'s title argument; without it a quoted URL would be + // taken as the window title. + command.args(["/C", "start", "", url]); + command +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn browser_command(url: &str) -> std::process::Command { + let mut command = std::process::Command::new("xdg-open"); + command.arg(url); + command +} + +/// Build the success envelope as a JSON value (the printed form minus +/// pretty-spacing), for per-verb snapshot tests locking the `{ok,data}` shape. +#[cfg(test)] +fn success_envelope(value: &Value) -> Value { + serde_json::to_value(Envelope { + ok: true, + data: Some(value), + error: None, + }) + .expect("serializing an owned envelope cannot fail") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn success_envelope_wraps_data_verbatim() { + let value = json!({"id": "t_1", "name": "Acme", "extra": [1, 2, 3]}); + let env = Envelope { + ok: true, + data: Some(&value), + error: None, + }; + let rendered = serde_json::to_value(&env).unwrap(); + assert_eq!(rendered["ok"], true); + assert_eq!(rendered["data"], value); + assert!(rendered.get("error").is_none(), "no error arm on success"); + } + + #[test] + fn error_envelope_carries_kind_and_status() { + let err = ApiError::Api { + status: 404, + message: "tenants not enabled on this control plane".to_string(), + }; + let env = Envelope { + ok: false, + data: None, + error: Some(ErrorBody { + kind: err.kind(), + message: err.to_string(), + status: err.status(), + }), + }; + let rendered = serde_json::to_value(&env).unwrap(); + assert_eq!(rendered["ok"], false); + assert!(rendered.get("data").is_none(), "no data arm on failure"); + assert_eq!(rendered["error"]["kind"], "api"); + assert_eq!(rendered["error"]["status"], 404); + } + + #[test] + fn table_pads_columns_and_leaves_last_ragged() { + let rows = vec![ + vec!["t_1".to_string(), "Acme".to_string()], + vec!["t_22".to_string(), "Beta".to_string()], + ]; + let out = table(&["ID", "NAME"], &rows); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "ID NAME"); + assert_eq!(lines[1], "t_1 Acme"); + assert_eq!(lines[2], "t_22 Beta"); + } + + #[test] + fn field_stringifies_scalars_and_blanks_the_rest() { + let v = json!({"s": "x", "n": 42, "b": true, "nil": null, "arr": [1]}); + assert_eq!(field(&v, "s"), "x"); + assert_eq!(field(&v, "n"), "42"); + assert_eq!(field(&v, "b"), "true"); + assert_eq!(field(&v, "nil"), ""); + assert_eq!(field(&v, "arr"), ""); + assert_eq!(field(&v, "absent"), ""); + } +} diff --git a/src/verbs/stamp.rs b/src/verbs/stamp.rs new file mode 100644 index 0000000..94121c9 --- /dev/null +++ b/src/verbs/stamp.rs @@ -0,0 +1,310 @@ +//! The `stamp` verb family (spec 004 §5.1): new, status (with `--watch`). + +use std::fmt::Write; +use std::time::Duration; + +use serde_json::{Map, Value}; + +use super::{client_for, emit_err, emit_ok, field, render, require_field}; +use crate::api::{block_on, ApiClient, ApiError}; +use crate::cli::Posture; +use crate::config::ResolvedConfig; +use crate::error::{AppError, AppResult, EXIT_OPERATIONAL}; +use crate::output::OutputFormat; + +/// First poll interval for `--watch`; it backs off toward [`WATCH_MAX`]. +const WATCH_INITIAL: Duration = Duration::from_secs(2); +/// The poll-interval ceiling for `--watch` (spec 004 §5.4). +const WATCH_MAX: Duration = Duration::from_secs(10); + +/// `stamp new` -> POST /api/v1/tenants/:id/stamps. `posture` is always sent +/// (the flag is required); `frontend` only when supplied. +#[allow(clippy::too_many_arguments)] +pub fn new( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + tenant: &str, + app: &str, + org: &str, + frontend: Option<&str>, + posture: Posture, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/tenants/{tenant}/stamps"); + + let mut body = Map::new(); + body.insert("appName".to_string(), Value::String(app.to_string())); + body.insert("targetOrg".to_string(), Value::String(org.to_string())); + body.insert( + "posture".to_string(), + Value::String(posture.as_wire().to_string()), + ); + if let Some(frontend) = frontend { + body.insert("frontend".to_string(), Value::String(frontend.to_string())); + } + + let result = block_on(client.post_value(&path, Value::Object(body)))?; + render(format, result, render_stamp) +} + +/// `stamp status [--watch]` -> GET /api/v1/stamps/:jobId. +pub fn status( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + job_id: &str, + watch: bool, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/stamps/{job_id}"); + if watch { + block_on(watch_loop(&client, &path, format))? + } else { + let result = block_on(client.get_value(&path))?; + render(format, result, render_stamp) + } +} + +/// A stamp job's terminal states (spec 005: queued|stamping|pushing|verifying +/// are transient; green|failed are terminal). +enum Terminal { + Green, + Failed, +} + +/// Classify a job status: `Some` at a terminal state, `None` while it is still +/// running (keep polling). +fn classify(status: &str) -> Option { + match status { + "green" => Some(Terminal::Green), + "failed" => Some(Terminal::Failed), + _ => None, + } +} + +/// Poll the status endpoint until the job settles, emitting each *changed* +/// state (a line for humans, an envelope for JSON) and backing the interval off +/// toward the cap. Green exits 0; failed returns [`AppError::Rendered`] (exit 1, +/// the failed state already on stdout). A transport error aborts through the +/// taxonomy after the client's GET retries are exhausted. +async fn watch_loop(client: &ApiClient, path: &str, format: OutputFormat) -> AppResult<()> { + let mut interval = WATCH_INITIAL; + let mut last_status: Option = None; + loop { + let value = match client.get_value(path).await { + Ok(value) => value, + Err(err) => return Err(emit_err(format, err)), + }; + // A response with no readable `status` is a shape the CLI cannot follow + // (spec 004 §5.3); abort as a decode error rather than poll forever. + let status = match value.get("status").and_then(Value::as_str) { + Some(status) => status.to_string(), + None => { + return Err(emit_err( + format, + ApiError::Decode("stamp response has no readable `status`".to_string()), + )) + } + }; + if last_status.as_deref() != Some(status.as_str()) { + emit_ok(format, &value, render_stamp)?; + last_status = Some(status.clone()); + } + match classify(&status) { + Some(Terminal::Green) => return Ok(()), + Some(Terminal::Failed) => { + return Err(AppError::Rendered { + code: EXIT_OPERATIONAL, + }) + } + None => {} + } + tokio::time::sleep(interval).await; + interval = (interval + interval / 2).min(WATCH_MAX); + } +} + +fn render_stamp(v: &Value) -> AppResult { + let mut out = String::new(); + let _ = write!( + out, + "job {} status: {}", + require_field(v, "id")?, + require_field(v, "status")? + ); + let app = field(v, "appName"); + if !app.is_empty() { + let org = field(v, "org"); + let _ = write!(out, " ({app} -> {org})"); + } + let cert = field(v, "certHash"); + if !cert.is_empty() { + let _ = write!(out, " cert: {cert}"); + } + let error = field(v, "error"); + if !error.is_empty() { + let _ = write!(out, "\n error: {error}"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + use serde_json::json; + + #[test] + fn classify_marks_only_terminal_states() { + assert!(matches!(classify("green"), Some(Terminal::Green))); + assert!(matches!(classify("failed"), Some(Terminal::Failed))); + assert!(classify("stamping").is_none()); + assert!(classify("queued").is_none()); + assert!(classify("verifying").is_none()); + } + + #[test] + fn render_stamp_summarizes_the_job() { + let v = json!({"id": "j_1", "status": "green", "appName": "smoke", "org": "acme-inc"}); + let out = render_stamp(&v).unwrap(); + assert!(out.contains("job j_1")); + assert!(out.contains("status: green")); + assert!(out.contains("smoke -> acme-inc")); + } + + #[test] + fn watch_exits_ok_on_a_terminal_green() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/stamps/j_1"); + then.status(200) + .json_body(json!({"id": "j_1", "status": "green"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let outcome = block_on(watch_loop( + &client, + "/api/v1/stamps/j_1", + OutputFormat::Human, + )) + .unwrap(); + assert!(outcome.is_ok(), "green must exit 0"); + } + + #[test] + fn watch_exits_one_on_a_terminal_failed() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/stamps/j_2"); + then.status(200) + .json_body(json!({"id": "j_2", "status": "failed", "error": "push rejected"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let outcome = block_on(watch_loop( + &client, + "/api/v1/stamps/j_2", + OutputFormat::Human, + )) + .unwrap(); + match outcome { + Err(AppError::Rendered { code }) => assert_eq!(code, EXIT_OPERATIONAL), + other => panic!("expected Rendered exit-1, got {other:?}"), + } + } + + #[test] + fn new_posts_appname_org_and_posture() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/tenants/t_1/stamps") + .json_body(json!({ + "appName": "smoke", + "targetOrg": "acme-inc", + "posture": "assisted" + })); + then.status(200) + .json_body(json!({"id": "j_1", "status": "queued"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = block_on(client.post_value( + "/api/v1/tenants/t_1/stamps", + json!({"appName": "smoke", "targetOrg": "acme-inc", "posture": "assisted"}), + )) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value["status"], "queued"); + } + + #[test] + fn status_surfaces_an_api_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/stamps/j_1"); + then.status(409).json_body(json!({"message": "job gone"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/stamps/j_1")) + .unwrap() + .unwrap_err(); + assert!(matches!(err, crate::api::ApiError::Api { status: 409, .. })); + } + + #[test] + fn status_maps_a_missing_service_404() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/stamps/j_1"); + then.status(404); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/stamps/j_1")) + .unwrap() + .unwrap_err(); + assert!(matches!(err, crate::api::ApiError::Api { status: 404, .. })); + } + + #[test] + fn render_stamp_errors_on_a_statusless_record() { + // Spec 004 §5.3: a stamp record must carry `id` and `status`; a shape + // missing them is a decode error, not a silent blank. + assert!(render_stamp(&json!({"appName": "smoke"})).is_err()); + } + + #[test] + fn watch_aborts_on_a_statusless_response() { + // A response with no `status` must abort (exit 1) rather than poll + // forever; the mock always returns the same statusless body. + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/stamps/j_9"); + then.status(200).json_body(json!({"id": "j_9"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let outcome = block_on(watch_loop( + &client, + "/api/v1/stamps/j_9", + OutputFormat::Human, + )) + .unwrap(); + assert!( + outcome.is_err(), + "a statusless response must not loop forever" + ); + } + + #[test] + fn stamp_envelope_snapshot() { + let data = json!({"id": "j_1", "status": "queued"}); + let env = crate::verbs::success_envelope(&data); + assert_eq!(env, json!({ "ok": true, "data": data })); + } +} diff --git a/src/verbs/tenants.rs b/src/verbs/tenants.rs new file mode 100644 index 0000000..e072bd1 --- /dev/null +++ b/src/verbs/tenants.rs @@ -0,0 +1,232 @@ +//! The `tenants` verb family (spec 004 §5.1): list, show, install-url. + +use std::fmt::Write; + +use serde_json::Value; + +use super::{as_array, client_for, emit_err, emit_ok, field, render, table}; +use crate::api::block_on; +use crate::config::ResolvedConfig; +use crate::error::AppResult; +use crate::output::OutputFormat; + +/// `tenants list` -> GET /api/v1/tenants. +pub fn list(resolved: &ResolvedConfig, format: OutputFormat, debug: bool) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let result = block_on(client.get_value("/api/v1/tenants"))?; + render(format, result, render_list) +} + +/// `tenants show ` -> GET /api/v1/tenants/:id (includes installations). +pub fn show( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + id: &str, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/tenants/{id}"); + let result = block_on(client.get_value(&path))?; + render(format, result, render_detail) +} + +/// `tenants install-url ` -> GET /api/v1/tenants/:id/github/install-url. +/// With `open`, the returned URL is launched in the default browser as well as +/// printed. The passthrough envelope is emitted either way. +pub fn install_url( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + id: &str, + open: bool, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let path = format!("/api/v1/tenants/{id}/github/install-url"); + match block_on(client.get_value(&path))? { + Ok(value) => { + // Print the URL (or envelope) first: it is the actual deliverable, so + // a best-effort browser launch never costs the operator the URL. + emit_ok(format, &value, render_install_url)?; + if open { + match value.get("url").and_then(Value::as_str) { + Some(url) => super::open_in_browser(url), + None => eprintln!("warning: the response has no install URL to open"), + } + } + Ok(()) + } + Err(err) => Err(emit_err(format, err)), + } +} + +fn render_list(v: &Value) -> AppResult { + let tenants = as_array(v)?; + if tenants.is_empty() { + return Ok("no tenants".to_string()); + } + let rows: Vec> = tenants + .iter() + .map(|t| vec![field(t, "id"), field(t, "name"), field(t, "createdAt")]) + .collect(); + Ok(table(&["ID", "NAME", "CREATED"], &rows)) +} + +fn render_detail(v: &Value) -> AppResult { + let mut out = String::new(); + let _ = write!(out, "id: {}", field(v, "id")); + let _ = write!(out, "\nname: {}", field(v, "name")); + let owner = field(v, "ownerUserId"); + if !owner.is_empty() { + let _ = write!(out, "\nowner: {owner}"); + } + let created = field(v, "createdAt"); + if !created.is_empty() { + let _ = write!(out, "\ncreated: {created}"); + } + + match v.get("installations").and_then(Value::as_array) { + Some(installs) if !installs.is_empty() => { + let rows: Vec> = installs + .iter() + .map(|i| { + vec![ + field(i, "id"), + field(i, "githubOrg"), + field(i, "installationId"), + field(i, "status"), + ] + }) + .collect(); + let _ = write!( + out, + "\n\ninstallations:\n{}", + table(&["ID", "ORG", "INSTALLATION", "STATUS"], &rows) + ); + } + _ => { + let _ = write!(out, "\n\ninstallations: none"); + } + } + Ok(out) +} + +fn render_install_url(v: &Value) -> AppResult { + match v.get("url").and_then(Value::as_str) { + Some(url) => Ok(url.to_string()), + None => Ok(v.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + use serde_json::json; + + use crate::api::ApiClient; + + #[test] + fn list_renders_a_table() { + let value = json!([ + {"id": "t_1", "name": "Acme", "createdAt": "2026-07-01"}, + {"id": "t_2", "name": "Beta", "createdAt": "2026-07-02"} + ]); + let out = render_list(&value).unwrap(); + assert!(out.contains("ID")); + assert!(out.contains("t_1")); + assert!(out.contains("Acme")); + } + + #[test] + fn list_empty_states_when_no_tenants() { + assert_eq!(render_list(&json!([])).unwrap(), "no tenants"); + } + + #[test] + fn detail_shows_installations() { + let value = json!({ + "id": "t_1", + "name": "Acme", + "ownerUserId": "u_1", + "createdAt": "2026-07-01", + "installations": [ + {"id": "i_1", "githubOrg": "acme-inc", "installationId": 125344051, "status": "active"} + ] + }); + let out = render_detail(&value).unwrap(); + assert!(out.contains("name: Acme")); + assert!(out.contains("acme-inc")); + assert!(out.contains("125344051")); + } + + #[test] + fn install_url_renders_bare_url_for_humans() { + let value = json!({"url": "https://github.com/apps/x/installations/new?state=abc"}); + assert_eq!( + render_install_url(&value).unwrap(), + "https://github.com/apps/x/installations/new?state=abc" + ); + } + + #[test] + fn list_get_hits_the_tenants_endpoint() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants"); + then.status(200) + .json_body(json!([{"id": "t_1", "name": "Acme"}])); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let value = block_on(client.get_value("/api/v1/tenants")) + .unwrap() + .unwrap(); + + mock.assert(); + assert_eq!(value[0]["id"], "t_1"); + } + + #[test] + fn list_surfaces_an_api_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants"); + then.status(403) + .json_body(json!({"message": "tenant suspended"})); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/tenants")) + .unwrap() + .unwrap_err(); + match err { + crate::api::ApiError::Api { status, message } => { + assert_eq!(status, 403); + assert_eq!(message, "tenant suspended"); + } + other => panic!("expected Api error, got {other:?}"), + } + } + + #[test] + fn show_maps_a_missing_service_404() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants/t_9"); + then.status(404); + }); + + let client = ApiClient::new(server.base_url(), Some("tok".into()), false).unwrap(); + let err = block_on(client.get_value("/api/v1/tenants/t_9")) + .unwrap() + .unwrap_err(); + assert!(matches!(err, crate::api::ApiError::Api { status: 404, .. })); + } + + #[test] + fn list_envelope_snapshot() { + let data = json!([{"id": "t_1", "name": "Acme"}]); + let env = crate::verbs::success_envelope(&data); + assert_eq!(env, json!({ "ok": true, "data": data })); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 4d07008..0091864 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -16,29 +16,84 @@ fn run(args: &[&str]) -> Output { #[test] fn stub_verb_exits_2_names_spec_and_keeps_stdout_clean() { - // One representative stub per still-unimplemented owning spec. login and - // whoami left this list when spec 003 implemented them. - for (args, spec) in [ - (["tenants", "list"].as_slice(), "004"), - (["stamp", "new"].as_slice(), "004"), - (["fleet", "remove"].as_slice(), "004"), - (["mcp"].as_slice(), "005"), + // `mcp` is the last stub: login/whoami left when spec 003 landed, and + // tenants/stamp/fleet left when spec 004 implemented them. + let out = run(&["mcp"]); + assert_eq!(out.status.code(), Some(2), "mcp should exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("005"), + "mcp stderr should name spec 005, got: {stderr}" + ); + assert!( + out.stdout.is_empty(), + "errors must not print to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); +} + +#[test] +fn governance_verbs_without_base_url_are_usage_errors() { + // The spec 004 verbs reach the base-URL guard before any network call; + // omitting it is misuse (exit 2), same contract as login/whoami. Each is + // given its required flags/args so clap does not reject it first. + for args in [ + ["tenants", "list"].as_slice(), + ["fleet", "list", "--tenant", "t_1"].as_slice(), + ["stamp", "status", "j_1"].as_slice(), ] { let out = run(args); assert_eq!(out.status.code(), Some(2), "args {args:?} should exit 2"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains(spec), - "args {args:?}: stderr should name spec {spec}, got: {stderr}" + stderr.contains("base URL"), + "args {args:?}: stderr should name the missing base URL, got: {stderr}" ); assert!( out.stdout.is_empty(), - "args {args:?}: errors must not print to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) + "args {args:?}: errors must not print to stdout" ); } } +#[test] +fn governance_verb_without_a_credential_exits_1_with_login_hint() { + // With a base URL but no stored token, a spec 004 verb short-circuits to the + // unauthenticated error before any network call (hermetic), like whoami. + let out = run(&[ + "tenants", + "list", + "--base-url", + "https://unconfigured.plane.invalid", + ]); + assert_eq!(out.status.code(), Some(1), "no credential is exit 1"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("stagecraft login"), + "stderr should hint at login, got: {stderr}" + ); + assert!(out.stdout.is_empty(), "errors must not print to stdout"); +} + +#[test] +fn stamp_new_requires_a_posture() { + // Posture is a required flag with no default (spec 004 §5.1): omitting it is + // a clap usage error (exit 2), so the CLI never invents a posture. + let out = run(&[ + "stamp", "new", "--tenant", "t_1", "--app", "x", "--org", "acme", + ]); + assert_eq!( + out.status.code(), + Some(2), + "missing --posture should exit 2" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("posture"), + "stderr should name the missing posture, got: {stderr}" + ); +} + #[test] fn auth_verbs_without_base_url_are_usage_errors() { // login and whoami need a control plane to talk to; omitting it is misuse