From 47aee20e1cd1efe7574e466bb598cf9d4aa434c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:37:11 +0000 Subject: [PATCH 01/14] fix(cli): standardize unpublished machine output contracts --- apps/desktop/README.md | 5 + apps/desktop/core/src/runner.rs | 91 ++++++++++-- apps/desktop/core/src/source.rs | 6 +- apps/desktop/core/tests/dump_state.rs | 4 +- .../machine-output/global-install-plan.json | 12 ++ .../machine-output/global-status-observe.json | 13 ++ .../machine-output/global-trust-plan.json | 12 ++ .../machine-output/plan-incomplete.json | 18 +++ .../machine-output/projects-observe.json | 10 ++ .../fixtures/machine-output/version.json | 10 ++ apps/desktop/core/tests/machine_output.rs | 68 +++++++++ docs/cli.md | 3 +- docs/reference.md | 1 + docs/reference/cli.md | 2 +- docs/reference/global-admin-plan.md | 94 ++++++++++--- docs/reference/global-status-observe.md | 68 ++++++--- docs/reference/machine-readable-output.md | 132 ++++++++++++++++++ docs/reference/machine-readable-output.mmd | 7 + docs/reference/machine-readable-output.svg | 66 +++++++++ docs/reference/version.md | 15 +- docs/versions/v5/guides/agent-first-setup.md | 5 + .../hack-diagnose/references/error-codes.md | 4 +- scripts/generate-machine-output-reference.ts | 30 ++++ scripts/lib/machine-output-fixtures.ts | 56 ++++++++ src/agents/error-code-reference.ts | 10 ++ src/cli/json-contracts.ts | 107 ++++++++++++++ src/cli/json-reference.ts | 65 +++++++++ src/cli/reference.ts | 2 +- src/commands/global-admin-plan.ts | 3 +- src/commands/global-status-observe.ts | 3 +- src/commands/global.ts | 52 +++++-- src/commands/projects.ts | 8 +- src/commands/version.ts | 7 +- src/lib/cli-result.ts | 9 +- src/lib/machine-output.ts | 16 +++ tests/admin-plan-json-contract.test.ts | 114 +++++++++++++++ tests/global-command.macos.test.ts | 9 +- tests/global-command.test.ts | 16 ++- tests/global-status-observe.test.ts | 21 ++- tests/helpers/machine-output.ts | 19 +++ tests/machine-output-contract.test.ts | 118 ++++++++++++++++ tests/projects-observe.test.ts | 30 +++- tests/version-report.test.ts | 4 +- 43 files changed, 1249 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/projects-observe.json create mode 100644 apps/desktop/core/tests/fixtures/machine-output/version.json create mode 100644 apps/desktop/core/tests/machine_output.rs create mode 100644 docs/reference/machine-readable-output.md create mode 100644 docs/reference/machine-readable-output.mmd create mode 100644 docs/reference/machine-readable-output.svg create mode 100644 scripts/generate-machine-output-reference.ts create mode 100644 scripts/lib/machine-output-fixtures.ts create mode 100644 src/cli/json-contracts.ts create mode 100644 src/cli/json-reference.ts create mode 100644 src/lib/machine-output.ts create mode 100644 tests/admin-plan-json-contract.test.ts create mode 100644 tests/helpers/machine-output.ts create mode 100644 tests/machine-output-contract.test.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 03fe99700..af83baccf 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -31,6 +31,11 @@ cargo run -p hack-desktop -- --list-fixtures cargo run -p hack-desktop -- --fixtures failed-start --theme dark ``` +The core reads version identity through `hack version --json` and validates its +`hack.version.v1` schema inside the success envelope. New schema-tagged commands +use the strict parser; daemon and published legacy payloads keep their existing +parsers. Shared boundary fixtures are checked against the CLI producers. + ## Check the live data path without a window ```sh diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index e41886c2a..67c4591ff 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -274,19 +274,41 @@ impl CliRunner { }) } - /// `hack --version` → `"5.0.0"` (text output until request R1). + /// Run a HACK-2218 command with a required envelope and payload schema. + /// Legacy JSON commands continue to use `run_json`. + pub fn run_schema_json( + &self, + args: &[&str], + path: Option<&Path>, + timeout: Duration, + schema: &str, + ) -> Result { + let (stdout, stderr, code) = self.run_raw(args, path, timeout)?; + let value = parse_schema_result(&stdout, &stderr, code, schema)?; + serde_json::from_value(value).map_err(|e| RunError::BadJson { + error: e.to_string(), + stdout_head: head(&stdout), + }) + } + + /// Read the running CLI version through the schema-tagged JSON contract. /// /// # Errors - /// Any [`RunError`]. + /// Any [`RunError`], including an unsupported or missing schema. pub fn version(&self, timeout: Duration) -> Result { - let (stdout, stderr, code) = self.run_raw(&["--version"], None, timeout)?; - if code != Some(0) { - return Err(RunError::Exit { code, stderr }); + #[derive(serde::Deserialize)] + struct Version { + version: String, } - parse_version(&stdout).ok_or_else(|| RunError::BadJson { - error: "no version in output".into(), - stdout_head: head(&stdout), - }) + let report: Version = + self.run_schema_json(&["version", "--json"], None, timeout, "hack.version.v1")?; + if report.version.is_empty() { + return Err(RunError::BadJson { + error: "empty version".into(), + stdout_head: String::new(), + }); + } + Ok(report.version) } /// Start a long-running ndjson stream (`hack logs --json ...`). @@ -535,6 +557,53 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option) -> Result, + schema: &str, +) -> Result { + let invalid = || RunError::BadJson { + error: format!("expected CLI envelope with schema {schema}"), + stdout_head: head(stdout), + }; + let value: Value = serde_json::from_str(stdout).map_err(|_| invalid())?; + if value.as_object().is_none_or(|object| object.len() != 2) { + return Err(invalid()); + } + match value.get("ok").and_then(Value::as_bool) { + Some(false) if code.is_some_and(|code| code != 0) => { + let error = value.get("error").ok_or_else(invalid)?; + if !error + .get("code") + .and_then(Value::as_str) + .is_some_and(|code| { + code.strip_prefix("E_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix.bytes().all(|byte| { + byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_' + }) + }) + }) + || error.get("message").and_then(Value::as_str).is_none() + { + return Err(invalid()); + } + parse_result(stdout, stderr, code) + } + Some(true) if code == Some(0) => { + let data = value.get("data").ok_or_else(invalid)?; + if data.get("schema").and_then(Value::as_str) != Some(schema) { + return Err(invalid()); + } + Ok(data.clone()) + } + _ => Err(invalid()), + } +} + /// `hack v5.0.0` / `5.0.0-next.3` → the version string. #[must_use] pub fn parse_version(text: &str) -> Option { @@ -884,7 +953,9 @@ wait $!"#, Some("5.0.0-next.3") ); assert_eq!(parse_version("hack"), None); - let f = FakeHack::new("echo 'hack v5.0.0'"); + let f = FakeHack::new( + r#"echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}'"#, + ); assert_eq!(f.runner().version(T).unwrap(), "5.0.0"); } diff --git a/apps/desktop/core/src/source.rs b/apps/desktop/core/src/source.rs index 1c97ea5f0..410aef378 100644 --- a/apps/desktop/core/src/source.rs +++ b/apps/desktop/core/src/source.rs @@ -570,7 +570,7 @@ mod tests { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo '{"runtime_ok":true,"projects":[{"name":"svc","status":"running","repo_root":"/work/svc"},{"name":"nat","status":"unavailable","repo_root":"/work/nat","input_diagnostic":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}]}' ;; ps) if [ "$HACK_RUNTIME_BACKEND" = native ]; then echo '{"ok":true,"data":{"backend":"native","status":"pending","items":[]}}'; else echo '{"ok":false,"error":{"code":"E_NATIVE_PROJECT_UNSUPPORTED","message":"m"}}'; exit 1; fi ;; esac @@ -579,7 +579,7 @@ esac .unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); // Complete macOS first-launch scanning before the fake daemon's - // bounded accept window starts. --version has no fixture effects. + // bounded accept window starts. version --json has no fixture effects. CliRunner::new(&hack) .version(Duration::from_secs(60)) .unwrap(); @@ -629,7 +629,7 @@ esac let hack = dir.join("hack"); std::fs::write( &hack, - "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0' ;; projects) echo '{}' ;; esac\n", + "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; projects) echo '{}' ;; esac\n", ) .unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o755)).unwrap(); diff --git a/apps/desktop/core/tests/dump_state.rs b/apps/desktop/core/tests/dump_state.rs index 79135841f..f60333a03 100644 --- a/apps/desktop/core/tests/dump_state.rs +++ b/apps/desktop/core/tests/dump_state.rs @@ -73,7 +73,7 @@ fn dump_state_reads_the_cli_like_the_app_and_prints_view_models() { &hack, r#"#!/bin/sh case "$1" in - --version) echo "hack v5.0.0" ;; + version) echo '{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}}' ;; projects) echo called > "$HACK_HOME/unsafe-listing"; exit 2 ;; *) echo "unexpected: $*" >&2; exit 2 ;; esac @@ -145,7 +145,7 @@ fn dump_state_without_hack_reports_the_missing_cli() { fn missing_daemon_never_runs_the_registry_writing_cli_listing() { let home = temp_dir("observe"); let hack = home.join("hack"); - std::fs::write(&hack, "#!/bin/sh\ncase \"$1\" in --version) echo 'hack v5.0.0';; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n").unwrap(); + std::fs::write(&hack, "#!/bin/sh\ncase \"$1\" in version) echo '{\"ok\":true,\"data\":{\"schema\":\"hack.version.v1\",\"version\":\"5.0.0\"}}' ;; *) echo called > \"$HACK_HOME/unexpected\"; exit 1;; esac\n").unwrap(); std::fs::set_permissions(&hack, std::fs::Permissions::from_mode(0o700)).unwrap(); let out = Command::new(env!("CARGO_BIN_EXE_hack-desktop-state")) .env("PATH", &home) diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json new file mode 100644 index 000000000..a32920e8c --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-install-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json new file mode 100644 index 000000000..027202bba --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-status-observe.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "data": { + "observe": true, + "docker": { + "state": "not_running" + }, + "summary": { + "ok": "unknown" + }, + "schema": "hack.global-status.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json new file mode 100644 index 000000000..1b6bf94a5 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/global-trust-plan.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "data": { + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json new file mode 100644 index 000000000..e930570d7 --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/plan-incomplete.json @@ -0,0 +1,18 @@ +{ + "ok": false, + "error": { + "code": "E_PLAN_INCOMPLETE", + "message": "Administrator plan is incomplete.", + "detail": { + "plan": { + "command": "install", + "platform": "linux", + "refused": null, + "complete": false, + "blocked": [], + "steps": [], + "schema": "hack.global-admin-plan.v1" + } + } + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json new file mode 100644 index 000000000..87802e7ce --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/projects-observe.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "observe": true, + "runtime_requested": false, + "runtime_ok": false, + "projects": [], + "schema": "hack.projects.observe.v1" + } +} diff --git a/apps/desktop/core/tests/fixtures/machine-output/version.json b/apps/desktop/core/tests/fixtures/machine-output/version.json new file mode 100644 index 000000000..440c4288a --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/version.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "data": { + "version": "5.0.0", + "channel": "stable", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} diff --git a/apps/desktop/core/tests/machine_output.rs b/apps/desktop/core/tests/machine_output.rs new file mode 100644 index 000000000..e307ed520 --- /dev/null +++ b/apps/desktop/core/tests/machine_output.rs @@ -0,0 +1,68 @@ +//! The same fixtures are checked against the CLI producers by Bun tests. +use hack_desktop_core::runner::{RunError, parse_schema_result}; +use serde_json::Value; + +#[test] +fn migrated_payloads_require_the_expected_envelope_and_schema() { + let cases = [ + ( + include_str!("fixtures/machine-output/version.json"), + "hack.version.v1", + ), + ( + include_str!("fixtures/machine-output/projects-observe.json"), + "hack.projects.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-status-observe.json"), + "hack.global-status.observe.v1", + ), + ( + include_str!("fixtures/machine-output/global-install-plan.json"), + "hack.global-admin-plan.v1", + ), + ( + include_str!("fixtures/machine-output/global-trust-plan.json"), + "hack.global-admin-plan.v1", + ), + ]; + for (text, schema) in cases { + let value = parse_schema_result(text, "", Some(0), schema).unwrap(); + assert_eq!(value["schema"], schema); + // Bare payloads, wrong schemas and success with a failing process are refused. + assert!(parse_schema_result(&value.to_string(), "", Some(0), schema).is_err()); + assert!(parse_schema_result(text, "", Some(0), "unknown.schema").is_err()); + assert!(parse_schema_result(text, "", Some(1), schema).is_err()); + } +} + +#[test] +fn incomplete_admin_plan_is_an_error_with_preserved_recovery_evidence() { + let text = include_str!("fixtures/machine-output/plan-incomplete.json"); + let error = parse_schema_result(text, "", Some(3), "hack.global-admin-plan.v1").unwrap_err(); + match error { + RunError::Cli(error) => { + assert_eq!(error.code, "E_PLAN_INCOMPLETE"); + assert_eq!( + error.detail.unwrap()["plan"]["schema"], + "hack.global-admin-plan.v1" + ); + } + other => panic!("unexpected result: {other:?}"), + } + assert!(parse_schema_result(text, "", Some(0), "hack.global-admin-plan.v1").is_err()); +} + +#[test] +fn malformed_envelopes_cannot_fall_back_to_legacy_parsing() { + for value in [ + serde_json::json!({"ok": true, "data": {}}), + serde_json::json!({"ok": false, "error": "legacy message"}), + serde_json::json!({"ok": false, "error": {"code": "BAD", "message": "message"}}), + serde_json::json!({"ok": false, "error": {"code": "E_", "message": "message"}}), + serde_json::json!({"ok": true, "data": {"schema": "hack.version.v1"}, "extra": true}), + Value::Null, + ] { + assert!(parse_schema_result(&value.to_string(), "", Some(1), "hack.version.v1").is_err()); + } +} diff --git a/docs/cli.md b/docs/cli.md index dc4b4c9ff..5dbf5bf57 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,7 +132,8 @@ ps/inspect reads, which can start a socket-activated engine (for example a systemd Docker socket or an on-demand macOS engine). Both modes bypass hackd, including when a daemon is available. `--all` discovers runtime-only projects only with `--runtime`. -Observe returns the normal listing fields plus `observe: true` and +Observe returns a success envelope with `data.schema: "hack.projects.observe.v1"`. +The `data` payload contains the normal listing fields plus `observe: true` and `runtime_requested: false|true`. Without runtime queries, `runtime_error` and `runtime_checked_at` are null rather than claiming Docker failed. Each full or summary row adds `backend`, `state`, `state_reason`, `started_at` and `services`. diff --git a/docs/reference.md b/docs/reference.md index 696fb696e..72070ff00 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -11,6 +11,7 @@ ## Environment & sessions - [Version identity](./reference/version.md) — `hack version --json` describes the running CLI without starting services +- [Machine-readable output](./reference/machine-readable-output.md) — envelope rule, schema identifiers and the complete JSON command inventory - [Environment model](./env.md) - [Lifecycle & startup](./lifecycle.md) - [Admin plans](./reference/global-admin-plan.md) — `hack global install|trust --plan --json`: the ordered root and user steps and exact root argv, without running anything diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..cc6f8fb96 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -4,7 +4,7 @@ hack — Local development without the port-collision tax -Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout. +Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md). ## Project diff --git a/docs/reference/global-admin-plan.md b/docs/reference/global-admin-plan.md index 456612238..b38872060 100644 --- a/docs/reference/global-admin-plan.md +++ b/docs/reference/global-admin-plan.md @@ -40,30 +40,79 @@ to run the listed commands. ```json { - "schema": "hack.global-admin-plan.v1", - "command": "trust", - "platform": "linux", - "refused": null, - "complete": true, - "blocked": [], - "steps": [ - { - "order": 1, - "privilege": "root", - "id": "system-trust-install", - "description": "Install the CA (SHA-256 …) into the debian trust anchors; root accepts only these exact bytes", - "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C", "LC_ALL=C", "/bin/sh", "-c", "", "hack-install", "", "", "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", "0644"], - "shell": "/usr/bin/sudo /usr/bin/env -i … 0644", - "stdin": { "bytes": 652, "sha256": "…", "content": "the verified Caddy Local CA (PEM)" }, - "when": "after you confirm the system-wide trust prompt on an interactive terminal; …" - }, - { "order": 2, "privilege": "root", "id": "system-trust-refresh", "argv": ["/usr/bin/sudo", "/usr/bin/env", "-i", "…", "/usr/sbin/update-ca-certificates"], "…": "…" }, - { "order": 3, "privilege": "user", "id": "nss-trust", "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", "when": "…" }, - { "order": 4, "privilege": "user", "id": "host-tls-env", "description": "…", "when": "…" } - ] + "ok": true, + "data": { + "schema": "hack.global-admin-plan.v1", + "command": "trust", + "platform": "linux", + "refused": null, + "complete": true, + "blocked": [], + "steps": [ + { + "order": 1, + "privilege": "root", + "id": "system-trust-install", + "description": "Install the CA (SHA-256 \u2026) into the debian trust anchors; root accepts only these exact bytes", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "PATH=/usr/sbin:/usr/bin:/sbin:/bin", + "LANG=C", + "LC_ALL=C", + "/bin/sh", + "-c", + "", + "hack-install", + "", + "", + "/usr/local/share/ca-certificates/hack-caddy-local-ca.crt", + "0644" + ], + "shell": "/usr/bin/sudo /usr/bin/env -i \u2026 0644", + "stdin": { + "bytes": 652, + "sha256": "\u2026", + "content": "the verified Caddy Local CA (PEM)" + }, + "when": "after you confirm the system-wide trust prompt on an interactive terminal; \u2026" + }, + { + "order": 2, + "privilege": "root", + "id": "system-trust-refresh", + "argv": [ + "/usr/bin/sudo", + "/usr/bin/env", + "-i", + "\u2026", + "/usr/sbin/update-ca-certificates" + ], + "\u2026": "\u2026" + }, + { + "order": 3, + "privilege": "user", + "id": "nss-trust", + "description": "Add or update Hack's CA in each browser NSS database you own (certutil, as you)", + "when": "\u2026" + }, + { + "order": 4, + "privilege": "user", + "id": "host-tls-env", + "description": "\u2026", + "when": "\u2026" + } + ] + } } ``` +Fields below are inside `data` on success. Refused or incomplete plans use an +error envelope with `error.detail.plan` retaining the same plan payload. + | Field | Meaning | |---|---| | `refused` | the real command would refuse (native runtime, slim mode, an invalid CA); no steps | @@ -72,7 +121,8 @@ to run the listed commands. | `steps[].when` | when the real command takes the step: after a confirmation, only interactively, only if a check passes | | `steps[].gates` | on a probe: the step it can skip (macOS non-interactive runs check `sudo -n true` before the keychain step) | -Exit code: 0 for a complete plan, 1 when `refused`, 3 when incomplete; `--plan` +Exit code: 0 for a complete success envelope, 1 with `E_PLAN_REFUSED` when +refused, 3 with `E_PLAN_INCOMPLETE` when incomplete; `--plan` without `--json` exits 2. `--json` without `--plan` also exits 2 before any effect. ## What `hack global trust` plans diff --git a/docs/reference/global-status-observe.md b/docs/reference/global-status-observe.md index 6cf8eb5c8..364f43e81 100644 --- a/docs/reference/global-status-observe.md +++ b/docs/reference/global-status-observe.md @@ -49,24 +49,50 @@ could not tell; it is never reported as absent or as a failure of the thing. ```json { - "schema": "hack.global-status.observe.v1", - "generated_at": "2026-11-14T00:00:00.000Z", - "observe": true, - "platform": "linux", - "docker": { "state": "running", "detail": null }, - "caddy": { "ok": true, "error": null, "services": [] }, - "logging": { "ok": true, "error": null, "services": [] }, - "networks": { "ok": true, "missing": [], "networks": [] }, - "proxy_network": { "ok": true, "readable": true, "issues": [] }, - "host_dns": { "platform": "linux", "state": "configured", "ok": true }, - "summary": { - "docker_ok": true, - "caddy_ok": true, - "logging_ok": true, - "networks_ok": true, - "proxy_network_ok": true, - "host_dns_ok": true, - "ok": true + "ok": true, + "data": { + "schema": "hack.global-status.observe.v1", + "generated_at": "2026-11-14T00:00:00.000Z", + "observe": true, + "platform": "linux", + "docker": { + "state": "running", + "detail": null + }, + "caddy": { + "ok": true, + "error": null, + "services": [] + }, + "logging": { + "ok": true, + "error": null, + "services": [] + }, + "networks": { + "ok": true, + "missing": [], + "networks": [] + }, + "proxy_network": { + "ok": true, + "readable": true, + "issues": [] + }, + "host_dns": { + "platform": "linux", + "state": "configured", + "ok": true + }, + "summary": { + "docker_ok": true, + "caddy_ok": true, + "logging_ok": true, + "networks_ok": true, + "proxy_network_ok": true, + "host_dns_ok": true, + "ok": true + } } } ``` @@ -83,8 +109,10 @@ could not tell; it is never reported as absent or as a failure of the thing. | `host_dns` (other) | `state: "unknown"`, `ok: "unknown"` | | `summary.ok` | `false` if any part is `false`; otherwise `"unknown"` if any part is; otherwise `true` | -Exit code: 0 whenever the JSON was printed, whatever it says; read the `ok` -fields. +Exit code 0 means the observation was collected, with the report inside `data` +and `data.schema: "hack.global-status.observe.v1"`. Read `data.summary.ok` and +the individual report parts for health; top-level `ok` is not a health claim. +A command failure uses the standard error envelope and a nonzero exit. Not included: the gateway section of plain `hack global status --json` (it reads the daemon and token store and is not routing state). diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md new file mode 100644 index 000000000..d98b17767 --- /dev/null +++ b/docs/reference/machine-readable-output.md @@ -0,0 +1,132 @@ +# Machine-readable output + +New `--json` outputs added for HACK-2218 use one success envelope: + +```json +{"ok":true,"data":{"schema":"hack.version.v1","version":"5.0.0"}} +``` + +The stable schema identifier belongs inside `data`. A payload schema changes +when its incompatible contract changes. Consumers unwrap `data` and validate +its schema before using it. Human diagnostics go to stderr. + +Failures use a stable error code and a nonzero exit status: + +```json +{"ok":false,"error":{"code":"E_USAGE","message":"Use a supported option combination."}} +``` + +Errors may carry `error.detail`. A refused administrator plan uses +`E_PLAN_REFUSED` (exit 1); an incomplete plan uses `E_PLAN_INCOMPLETE` (exit 3). +Both retain the partial plan, including its schema, at `error.detail.plan`. +Unknown/offline state in an observation is still a successful observation; +it is not a claim that the runtime is healthy. + +The previously unpublished version, observe-only project/status and admin-plan +outputs now follow this rule. Plain `projects --json` and other published legacy +outputs keep their shapes. A consumer must not use the new observe-only parser +for a plain projects result. Streaming interfaces remain NDJSON and have their +own event contracts. No published payload is silently wrapped by this change. + +![JSON command boundary](./machine-readable-output.svg) + +## Command inventory + +This inventory includes every canonical command exposing `--json`, including +retained unsupported command groups. Aliases have the same contract as their +canonical command. “Legacy” records the existing output; it does not recommend +that shape for a new interface. Backend-dependent entries need their existing +command-specific parser. Do not infer a success shape from an error envelope. + +Regenerate with `bun scripts/generate-machine-output-reference.ts`. Tests compare +the inventory to the command definitions and exercise every command's shared +usage-error boundary without entering its handler. The migrated success variants +also have real CLI-boundary contract tests; existing legacy success behavior +remains covered by its command-specific suites. + + +| Canonical command / variant | Success shape | +|---|---| +| `hack activity --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack commands read --json` | NDJSON events / terminal event, not a single envelope | +| `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack config adopt --json` | Legacy bare object (no `data` envelope) | +| `hack config import --json` | Legacy bare object (no `data` envelope) | +| `hack config plan --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack config validate --json` | Native compiler protocol object (`transport_version`, `ok`, result / diagnostics) | +| `hack daemon status --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch logs --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch run --json` | Legacy bare object (no `data` envelope) | +| `hack dispatch status --json` | Legacy bare object (no `data` envelope) | +| `hack doctor --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack down --json` | Backend-dependent: envelope or legacy bare object | +| `hack env apply --json` | Legacy bare object (no `data` envelope) | +| `hack env backend status --json` | Legacy bare object (no `data` envelope) | +| `hack env backend use --json` | Legacy bare object (no `data` envelope) | +| `hack env explain --json` | Legacy bare object (no `data` envelope) | +| `hack env hydrate --json` | Legacy bare object (no `data` envelope) | +| `hack env list --json` | Legacy bare object (no `data` envelope) | +| `hack env protect --json` | Legacy bare object (no `data` envelope) | +| `hack exec --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack exec-sessions --json` | Legacy bare object (no `data` envelope) | +| `hack global dns preview --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack global install --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack global status --json` | Legacy bare object (no `data` envelope) | +| `hack global trust --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | +| `hack host ps --json` | Legacy bare object (no `data` envelope) | +| `hack logs --json` | NDJSON logs; finite native modes may return a legacy snapshot object | +| `hack node add --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer attach --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer down --json` | Legacy bare object (no `data` envelope) | +| `hack node devcontainer up --json` | Legacy bare object (no `data` envelope) | +| `hack node init --json` | Legacy bare object (no `data` envelope) | +| `hack node list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair --json` | Legacy bare object (no `data` envelope) | +| `hack node pair approve --json` | Legacy bare object (no `data` envelope) | +| `hack node pair cancel --json` | Legacy bare object (no `data` envelope) | +| `hack node pair complete --json` | Legacy bare object (no `data` envelope) | +| `hack node pair fulfill --json` | Legacy bare object (no `data` envelope) | +| `hack node pair list --json` | Legacy bare object (no `data` envelope) | +| `hack node pair request --json` | Legacy bare object (no `data` envelope) | +| `hack node pair start --json` | Legacy bare object (no `data` envelope) | +| `hack node pair walkthrough --json` | Legacy bare object (no `data` envelope) | +| `hack node provider railway bootstrap --json` | Legacy bare object (no `data` envelope) | +| `hack node remove --json` | Legacy bare object (no `data` envelope) | +| `hack node routes repair --json` | Legacy bare object (no `data` envelope) | +| `hack node routes status --json` | Legacy bare object (no `data` envelope) | +| `hack node ssh setup --json` | Legacy bare object (no `data` envelope) | +| `hack node status --json` | Legacy bare object (no `data` envelope) | +| `hack node use --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace attach --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace list --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace remove --json` | Legacy bare object (no `data` envelope) | +| `hack node workspace resolve --json` | Legacy bare object (no `data` envelope) | +| `hack open --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project owner show --json` | Legacy bare object (no `data` envelope) | +| `hack project target --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack project task list --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack projects --json` | Legacy bare object (no `data` envelope) | +| `hack projects prune --json` | Legacy bare object (no `data` envelope) | +| `hack ps --json` | Backend-dependent: envelope or legacy bare object | +| `hack restart --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack run --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack session capture --json` | NDJSON events / terminal event, not a single envelope | +| `hack session panes --json` | NDJSON events / terminal event, not a single envelope | +| `hack session tail --json` | NDJSON events / terminal event, not a single envelope | +| `hack setup migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack setup sync --json` | Retired: usage-error envelope, no success payload | +| `hack status --json` | Backend-dependent: envelope or legacy bare object | +| `hack up --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack update --json` | Legacy result (`ok` plus fields, no `data`; error may be a string) | +| `hack upgrade apply --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade check --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade review --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade rollback --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack upgrade snapshot --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack usage --json` | Legacy bare object (no `data` envelope) | +| `hack version --json` | Envelope; versioned `data.schema`: `hack.version.v1` | +| `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | +| `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | + diff --git a/docs/reference/machine-readable-output.mmd b/docs/reference/machine-readable-output.mmd new file mode 100644 index 000000000..2522472fb --- /dev/null +++ b/docs/reference/machine-readable-output.mmd @@ -0,0 +1,7 @@ +flowchart LR + C[New JSON command] --> O{Command outcome} + O -->|success| D[ok true with data and schema] + O -->|failure| E[ok false with stable error code] + D --> V[Client validates schema] + E --> F[Nonzero exit and optional recovery detail] + L[Published legacy command] --> P[Existing parser unchanged] diff --git a/docs/reference/machine-readable-output.svg b/docs/reference/machine-readable-output.svg new file mode 100644 index 000000000..10335d781 --- /dev/null +++ b/docs/reference/machine-readable-output.svg @@ -0,0 +1,66 @@ +successfailureNew JSON commandCommand outcomeok true with data andschemaok false with stable errorcodeClient validates schemaNonzero exit and optionalrecovery detailPublished legacycommandExisting parser unchanged \ No newline at end of file diff --git a/docs/reference/version.md b/docs/reference/version.md index f88e84202..90ac8b500 100644 --- a/docs/reference/version.md +++ b/docs/reference/version.md @@ -5,7 +5,16 @@ lookup, project discovery, registry writes or starting Docker, a VM or hackd. Plain `hack version` and `hack --version` keep their text output. ```json -{"version":"5.0.0-next.1","channel":"next","install_kind":"standalone","binary_path":"/example/bin/hack"} +{ + "ok": true, + "data": { + "version": "5.0.0-next.1", + "channel": "next", + "install_kind": "standalone", + "binary_path": "/example/bin/hack", + "schema": "hack.version.v1" + } +} ``` - `version` is the running CLI's embedded version. @@ -23,8 +32,8 @@ Plain `hack version` and `hack --version` keep their text output. The OS may already have resolved a launch symlink, so its original spelling is not guaranteed. -The JSON is a bare object with these four fields, without an `ok` or `data` -envelope. +The four identity fields and `schema: "hack.version.v1"` are inside `data` +in the standard success envelope. See [Machine-readable output](./machine-readable-output.md). The report inspects only local executable metadata and, for compiled installs, its initial bytes. It does not check whether a newer version exists. diff --git a/docs/versions/v5/guides/agent-first-setup.md b/docs/versions/v5/guides/agent-first-setup.md index 2e7c00acd..b5a992b85 100644 --- a/docs/versions/v5/guides/agent-first-setup.md +++ b/docs/versions/v5/guides/agent-first-setup.md @@ -44,3 +44,8 @@ install kind and executable path without starting services or checking the network. The JSON fields are `version`, `channel`, `install_kind` and `binary_path`; a source run reports Bun as its executable and `source` as its install kind. + +New machine-readable contracts return `{ok:true,data:{schema,...}}`; failures +return `{ok:false,error:{code,message}}` with a nonzero exit. Version identity, +observe-only projects/global status and administrator plans use this envelope. +Published plain project listing and existing streaming formats keep their shapes. diff --git a/plugins/hack/skills/hack-diagnose/references/error-codes.md b/plugins/hack/skills/hack-diagnose/references/error-codes.md index 36e09998b..200685bee 100644 --- a/plugins/hack/skills/hack-diagnose/references/error-codes.md +++ b/plugins/hack/skills/hack-diagnose/references/error-codes.md @@ -19,6 +19,8 @@ stay within the request. | `E_COMPOSE_FAILED` | A Compose operation failed or left a stop incomplete. | `hack logs --json --no-follow --tail 200` | | `E_COMPOSE_OUTPUT_INVALID` | Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version. | `docker compose version` | | `E_STARTUP_INCOMPLETE` | After start or restart, a service is neither running nor a successful one-shot. | `hack ps --json` | +| `E_PLAN_REFUSED` | A read-only administrator plan established that the command would refuse to run. | Read `error.detail.plan.refused`; no planned step ran. | +| `E_PLAN_INCOMPLETE` | An administrator plan cannot yet determine every required phase. | Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied. | | `E_DEPENDENCY_BOOTSTRAP_FAILED` | A dependency installer service did not complete successfully. | `hack logs --json --no-follow --tail 200` | | `E_STARTUP_TIMEOUT` | Bounded startup ran out of time before services settled. | `hack ps --json` | | `E_CANCELLED` | The caller cancelled the operation; pending ownership is retained. | `hack ps --json` | @@ -43,4 +45,4 @@ stay within the request. --- -Content revision: `e104e92c08a5`. Generated by Hack; edit the canonical source and regenerate the plugin. +Content revision: `3dd842ac3170`. Generated by Hack; edit the canonical source and regenerate the plugin. diff --git a/scripts/generate-machine-output-reference.ts b/scripts/generate-machine-output-reference.ts new file mode 100644 index 000000000..2f81ec397 --- /dev/null +++ b/scripts/generate-machine-output-reference.ts @@ -0,0 +1,30 @@ +import { renderJsonInventory } from "../src/cli/json-reference.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { machineOutputFixtures } from "./lib/machine-output-fixtures.ts"; + +const path = new URL( + "../docs/reference/machine-readable-output.md", + import.meta.url +); +const text = await Bun.file(path).text(); +const start = ""; +const end = ""; +if ( + text.split(start).length !== 2 || + text.split(end).length !== 2 || + text.indexOf(end) <= text.indexOf(start) +) { + throw new Error("Machine output inventory markers must occur once, in order"); +} +const next = `${text.slice(0, text.indexOf(start) + start.length)}\n${renderJsonInventory(CLI_SPEC)}${text.slice(text.indexOf(end))}`; +await Bun.write(path, next); + +for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + await Bun.write( + new URL( + `../apps/desktop/core/tests/fixtures/machine-output/${name}`, + import.meta.url + ), + `${JSON.stringify(fixture, null, 2)}\n` + ); +} diff --git a/scripts/lib/machine-output-fixtures.ts b/scripts/lib/machine-output-fixtures.ts new file mode 100644 index 000000000..2233745e9 --- /dev/null +++ b/scripts/lib/machine-output-fixtures.ts @@ -0,0 +1,56 @@ +import { errorResult } from "../../src/lib/cli-result.ts"; +import { + MACHINE_SCHEMAS, + machineSuccess, +} from "../../src/lib/machine-output.ts"; + +/** Minimal payloads for cross-language boundary tests, not runtime-state fixtures. */ +export function machineOutputFixtures() { + const plan = { + command: "install", + platform: "linux", + refused: null, + complete: true, + blocked: [], + steps: [], + }; + const success = ( + schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS], + data: object + ) => machineSuccess({ schema, data }); + return { + "version.json": success(MACHINE_SCHEMAS.version, { + version: "5.0.0", + channel: "stable", + install_kind: "standalone", + binary_path: "/example/bin/hack", + }), + "projects-observe.json": success(MACHINE_SCHEMAS.projectsObserve, { + observe: true, + runtime_requested: false, + runtime_ok: false, + projects: [], + }), + "global-status-observe.json": success(MACHINE_SCHEMAS.globalStatusObserve, { + observe: true, + docker: { state: "not_running" }, + summary: { ok: "unknown" }, + }), + "global-install-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, plan), + "global-trust-plan.json": success(MACHINE_SCHEMAS.globalAdminPlan, { + ...plan, + command: "trust", + }), + "plan-incomplete.json": errorResult({ + code: "E_PLAN_INCOMPLETE", + message: "Administrator plan is incomplete.", + detail: { + plan: { + ...plan, + schema: MACHINE_SCHEMAS.globalAdminPlan, + complete: false, + }, + }, + }), + }; +} diff --git a/src/agents/error-code-reference.ts b/src/agents/error-code-reference.ts index 72fa4f670..4695c63f0 100644 --- a/src/agents/error-code-reference.ts +++ b/src/agents/error-code-reference.ts @@ -65,6 +65,16 @@ const ERROR_CODE_GUIDES: Readonly> = { "Compose exited successfully but printed output Hack cannot parse as JSON, usually an unsupported Compose version.", readFirst: "`docker compose version`", }, + E_PLAN_REFUSED: { + meaning: + "A read-only administrator plan established that the command would refuse to run.", + readFirst: "Read `error.detail.plan.refused`; no planned step ran.", + }, + E_PLAN_INCOMPLETE: { + meaning: "An administrator plan cannot yet determine every required phase.", + readFirst: + "Read `error.detail.plan.blocked` and re-plan after its prerequisites are satisfied.", + }, E_STARTUP_INCOMPLETE: { meaning: "After start or restart, a service is neither running nor a successful one-shot.", diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts new file mode 100644 index 000000000..7e2e48383 --- /dev/null +++ b/src/cli/json-contracts.ts @@ -0,0 +1,107 @@ +/** Canonical --json inventory. New command paths require an explicit shape decision. */ +export const JSON_COMMAND_SHAPES = { + "global install": "schema-envelope", + "global dns preview": "envelope", + "global status": "legacy-object", + "global trust": "schema-envelope", + status: "backend-dependent", + usage: "legacy-object", + projects: "legacy-object", + "projects prune": "legacy-object", + "project owner show": "legacy-object", + "project target": "envelope", + "project task list": "envelope", + "project task inspect": "envelope", + up: "envelope", + down: "backend-dependent", + restart: "envelope", + ps: "backend-dependent", + run: "envelope", + exec: "envelope", + "exec-sessions": "legacy-object", + "commands read": "ndjson", + "commands show": "envelope", + "commands cancel": "envelope", + logs: "logs", + open: "envelope", + activity: "envelope", + doctor: "envelope", + "daemon status": "legacy-object", + "dispatch run": "legacy-object", + "dispatch status": "legacy-object", + "dispatch logs": "legacy-object", + "env list": "legacy-object", + "env protect": "legacy-object", + "env explain": "legacy-object", + "env apply": "legacy-object", + "env hydrate": "legacy-object", + "env backend status": "legacy-object", + "env backend use": "legacy-object", + "host ps": "legacy-object", + "config validate": "transport", + "config plan": "transport", + "config import": "legacy-object", + "config adopt": "legacy-object", + "setup sync": "error-only", + "setup migrate": "envelope", + "session panes": "ndjson", + "session capture": "ndjson", + "session tail": "ndjson", + "node init": "legacy-object", + "node pair": "legacy-object", + "node pair start": "legacy-object", + "node pair request": "legacy-object", + "node pair approve": "legacy-object", + "node pair complete": "legacy-object", + "node pair cancel": "legacy-object", + "node pair walkthrough": "legacy-object", + "node pair list": "legacy-object", + "node pair fulfill": "legacy-object", + "node ssh setup": "legacy-object", + "node add": "legacy-object", + "node list": "legacy-object", + "node status": "legacy-object", + "node use": "legacy-object", + "node remove": "legacy-object", + "node workspace list": "legacy-object", + "node workspace resolve": "legacy-object", + "node workspace remove": "legacy-object", + "node workspace attach": "legacy-object", + "node routes status": "legacy-object", + "node routes repair": "legacy-object", + "node provider railway bootstrap": "legacy-object", + "node devcontainer up": "legacy-object", + "node devcontainer down": "legacy-object", + "node devcontainer attach": "legacy-object", + update: "legacy-result", + "upgrade check": "envelope", + "upgrade apply": "envelope", + "upgrade review": "envelope", + "upgrade snapshot": "envelope", + "upgrade rollback": "envelope", + version: "schema-envelope", +} as const; + +export const SCHEMA_JSON_VARIANTS = [ + { command: "version", flags: [], schema: "hack.version.v1" }, + { + command: "projects", + flags: ["--observe"], + schema: "hack.projects.observe.v1", + }, + { + command: "global status", + flags: ["--observe"], + schema: "hack.global-status.observe.v1", + }, + { + command: "global install", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, + { + command: "global trust", + flags: ["--plan"], + schema: "hack.global-admin-plan.v1", + }, +] as const; diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts new file mode 100644 index 000000000..bd22f2795 --- /dev/null +++ b/src/cli/json-reference.ts @@ -0,0 +1,65 @@ +import type { AnyCommandSpec, CliSpec } from "./command.ts"; +import { JSON_COMMAND_SHAPES, SCHEMA_JSON_VARIANTS } from "./json-contracts.ts"; + +export function jsonCommandPaths(cli: CliSpec): readonly string[] { + const out: string[] = []; + function visit(command: AnyCommandSpec, parents: readonly string[]): void { + const path = [...parents, command.name]; + if (command.options.some((option) => option.name === "json")) { + out.push(path.join(" ")); + } + for (const child of command.subcommands) { + visit(child, path); + } + } + for (const command of cli.commands) { + visit(command, []); + } + return out.sort(); +} + +const SHAPES: Record< + (typeof JSON_COMMAND_SHAPES)[keyof typeof JSON_COMMAND_SHAPES], + string +> = { + "schema-envelope": "Envelope; versioned `data.schema`", + envelope: + "Envelope (`ok`, `data` / `error`); legacy payload has no required schema", + "legacy-object": "Legacy bare object (no `data` envelope)", + "legacy-result": + "Legacy result (`ok` plus fields, no `data`; error may be a string)", + "backend-dependent": "Backend-dependent: envelope or legacy bare object", + ndjson: "NDJSON events / terminal event, not a single envelope", + logs: "NDJSON logs; finite native modes may return a legacy snapshot object", + transport: + "Native compiler protocol object (`transport_version`, `ok`, result / diagnostics)", + "error-only": "Retired: usage-error envelope, no success payload", +}; + +export function renderJsonInventory(cli: CliSpec): string { + const rows = jsonCommandPaths(cli).map((command) => { + const kind = + JSON_COMMAND_SHAPES[command as keyof typeof JSON_COMMAND_SHAPES]; + if (kind === undefined) { + throw new Error(`Missing JSON shape decision: ${command}`); + } + const variant = SCHEMA_JSON_VARIANTS.find( + (entry) => entry.command === command && kind === "schema-envelope" + ); + const flags = variant?.flags.join(" ") ?? ""; + return `| \`hack ${command}${flags ? ` ${flags}` : ""} --json\` | ${SHAPES[kind]}${variant ? `: \`${variant.schema}\`` : ""} |`; + }); + for (const variant of SCHEMA_JSON_VARIANTS.filter( + (entry) => entry.command === "projects" || entry.command === "global status" + )) { + rows.push( + `| \`hack ${variant.command} --json ${variant.flags.join(" ")}\` | Envelope; \`data.schema: ${variant.schema}\` |` + ); + } + return [ + "| Canonical command / variant | Success shape |", + "|---|---|", + ...rows, + "", + ].join("\n"); +} diff --git a/src/cli/reference.ts b/src/cli/reference.ts index 29ebb189f..cfc75047d 100644 --- a/src/cli/reference.ts +++ b/src/cli/reference.ts @@ -46,7 +46,7 @@ export function renderCliReferenceMarkdown(opts: { "", `${opts.cli.name} — ${opts.cli.summary}`, "", - "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); commands with `--json` emit a `{ok, data, error}` envelope on stdout.", + "Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); new JSON contracts use schema-tagged envelopes; published legacy and streaming formats are listed in [Machine-readable output](./machine-readable-output.md).", "", ]; diff --git a/src/commands/global-admin-plan.ts b/src/commands/global-admin-plan.ts index 05d11ec42..973277d3c 100644 --- a/src/commands/global-admin-plan.ts +++ b/src/commands/global-admin-plan.ts @@ -35,6 +35,7 @@ import type { LinuxTrustHost, PreparedLinuxSystemTrust, } from "../lib/linux-trust-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { trustedSudoInvocation } from "../lib/privileged-sudo.ts"; import { type DockerBackend, @@ -54,7 +55,7 @@ import { type PreparedMacTrustPlan, } from "./global-mac-trust.ts"; -export const ADMIN_PLAN_SCHEMA = "hack.global-admin-plan.v1"; +export const ADMIN_PLAN_SCHEMA = MACHINE_SCHEMAS.globalAdminPlan; /** * A bounded `docker info` result as the plan reads it: exit 0 answers; diff --git a/src/commands/global-status-observe.ts b/src/commands/global-status-observe.ts index f1b752a55..5c7a37476 100644 --- a/src/commands/global-status-observe.ts +++ b/src/commands/global-status-observe.ts @@ -13,6 +13,7 @@ * reported as absent or as a failure of the thing. */ import type { LinuxDnsInspection } from "../lib/linux-dns-setup.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import type { ExecResult } from "../lib/shell.ts"; import type { ObservedCheck } from "./global-mac-dns.ts"; import type { @@ -21,7 +22,7 @@ import type { ProxyObservation, } from "./global-status-observe-host.ts"; -export const OBSERVE_STATUS_SCHEMA = "hack.global-status.observe.v1"; +export const OBSERVE_STATUS_SCHEMA = MACHINE_SCHEMAS.globalStatusObserve; /** A yes/no answer, or "unknown" when it could not be determined. */ export type Tri = true | false | "unknown"; diff --git a/src/commands/global.ts b/src/commands/global.ts index 10374da53..b2217accb 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -11,7 +11,6 @@ import type { CommandHandlerFor, } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; - import { optFollow, optJson, @@ -67,6 +66,7 @@ import { inspectCaddyCaIdentity, } from "../lib/caddy-ca-identity.ts"; import { observeGlobalCaddyIp } from "../lib/caddy-hosts.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; import { resolveGlobalConfigPath, resolveGlobalHackDir, @@ -107,6 +107,7 @@ import { resolveHackLocalCaCertPath, } from "../lib/local-ca.ts"; import { inspectMacCaEligibility } from "../lib/mac-ca-trust.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { ensureBundledMutagenInstalled, getMutagenPath, @@ -483,8 +484,12 @@ export const globalCommand = defineCommand({ subcommands: [ withHandler(globalInstallSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global install --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: + "Use --json with --plan: hack global install --plan --json", + }), }); return 2; } @@ -505,8 +510,11 @@ export const globalCommand = defineCommand({ withHandler(globalCertSpec, handleGlobalCert), withHandler(globalTrustSpec, async ({ args }) => { if (args.options.json === true && args.options.plan !== true) { - logger.error({ - message: "Use --json with --plan: hack global trust --plan --json", + emitCliResult({ + result: errorResult({ + code: "E_USAGE", + message: "Use --json with --plan: hack global trust --plan --json", + }), }); return 2; } @@ -2572,7 +2580,12 @@ async function globalStatusObserve(): Promise { observeMacDns: observeMacHostDns, now: () => new Date(), }); - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalStatusObserve, + data: payload, + }), + }); return 0; } @@ -3382,11 +3395,34 @@ async function printAdminPlan(opts: { return 2; } const result = await opts.build(); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (result.refused !== null) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_REFUSED", + message: result.refused, + detail: { plan: result }, + }), + }); return 1; } - return result.complete ? 0 : 3; + if (!result.complete) { + emitCliResult({ + result: errorResult({ + code: "E_PLAN_INCOMPLETE", + message: + "Administrator plan is incomplete; inspect error.detail.plan.blocked before proceeding.", + detail: { plan: result }, + }), + }); + return 3; + } + emitCliResult({ + result: machineSuccess({ + schema: MACHINE_SCHEMAS.globalAdminPlan, + data: result, + }), + }); + return 0; } /** Install's read-only snapshot. Never enter the mutating install handler. */ diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 191aa8b0e..06e9348ea 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -27,6 +27,7 @@ import { import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { confirmSafe } from "../lib/interactivity.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; import { createOperationTimings, @@ -866,7 +867,12 @@ function outputProjectsJson(opts: { ), }; writeProfiledProjects({ - payload, + payload: opts.observe + ? machineSuccess({ + schema: MACHINE_SCHEMAS.projectsObserve, + data: payload, + }) + : payload, profiler: opts.profiler, started: opts.started, enabled: opts.timings, diff --git a/src/commands/version.ts b/src/commands/version.ts index 8c4cabf76..a74d2a1a1 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,7 +1,8 @@ import type { CommandHandlerFor } from "../cli/command.ts"; import { defineCommand, withHandler } from "../cli/command.ts"; - import { optJson } from "../cli/options.ts"; +import { emitCliResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { buildVersionReport } from "../lib/version-report.ts"; const versionSpec = defineCommand({ @@ -23,7 +24,9 @@ const handleVersion: CommandHandlerFor = async ({ executable: process.execPath, compiled: Bun.main.startsWith("/$bunfs/"), }); - console.log(JSON.stringify(report)); + emitCliResult({ + result: machineSuccess({ schema: MACHINE_SCHEMAS.version, data: report }), + }); return 0; } console.log(`${ctx.cli.name} v${ctx.cli.version}`); diff --git a/src/lib/cli-result.ts b/src/lib/cli-result.ts index 099e20fb7..dc6af93bd 100644 --- a/src/lib/cli-result.ts +++ b/src/lib/cli-result.ts @@ -1,10 +1,11 @@ /** * Machine-readable CLI result envelope. * - * Commands that support `--json` print exactly one envelope object to stdout: + * New structured `--json` contracts print one envelope object to stdout: * `{ok: true, data}` on success or `{ok: false, error: {code, message, - * detail?}}` on failure. Exit codes keep their existing 0/1 semantics; the - * envelope only adds structure, never changes them. + * detail?}}` on failure. New HACK-2218 successes also carry `data.schema`. + * Errors exit nonzero. Published legacy and streaming formats are inventoried + * in docs/reference/machine-readable-output.md and are not silently wrapped. */ /** @@ -28,6 +29,8 @@ export const HACK_ERROR_CODES = [ "E_COMPOSE_FAILED", "E_COMPOSE_OUTPUT_INVALID", "E_STARTUP_INCOMPLETE", + "E_PLAN_REFUSED", + "E_PLAN_INCOMPLETE", "E_DEPENDENCY_BOOTSTRAP_FAILED", "E_STARTUP_TIMEOUT", "E_CANCELLED", diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts new file mode 100644 index 000000000..1d20a97ce --- /dev/null +++ b/src/lib/machine-output.ts @@ -0,0 +1,16 @@ +import { type CliOkResult, okResult } from "./cli-result.ts"; + +/** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ +export const MACHINE_SCHEMAS = { + version: "hack.version.v1", + projectsObserve: "hack.projects.observe.v1", + globalAdminPlan: "hack.global-admin-plan.v1", + globalStatusObserve: "hack.global-status.observe.v1", +} as const; + +export function machineSuccess(opts: { + readonly schema: (typeof MACHINE_SCHEMAS)[keyof typeof MACHINE_SCHEMAS]; + readonly data: T; +}): CliOkResult { + return okResult({ data: { ...opts.data, schema: opts.schema } }); +} diff --git a/tests/admin-plan-json-contract.test.ts b/tests/admin-plan-json-contract.test.ts new file mode 100644 index 000000000..e3a497fef --- /dev/null +++ b/tests/admin-plan-json-contract.test.ts @@ -0,0 +1,114 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdminPlan } from "../src/commands/global-admin-plan.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +let plan: AdminPlan; +let root: string; +let env: Record; +const planner = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/commands/global-admin-plan.ts", + overrides: { + planGlobalInstall: async () => plan, + planGlobalTrust: async () => plan, + }, +}); +beforeAll(() => planner.activate()); +afterAll(() => planner.deactivate()); +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plan-json-")); + const keys = [ + "HOME", + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_CANDIDATE_ENTRYPOINT", + "HACK_LOGGER", + "HACK_DAEMON_AUTOSTART_DISABLE", + ]; + env = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + for (const key of keys) { + Reflect.deleteProperty(process.env, key); + } + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +for (const command of ["install", "trust"] as const) { + test.each([ + "complete", + "refused", + "incomplete", + ] as const)(`${command} plan %s follows its JSON result contract`, async (outcome) => { + plan = { + schema: MACHINE_SCHEMAS.globalAdminPlan, + command, + platform: "linux", + complete: outcome === "complete", + refused: outcome === "refused" ? "Host selection refused" : null, + blocked: + outcome === "incomplete" + ? [ + { + phase: "host", + reason: "Unknown", + prerequisite: "Re-check host state", + }, + ] + : [], + steps: [], + }; + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("plan contract executed a child"); + }); + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", command, "--plan", "--json"]); + expect(code).toBe( + outcome === "complete" ? 0 : outcome === "refused" ? 1 : 3 + ); + if (outcome === "complete") { + expect(machineData(stdout, MACHINE_SCHEMAS.globalAdminPlan)).toEqual( + plan + ); + } else { + expect( + machineError( + stdout, + outcome === "refused" ? "E_PLAN_REFUSED" : "E_PLAN_INCOMPLETE" + ).detail.plan + ).toEqual(plan); + } + expect(spawn).not.toHaveBeenCalled(); + }); +} diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index cefc00a39..0c900a6d3 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -26,6 +26,7 @@ import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { CURRENT_CA_PEM, OLD_CA_PEM } from "./helpers/ca-certificates.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; import { asExecStdout, tarArchive } from "./helpers/tar.ts"; @@ -440,7 +441,10 @@ test("global install plan root argv equals the command's fake-root execution, in value: true, }); } - const planned = JSON.parse(chunks.join("")) as AdminPlan; + const planned = machineData( + chunks.join(""), + "hack.global-admin-plan.v1" + ) as AdminPlan; expect(planCode).toBe(0); expect(planned.complete).toBe(true); expect(runCalls).toEqual([]); @@ -475,7 +479,8 @@ test("global install plan keeps JSON stdout pure when helper ownership refuses r } finally { stdout.mockRestore(); } - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ command: "install", steps: expect.arrayContaining([ diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index 89f32e555..b0a5945b6 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -10,7 +10,6 @@ import { import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; - import { DEFAULT_CADDY_IP, DEFAULT_COREDNS_IP, @@ -28,6 +27,7 @@ import { resolveGlobalConfigPath } from "../src/lib/config-paths.ts"; import * as interactivity from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData, machineError } from "./helpers/machine-output.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let tempDir: string | null = null; @@ -187,6 +187,11 @@ test.each([ "install", "trust", ])("global %s --json without --plan refuses before any effect", async (command) => { + const chunks: string[] = []; + const output = spyOn(process.stdout, "write").mockImplementation((chunk) => { + chunks.push(String(chunk)); + return true; + }); const confirm = spyOn(interactivity, "confirmSafe").mockImplementation(() => { throw new Error("No confirmation expected"); }); @@ -197,8 +202,10 @@ test.each([ expect(runCalls).toEqual([]); expect(linuxDnsPrivileged).toEqual([]); expect(confirm).not.toHaveBeenCalled(); + machineError(chunks.join(""), "E_USAGE"); } finally { confirm.mockRestore(); + output.mockRestore(); } }); @@ -221,7 +228,9 @@ test.skipIf(process.platform !== "linux")( } finally { stdout.mockRestore(); } - expect(JSON.parse(chunks.join(""))).toMatchObject({ + expect( + machineData(chunks.join(""), "hack.global-admin-plan.v1") + ).toMatchObject({ schema: "hack.global-admin-plan.v1", command: "install", complete: true, @@ -962,7 +971,8 @@ test.skipIf(process.platform !== "linux")( } expect(dockerStarted).toBe(false); expect(runCalls).toEqual([]); - const planned: unknown = JSON.parse(chunks.join("")); + const planned: unknown = machineError(chunks.join(""), "E_PLAN_INCOMPLETE") + .detail.plan; expect(planned).toMatchObject({ steps: expect.arrayContaining([ expect.objectContaining({ diff --git a/tests/global-status-observe.test.ts b/tests/global-status-observe.test.ts index f59b73cc8..81e83db74 100644 --- a/tests/global-status-observe.test.ts +++ b/tests/global-status-observe.test.ts @@ -32,6 +32,7 @@ import type { LinuxDnsInspection } from "../src/lib/linux-dns-setup.ts"; import * as shell from "../src/lib/shell.ts"; import { isolatedDockerClientEnv } from "./helpers/docker-client.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { machineData } from "./helpers/machine-output.ts"; // `hack global status --json --observe` (HACK-2218, R13): never starts // anything, and never reports what it could not read as absent or failed. @@ -1137,7 +1138,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly summary: Record; }; @@ -1173,7 +1177,10 @@ test( // Nothing outside the listed read-only probes, checked first. expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly docker: { readonly state: string }; readonly networks: { readonly ok: unknown }; }; @@ -1201,7 +1208,10 @@ test( const result = await runObserve(base); expect(result.spawned.filter((argv) => !allowed(argv))).toEqual([]); expect(result.exitCode).toBe(0); - const payload = JSON.parse(result.stdout) as { + const payload = machineData( + result.stdout, + "hack.global-status.observe.v1" + ) as { readonly host_dns: { readonly ok: unknown }; readonly summary: { readonly host_dns_ok: unknown }; }; @@ -1211,7 +1221,10 @@ test( // state. await writeFile(join(hackHome, "hack.config.json"), "{}\n"); const control = await runObserve(base); - const known = JSON.parse(control.stdout) as { + const known = machineData( + control.stdout, + "hack.global-status.observe.v1" + ) as { readonly summary: { readonly host_dns_ok: unknown }; }; expect(known.summary.host_dns_ok).not.toBe("unknown"); diff --git a/tests/helpers/machine-output.ts b/tests/helpers/machine-output.ts new file mode 100644 index 000000000..940e8909e --- /dev/null +++ b/tests/helpers/machine-output.ts @@ -0,0 +1,19 @@ +import { expect } from "bun:test"; + +/** Assert the public boundary before tests inspect command-specific payload fields. */ +export function machineData(text: string, schema: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data.schema).toBe(schema); + return result.data; +} + +export function machineError(text: string, code: string) { + const result = JSON.parse(text); + expect(Object.keys(result).sort()).toEqual(["error", "ok"]); + expect(result.ok).toBe(false); + expect(result.error.code).toBe(code); + expect(result.error.message).toBeString(); + return result.error; +} diff --git a/tests/machine-output-contract.test.ts b/tests/machine-output-contract.test.ts new file mode 100644 index 000000000..71250780c --- /dev/null +++ b/tests/machine-output-contract.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { machineOutputFixtures } from "../scripts/lib/machine-output-fixtures.ts"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { + jsonCommandPaths, + renderJsonInventory, +} from "../src/cli/json-reference.ts"; +import { runCli } from "../src/cli/run.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; +import { machineError } from "./helpers/machine-output.ts"; + +let root: string; +let original: Record; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-json-contract-")); + original = Object.fromEntries( + ["HOME", "HACK_HOME", "HACK_LOGGER", "HACK_DAEMON_AUTOSTART_DISABLE"].map( + (key) => [key, process.env[key]] + ) + ); + process.env.HOME = root; + process.env.HACK_HOME = join(root, "state"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_DAEMON_AUTOSTART_DISABLE = "1"; +}); +afterEach(async () => { + mock.restore(); + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } + await rm(root, { recursive: true, force: true }); +}); + +test("every canonical JSON command has an explicit inventory shape decision", () => { + expect(Object.keys(JSON_COMMAND_SHAPES).sort()).toEqual([ + ...jsonCommandPaths(CLI_SPEC), + ]); + for (const variant of SCHEMA_JSON_VARIANTS) { + expect(JSON_COMMAND_SHAPES).toHaveProperty(variant.command); + } +}); + +test("machine-readable reference table is generated from the command inventory", async () => { + const doc = await Bun.file( + join(import.meta.dir, "..", "docs/reference/machine-readable-output.md") + ).text(); + const table = doc + .split("\n")[1] + ?.split("")[0]; + expect(table).toBe(renderJsonInventory(CLI_SPEC)); +}); + +test.each( + Object.keys(JSON_COMMAND_SHAPES) +)("%s keeps its pre-handler JSON usage-error contract", async (command) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("contract probe entered a runtime handler"); + }); + const spawnSync = spyOn(Bun, "spawnSync").mockImplementation(() => { + throw new Error("contract probe entered a synchronous handler"); + }); + const fetch = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + () => { + throw new Error("contract probe entered a network handler"); + }, + { + preconnect: () => { + throw new Error("unexpected preconnect"); + }, + } + ) + ); + const code = await runCli([ + ...command.split(" "), + "--json", + "--__contract_refuse", + ]); + expect(code).not.toBe(0); + machineError(stdout, "E_USAGE"); + expect(spawn).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); +}); + +test("desktop boundary fixtures and schema inventory agree with CLI producers", async () => { + expect( + [...new Set(SCHEMA_JSON_VARIANTS.map((variant) => variant.schema))].sort() + ).toEqual(Object.values(MACHINE_SCHEMAS).sort()); + for (const [name, fixture] of Object.entries(machineOutputFixtures())) { + const text = await Bun.file( + join( + import.meta.dir, + "..", + "apps/desktop/core/tests/fixtures/machine-output", + name + ) + ).text(); + expect(text).toBe(`${JSON.stringify(fixture, null, 2)}\n`); + } +}); diff --git a/tests/projects-observe.test.ts b/tests/projects-observe.test.ts index 0a5592b35..ae88c4009 100644 --- a/tests/projects-observe.test.ts +++ b/tests/projects-observe.test.ts @@ -14,6 +14,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; let root = ""; @@ -146,7 +147,7 @@ test("observe default preserves HOME and Hack home and never contacts Docker", a ]) { const result = await run(["--json", "--observe", ...flags]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.observe).toBe(true); expect(payload.runtime_requested).toBe(false); expect(payload.runtime_error).toBeNull(); @@ -188,7 +189,7 @@ test("observe runtime explicitly opts in to Docker reads without registering dis ...flags, ]); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_requested).toBe(true); expect( payload.projects.find( @@ -219,7 +220,7 @@ test("observe keeps native rows and unknown Compose services when Docker is unav OFFLINE: "1", }); expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); + const payload = machineData(result.stdout, "hack.projects.observe.v1"); expect(payload.runtime_ok).toBe(false); const compose = payload.projects.find( (row: { name: string }) => row.name === "compose-app" @@ -249,7 +250,10 @@ test("mixed running and exited replicas keep service and project degraded", asyn { MIXED: "1" } ); expect(result.code).toBe(0); - const row = JSON.parse(result.stdout).projects.find( + const row = machineData( + result.stdout, + "hack.projects.observe.v1" + ).projects.find( (project: { name: string }) => project.name === "unregistered-app" ); expect(row.services[0].state).toBe("degraded"); @@ -261,7 +265,9 @@ test("observe never creates a missing registry or registers the current checkout await rm(registry); const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toEqual([]); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toEqual([]); expect(await stat(registry).catch(() => null)).toBeNull(); }); @@ -293,9 +299,21 @@ test("observe does not query an available daemon cache", async () => { try { const result = await run(["--json", "--observe"]); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout).projects).toHaveLength(2); + expect( + machineData(result.stdout, "hack.projects.observe.v1").projects + ).toHaveLength(2); expect(requests).toBe(0); } finally { await server.stop(true); } }); + +test("published plain projects JSON retains its unwrapped shape", async () => { + const result = await run(["--json", "--no-daemon"]); + expect(result.code).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.projects).toBeArray(); + expect(payload).not.toHaveProperty("ok"); + expect(payload).not.toHaveProperty("data"); + expect(payload).not.toHaveProperty("schema"); +}); diff --git a/tests/version-report.test.ts b/tests/version-report.test.ts index 62d7ad27f..ec8e41ea3 100644 --- a/tests/version-report.test.ts +++ b/tests/version-report.test.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildVersionReport } from "../src/lib/version-report.ts"; +import { machineData } from "./helpers/machine-output.ts"; const owned: string[] = []; afterEach(async () => { @@ -74,11 +75,12 @@ test("source CLI JSON is pure, local and leaves a private home unchanged", async const stderr = await new Response(child.stderr).text(); expect(await child.exited).toBe(0); expect(stderr).toBe(""); - const report = JSON.parse(stdout); + const report = machineData(stdout, "hack.version.v1"); expect(Object.keys(report).sort()).toEqual([ "binary_path", "channel", "install_kind", + "schema", "version", ]); expect(report.install_kind).toBe("source"); From bce84af562e5bbec8c2557be9ecd396758bd02de Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:45:21 +0000 Subject: [PATCH 02/14] fix(cli): include activity in schema contract and correct inventory --- .../fixtures/machine-output/activity.json | 8 ++++ apps/desktop/core/tests/machine_output.rs | 4 ++ docs/reference/activity-log.md | 2 +- docs/reference/machine-readable-output.md | 7 +++- scripts/check-native-env-plan-cli.ts | 9 ++++- scripts/lib/machine-output-fixtures.ts | 4 ++ src/cli/json-contracts.ts | 5 ++- src/commands/activity.ts | 8 +++- src/lib/machine-output.ts | 1 + tests/activity-cli.test.ts | 11 ++---- tests/machine-output-contract.test.ts | 38 ++++++++++++++++++- 11 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/core/tests/fixtures/machine-output/activity.json diff --git a/apps/desktop/core/tests/fixtures/machine-output/activity.json b/apps/desktop/core/tests/fixtures/machine-output/activity.json new file mode 100644 index 000000000..0c762508e --- /dev/null +++ b/apps/desktop/core/tests/fixtures/machine-output/activity.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "data": { + "version": 1, + "entries": [], + "schema": "hack.activity.v1" + } +} diff --git a/apps/desktop/core/tests/machine_output.rs b/apps/desktop/core/tests/machine_output.rs index e307ed520..3abaa24bd 100644 --- a/apps/desktop/core/tests/machine_output.rs +++ b/apps/desktop/core/tests/machine_output.rs @@ -5,6 +5,10 @@ use serde_json::Value; #[test] fn migrated_payloads_require_the_expected_envelope_and_schema() { let cases = [ + ( + include_str!("fixtures/machine-output/activity.json"), + "hack.activity.v1", + ), ( include_str!("fixtures/machine-output/version.json"), "hack.version.v1", diff --git a/docs/reference/activity-log.md b/docs/reference/activity-log.md index ee584c4c9..c3c9b41ef 100644 --- a/docs/reference/activity-log.md +++ b/docs/reference/activity-log.md @@ -18,7 +18,7 @@ hack activity --json --project shop --limit 100 Historical entries remain selectable after a project leaves the registry. - `--limit `: at most `n` entries, newest first (default 50, at most 10,000). -- `--json`: the standard envelope, `{"ok": true, "data": {"version": 1, +- `--json`: the standard envelope, `{"ok": true, "data": {"schema": "hack.activity.v1", "version": 1, "entries": [...]}}`. A log that exists but cannot be read (not this user's regular file, a symlink, or a read error) is `{"ok": false, "error": {"code": "E_PATH_CONFLICT", ...}}` with exit 1, never an empty history. diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index d98b17767..494a713bf 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -9,6 +9,8 @@ New `--json` outputs added for HACK-2218 use one success envelope: The stable schema identifier belongs inside `data`. A payload schema changes when its incompatible contract changes. Consumers unwrap `data` and validate its schema before using it. Human diagnostics go to stderr. +The envelope has exactly two top-level keys; additional metadata belongs inside +the schema-tagged payload, not alongside `ok` and `data`. Failures use a stable error code and a nonzero exit status: @@ -27,6 +29,7 @@ outputs now follow this rule. Plain `projects --json` and other published legacy outputs keep their shapes. A consumer must not use the new observe-only parser for a plain projects result. Streaming interfaces remain NDJSON and have their own event contracts. No published payload is silently wrapped by this change. +The unpublished Activity output also carries `data.schema: "hack.activity.v1"`. ![JSON command boundary](./machine-readable-output.svg) @@ -47,7 +50,7 @@ remains covered by its command-specific suites. | Canonical command / variant | Success shape | |---|---| -| `hack activity --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack activity --json` | Envelope; versioned `data.schema`: `hack.activity.v1` | | `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack commands read --json` | NDJSON events / terminal event, not a single envelope | | `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | @@ -108,7 +111,7 @@ remains covered by its command-specific suites. | `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack project task list --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack projects --json` | Legacy bare object (no `data` envelope) | -| `hack projects prune --json` | Legacy bare object (no `data` envelope) | +| `hack projects prune --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack ps --json` | Backend-dependent: envelope or legacy bare object | | `hack restart --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack run --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 04e189bc0..71394a1ce 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -264,6 +264,8 @@ try { ), "native detached service selection refuses before input or runtime work"); // Whole-project detach is admitted. Invalid input keeps this relocated-bundle // check offline on supported hosts; unsupported hosts also fail before ready. + require((await readdir(home)).length === + 0, "validation and refused service selection write no home state"); await Bun.write(join(checkout, ".hack/hack.project.json"), "{"); const detached = await invoke(["up", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -276,7 +278,12 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - require((await readdir(home)).length === 0 && + // The admitted lifecycle action records its failed outcome. This is not + // registry or generated runtime state; permit only that named audit file. + const homeEntries = (await readdir(home, { recursive: true })).sort(); + require((homeEntries.length === 0 || + JSON.stringify(homeEntries) === + JSON.stringify([".hack", ".hack/activity.jsonl"])) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), "no registry or generated runtime state"); diff --git a/scripts/lib/machine-output-fixtures.ts b/scripts/lib/machine-output-fixtures.ts index 2233745e9..a213655a8 100644 --- a/scripts/lib/machine-output-fixtures.ts +++ b/scripts/lib/machine-output-fixtures.ts @@ -19,6 +19,10 @@ export function machineOutputFixtures() { data: object ) => machineSuccess({ schema, data }); return { + "activity.json": success(MACHINE_SCHEMAS.activity, { + version: 1, + entries: [], + }), "version.json": success(MACHINE_SCHEMAS.version, { version: "5.0.0", channel: "stable", diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 7e2e48383..be69107b4 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -7,7 +7,7 @@ export const JSON_COMMAND_SHAPES = { status: "backend-dependent", usage: "legacy-object", projects: "legacy-object", - "projects prune": "legacy-object", + "projects prune": "envelope", "project owner show": "legacy-object", "project target": "envelope", "project task list": "envelope", @@ -24,7 +24,7 @@ export const JSON_COMMAND_SHAPES = { "commands cancel": "envelope", logs: "logs", open: "envelope", - activity: "envelope", + activity: "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", "dispatch run": "legacy-object", @@ -83,6 +83,7 @@ export const JSON_COMMAND_SHAPES = { } as const; export const SCHEMA_JSON_VARIANTS = [ + { command: "activity", flags: [], schema: "hack.activity.v1" }, { command: "version", flags: [], schema: "hack.version.v1" }, { command: "projects", diff --git a/src/commands/activity.ts b/src/commands/activity.ts index b5094f03f..f0b39644b 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -12,7 +12,8 @@ import { type ActivityRecord, readActivity, } from "../lib/activity-log.ts"; -import { emitCliResult, errorResult, okResult } from "../lib/cli-result.ts"; +import { emitCliResult, errorResult } from "../lib/cli-result.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; const WHOLE_NUMBER = /^[1-9][0-9]*$/; @@ -97,7 +98,10 @@ async function handleActivity({ } if (json) { emitCliResult({ - result: okResult({ data: { version: ACTIVITY_SCHEMA_VERSION, entries } }), + result: machineSuccess({ + schema: MACHINE_SCHEMAS.activity, + data: { version: ACTIVITY_SCHEMA_VERSION, entries }, + }), }); return 0; } diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index 1d20a97ce..ca0fdf8e9 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -2,6 +2,7 @@ import { type CliOkResult, okResult } from "./cli-result.ts"; /** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ export const MACHINE_SCHEMAS = { + activity: "hack.activity.v1", version: "hack.version.v1", projectsObserve: "hack.projects.observe.v1", globalAdminPlan: "hack.global-admin-plan.v1", diff --git a/tests/activity-cli.test.ts b/tests/activity-cli.test.ts index 68a6a9a4b..3d99ac034 100644 --- a/tests/activity-cli.test.ts +++ b/tests/activity-cli.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; +import { machineData } from "./helpers/machine-output.ts"; import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; /** @@ -129,13 +130,9 @@ type Entry = Record; async function activity(args: readonly string[] = []): Promise { const result = await runCli({ args: ["activity", "--json", ...args] }); expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as { - ok: boolean; - data: { version: number; entries: Entry[] }; - }; - expect(envelope.ok).toBe(true); - expect(envelope.data.version).toBe(1); - return envelope.data.entries; + const data = machineData(result.stdout, "hack.activity.v1"); + expect(data.version).toBe(1); + return data.entries; } test("up, a failed up, restart and down are recorded with names only", async () => { diff --git a/tests/machine-output-contract.test.ts b/tests/machine-output-contract.test.ts index 71250780c..f45039edd 100644 --- a/tests/machine-output-contract.test.ts +++ b/tests/machine-output-contract.test.ts @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + mock, + spyOn, + test, +} from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -15,6 +24,15 @@ import { runCli } from "../src/cli/run.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { MACHINE_SCHEMAS } from "../src/lib/machine-output.ts"; import { machineError } from "./helpers/machine-output.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const runtime = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/runtime-projects.ts", + overrides: { readRuntimeProjects: async () => ({ ok: true, runtime: [] }) }, +}); +beforeAll(() => runtime.activate()); +afterAll(() => runtime.deactivate()); let root: string; let original: Record; @@ -51,6 +69,24 @@ test("every canonical JSON command has an explicit inventory shape decision", () } }); +test.each([ + { flags: [] }, + { flags: ["--dry-run"] }, +])("projects prune %j matches its envelope inventory", async ({ flags }) => { + let stdout = ""; + spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + const code = await runCli(["projects", "prune", "--json", ...flags]); + expect(code).toBe(0); + const result = JSON.parse(stdout); + expect(JSON_COMMAND_SHAPES["projects prune"]).toBe("envelope"); + expect(Object.keys(result).sort()).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); +}); + test("machine-readable reference table is generated from the command inventory", async () => { const doc = await Bun.file( join(import.meta.dir, "..", "docs/reference/machine-readable-output.md") From 2be5183452af4c97ea3af110441c90d53bf26eb5 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:53:40 +0000 Subject: [PATCH 03/14] test(config): account for both failed lifecycle audit records --- scripts/check-native-env-plan-cli.ts | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 71394a1ce..d13fd2e68 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, + readFile, rm, symlink, } from "node:fs/promises"; @@ -249,6 +250,8 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); + require((await readdir(home)).length === + 0, "validation and planning write no home state"); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -264,8 +267,6 @@ try { ), "native detached service selection refuses before input or runtime work"); // Whole-project detach is admitted. Invalid input keeps this relocated-bundle // check offline on supported hosts; unsupported hosts also fail before ready. - require((await readdir(home)).length === - 0, "validation and refused service selection write no home state"); await Bun.write(join(checkout, ".hack/hack.project.json"), "{"); const detached = await invoke(["up", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", @@ -287,6 +288,31 @@ try { !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), "no registry or generated runtime state"); + if (homeEntries.length > 0) { + const entries = (await readFile(join(home, ".hack/activity.jsonl"), "utf8")) + .trim() + .split("\n") + .map((line) => record(JSON.parse(line))); + require(entries.length === 2 && + entries.every( + (entry) => + entry.action === "up" && + entry.outcome === "failed" && + JSON.stringify(Object.keys(entry).sort()) === + JSON.stringify([ + "action", + "branch", + "duration_ms", + "failed_services", + "origin", + "outcome", + "project", + "ts", + "v", + ]) && + !JSON.stringify(entry).includes(directory) + ), "only the two failed lifecycle actions are recorded without private paths"); + } process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); From 82de876997bcd039e4ee61c515d73a25d14b1d13 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:56:24 +0000 Subject: [PATCH 04/14] test(config): verify audit-only effects across native plan probes --- scripts/check-native-acquisition-plan-cli.ts | 15 ++++- scripts/check-native-endpoint-plan-cli.ts | 19 ++++++- scripts/check-native-env-plan-cli.ts | 47 ++++----------- scripts/check-native-host-plan-cli.ts | 15 ++++- scripts/check-native-process-plan-cli.ts | 15 ++++- scripts/check-native-routing-plan-cli.ts | 18 +++++- scripts/lib/native-plan-home.ts | 52 +++++++++++++++++ tests/native-plan-home.test.ts | 60 ++++++++++++++++++++ 8 files changed, 200 insertions(+), 41 deletions(-) create mode 100644 scripts/lib/native-plan-home.ts create mode 100644 tests/native-plan-home.test.ts diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index d38b95270..788698c8f 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Qualify pure acquisition intent in a relocated bundle and a real linked worktree. */ const root = resolve(import.meta.dir, ".."); @@ -264,6 +265,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach"], checkout, false, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -283,7 +292,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts index 8b98031cd..7c9463d8f 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Exercise pure endpoint planning through the relocated CLI and real Git linkage. */ const root = resolve(import.meta.dir, ".."); @@ -367,6 +368,14 @@ try { "actual resolution rejects unbound logical reference" ); await authored(checkout, project); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -393,8 +402,14 @@ try { "no generated runtime state" ); } - const homeEntries = await readdir(join(home, ".hack")).catch(() => []); - assert(homeEntries.length === 0, "no registry or runtime home writes"); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), + "no registry or runtime home writes" + ); process.stdout.write( `Relocated native endpoint planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` ); diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index d13fd2e68..6c5a1a141 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -4,13 +4,13 @@ import { mkdir, mkdtemp, readdir, - readFile, rm, symlink, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Real compiled CLI/sidecar, isolated HOME and Git worktrees, no source or toolchain on PATH. */ const root = resolve(import.meta.dir, ".."); @@ -250,8 +250,11 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); - require((await readdir(home)).length === - 0, "validation and planning write no home state"); + require(await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), "validation and planning write no home state"); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -279,40 +282,14 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - // The admitted lifecycle action records its failed outcome. This is not - // registry or generated runtime state; permit only that named audit file. - const homeEntries = (await readdir(home, { recursive: true })).sort(); - require((homeEntries.length === 0 || - JSON.stringify(homeEntries) === - JSON.stringify([".hack", ".hack/activity.jsonl"])) && + require((await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 2, + })) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" - ), "no registry or generated runtime state"); - if (homeEntries.length > 0) { - const entries = (await readFile(join(home, ".hack/activity.jsonl"), "utf8")) - .trim() - .split("\n") - .map((line) => record(JSON.parse(line))); - require(entries.length === 2 && - entries.every( - (entry) => - entry.action === "up" && - entry.outcome === "failed" && - JSON.stringify(Object.keys(entry).sort()) === - JSON.stringify([ - "action", - "branch", - "duration_ms", - "failed_services", - "origin", - "outcome", - "project", - "ts", - "v", - ]) && - !JSON.stringify(entry).includes(directory) - ), "only the two failed lifecycle actions are recorded without private paths"); - } + ), "only failed lifecycle audit records; no registry or generated runtime state"); process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts index de6dd3033..23b7fa1e4 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -3,6 +3,7 @@ import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Actual relocated CLI and sidecar; no Bun/Rust on PATH and no host effects. */ const root = resolve(import.meta.dir, ".."); @@ -277,6 +278,14 @@ try { "local settings cannot inject host commands" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -288,7 +297,11 @@ try { ); assert( !(await Bun.file(marker).exists()) && - (await readdir(home)).length === 0 && + (await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + })) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts index a9e2d0c8b..cc7aac0c6 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Qualify pure process planning through a relocated bundle and real Git worktree. */ const root = resolve(import.meta.dir, ".."); @@ -262,6 +263,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -289,7 +298,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + privateRoot: directory, + failedActions: 1, + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts index f85076287..8c21094b1 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home.ts"; /** Exercise the relocated executable/sidecar with isolated policy and real linked Git worktrees. */ const root = resolve(import.meta.dir, ".."); @@ -378,6 +379,15 @@ try { "local route injection refused" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + privateRoot: directory, + existing: [".hack", ".hack/hack.config.json"], + failedActions: 0, + }), + "planning writes no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -393,7 +403,13 @@ try { (await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ) - ) && (await readdir(join(home, ".hack"))).join() === "hack.config.json", + ) && + (await nativePlanHomeMatches({ + home, + privateRoot: directory, + existing: [".hack", ".hack/hack.config.json"], + failedActions: 1, + })), "no hook, registry, route publication or generated-state effects" ); process.stdout.write( diff --git a/scripts/lib/native-plan-home.ts b/scripts/lib/native-plan-home.ts new file mode 100644 index 000000000..5ef439dd6 --- /dev/null +++ b/scripts/lib/native-plan-home.ts @@ -0,0 +1,52 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; + +/** Planning stays read-only; subsequent refused lifecycle probes may append only their audit records. */ +export async function nativePlanHomeMatches(opts: { + readonly home: string; + readonly privateRoot: string; + readonly failedActions: number; + readonly existing?: readonly string[]; +}): Promise { + const actual = (await readdir(opts.home, { recursive: true })).sort(); + const expected = [...(opts.existing ?? [])]; + if (opts.failedActions > 0) { + expected.push(".hack", ".hack/activity.jsonl"); + } + if ( + JSON.stringify(actual) !== JSON.stringify([...new Set(expected)].sort()) + ) { + return false; + } + if (opts.failedActions === 0) { + return true; + } + const text = await readFile(join(opts.home, ".hack/activity.jsonl"), "utf8"); + const entries: unknown[] = text + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + return ( + entries.length === opts.failedActions && + entries.every( + (entry) => + isRecord(entry) && + entry.action === "up" && + entry.outcome === "failed" && + JSON.stringify(Object.keys(entry).sort()) === + JSON.stringify([ + "action", + "branch", + "duration_ms", + "failed_services", + "origin", + "outcome", + "project", + "ts", + "v", + ]) && + !JSON.stringify(entry).includes(opts.privateRoot) + ) + ); +} diff --git a/tests/native-plan-home.test.ts b/tests/native-plan-home.test.ts new file mode 100644 index 000000000..5d7ab7ab1 --- /dev/null +++ b/tests/native-plan-home.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativePlanHomeMatches } from "../scripts/lib/native-plan-home.ts"; + +let root: string; +let home: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-plan-audit-")); + home = join(root, "home"); + await mkdir(home); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +const entry = { + v: 1, + ts: "2026-01-01T00:00:00Z", + project: null, + branch: null, + action: "up", + origin: "cli", + outcome: "failed", + failed_services: [], + duration_ms: 1, +}; +async function write(records: readonly object[]) { + await mkdir(join(home, ".hack"), { recursive: true }); + await Bun.write( + join(home, ".hack/activity.jsonl"), + `${records.map((value) => JSON.stringify(value)).join("\n")}\n` + ); +} +function matches(failedActions: number) { + return nativePlanHomeMatches({ home, privateRoot: root, failedActions }); +} +test("planning must leave home empty; lifecycle probes have an exact audit count", async () => { + expect(await matches(0)).toBe(true); + expect(await matches(1)).toBe(false); + await write([entry]); + expect(await matches(0)).toBe(false); + expect(await matches(1)).toBe(true); + expect(await matches(2)).toBe(false); + await write([entry, entry]); + expect(await matches(2)).toBe(true); +}); +test("audit allowance refuses registry files, unexpected fields, paths and successful actions", async () => { + for (const record of [ + { ...entry, outcome: "ok" }, + { ...entry, path: "extra" }, + { ...entry, project: root }, + ]) { + await write([record]); + expect(await matches(1)).toBe(false); + } + await write([entry]); + await Bun.write(join(home, ".hack/projects.json"), "{}"); + expect(await matches(1)).toBe(false); +}); From 0c69a6ff8e0549cb143af1a99c0ef6cdd03de03a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:02:13 +0000 Subject: [PATCH 05/14] docs(cli): classify published open output as a bare object --- docs/reference/machine-readable-output.md | 2 +- src/cli/json-contracts.ts | 2 +- tests/project-open-loopback.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 494a713bf..2b87ba49a 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -105,7 +105,7 @@ remains covered by its command-specific suites. | `hack node workspace list --json` | Legacy bare object (no `data` envelope) | | `hack node workspace remove --json` | Legacy bare object (no `data` envelope) | | `hack node workspace resolve --json` | Legacy bare object (no `data` envelope) | -| `hack open --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | +| `hack open --json` | Legacy bare object (no `data` envelope) | | `hack project owner show --json` | Legacy bare object (no `data` envelope) | | `hack project target --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack project task inspect --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index be69107b4..93359552d 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -23,7 +23,7 @@ export const JSON_COMMAND_SHAPES = { "commands show": "envelope", "commands cancel": "envelope", logs: "logs", - open: "envelope", + open: "legacy-object", activity: "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", diff --git a/tests/project-open-loopback.test.ts b/tests/project-open-loopback.test.ts index cbe1469d9..ae222cf21 100644 --- a/tests/project-open-loopback.test.ts +++ b/tests/project-open-loopback.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn, test } from "bun:test"; +import { JSON_COMMAND_SHAPES } from "../src/cli/json-contracts.ts"; import { emitOpenOrigin } from "../src/commands/project.ts"; test("open refuses a confirmed loopback failure before JSON or browser output", async () => { @@ -25,6 +26,7 @@ test("open refuses a confirmed loopback failure before JSON or browser output", }); test("native loopback origin bypasses Compose DNS, Docker and CA probes even if a proxy failure would be reported", async () => { + expect(JSON_COMMAND_SHAPES.open).toBe("legacy-object"); const write = spyOn(process.stdout, "write").mockImplementation(() => true); let checks = 0; try { From d6372ba7e256a284987e48760b2b7518c6bd28c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:51:31 +0000 Subject: [PATCH 06/14] test(desktop): establish log child before stream drop --- apps/desktop/core/src/runner.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 67c4591ff..53492e5ad 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -961,11 +961,15 @@ wait $!"#, #[test] fn log_stream_parses_ndjson_and_stops_on_drop() { + // Establish the pipe-holding descendant before publishing events. If + // sleep starts after the last event, drop can race that fork rather + // than exercise cleanup of an already-running process group. let f = FakeHack::new( - r#"echo '{"type":"start","backend":"compose"}' + r#"sleep 30 & +echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' -sleep 30"#, +wait $!"#, ); let s = f.runner().stream_logs(&["logs", "--json"], None).unwrap(); let mut got = Vec::new(); From 3ffc59744142aef93a1b412def28d866bf41adea Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:56:24 +0000 Subject: [PATCH 07/14] fix(desktop): bound log stream reader cleanup on drop --- apps/desktop/README.md | 5 +++ apps/desktop/core/src/runner.rs | 80 +++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index af83baccf..01a3153a1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -36,6 +36,11 @@ The core reads version identity through `hack version --json` and validates its use the strict parser; daemon and published legacy payloads keep their existing parsers. Shared boundary fixtures are checked against the CLI producers. +Dropping a log stream retries killing its owned process group for up to two +seconds before detaching a reader still waiting for EOF. A descendant that +leaves that group cannot be retired by group signals; it no longer blocks the +dropping thread, and its reader exits when the descendant closes stdout. + ## Check the live data path without a window ```sh diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 53492e5ad..97caf7c33 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -344,7 +344,8 @@ impl CliRunner { } } -/// A running log stream. Dropping it kills the process group. +/// A running log stream. Drop retries group cleanup for at most two seconds, +/// then detaches a reader still held open by a descendant outside that group. pub struct LogStream { child: Child, rx: mpsc::Receiver, @@ -365,10 +366,23 @@ impl LogStream { impl Drop for LogStream { fn drop(&mut self) { - kill_group(&mut self.child); + signal_kill_group(&mut self.child); if let Some(r) = self.reader.take() { - let _ = r.join(); + // A group signal can race a descendant fork on macOS. Keep the + // leader unreaped while retrying, preserving ownership of its PID + // and catching late members of the same process group. + let until = Instant::now() + Duration::from_secs(2); + while !r.is_finished() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + signal_kill_group(&mut self.child); + } + // A descendant may have left our group while keeping stdout open. + // Detach that reader rather than block the dropping UI thread. + if r.is_finished() { + let _ = r.join(); + } } + let _ = self.child.wait(); } } @@ -498,6 +512,18 @@ fn kill_group(child: &mut Child) { let _ = child.wait(); } +/// Only for the log-drop path, which has not reaped its child. Avoid +/// Child::kill here: its status check may reap and release the PID reservation. +fn signal_kill_group(child: &mut Child) { + if let Ok(pid) = i32::try_from(child.id()) { + // SAFETY: plain syscall on the group we created with process_group(0). + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::kill(pid, libc::SIGKILL); + } + } +} + fn head(s: &str) -> String { s.chars().take(200).collect() } @@ -983,4 +1009,52 @@ wait $!"#, drop(s); assert!(t.elapsed() < Duration::from_secs(5)); } + + #[test] + #[ignore = "subprocess fixture for log_stream_drop_is_bounded_with_an_escaped_writer"] + fn escaped_log_pipe_helper() { + // SAFETY: this fixture subprocess leaves its parent's group so the + // parent can test a writer that its owned-group signal cannot reach. + assert_eq!(unsafe { libc::setpgid(0, 0) }, 0); + let path = std::env::var("HACK_TEST_ESCAPED_LOG_PID").unwrap(); + std::fs::write(path, std::process::id().to_string()).unwrap(); + println!("escaped-ready"); + thread::sleep(Duration::from_secs(30)); + } + + #[test] + fn log_stream_drop_is_bounded_with_an_escaped_writer() { + struct EscapedWriter(i32); + impl Drop for EscapedWriter { + fn drop(&mut self) { + // SAFETY: PID supplied only by our fixture subprocess, which + // remains alive until this guard retires it. + unsafe { libc::kill(self.0, libc::SIGKILL) }; + } + } + let executable = std::env::current_exe().unwrap(); + let quoted = executable.to_string_lossy().replace('\'', "'\\''"); + let f = FakeHack::new(&format!( + "'{quoted}' --exact runner::tests::escaped_log_pipe_helper --ignored --nocapture &\nwait $!" + )); + let pid_file = f.dir.join("escaped.pid"); + let runner = f + .runner() + .with_env("HACK_TEST_ESCAPED_LOG_PID", &pid_file.to_string_lossy()); + let s = runner.stream_logs(&["logs", "--json"], None).unwrap(); + loop { + let event = s.next_timeout(T).expect("escaped writer ready"); + if event.message.as_deref() == Some("escaped-ready") { + break; + } + } + let escaped = EscapedWriter(std::fs::read_to_string(pid_file).unwrap().parse().unwrap()); + let t = Instant::now(); + drop(s); + assert!(t.elapsed() < Duration::from_secs(5)); + // The fixture still holds stdout: bounded return did not require EOF. + // SAFETY: signal zero only checks our still-owned fixture process. + assert_eq!(unsafe { libc::kill(escaped.0, 0) }, 0); + drop(escaped); + } } From 337f005f0a0843a9a29b8a0060ebb8252d67d7ed Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:37:36 +0000 Subject: [PATCH 08/14] test(desktop): keep stream fixture alive past cleanup deadline --- apps/desktop/core/src/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 0d7e37453..a33404abc 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -1012,7 +1012,7 @@ wait $!"#, // sleep starts after the last event, drop can race that fork rather // than exercise cleanup of an already-running process group. let f = FakeHack::new( - r#"sleep 30 & + r#"sleep 300 & echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' From 6303c0bfd32eddac2c7e08564b0a280a0bded22d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:41:01 +0000 Subject: [PATCH 09/14] test(desktop): assert log pipe descendant is retired --- apps/desktop/core/src/runner.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index a33404abc..505b68212 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -1013,6 +1013,7 @@ wait $!"#, // than exercise cleanup of an already-running process group. let f = FakeHack::new( r#"sleep 300 & +echo $! > "$HACK_HOME/holder" echo '{"type":"start","backend":"compose"}' echo '{"type":"log","entry":{"service":"api","message":"hi"}}' echo 'plain text' @@ -1027,6 +1028,12 @@ wait $!"#, assert_eq!(got[1].entry.as_ref().unwrap().message, "hi"); assert_eq!(got[2].message.as_deref(), Some("plain text")); let pid = i32::try_from(s.child.id()).unwrap(); + let holder: i32 = std::fs::read_to_string(f.dir.join("holder")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(!process_gone(holder), "pipe holder is alive"); assert!( !process_gone(pid), "positive control: stream child is alive" @@ -1037,6 +1044,7 @@ wait $!"#, ); completes(move || drop(s)); assert_retired(pid); + assert_retired(holder); } #[test] From f4d2b55887fe25ece662f490e38def9bb74f9e68 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:13:27 +0000 Subject: [PATCH 10/14] test(cli): check real success shapes of hermetic JSON commands The inventory test compared command names and usage errors only, so a listed command could change its success shape unnoticed. Run each listed command that succeeds without a runtime, engine or project and check its output against its inventory row; legacy objects pin their top-level keys. Co-Authored-By: Claude Opus 5.5 --- docs/reference/machine-readable-output.md | 7 +- tests/machine-output-success-shapes.test.ts | 157 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/machine-output-success-shapes.test.ts diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 2b87ba49a..817669e62 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -43,9 +43,10 @@ command-specific parser. Do not infer a success shape from an error envelope. Regenerate with `bun scripts/generate-machine-output-reference.ts`. Tests compare the inventory to the command definitions and exercise every command's shared -usage-error boundary without entering its handler. The migrated success variants -also have real CLI-boundary contract tests; existing legacy success behavior -remains covered by its command-specific suites. +usage-error boundary without entering its handler. Every listed command that can +succeed without a runtime, engine or project is also run for real and checked +against its row; legacy objects there pin their top-level keys. The remaining +success behavior stays covered by command-specific suites. | Canonical command / variant | Success shape | diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts new file mode 100644 index 000000000..00796225e --- /dev/null +++ b/tests/machine-output-success-shapes.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + JSON_COMMAND_SHAPES, + SCHEMA_JSON_VARIANTS, +} from "../src/cli/json-contracts.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * Real success output of every listed command that can succeed hermetically + * (temp HOME, empty working directory, no daemon, engine or network). Each case + * checks the shape its inventory row declares, so changing either the output + * or the row without the other fails. Legacy objects pin their top-level keys: + * adding, removing or wrapping one is a contract change that must be deliberate. + * Commands needing a runtime, Docker engine, project or macOS (such as + * `usage`, `doctor` and admin plans) keep their command-specific + * suites; the usage-error boundary for all of them is in + * machine-output-contract.test.ts. + */ +const entrypoint = resolve(import.meta.dir, "..", "index.ts"); + +type Case = { + readonly command: string; + readonly flags?: readonly string[]; + readonly keys?: readonly string[]; +}; + +const CASES: readonly Case[] = [ + { command: "version" }, + { command: "activity" }, + { command: "projects", flags: ["--observe"] }, + { command: "global status", flags: ["--observe"] }, + { command: "projects prune" }, + { command: "upgrade check" }, + { + command: "projects", + keys: [ + "filter", + "generated_at", + "include_global", + "include_meta", + "include_unregistered", + "projects", + "runtime_checked_at", + "runtime_error", + "runtime_last_ok_at", + "runtime_last_repair_at", + "runtime_next_step", + "runtime_ok", + "runtime_repair_action", + "runtime_repair_outcome", + "runtime_reset_at", + "runtime_reset_changes", + "runtime_reset_count", + "runtime_reset_summary", + ], + }, + { + command: "host ps", + keys: ["commands", "generatedAt", "lifecycle", "snapshotAvailable"], + }, + { command: "node list", keys: ["defaultNodeId", "nodes"] }, + { + command: "env backend status", + keys: [ + "allow_env_auth_refs", + "backend", + "cloud", + "encrypted_file", + "encrypted_file_key_env", + "status", + "store_plaintext_in_backend", + ], + }, +]; + +function schemaFor(item: Case): string | undefined { + const flags = item.flags ?? []; + return SCHEMA_JSON_VARIANTS.find( + (variant) => + variant.command === item.command && + variant.flags.join(" ") === flags.join(" ") + )?.schema; +} + +/** A versioned flag variant overrides its base row (plain `projects` is legacy). */ +function declaredShape(item: Case): string | undefined { + if (schemaFor(item)) { + return "schema-envelope"; + } + return JSON_COMMAND_SHAPES[item.command as keyof typeof JSON_COMMAND_SHAPES]; +} + +async function runJson(item: Case): Promise<{ code: number; json: unknown }> { + const root = await mkdtemp(join(tmpdir(), "hack-json-shape-")); + try { + const cwd = join(root, "work"); + await mkdir(cwd); + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + ...item.command.split(" "), + ...(item.flags ?? []), + "--json", + ], + { + cwd, + env: privateCliEnv({ + root, + env: { HACK_HOME: join(root, "state"), HACK_LOGGER: "console" }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, stderr).toBe(0); + return { code, json: JSON.parse(stdout) }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test.each( + CASES.map((item) => [ + [item.command, ...(item.flags ?? []), "--json"].join(" "), + item, + ]) +)("%s keeps its inventory success shape", async (_label, item) => { + const shape = declaredShape(item); + const { json } = await runJson(item); + expect(json).toBeObject(); + const result = json as Record; + const keys = Object.keys(result).sort(); + if (shape === "schema-envelope" || shape === "envelope") { + expect(item.keys).toBeUndefined(); + expect(keys).toEqual(["data", "ok"]); + expect(result.ok).toBe(true); + expect(result.data).toBeObject(); + const data = result.data as Record; + if (shape === "schema-envelope") { + expect(data.schema).toBe(schemaFor(item)); + } + return; + } + expect(shape).toBe("legacy-object"); + expect(item.keys).toBeDefined(); + expect(keys).toEqual([...(item.keys ?? [])].sort()); +}); From cda79803acc93f3dd368846de252890d94391cd7 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:14:40 +0000 Subject: [PATCH 11/14] test(cli): take plain command shapes from their inventory row Co-Authored-By: Claude Opus 5.5 --- tests/machine-output-success-shapes.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts index 00796225e..4c6b84cb0 100644 --- a/tests/machine-output-success-shapes.test.ts +++ b/tests/machine-output-success-shapes.test.ts @@ -85,10 +85,14 @@ function schemaFor(item: Case): string | undefined { )?.schema; } -/** A versioned flag variant overrides its base row (plain `projects` is legacy). */ +/** + * The inventory row decides a plain command's shape. Only a flag variant + * (`projects --observe`) may differ from its base row, and then only through + * its own SCHEMA_JSON_VARIANTS entry. + */ function declaredShape(item: Case): string | undefined { - if (schemaFor(item)) { - return "schema-envelope"; + if ((item.flags ?? []).length > 0) { + return schemaFor(item) ? "schema-envelope" : undefined; } return JSON_COMMAND_SHAPES[item.command as keyof typeof JSON_COMMAND_SHAPES]; } From 2058a977bef8f0513d57254f1fe20eb0e2bf44dc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:32:25 +0000 Subject: [PATCH 12/14] docs(cli): list native setup in the machine output inventory `native setup --json` (from #794) passes the runtime result through as a bare `{status, files, probe}` object. Record that shape so the inventory matches the command definitions; it does not follow the envelope rule. Co-Authored-By: Claude Opus 5.5 --- docs/reference/machine-readable-output.md | 1 + src/cli/json-contracts.ts | 1 + src/cli/json-reference.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 817669e62..478a2cdd1 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -80,6 +80,7 @@ success behavior stays covered by command-specific suites. | `hack global trust --plan --json` | Envelope; versioned `data.schema`: `hack.global-admin-plan.v1` | | `hack host ps --json` | Legacy bare object (no `data` envelope) | | `hack logs --json` | NDJSON logs; finite native modes may return a legacy snapshot object | +| `hack native setup --json` | Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope) | | `hack node add --json` | Legacy bare object (no `data` envelope) | | `hack node devcontainer attach --json` | Legacy bare object (no `data` envelope) | | `hack node devcontainer down --json` | Legacy bare object (no `data` envelope) | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 93359552d..843f71f92 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -44,6 +44,7 @@ export const JSON_COMMAND_SHAPES = { "config adopt": "legacy-object", "setup sync": "error-only", "setup migrate": "envelope", + "native setup": "runtime-passthrough", "session panes": "ndjson", "session capture": "ndjson", "session tail": "ndjson", diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts index bd22f2795..ba336aa2e 100644 --- a/src/cli/json-reference.ts +++ b/src/cli/json-reference.ts @@ -34,6 +34,8 @@ const SHAPES: Record< transport: "Native compiler protocol object (`transport_version`, `ok`, result / diagnostics)", "error-only": "Retired: usage-error envelope, no success payload", + "runtime-passthrough": + "Native runtime result passed through unchanged (`status`, `files`, `probe`; no `data` envelope)", }; export function renderJsonInventory(cli: CliSpec): string { From 4e5538263afd8d5e104d9db8a157c1a227e6f1df Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:08:33 +0000 Subject: [PATCH 13/14] fix(cli)!: record env migrate and usage by-service; note the next.17 migration `env migrate --json` (#797) is an envelope without `data.schema`; `usage --json --by-service` (#852) is a schema-tagged variant, so its schema id moves into MACHINE_SCHEMAS. Flag variants whose base command keeps another shape now get their own reference row instead of a hard-coded list. The outputs this branch migrates first shipped in 5.0.0-next.17, so the reference page no longer calls them unpublished and the next.18 release notes describe the change for scripts. BREAKING CHANGE: version, activity, projects --observe, global status --observe and global install|trust --plan JSON move into the {ok,data:{schema}} / {ok:false,error} envelope. Co-Authored-By: Claude Opus 5.5 --- docs/guides/v5-release-notes.md | 11 +++++++++++ docs/reference/machine-readable-output.md | 14 ++++++++------ src/cli/json-contracts.ts | 6 ++++++ src/cli/json-reference.ts | 5 ++++- src/commands/usage.ts | 3 ++- src/lib/machine-output.ts | 3 ++- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 11af785e2..039a6f04c 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -35,6 +35,17 @@ no longer removes legacy inputs in the same step. The full upgrade steps are in [Upgrade to v5](upgrade-to-v5.md#preview-resume-and-undo-env-migration). +### Breaking change: next.17 machine output moves into the JSON envelope + +Five `--json` outputs that first shipped in next.17 now use the documented +envelope (HACK-2238). Plain `hack projects --json` and other older outputs keep +their shapes. See [machine-readable output](../reference/machine-readable-output.md). + +| Change | What to do | +| --- | --- | +| **`hack version --json`, `hack activity --json`, `hack projects --json --observe`, `hack global status --json --observe` and `hack global install\|trust --plan --json` print `{"ok":true,"data":{...}}`.** The payload is under `data` and carries a string `data.schema` (for example `hack.version.v1`). | Read the payload from `data` and check `data.schema` before using it. | +| **Their failures print `{"ok":false,"error":{"code":"E_...","message":"..."}}` and exit nonzero.** A refused or incomplete administrator plan is `E_PLAN_REFUSED` (exit 1) or `E_PLAN_INCOMPLETE` (exit 3), with the partial plan at `error.detail.plan`. | Branch on `ok` and `error.code`, not on top-level payload fields. | + ## next.17: what changed and what to do These are source changes intended for `5.0.0-next.17`, not a claim that the diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 478a2cdd1..560859956 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -24,12 +24,12 @@ Both retain the partial plan, including its schema, at `error.detail.plan`. Unknown/offline state in an observation is still a successful observation; it is not a claim that the runtime is healthy. -The previously unpublished version, observe-only project/status and admin-plan -outputs now follow this rule. Plain `projects --json` and other published legacy -outputs keep their shapes. A consumer must not use the new observe-only parser -for a plain projects result. Streaming interfaces remain NDJSON and have their -own event contracts. No published payload is silently wrapped by this change. -The unpublished Activity output also carries `data.schema: "hack.activity.v1"`. +The version, Activity, observe-only project/status and admin-plan outputs +follow this rule from next.18. They first shipped in 5.0.0-next.17 with other +shapes; the [next.18 release notes](../guides/v5-release-notes.md) say how to +migrate. Plain `projects --json` and other older legacy outputs keep their +shapes. A consumer must not use the new observe-only parser for a plain projects +result. Streaming interfaces remain NDJSON and have their own event contracts. ![JSON command boundary](./machine-readable-output.svg) @@ -71,6 +71,7 @@ success behavior stays covered by command-specific suites. | `hack env explain --json` | Legacy bare object (no `data` envelope) | | `hack env hydrate --json` | Legacy bare object (no `data` envelope) | | `hack env list --json` | Legacy bare object (no `data` envelope) | +| `hack env migrate --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack env protect --json` | Legacy bare object (no `data` envelope) | | `hack exec --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack exec-sessions --json` | Legacy bare object (no `data` envelope) | @@ -134,4 +135,5 @@ success behavior stays covered by command-specific suites. | `hack version --json` | Envelope; versioned `data.schema`: `hack.version.v1` | | `hack projects --json --observe` | Envelope; `data.schema: hack.projects.observe.v1` | | `hack global status --json --observe` | Envelope; `data.schema: hack.global-status.observe.v1` | +| `hack usage --json --by-service` | Envelope; `data.schema: hack.usage.by-service.v1` | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 843f71f92..1bd5047de 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -31,6 +31,7 @@ export const JSON_COMMAND_SHAPES = { "dispatch status": "legacy-object", "dispatch logs": "legacy-object", "env list": "legacy-object", + "env migrate": "envelope", "env protect": "legacy-object", "env explain": "legacy-object", "env apply": "legacy-object", @@ -106,4 +107,9 @@ export const SCHEMA_JSON_VARIANTS = [ flags: ["--plan"], schema: "hack.global-admin-plan.v1", }, + { + command: "usage", + flags: ["--by-service"], + schema: "hack.usage.by-service.v1", + }, ] as const; diff --git a/src/cli/json-reference.ts b/src/cli/json-reference.ts index ba336aa2e..e4367baf3 100644 --- a/src/cli/json-reference.ts +++ b/src/cli/json-reference.ts @@ -51,8 +51,11 @@ export function renderJsonInventory(cli: CliSpec): string { const flags = variant?.flags.join(" ") ?? ""; return `| \`hack ${command}${flags ? ` ${flags}` : ""} --json\` | ${SHAPES[kind]}${variant ? `: \`${variant.schema}\`` : ""} |`; }); + // A flag variant whose base command keeps another shape gets its own row. for (const variant of SCHEMA_JSON_VARIANTS.filter( - (entry) => entry.command === "projects" || entry.command === "global status" + (entry) => + JSON_COMMAND_SHAPES[entry.command as keyof typeof JSON_COMMAND_SHAPES] !== + "schema-envelope" )) { rows.push( `| \`hack ${variant.command} --json ${variant.flags.join(" ")}\` | Envelope; \`data.schema: ${variant.schema}\` |` diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 84001c938..0add0619b 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -22,6 +22,7 @@ import { readHostCommandRecords, readObservedProcesses, } from "../lib/host-command-observation.ts"; +import { MACHINE_SCHEMAS } from "../lib/machine-output.ts"; import { MissingProjectContextError } from "../lib/missing-project-error.ts"; import { findProjectContext, @@ -90,7 +91,7 @@ const optByService = defineOption({ } as const); /** Schema id of `hack usage --json --by-service`; see docs/reference/usage-by-service.md. */ -export const USAGE_BY_SERVICE_SCHEMA = "hack.usage.by-service.v1"; +export const USAGE_BY_SERVICE_SCHEMA = MACHINE_SCHEMAS.usageByService; const options = [ optProject, diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index ca0fdf8e9..1b8561c5c 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -1,12 +1,13 @@ import { type CliOkResult, okResult } from "./cli-result.ts"; -/** Unpublished HACK-2218 contracts use an envelope and a versioned payload schema. */ +/** HACK-2218 machine contracts use an envelope and a versioned payload schema. */ export const MACHINE_SCHEMAS = { activity: "hack.activity.v1", version: "hack.version.v1", projectsObserve: "hack.projects.observe.v1", globalAdminPlan: "hack.global-admin-plan.v1", globalStatusObserve: "hack.global-status.observe.v1", + usageByService: "hack.usage.by-service.v1", } as const; export function machineSuccess(opts: { From db01be6341412345ba1a1b35f0fc1ea82169bf4a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:09:33 +0000 Subject: [PATCH 14/14] docs(desktop): drop the stale unpublished claim from the schema parser Co-Authored-By: Claude Opus 5.5 --- apps/desktop/core/src/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/core/src/runner.rs b/apps/desktop/core/src/runner.rs index 384caee5a..a86a9738e 100644 --- a/apps/desktop/core/src/runner.rs +++ b/apps/desktop/core/src/runner.rs @@ -584,7 +584,7 @@ pub fn parse_result(stdout: &str, stderr: &str, code: Option) -> Result