@bli-cockpit/cli 0.2.97 → 0.2.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A run that dies must not be able to say it worked (BLI-4110).
3
+ *
4
+ * On 2026-09-09 `cockpit do-everything` on the founder's Windows box reached
5
+ * "windows task repair converged", printed `Error: read ENOTCONN` from
6
+ * `child_process.spawn`, and **exited 0**. That is the worse half of that
7
+ * incident. A repair that fails loudly gets looked at; a repair that dies
8
+ * while claiming success stops anybody looking at all, and that machine had
9
+ * not delivered a session in 29 days.
10
+ *
11
+ * **What was measured** (Node 22.20, win32), because the fix depends entirely
12
+ * on which shapes can produce a zero:
13
+ *
14
+ * | shape | exit code |
15
+ * | -- | -- |
16
+ * | `throw` from a `setImmediate` after `process.exitCode = 0` | 1 |
17
+ * | an unhandled promise rejection | 1 |
18
+ * | an `error` event on an inherited stdio stream | 1 |
19
+ * | a `throw` inside a `process.on("exit")` handler | **0** |
20
+ *
21
+ * Node's own defaults are honest about every asynchronous crash. The zero can
22
+ * only come from the window where OUR code is the last thing holding the exit
23
+ * code — and `cli.ts` held it in the most optimistic way available:
24
+ *
25
+ * const exitCode = await runCockpitCli(argv);
26
+ * process.exitCode = exitCode;
27
+ *
28
+ * `process.exitCode` starts at 0. So *anything* that prevents that assignment
29
+ * from being reached, or that runs after it, reports success by default.
30
+ * Success was the resting state and had to be disproved.
31
+ *
32
+ * **The fix inverts that: success must be earned.** The entry point sets a
33
+ * non-zero code BEFORE doing any work, and only a command that actually
34
+ * returned is allowed to lower it. Nothing here has to enumerate the ways a
35
+ * run can die, which matters because the specific `read ENOTCONN` path has not
36
+ * been reproduced off that machine — see `docs/runbooks/` and the ticket. A
37
+ * guard that depends on correctly predicting the crash is a guard that works
38
+ * on the crashes you already knew about.
39
+ *
40
+ * The guard only ever RAISES a zero. It can never turn a real failure into a
41
+ * success, and it never overwrites a code a command chose.
42
+ */
43
+ /**
44
+ * Distinct from 1 on purpose. `1` is "this ran and the answer is no"; 70 is
45
+ * "this never finished, so there is no answer". A scheduled task's log can
46
+ * then tell a failed repair from an abandoned one without parsing prose.
47
+ */
48
+ export const EXIT_DIED_MID_RUN = 70;
49
+ /** Stages nest — the subcommand, then the invariant running inside it. */
50
+ const openStages = [];
51
+ let commandReturned = false;
52
+ let installed = false;
53
+ export function beginStage(stage) {
54
+ openStages.push(stage);
55
+ }
56
+ /**
57
+ * Closes the innermost stage. Takes the name so an unbalanced pair is visible:
58
+ * closing a stage that is not the open one would make a later crash blame the
59
+ * wrong stage, which is worse than naming none.
60
+ */
61
+ export function endStage(stage) {
62
+ const top = openStages[openStages.length - 1];
63
+ if (top !== stage) {
64
+ console.error("[cockpit-cli] stage mismatch", JSON.stringify({ reason: "stage_mismatch", closing: stage, open: top ?? null }));
65
+ return;
66
+ }
67
+ openStages.pop();
68
+ }
69
+ /** The innermost stage still running, or null. Naming only — never a verdict. */
70
+ export function openStage() {
71
+ return openStages[openStages.length - 1] ?? null;
72
+ }
73
+ /**
74
+ * Called by the entry point when the command function actually returned a
75
+ * code. This is the ONE fact the guard trusts: not "did a stage close", not
76
+ * "were there failures", but "did control come back".
77
+ */
78
+ export function markCommandReturned() {
79
+ commandReturned = true;
80
+ }
81
+ /** Test seam — the guard is process-global, so a suite must reset it. */
82
+ export function resetCrashGuardForTest() {
83
+ openStages.length = 0;
84
+ commandReturned = false;
85
+ installed = false;
86
+ }
87
+ /**
88
+ * Error facts that are safe to log: names and codes, never a message. An error
89
+ * message routinely carries a path under somebody's home directory, and this
90
+ * line is read off a fleet-visible log.
91
+ */
92
+ function describeCrash(error) {
93
+ if (!(error instanceof Error)) {
94
+ return { error_name: typeof error, error_code: null, error_syscall: null };
95
+ }
96
+ const record = error;
97
+ return {
98
+ error_name: error.name,
99
+ error_code: record.code ?? null,
100
+ error_syscall: record.syscall ?? null,
101
+ };
102
+ }
103
+ /**
104
+ * Installs the guard. Idempotent — the entry point is also imported by tests.
105
+ *
106
+ * `uncaughtExceptionMonitor`, not `uncaughtException`, and the difference is
107
+ * the whole safety argument: the monitor OBSERVES and lets Node's default
108
+ * handling proceed, so the stack trace still prints and the process still
109
+ * dies. Registering `uncaughtException` would SUPPRESS the default, leaving
110
+ * this guard to re-implement crashing correctly — which is precisely how a
111
+ * safety net becomes the thing that swallows the error. Since Node 15 an
112
+ * unhandled rejection is raised as an uncaught exception by default, so the
113
+ * monitor names those too without a second listener.
114
+ */
115
+ export function installCrashGuard(options = {}) {
116
+ const target = options.process ?? process;
117
+ if (installed)
118
+ return;
119
+ installed = true;
120
+ target.on("uncaughtExceptionMonitor", (error) => {
121
+ console.error("[cockpit-cli] crashed", JSON.stringify({
122
+ reason: "uncaught_exception",
123
+ stage: openStage(),
124
+ ...describeCrash(error),
125
+ }));
126
+ });
127
+ target.on("exit", () => {
128
+ if (commandReturned)
129
+ return;
130
+ // `EXIT_DIED_MID_RUN` has to be in this set, and leaving it out made this
131
+ // whole branch dead code in the shipped binary: `cli.ts` presets 70 BEFORE
132
+ // any work, so by the time `exit` fires the code is 70 (quiet death) or 1
133
+ // (Node overwrote it on an uncaught exception) and never 0. The exit CODE
134
+ // was right; the line that names the stage never printed once. Caught in
135
+ // review by transcribing this file plus `cli.ts` and running every death
136
+ // shape — not by the unit test, which passed only because it hand-set 0,
137
+ // a state the entry point cannot reach.
138
+ if (target.exitCode !== 0 &&
139
+ target.exitCode !== undefined &&
140
+ target.exitCode !== EXIT_DIED_MID_RUN) {
141
+ return;
142
+ }
143
+ console.error("[cockpit-cli] died mid-run", JSON.stringify({
144
+ reason: "died_mid_run",
145
+ stage: openStage(),
146
+ corrected_exit_code: EXIT_DIED_MID_RUN,
147
+ }));
148
+ target.exitCode = EXIT_DIED_MID_RUN;
149
+ });
150
+ }
151
+ /**
152
+ * Runs `body` as a named stage so a crash inside it can say where it was.
153
+ *
154
+ * The `finally` closes the stage on both paths on purpose: a stage that THREW
155
+ * is finished, and its exception is already travelling to somebody who will
156
+ * set a real exit code. Naming is all this does — the exit-code decision rests
157
+ * on `markCommandReturned`, never on whether a stage happens to be open.
158
+ */
159
+ export async function withStage(stage, body) {
160
+ beginStage(stage);
161
+ try {
162
+ return await body();
163
+ }
164
+ finally {
165
+ endStage(stage);
166
+ }
167
+ }
@@ -49,6 +49,34 @@ export function createCapturedExecRunner(options = {}) {
49
49
  });
