@bli-cockpit/cli 0.2.48 → 0.2.50
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-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +94 -1203
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +58 -1705
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +323 -7
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +604 -0
- package/dist/commands/local-args-tower.js +891 -0
- package/dist/commands/local-args.js +10 -1549
- package/dist/commands/local-help.js +30 -5
- package/dist/commands/local.js +21 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +231 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +378 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +308 -0
- package/dist/commands/sync.js +387 -0
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +76 -6
- package/package.json +2 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL } from "./local-state.js";
|
|
5
|
+
import { resolveStableNodeExecutable, } from "./autostart-node-path.js";
|
|
6
|
+
/** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
|
|
7
|
+
export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
8
|
+
/** The name Task Scheduler files the same job under on Windows. */
|
|
9
|
+
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
10
|
+
export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
|
|
11
|
+
export const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
|
|
12
|
+
/** Where the LaunchAgent lives for a given home; also the `plist_path` every
|
|
13
|
+
* result carries, including the unsupported-platform one. */
|
|
14
|
+
export function plistPathFor(homeDir) {
|
|
15
|
+
return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
|
|
16
|
+
}
|
|
17
|
+
/** The one answer for a host neither host family covers — Linux, mostly CI. */
|
|
18
|
+
export function unsupportedResult(homeDir) {
|
|
19
|
+
return {
|
|
20
|
+
status: "unsupported",
|
|
21
|
+
label: AUTOSTART_LABEL,
|
|
22
|
+
plist_path: plistPathFor(homeDir ?? os.homedir()),
|
|
23
|
+
message: UNSUPPORTED_MESSAGE,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The one place a caller's partial options become the full registration.
|
|
28
|
+
*
|
|
29
|
+
* Writer and read-back validator both start here, on both host families: if
|
|
30
|
+
* the two ever filled a default differently, every machine would read as
|
|
31
|
+
* needing repair forever, which is BLI-2541's failure mode. `platform` picks
|
|
32
|
+
* the path flavour — Windows resolves and dedupes case-insensitively, macOS
|
|
33
|
+
* keeps the case it was given.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveAutostartSettings(options, platform) {
|
|
36
|
+
const resolvePath = platform === "win32" ? path.win32.resolve : path.resolve;
|
|
37
|
+
const requestedRoots = uniqueResolvedRoots(options.repoRoots, platform);
|
|
38
|
+
const workDir = resolvePath(options.repoRoot ?? requestedRoots[0] ?? process.cwd());
|
|
39
|
+
return {
|
|
40
|
+
work_dir: workDir,
|
|
41
|
+
work_dirs: requestedRoots.length > 0 ? requestedRoots : [workDir],
|
|
42
|
+
dashboard_url: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
|
|
43
|
+
interval_seconds: options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS,
|
|
44
|
+
cli_entry_point: resolvePath(options.cliEntryPoint ?? process.argv[1] ?? ""),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Absolute roots in the order given, first occurrence wins. */
|
|
48
|
+
function uniqueResolvedRoots(repoRoots, platform) {
|
|
49
|
+
if (!repoRoots)
|
|
50
|
+
return [];
|
|
51
|
+
const resolvePath = platform === "win32" ? path.win32.resolve : path.resolve;
|
|
52
|
+
const dedupeKey = (root) => platform === "win32" ? root.toLowerCase() : root;
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const roots = [];
|
|
55
|
+
for (const root of repoRoots) {
|
|
56
|
+
const resolved = resolvePath(root);
|
|
57
|
+
const key = dedupeKey(resolved);
|
|
58
|
+
if (seen.has(key))
|
|
59
|
+
continue;
|
|
60
|
+
seen.add(key);
|
|
61
|
+
roots.push(resolved);
|
|
62
|
+
}
|
|
63
|
+
return roots;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The node binary the scheduler should name. BOTH the writer and the read-back
|
|
67
|
+
* validator go through here — if only the writer pinned the stable alias, every
|
|
68
|
+
* machine would read "does not match the current Tower runtime" forever, which
|
|
69
|
+
* is BLI-2541 with a new coat of paint.
|
|
70
|
+
*/
|
|
71
|
+
export async function schedulerNodeExecutable(options, platform) {
|
|
72
|
+
const resolver = platform === "win32" ? path.win32.resolve : path.resolve;
|
|
73
|
+
const given = resolver(options.nodeExecutable ?? process.execPath);
|
|
74
|
+
return resolveStableNodeExecutable(given, { platform });
|
|
75
|
+
}
|
|
76
|
+
/** Presence only; a path we cannot stat counts as absent. */
|
|
77
|
+
export async function fileExists(filePath) {
|
|
78
|
+
return stat(filePath).then(() => true, () => false);
|
|
79
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
|
+
import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
|
|
6
|
+
import { DARWIN_LOGIN_SHELL } from "./autostart-node-path.js";
|
|
7
|
+
import { AUTOSTART_LABEL, fileExists, plistPathFor, resolveAutostartSettings, schedulerNodeExecutable, } from "./autostart-contract.js";
|
|
8
|
+
import { decodeXmlEntities, xmlEscape } from "./autostart-xml.js";
|
|
9
|
+
/**
|
|
10
|
+
* What the LaunchAgent plist SAYS: render it, write it, and read it back to
|
|
11
|
+
* decide whether the file on disk still names the current roots and runtime.
|
|
12
|
+
*
|
|
13
|
+
* Registration with launchd is the sibling file's job (autostart-darwin.ts).
|
|
14
|
+
* Renderer and validator share this module deliberately — the validator
|
|
15
|
+
* compares against the renderer's own output, so a change to one is made in
|
|
16
|
+
* front of the other (BLI-2541).
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* macOS rewrites this file on every network transition (join, leave, DNS
|
|
20
|
+
* change), so watching it retries spooled uploads the moment connectivity
|
|
21
|
+
* returns instead of waiting out the StartInterval — the lid-closed-mid-upload
|
|
22
|
+
* machine hopping cafés is where most historical upload failures came from
|
|
23
|
+
* (BLI-2604). The real file, not the /etc/resolv.conf symlink: launchd watches
|
|
24
|
+
* the path it is given, and the symlink itself never changes. Burst fires are
|
|
25
|
+
* cheap — the collection lock turns overlap into a named no-op.
|
|
26
|
+
*/
|
|
27
|
+
export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
|
|
28
|
+
/**
|
|
29
|
+
* What the tick says to sync.err.log when neither the registered node binary
|
|
30
|
+
* nor a login-shell node exists. Named, actionable, path-free (BLI-3553).
|
|
31
|
+
*/
|
|
32
|
+
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";
|
|
33
|
+
/**
|
|
34
|
+
* Renders and writes the LaunchAgent plist without touching launchd.
|
|
35
|
+
*
|
|
36
|
+
* Split out of `installAutostartAgent` for BLI-3553: the detached macOS healer
|
|
37
|
+
* (autostart-self-heal.ts) has to write the SAME plist this file writes and
|
|
38
|
+
* then re-register it with `bootout`/`bootstrap` rather than `unload`/`load`,
|
|
39
|
+
* and two renderers would be two chances to disagree. One writer, two
|
|
40
|
+
* registration verbs.
|
|
41
|
+
*/
|
|
42
|
+
export async function writeDarwinAutostartPlist(options) {
|
|
43
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
44
|
+
const settings = resolveAutostartSettings(options, "darwin");
|
|
45
|
+
const node = await schedulerNodeExecutable(options, "darwin");
|
|
46
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
47
|
+
const plistPath = plistPathFor(homeDir);
|
|
48
|
+
const stdoutPath = path.join(paths.state_dir, "sync.log");
|
|
49
|
+
const stderrPath = path.join(paths.state_dir, "sync.err.log");
|
|
50
|
+
const watchPaths = await darwinWatchPaths(homeDir);
|
|
51
|
+
await mkdir(path.dirname(plistPath), { recursive: true });
|
|
52
|
+
await mkdir(paths.state_dir, { recursive: true });
|
|
53
|
+
await writeFile(plistPath, renderPlist({
|
|
54
|
+
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
55
|
+
workDir: settings.work_dir,
|
|
56
|
+
workDirs: settings.work_dirs,
|
|
57
|
+
dashboardUrl: settings.dashboard_url,
|
|
58
|
+
intervalSeconds: settings.interval_seconds,
|
|
59
|
+
nodeExecutable: node.path,
|
|
60
|
+
cliEntryPoint: settings.cli_entry_point,
|
|
61
|
+
stdoutPath,
|
|
62
|
+
stderrPath,
|
|
63
|
+
watchPaths,
|
|
64
|
+
}), "utf8");
|
|
65
|
+
return {
|
|
66
|
+
plist_path: plistPath,
|
|
67
|
+
work_dir: settings.work_dir,
|
|
68
|
+
work_dirs: settings.work_dirs,
|
|
69
|
+
dashboard_url: settings.dashboard_url,
|
|
70
|
+
interval_seconds: settings.interval_seconds,
|
|
71
|
+
node_executable: node.path,
|
|
72
|
+
node_path_reason: node.reason,
|
|
73
|
+
cli_entry_point: settings.cli_entry_point,
|
|
74
|
+
watch_path_count: watchPaths.length,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Named differences between the plist on disk and the one this build would
|
|
79
|
+
* write, or an empty list. The strings are what an operator reads in
|
|
80
|
+
* `cockpit autostart status`, so they stay path-free.
|
|
81
|
+
*/
|
|
82
|
+
export async function darwinAgentProblems(options, settings) {
|
|
83
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
84
|
+
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
85
|
+
const plist = await readFile(plistPathFor(homeDir), "utf8").catch(() => "");
|
|
86
|
+
return darwinAgentRegistrationProblems(plist, {
|
|
87
|
+
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
88
|
+
workDirs: settings.work_dirs,
|
|
89
|
+
dashboardUrl: settings.dashboard_url,
|
|
90
|
+
intervalSeconds: settings.interval_seconds,
|
|
91
|
+
nodeExecutable,
|
|
92
|
+
cliEntryPoint: settings.cli_entry_point,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What the plist says it will run, parsed out of the registration itself
|
|
97
|
+
* (BLI-3553) rather than rebuilt from `process.execPath`.
|
|
98
|
+
*/
|
|
99
|
+
export async function readRegisteredDarwinRuntimePaths(homeDir) {
|
|
100
|
+
const plist = await readFile(plistPathFor(homeDir), "utf8").catch(() => "");
|
|
101
|
+
const command = decodeXmlEntities(plist);
|
|
102
|
+
return {
|
|
103
|
+
node_executable: /node_bin='([^']*)'/u.exec(command)?.[1] ?? null,
|
|
104
|
+
cli_entry_point: /"\$node_bin" '([^']*)' sync /u.exec(command)?.[1] ?? null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The transcript directories whose changes should re-trigger a sync, in the
|
|
109
|
+
* order they appear in WatchPaths. Resolved from the same homeDir as the plist
|
|
110
|
+
* and log paths so `--home` redirects them together.
|
|
111
|
+
*/
|
|
112
|
+
function watchPathCandidates(homeDir) {
|
|
113
|
+
return [
|
|
114
|
+
path.join(homeDir, ".claude", "projects"),
|
|
115
|
+
path.join(homeDir, ".codex", "sessions"),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* launchd will not reliably watch a path that does not exist at load time, so
|
|
120
|
+
* only feed it the transcript dirs that are present right now. A missing dir
|
|
121
|
+
* is fine — the StartInterval floor still covers it. The network signal is
|
|
122
|
+
* appended unconditionally: it always exists on the Macs this plist targets,
|
|
123
|
+
* and an existence filter would drop it when rendering on another host.
|
|
124
|
+
*/
|
|
125
|
+
async function darwinWatchPaths(homeDir) {
|
|
126
|
+
const candidates = watchPathCandidates(homeDir);
|
|
127
|
+
const present = await Promise.all(candidates.map((dir) => fileExists(dir)));
|
|
128
|
+
return [
|
|
129
|
+
...candidates.filter((_, i) => present[i]),
|
|
130
|
+
DARWIN_NETWORK_CHANGE_SIGNAL,
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
function renderPlist(options) {
|
|
134
|
+
const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
|
|
135
|
+
const command = renderDarwinSyncCommand({
|
|
136
|
+
workDirs: roots,
|
|
137
|
+
dashboardUrl: options.dashboardUrl,
|
|
138
|
+
nodeExecutable: options.nodeExecutable,
|
|
139
|
+
cliEntryPoint: options.cliEntryPoint,
|
|
140
|
+
discoveryArgs: options.discoveryArgs,
|
|
141
|
+
});
|
|
142
|
+
return [
|
|
143
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
144
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
145
|
+
'<plist version="1.0">',
|
|
146
|
+
"<dict>",
|
|
147
|
+
" <key>Label</key>",
|
|
148
|
+
` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`,
|
|
149
|
+
...programArgumentsBlock(command),
|
|
150
|
+
" <key>StartInterval</key>",
|
|
151
|
+
` <integer>${options.intervalSeconds}</integer>`,
|
|
152
|
+
" <key>RunAtLoad</key>",
|
|
153
|
+
" <true/>",
|
|
154
|
+
// WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
|
|
155
|
+
// captures land near-real-time; StartInterval above is the safety-net floor.
|
|
156
|
+
// It fires often, but launchd single-flights the job, sync holds its own
|
|
157
|
+
// lock, and growing-transcript re-upload is damped — so cost stays bounded
|
|
158
|
+
// and no extra debounce is needed.
|
|
159
|
+
...watchPathsBlock(options.watchPaths),
|
|
160
|
+
" <key>StandardOutPath</key>",
|
|
161
|
+
` <string>${xmlEscape(options.stdoutPath)}</string>`,
|
|
162
|
+
" <key>StandardErrorPath</key>",
|
|
163
|
+
` <string>${xmlEscape(options.stderrPath)}</string>`,
|
|
164
|
+
"</dict>",
|
|
165
|
+
"</plist>",
|
|
166
|
+
"",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The `<key>ProgramArguments</key>` lines. The whole command is ONE zsh -c
|
|
171
|
+
* argument, which is what makes the read-back check a single-substring match
|
|
172
|
+
* against these exact lines.
|
|
173
|
+
*/
|
|
174
|
+
function programArgumentsBlock(command) {
|
|
175
|
+
return [
|
|
176
|
+
" <key>ProgramArguments</key>",
|
|
177
|
+
" <array>",
|
|
178
|
+
" <string>/bin/zsh</string>",
|
|
179
|
+
" <string>-c</string>",
|
|
180
|
+
` <string>${xmlEscape(command)}</string>`,
|
|
181
|
+
" </array>",
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
186
|
+
* transcript dir exists yet (an empty array would tell launchd to watch
|
|
187
|
+
* everything-and-nothing; omitting the key leaves StartInterval as the floor).
|
|
188
|
+
*/
|
|
189
|
+
function watchPathsBlock(watchPaths) {
|
|
190
|
+
if (watchPaths.length === 0)
|
|
191
|
+
return [];
|
|
192
|
+
return [
|
|
193
|
+
" <key>WatchPaths</key>",
|
|
194
|
+
" <array>",
|
|
195
|
+
...watchPaths.map((dir) => ` <string>${xmlEscape(dir)}</string>`),
|
|
196
|
+
" </array>",
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
function renderDarwinSyncCommand(options) {
|
|
200
|
+
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
201
|
+
? ""
|
|
202
|
+
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
203
|
+
const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
204
|
+
const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
|
|
205
|
+
return [
|
|
206
|
+
"exit_code=0",
|
|
207
|
+
...darwinNodeResolutionPreamble(options.nodeExecutable),
|
|
208
|
+
...commands,
|
|
209
|
+
'exit "$exit_code"',
|
|
210
|
+
].join("; ");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The registered node path first, a login-shell PATH lookup second, a named
|
|
214
|
+
* failure third (BLI-3553).
|
|
215
|
+
*
|
|
216
|
+
* The hardcoded absolute path stays FIRST and stays the normal case — launchd
|
|
217
|
+
* hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing this fleet
|
|
218
|
+
* runs lives there, which is the whole reason the plist names absolutes
|
|
219
|
+
* (scheduled-self-update.ts:55-62). The fallback only fires when that path is
|
|
220
|
+
* gone: `brew upgrade node` deleted the Cellar directory, `nvm uninstall`
|
|
221
|
+
* removed a version, someone moved a prefix. Before this, that state was a
|
|
222
|
+
* silent `exit 127` every 15 minutes forever, on every tick, with nothing in
|
|
223
|
+
* sync.err.log naming it.
|
|
224
|
+
*
|
|
225
|
+
* `/bin/zsh -lc` because a login shell is where Homebrew's `brew shellenv` and
|
|
226
|
+
* a machine's own PATH edits live; `command -v` because it is a builtin and
|
|
227
|
+
* cannot itself be missing. Recovering here is deliberately temporary: running
|
|
228
|
+
* under a node the plist does not name makes the next status read disagree,
|
|
229
|
+
* which is exactly what wakes the self-heal up to re-register the new path.
|
|
230
|
+
* A machine whose node is only on an INTERACTIVE shell's PATH (nvm sourced from
|
|
231
|
+
* .zshrc) still fails — but now it fails with a reason in the log instead of a
|
|
232
|
+
* bare 127.
|
|
233
|
+
*/
|
|
234
|
+
function darwinNodeResolutionPreamble(nodeExecutable) {
|
|
235
|
+
return [
|
|
236
|
+
`node_bin=${shellQuote(nodeExecutable)}`,
|
|
237
|
+
`[ -x "$node_bin" ] || node_bin="$(${DARWIN_LOGIN_SHELL} -lc 'command -v node' 2>/dev/null)"`,
|
|
238
|
+
`[ -x "$node_bin" ] || { echo ${shellQuote(DARWIN_NODE_MISSING_MESSAGE)} >&2; exit 127; }`,
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
function darwinAgentRegistrationProblems(plist, expected) {
|
|
242
|
+
if (!plist)
|
|
243
|
+
return ["plist could not be read"];
|
|
244
|
+
const problems = [];
|
|
245
|
+
if (!plist.includes(` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`)) {
|
|
246
|
+
problems.push("label does not match");
|
|
247
|
+
}
|
|
248
|
+
if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
|
|
249
|
+
problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
|
|
250
|
+
}
|
|
251
|
+
const expectedCommand = renderDarwinSyncCommand({
|
|
252
|
+
discoveryArgs: expected.discoveryArgs,
|
|
253
|
+
workDirs: expected.workDirs,
|
|
254
|
+
dashboardUrl: expected.dashboardUrl,
|
|
255
|
+
nodeExecutable: expected.nodeExecutable,
|
|
256
|
+
cliEntryPoint: expected.cliEntryPoint,
|
|
257
|
+
});
|
|
258
|
+
if (!plist.includes(programArgumentsBlock(expectedCommand).join("\n"))) {
|
|
259
|
+
problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
|
|
260
|
+
}
|
|
261
|
+
return problems;
|
|
262
|
+
}
|
|
263
|
+
function shellQuote(value) {
|
|
264
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
265
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { redactedHealthDetail } from "./health-detail.js";
|
|
4
|
+
import { AUTOSTART_LABEL, fileExists, plistPathFor, resolveAutostartSettings, } from "./autostart-contract.js";
|
|
5
|
+
import { darwinAgentProblems, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
|
|
6
|
+
/**
|
|
7
|
+
* Installs (or refreshes) the launchd LaunchAgent that keeps `cockpit sync`
|
|
8
|
+
* running at login and every `intervalSeconds`. Mirrors the plist in
|
|
9
|
+
* docs/runbooks/cockpit-launchd-sync.md exactly, but writes resolved absolute
|
|
10
|
+
* log paths (launchd does not expand `$HOME`). The unload before load makes the
|
|
11
|
+
* install idempotent — re-running picks up a changed repo/url/interval.
|
|
12
|
+
*/
|
|
13
|
+
export async function installDarwinAgent(options) {
|
|
14
|
+
const written = await writeDarwinAutostartPlist(options);
|
|
15
|
+
const load = await reloadLaunchAgent(options.exec, written.plist_path);
|
|
16
|
+
reportDarwinInstall(written, load);
|
|
17
|
+
return {
|
|
18
|
+
status: "installed",
|
|
19
|
+
label: AUTOSTART_LABEL,
|
|
20
|
+
plist_path: written.plist_path,
|
|
21
|
+
loaded: load.code === 0,
|
|
22
|
+
interval_seconds: written.interval_seconds,
|
|
23
|
+
work_dir: written.work_dir,
|
|
24
|
+
work_dirs: written.work_dirs,
|
|
25
|
+
dashboard_url: written.dashboard_url,
|
|
26
|
+
...(load.code === 0
|
|
27
|
+
? {}
|
|
28
|
+
: {
|
|
29
|
+
message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
|
|
35
|
+
* the command is safe to run repeatedly.
|
|
36
|
+
*/
|
|
37
|
+
export async function uninstallDarwinAgent(options) {
|
|
38
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
39
|
+
const plistPath = plistPathFor(homeDir);
|
|
40
|
+
if (!(await fileExists(plistPath))) {
|
|
41
|
+
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
42
|
+
}
|
|
43
|
+
// Same reasoning as the install path above: an agent that was never loaded
|
|
44
|
+
// makes unload fail harmlessly, and the plist is removed either way, so the
|
|
45
|
+
// error is deliberately ignored (BLI-3238).
|
|
46
|
+
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
47
|
+
await rm(plistPath, { force: true });
|
|
48
|
+
return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Reports whether the agent is installed, loaded, and still matches the
|
|
52
|
+
* configured roots, cadence, dashboard URL, and Cockpit runtime.
|
|
53
|
+
*/
|
|
54
|
+
export async function darwinAgentStatus(options) {
|
|
55
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
56
|
+
const plistPath = plistPathFor(homeDir);
|
|
57
|
+
if (!(await fileExists(plistPath))) {
|
|
58
|
+
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
59
|
+
}
|
|
60
|
+
const settings = resolveAutostartSettings(options, "darwin");
|
|
61
|
+
const problems = await darwinAgentProblems(options, settings);
|
|
62
|
+
const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
|
|
63
|
+
if (list.code !== 0)
|
|
64
|
+
problems.unshift("agent is not loaded");
|
|
65
|
+
return {
|
|
66
|
+
status: problems.length === 0 ? "loaded" : "not_loaded",
|
|
67
|
+
label: AUTOSTART_LABEL,
|
|
68
|
+
plist_path: plistPath,
|
|
69
|
+
interval_seconds: settings.interval_seconds,
|
|
70
|
+
work_dir: settings.work_dir,
|
|
71
|
+
work_dirs: settings.work_dirs,
|
|
72
|
+
dashboard_url: settings.dashboard_url,
|
|
73
|
+
...(problems.length > 0
|
|
74
|
+
? { message: `launchd agent needs repair: ${problems.join("; ")}` }
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Unload-then-load, the interactive install's registration verbs. Unload first
|
|
80
|
+
* so a changed plist is actually picked up; a not-yet-loaded agent makes unload
|
|
81
|
+
* fail harmlessly, so the error is ignored.
|
|
82
|
+
*/
|
|
83
|
+
async function reloadLaunchAgent(exec, plistPath) {
|
|
84
|
+
await exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
85
|
+
return exec("launchctl", ["load", plistPath]);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Both branches log, for the same reason the Windows path does: a line that
|
|
89
|
+
* only fires on failure cannot answer "did background collection get installed
|
|
90
|
+
* at all today?". Until BLI-3483 the macOS failure existed only in the returned
|
|
91
|
+
* `message`, which nothing on the sync path reads — a Mac could finish
|
|
92
|
+
* `cockpit onboard` with no scheduler and nothing in sync.err.log.
|
|
93
|
+
* Metadata only: launchctl's stderr can carry the state directory, so the exit
|
|
94
|
+
* code and the redacted first line travel, never the raw text.
|
|
95
|
+
*/
|
|
96
|
+
function reportDarwinInstall(written, load) {
|
|
97
|
+
if (load.code === 0) {
|
|
98
|
+
console.error("[autostart] launchd agent loaded", JSON.stringify({
|
|
99
|
+
label: AUTOSTART_LABEL,
|
|
100
|
+
interval_seconds: written.interval_seconds,
|
|
101
|
+
watch_path_count: written.watch_path_count,
|
|
102
|
+
node_path_reason: written.node_path_reason,
|
|
103
|
+
}));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
|
|
107
|
+
reason: "autostart_load_failed",
|
|
108
|
+
label: AUTOSTART_LABEL,
|
|
109
|
+
exit_code: load.code,
|
|
110
|
+
detail: launchctlDetail(load),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* What `launchctl` actually said, or an honest account of it saying nothing.
|
|
115
|
+
*
|
|
116
|
+
* "unknown error" was the old fallback, and it is the least useful string in
|
|
117
|
+
* the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
|
|
118
|
+
* cases — the agent is already loaded, or the plist was rejected — so the
|
|
119
|
+
* message an operator saw named neither the exit code's meaning nor the
|
|
120
|
+
* silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
|
|
121
|
+
* what to do next. stdout is consulted too because launchctl is inconsistent
|
|
122
|
+
* about which stream it uses across macOS versions.
|
|
123
|
+
*/
|
|
124
|
+
function launchctlDetail(result) {
|
|
125
|
+
const spoken = result.stderr.trim() || result.stdout.trim();
|
|
126
|
+
if (spoken)
|
|
127
|
+
return redactedHealthDetail(spoken);
|
|
128
|
+
return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
|
|
129
|
+
}
|
|
130
|
+
/** `gui/<uid>` — the per-user launchd domain the LaunchAgent lives in. */
|
|
131
|
+
export function launchdUserDomain(uid = process.getuid?.() ?? 0) {
|
|
132
|
+
return `gui/${uid}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Re-registers the LaunchAgent with the modern verbs (BLI-3553).
|
|
136
|
+
*
|
|
137
|
+
* `unload`/`load` is what the interactive install path uses and it stays there;
|
|
138
|
+
* this exists for the DETACHED healer, where the difference matters:
|
|
139
|
+
* `bootstrap` reports a rejected plist with a real error, whereas `load` is
|
|
140
|
+
* documented-deprecated and exits nonzero with empty output for several
|
|
141
|
+
* unrelated states (see launchctlDetail). The healer gets one shot and has no
|
|
142
|
+
* human watching, so it uses the verb that says what went wrong.
|
|
143
|
+
*
|
|
144
|
+
* `bootout` on a service that is not loaded exits nonzero (`3: No such
|
|
145
|
+
* process`); that is the expected steady state after the job that spawned us
|
|
146
|
+
* exited, so it is not treated as a failure. Only `bootstrap` decides the
|
|
147
|
+
* outcome. If it fails, the agent is left unloaded — recoverable, because
|
|
148
|
+
* launchd bootstraps everything in ~/Library/LaunchAgents at the next login,
|
|
149
|
+
* and the plist file itself was already rewritten before this call.
|
|
150
|
+
*/
|
|
151
|
+
export async function reregisterDarwinAgent(options) {
|
|
152
|
+
const domain = launchdUserDomain(options.uid);
|
|
153
|
+
const bootout = await options
|
|
154
|
+
.exec("launchctl", ["bootout", `${domain}/${AUTOSTART_LABEL}`])
|
|
155
|
+
.catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
156
|
+
const bootstrap = await options
|
|
157
|
+
.exec("launchctl", ["bootstrap", domain, options.plistPath])
|
|
158
|
+
.catch(() => ({ code: 1, stdout: "", stderr: "" }));
|
|
159
|
+
if (bootstrap.code === 0) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
reason: "agent_rebootstrapped",
|
|
163
|
+
detail: `bootout exit ${bootout.code}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: "bootstrap_failed",
|
|
169
|
+
detail: `launchctl bootstrap exited ${bootstrap.code}: ${launchctlDetail(bootstrap)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|