@bli-cockpit/cli 0.2.45 → 0.2.47

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/autostart.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
5
  import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
6
6
  import { redactedHealthDetail } from "./health-detail.js";
7
+ import { DARWIN_LOGIN_SHELL, resolveStableNodeExecutable, } from "./autostart-node-path.js";
7
8
  /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
8
9
  export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
9
10
  export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
@@ -37,6 +38,11 @@ export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
37
38
  */
38
39
  export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
39
40
  const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
41
+ /**
42
+ * What the tick says to sync.err.log when neither the registered node binary
43
+ * nor a login-shell node exists. Named, actionable, path-free (BLI-3553).
44
+ */
45
+ export const DARWIN_NODE_MISSING_MESSAGE = "[autostart] tick skipped: reason=node_binary_missing - the registered node binary is gone and no node is on the login PATH; run cockpit doctor to re-register";
40
46
  function plistPathFor(homeDir) {
41
47
  return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
42
48
  }
@@ -61,41 +67,8 @@ export async function installAutostartAgent(options) {
61
67
  return installWindowsTask(options);
62
68
  if (platform !== "darwin")
63
69
  return unsupportedResult(options.homeDir);
64
- const homeDir = options.homeDir ?? os.homedir();
65
- const workDirs = normalizeWorkDirs(options.repoRoots);
66
- const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
67
- const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
68
- const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
69
- const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
70
- const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
71
- const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
72
- const paths = getCollectorRuntimePaths(homeDir);
73
- const plistPath = plistPathFor(homeDir);
74
- const stdoutPath = path.join(paths.state_dir, "sync.log");
75
- const stderrPath = path.join(paths.state_dir, "sync.err.log");
76
- // launchd will not reliably watch a path that does not exist at load time, so
77
- // only feed it the transcript dirs that are present right now. A missing dir
78
- // is fine — the StartInterval floor still covers it. The network signal is
79
- // appended unconditionally: it always exists on the Macs this plist targets,
80
- // and an existence filter would drop it when rendering on another host.
81
- const watchPaths = [
82
- ...(await existingWatchPaths(homeDir)),
83
- DARWIN_NETWORK_CHANGE_SIGNAL,
84
- ];
85
- await mkdir(path.dirname(plistPath), { recursive: true });
86
- await mkdir(paths.state_dir, { recursive: true });
87
- await writeFile(plistPath, renderPlist({
88
- discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
89
- workDir,
90
- workDirs: resolvedWorkDirs,
91
- dashboardUrl,
92
- intervalSeconds,
93
- nodeExecutable,
94
- cliEntryPoint,
95
- stdoutPath,
96
- stderrPath,
97
- watchPaths,
98
- }), "utf8");
70
+ const written = await writeDarwinAutostartPlist(options);
71
+ const { plist_path: plistPath, work_dir: workDir, work_dirs: resolvedWorkDirs, dashboard_url: dashboardUrl, interval_seconds: intervalSeconds, watch_path_count: watchPathCount, } = written;
99
72
  // Unload first so a changed plist is actually picked up; a not-yet-loaded
100
73
  // agent makes unload fail harmlessly, so the error is ignored.
101
74
  await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
@@ -112,7 +85,8 @@ export async function installAutostartAgent(options) {
112
85
  console.error("[autostart] launchd agent loaded", JSON.stringify({
113
86
  label: AUTOSTART_LABEL,
114
87
  interval_seconds: intervalSeconds,
115
- watch_path_count: watchPaths.length,
88
+ watch_path_count: watchPathCount,
89
+ node_path_reason: written.node_path_reason,
116
90
  }));
117
91
  }
118
92
  else {
@@ -139,6 +113,74 @@ export async function installAutostartAgent(options) {
139
113
  }),
140
114
  };
141
115
  }