50
50
  });
51
51
  }
52
+ /**
53
+ * Which stdio a re-exec may inherit (BLI-4110).
54
+ *
55
+ * `stdio: "inherit"` hands the child all three of the parent's handles. Under
56
+ * the Windows Task Scheduler — and any other non-interactive host — the
57
+ * parent's stdin is not a console: it can be a pipe nobody is writing to, or a
58
+ * socket that was never connected, and reading it raises `read ENOTCONN` from
59
+ * inside `child_process.spawn`. That is the error `cockpit do-everything`
60
+ * printed on the founder's box on 2026-09-09, from `reexecDoctor`, in a
61
+ * non-interactive shell.
62
+ *
63
+ * **stdout and stderr are still inherited, always.** They are what makes a
64
+ * scheduled run's output land in the log a person later reads, and dropping
65
+ * them to fix stdin would trade a crash for a silence — which is the failure
66
+ * this whole ticket is about.
67
+ *
68
+ * **stdin is inherited only when there is a console to read from.** A
69
+ * re-execed `do-everything` under a scheduler has nothing to type at it; the
70
+ * handle was pure liability. `"ignore"` gives the child a real, closed stdin
71
+ * rather than a broken one, so a child that does read it gets EOF instead of
72
+ * an error.
73
+ *
74
+ * Pure and exported so both host families are testable without a real TTY,
75
+ * per the supported-fleet contract in AGENTS.md.
76
+ */
77
+ export function interactiveStdio(input) {
78
+ return [input.stdinIsTty === true ? "inherit" : "ignore", "inherit", "inherit"];
79
+ }
52
80
  export function createInteractiveExecRunner(options = {}) {
53
81
  return (command, args, runOptions) => new Promise((resolve) => {
54
82
  const env = runOptions?.env ?? options.env ?? process.env;
@@ -56,8 +84,9 @@ export function createInteractiveExecRunner(options = {}) {
56
84
  platform: options.platform,
57
85
  env,
58
86
  });
87
+ const stdio = interactiveStdio({ stdinIsTty: process.stdin.isTTY });
59
88
  const child = spawn(invocation.command, invocation.args, {
60
- stdio: "inherit",
89
+ stdio,
61
90
  env,
62
91
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
63
92
  });
@@ -66,6 +95,15 @@ export function createInteractiveExecRunner(options = {}) {
66
95
  if (settled)
67
96
  return;
68
97
  settled = true;
98
+ // Named, because a spawn that never started and a child that ran and
99
+ // failed both used to arrive here as a bare code 1. Metadata only.
100
+ const record = error;
101
+ console.error("[process-runner] spawn failed", JSON.stringify({
102
+ reason: "spawn_failed",
103
+ error_code: record.code ?? null,
104
+ error_syscall: record.syscall ?? null,
105
+ stdin: stdio[0],
106
+ }));
69
107
  resolve({ code: 1, stdout: "", stderr: error.message });
70
108
  });
71
109
  child.on("close", (code) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.97",
3
+ "version": "0.2.99",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,11 +24,11 @@
24
24
  "pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.25",
31
- "@bli-cockpit/mcp": "0.1.28",
32
- "@bli-cockpit/telemetry-core": "0.1.42"
30
+ "@bli-cockpit/memory-mcp": "0.1.26",
31
+ "@bli-cockpit/mcp": "0.1.30",
32
+ "@bli-cockpit/telemetry-core": "0.1.43"
33
33
  }
34
34
  }