@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/adapters/raw-evidence-git-diff.js +50 -0
- package/dist/adapters/raw-evidence-keys.js +4 -1
- package/dist/adapters/raw-evidence-pack-store.js +13 -4
- package/dist/adapters/raw-evidence.js +43 -1
- package/dist/autostart-node-path.js +141 -0
- package/dist/autostart-self-heal.js +115 -11
- package/dist/autostart.js +213 -41
- package/dist/commands/autostart-heal.js +162 -0
- package/dist/commands/collection-roots.js +4 -4
- package/dist/commands/doctor.js +47 -1
- package/dist/commands/heartbeat.js +193 -0
- package/dist/commands/install-receipts.js +45 -19
- package/dist/commands/install-update.js +3 -3
- package/dist/commands/local-args.js +7 -1
- package/dist/commands/local.js +116 -10
- package/dist/commands/ops-render.js +29 -0
- package/dist/commands/ops.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +146 -25
- package/dist/dev-build.js +186 -0
- package/dist/evidence-upload-client.js +112 -4
- package/dist/evidence-upload-rekey.js +40 -0
- package/dist/log-rotation.js +144 -0
- package/dist/onboarding-roots.js +23 -6
- package/dist/raw-evidence-gc.js +9 -23
- package/dist/scheduled-self-update.js +1 -0
- package/dist/second-install.js +160 -0
- package/dist/sync-health-class.js +242 -0
- package/dist/upload.js +2 -0
- package/package.json +3 -3
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
|
|
65
|
-
const
|
|
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:
|
|
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 =
|
|
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
|
|
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:
|
|
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) =>
|
|
958
|
-
return [
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
94
|
+
throw new CollectionRootRequiredError(collectionRootMissingOnDiskMessage(saved, homeDir));
|
|
95
95
|
}
|
|
96
96
|
return usable;
|
|
97
97
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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") {
|