From 7e052700cb38e754666e01efb1eec1c9cf8a76ce Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 11:37:11 -0700 Subject: [PATCH 1/3] feat: shared foundation for API-backed commands, and the api command The reusable pieces auth, products and later commands build on: - api.rs: the source.coop API client (/api/v1), its errors and pages - failure.rs and fail(e, json): JSON errors under --json and one table of exit codes for every command (3 rejected input, 4 not signed in or not allowed, 5 not found, 130 interrupted, else 1) - output::json for --json output; style.rs for color (NO_COLOR, gray IDs) - prompt.rs (scriptable prompts, Esc/Ctrl+C, typed confirmation, an Esc-able line editor), picker.rs (the -i list and full screen) and draft.rs (answers kept when a send fails) - AGENTS.md (with CLAUDE.md importing it): the CLI's conventions, including the JSON-output rules And its first user, source-coop api, which sends any /api/v1 request as the signed-in user. Pieces no command uses yet are allowed as dead code until the commands that use them land. Access tokens issued without an API audience are no longer sent to the API. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 101 +++++++++ CLAUDE.md | 1 + Cargo.lock | 111 +++++++++- Cargo.toml | 2 + README.md | 37 ++++ src/api.rs | 308 ++++++++++++++++++++++++++++ src/api_cmd.rs | 193 +++++++++++++++++ src/cache.rs | 6 +- src/draft.rs | 171 ++++++++++++++++ src/failure.rs | 180 ++++++++++++++++ src/main.rs | 120 ++++++++++- src/output.rs | 9 + src/picker.rs | 529 +++++++++++++++++++++++++++++++++++++++++++++++ src/prompt.rs | 546 +++++++++++++++++++++++++++++++++++++++++++++++++ src/session.rs | 18 +- src/style.rs | 65 ++++++ 16 files changed, 2382 insertions(+), 15 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 src/api.rs create mode 100644 src/api_cmd.rs create mode 100644 src/draft.rs create mode 100644 src/failure.rs create mode 100644 src/picker.rs create mode 100644 src/prompt.rs create mode 100644 src/style.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7865fa1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# Working on source-coop-cli + +How this CLI behaves, so every command behaves the same way. Each rule names the code that already does it: use that rather than writing your own. `resource_commands_follow_the_cli_conventions` in `src/main.rs` checks the parts that can be checked mechanically. + +## Keep the README in sync + +`README.md` is the user guide, and it must describe the CLI as it is. Any change to what a user can see or do updates it **in the same commit**: + +- **Commands and flags:** new ones get an example. Changed or removed ones are changed or removed there too. +- **Behaviour:** prompts, keys, output formats and defaults. +- **Files and environment variables** it reads or writes. + +Before committing, read the README sections your change touches and fix anything now untrue, including text written before your change. The README isn't a changelog: describe how the CLI works now, not what changed. `every_command_is_in_the_readme` in `src/main.rs` fails if a command is missing from it, but it can't tell whether the description is right. That part is on you. + +## Commands + +- **One shape per resource.** A resource (`product`, and later accounts or members) has `list`, `view`, `create`, `edit` and `delete` under one noun. Name records as `ACCOUNT/PRODUCT`. +- **Signing in lives under `auth`:** `auth login | status | logout | token`. `creds` stays top-level because people's AWS configs run `credential_process = source-coop creds`. +- **Renaming a command keeps the old name working** as a hidden command that runs the same code (`#[command(hide = true)]`, like top-level `login`). Scripts, docs and habits use the old name. The README shows only the new one, with a note that the old still works. +- **`-i` wherever a record is named or listed** (everything but `create`, which prompts anyway). It opens the picker, or the record's screen when the record is named. With a name omitted, it picks one first. The test checks this. +- **`--json` on every read** (`list`, `view`), carrying the whole record. The test checks this. Writes take `--json` too. +- **Environments come from `--proxy-url` / `SOURCE_PROXY_URL`**, via `cache::env`. Every cached slot (login session, role credentials) is per environment. Production keeps the bare names so logins survive upgrades. + +## Input + +- **Precedence:** flags win over `-F/--from-file PATH` (`-` for stdin), which wins over prompts. +- **Prompt only when someone is there to answer:** `prompt::tty()` returns None without a terminal on stdin and stderr, or with `SOURCE_PROMPT_DISABLED` set. Then send what was given and let the API say what's missing. `delete` needs `--yes`. +- **The API is the only judge.** Don't validate locally. Show its field errors and ask again for just those fields (`send_until_accepted` / `reask` in `src/product.rs`). The one exception mirrors a server rule only to choose what to offer (`creatable_accounts`). +- **Defaults match the web UI's form**, for example a title made from the ID, and only the data connections and visibilities that are actually allowed. + +## Prompts and interactive flows + +Everything goes through the `Prompter` trait (`src/prompt.rs`), so tests can script it. + +- **Sign in first.** Any create, edit or delete that prompts calls `client.check_signed_in()` before its first question, so nobody fills in a form only to be refused. +- **Questions are steps you can go back through.** ↑ (or ← at the start of a line) in text, and ← in a list, returns to the question before, starting from the answer given there; later answers are kept. Use the `*_or_back` methods, and model new flows on `ask_create`'s `Step`/`Next` loop. Fields answered by a flag, file or draft are skipped in both directions. +- **Review before creating.** Once a flow has asked anything, show what it will send and ask to confirm (`confirm_or_back`, `review_lines`). ← goes back from there too. +- **Typed confirmation before anything irreversible:** `prompt::confirm_by_name`. +- **Backing out:** + - Esc (or `q` on a choice) is `prompt::CANCELLED`: nothing is changed. + - Ctrl+C is `prompt::INTERRUPTED`, exit 130. + - Neither prints "Error:". Report every error through `prompt::report` / `fail()` in `main.rs`, never with `eprintln!("Error: …")`. +- **Keep answers when a send fails.** Prompted sends go through `draft::Draft`: `resume()` before asking, `finish()` with the result. Drafts live in the OS state dir, not the cache. + +## The `-i` picker + +`src/picker.rs`, driven through `Prompter::browse` / `keypress`. + +- **Keys:** ↑/↓ (`k`/`j`), PgUp/PgDn and Home/End move. → or Enter opens. ← or Esc (`h`/`q`) goes back, and quits from the top. `/` filters, and searches the server where the listing supports it. `b` opens the record in the browser. +- **Changing things only from a record's screen.** `e` and `d` (edit, delete) work after opening a record, not from the list, so you see the whole record first. Shortcuts run the commands' own code (`act`), never a second implementation. +- **Lists page themselves** as you near the end (`Action::More`). There's no "Load more…" entry. +- **Full screen:** `-i` runs on the alternate screen (`picker::FullScreen`) and restores the terminal however it ends. Outcomes appear as notices on the redrawn screen, not printed and then wiped. + +## Output + +- **Data on stdout, messages on stderr**, so `url=$(source-coop product create …)` works. +- **For people on a terminal, for scripts when piped.** + - On a terminal, lists stream through the pager (`SOURCE_PAGER`, `PAGER`, then `less -FRX`; `--no-pager` turns it off). + - Piped output and `--json` are for scripts: keep them stable. +- **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. +- Secrets in files are created `0600` (`cache::write_file`). The login session's refresh token stays in the keyring. + +## Testing + +- **Before pushing:** `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, `cargo test --features staging`. +- **Interactive flows** are tested with the scripted prompter (`prompt::scripted::Script`) against `api_server()`, which mocks `/whoami`. Check the full `asked` list, so a new or reordered question shows up in review. +- **Never touch the real macOS keychain**, in tests or manual runs: a new build makes macOS ask for your password. Use made-up role names, a temp `HOME`, or a local stub API. +- **Never run a pager under a pty** unless it exits on its own (`PAGER='head -5'`) or with `--no-pager`, or the run hangs. + +## Git + +- Squash-merge with the PR title; release-please reads it. +- Use `--force-with-lease` pinned to the expected commit only for rebases. Otherwise push plainly. +- Read a pushed commit's hash from the push output before reporting it. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index a611e4f..342a1b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,6 +213,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -303,6 +316,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "digest" version = "0.10.7" @@ -346,6 +371,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -362,6 +393,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -891,6 +928,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1087,7 +1130,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1108,7 +1151,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1219,7 +1262,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1309,6 +1352,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1464,6 +1520,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -1509,6 +1571,8 @@ dependencies = [ "base64", "chrono", "clap", + "console", + "dialoguer", "dirs", "keyring", "open", @@ -1583,13 +1647,46 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1762,6 +1859,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4ecdc8f..3d6af0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ quick-xml = { version = "0.37", features = ["serialize"] } chrono = { version = "0.4", features = ["serde"] } dirs = "6" keyring = "3" +dialoguer = { version = "0.11", default-features = false, features = ["editor"] } +console = "0.15" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/README.md b/README.md index c3f8b33..9fd9476 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,43 @@ 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`. +### Any API request + +`source-coop api` sends any request to `/api/v1`, signed in as you, and prints the response: + +```bash +source-coop api products/my-org -X GET -F limit=5 +source-coop api products/my-org -f product_id=new -f title="New" -f description="" \ + -f visibility=public -f data_connection_id=my-connection +source-coop api products/my-org/new -X PATCH --input changes.json +``` + +`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given. A status other than success exits non-zero. + +Reading public data needs no credentials. Anything else acts as whoever ran `source-coop login`, which asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`. + +### JSON output and exit codes + +Commands that report information take `--json`. 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. `creds` (`--format`), `auth token` (a bare token) and `api` (the API's response) already print output for machines, so they don't take `--json`. + +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) | + ## Credential storage The CLI caches the login session (the Ory refresh, ID and access tokens) and the temporary STS credentials for each role, so that `creds` and API commands work without re-authenticating. The refresh token is kept in the session only: it rotates on use, so every role and every API call refreshes through it, one at a time. diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..7ac37dc --- /dev/null +++ b/src/api.rs @@ -0,0 +1,308 @@ +//! A thin client for source.coop's `/api/v1`. +//! +//! The CLI validates nothing itself: the API enforces the same rules as the web +//! UI, and this module's job is to send the request and turn the API's error +//! body — `{"error": {"code", "message", "field_errors"?}}` — into a message a +//! person can act on. + +use reqwest::{Method, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +/// The API's shared error shape. +#[derive(Debug, Deserialize)] +struct ErrorBody { + error: ApiErrorDetail, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorDetail { + code: String, + message: String, + #[serde(default)] + field_errors: BTreeMap>, +} + +/// A failed API call, with whatever the API said about why. +#[derive(Debug)] +pub struct ApiError { + pub status: Option, + pub code: Option, + pub message: String, + pub field_errors: BTreeMap>, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match (&self.code, self.status) { + (Some(code), Some(status)) => { + write!(f, "{} ({code}, HTTP {})", self.message, status.as_u16())? + } + (None, Some(status)) => write!(f, "{} (HTTP {})", self.message, status.as_u16())?, + _ => write!(f, "{}", self.message)?, + } + for (field, problems) in &self.field_errors { + for problem in problems { + write!(f, "\n {field}: {problem}")?; + } + } + if self.status == Some(StatusCode::UNAUTHORIZED) { + write!(f, "\nThis needs you signed in: run 'source-coop login'.")?; + } + Ok(()) + } +} + +impl From for String { + fn from(e: ApiError) -> Self { + let text = e.to_string(); + // So `--json` errors and exit codes can still give its code and fields. + crate::failure::remember(&e, &text); + text + } +} + +impl ApiError { + /// A request that never got a response. reqwest's own message names only + /// the URL; the cause (DNS, TLS, a refused connection) is further down the + /// chain, so all of it is shown. + fn transport(e: &reqwest::Error) -> Self { + let mut message = format!("API request failed: {e}"); + let mut source = std::error::Error::source(e); + while let Some(cause) = source { + message.push_str(&format!(": {cause}")); + source = cause.source(); + } + Self::other(message) + } + + fn other(message: String) -> Self { + ApiError { + status: None, + code: None, + message, + field_errors: BTreeMap::new(), + } + } + + /// Build the error from a non-2xx response body, which is the shared error + /// shape when the API produced it and anything at all when something in + /// front of the API did. + pub fn from_response(status: StatusCode, body: &str) -> Self { + match serde_json::from_str::(body) { + Ok(ErrorBody { error }) => ApiError { + status: Some(status), + code: Some(error.code), + message: error.message, + field_errors: error.field_errors, + }, + Err(_) => ApiError { + status: Some(status), + code: None, + message: status + .canonical_reason() + .unwrap_or("Unexpected response") + .to_string(), + field_errors: BTreeMap::new(), + }, + } + } +} + +pub struct Client { + base: url::Url, + token: Option, + http: reqwest::Client, + verbose: bool, +} + +impl Client { + /// `api_url` is the site's origin (e.g. `https://source.coop`); requests go + /// to `{api_url}/api/v1/...`. + pub fn new(api_url: &str, token: Option, verbose: bool) -> Result { + let mut base = url::Url::parse(api_url).map_err(|e| format!("Invalid API URL: {e}"))?; + base.set_path("/api/v1/"); + Ok(Client { + base, + token: token.filter(|t| !t.trim().is_empty()), + http: reqwest::Client::new(), + verbose, + }) + } + + /// The URL for `segments` under `/api/v1/`, each one percent-encoded. + pub fn url(&self, segments: &[&str]) -> url::Url { + let mut url = self.base.clone(); + url.path_segments_mut() + .expect("an http(s) URL has path segments") + .pop_if_empty() + .extend(segments); + url + } + + /// Send a request and return the status and body as they came, whatever + /// the status. Only a request that gets no response at all is an error. + pub async fn send( + &self, + method: Method, + url: url::Url, + body: Option<&impl Serialize>, + ) -> Result<(StatusCode, String), ApiError> { + if self.verbose { + let auth = if self.token.is_some() { + " (bearer)" + } else { + "" + }; + eprintln!("[verbose] {method} {url}{auth}"); + } + let mut req = self.http.request(method, url); + if let Some(token) = &self.token { + req = req.bearer_auth(token); + } + if let Some(body) = body { + req = req.json(body); + } + let resp = req.send().await.map_err(|e| ApiError::transport(&e))?; + let status = resp.status(); + let text = resp.text().await.map_err(|e| ApiError::transport(&e))?; + if self.verbose { + eprintln!("[verbose] -> HTTP {}", status.as_u16()); + } + Ok((status, text)) + } + + /// Send a request and parse a successful response; any other status is an + /// error carrying what the API said. + pub async fn request( + &self, + method: Method, + url: url::Url, + body: Option<&impl Serialize>, + ) -> Result { + let (status, text) = self.send(method, url, body).await?; + if !status.is_success() { + return Err(ApiError::from_response(status, &text)); + } + serde_json::from_str(&text) + .map_err(|e| ApiError::other(format!("API response unreadable: {e}"))) + } + + /// Resolve a path the way `source-coop api` takes one: relative to + /// `/api/v1/`, with or without a leading `/` or `api/v1/`, and with any + /// query string kept. + pub fn resolve(&self, path: &str) -> Result { + let path = path.trim_start_matches('/'); + let path = path.strip_prefix("api/v1/").unwrap_or(path); + let url = self + .base + .join(path) + .map_err(|e| format!("Invalid API path '{path}': {e}"))?; + // A full URL joins as itself, and the bearer must not go elsewhere. + if url.origin() != self.base.origin() { + return Err(format!( + "'{path}' isn't on the API at {}; give a path under /api/v1", + self.base.origin().ascii_serialization() + )); + } + Ok(url) + } + + pub async fn get(&self, url: url::Url) -> Result { + self.request(Method::GET, url, None::<&()>).await + } + + /// Fail before anyone is asked anything if changes can't be made as them: + /// no token, or one the API refuses (expired, or for another audience). + /// Returns the `/whoami` session, which says who they are and what they're + /// a member of. + pub async fn check_signed_in(&self) -> Result { + if self.token.is_none() { + return Err(ApiError::other( + "Not signed in: run 'source-coop login'.".into(), + )); + } + self.get(self.url(&["whoami"])).await + } +} + +/// One page of a listing. +#[derive(Debug, Deserialize)] +pub struct Page { + pub items: Vec, + pub next_cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_encodes_segments_under_api_v1() { + let c = Client::new("https://source.coop", None, false).unwrap(); + assert_eq!( + c.url(&["products", "acct", "a b"]).as_str(), + "https://source.coop/api/v1/products/acct/a%20b" + ); + let c = Client::new("http://localhost:3000/ignored", None, false).unwrap(); + assert_eq!( + c.url(&["products"]).as_str(), + "http://localhost:3000/api/v1/products" + ); + } + + #[test] + fn resolves_api_paths() { + let c = Client::new("https://source.coop", None, false).unwrap(); + for p in [ + "products/acct?limit=2", + "/products/acct?limit=2", + "/api/v1/products/acct?limit=2", + ] { + assert_eq!( + c.resolve(p).unwrap().as_str(), + "https://source.coop/api/v1/products/acct?limit=2" + ); + } + // The token is only ever sent to the API's own origin. + assert!(c.resolve("https://example.com/steal").is_err()); + } + + #[tokio::test] + async fn a_failed_connection_says_why() { + // Nothing listens on port 9 (discard) on loopback. + let c = Client::new("http://127.0.0.1:9", None, false).unwrap(); + let e = c.get::<()>(c.url(&["products"])).await.unwrap_err(); + let msg = e.to_string(); + assert!( + msg.starts_with("API request failed: error sending request"), + "{msg}" + ); + assert!(msg.matches(": ").count() >= 2, "no cause in: {msg}"); + } + + #[test] + fn shows_field_errors_from_the_api() { + let body = r#"{"error":{"code":"invalid","message":"The request is invalid.", + "field_errors":{"title":["A title is required"]}}}"#; + let e = ApiError::from_response(StatusCode::BAD_REQUEST, body); + assert_eq!( + e.to_string(), + "The request is invalid. (invalid, HTTP 400)\n title: A title is required" + ); + } + + #[test] + fn survives_a_body_that_is_not_the_error_shape() { + let e = ApiError::from_response(StatusCode::BAD_GATEWAY, "oops"); + assert_eq!(e.to_string(), "Bad Gateway (HTTP 502)"); + } + + #[test] + fn says_how_to_authenticate_on_401() { + let body = r#"{"error":{"code":"unauthenticated","message":"Sign in first."}}"#; + let e = ApiError::from_response(StatusCode::UNAUTHORIZED, body); + assert!(e.to_string().contains("source-coop login")); + } +} diff --git a/src/api_cmd.rs b/src/api_cmd.rs new file mode 100644 index 0000000..ae1326d --- /dev/null +++ b/src/api_cmd.rs @@ -0,0 +1,193 @@ +//! `source-coop api`: any `/api/v1` request, authenticated as whoever ran +//! `login`, for whatever the other commands don't cover yet. Modeled on +//! `gh api`. + +use crate::api::{ApiError, Client}; +use clap::Args; +use reqwest::Method; +use serde_json::{Map, Value}; +use std::io::Read; + +#[derive(Args)] +pub struct ApiArgs { + /// The path under /api/v1, e.g. `products/my-org` (a query string is kept) + path: String, + + /// HTTP method; GET, or POST when fields or --input are given + #[arg(long, short = 'X')] + method: Option, + + /// A field, `key=value`, sent as a string; in the query string for GET + #[arg(long = "raw-field", short = 'f', value_name = "KEY=VALUE")] + raw_fields: Vec, + + /// A field, `key=value`, where `true`, `false`, `null` and numbers are + /// sent as JSON rather than strings + #[arg(long = "field", short = 'F', value_name = "KEY=VALUE")] + fields: Vec, + + /// Send this file's JSON as the request body (`-` for stdin) + #[arg(long, value_name = "PATH", conflicts_with_all = ["raw_fields", "fields"])] + input: Option, +} + +/// Prints the response body (pretty if JSON) to stdout, and fails on any +/// status that isn't a success, as `gh api` does, so scripts can check `$?`. +pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> { + let mut url = client.resolve(&args.path)?; + let fields = parse_fields(&args.raw_fields, &args.fields)?; + let body = match &args.input { + Some(path) => Some(read_json(path)?), + None => None, + }; + let method = match &args.method { + Some(m) => m + .to_uppercase() + .parse::() + .map_err(|_| format!("Invalid method '{m}'"))?, + None if body.is_some() || !fields.is_empty() => Method::POST, + None => Method::GET, + }; + + let body = if method == Method::GET { + let mut q = url.query_pairs_mut(); + for (k, v) in &fields { + q.append_pair(k, &v.as_str().map_or_else(|| v.to_string(), String::from)); + } + drop(q); + body + } else if fields.is_empty() { + body + } else { + Some(Value::Object(fields)) + }; + + let (status, text) = client.send(method, url, body.as_ref()).await?; + match serde_json::from_str::(&text) { + Ok(json) => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + Err(_) if text.is_empty() => {} + Err(_) => println!("{text}"), + } + if status.is_success() { + Ok(()) + } else { + Err(ApiError::from_response(status, &text).to_string()) + } +} + +/// `-f` values are strings; `-F` values are JSON when they parse as a +/// boolean, null or number. +fn parse_fields(raw: &[String], typed: &[String]) -> Result, String> { + let mut fields = Map::new(); + let split = |f: &str| { + f.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + .ok_or_else(|| format!("Expected KEY=VALUE, got '{f}'")) + }; + for f in raw { + let (k, v) = split(f)?; + fields.insert(k, Value::String(v)); + } + for f in typed { + let (k, v) = split(f)?; + let value = match serde_json::from_str::(&v) { + Ok(j @ (Value::Bool(_) | Value::Null | Value::Number(_))) => j, + _ => Value::String(v), + }; + fields.insert(k, value); + } + Ok(fields) +} + +/// The JSON in `path` (`-` for stdin). +pub(crate) fn read_json(path: &str) -> Result { + let text = if path == "-" { + let mut s = String::new(); + std::io::stdin() + .read_to_string(&mut s) + .map_err(|e| format!("Couldn't read stdin: {e}"))?; + s + } else { + std::fs::read_to_string(path).map_err(|e| format!("Couldn't read {path}: {e}"))? + }; + serde_json::from_str(&text).map_err(|e| format!("{path} isn't JSON: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{body_json, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn args(path: &str) -> ApiArgs { + ApiArgs { + path: path.into(), + method: None, + raw_fields: vec![], + fields: vec![], + input: None, + } + } + + #[test] + fn typed_fields_become_json() { + let f = parse_fields( + &["n=5".into()], + &["a=true".into(), "b=null".into(), "c=7".into(), "d=x".into()], + ) + .unwrap(); + assert_eq!( + Value::Object(f), + json!({"n": "5", "a": true, "b": null, "c": 7, "d": "x"}) + ); + assert!(parse_fields(&["nokey".into()], &[]).is_err()); + } + + #[tokio::test] + async fn get_puts_fields_in_the_query() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/v1/products/acct")) + .and(query_param("limit", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": []}))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let mut a = args("/products/acct"); + a.method = Some("get".into()); + a.fields = vec!["limit=2".into()]; + run(a, &client).await.unwrap(); + } + + #[tokio::test] + async fn fields_default_to_a_post_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/v1/products/acct")) + .and(body_json(json!({"product_id": "p", "title": "T"}))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let mut a = args("products/acct"); + a.raw_fields = vec!["product_id=p".into(), "title=T".into()]; + run(a, &client).await.unwrap(); + } + + #[tokio::test] + async fn an_error_status_fails_with_the_apis_message() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404).set_body_json( + json!({"error": {"code": "not_found", "message": "No such product."}}), + )) + .mount(&server) + .await; + let client = Client::new(&server.uri(), None, false).unwrap(); + let err = run(args("products/acct/nope"), &client).await.unwrap_err(); + assert!(err.contains("No such product. (not_found, HTTP 404)")); + } +} diff --git a/src/cache.rs b/src/cache.rs index 792393f..ee17818 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -73,7 +73,7 @@ struct Slot { /// The OS-idiomatic cache directory (`~/Library/Caches` on macOS, `~/.cache` /// on Linux, `%LocalAppData%` on Windows). -fn cache_dir() -> Result { +pub(crate) fn cache_dir() -> Result { Ok(dirs::cache_dir() .ok_or("Could not determine cache directory")? .join("source-coop")) @@ -156,7 +156,7 @@ fn is_keyring_unavailable(err: &keyring::Error) -> bool { } /// Replace any character that isn't alphanumeric or in `keep` with `_`. -fn sanitize(s: &str, keep: &[char]) -> String { +pub(crate) fn sanitize(s: &str, keep: &[char]) -> String { s.chars() .map(|c| { if c.is_ascii_alphanumeric() || keep.contains(&c) { @@ -275,7 +275,7 @@ fn read_slot(slot: &Slot) -> Result, String> { } /// Write a cache file readable only by its owner. Returns the file path. -fn write_file(path: &Path, json: &str) -> Result { +pub(crate) fn write_file(path: &Path, json: &str) -> Result { let dir = path.parent().unwrap(); fs::create_dir_all(dir) .map_err(|e| format!("Failed to create cache directory {}: {e}", dir.display()))?; diff --git a/src/draft.rs b/src/draft.rs new file mode 100644 index 0000000..59a6f8f --- /dev/null +++ b/src/draft.rs @@ -0,0 +1,171 @@ +//! Answers someone typed for a create or edit, kept when sending them fails so +//! nothing has to be typed again. Any command that asks for fields and then +//! sends them can use one: `resume` before asking, `finish` with the result. +//! +//! Drafts are the person's own work, so they live in the OS's place for app +//! state rather than its cache, which the OS or a cleaner may empty: +//! `$XDG_STATE_HOME` (`~/.local/state`) on Linux, `~/Library/Application +//! Support` on macOS, `%LocalAppData%` on Windows. + +use crate::cache; +use crate::prompt::Prompter; +use serde_json::{Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct Draft { + path: PathBuf, +} + +fn dir() -> Result { + // Tests never touch the person's real drafts, nor each other's: each test + // runs on its own thread, and many name the same product. + if cfg!(test) { + return Ok(std::env::temp_dir().join(format!( + "source-coop-drafts-{}-{:?}", + std::process::id(), + std::thread::current().id() + ))); + } + Ok(dirs::state_dir() + .or_else(dirs::data_local_dir) + .ok_or("Could not determine a directory for drafts")? + .join("source-coop") + .join("drafts")) +} + +impl Draft { + /// The draft for `key`, one per thing being created or edited, e.g. + /// `product-create/acct/prod`. + pub fn for_key(key: &str) -> Result { + let name = cache::sanitize(key, &['-', '_', '.']); + Ok(Draft { + path: dir()?.join(format!("{name}.json")), + }) + } + + /// Offer the answers saved by a failed try. Taken, they fill in whatever + /// `body` doesn't already hold, so flags and --from-file still win; + /// declined, the draft is dropped. Returns whether they were taken. + pub fn resume( + &self, + ask: &mut dyn Prompter, + body: &mut Map, + ) -> Result { + let Some(saved) = self.load() else { + return Ok(false); + }; + if !ask.confirm("Use the answers saved from your last try?", true)? { + self.discard(); + return Ok(false); + } + for (field, value) in saved { + body.entry(field).or_insert(value); + } + Ok(true) + } + + /// Drop the draft once `result` succeeded. When it failed, save `body` so + /// the next run can offer it, and say so in the error. + pub fn finish( + &self, + result: Result, + body: &Map, + ) -> Result { + let e = match result { + Ok(value) => { + self.discard(); + return Ok(value); + } + Err(e) => e, + }; + let json = serde_json::to_string_pretty(body).expect("a JSON object serializes"); + Err(match cache::write_file(&self.path, &json) { + Ok(path) => format!( + "{e}\nYour answers are saved: run the same command again to use them, or pass --from-file {path}" + ), + Err(save) => format!("{e}\nYour answers couldn't be saved: {save}"), + }) + } + + /// After a send that asked nothing succeeded: if it was sent from this + /// draft's own file (`--from-file`, as `finish` suggests), it's used up. + pub fn sent_from(&self, from_file: Option<&str>) { + let canon = |p: &Path| fs::canonicalize(p).ok(); + let same = from_file + .and_then(|f| canon(Path::new(f))) + .is_some_and(|f| Some(f) == canon(&self.path)); + if same { + self.discard(); + } + } + + fn load(&self) -> Option> { + match serde_json::from_str(&fs::read_to_string(&self.path).ok()?) { + Ok(Value::Object(fields)) => Some(fields), + _ => None, + } + } + + fn discard(&self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::prompt::scripted::{Answer::*, Script}; + use serde_json::json; + + fn body(v: Value) -> Map { + v.as_object().unwrap().clone() + } + + #[test] + fn a_failed_send_keeps_the_answers_for_the_next_try() { + let draft = Draft::for_key("test/kept").unwrap(); + let typed = body(json!({"title": "Typed", "description": "Long text"})); + let err = draft + .finish::<()>(Err("Authentication required".into()), &typed) + .unwrap_err(); + assert!(err.starts_with("Authentication required\nYour answers are saved")); + + // Next try: a flag's value wins over the saved one, the rest is resumed. + let mut next = body(json!({"title": "From a flag"})); + let mut ask = Script::new([Yes(true)]); + assert!(draft.resume(&mut ask, &mut next).unwrap()); + assert_eq!( + Value::Object(next), + json!({"title": "From a flag", "description": "Long text"}) + ); + + draft.finish(Ok(()), &Map::new()).unwrap(); + assert!(draft.load().is_none(), "success drops the draft"); + } + + #[test] + fn sending_the_drafts_own_file_uses_it_up() { + let draft = Draft::for_key("test/from-file").unwrap(); + let _ = draft.finish::<()>(Err("boom".into()), &body(json!({"title": "T"}))); + draft.sent_from(Some("some-other-file.json")); + draft.sent_from(None); + assert!(draft.load().is_some(), "another file leaves the draft"); + draft.sent_from(draft.path.to_str()); + assert!(draft.load().is_none()); + } + + #[test] + fn declining_a_draft_drops_it_and_no_draft_asks_nothing() { + let draft = Draft::for_key("test/declined").unwrap(); + let _ = draft.finish::<()>(Err("boom".into()), &body(json!({"title": "T"}))); + + let mut fresh = Map::new(); + assert!(!draft + .resume(&mut Script::new([Yes(false)]), &mut fresh) + .unwrap()); + assert!(fresh.is_empty()); + // Gone now, so nothing is offered (an unscripted prompt would panic). + assert!(!draft.resume(&mut Script::new([]), &mut fresh).unwrap()); + } +} diff --git a/src/failure.rs b/src/failure.rs new file mode 100644 index 0000000..f84dbb6 --- /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 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 4612ecb..22de77d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,24 @@ +mod api_cmd; mod cache; +mod failure; mod oidc; -mod output; mod session; mod sts; +// Reusable pieces for the commands built on them (resources such as +// products, and `auth`). Parts are unused until those land; each command PR +// removes the allowances it no longer needs. +#[allow(dead_code)] +mod api; +#[allow(dead_code)] +mod draft; +#[allow(dead_code)] +mod output; +#[allow(dead_code)] +mod picker; +#[allow(dead_code)] +mod prompt; +#[allow(dead_code)] +mod style; use clap::{Parser, Subcommand, ValueEnum}; @@ -65,6 +81,22 @@ enum Commands { /// Work with the login session #[command(subcommand)] Auth(AuthCommand), + /// Make any request to the source.coop API, signed in as you + Api(ApiCommandArgs), +} + +#[derive(Parser)] +struct ApiCommandArgs { + /// source.coop site URL; the API is served under its `/api/v1` + #[arg(long, env = "SOURCE_API_URL", default_value = defaults::API_URL)] + api_url: String, + + /// S3 proxy URL of the environment whose login session to use + #[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)] + proxy_url: String, + + #[command(flatten)] + request: api_cmd::ApiArgs, } #[derive(Subcommand)] @@ -159,6 +191,19 @@ enum OutputFormat { AwsCredentials, } +/// 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] async fn main() { let cli = Cli::parse(); @@ -168,25 +213,56 @@ async fn main() { match cli.command { Commands::Login(args) => { if let Err(e) = run_login(args, verbose).await { - eprintln!("Error: {e}"); - std::process::exit(1); + fail(e, false); } } Commands::Creds(args) => { if let Err(e) = run_creds(args, verbose).await { - eprintln!("Error: {e}"); - std::process::exit(1); + fail(e, false); } } Commands::Auth(AuthCommand::Token(args)) => { if let Err(e) = run_auth_token(args, verbose).await { - eprintln!("Error: {e}"); - std::process::exit(1); + fail(e, false); + } + } + Commands::Api(args) => { + let token = api_token(&args.proxy_url, verbose).await; + let result = match api::Client::new(&args.api_url, token, verbose) { + Ok(client) => api_cmd::run(args.request, &client).await, + Err(e) => Err(e), + }; + if let Err(e) = result { + fail(e, false); } } } } +/// The bearer for the source.coop API: `SOURCE_TOKEN` if set (for a token +/// obtained some other way), else the login session's access token. Without +/// either, commands run signed out, which is enough to read public data. +async fn api_token(proxy_url: &str, verbose: bool) -> Option { + // Set but empty is unset, so `SOURCE_TOKEN=` doesn't skip the session. + if let Some(token) = std::env::var("SOURCE_TOKEN") + .ok() + .filter(|t| !t.trim().is_empty()) + { + return Some(token); + } + let token = match cache::env(proxy_url) { + Ok(env) => session::token(env.as_deref(), session::Want::Access, verbose).await, + Err(e) => Err(e), + }; + match token { + Ok(token) => token, + Err(e) => { + eprintln!("Warning: continuing signed out: {e}"); + None + } + } +} + async fn run_auth_token(args: TokenArgs, verbose: bool) -> Result<(), String> { let env = cache::env(&args.proxy_url)?; let token = session::token(env.as_deref(), session::Want::Access, verbose) @@ -384,9 +460,39 @@ async fn refresh( #[cfg(test)] mod tests { use super::*; + use clap::CommandFactory; use wiremock::matchers::{body_string_contains, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; + /// Every runnable command, as its words after `source-coop`. + fn commands(cmd: &clap::Command, path: &str, out: &mut Vec) { + // Hidden ones are old names kept working, documented under the new. + for sub in cmd + .get_subcommands() + .filter(|s| s.get_name() != "help" && !s.is_hide_set()) + { + let words = format!("{path} {}", sub.get_name()).trim().to_string(); + if sub.has_subcommands() { + commands(sub, &words, out); + } else { + out.push(words); + } + } + } + + #[test] + fn every_command_is_in_the_readme() { + let readme = include_str!("../README.md"); + let mut all = vec![]; + commands(&Cli::command(), "", &mut all); + let missing: Vec<_> = all + .iter() + .filter(|c| !readme.contains(&format!("source-coop {c}"))) + .collect(); + assert!(all.len() >= 4, "found too few commands: {all:?}"); + assert!(missing.is_empty(), "not in README.md: {missing:?}"); + } + const STS_OK: &str = "\ NEWKEYNEWSECRET\ NEWSESSION2099-01-01T00:00:00Z\ diff --git a/src/output.rs b/src/output.rs index 1863d05..e69cb6e 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,5 +1,14 @@ use crate::sts::Credentials; +/// Print a command's `--json` output: one document on stdout and nothing +/// else there (see AGENTS.md, "JSON output"). +pub fn json(value: &impl serde::Serialize) { + println!( + "{}", + serde_json::to_string_pretty(value).expect("JSON output serializes") + ); +} + /// Print credentials in AWS credential_process JSON format. pub fn print_credential_process(creds: &Credentials) { let json = serde_json::json!({ diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000..3940333 --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,529 @@ +//! The keyboard-driven list and screen behind `-i`, drawn on stderr. +//! +//! dialoguer's pickers take no shortcuts, so this is a small one of our own +//! on `console`, which dialoguer already brings in. + +use crate::prompt::{Action, Place}; +use console::{style, Key, Term}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Once; + +/// Restores the cursor however the picker ends. +struct Visible<'a>(&'a Term); + +impl Drop for Visible<'_> { + fn drop(&mut self) { + let _ = self.0.show_cursor(); + } +} + +/// The terminal's alternate screen, as `less` and `vim` use it, held for an +/// `-i` session: each screen clears and redraws rather than piling up, and +/// dropping the guard puts back what was on the terminal before. +pub struct FullScreen; + +/// Whether the alternate screen is up, for the Ctrl+C handler. +static IN_FULLSCREEN: AtomicBool = AtomicBool::new(false); + +/// Put the terminal back on Ctrl+C, however it arrives. Our own prompts read +/// it as a key, but dialoguer's choices have `console` raise it as a signal, +/// and so does a Ctrl+C while a request is running: the default handler would +/// end the process with the cursor dialoguer hid still hidden, and inside the +/// alternate screen. Installed once, as a handler stays for the process. +pub fn handle_interrupts() { + static HANDLER: Once = Once::new(); + HANDLER.call_once(|| { + tokio::spawn(async { + if tokio::signal::ctrl_c().await.is_ok() { + if IN_FULLSCREEN.load(Ordering::SeqCst) { + eprint!("\x1b[?1049l"); + } + eprint!("\x1b[?25h"); + say_interrupted(); + std::process::exit(130); + } + }); + }); +} + +/// Print "Interrupted." once: the handler above and the prompt whose read the +/// signal interrupted can both get there. +pub fn say_interrupted() { + static SAID: AtomicBool = AtomicBool::new(false); + if !SAID.swap(true, Ordering::SeqCst) { + eprintln!("\n{}", crate::prompt::INTERRUPTED); + } +} + +impl FullScreen { + pub fn enter() -> FullScreen { + handle_interrupts(); + IN_FULLSCREEN.store(true, Ordering::SeqCst); + eprint!("\x1b[?1049h\x1b[H"); + FullScreen + } +} + +impl Drop for FullScreen { + fn drop(&mut self) { + IN_FULLSCREEN.store(false, Ordering::SeqCst); + eprint!("\x1b[?1049l"); + } +} + +fn failed(e: std::io::Error) -> String { + format!("Prompt failed: {e}") +} + +fn interrupted() -> String { + crate::prompt::INTERRUPTED.into() +} + +/// `line` cut to `cols` columns, keeping its styling. +fn fit(line: &str, cols: u16) -> String { + console::truncate_str(line, (cols as usize).max(1), "…").into_owned() +} + +/// How close to the end of the items the highlight gets before more are asked +/// for, so they're usually there before it arrives. +const LOOKAHEAD: usize = 3; + +/// Move through `items` from `place` and return what was pressed, with +/// `place.at` the highlighted item: ↑/↓ (or k/j, PgUp/PgDn) move, →/Enter/l +/// open, ←/Esc/h/q go back, `/` narrows the list by typed text, and any key in +/// `keys` comes back as `Action::Key`. +/// +/// With `more`, the list pages itself: once the highlight is within +/// `LOOKAHEAD` of the last item shown (or a filter matches nothing), it shows +/// "Loading more…" and returns `Action::More`, leaving the list on screen for +/// the next call, with the extra items, to redraw in place. +pub fn browse( + label: &str, + items: &[String], + place: &mut Place, + more: bool, + keys: &str, + hint: &str, +) -> Result { + let term = Term::stderr(); + term.hide_cursor().map_err(failed)?; + let _visible = Visible(&term); + let (rows, cols) = term.size(); + + // Items may carry styling (a gray ID): filter on the text alone, and + // highlight it whole rather than let an item's own reset end the cyan. + let plain: Vec = items + .iter() + .map(|i| console::strip_ansi_codes(i).into_owned()) + .collect(); + let mut filter = std::mem::take(&mut place.filter); + let mut filtering = place.filtering; + // The highlighted item, as an index into `items`. + let mut current = place.at.min(items.len().saturating_sub(1)); + let mut drawn = place.drawn; + let mut notice = place.notice.take(); + loop { + let height = (rows as usize) + .saturating_sub(3 + usize::from(notice.is_some())) + .max(3); + let needle = filter.to_lowercase(); + let shown: Vec = (0..items.len()) + .filter(|&i| needle.is_empty() || plain[i].to_lowercase().contains(&needle)) + .collect(); + let sel = shown.iter().position(|&i| i == current).unwrap_or(0); + if let Some(&i) = shown.get(sel) { + current = i; + } + // While a filter is typed for the server to search, wait for Enter + // rather than page through everything loaded meanwhile. + let near_end = + more && !(filtering && place.server_search) && sel + LOOKAHEAD >= shown.len(); + let top = sel + .saturating_sub(height - 1) + .min(shown.len().saturating_sub(height)); + + let mut lines = vec![match (filtering, filter.is_empty()) { + (true, _) => format!("{} /{filter}▏", style(label).bold()), + (false, false) => format!("{} /{filter}", style(label).bold()), + (false, true) => style(label).bold().to_string(), + }]; + if shown.is_empty() { + lines.push(style(" (nothing matches)").dim().to_string()); + } + for (row, &i) in shown.iter().enumerate().skip(top).take(height) { + lines.push(if row == sel { + let text = fit(&plain[i], cols.saturating_sub(2)); + format!("{} {}", style("›").cyan(), style(text).cyan()) + } else { + format!(" {}", fit(&items[i], cols.saturating_sub(2))) + }); + } + if let Some(n) = ¬ice { + lines.push(style(n).yellow().to_string()); + } + lines.push( + style(if near_end { + "Loading more…" + } else if filtering && place.server_search { + "type to filter · Enter search all · ↓ done · Esc clear" + } else if filtering { + "type to filter · Enter/↓ done · Esc clear" + } else { + hint + }) + .dim() + .to_string(), + ); + term.clear_last_lines(drawn).map_err(failed)?; + for line in &lines { + // A line that wraps takes two rows, and the next redraw would + // clear one too few. + term.write_line(&fit(line, cols)).map_err(failed)?; + } + drawn = lines.len(); + if near_end { + place.at = current; + place.filter = filter; + place.filtering = filtering; + place.drawn = drawn; + place.notice = notice; + return Ok(Action::More); + } + + let key = term.read_key_raw().map_err(failed)?; + // A notice is read once; the next key clears it. + notice = None; + // The item `by` rows from the highlighted one, staying in the list. + let n = shown.len() as isize; + let step = |by: isize| (n > 0).then(|| shown[(sel as isize + by).clamp(0, n - 1) as usize]); + if filtering { + match key { + Key::Escape => { + filter.clear(); + filtering = false; + } + Key::Enter if place.server_search && !filter.trim().is_empty() => { + term.clear_last_lines(drawn).map_err(failed)?; + place.at = current; + place.filter = filter; + place.filtering = false; + place.drawn = 0; + return Ok(Action::Search); + } + Key::Enter | Key::ArrowDown => filtering = false, + Key::Backspace => { + filter.pop(); + } + Key::CtrlC => return Err(interrupted()), + Key::Char(c) if !c.is_control() => filter.push(c), + _ => {} + } + continue; + } + let moved = match key { + Key::ArrowUp | Key::Char('k') => step(-1), + Key::ArrowDown | Key::Char('j') => step(1), + Key::PageUp => step(-(height as isize)), + Key::PageDown => step(height as isize), + Key::Home => step(-n), + Key::End => step(n), + _ => None, + }; + let action = match key { + Key::Char('/') => { + filtering = true; + None + } + Key::ArrowLeft | Key::Escape | Key::Char('h' | 'q') => Some(Action::Back), + Key::CtrlC => return Err(interrupted()), + _ if shown.is_empty() => None, + Key::Enter | Key::ArrowRight | Key::Char('l') => Some(Action::Open), + Key::Char(c) if keys.contains(c) => Some(Action::Key(c)), + _ => None, + }; + if let Some(i) = moved { + current = i; + } + if let Some(action) = action { + term.clear_last_lines(drawn).map_err(failed)?; + // The filter stays for when they come back to the list. + place.at = current; + place.filter = filter; + place.filtering = false; + place.drawn = 0; + return Ok(action); + } + } +} + +/// Wait for ←/Esc/h/q (back) or one of `keys`, showing +/// `hint` meanwhile. +/// The part of `text` to show in `room` columns with the cursor at `at`: +/// all of it if it fits, otherwise a window that keeps the cursor in view. +/// Returns the shown text and how many columns the cursor sits from its end. +fn window(text: &[char], at: usize, room: usize) -> (String, usize) { + let width = |cs: &[char]| console::measure_text_width(&cs.iter().collect::()); + // Start as far left as still leaves a column for the cursor. + let mut start = 0; + while start < at && width(&text[start..at]) + 1 > room { + start += 1; + } + let mut end = at; + while end < text.len() && width(&text[start..end + 1]) <= room { + end += 1; + } + (text[start..end].iter().collect(), width(&text[at..end])) +} + +#[cfg(test)] +mod tests { + use super::window; + + fn chars(s: &str) -> Vec { + s.chars().collect() + } + + #[test] + fn a_line_that_fits_is_shown_whole_with_the_cursor_where_it_is() { + assert_eq!(window(&chars("héllo"), 5, 20), ("héllo".into(), 0)); + assert_eq!(window(&chars("héllo"), 1, 20), ("héllo".into(), 4)); + } + + #[test] + fn a_long_line_scrolls_to_keep_the_cursor_in_view() { + let text = chars("abcdefghij"); + // At the end: the last 4 letters and a column for the cursor. + assert_eq!(window(&text, 10, 5), ("ghij".into(), 0)); + // At the start: the first 5 letters. + assert_eq!(window(&text, 0, 5), ("abcde".into(), 5)); + } + + #[test] + fn wide_characters_count_their_columns() { + // Each emoji takes two columns. + assert_eq!(window(&chars("🌍🌍🌍"), 3, 5), ("🌍🌍".into(), 0)); + assert_eq!(window(&chars("a🌍b"), 1, 20), ("a🌍b".into(), 3)); + } +} + +/// How a `line` ended. +#[derive(Debug, PartialEq)] +pub enum Line { + /// Enter, with what was in the line. + Text(String), + /// Esc. + Cancelled, + /// Ctrl+E, when `editor` was offered: open the person's editor instead. + Editor, + /// ↑, or ← at the start of the line, when `back` was offered: return to + /// the question before. + Back, +} + +/// Leave a question for the one before it: clear this one and the line where +/// the one before was answered, so it's asked again in its place. +fn step_back(term: &Term, lines: usize) -> Result<(), String> { + term.clear_line().map_err(failed)?; + term.clear_last_lines(lines + 1).map_err(failed) +} + +/// A line of text, as dialoguer's `Input` asks for it but cancellable: it +/// starts as `initial` to keep or edit, Enter takes it, Esc backs out. ←/→, +/// Home/End and Ctrl+A move; Backspace and Delete erase a whole character, +/// accents and emoji included. A line wider than the terminal scrolls +/// sideways rather than wrap. Empty is refused unless `allow_empty`. With +/// `editor`, Ctrl+E at the end of the line asks for the person's editor +/// instead. With `back`, ↑ (or ← at the start of the line) returns `Back`. +pub fn line( + label: &str, + initial: &str, + allow_empty: bool, + editor: bool, + back: bool, +) -> Result { + let term = Term::stderr(); + term.show_cursor().map_err(failed)?; + let prefix = format!( + "{} {} {} ", + style("?").yellow().bold(), + style(label).bold(), + style("›").dim() + ); + let mut text: Vec = initial.chars().collect(); + let mut at = text.len(); + let done = |mark: String, value: &str| -> Result<(), String> { + term.clear_line().map_err(failed)?; + term.write_line(&fit( + &format!( + "{mark} {} {} {value}", + style(label).bold(), + style("·").dim() + ), + term.size().1, + )) + .map_err(failed) + }; + loop { + let room = (term.size().1 as usize) + .saturating_sub(console::measure_text_width(&prefix) + 1) + .max(1); + let (shown, after) = window(&text, at, room); + term.clear_line().map_err(failed)?; + term.write_str(&format!("{prefix}{shown}")) + .map_err(failed)?; + if after > 0 { + term.move_cursor_left(after).map_err(failed)?; + } + match term.read_key_raw().map_err(failed)? { + Key::Escape => { + done(style("✘").red().to_string(), "")?; + return Ok(Line::Cancelled); + } + // `console` reads Ctrl+E as End (as shells do), so the two can't + // be told apart: at the end of the line it opens the editor, and + // elsewhere it moves there first. + Key::End | Key::Char('\u{5}') if editor && at == text.len() => { + done(style("✎").cyan().to_string(), "(in your editor)")?; + return Ok(Line::Editor); + } + Key::CtrlC => { + term.write_line("").map_err(failed)?; + return Err(interrupted()); + } + Key::ArrowUp if back => { + step_back(&term, 0)?; + return Ok(Line::Back); + } + Key::ArrowLeft if back && at == 0 => { + step_back(&term, 0)?; + return Ok(Line::Back); + } + Key::Enter => { + let value: String = text.iter().collect(); + if value.trim().is_empty() && !allow_empty { + continue; + } + // Long text (the one with an editor) keeps what was there on + // an empty Enter, so say that rather than show nothing. + let shown = if editor && value.trim().is_empty() { + style("(no change)").dim().to_string() + } else { + value.clone() + }; + done(style("✔").green().to_string(), &shown)?; + return Ok(Line::Text(value)); + } + Key::Backspace if at > 0 => { + at -= 1; + text.remove(at); + } + Key::Del if at < text.len() => { + text.remove(at); + } + Key::ArrowLeft if at > 0 => at -= 1, + Key::ArrowRight if at < text.len() => at += 1, + Key::Home | Key::Char('\u{1}') => at = 0, + Key::End => at = text.len(), + Key::Char(c) if !c.is_control() => { + text.insert(at, c); + at += 1; + } + _ => {} + } + } +} + +pub fn keypress(hint: &str, keys: &str) -> Result { + let term = Term::stderr(); + term.hide_cursor().map_err(failed)?; + let _visible = Visible(&term); + term.write_line(&fit(&style(hint).dim().to_string(), term.size().1)) + .map_err(failed)?; + let action = loop { + match term.read_key_raw().map_err(failed)? { + Key::ArrowLeft | Key::Escape | Key::Char('h' | 'q') => break Action::Back, + Key::CtrlC => return Err(interrupted()), + Key::Char(c) if keys.contains(c) => break Action::Key(c), + _ => {} + } + }; + term.clear_last_lines(1).map_err(failed)?; + Ok(action) +} + +/// One of `items`, as dialoguer's `Select` asks for it, plus going back: +/// ↑/↓ (k/j) move, Enter (or →) chooses, Esc cancels, and with `back`, ← +/// returns None to ask the question before again. `details` are shown under +/// the question while it's asked (a summary to confirm, say), and go with it. +// ponytail: no scrolling; its lists (accounts, data connections, +// visibilities) fit a screen. Window it like `browse` if one grows past that. +pub fn select( + label: &str, + details: &[String], + items: &[String], + default: usize, + back: bool, +) -> Result, String> { + let term = Term::stderr(); + term.hide_cursor().map_err(failed)?; + let _visible = Visible(&term); + let cols = term.size().1; + let hint = if back { + "↑↓ move · Enter choose · ← back · Esc cancel" + } else { + "↑↓ move · Enter choose · Esc cancel" + }; + let mut at = default.min(items.len().saturating_sub(1)); + let mut drawn = 0; + let done = |term: &Term, drawn: usize, mark: String, value: &str| -> Result<(), String> { + term.clear_last_lines(drawn).map_err(failed)?; + term.write_line(&fit( + &format!( + "{mark} {} {} {value}", + style(label).bold(), + style("·").dim() + ), + cols, + )) + .map_err(failed) + }; + loop { + let mut lines = vec![format!( + "{} {}", + style("?").yellow().bold(), + style(label).bold() + )]; + lines.extend(details.iter().map(|d| format!(" {d}"))); + for (i, item) in items.iter().enumerate() { + lines.push(if i == at { + format!("{} {}", style("›").cyan(), style(item).cyan()) + } else { + format!(" {item}") + }); + } + lines.push(style(hint).dim().to_string()); + term.clear_last_lines(drawn).map_err(failed)?; + for line in &lines { + term.write_line(&fit(line, cols)).map_err(failed)?; + } + drawn = lines.len(); + match term.read_key_raw().map_err(failed)? { + Key::ArrowUp | Key::Char('k') => at = at.saturating_sub(1), + Key::ArrowDown | Key::Char('j') => at = (at + 1).min(items.len().saturating_sub(1)), + Key::Enter | Key::ArrowRight if !items.is_empty() => { + done(&term, drawn, style("✔").green().to_string(), &items[at])?; + return Ok(Some(at)); + } + Key::ArrowLeft if back => { + step_back(&term, drawn)?; + return Ok(None); + } + Key::Escape | Key::Char('q') => { + done(&term, drawn, style("✘").red().to_string(), "")?; + return Err(crate::prompt::CANCELLED.into()); + } + Key::CtrlC => return Err(interrupted()), + _ => {} + } + } +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..4c818f3 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,546 @@ +//! Interactive prompts for whatever a command wasn't given by flag or file. +//! +//! Prompts appear only when someone is there to answer them: stdin and stderr +//! are both terminals and `SOURCE_PROMPT_DISABLED` is unset. Otherwise a +//! command sends what it was given, and the API says what's missing. + +use crate::picker::Line; +use dialoguer::{theme::ColorfulTheme, Confirm, Editor, MultiSelect, Select}; +use std::io::IsTerminal; + +/// What someone did in a `browse` list or at a `keypress`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Action { + /// → or Enter: go into the highlighted item. + Open, + /// ← or Esc: go back (or out). + Back, + /// One of the shortcut keys asked for. + Key(char), + /// Only from `browse`, when told there's more: the highlight neared the + /// end of the items. Load the next page and browse again from `Place`. + More, + /// Only from `browse` with `Place::server_search`: a filter was submitted + /// with Enter. Search for `Place::filter` and browse the results. + Search, +} + +/// Where someone is in a `browse` list, kept between calls so loading more +/// items (`Action::More`) doesn't lose their place or their filter. +#[derive(Default)] +pub struct Place { + /// The highlighted item. + pub at: usize, + pub filter: String, + /// Typing a filter, rather than moving. + pub filtering: bool, + /// Lines left on screen while more load, for the next call to redraw over. + pub drawn: usize, + /// Whether the list can be searched on the server: Enter on a filter + /// returns `Action::Search` instead of only narrowing what's loaded. + pub server_search: bool, + /// A message for the person, shown once in the list (e.g. "Deleted …"). + pub notice: Option, +} + +pub trait Prompter { + /// Ask for a line of text, offering `default` (taken on a bare Enter). + fn input(&mut self, label: &str, default: &str, allow_empty: bool) -> Result; + /// Ask for one of `items`, starting on `default`; returns its index. + fn select(&mut self, label: &str, items: &[String], default: usize) -> Result; + /// Let someone move through `items` from `place` and act on one: returns + /// what they pressed, with `place.at` the item it was pressed on. With + /// `more`, nearing the end returns `Action::More` (see + /// `crate::picker::browse`). + fn browse( + &mut self, + label: &str, + items: &[String], + place: &mut Place, + more: bool, + keys: &str, + hint: &str, + ) -> Result; + /// Wait for back or one of `keys`, showing `hint`. + fn keypress(&mut self, hint: &str, keys: &str) -> Result; + /// Take over the screen until the returned guard is dropped (see + /// `crate::picker::FullScreen`); None where there's no screen to take. + fn fullscreen(&mut self) -> Option { + None + } + /// Start a fresh screen, inside `fullscreen`. + fn clear(&mut self) {} + /// Ask a yes/no question. + fn confirm(&mut self, label: &str, default: bool) -> Result; + /// Ask for text that may run to paragraphs, starting from `current`: typed + /// on one line, or written in the person's editor. + fn long_text(&mut self, label: &str, current: &str) -> Result; + /// Ask for any number of `items`; returns their indexes. + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String>; + + /// `input`, `select` and `long_text` for a question with one before it: + /// None means the person went back to it (↑, or ← at the start of a line + /// or in a list). These ask exactly as the plain ones where there's no + /// going back. + fn input_or_back( + &mut self, + label: &str, + default: &str, + allow_empty: bool, + ) -> Result, String> { + self.input(label, default, allow_empty).map(Some) + } + fn select_or_back( + &mut self, + label: &str, + items: &[String], + default: usize, + ) -> Result, String> { + self.select(label, items, default).map(Some) + } + fn long_text_or_back(&mut self, label: &str, current: &str) -> Result, String> { + self.long_text(label, current).map(Some) + } + /// Show `details` (what's about to be sent) and ask `question`, yes by + /// default. None means back to the question before. + fn confirm_or_back( + &mut self, + question: &str, + details: &[String], + ) -> Result, String> { + for line in details { + eprintln!(" {line}"); + } + self.confirm(question, true).map(Some) + } +} + +/// Prompts on the terminal, written to stderr so stdout stays clean for output. +pub struct Tty { + theme: ColorfulTheme, +} + +/// A terminal prompter, or None when no one is there to answer. +pub fn tty() -> Option { + let enabled = std::env::var_os("SOURCE_PROMPT_DISABLED").is_none() + && std::io::stdin().is_terminal() + && std::io::stderr().is_terminal(); + enabled.then(|| Tty { + theme: ColorfulTheme::default(), + }) +} + +fn failed(e: dialoguer::Error) -> String { + match e { + // Ctrl+C, which `console` raises as a signal, interrupting the read. + dialoguer::Error::IO(ref io) if io.kind() == std::io::ErrorKind::Interrupted => { + INTERRUPTED.into() + } + e => format!("Prompt failed: {e}"), + } +} + +/// What a prompt returns as its error when the person backs out with Esc (or +/// `q`) on a choice: the command stops, having changed nothing. dialoguer's +/// text prompts don't take Esc, so a text prompt that is the last step before +/// something irreversible says how to back out instead (see `delete`). +pub const CANCELLED: &str = "Cancelled; nothing was changed."; + +/// An error as the person should see it. Backing out of a prompt isn't a +/// failure worth an "Error:", so that message stands alone. +pub fn describe(e: &str) -> String { + if e == CANCELLED || e == INTERRUPTED { + e.to_string() + } else { + format!("Error: {e}") + } +} + +/// What a prompt returns as its error on Ctrl+C. Like backing out, it's the +/// person's own doing, so it's reported without an "Error:". +pub const INTERRUPTED: &str = "Interrupted."; + +/// Print an error on stderr, as `describe` puts it. +pub fn report(e: &str) { + if e == INTERRUPTED { + crate::picker::say_interrupted(); + } else { + eprintln!("{}", describe(e)); + } +} + +fn cancellable(answer: dialoguer::Result>) -> Result { + answer.map_err(failed)?.ok_or_else(|| CANCELLED.to_string()) +} + +/// The last step before something irreversible, for any kind of record: the +/// person types `name` back. Enter on nothing backs out (text prompts can't +/// take Esc); anything else that isn't `name` stops with `action` undone. +pub fn confirm_by_name(ask: &mut dyn Prompter, name: &str, action: &str) -> Result<(), String> { + let typed = ask.input(&format!("Type {name} to confirm (Esc to cancel)"), "", true)?; + match typed.trim() { + "" => Err(CANCELLED.into()), + t if t == name => Ok(()), + _ => Err(format!("Not confirmed; nothing was {action}.")), + } +} + +/// `Tty::long_text`, and None for back when `back` was offered. +fn long_text(label: &str, current: &str, back: bool) -> Result, String> { + let keep = if current.is_empty() { "skip" } else { "keep" }; + let hint = format!("{label} [Enter to {keep} · Ctrl+E editor · Esc cancel]"); + match crate::picker::line(&hint, "", true, true, back)? { + Line::Cancelled => Err(CANCELLED.into()), + Line::Back => Ok(None), + Line::Text(typed) if typed.trim().is_empty() => Ok(Some(current.to_string())), + Line::Text(typed) => Ok(Some(typed)), + Line::Editor => { + let edited = Editor::new() + .require_save(true) + .edit(current) + .map_err(|e| format!("Couldn't open your editor: {e}"))?; + // Quitting without saving keeps what was there. + Ok(Some(edited.map_or_else( + || current.to_string(), + |t| t.trim_end().to_string(), + ))) + } + } +} + +impl Prompter for Tty { + /// Our own line prompt rather than dialoguer's, whose text prompts ignore + /// Esc: the default starts in the line to keep or edit, and Esc backs out. + fn input(&mut self, label: &str, default: &str, allow_empty: bool) -> Result { + match crate::picker::line(label, default, allow_empty, false, false)? { + Line::Text(text) => Ok(text), + Line::Cancelled | Line::Editor | Line::Back => Err(CANCELLED.into()), + } + } + + fn input_or_back( + &mut self, + label: &str, + default: &str, + allow_empty: bool, + ) -> Result, String> { + match crate::picker::line(label, default, allow_empty, false, true)? { + Line::Text(text) => Ok(Some(text)), + Line::Back => Ok(None), + Line::Cancelled | Line::Editor => Err(CANCELLED.into()), + } + } + + fn select_or_back( + &mut self, + label: &str, + items: &[String], + default: usize, + ) -> Result, String> { + crate::picker::select(label, &[], items, default, true) + } + + fn confirm_or_back( + &mut self, + question: &str, + details: &[String], + ) -> Result, String> { + let options = ["Yes".to_string(), "No, cancel".to_string()]; + Ok(crate::picker::select(question, details, &options, 0, true)?.map(|i| i == 0)) + } + + fn select(&mut self, label: &str, items: &[String], default: usize) -> Result { + cancellable( + Select::with_theme(&self.theme) + .with_prompt(label) + .items(items) + .default(default) + .interact_opt(), + ) + } + + fn browse( + &mut self, + label: &str, + items: &[String], + place: &mut Place, + more: bool, + keys: &str, + hint: &str, + ) -> Result { + crate::picker::browse(label, items, place, more, keys, hint) + } + + fn keypress(&mut self, hint: &str, keys: &str) -> Result { + crate::picker::keypress(hint, keys) + } + + fn fullscreen(&mut self) -> Option { + Some(crate::picker::FullScreen::enter()) + } + + fn clear(&mut self) { + let _ = console::Term::stderr().clear_screen(); + } + + fn confirm(&mut self, label: &str, default: bool) -> Result { + cancellable( + Confirm::with_theme(&self.theme) + .with_prompt(label) + .default(default) + .interact_opt(), + ) + } + + /// Enter keeps what's there, anything typed is the new text, and Ctrl+E + /// opens `$VISUAL` or `$EDITOR` on it, for text of several paragraphs. The + /// line starts empty rather than holding text that may span lines. + fn long_text(&mut self, label: &str, current: &str) -> Result { + Ok(long_text(label, current, false)?.unwrap_or_default()) + } + + fn long_text_or_back(&mut self, label: &str, current: &str) -> Result, String> { + long_text(label, current, true) + } + + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String> { + cancellable( + MultiSelect::with_theme(&self.theme) + .with_prompt(format!("{label} (Space to pick, Enter when done)")) + .items(items) + .interact_opt(), + ) + } +} + +/// Answers from a script, in order, for tests. +#[cfg(test)] +pub mod scripted { + use super::{Action, Prompter}; + use std::collections::VecDeque; + + #[derive(Debug)] + pub enum Answer { + /// Take the offered default. + Default, + Text(&'static str), + Pick(usize), + Picks(&'static [usize]), + Yes(bool), + /// In a `browse` list: highlight item `.0` and press `.1`. + Browse(usize, Action), + /// In a `browse` list: type `/` and this, then Enter to search. + Search(&'static str), + /// At a `keypress`. + Press(Action), + /// Back out, as Esc does on a choice. + Cancel, + /// Go back to the question before (↑ or ←), where one may be asked. + Back, + } + + #[derive(Default)] + pub struct Script { + pub answers: VecDeque, + /// Every prompt asked, as `label [default]`, to check what was offered. + pub asked: Vec, + } + + impl Script { + pub fn new(answers: impl IntoIterator) -> Self { + Script { + answers: answers.into_iter().collect(), + asked: vec![], + } + } + + fn next(&mut self, label: &str) -> Answer { + self.answers + .pop_front() + .unwrap_or_else(|| panic!("unscripted prompt: {label}")) + } + + /// A scripted `Back` for a question that may go back: recorded as + /// `label (back)`. Any other answer is left for the plain prompt. + fn went_back(&mut self, label: &str) -> bool { + let back = matches!(self.answers.front(), Some(Answer::Back)); + if back { + self.answers.pop_front(); + self.asked.push(format!("{label} (back)")); + } + back + } + } + + impl Prompter for Script { + fn input_or_back( + &mut self, + label: &str, + default: &str, + allow_empty: bool, + ) -> Result, String> { + if self.went_back(label) { + return Ok(None); + } + self.input(label, default, allow_empty).map(Some) + } + + fn select_or_back( + &mut self, + label: &str, + items: &[String], + default: usize, + ) -> Result, String> { + if self.went_back(label) { + return Ok(None); + } + self.select(label, items, default).map(Some) + } + + fn long_text_or_back( + &mut self, + label: &str, + current: &str, + ) -> Result, String> { + if self.went_back(label) { + return Ok(None); + } + self.long_text(label, current).map(Some) + } + + /// Recorded as `question {detail | detail | …} [true]`. + fn confirm_or_back( + &mut self, + question: &str, + details: &[String], + ) -> Result, String> { + let label = format!("{question} {{{}}}", details.join(" | ")); + if self.went_back(&label) { + return Ok(None); + } + self.confirm(&label, true).map(Some) + } + + fn input(&mut self, label: &str, default: &str, _: bool) -> Result { + self.asked.push(format!("{label} [{default}]")); + Ok(match self.next(label) { + Answer::Default => default.to_string(), + Answer::Text(t) => t.to_string(), + Answer::Cancel => return Err(super::CANCELLED.into()), + a => panic!("{label}: expected text, scripted {a:?}"), + }) + } + + fn select( + &mut self, + label: &str, + items: &[String], + default: usize, + ) -> Result { + self.asked.push(format!( + "{label} [{}] of {}", + items[default], + items.join(" | ") + )); + Ok(match self.next(label) { + Answer::Default => default, + Answer::Pick(i) => i, + Answer::Cancel => return Err(super::CANCELLED.into()), + a => panic!("{label}: expected a pick, scripted {a:?}"), + }) + } + + fn confirm(&mut self, label: &str, default: bool) -> Result { + self.asked.push(format!("{label} [{default}]")); + Ok(match self.next(label) { + Answer::Default => default, + Answer::Yes(y) => y, + Answer::Cancel => return Err(super::CANCELLED.into()), + a => panic!("{label}: expected yes/no, scripted {a:?}"), + }) + } + + fn long_text(&mut self, label: &str, current: &str) -> Result { + self.asked.push(format!("{label} (long) [{current}]")); + Ok(match self.next(label) { + Answer::Default => current.to_string(), + Answer::Text(t) => t.to_string(), + Answer::Cancel => return Err(super::CANCELLED.into()), + a => panic!("{label}: expected text, scripted {a:?}"), + }) + } + + fn multi_select(&mut self, label: &str, items: &[String]) -> Result, String> { + self.asked.push(format!("{label} of {}", items.join(" | "))); + Ok(match self.next(label) { + Answer::Picks(p) => p.to_vec(), + Answer::Cancel => return Err(super::CANCELLED.into()), + a => panic!("{label}: expected picks, scripted {a:?}"), + }) + } + + fn browse( + &mut self, + label: &str, + items: &[String], + place: &mut super::Place, + more: bool, + keys: &str, + _: &str, + ) -> Result { + let more_note = if more { " (more)" } else { "" }; + // A notice is shown once, as the picker does. + let notice = place + .notice + .take() + .map_or(String::new(), |n| format!(" !{n}")); + self.asked.push(format!( + "{label} [{}] of {}{more_note}{notice} (keys {keys})", + items.get(place.at).map_or("", String::as_str), + items.join(" | ") + )); + match self.next(label) { + Answer::Browse(i, action) => { + assert!(more || action != Action::More, "More without more"); + // As the picker ignores keys it wasn't given. + if let Action::Key(c) = action { + assert!(keys.contains(c), "{c} isn't a key here ({keys})"); + } + place.at = i; + Ok(action) + } + Answer::Search(text) => { + assert!(place.server_search, "Search on a list that can't"); + place.filter = text.to_string(); + Ok(Action::Search) + } + a => panic!("{label}: expected a browse, scripted {a:?}"), + } + } + + fn keypress(&mut self, hint: &str, keys: &str) -> Result { + self.asked.push(format!("{hint} (keys {keys})")); + Ok(match self.next(hint) { + Answer::Press(action) => action, + a => panic!("{hint}: expected a key, scripted {a:?}"), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::scripted::{Answer::*, Script}; + use super::*; + + #[test] + fn confirming_by_name_takes_the_name_backs_out_on_nothing_and_refuses_the_rest() { + let confirm = |answer| confirm_by_name(&mut Script::new([answer]), "acct/thing", "deleted"); + assert_eq!(confirm(Text("acct/thing")), Ok(())); + assert_eq!(confirm(Text(" ")), Err(CANCELLED.to_string())); + assert_eq!( + confirm(Text("acct/other")), + Err("Not confirmed; nothing was deleted.".to_string()) + ); + } +} diff --git a/src/session.rs b/src/session.rs index ffcd5a2..bbc3958 100644 --- a/src/session.rs +++ b/src/session.rs @@ -88,7 +88,15 @@ impl Session { fn current(&self, want: Want, now: i64) -> Result, ()> { let (token, expires_at) = match want { Want::Id => (Some(self.id_token.as_str()), jwt_exp(&self.id_token)), - Want::Access => (self.access_token.as_deref(), self.access_token_expires_at), + // A token endpoint always issues an access token, but without an + // audience it isn't one the API takes: sending it would turn even + // public reads into a 401. + Want::Access => ( + self.access_token + .as_deref() + .filter(|_| self.audience.is_some()), + self.access_token_expires_at, + ), }; match (token, expires_at) { (None, _) => Ok(None), @@ -290,6 +298,14 @@ mod tests { .await .unwrap(); assert_eq!(access, None); + + // The token endpoint issues one anyway; it isn't for the API. + let mut s = session(&server, tokens(NOW + 3600, Some("at"), Some(3600))); + s.audience = None; + let access = ensure(&mut s, Want::Access, NOW, false, |_| Ok(())) + .await + .unwrap(); + assert_eq!(access, None); } #[tokio::test] diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..db4615b --- /dev/null +++ b/src/style.rs @@ -0,0 +1,65 @@ +//! Text styling for every command's output, so records of every type look +//! alike: ANSI codes on a terminal that wants color, plain text otherwise. + +use std::io::IsTerminal; + +/// Whether output to a stream that `is_terminal` should be colored: only on a +/// terminal, and never with `NO_COLOR` set (https://no-color.org). +pub fn wanted(is_terminal: bool) -> bool { + is_terminal && std::env::var_os("NO_COLOR").is_none() +} + +/// Whether prompts and pickers, which draw on stderr, should be colored. +pub fn on_stderr() -> bool { + wanted(std::io::stderr().is_terminal()) +} + +/// `s` in the SGR style `code` when `on`. +pub fn paint(on: bool, code: &str, s: &str) -> String { + if on { + format!("\x1b[{code}m{s}\x1b[0m") + } else { + s.to_string() + } +} + +/// A record's ID, e.g. `acct/prod`: a lighter gray than the name beside it. +pub fn id(on: bool, s: &str) -> String { + paint(on, "90", s) +} + +/// How long ago `then` was, at `now`, in the largest whole unit: "just now", +/// "5 minutes ago", "3 days ago", "2 years ago". +pub fn ago(then: chrono::DateTime, now: chrono::DateTime) -> String { + let secs = (now - then).num_seconds(); + if secs < 0 { + return "in the future".into(); + } + let (n, unit) = match secs { + 0..60 => return "just now".into(), + 60..3_600 => (secs / 60, "minute"), + 3_600..86_400 => (secs / 3_600, "hour"), + 86_400..2_592_000 => (secs / 86_400, "day"), + 2_592_000..31_536_000 => (secs / 2_592_000, "month"), + _ => (secs / 31_536_000, "year"), + }; + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} + +#[cfg(test)] +mod tests { + use super::ago; + + #[test] + fn ago_counts_in_the_largest_whole_unit() { + let now: chrono::DateTime = "2026-10-14T12:00:00Z".parse().unwrap(); + let before = |secs: i64| ago(now - chrono::Duration::seconds(secs), now); + assert_eq!(before(30), "just now"); + assert_eq!(before(60), "1 minute ago"); + assert_eq!(before(5 * 3_600 + 59), "5 hours ago"); + assert_eq!(before(86_400), "1 day ago"); + assert_eq!(before(65 * 86_400), "2 months ago"); + assert_eq!(before(800 * 86_400), "2 years ago"); + assert_eq!(before(-10), "in the future"); + } +} From 39f284b4b3e9f9c6bb82a2c15b8066f32dd3a20e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 11:47:28 -0700 Subject: [PATCH 2/3] fix: api exits by the error's kind, and keeps requests under /api/v1 - An error status from `source-coop api` went through to_string(), skipping what records its code and status, so every failure exited 1. It goes through From now: a 404 exits 5, a 401 or 403 exits 4, and --json errors carry the code and status. - -X GET with --input is refused rather than sending a body on a GET. - Paths with .. that leave /api/v1 are refused, as a full URL elsewhere already was. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/api.rs | 11 ++++++++--- src/api_cmd.rs | 29 ++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9fd9476..7f0cc5d 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ source-coop api products/my-org -f product_id=new -f title="New" -f description= source-coop api products/my-org/new -X PATCH --input changes.json ``` -`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given. A status other than success exits non-zero. +`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given; a `GET` takes no `--input`. Paths stay under `/api/v1`. A status other than success exits non-zero, with the exit code for its kind (see "JSON output and exit codes"). Reading public data needs no credentials. Anything else acts as whoever ran `source-coop login`, which asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`. diff --git a/src/api.rs b/src/api.rs index 7ac37dc..1357427 100644 --- a/src/api.rs +++ b/src/api.rs @@ -199,8 +199,9 @@ impl Client { .base .join(path) .map_err(|e| format!("Invalid API path '{path}': {e}"))?; - // A full URL joins as itself, and the bearer must not go elsewhere. - if url.origin() != self.base.origin() { + // A full URL joins as itself, and the bearer must not go elsewhere; + // `..` segments must not climb out of the API either. + if url.origin() != self.base.origin() || !url.path().starts_with(self.base.path()) { return Err(format!( "'{path}' isn't on the API at {}; give a path under /api/v1", self.base.origin().ascii_serialization() @@ -265,8 +266,12 @@ mod tests { "https://source.coop/api/v1/products/acct?limit=2" ); } - // The token is only ever sent to the API's own origin. + // The token is only ever sent to the API's own origin, and only under + // /api/v1 there. assert!(c.resolve("https://example.com/steal").is_err()); + assert!(c.resolve("../../whoami").is_err()); + assert!(c.resolve("products/../../../admin").is_err()); + assert!(c.resolve("products/../whoami").is_ok()); } #[tokio::test] diff --git a/src/api_cmd.rs b/src/api_cmd.rs index ae1326d..2e3be93 100644 --- a/src/api_cmd.rs +++ b/src/api_cmd.rs @@ -48,6 +48,12 @@ pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> { None if body.is_some() || !fields.is_empty() => Method::POST, None => Method::GET, }; + if method == Method::GET && body.is_some() { + return Err( + "--input sends a request body, which a GET doesn't take: give query fields with -f/-F, or another method with -X" + .into(), + ); + } let body = if method == Method::GET { let mut q = url.query_pairs_mut(); @@ -71,7 +77,8 @@ pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> { if status.is_success() { Ok(()) } else { - Err(ApiError::from_response(status, &text).to_string()) + // Through `From`, which keeps the code and status for the exit code. + Err(ApiError::from_response(status, &text).into()) } } @@ -189,5 +196,25 @@ mod tests { let client = Client::new(&server.uri(), None, false).unwrap(); let err = run(args("products/acct/nope"), &client).await.unwrap_err(); assert!(err.contains("No such product. (not_found, HTTP 404)")); + // The API's code and status survive, for --json errors and exit codes. + let failure = crate::failure::classify(&err); + assert_eq!( + (failure.code.as_str(), failure.status), + ("not_found", Some(404)) + ); + assert_eq!(failure.exit_code(), 5); + } + + #[tokio::test] + async fn a_get_refuses_a_body() { + let path = std::env::temp_dir().join(format!("api-cmd-get-{}.json", std::process::id())); + std::fs::write(&path, "{}").unwrap(); + let client = Client::new("http://127.0.0.1:9", None, false).unwrap(); + let mut a = args("products/acct"); + a.method = Some("GET".into()); + a.input = Some(path.display().to_string()); + let err = run(a, &client).await.unwrap_err(); + let _ = std::fs::remove_file(&path); + assert!(err.starts_with("--input sends a request body"), "{err}"); } } From 3c92490b409bd85ac58044a41684b16261596677 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 14 Oct 2026 14:22:53 -0700 Subject: [PATCH 3/3] refactor: simpler shared code: console for color, fewer copies - style: color detection is console's colors_enabled / colors_enabled_stderr (NO_COLOR, CLICOLOR), still only on a terminal. - prompt: the *_or_back methods are required; every prompter has them. - picker: one answered() line writer and one keep() for where browse left off, the two go-back arms merged, keypress's doc comment back on it. - failure: keeps the ApiError itself and serializes Failure with serde. - api: error causes via iter::successors; the api command prints JSON through output::json; main's api_token and api arm without the match-on-Result dances; cache_dir private again. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/api.rs | 13 ++++------ src/api_cmd.rs | 2 +- src/cache.rs | 2 +- src/failure.rs | 50 +++++++++++---------------------------- src/main.rs | 25 +++++++++----------- src/picker.rs | 64 +++++++++++++++++++++----------------------------- src/prompt.rs | 22 ++++------------- src/style.rs | 9 ++++--- 9 files changed, 69 insertions(+), 120 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7865fa1..7f5b672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Everything goes through the `Prompter` trait (`src/prompt.rs`), so tests can scr - **For people on a terminal, for scripts when piped.** - On a terminal, lists stream through the pager (`SOURCE_PAGER`, `PAGER`, then `less -FRX`; `--no-pager` turns it off). - Piped output and `--json` are for scripts: keep them stable. -- **Color only through `src/style.rs`** (`style::wanted`, `paint`, `id`), only on a terminal, never with `NO_COLOR`. IDs are gray. +- **Color only through `src/style.rs`** (`style::wanted`, `on_stderr`, `paint`, `id`): only on a terminal, and as `console` decides (never with `NO_COLOR` or `CLICOLOR=0`). Don't check the environment yourself. 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 diff --git a/src/api.rs b/src/api.rs index 1357427..d785ac1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -25,7 +25,7 @@ struct ApiErrorDetail { } /// A failed API call, with whatever the API said about why. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ApiError { pub status: Option, pub code: Option, @@ -68,13 +68,10 @@ impl ApiError { /// the URL; the cause (DNS, TLS, a refused connection) is further down the /// chain, so all of it is shown. fn transport(e: &reqwest::Error) -> Self { - let mut message = format!("API request failed: {e}"); - let mut source = std::error::Error::source(e); - while let Some(cause) = source { - message.push_str(&format!(": {cause}")); - source = cause.source(); - } - Self::other(message) + let causes: String = std::iter::successors(std::error::Error::source(e), |c| c.source()) + .map(|c| format!(": {c}")) + .collect(); + Self::other(format!("API request failed: {e}{causes}")) } fn other(message: String) -> Self { diff --git a/src/api_cmd.rs b/src/api_cmd.rs index 2e3be93..fb3a8ff 100644 --- a/src/api_cmd.rs +++ b/src/api_cmd.rs @@ -70,7 +70,7 @@ pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> { let (status, text) = client.send(method, url, body.as_ref()).await?; match serde_json::from_str::(&text) { - Ok(json) => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + Ok(json) => crate::output::json(&json), Err(_) if text.is_empty() => {} Err(_) => println!("{text}"), } diff --git a/src/cache.rs b/src/cache.rs index ee17818..2757b1b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -73,7 +73,7 @@ struct Slot { /// The OS-idiomatic cache directory (`~/Library/Caches` on macOS, `~/.cache` /// on Linux, `%LocalAppData%` on Windows). -pub(crate) fn cache_dir() -> Result { +fn cache_dir() -> Result { Ok(dirs::cache_dir() .ok_or("Could not determine cache directory")? .join("source-coop")) diff --git a/src/failure.rs b/src/failure.rs index f84dbb6..0193a79 100644 --- a/src/failure.rs +++ b/src/failure.rs @@ -12,43 +12,28 @@ use crate::api::ApiError; use std::cell::RefCell; use std::collections::BTreeMap; -/// A failure as scripts see it. -#[derive(Debug, Clone, PartialEq)] +/// A failure as scripts see it, serialized as the API's error body. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] 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, + #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] 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) }; + /// The last API error, with the text it became. + 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(), - }) - }); + LAST.with(|last| *last.borrow_mut() = Some((text.to_string(), e.clone()))); } /// What `e`, the text that reached `fail`, means. @@ -70,14 +55,14 @@ pub fn classify(e: &str) -> Failure { } let api = LAST .with(|last| last.borrow().clone()) - .filter(|r| e.contains(&r.text)); + .filter(|(text, _)| e.contains(text)); match api { - Some(r) => Failure { - code: r.code.unwrap_or_else(|| "error".into()), + Some((text, api)) => Failure { + code: api.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, + message: e.replace(&text, &api.message), + status: api.status.map(|s| s.as_u16()), + field_errors: api.field_errors, }, None => local("error", None), } @@ -98,14 +83,7 @@ impl Failure { /// `{"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 }) + serde_json::json!({ "error": self }) } } diff --git a/src/main.rs b/src/main.rs index 22de77d..9269a03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -228,11 +228,11 @@ async fn main() { } Commands::Api(args) => { let token = api_token(&args.proxy_url, verbose).await; - let result = match api::Client::new(&args.api_url, token, verbose) { - Ok(client) => api_cmd::run(args.request, &client).await, - Err(e) => Err(e), + let result = async { + let client = api::Client::new(&args.api_url, token, verbose)?; + api_cmd::run(args.request, &client).await }; - if let Err(e) = result { + if let Err(e) = result.await { fail(e, false); } } @@ -250,17 +250,14 @@ async fn api_token(proxy_url: &str, verbose: bool) -> Option { { return Some(token); } - let token = match cache::env(proxy_url) { - Ok(env) => session::token(env.as_deref(), session::Want::Access, verbose).await, - Err(e) => Err(e), + let token = async { + let env = cache::env(proxy_url)?; + session::token(env.as_deref(), session::Want::Access, verbose).await }; - match token { - Ok(token) => token, - Err(e) => { - eprintln!("Warning: continuing signed out: {e}"); - None - } - } + token.await.unwrap_or_else(|e| { + eprintln!("Warning: continuing signed out: {e}"); + None + }) } async fn run_auth_token(args: TokenArgs, verbose: bool) -> Result<(), String> { diff --git a/src/picker.rs b/src/picker.rs index 3940333..ad33bd5 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -182,10 +182,7 @@ pub fn browse( } drawn = lines.len(); if near_end { - place.at = current; - place.filter = filter; - place.filtering = filtering; - place.drawn = drawn; + keep(place, current, filter, filtering, drawn); place.notice = notice; return Ok(Action::More); } @@ -204,10 +201,7 @@ pub fn browse( } Key::Enter if place.server_search && !filter.trim().is_empty() => { term.clear_last_lines(drawn).map_err(failed)?; - place.at = current; - place.filter = filter; - place.filtering = false; - place.drawn = 0; + keep(place, current, filter, false, 0); return Ok(Action::Search); } Key::Enter | Key::ArrowDown => filtering = false, @@ -247,17 +241,30 @@ pub fn browse( if let Some(action) = action { term.clear_last_lines(drawn).map_err(failed)?; // The filter stays for when they come back to the list. - place.at = current; - place.filter = filter; - place.filtering = false; - place.drawn = 0; + keep(place, current, filter, false, 0); return Ok(action); } } } -/// Wait for ←/Esc/h/q (back) or one of `keys`, showing -/// `hint` meanwhile. +/// The line a question leaves once answered: `