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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [ <PatchEvent>, ... ],
"summary": {
Expand Down Expand Up @@ -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 <uuid>` 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 <uuid>` 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. |
Expand Down
54 changes: 32 additions & 22 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(),
});
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 {
Expand Down Expand Up @@ -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<serde_json::Value>,
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.
Expand All @@ -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!(
Expand Down
11 changes: 2 additions & 9 deletions crates/socket-patch-cli/src/json_envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions crates/socket-patch-cli/tests/contract_paid_required.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading