diff --git a/AGENTS.md b/AGENTS.md index 8fe6c9b..7865fa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,28 @@ Everything goes through the `Prompter` trait (`src/prompt.rs`), so tests can scr - **Color only through `src/style.rs`** (`style::wanted`, `paint`, `id`), only on a terminal, never with `NO_COLOR`. IDs are gray. - **Show chosen fields, not every stored attribute.** Records carry internal leftovers; `data_mode` was one. Human output lists chosen fields with labels (`product_lines`). `--json` carries everything. +### JSON output + +Scripts and AI agents use the CLI through `--json`, so it is a contract, not a second layout. + +- **Every command that reports information takes `--json`:** reads, writes (the resulting record), `auth status`. Exceptions, which already print machine output or none of their own: `creds` (`--format`), `exec` (its command's output), `auth token` (a bare token), `api` (the API's response) and `-i` modes (interactive; `list --json` is their counterpart). +- **One JSON document on stdout and nothing else there.** Progress and notes go to stderr. No color, no padding, no pager. +- **Field names are the API's** (`account_id`, `created_at` in RFC 3339), plus derived fields only where they spare scripts work (`"expired": true`). Never human renderings like "3 years ago". +- **Adding fields is fine. Renaming or removing one is a breaking change.** Human output may change freely. +- **Print through `output::json`.** Don't hand-roll `serde_json` printing in a command. +- **Errors under `--json` are JSON too**, on stderr: `{"error": {"code", "message", "status"?, "field_errors"?}}`, the API's error shape. `fail(e, json)` in `main.rs` does it, using `failure::classify`. A command passes whether `--json` was asked for. +- **Exit codes mean the same everywhere** (`failure::Failure::exit_code`): + + | Code | Meaning | + |------|---------| + | 0 | Success | + | 1 | Any other failure, including cancelling a prompt | + | 2 | Bad command-line usage (clap) | + | 3 | The API rejected the input (400, 409, 422): fix the named fields and retry | + | 4 | Not signed in, or not allowed (401, 403) | + | 5 | Not found (404) | + | 130 | Interrupted (Ctrl+C) | + ## Stored data - Don't break a stored format (cache entries, sessions, drafts) without a way to read the old one. diff --git a/README.md b/README.md index bb22f4b..4a247ce 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,12 @@ Credentials _default: expire 2026-10-13T18:30:00Z **How long until you must log in again** is set by the refresh token, which `creds` and API commands use to renew everything else, so the expiries of the ID token, access token and credentials don't force a login. source.coop's login (Ory) issues opaque refresh tokens, and its token response doesn't say how long they last: that's an Ory project setting, and each renewal starts the clock again. So `auth status` shows when the refresh token was last renewed, and when it expires if left unused: an estimate, to the day, assuming a 30-day lifetime (Ory's default). Set `SOURCE_REFRESH_TOKEN_LIFETIME` (e.g. `720h`, `14d`) if the server uses another; it only changes this estimate, since a login can't ask for a lifetime. Where a provider does give an expiry, that's shown instead. A login from before renewals were recorded shows just `yes` until its next renewal or `auth login`. The profile link is on the site the login was for (`https://staging.source.coop` for a staging login). +`--json` prints the same as data, for scripts and agents: times in RFC 3339 with an `expired` flag, the account's `profile_url`, and, when the provider gave no refresh-token expiry, `estimated_lapse_at` with the `assumed_lifetime` it was counted from. + +```bash +source-coop auth status --json | jq -r .login.refresh_token.issued_at +``` + The keyring can't list its entries, so `auth status` reports on one environment and role. Pass `--proxy-url` and `--role-arn` (or set `SOURCE_PROXY_URL` and `SOURCE_ROLE_ARN`) to check others. ### Setting credentials on the environment @@ -223,6 +229,30 @@ Use `--profile` to change the section name. > [!TIP] > The credentials are temporary; re-run after expiry (appending adds a duplicate section — AWS uses the last one, but prune stale sections occasionally). We recommend the utilizing `credential-process` in `~/.aws/config` rather storing temporary credentials in `~/.aws/credentials`. +### JSON output and exit codes + +Every command that reports information takes `--json`: `auth status`, and `product list`, `view`, `create`, `edit` and `delete` (which print the resulting record). Under `--json`, stdout holds one JSON document and nothing else, with the API's field names and RFC 3339 times; notes and progress go to stderr. Fields may be added over time, but not renamed or removed. + +A failure under `--json` is JSON too, on stderr, in the API's error shape. `field_errors` names what to fix when the API rejected the input: + +```json +{"error": {"code": "invalid_input", "message": "…", "status": 400, "field_errors": {"title": ["…"]}}} +``` + +Exit codes are the same for every command: + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Any other failure, including cancelling a prompt | +| 2 | Bad command-line usage | +| 3 | The API rejected the input (HTTP 400, 409, 422): fix the named fields and retry | +| 4 | Not signed in, or not allowed (HTTP 401, 403) | +| 5 | Not found (HTTP 404) | +| 130 | Interrupted (Ctrl+C) | + +`creds` (`--format`), `auth token` (a bare token) and `api` (the API's response) already print output for machines, so they don't take `--json`. + ## Managing products `source-coop product` lists, views, creates, edits and deletes products through the source.coop API (`/api/v1`), with the same rules as the web UI: the CLI checks nothing itself and shows the API's errors, field by field. diff --git a/src/api.rs b/src/api.rs index e20769a..0cd8df4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -59,7 +59,10 @@ impl fmt::Display for ApiError { impl From for String { fn from(e: ApiError) -> Self { - e.to_string() + let text = e.to_string(); + // So `--json` errors and exit codes can still give its code and fields. + crate::failure::remember(&e, &text); + text } } diff --git a/src/failure.rs b/src/failure.rs new file mode 100644 index 0000000..b137dcb --- /dev/null +++ b/src/failure.rs @@ -0,0 +1,180 @@ +//! What a command's failure means, for `--json` errors and exit codes: the +//! API's error code, status and field errors where the failure came from the +//! API, and a local code otherwise. +//! +//! ponytail: errors travel through the commands as `String`s, which drop the +//! API's structure. `remember` keeps the last API error as it's turned into +//! text, and `classify` finds it again in the text that reaches `fail`, even +//! once wrapped ("Couldn't get new credentials (…)", a saved-draft note). The +//! upgrade path is a typed error carried through `Result` instead. + +use crate::api::ApiError; +use std::cell::RefCell; +use std::collections::BTreeMap; + +/// A failure as scripts see it. +#[derive(Debug, Clone, PartialEq)] +pub struct Failure { + /// The API's error code, or a local one: `cancelled`, `interrupted`, + /// `not_signed_in`, `error`. + pub code: String, + /// The whole message, as a person would read it. + pub message: String, + pub status: Option, + pub field_errors: BTreeMap>, +} + +#[derive(Clone)] +struct Remembered { + text: String, + /// The API's own message, without the code and status `text` adds. + message: String, + code: Option, + status: Option, + field_errors: BTreeMap>, +} + +thread_local! { + static LAST: RefCell> = const { RefCell::new(None) }; +} + +/// Keep `e`'s structure, as it becomes `text`. +pub fn remember(e: &ApiError, text: &str) { + LAST.with(|last| { + *last.borrow_mut() = Some(Remembered { + text: text.to_string(), + message: e.message.clone(), + code: e.code.clone(), + status: e.status.map(|s| s.as_u16()), + field_errors: e.field_errors.clone(), + }) + }); +} + +/// What `e`, the text that reached `fail`, means. +pub fn classify(e: &str) -> Failure { + let local = |code: &str, status: Option| Failure { + code: code.into(), + message: e.to_string(), + status, + field_errors: BTreeMap::new(), + }; + if e == crate::prompt::CANCELLED { + return local("cancelled", None); + } + if e == crate::prompt::INTERRUPTED { + return local("interrupted", None); + } + if e.starts_with("Not signed in") { + return local("not_signed_in", None); + } + let api = LAST + .with(|last| last.borrow().clone()) + .filter(|r| e.contains(&r.text)); + match api { + Some(r) => Failure { + code: r.code.unwrap_or_else(|| "error".into()), + // The code, status and field errors have fields of their own. + message: e.replace(&r.text, &r.message), + status: r.status, + field_errors: r.field_errors, + }, + None => local("error", None), + } +} + +impl Failure { + /// The process exit code, the same for every command. + pub fn exit_code(&self) -> i32 { + match (self.code.as_str(), self.status) { + ("interrupted", _) => 130, + ("not_signed_in", _) | (_, Some(401 | 403)) => 4, + (_, Some(400 | 409 | 422)) => 3, + (_, Some(404)) => 5, + _ => 1, + } + } + + /// `{"error": {"code", "message", "status"?, "field_errors"?}}`, the API's + /// own error shape. + pub fn to_json(&self) -> serde_json::Value { + let mut error = serde_json::json!({"code": self.code, "message": self.message}); + if let Some(status) = self.status { + error["status"] = status.into(); + } + if !self.field_errors.is_empty() { + error["field_errors"] = serde_json::json!(self.field_errors); + } + serde_json::json!({ "error": error }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::StatusCode; + + fn api_error(status: u16, code: &str, fields: &[(&str, &str)]) -> String { + let body = serde_json::json!({"error": { + "code": code, "message": "Rejected", + "field_errors": fields.iter().map(|(f, m)| (f.to_string(), vec![m.to_string()])).collect::>(), + }}); + ApiError::from_response(StatusCode::from_u16(status).unwrap(), &body.to_string()).into() + } + + #[test] + fn an_api_error_keeps_its_code_fields_and_status_even_wrapped() { + let text = api_error(400, "invalid_input", &[("title", "must not be empty")]); + let wrapped = format!("{text}\nYour answers are saved: run the same command again"); + let f = classify(&wrapped); + assert_eq!(f.code, "invalid_input"); + assert_eq!(f.status, Some(400)); + assert_eq!(f.field_errors["title"], ["must not be empty"]); + // The API's message, without what the other fields carry, still wrapped. + assert_eq!( + f.message, + "Rejected\nYour answers are saved: run the same command again" + ); + assert_eq!(f.exit_code(), 3); + assert_eq!( + f.to_json(), + serde_json::json!({"error": {"code": "invalid_input", "message": f.message, + "status": 400, "field_errors": {"title": ["must not be empty"]}}}) + ); + } + + #[test] + fn exit_codes_say_what_kind_of_failure_it_was() { + assert_eq!( + classify(&api_error(401, "unauthenticated", &[])).exit_code(), + 4 + ); + assert_eq!(classify(&api_error(403, "forbidden", &[])).exit_code(), 4); + assert_eq!(classify(&api_error(409, "conflict", &[])).exit_code(), 3); + assert_eq!(classify(&api_error(404, "not_found", &[])).exit_code(), 5); + assert_eq!(classify(&api_error(500, "internal", &[])).exit_code(), 1); + assert_eq!( + classify("Not signed in: run 'source-coop auth login'.").exit_code(), + 4 + ); + assert_eq!(classify(crate::prompt::INTERRUPTED).exit_code(), 130); + let cancelled = classify(crate::prompt::CANCELLED); + assert_eq!( + (cancelled.code.as_str(), cancelled.exit_code()), + ("cancelled", 1) + ); + } + + #[test] + fn a_local_error_is_just_an_error() { + let f = classify("Couldn't read input.json: No such file"); + assert_eq!( + (f.code.as_str(), f.status, f.exit_code()), + ("error", None, 1) + ); + assert_eq!( + f.to_json(), + serde_json::json!({"error": {"code": "error", "message": "Couldn't read input.json: No such file"}}) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 7c3919c..e9e491d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod api; mod api_cmd; mod cache; mod draft; +mod failure; mod oidc; mod output; mod picker; @@ -136,6 +137,10 @@ struct StatusArgs { /// Role ARN whose cached credentials to report on #[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)] role_arn: String, + + /// Print the status as JSON + #[arg(long)] + json: bool, } #[derive(Parser)] @@ -228,11 +233,17 @@ enum OutputFormat { AwsCredentials, } -/// Report a command's error (see `prompt::report`) and exit 1. -fn fail(e: String) -> ! { - prompt::report(&e); - // 130 for Ctrl+C, as a shell reports a process interrupted by SIGINT. - std::process::exit(if e == prompt::INTERRUPTED { 130 } else { 1 }); +/// Report a command's error and exit with the code for its kind (see +/// `failure::Failure::exit_code`): as JSON on stderr when the command was +/// asked for `--json`, otherwise as `prompt::report` puts it. +fn fail(e: String, json: bool) -> ! { + let failure = failure::classify(&e); + if json { + eprintln!("{}", failure.to_json()); + } else { + prompt::report(&e); + } + std::process::exit(failure.exit_code()); } #[tokio::main] @@ -244,22 +255,23 @@ async fn main() { match cli.command { Commands::Auth(AuthCommand::Login(args)) | Commands::Login(args) => { if let Err(e) = run_login(args, verbose).await { - fail(e); + fail(e, false); } } Commands::Creds(args) => { if let Err(e) = run_creds(args, verbose).await { - fail(e); + fail(e, false); } } Commands::Auth(AuthCommand::Status(args)) => { + let json = args.json; if let Err(e) = run_status(args) { - fail(e); + fail(e, json); } } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { - fail(e); + fail(e, false); } } Commands::Api(args) => { @@ -269,10 +281,11 @@ async fn main() { Err(e) => Err(e), }; if let Err(e) = result { - fail(e); + fail(e, false); } } Commands::Product(args) => { + let json = args.command.wants_json(); let token = api_token(&args.proxy_url, verbose).await; let result = match api::Client::new(&args.api_url, token, verbose) { Ok(client) => { @@ -286,7 +299,7 @@ async fn main() { Err(e) => Err(e), }; if let Err(e) = result { - fail(e); + fail(e, json); } } } @@ -317,20 +330,35 @@ fn run_status(args: StatusArgs) -> Result<(), String> { let env = cache::env(&args.proxy_url)?; let session = cache::read_session(env.as_deref())?; let entry = cache::read_credentials(env.as_deref(), &args.role_arn)?; + let (session, creds) = (session.as_ref(), entry.as_ref().map(|e| &e.creds)); + let now = chrono::Utc::now().timestamp(); let lifetime = refresh_lifetime()?; - print!( - "{}", - status_report( + let lifetime = Some((lifetime.0, lifetime.1.as_str())); + if args.json { + output::json(&status_json( &args.proxy_url, env.as_deref(), &args.role_arn, - session.as_ref(), - entry.as_ref().map(|e| &e.creds), - chrono::Utc::now().timestamp(), - Some((lifetime.0, lifetime.1.as_str())), - style::wanted(std::io::IsTerminal::is_terminal(&std::io::stdout())), - ) - ); + session, + creds, + now, + lifetime, + )); + } else { + print!( + "{}", + status_report( + &args.proxy_url, + env.as_deref(), + &args.role_arn, + session, + creds, + now, + lifetime, + style::wanted(std::io::IsTerminal::is_terminal(&std::io::stdout())), + ) + ); + } Ok(()) } @@ -382,6 +410,79 @@ fn rfc3339(t: i64) -> Option { .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) } +/// `auth status --json`: what `status_report` shows, as data. Times are RFC +/// 3339 with an `expired` flag, as of `now`; absent ones are null. +fn status_json( + proxy_url: &str, + env: Option<&str>, + role_arn: &str, + session: Option<&session::Session>, + creds: Option<&sts::Credentials>, + now: i64, + refresh_lifetime: Option<(i64, &str)>, +) -> serde_json::Value { + use serde_json::{json, Value}; + let at = |t: Option| match t { + Some(t) => json!({"expires_at": rfc3339(t), "expired": t <= now}), + None => json!({"expires_at": null, "expired": null}), + }; + let login = session.map(|s| { + let id_exp = session::jwt_exp(&s.id_token); + let expired = s.refresh_token.is_none() && id_exp.is_some_and(|e| e <= now); + let estimate = match (&s.refresh_token, s.refresh_token_expires_at) { + (Some(_), None) => s + .refresh_token_issued_at + .zip(refresh_lifetime) + .map(|(issued, (secs, label))| (issued + secs, label)), + _ => None, + }; + json!({ + "state": if expired { "expired" } else { "logged_in" }, + "account": s.account.as_ref().map(|a| json!({ + "account_id": a.account_id, + "name": a.name, + "profile_url": format!("{}/{}", site(s), a.account_id), + })), + "refresh_token": { + "present": s.refresh_token.is_some(), + "issued_at": s.refresh_token_issued_at.and_then(rfc3339), + "expires_at": s.refresh_token_expires_at.and_then(rfc3339), + // When the provider gave no expiry: the last renewal plus the + // assumed lifetime, as `auth status` estimates it. + "estimated_lapse_at": estimate.and_then(|(at, _)| rfc3339(at)), + "assumed_lifetime": estimate.map(|(_, label)| label), + }, + "id_token": at(id_exp), + "access_token": match &s.access_token { + None => Value::Null, + Some(_) => { + let mut a = at(s.access_token_expires_at); + a["audience"] = json!(s.audience); + a + } + }, + }) + }); + let credentials = match creds { + None => json!({"role_arn": role_arn, "cached": false}), + Some(c) => { + let exp = chrono::DateTime::parse_from_rfc3339(&c.expiration).ok(); + json!({ + "role_arn": role_arn, + "cached": true, + "expires_at": c.expiration, + "expired": exp.map(|t| t.timestamp() <= now), + }) + } + }; + json!({ + "environment": {"name": env.unwrap_or("production"), "proxy_url": proxy_url}, + "logged_in": login.as_ref().is_some_and(|l| l["state"] == "logged_in"), + "login": login, + "credentials": credentials, + }) +} + /// `