diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f5b672 --- /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`, `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 + +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..7f0cc5d 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 `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`. + +### 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..d785ac1 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,310 @@ +//! 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, Clone)] +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 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 { + 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; + // `..` 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() + )); + } + 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, 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] + 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..fb3a8ff --- /dev/null +++ b/src/api_cmd.rs @@ -0,0 +1,220 @@ +//! `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, + }; + 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(); + 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) => crate::output::json(&json), + Err(_) if text.is_empty() => {} + Err(_) => println!("{text}"), + } + if status.is_success() { + Ok(()) + } else { + // Through `From`, which keeps the code and status for the exit code. + Err(ApiError::from_response(status, &text).into()) + } +} + +/// `-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)")); + // 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}"); + } +} diff --git a/src/cache.rs b/src/cache.rs index 792393f..2757b1b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -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..0193a79 --- /dev/null +++ b/src/failure.rs @@ -0,0 +1,158 @@ +//! 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, 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>, +} + +thread_local! { + /// 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((text.to_string(), e.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(|(text, _)| e.contains(text)); + match api { + 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(&text, &api.message), + status: api.status.map(|s| s.as_u16()), + field_errors: api.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 { + serde_json::json!({ "error": self }) + } +} + +#[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..9269a03 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,53 @@ 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 = async { + let client = api::Client::new(&args.api_url, token, verbose)?; + api_cmd::run(args.request, &client).await + }; + if let Err(e) = result.await { + 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 = async { + let env = cache::env(proxy_url)?; + session::token(env.as_deref(), session::Want::Access, verbose).await + }; + token.await.unwrap_or_else(|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 +457,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..ad33bd5 --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,519 @@ +//! 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 { + keep(place, current, filter, filtering, 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)?; + keep(place, current, filter, false, 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. + keep(place, current, filter, false, 0); + return Ok(action); + } + } +} + +/// The line a question leaves once answered: `