@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/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/brief.js +95 -1
- package/dist/commands/collection-roots.js +4 -4
- package/dist/commands/doctor.js +47 -1
- package/dist/commands/heartbeat.js +175 -0
- package/dist/commands/install-receipts.js +11 -19
- package/dist/commands/install-update.js +3 -3
- package/dist/commands/jarvis.js +5 -0
- package/dist/commands/local-args.js +70 -4
- package/dist/commands/local-help.js +13 -4
- 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/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 +2 -2
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;
|
package/dist/commands/brief.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
}
|