From 6a668e0b2ec2541a4d7359d99caac24097059a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:35:26 +0000 Subject: [PATCH] Document get's real paid_required JSON shape CLI_CONTRACT.md promised an envelope status "paidRequired" from get and scan. Nothing emits it: get prints its legacy {"status": "paid_required", ...} shape (exit 0) and scan only counts paid patches. Clients coding against the contract never matched. The contract now describes the shape get emits and says scan never reports it; the unused Status::PaidRequired variant is gone; get's two paid paths share one emitter, and a contract test pins the row against the source (#982). Also applies rustfmt to a test in redirect/upstream that main left unformatted. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +- crates/socket-patch-cli/src/commands/get.rs | 54 ++++++++++-------- crates/socket-patch-cli/src/json_envelope.rs | 11 +--- .../tests/contract_paid_required.rs | 56 +++++++++++++++++++ .../src/patch/redirect/upstream/mod.rs | 5 +- 5 files changed, 96 insertions(+), 34 deletions(-) create mode 100644 crates/socket-patch-cli/tests/contract_paid_required.rs diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..8ac278069 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1185,7 +1185,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified ```jsonc { "command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair", - "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound", + "status": "success" | "partialFailure" | "error" | "noManifest" | "notFound", "dryRun": false, "events": [ , ... ], "summary": { @@ -1256,7 +1256,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `apply_failed` | `failed` | apply: hash mismatch, write error, archive read error. | | `no_local_source` | `skipped`/`failed` | Agent patch application cannot obtain the required local or downloaded patch source. Vendored mode consumes complete server artifacts and no longer stages patch blobs. | | `offline_missing_sources` / `sources_download_failed` | apply run-level `warnings[]` | apply (additive): the patch sources were unavailable — `--offline` with no local source, or the download left a patch with no source — so nothing was attempted. The envelope keeps its pinned shape (`partialFailure`, empty `events[]`, zero summary, no top-level `error`); the warning is its machine-readable reason (the human path prints the staging `Error:` line on stderr instead, even under `--silent`). | -| `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. `get ` on the public proxy reports it (exit 0) both for a `tier: "paid"` view and for the proxy's 403 refusal, whose record then carries only `uuid` + `tier` (the proxy never named the purl). | +| `paid_required` | — (top-level `status` of `get`'s legacy JSON, not an event tag) | `get` only: every matching patch needs a paid plan the caller's token isn't entitled to. `get --json` prints `{"status": "paid_required", "found": N, "downloaded": 0, "applied": 0, "patches": [{"purl", "uuid", "tier"}, …]}` (plus any narrowing `skipped`/`warnings`), with no `events` and no `error`, and exits 0. `get ` on the public proxy reports it both for a `tier: "paid"` view and for the proxy's 403 refusal, whose record then carries only `uuid` + `tier` (the proxy never named the purl). `scan` never reports it: it leaves paid patches out of the selection and counts them in `paidPatches` / `canAccessPaidPatches`. | | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | | `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC surfaces the same condition in its run-level `warnings[]` (and `remove`'s extended archive GC on stderr) — same posture, never affects the exit. | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e83707d17..41a646572 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1468,19 +1468,18 @@ pub async fn run(args: GetArgs) -> i32 { if accessible.is_empty() { if args.common.json { - let mut result = serde_json::json!({ - "status": "paid_required", - "found": search_response.patches.len(), - "downloaded": 0, - "applied": 0, - "patches": search_response.patches.iter().map(|p| serde_json::json!({ - "purl": p.purl, - "uuid": p.uuid, - "tier": p.tier, - })).collect::>(), - }); - fold_narrowing_into_result(&mut result, &[], &org_warnings); - print_json(&result); + let records = search_response + .patches + .iter() + .map(|p| { + serde_json::json!({ + "purl": p.purl, + "uuid": p.uuid, + "tier": p.tier, + }) + }) + .collect(); + print_json(&paid_required_json(records, &org_warnings)); } else if !args.common.silent { let all: Vec<&PatchSearchResult> = search_response.patches.iter().collect(); if id_type == TargetKind::Name && !quiet { @@ -1751,6 +1750,25 @@ pub async fn run(args: GetArgs) -> i32 { code } +/// `get --json`'s one paid-plan shape (CLI_CONTRACT.md, `paid_required`): +/// the legacy top-level `status: "paid_required"` with the refused +/// `patches` records and nothing downloaded or applied. No `events`, no +/// `error`: it is a clean outcome (exit 0). +fn paid_required_json( + records: Vec, + org_warnings: &[(String, String)], +) -> serde_json::Value { + let mut result = serde_json::json!({ + "status": "paid_required", + "found": records.len(), + "downloaded": 0, + "applied": 0, + "patches": records, + }); + fold_narrowing_into_result(&mut result, &[], org_warnings); + result +} + /// `paid_required` for the uuid path: the patch exists but the caller /// (on the public proxy) cannot download it. A clean outcome, exit 0. /// `purl` is `None` when the proxy refused with 403 before naming it. @@ -1768,15 +1786,7 @@ async fn report_paid_required_uuid( if let Some(purl) = purl { record["purl"] = serde_json::json!(purl); } - let mut result = serde_json::json!({ - "status": "paid_required", - "found": 1, - "downloaded": 0, - "applied": 0, - "patches": [record], - }); - fold_narrowing_into_result(&mut result, &[], org_warnings); - print_json(&result); + print_json(&paid_required_json(vec![record], org_warnings)); } else if !args.common.silent { let name = purl.map(|p| normalize_purl(p).into_owned()); println!( diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 68bfcdfe5..6d398c323 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -451,12 +451,6 @@ pub enum Status { /// there's nothing to apply. Distinct from `Success` because some /// consumers want to early-exit on this state. NoManifest, - /// Reserved: the requested patch requires a paid plan but the caller's - /// API token isn't entitled. Nothing emits it yet (`get` reports this - /// via its legacy `status: "paid_required"` shape; scan never does). - /// Distinct from `Error` so PR bots can post a "upgrade your plan" - /// comment instead of failing. - PaidRequired, /// `remove` / `rollback`: the patch identifier didn't resolve to /// anything in the local manifest. NotFound, @@ -1193,7 +1187,6 @@ mod tests { // codes on these strings. for (status, tag) in [ (Status::NoManifest, "noManifest"), - (Status::PaidRequired, "paidRequired"), (Status::NotFound, "notFound"), ] { let mut env = Envelope::new(Command::Remove); @@ -1288,10 +1281,10 @@ mod tests { // ("Exit 1 when status is partialFailure (any events[*].action == // \"failed\")"). `record` enforces that by escalating every // non-Error status — including the success-like specials - // (`notFound`, `noManifest`, `paidRequired`) — to PartialFailure. + // (`notFound`, `noManifest`) — to PartialFailure. // Only a hard `Error` outranks it. Pin that so the auto-escalation // can't regress to leaving a `failed` event under an exit-0 status. - for start in [Status::NotFound, Status::NoManifest, Status::PaidRequired] { + for start in [Status::NotFound, Status::NoManifest] { let mut env = Envelope::new(Command::Remove); env.status = start; env.record( diff --git a/crates/socket-patch-cli/tests/contract_paid_required.rs b/crates/socket-patch-cli/tests/contract_paid_required.rs new file mode 100644 index 000000000..51662d28d --- /dev/null +++ b/crates/socket-patch-cli/tests/contract_paid_required.rs @@ -0,0 +1,56 @@ +//! #982: CLI_CONTRACT.md's paid-plan contract matches what ships. +//! +//! `get --json` reports a paid-only result through its legacy top-level +//! `status: "paid_required"` (one emitter), and no command emits the +//! envelope status `paidRequired`, so the contract must neither list that +//! status nor describe `paid_required` as an event tag `scan` emits. The +//! emitted shape itself is pinned by the `--test get paid` tests. + +use std::path::Path; + +fn read(rel: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .replace("\r\n", "\n") +} + +#[test] +fn contract_paid_required_row_matches_get() { + let contract = read("CLI_CONTRACT.md"); + let row = contract + .lines() + .find(|l| l.starts_with("| `paid_required`")) + .expect("CLI_CONTRACT.md documents `paid_required`"); + assert!( + row.contains(r#""status": "paid_required""#), + "the row names the status spelling `get` emits: {row}" + ); + assert!( + !row.contains("paidRequired"), + "no command emits the envelope status `paidRequired`: {row}" + ); + assert!( + row.contains("`scan` never reports it"), + "scan has no paid_required outcome: {row}" + ); + assert!( + !contract.contains("\"paidRequired\""), + "the envelope status enum must not list `paidRequired`" + ); +} + +#[test] +fn get_writes_the_paid_required_shape_once() { + let get = read("src/commands/get.rs"); + assert_eq!( + get.matches(r#""status": "paid_required""#).count(), + 1, + "both paid paths share one emitter" + ); + let envelope = read("src/json_envelope.rs"); + assert!( + !envelope.contains("PaidRequired"), + "the envelope has no paid status variant" + ); +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 944435d03..8c2a0d793 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -917,7 +917,10 @@ mod tests { fn bun_lock_remedies_name_the_forced_reinstall() { for file in ["bun.lockb", "bun.lock", "packages/app/bun.lockb"] { let remedy = checkout_remedy(&[file.to_string()]); - assert!(remedy.contains(&format!("`git checkout -- {file}`")), "{remedy}"); + assert!( + remedy.contains(&format!("`git checkout -- {file}`")), + "{remedy}" + ); assert!(remedy.ends_with( ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" ), "{remedy}");