116
+ /**
117
+ * Renders and writes the LaunchAgent plist without touching launchd.
118
+ *
119
+ * Split out of `installAutostartAgent` for BLI-3553: the detached macOS healer
120
+ * (autostart-self-heal.ts) has to write the SAME plist this file writes and
121
+ * then re-register it with `bootout`/`bootstrap` rather than `unload`/`load`,
122
+ * and two renderers would be two chances to disagree. One writer, two
123
+ * registration verbs.
124
+ */
125
+ export async function writeDarwinAutostartPlist(options) {
126
+ const homeDir = options.homeDir ?? os.homedir();
127
+ const workDirs = normalizeWorkDirs(options.repoRoots);
128
+ const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
129
+ const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
130
+ const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
131
+ const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
132
+ const node = await schedulerNodeExecutable(options, "darwin");
133
+ const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
134
+ const paths = getCollectorRuntimePaths(homeDir);
135
+ const plistPath = plistPathFor(homeDir);
136
+ const stdoutPath = path.join(paths.state_dir, "sync.log");
137
+ const stderrPath = path.join(paths.state_dir, "sync.err.log");
138
+ // launchd will not reliably watch a path that does not exist at load time, so
139
+ // only feed it the transcript dirs that are present right now. A missing dir
140
+ // is fine — the StartInterval floor still covers it. The network signal is
141
+ // appended unconditionally: it always exists on the Macs this plist targets,
142
+ // and an existence filter would drop it when rendering on another host.
143
+ const watchPaths = [
144
+ ...(await existingWatchPaths(homeDir)),
145
+ DARWIN_NETWORK_CHANGE_SIGNAL,
146
+ ];
147
+ await mkdir(path.dirname(plistPath), { recursive: true });
148
+ await mkdir(paths.state_dir, { recursive: true });
149
+ await writeFile(plistPath, renderPlist({
150
+ discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
151
+ workDir,
152
+ workDirs: resolvedWorkDirs,
153
+ dashboardUrl,
154
+ intervalSeconds,
155
+ nodeExecutable: node.path,
156
+ cliEntryPoint,
157
+ stdoutPath,
158
+ stderrPath,
159
+ watchPaths,
160
+ }), "utf8");
161
+ return {
162
+ plist_path: plistPath,
163
+ work_dir: workDir,
164
+ work_dirs: resolvedWorkDirs,
165
+ dashboard_url: dashboardUrl,
166
+ interval_seconds: intervalSeconds,
167
+ node_executable: node.path,
168
+ node_path_reason: node.reason,
169
+ cli_entry_point: cliEntryPoint,
170
+ watch_path_count: watchPaths.length,
171
+ };
172
+ }
173
+ /**
174
+ * The node binary the scheduler should name. BOTH the writer and the read-back
175
+ * validator go through here — if only the writer pinned the stable alias, every
176
+ * machine would read "does not match the current Tower runtime" forever, which
177
+ * is BLI-2541 with a new coat of paint.
178
+ */
179
+ async function schedulerNodeExecutable(options, platform) {
180
+ const resolver = platform === "win32" ? path.win32.resolve : path.resolve;
181
+ const given = resolver(options.nodeExecutable ?? process.execPath);
182
+ return resolveStableNodeExecutable(given, { platform });
183
+ }
142
184
  /**
143
185
  * What `launchctl` actually said, or an honest account of it saying nothing.
144
186
  *
@@ -198,7 +240,7 @@ export async function autostartStatus(options) {
198
240
  const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
199
241
  const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
200
242
  const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
201
- const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
243
+ const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
202
244
  const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
203
245
  const plist = await readFile(plistPath, "utf8").catch(() => "");
204
246
  const registrationProblems = darwinAgentRegistrationProblems(plist, {
@@ -238,7 +280,8 @@ async function installWindowsTask(options) {
238
280
  const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
239
281
  const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
240
282
  const launcherPath = windowsAutostartLauncherPath(homeDir);
241
- const nodeExecutable = path.win32.resolve(options.nodeExecutable ?? process.execPath);
283
+ const node = await schedulerNodeExecutable(options, "win32");
284
+ const nodeExecutable = node.path;
242
285
  const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
243
286
  await mkdir(path.dirname(scriptPath), { recursive: true });
244
287
  await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
@@ -414,7 +457,7 @@ async function windowsTaskStatus(options) {
414
457
  discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
415
458
  workDirs: normalizeWindowsWorkDirs(options.repoRoots),
416
459
  dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
417
- nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
460
+ nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
418
461
  cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
419
462
  syncLogPath: windowsSyncLogPath(options.homeDir),
420
463
  })}`;
@@ -954,8 +997,42 @@ function renderDarwinSyncCommand(options) {
954
997
  ? ""
955
998
  : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
956
999
  const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
957
- const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
958
- return ["exit_code=0", ...commands, 'exit "$exit_code"'].join("; ");
1000
+ const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
1001
+ return [
1002
+ "exit_code=0",
1003
+ ...darwinNodeResolutionPreamble(options.nodeExecutable),
1004
+ ...commands,
1005
+ 'exit "$exit_code"',
1006
+ ].join("; ");
1007
+ }
1008
+ /**
1009
+ * The registered node path first, a login-shell PATH lookup second, a named
1010
+ * failure third (BLI-3553).
1011
+ *
1012
+ * The hardcoded absolute path stays FIRST and stays the normal case — launchd
1013
+ * hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing this fleet
1014
+ * runs lives there, which is the whole reason the plist names absolutes
1015
+ * (scheduled-self-update.ts:55-62). The fallback only fires when that path is
1016
+ * gone: `brew upgrade node` deleted the Cellar directory, `nvm uninstall`
1017
+ * removed a version, someone moved a prefix. Before this, that state was a
1018
+ * silent `exit 127` every 15 minutes forever, on every tick, with nothing in
1019
+ * sync.err.log naming it.
1020
+ *
1021
+ * `/bin/zsh -lc` because a login shell is where Homebrew's `brew shellenv` and
1022
+ * a machine's own PATH edits live; `command -v` because it is a builtin and
1023
+ * cannot itself be missing. Recovering here is deliberately temporary: running
1024
+ * under a node the plist does not name makes the next status read disagree,
1025
+ * which is exactly what wakes the self-heal up to re-register the new path.
1026
+ * A machine whose node is only on an INTERACTIVE shell's PATH (nvm sourced from
1027
+ * .zshrc) still fails — but now it fails with a reason in the log instead of a
1028
+ * bare 127.
1029
+ */
1030
+ function darwinNodeResolutionPreamble(nodeExecutable) {
1031
+ return [
1032
+ `node_bin=${shellQuote(nodeExecutable)}`,
1033
+ `[ -x "$node_bin" ] || node_bin="$(${DARWIN_LOGIN_SHELL} -lc 'command -v node' 2>/dev/null)"`,
1034
+ `[ -x "$node_bin" ] || { echo ${shellQuote(DARWIN_NODE_MISSING_MESSAGE)} >&2; exit 127; }`,
1035
+ ];
959
1036
  }
