@@ -43,6 +43,7 @@ use crate::ecosystem_dispatch::{ |
43 | 43 | crawl_all_ecosystems, find_all_packages_for_rollback, find_packages_for_rollback, |
44 | 44 | partition_purls, |
45 | 45 | }; |
| 46 | +use crate::json_envelope::{usage_error, Command as JsonCommand}; |
46 | 47 | use crate::ui::{print_json, select_one, SelectError}; |
47 | 48 |
|
48 | 49 | /// Best-effort ecosystem extractor for a `pkg:<eco>/...` PURL. Used as |
@@ -228,26 +229,28 @@ async fn report_fetch_failure( |
228 | 229 | ) -> i32 { |
229 | 230 | let msg = error.to_string(); |
230 | 231 | track_patch_fetch_failed(identifier, &msg, fallback_to_proxy, api_token, org_slug).await; |
231 | | - report_error(json, msg); |
| 232 | + report_error(json, "patch_fetch_failed", msg); |
232 | 233 | 1 |
233 | 234 | } |
234 | 235 |
|
235 | | -/// Report an error to the caller: a `{status, error}` envelope on |
236 | | -/// stdout when `json` is true, otherwise a plain `Error: ...` on stderr. |
237 | | -fn report_error(json: bool, message: impl std::fmt::Display) { |
| 236 | +/// Report an error to the caller: a `{status: "error", error: {code, |
| 237 | +/// message}}` object on stdout when `json` is true, otherwise a plain |
| 238 | +/// `Error: ...` on stderr. Every top-level `get` failure goes through here |
| 239 | +/// (or [`report_lock_failure`]) so the error shape cannot drift. |
| 240 | +fn report_error(json: bool, code: &str, message: impl std::fmt::Display) { |
238 | 241 | let message = message.to_string(); |
239 | 242 | if json { |
240 | | - print_json(&serde_json::json!({"status": "error", "error": message})); |
| 243 | + crate::json_envelope::print_legacy_error(code, &message); |
241 | 244 | } else { |
242 | 245 | eprintln!("Error: {message}"); |
243 | 246 | } |
244 | 247 | } |
245 | 248 |
|
246 | 249 | /// Report a failed apply-lock acquire in get's legacy error shape — the |
247 | | -/// `{status: "error", error: "<message>"}` envelope every other hard error |
248 | | -/// here uses, plus the stable `errorCode` (`lock_held` / `lock_io`) the |
249 | | -/// other lock sites emit — and return the envelope for the caller's |
250 | | -/// early-return guard. The message/code mapping is |
| 250 | +/// `{status: "error", error: {code, message}}` object every other hard |
| 251 | +/// error here uses, with the stable code (`lock_held` / `lock_io`) the |
| 252 | +/// other lock sites emit — and return it for the caller's early-return |
| 253 | +/// guard. The message/code mapping is |
251 | 254 | /// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and |
252 | 255 | /// the I/O rendering cannot drift from `apply`'s. |
253 | 256 | fn report_lock_failure( |
@@ -257,11 +260,7 @@ fn report_lock_failure( |
257 | 260 | timeout: Duration, |
258 | 261 | ) -> serde_json::Value { |
259 | 262 | let (code, message) = lock_failure(err, timeout); |
260 | | - let envelope = serde_json::json!({ |
261 | | - "status": "error", |
262 | | - "errorCode": code, |
263 | | - "error": message, |
264 | | - }); |
| 263 | + let envelope = crate::json_envelope::legacy_error(code, &message); |
265 | 264 | if json { |
266 | 265 | print_json(&envelope); |
267 | 266 | } else { |
@@ -1075,7 +1074,10 @@ pub(crate) fn select_patches( |
1075 | 1074 | .collect(); |
1076 | 1075 | print_json(&serde_json::json!({ |
1077 | 1076 | "status": "selection_required", |
1078 | | - "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get <uuid>`) to select one."), |
| 1077 | + "error": { |
| 1078 | + "code": "selection_required", |
| 1079 | + "message": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get <uuid>`) to select one."), |
| 1080 | + }, |
1079 | 1081 | "purl": purl, |
1080 | 1082 | "options": options_json, |
1081 | 1083 | })); |
@@ -2399,7 +2401,8 @@ fn apply_key_covers(key: &str, record: &str) -> bool { |
2399 | 2401 | /// as on every `failed` record); any other failed manifest patch (one this |
2400 | 2402 | /// run did not select) gets its own `failed` record (`uuid_of` looks up |
2401 | 2403 | /// its uuid); a run-level reason rides the envelope's top-level |
2402 | | -/// `errorCode` / `error`. `failed` grows by every record marked or |
| 2404 | +/// `error: {code, message}` (status stays `partial_failure`: the downloads |
| 2405 | +/// it reports still landed). `failed` grows by every record marked or |
2403 | 2406 | /// appended here. Returns `applied`: how many of the run's recorded |
2404 | 2407 | /// patches apply really patched (or found already patched). |
2405 | 2408 | fn fold_apply_failures( |
@@ -2465,8 +2468,10 @@ fn fold_apply_failures( |
2465 | 2468 | let failed = envelope["failed"].as_u64().unwrap_or(0) as usize + added; |
2466 | 2469 | envelope["failed"] = serde_json::json!(failed); |
2467 | 2470 | if let Some((code, error)) = &report.run_error { |
2468 | | - envelope["errorCode"] = serde_json::json!(code); |
2469 | | - envelope["error"] = serde_json::json!(error); |
| 2471 | + crate::json_envelope::set_error_keep_status( |
| 2472 | + envelope, |
| 2473 | + crate::json_envelope::EnvelopeError::new(code, error), |
| 2474 | + ); |
2470 | 2475 | } |
2471 | 2476 | applied |
2472 | 2477 | } |
@@ -2512,8 +2517,11 @@ pub async fn download_and_apply_patches_with( |
2512 | 2517 | // and destroy every tracked patch record. |
2513 | 2518 | Err(e) => { |
2514 | 2519 | let err = format!("Failed to read manifest: {e}"); |
2515 | | - report_error(params.json, &err); |
2516 | | - return (1, serde_json::json!({"status": "error", "error": err})); |
| 2520 | + report_error(params.json, "manifest_unreadable", &err); |
| 2521 | + return ( |
| 2522 | + 1, |
| 2523 | + crate::json_envelope::legacy_error("manifest_unreadable", &err), |
| 2524 | + ); |
2517 | 2525 | } |
2518 | 2526 | }; |
2519 | 2527 |
|
@@ -2565,8 +2573,11 @@ pub async fn download_and_apply_patches_with( |
2565 | 2573 | // unwind exactly those (a pre-existing record's blobs stay). |
2566 | 2574 | unwind_new_blobs(&blobs_dir, &new_blobs).await; |
2567 | 2575 | let msg = format!("Failed to write manifest: {e}"); |
2568 | | - report_error(params.json, &msg); |
2569 | | - return (1, serde_json::json!({ "status": "error", "error": msg })); |
| 2576 | + report_error(params.json, "manifest_write_failed", &msg); |
| 2577 | + return ( |
| 2578 | + 1, |
| 2579 | + crate::json_envelope::legacy_error("manifest_write_failed", &msg), |
| 2580 | + ); |
2570 | 2581 | } |
2571 | 2582 | } |
2572 | 2583 | // Every selected patch that is now recorded is owed the nested apply: |
@@ -2664,11 +2675,13 @@ pub async fn run(args: GetArgs) -> i32 { |
2664 | 2675 | .filter(|&&f| f) |
2665 | 2676 | .count(); |
2666 | 2677 | if type_flags > 1 { |
2667 | | - report_error( |
| 2678 | + return usage_error( |
| 2679 | + JsonCommand::Get, |
2668 | 2680 | args.common.json, |
| 2681 | + args.common.dry_run, |
| 2682 | + "invalid_args", |
2669 | 2683 | "Only one of --id, --cve, --ghsa, or --package can be specified", |
2670 | 2684 | ); |
2671 | | - return 2; |
2672 | 2685 | } |
2673 | 2686 | // v5: hosted by default, like scan. `--save-only` (records a manifest |
2674 | 2687 | // entry) and global installs (no project lockfile) mean agent mode. |
@@ -2683,20 +2696,27 @@ pub async fn run(args: GetArgs) -> i32 { |
2683 | 2696 | // Global installs have no project lockfile: an explicit hosted or |
2684 | 2697 | // vendored mode would rewire the cwd project, not the global copy. |
2685 | 2698 | if let Some(conflict) = super::global_mode_conflict(&args.common, mode) { |
2686 | | - report_error(args.common.json, conflict); |
2687 | | - return 2; |
| 2699 | + return usage_error( |
| 2700 | + JsonCommand::Get, |
| 2701 | + args.common.json, |
| 2702 | + args.common.dry_run, |
| 2703 | + "global_scope_unsupported", |
| 2704 | + &conflict, |
| 2705 | + ); |
2688 | 2706 | } |
2689 | 2707 | if args.save_only && mode != super::scan::ScanMode::Agent { |
2690 | | - report_error( |
| 2708 | + return usage_error( |
| 2709 | + JsonCommand::Get, |
2691 | 2710 | args.common.json, |
2692 | | - format!( |
| 2711 | + args.common.dry_run, |
| 2712 | + "invalid_args", |
| 2713 | + &format!( |
2693 | 2714 | "--save-only cannot be used with --mode {}: hosted mode never writes the \ |
2694 | 2715 | manifest, and vendored mode's vendor step IS the persistence (plain \ |
2695 | 2716 | `get --save-only` already records without applying)", |
2696 | 2717 | mode.cli_name() |
2697 | 2718 | ), |
2698 | 2719 | ); |
2699 | | - return 2; |
2700 | 2720 | } |
2701 | 2721 | // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the |
2702 | 2722 | // network; operations that need remote data fail loudly). Every `get` |
@@ -2707,6 +2727,7 @@ pub async fn run(args: GetArgs) -> i32 { |
2707 | 2727 | if args.common.offline { |
2708 | 2728 | report_error( |
2709 | 2729 | args.common.json, |
| 2730 | + "offline_unsupported", |
2710 | 2731 | "Fetching patches needs network access, so `get` cannot run with \ |
2711 | 2732 | --offline/SOCKET_OFFLINE (strict airgap)", |
2712 | 2733 | ); |
@@ -2729,8 +2750,13 @@ pub async fn run(args: GetArgs) -> i32 { |
2729 | 2750 | // a typo reads as a plain message instead of a raw API 400 body. |
2730 | 2751 | if args.id || args.cve || args.ghsa { |
2731 | 2752 | if let Some(err) = forced_identifier_error(&args.identifier, id_type) { |
2732 | | - report_error(args.common.json, err); |
2733 | | - return 2; |
| 2753 | + return usage_error( |
| 2754 | + JsonCommand::Get, |
| 2755 | + args.common.json, |
| 2756 | + args.common.dry_run, |
| 2757 | + "identifier_invalid", |
| 2758 | + &err, |
| 2759 | + ); |
2734 | 2760 | } |
2735 | 2761 | } |
2736 | 2762 |
|
@@ -3356,7 +3382,11 @@ async fn agent_dry_run( |
3356 | 3382 | let manifest = match read_manifest(&args.common.resolved_manifest_path()).await { |
3357 | 3383 | Ok(m) => m.unwrap_or_else(PatchManifest::new), |
3358 | 3384 | Err(e) => { |
3359 | | - report_error(args.common.json, format!("Failed to read manifest: {e}")); |
| 3385 | + report_error( |
| 3386 | + args.common.json, |
| 3387 | + "manifest_unreadable", |
| 3388 | + format!("Failed to read manifest: {e}"), |
| 3389 | + ); |
3360 | 3390 | return 1; |
3361 | 3391 | } |
3362 | 3392 | }; |
@@ -3446,7 +3476,11 @@ async fn save_patch_record( |
3446 | 3476 | // treated as empty would be rewritten below with only this one |
3447 | 3477 | // patch, destroying every tracked record. |
3448 | 3478 | Err(e) => { |
3449 | | - report_error(args.common.json, format!("Failed to read manifest: {e}")); |
| 3479 | + report_error( |
| 3480 | + args.common.json, |
| 3481 | + "manifest_unreadable", |
| 3482 | + format!("Failed to read manifest: {e}"), |
| 3483 | + ); |
3450 | 3484 | return Err(1); |
3451 | 3485 | } |
3452 | 3486 | }; |
@@ -3463,6 +3497,7 @@ async fn save_patch_record( |
3463 | 3497 | if files.is_empty() { |
3464 | 3498 | report_error( |
3465 | 3499 | args.common.json, |
| 3500 | + "patch_no_applicable_files", |
3466 | 3501 | format!( |
3467 | 3502 | "Patch {} has no applicable files; nothing to apply", |
3468 | 3503 | patch.purl |
@@ -3488,7 +3523,10 @@ async fn save_patch_record( |
3488 | 3523 | "found": 1, |
3489 | 3524 | "downloaded": 0, |
3490 | 3525 | "applied": 0, |
3491 | | - "error": "Blob decode or write failed", |
| 3526 | + "error": { |
| 3527 | + "code": "blob_write_failed", |
| 3528 | + "message": "Blob decode or write failed", |
| 3529 | + }, |
3492 | 3530 | "patches": [{ |
3493 | 3531 | "purl": patch.purl, |
3494 | 3532 | "uuid": patch.uuid, |
@@ -3511,7 +3549,11 @@ async fn save_patch_record( |
3511 | 3549 | if let Err(e) = write_manifest(manifest_path, &manifest).await { |
3512 | 3550 | // No record points at the blobs just written: unwind exactly those. |
3513 | 3551 | unwind_new_blobs(&blobs_dir, &new_blobs).await; |
3514 | | - report_error(args.common.json, format!("Failed to write manifest: {e}")); |
| 3552 | + report_error( |
| 3553 | + args.common.json, |
| 3554 | + "manifest_write_failed", |
| 3555 | + format!("Failed to write manifest: {e}"), |
| 3556 | + ); |
3515 | 3557 | return Err(1); |
3516 | 3558 | } |
3517 | 3559 | Ok(action) |
@@ -3958,8 +4000,10 @@ async fn run_get_vendored( |
3958 | 4000 | result["vendor"] = |
3959 | 4001 | serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); |
3960 | 4002 | } |
3961 | | - result["status"] = serde_json::json!("error"); |
3962 | | - result["error"] = serde_json::json!({ "code": code, "message": message }); |
| 4003 | + crate::json_envelope::set_error( |
| 4004 | + &mut result, |
| 4005 | + crate::json_envelope::EnvelopeError::new(code, message), |
| 4006 | + ); |
3963 | 4007 | print_json(&result); |
3964 | 4008 | } else { |
3965 | 4009 | eprintln!( |
@@ -4953,8 +4997,12 @@ mod tests { |
4953 | 4997 | applied: Vec::new(), |
4954 | 4998 | }; |
4955 | 4999 | assert_eq!(fold_apply_failures(&mut env, &report, |_| None), 0); |
4956 | | - assert_eq!(env["errorCode"], "yarn_pnp_unsupported", "{env}"); |
4957 | | - assert_eq!(env["error"], "pnp", "{env}"); |
| 5000 | + assert!(env.get("errorCode").is_none(), "{env}"); |
| 5001 | + assert_eq!( |
| 5002 | + env["error"], |
| 5003 | + serde_json::json!({"code": "yarn_pnp_unsupported", "message": "pnp"}), |
| 5004 | + "{env}" |
| 5005 | + ); |
4958 | 5006 | assert_eq!(env["failed"], 0, "{env}"); |
4959 | 5007 | } |
4960 | 5008 |
|
|