Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ impl fmt::Display for ApiError {

impl From<ApiError> 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
}
}

Expand Down
180 changes: 180 additions & 0 deletions src/failure.rs
Original file line number Diff line number Diff line change
@@ -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<u16>,
pub field_errors: BTreeMap<String, Vec<String>>,
}

#[derive(Clone)]
struct Remembered {
text: String,
/// The API's own message, without the code and status `text` adds.
message: String,
code: Option<String>,
status: Option<u16>,
field_errors: BTreeMap<String, Vec<String>>,
}

thread_local! {
static LAST: RefCell<Option<Remembered>> = 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<u16>| 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::<BTreeMap<_, _>>(),
}});
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"}})
);
}
}
Loading
Loading