960
1037
  function darwinAgentRegistrationProblems(plist, expected) {
961
1038
  if (!plist)
@@ -1031,6 +1108,101 @@ function watchPathsBlock(watchPaths) {
1031
1108
  " </array>",
1032
1109
  ];
1033
1110
  }
1111
+ /**
1112
+ * What the platform says it will run, parsed out of the registration itself
1113
+ * (BLI-3553). Deliberately NOT rebuilt from `process.execPath` — the whole
1114
+ * failure this ticket exists for is the registered path outliving the binary,
1115
+ * and a check built from our own current values cannot see that.
1116
+ */
1117
+ export async function readRegisteredRuntimePaths(options) {
1118
+ const platform = options.platform ?? process.platform;
1119
+ if (platform === "darwin") {
1120
+ const plist = await readFile(plistPathFor(options.homeDir ?? os.homedir()), "utf8").catch(() => "");
1121
+ const command = decodeXmlEntities(plist);
1122
+ return {
1123
+ node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
1124
+ cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
1125
+ };
1126
+ }
1127
+ if (platform === "win32") {
1128
+ const script = await readFile(windowsAutostartScriptPath(options.homeDir), "utf8").catch(() => "");
1129
+ return {
1130
+ node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
1131
+ cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
1132
+ };
1133
+ }
1134
+ return { node_executable: null, cli_entry_point: null };
1135
+ }
1136
+ /**
1137
+ * Named problems with the registered runtime, or an empty list. Path-free
1138
+ * strings: safe to log, safe to upload as an install-event detail.
1139
+ */
1140
+ export async function registeredRuntimePathProblems(options) {
1141
+ // A real-host check by nature: this stats paths. A suite simulating win32 on
1142
+ // a Mac (which every Windows test here does) would be asking the wrong
1143
+ // filesystem about `C:\...`, and "missing" would mean nothing. Say nothing
1144
+ // rather than something false.
1145
+ const platform = options.platform ?? process.platform;
1146
+ if (platform !== (options.hostPlatform ?? process.platform))
1147
+ return [];
1148
+ const registered = await readRegisteredRuntimePaths(options);
1149
+ const problems = [];
1150
+ if (registered.node_executable === null) {
1151
+ problems.push("registration names no node binary");
1152
+ }
1153
+ else if (!(await fileExists(registered.node_executable))) {
1154
+ problems.push("registered node binary is missing");
1155
+ }
1156
+ if (registered.cli_entry_point === null) {
1157
+ problems.push("registration names no Tower CLI entry point");
1158
+ }
1159
+ else if (!(await fileExists(registered.cli_entry_point))) {
1160
+ problems.push("registered Tower CLI entry point is missing");
1161
+ }
1162
+ return problems;
1163
+ }
1164
+ /** `gui/<uid>` — the per-user launchd domain the LaunchAgent lives in. */
1165
+ export function launchdUserDomain(uid = process.getuid?.() ?? 0) {
1166
+ return `gui/${uid}`;
1167
+ }
1168
+ /**
1169
+ * Re-registers the LaunchAgent with the modern verbs (BLI-3553).
1170
+ *
1171
+ * `unload`/`load` is what the interactive install path uses and it stays there;
1172
+ * this exists for the DETACHED healer, where the difference matters:
1173
+ * `bootstrap` reports a rejected plist with a real error, whereas `load` is
1174
+ * documented-deprecated and exits nonzero with empty output for several
1175
+ * unrelated states (see launchctlDetail). The healer gets one shot and has no
1176
+ * human watching, so it uses the verb that says what went wrong.
1177
+ *
1178
+ * `bootout` on a service that is not loaded exits nonzero (`3: No such
1179
+ * process`); that is the expected steady state after the job that spawned us
1180
+ * exited, so it is not treated as a failure. Only `bootstrap` decides the
1181
+ * outcome. If it fails, the agent is left unloaded — recoverable, because
1182
+ * launchd bootstraps everything in ~/Library/LaunchAgents at the next login,
1183
+ * and the plist file itself was already rewritten before this call.
1184
+ */
1185
+ export async function reregisterDarwinAgent(options) {
1186
+ const domain = launchdUserDomain(options.uid);
1187
+ const bootout = await options
1188
+ .exec("launchctl", ["bootout", `${domain}/${AUTOSTART_LABEL}`])
1189
+ .catch(() => ({ code: 1, stdout: "", stderr: "" }));
1190
+ const bootstrap = await options
1191
+ .exec("launchctl", ["bootstrap", domain, options.plistPath])
1192
+ .catch(() => ({ code: 1, stdout: "", stderr: "" }));
1193
+ if (bootstrap.code === 0) {
1194
+ return {
1195
+ ok: true,
1196
+ reason: "agent_rebootstrapped",
1197
+ detail: `bootout exit ${bootout.code}`,
1198
+ };
1199
+ }
1200
+ return {
1201
+ ok: false,
1202
+ reason: "bootstrap_failed",
1203
+ detail: `launchctl bootstrap exited ${bootstrap.code}: ${launchctlDetail(bootstrap)}`,
1204
+ };
1205
+ }
1034
1206
  function shellQuote(value) {
1035
1207
  return `'${value.replace(/'/g, "'\\''")}'`;
1036
1208
  }
