@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.
- package/dist/cli.js +13 -0
- package/dist/commands/doctor.js +6 -2
- package/dist/commands/local-args-collector-status.js +27 -6
- package/dist/commands/local-args-tower-admin.js +17 -6
- package/dist/commands/local-args-tower-memory.js +151 -0
- package/dist/commands/local-args-tower-pages.js +6 -1
- package/dist/commands/local-args-tower-usage.js +8 -0
- package/dist/commands/local-args-tower.js +2 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-command-shapes-memory.js +22 -0
- package/dist/commands/local-help-commands-tower.js +31 -5
- package/dist/commands/local-help-commands.js +25 -5
- package/dist/commands/local-help.js +7 -4
- package/dist/commands/local.js +18 -0
- package/dist/commands/memory-hook-counts.js +29 -8
- package/dist/commands/memory-store.js +331 -0
- package/dist/commands/notes-writes.js +37 -0
- package/dist/commands/notes.js +5 -3
- package/dist/commands/ops-render.js +5 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/usage.js +23 -0
- package/dist/crash-guard.js +167 -0
- package/dist/process-runner.js +39 -1
- package/package.json +5 -5
|
@@ -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
|
+
}
|
package/dist/process-runner.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
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
|
}
|