@bli-cockpit/cli 0.2.46 → 0.2.48

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;
@@ -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
  }
@@ -1,12 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { autostartStatus, installAutostartAgent } from "../autostart.js";
3
+ import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "../autostart.js";
4
4
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
5
5
  import { describeError, redactedHealthDetail } from "../health-detail.js";
6
6
  import { inspectBackfillLock } from "../backfill-lock.js";
7
7
  import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
8
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
9
9
  import { normalizeCollectionRoots } from "../root-normalization.js";
10
+ import { detectSecondCockpitInstall } from "../second-install.js";
10
11
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
11
12
  import { runBackfillCommand } from "./backfill.js";
12
13
  import { createInteractiveExecRunner } from "../process-runner.js";
@@ -71,6 +72,10 @@ function doctorInvariants() {
71
72
  fix: fixRootState,
72
73
  requiresInteractiveFix: true,
73
74
  },
75
+ // Deliberately has NO fix: uninstalling software the operator did not ask
76
+ // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
77
+ // other install and the exact command; the person decides.
78
+ { id: "single-install", check: (context) => context.deps.checkSingleInstall(context) },
74
79
  {
75
80
  id: "autostart-alive",
76
81
  check: (context) => context.deps.checkAutostart(context),
@@ -103,6 +108,7 @@ function defaultDoctorDeps(hooks) {
103
108
  runLogin: (context) => hooks.runLogin(context.command, context.io),
104
109
  readRoots: readRootState,
105
110
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
111
+ checkSingleInstall: checkSingleInstallState,
106
112
  checkAutostart: checkAutostartState,
107
113
  fixAutostart: fixAutostartState,
108
114
  checkBackfill: checkBackfillState,
@@ -221,6 +227,37 @@ async function readRootState(context) {
221
227
  " 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
222
228
  ].join("\n"));
223
229
  }
230
+ /**
231
+ * BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
232
+ * call the other's registration stale and re-register itself, so the plist
233
+ * flaps every 15 minutes and the device reports two CLI versions the same
234
+ * morning. Doctor OFFERS the removal and never performs it: an operator may
235
+ * have a second prefix on purpose, and `npm uninstall -g` of something nobody
236
+ * asked to remove is not a repair. The row therefore has no `fix` — it is
237
+ * information, and doctor still exits 0.
238
+ */
239
+ async function checkSingleInstallState(context) {
240
+ const exec = context.io.exec;
241
+ if (!exec) {
242
+ return skipped("single-install", "runner_unavailable", "could not look for a second Tower install");
243
+ }
244
+ const finding = await detectSecondCockpitInstall({ exec });
245
+ if (!finding.detected) {
246
+ return ok("single-install", finding.reason, finding.install_count === 1
247
+ ? "one Tower install on this machine"
248
+ : "no second Tower install found");
249
+ }
250
+ // The full paths go to the operator's own screen; redactedHealthDetail
251
+ // masks them again before any of this is uploaded.
252
+ return needsFix("single-install", "second_install_detected", [
253
+ `${finding.install_count} Tower installs are on this machine's PATH:`,
254
+ ...finding.paths.map((entry) => ` - ${entry}`),
255
+ "Both will fight over the background scheduler, re-registering it against each other.",
256
+ "Keep one. To remove the other:",
257
+ ` npm uninstall -g @bli-cockpit/cli # run it with the node that owns ${finding.other_bin ?? "the other bin"}`,
258
+ "Nothing was uninstalled — that is your call.",
259
+ ].join("\n"));
260
+ }
224
261
  async function checkAutostartState(context) {
225
262
  const exec = context.io.exec;
226
263
  if (!exec) {
@@ -234,6 +271,15 @@ async function checkAutostartState(context) {
234
271
  exec,
235
272
  });
236
273
  if (result.status === "loaded") {
274
+ // BLI-3553: "loaded" only means the scheduler accepted the registration.
275
+ // It says nothing about whether the binary that registration names still
276
+ // exists — and `brew upgrade node` deletes exactly that. Ask the
277
+ // filesystem about the paths the PLATFORM holds, not the ones this process
278
+ // happens to be running under.
279
+ const missing = await registeredRuntimePathProblems({ exec });
280
+ if (missing.length > 0) {
281
+ return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
282
+ }
237
283
  return ok("autostart-alive", "already_installed", "background sync is running");
238
284
  }
239
285
  if (result.status === "unsupported") {