@@ -0,0 +1,162 @@
1
+ import path from "node:path";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { AUTOSTART_LABEL, autostartStatus, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
4
+ import { redactedHealthDetail } from "../health-detail.js";
5
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
6
+ import { normalizeCollectionRoots } from "../root-normalization.js";
7
+ import { createCapturedExecRunner } from "../process-runner.js";
8
+ import { reportInstallEventsBestEffort } from "./install-receipts.js";
9
+ /** How long to wait for the launchd job that spawned us to exit. */
10
+ export const HEAL_PARENT_EXIT_TIMEOUT_MS = 120_000;
11
+ const HEAL_PARENT_POLL_MS = 250;
12
+ const DETAIL_MAX_CHARS = 300;
13
+ export async function runAutostartHealDetached(command, io, deps = {}) {
14
+ const outcome = await healDetached(command, io, deps);
15
+ const report = deps.reportInstallEvents ?? reportInstallEventsBestEffort;
16
+ await report({
17
+ homeDir: command.homeDir,
18
+ dashboardUrl: command.dashboardUrl,
19
+ command: "sync",
20
+ events: [
21
+ {
22
+ step: "autostart_heal",
23
+ status: outcome.status,
24
+ ...(outcome.status === "ok" ? {} : { error_code: outcome.reason }),
25
+ ...(outcome.detail ? { error_detail: outcome.detail } : {}),
26
+ },
27
+ ],
28
+ json: command.json,
29
+ io,
30
+ });
31
+ // Both branches log: "did any Mac in the fleet actually recover today?" has
32
+ // to be answerable from sync.err.log alone.
33
+ //
34
+ // And it has to be written THERE explicitly. This process is spawned with
35
+ // `stdio: "ignore"` — deliberately, so it holds no descriptor on a log the
36
+ // tick may be rotating — which means its stderr goes nowhere. Without the
37
+ // append below the only trace of a failed heal would be the uploaded
38
+ // receipt, and an operator reading their own machine would see a scheduler
39
+ // that silently stopped healing.
40
+ const line = `${outcome.status === "ok"
41
+ ? "[autostart-heal] launchd agent re-registered"
42
+ : "[autostart-heal] launchd agent not re-registered"} ${JSON.stringify({
43
+ label: AUTOSTART_LABEL,
44
+ status: outcome.status,
45
+ reason: outcome.reason,
46
+ })}`;
47
+ console.error(line);
48
+ await appendFile(path.join(getCollectorRuntimePaths(command.homeDir).state_dir, "sync.err.log"), `${line}\n`).catch(() => undefined);
49
+ return outcome.status === "fail" ? 1 : 0;
50
+ }
51
+ async function healDetached(command, io, deps) {
52
+ const platform = deps.platform ?? process.platform;
53
+ if (platform !== "darwin") {
54
+ return { status: "skipped", reason: "not_darwin" };
55
+ }
56
+ const exec = deps.exec ?? io.exec ?? createCapturedExecRunner();
57
+ const waited = await waitForParentExit(command.parentPid, deps);
58
+ if (!waited) {
59
+ // Booting the job out while it is still running would SIGTERM it — the
60
+ // exact hazard this process exists to avoid. Refuse instead.
61
+ return { status: "fail", reason: "parent_still_running" };
62
+ }
63
+ const roots = await savedRoots(command.homeDir);
64
+ if (roots.length === 0) {
65
+ return { status: "skipped", reason: "no_saved_roots" };
66
+ }
67
+ const before = await autostartStatus({
68
+ homeDir: command.homeDir,
69
+ repoRoot: roots[0],
70
+ repoRoots: roots,
71
+ dashboardUrl: command.dashboardUrl,
72
+ exec,
73
+ platform,
74
+ });
75
+ if (before.status === "absent") {
76
+ // Repair what exists, never create.
77
+ return { status: "skipped", reason: "autostart_absent" };
78
+ }
79
+ let written;
80
+ try {
81
+ written = await writeDarwinAutostartPlist({
82
+ homeDir: command.homeDir,
83
+ repoRoot: roots[0],
84
+ repoRoots: roots,
85
+ dashboardUrl: command.dashboardUrl,
86
+ exec,
87
+ platform,
88
+ });
89
+ }
90
+ catch (error) {
91
+ return {
92
+ status: "fail",
93
+ reason: "plist_write_failed",
94
+ detail: redactedHealthDetail(error instanceof Error ? error.message : String(error)).slice(0, DETAIL_MAX_CHARS),
95
+ };
96
+ }
97
+ const reregistered = await reregisterDarwinAgent({
98
+ exec,
99
+ plistPath: written.plist_path,
100
+ });
101
+ if (!reregistered.ok) {
102
+ return {
103
+ status: "fail",
104
+ reason: reregistered.reason,
105
+ detail: redactedHealthDetail(reregistered.detail ?? "").slice(0, DETAIL_MAX_CHARS),
106
+ };
107
+ }
108
+ // Verify against what launchd returns, not against what we sent it
109
+ // (BLI-2541). A bootstrap that exits 0 and a registration that reads back
110
+ // healthy are two different claims.
111
+ const after = await autostartStatus({
112
+ homeDir: command.homeDir,
113
+ repoRoot: roots[0],
114
+ repoRoots: roots,
115
+ dashboardUrl: command.dashboardUrl,
116
+ exec,
117
+ platform,
118
+ });
119
+ if (after.status !== "loaded") {
120
+ return {
121
+ status: "fail",
122
+ reason: "reregistered_but_still_broken",
123
+ detail: redactedHealthDetail(after.message ?? after.status).slice(0, DETAIL_MAX_CHARS),
124
+ };
125
+ }
126
+ return {
127
+ status: "ok",
128
+ reason: "autostart_healed",
129
+ detail: `node_path_reason=${written.node_path_reason}`,
130
+ };
131
+ }
132
+ async function waitForParentExit(parentPid, deps) {
133
+ if (!parentPid || parentPid <= 0)
134
+ return true;
135
+ const isAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
136
+ const sleep = deps.sleep ??
137
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
138
+ const now = deps.now ?? (() => Date.now());
139
+ const deadline = now() + HEAL_PARENT_EXIT_TIMEOUT_MS;
140
+ while (isAlive(parentPid)) {
141
+ if (now() >= deadline)
142
+ return false;
143
+ await sleep(HEAL_PARENT_POLL_MS);
144
+ }
145
+ return true;
146
+ }
147
+ function defaultIsProcessAlive(pid) {
148
+ try {
149
+ // Signal 0 tests for existence without delivering anything.
150
+ process.kill(pid, 0);
151
+ return true;
152
+ }
153
+ catch (error) {
154
+ // EPERM means it exists and belongs to someone else — still alive.
155
+ return error.code === "EPERM";
156
+ }
157
+ }
158
+ async function savedRoots(homeDir) {
159
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
160
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
161
+ }
162
+ export const AUTOSTART_HEAL_DEFAULT_DASHBOARD_URL = DEFAULT_DASHBOARD_URL;
@@ -59,6 +59,11 @@ export async function runBrief(command, io) {
59
59
  visible_because: body.visibleBecause ?? null,
60
60
  tldr: Boolean(command.tldr),
61
61
  pinned_version: command.version != null,
62
+ asked_day: command.date != null,
63
+ day: body.day?.date ?? null,
64
+ history_count: body.history?.length ?? null,
65
+ history_truncated: body.historyTruncated ?? null,
66
+ delta: command.delta ? (body.delta ? "answered" : (body.deltaReason ?? "missing")) : null,
62
67
  subject: command.subject ? "selected" : "caller",
63
68
  version_count: body.versions?.length ?? null,
64
69
  versions_reason: body.versionsReason ?? null,
@@ -127,12 +132,24 @@ function queryFor(command) {
127
132
  params.set("p", command.subject);
128
133
  if (command.version)
129
134
  params.set("v", command.version);
135
+ if (command.date)
136
+ params.set("d", command.date);
130
137
  if (command.tldr)
131
138
  params.set("tldr", "1");
132
139
  if (command.versions)
133
140
  params.set("versions", "1");
134
141
  if (command.claims)
135
142
  params.set("claims", "1");
143
+ if (command.action === "history") {
144
+ params.set("history", "1");
145
+ if (command.days !== undefined)
146
+ params.set("days", String(command.days));
147
+ }
148
+ if (command.delta) {
149
+ params.set("delta", "1");
150
+ if (command.against)
151
+ params.set("against", command.against);
152
+ }
136
153
  const query = params.toString();
137
154
  return query ? `?${query}` : "";
138
155
  }
@@ -145,9 +162,20 @@ function writeHuman(io, command, body) {
145
162
  // moment rather than something that failed to update.
146
163
  const whose = page.displayName ? `${page.displayName}'s page` : "This page";
147
164
  const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
148
- writeLine(io.stdout, dim(`${whose}${pinned}`, styled));
165
+ // BLI-3484: which day, in the SUBJECT's zone, said with the zone beside it so
166
+ // nobody has to work out whose midnight this is.
167
+ const day = body.day?.date ? ` · ${body.day.date}${body.day.zone ? ` ${body.day.zone}` : ""}` : "";
168
+ writeLine(io.stdout, dim(`${whose}${pinned}${day}`, styled));
169
+ // `history` lists days and prints no page — a list of dates under a full
170
+ // brief would bury the thing somebody asked for.
171
+ if (command.action === "history") {
172
+ writeHistory(io, body, styled);
173
+ return;
174
+ }
149
175
  writeLine(io.stdout, "");
150
176
  writeLine(io.stdout, body.text ?? "");
177
+ if (command.delta)
178
+ writeDelta(io, body, styled);
151
179
  if (body.claims && body.claims.length > 0) {
152
180
  writeLine(io.stdout, "");
153
181
  writeLine(io.stdout, dim("Claim ids — pass one to `cockpit correct --claim`:", styled));
@@ -181,6 +209,72 @@ function writeHuman(io, command, body) {
181
209
  }
182
210
  }
183
211
  }
212
+ /**
213
+ * `cockpit brief history` — the days there are, newest first (BLI-3484).
214
+ *
215
+ * The masthead line beside each date is what makes the list browsable: a column
216
+ * of dates is a filing cabinet, a column of dates with "Two reviews waiting,
217
+ * one shipped" beside them is a history somebody can find their way around.
218
+ *
219
+ * A short window says so. The oldest date printed is not the oldest date there
220
+ * is when the index hit its cap, and a person picking through history has to
221
+ * know which of those they are looking at.
222
+ */
223
+ function writeHistory(io, body, styled) {
224
+ const history = body.history ?? [];
225
+ writeLine(io.stdout, "");
226
+ if (history.length === 0) {
227
+ writeLine(io.stdout, dim("No days with a page yet.", styled));
228
+ return;
229
+ }
230
+ writeLine(io.stdout, dim("Days — pass one to `cockpit brief --date`:", styled));
231
+ for (const entry of history) {
232
+ const compiles = typeof entry.versionCount === "number" && entry.versionCount > 1
233
+ ? ` (${entry.versionCount} compiles)`
234
+ : "";
235
+ const heading = entry.heading ? ` — ${entry.heading}` : "";
236
+ writeLine(io.stdout, `${dim(` ${entry.date ?? "?"}`, styled)}${heading}${dim(compiles, styled)}`);
237
+ }
238
+ if (body.historyTruncated) {
239
+ writeLine(io.stdout, "");
240
+ writeLine(io.stdout, dim("There are older days than these. Ask for more with `--days <n>`.", styled));
241
+ }
242
+ }
243
+ /**
244
+ * `--delta` — what changed between this day and the one before it.
245
+ *
246
+ * The headline first, then the lines that moved, because the headline is the
247
+ * answer and the detail is the evidence for it. A missing delta prints its
248
+ * reason: silence here would read as "nothing changed", which is a completely
249
+ * different and much worse claim than "I could not work it out".
250
+ */
251
+ function writeDelta(io, body, styled) {
252
+ writeLine(io.stdout, "");
253
+ if (!body.delta) {
254
+ writeLine(io.stdout, dim(`No day-over-day comparison (${body.deltaReason ?? "no reason given"}).`, styled));
255
+ return;
256
+ }
257
+ const delta = body.delta;
258
+ const from = delta.from?.date ?? "the day before";
259
+ const to = delta.to?.date ?? "this day";
260
+ writeLine(io.stdout, dim(`What changed, ${from} → ${to}:`, styled));
261
+ writeLine(io.stdout, ` ${delta.headline ?? "(no headline)"}`);
262
+ for (const item of delta.detail ?? []) {
263
+ writeLine(io.stdout, "");
264
+ if (item.before)
265
+ writeLine(io.stdout, `${dim(" was:", styled)} ${item.before}`);
266
+ if (item.after)
267
+ writeLine(io.stdout, `${dim(" now:", styled)} ${item.after}`);
268
+ if (item.because)
269
+ writeLine(io.stdout, `${dim(" why:", styled)} ${item.because}`);
270
+ }
271
+ if (delta.storedReason) {
272
+ // Not a failure and not hidden either: this comparison skips versions, so
273
+ // it is worked out fresh every time somebody asks for it.
274
+ writeLine(io.stdout, "");
275
+ writeLine(io.stdout, dim(`(not kept: ${delta.storedReason})`, styled));
276
+ }
277
+ }
184
278
  function writeFailure(command, io, reason, detail) {
185
279
  if (command.json) {
186
280
  writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
@@ -11,7 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { stat } from "node:fs/promises";
13
13
  import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
14
- import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
14
+ import { CollectionRootRequiredError, resolveOnboardingRoots, } from "../onboarding-roots.js";
15
15
  import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
16
16
  import { collectionRootPathAliases } from "../repo-identity.js";
17
17
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -37,7 +37,7 @@ export async function resolveOnboardingRootsForCommand(command, io) {
37
37
  const collectionRoots = rootsResult.roots;
38
38
  const primaryRoot = collectionRoots[0];
39
39
  if (!primaryRoot) {
40
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
40
+ throw new CollectionRootRequiredError(`no collection root confirmed.`);
41
41
  }
42
42
  const replaceRepoRoots = rootsResult.source === "prompt" &&
43
43
  !command.collectionRoots?.length &&
@@ -80,7 +80,7 @@ export async function assertCollectionRootPersisted(homeDir) {
80
80
  const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
81
81
  const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
82
82
  if (saved.length === 0) {
83
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
83
+ throw new CollectionRootRequiredError(collectionRootNotPersistedMessage(homeDir));
84
84
  }
85
85
  // Present in the file is not the same as usable. A root that no longer
86
86
  // exists on disk resolves to nothing at sync time, which is the same silent
@@ -91,7 +91,7 @@ export async function assertCollectionRootPersisted(homeDir) {
91
91
  usable.push(root);
92
92
  }
93
93
  if (usable.length === 0) {
94
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
94
+ throw new CollectionRootRequiredError(collectionRootMissingOnDiskMessage(saved, homeDir));
95
95
  }
96
96
  return usable;
97
97
  }