@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
package/dist/autostart.js
CHANGED
|
@@ -1,65 +1,32 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
1
|
import os from "node:os";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
const WINDOWS_AUTOSTART_LAUNCHER_NAME = "autostart-sync.vbs";
|
|
24
|
-
const WINDOWS_SYNC_LOG_NAME = "sync.log";
|
|
25
|
-
/** With the window hidden, the log is the only place output goes; cap it so an
|
|
26
|
-
* always-on machine syncing every 15 minutes cannot grow it without bound. */
|
|
27
|
-
const WINDOWS_SYNC_LOG_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
28
|
-
const UTF8_BOM = "\uFEFF";
|
|
29
|
-
export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
|
|
30
|
-
/**
|
|
31
|
-
* macOS rewrites this file on every network transition (join, leave, DNS
|
|
32
|
-
* change), so watching it retries spooled uploads the moment connectivity
|
|
33
|
-
* returns instead of waiting out the StartInterval \u2014 the lid-closed-mid-upload
|
|
34
|
-
* machine hopping caf\u00E9s is where most historical upload failures came from
|
|
35
|
-
* (BLI-2604). The real file, not the /etc/resolv.conf symlink: launchd watches
|
|
36
|
-
* the path it is given, and the symlink itself never changes. Burst fires are
|
|
37
|
-
* cheap \u2014 the collection lock turns overlap into a named no-op.
|
|
38
|
-
*/
|
|
39
|
-
export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
|
|
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).
|
|
2
|
+
import { fileExists, unsupportedResult, } from "./autostart-contract.js";
|
|
3
|
+
import { darwinAgentStatus, installDarwinAgent, uninstallDarwinAgent, } from "./autostart-darwin.js";
|
|
4
|
+
import { readRegisteredDarwinRuntimePaths } from "./autostart-darwin-plist.js";
|
|
5
|
+
import { installWindowsTask, uninstallWindowsTask, windowsTaskStatus, } from "./autostart-windows.js";
|
|
6
|
+
import { readRegisteredWindowsRuntimePaths } from "./autostart-windows-scripts.js";
|
|
7
|
+
/**
|
|
8
|
+
* Background collection's front door: install, uninstall, and status for the
|
|
9
|
+
* OS scheduler, whichever OS this is.
|
|
10
|
+
*
|
|
11
|
+
* Everything here picks a host family and delegates. The two families each own
|
|
12
|
+
* a render → write → register → validate stack of their own:
|
|
13
|
+
*
|
|
14
|
+
* - macOS: autostart-darwin.ts (launchctl) over autostart-darwin-plist.ts.
|
|
15
|
+
* - Windows: autostart-windows.ts (schtasks) over autostart-windows-scripts.ts
|
|
16
|
+
* for what gets written and autostart-windows-task-xml.ts for what Windows
|
|
17
|
+
* says it stored.
|
|
18
|
+
*
|
|
19
|
+
* Shared vocabulary — labels, options, results, settings resolution — lives in
|
|
20
|
+
* autostart-contract.ts and is re-exported here because this module is the
|
|
21
|
+
* import site the rest of the collector already knows (BLI-3571).
|
|
44
22
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
function unsupportedResult(homeDir) {
|
|
50
|
-
return {
|
|
51
|
-
status: "unsupported",
|
|
52
|
-
label: AUTOSTART_LABEL,
|
|
53
|
-
plist_path: plistPathFor(homeDir ?? os.homedir()),
|
|
54
|
-
message: UNSUPPORTED_MESSAGE,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
23
|
+
export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, } from "./autostart-contract.js";
|
|
24
|
+
export { DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
|
|
25
|
+
export { launchdUserDomain, reregisterDarwinAgent, } from "./autostart-darwin.js";
|
|
57
26
|
/**
|
|
58
|
-
* Installs (or refreshes) the
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* log paths (launchd does not expand `$HOME`). The unload before load makes the
|
|
62
|
-
* install idempotent — re-running picks up a changed repo/url/interval.
|
|
27
|
+
* Installs (or refreshes) the scheduled `cockpit sync` this machine's OS
|
|
28
|
+
* supports. Idempotent on both host families: re-running picks up changed
|
|
29
|
+
* roots, dashboard URL, or cadence.
|
|
63
30
|
*/
|
|
64
31
|
export async function installAutostartAgent(options) {
|
|
65
32
|
const platform = options.platform ?? process.platform;
|
|
@@ -67,140 +34,11 @@ export async function installAutostartAgent(options) {
|
|
|
67
34
|
return installWindowsTask(options);
|
|
68
35
|
if (platform !== "darwin")
|
|
69
36
|
return unsupportedResult(options.homeDir);
|
|
70
|
-
|
|
71
|
-
const { plist_path: plistPath, work_dir: workDir, work_dirs: resolvedWorkDirs, dashboard_url: dashboardUrl, interval_seconds: intervalSeconds, watch_path_count: watchPathCount, } = written;
|
|
72
|
-
// Unload first so a changed plist is actually picked up; a not-yet-loaded
|
|
73
|
-
// agent makes unload fail harmlessly, so the error is ignored.
|
|
74
|
-
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
75
|
-
const load = await options.exec("launchctl", ["load", plistPath]);
|
|
76
|
-
const loaded = load.code === 0;
|
|
77
|
-
// Both branches log, for the same reason the Windows path does: a line that
|
|
78
|
-
// only fires on failure cannot answer "did background collection get
|
|
79
|
-
// installed at all today?". Until BLI-3483 the macOS failure existed only in
|
|
80
|
-
// the returned `message`, which nothing on the sync path reads — a Mac could
|
|
81
|
-
// finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
|
|
82
|
-
// Metadata only: launchctl's stderr can carry the state directory, so the
|
|
83
|
-
// exit code and the redacted first line travel, never the raw text.
|
|
84
|
-
if (loaded) {
|
|
85
|
-
console.error("[autostart] launchd agent loaded", JSON.stringify({
|
|
86
|
-
label: AUTOSTART_LABEL,
|
|
87
|
-
interval_seconds: intervalSeconds,
|
|
88
|
-
watch_path_count: watchPathCount,
|
|
89
|
-
node_path_reason: written.node_path_reason,
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
|
|
94
|
-
reason: "autostart_load_failed",
|
|
95
|
-
label: AUTOSTART_LABEL,
|
|
96
|
-
exit_code: load.code,
|
|
97
|
-
detail: launchctlDetail(load),
|
|
98
|
-
}));
|
|
99
|
-
}
|
|
100
|
-
return {
|
|
101
|
-
status: "installed",
|
|
102
|
-
label: AUTOSTART_LABEL,
|
|
103
|
-
plist_path: plistPath,
|
|
104
|
-
loaded,
|
|
105
|
-
interval_seconds: intervalSeconds,
|
|
106
|
-
work_dir: workDir,
|
|
107
|
-
work_dirs: resolvedWorkDirs,
|
|
108
|
-
dashboard_url: dashboardUrl,
|
|
109
|
-
...(loaded
|
|
110
|
-
? {}
|
|
111
|
-
: {
|
|
112
|
-
message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
|
|
113
|
-
}),
|
|
114
|
-
};
|
|
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
|
-
};
|
|
37
|
+
return installDarwinAgent(options);
|
|
172
38
|
}
|
|
173
39
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* What `launchctl` actually said, or an honest account of it saying nothing.
|
|
186
|
-
*
|
|
187
|
-
* "unknown error" was the old fallback, and it is the least useful string in
|
|
188
|
-
* the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
|
|
189
|
-
* cases — the agent is already loaded, or the plist was rejected — so the
|
|
190
|
-
* message an operator saw named neither the exit code's meaning nor the
|
|
191
|
-
* silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
|
|
192
|
-
* what to do next. stdout is consulted too because launchctl is inconsistent
|
|
193
|
-
* about which stream it uses across macOS versions.
|
|
194
|
-
*/
|
|
195
|
-
function launchctlDetail(result) {
|
|
196
|
-
const spoken = result.stderr.trim() || result.stdout.trim();
|
|
197
|
-
if (spoken)
|
|
198
|
-
return redactedHealthDetail(spoken);
|
|
199
|
-
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}\``;
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
|
|
203
|
-
* the command is safe to run repeatedly.
|
|
40
|
+
* Removes the scheduled sync. Reports `absent` when there was nothing to
|
|
41
|
+
* remove, so the command is safe to run repeatedly.
|
|
204
42
|
*/
|
|
205
43
|
export async function uninstallAutostartAgent(options) {
|
|
206
44
|
const platform = options.platform ?? process.platform;
|
|
@@ -208,21 +46,11 @@ export async function uninstallAutostartAgent(options) {
|
|
|
208
46
|
return uninstallWindowsTask(options);
|
|
209
47
|
if (platform !== "darwin")
|
|
210
48
|
return unsupportedResult(options.homeDir);
|
|
211
|
-
|
|
212
|
-
const plistPath = plistPathFor(homeDir);
|
|
213
|
-
if (!(await fileExists(plistPath))) {
|
|
214
|
-
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
215
|
-
}
|
|
216
|
-
// Same reasoning as the install path above: an agent that was never loaded
|
|
217
|
-
// makes unload fail harmlessly, and the plist is removed either way, so the
|
|
218
|
-
// error is deliberately ignored (BLI-3238).
|
|
219
|
-
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
220
|
-
await rm(plistPath, { force: true });
|
|
221
|
-
return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
49
|
+
return uninstallDarwinAgent(options);
|
|
222
50
|
}
|
|
223
51
|
/**
|
|
224
|
-
* Reports whether the
|
|
225
|
-
* configured roots, cadence, dashboard URL, and Cockpit runtime.
|
|
52
|
+
* Reports whether the scheduled sync is installed, registered, and still
|
|
53
|
+
* matches the configured roots, cadence, dashboard URL, and Cockpit runtime.
|
|
226
54
|
*/
|
|
227
55
|
export async function autostartStatus(options) {
|
|
228
56
|
const platform = options.platform ?? process.platform;
|
|
@@ -230,883 +58,7 @@ export async function autostartStatus(options) {
|
|
|
230
58
|
return windowsTaskStatus(options);
|
|
231
59
|
if (platform !== "darwin")
|
|
232
60
|
return unsupportedResult(options.homeDir);
|
|
233
|
-
|
|
234
|
-
const plistPath = plistPathFor(homeDir);
|
|
235
|
-
if (!(await fileExists(plistPath))) {
|
|
236
|
-
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
237
|
-
}
|
|
238
|
-
const workDirs = normalizeWorkDirs(options.repoRoots);
|
|
239
|
-
const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
240
|
-
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
241
|
-
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
242
|
-
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
243
|
-
const nodeExecutable = (await schedulerNodeExecutable(options, "darwin")).path;
|
|
244
|
-
const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
245
|
-
const plist = await readFile(plistPath, "utf8").catch(() => "");
|
|
246
|
-
const registrationProblems = darwinAgentRegistrationProblems(plist, {
|
|
247
|
-
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
248
|
-
workDirs: resolvedWorkDirs,
|
|
249
|
-
dashboardUrl,
|
|
250
|
-
intervalSeconds,
|
|
251
|
-
nodeExecutable,
|
|
252
|
-
cliEntryPoint,
|
|
253
|
-
});
|
|
254
|
-
const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
|
|
255
|
-
if (list.code !== 0)
|
|
256
|
-
registrationProblems.unshift("agent is not loaded");
|
|
257
|
-
return {
|
|
258
|
-
status: registrationProblems.length === 0 ? "loaded" : "not_loaded",
|
|
259
|
-
label: AUTOSTART_LABEL,
|
|
260
|
-
plist_path: plistPath,
|
|
261
|
-
interval_seconds: intervalSeconds,
|
|
262
|
-
work_dir: workDir,
|
|
263
|
-
work_dirs: resolvedWorkDirs,
|
|
264
|
-
dashboard_url: dashboardUrl,
|
|
265
|
-
...(registrationProblems.length > 0
|
|
266
|
-
? {
|
|
267
|
-
message: `launchd agent needs repair: ${registrationProblems.join("; ")}`,
|
|
268
|
-
}
|
|
269
|
-
: {}),
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
async function installWindowsTask(options) {
|
|
273
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
274
|
-
const workDirs = normalizeWindowsWorkDirs(options.repoRoots);
|
|
275
|
-
const workDir = path.win32.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
|
|
276
|
-
const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
|
|
277
|
-
const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
|
|
278
|
-
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
279
|
-
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
280
|
-
const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
281
|
-
const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
282
|
-
const launcherPath = windowsAutostartLauncherPath(homeDir);
|
|
283
|
-
const node = await schedulerNodeExecutable(options, "win32");
|
|
284
|
-
const nodeExecutable = node.path;
|
|
285
|
-
const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
286
|
-
await mkdir(path.dirname(scriptPath), { recursive: true });
|
|
287
|
-
await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
|
|
288
|
-
discoveryArgs: await savedDiscoveryLimitArgs(homeDir),
|
|
289
|
-
workDirs: resolvedWorkDirs,
|
|
290
|
-
dashboardUrl,
|
|
291
|
-
nodeExecutable,
|
|
292
|
-
cliEntryPoint,
|
|
293
|
-
syncLogPath: windowsSyncLogPath(homeDir),
|
|
294
|
-
})}`, "utf8");
|
|
295
|
-
// No BOM on the launcher: wscript.exe reads .vbs files as ANSI and treats a
|
|
296
|
-
// UTF-8 BOM as an invalid character. The rendered content is ASCII-safe for
|
|
297
|
-
// every path this fleet has.
|
|
298
|
-
await writeFile(launcherPath, renderWindowsLauncherScript({
|
|
299
|
-
powershellPath: windowsPowerShellPath(),
|
|
300
|
-
scriptPath,
|
|
301
|
-
}), "utf8");
|
|
302
|
-
await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
|
|
303
|
-
taskName: WINDOWS_AUTOSTART_TASK_NAME,
|
|
304
|
-
launcherPath,
|
|
305
|
-
intervalMinutes,
|
|
306
|
-
})}`, "utf8");
|
|
307
|
-
const created = await options.exec("powershell.exe", [
|
|
308
|
-
"-NoProfile",
|
|
309
|
-
"-NonInteractive",
|
|
310
|
-
"-ExecutionPolicy",
|
|
311
|
-
"Bypass",
|
|
312
|
-
"-File",
|
|
313
|
-
registrationPath,
|
|
314
|
-
]);
|
|
315
|
-
// BLI-2996: before this, a nonzero exit here (schtasks rejected the
|
|
316
|
-
// registration script, or Set-ScheduledTask threw) skipped straight to the
|
|
317
|
-
// return with `verified: null` and never logged anything — the doctor row
|
|
318
|
-
// said "needs repair" every run with no way to tell "the rewrite itself
|
|
319
|
-
// never ran" apart from "it ran and Windows still disagrees" (BLI-2996,
|
|
320
|
-
// Brandon's machine: same silent non-convergence under two different
|
|
321
|
-
// validator messages across CLI versions). Metadata only: exit code and
|
|
322
|
-
// whether the process produced any output, never the stderr/stdout text
|
|
323
|
-
// (it can carry the state directory or a workspace path).
|
|
324
|
-
if (created.code !== 0) {
|
|
325
|
-
console.error("[autostart] windows task repair rewrite failed", JSON.stringify({
|
|
326
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
327
|
-
exit_code: created.code,
|
|
328
|
-
had_output: Boolean(created.stderr.trim() || created.stdout.trim()),
|
|
329
|
-
}));
|
|
330
|
-
}
|
|
331
|
-
const verified = created.code === 0
|
|
332
|
-
? await windowsTaskStatus({
|
|
333
|
-
...options,
|
|
334
|
-
repoRoot: workDir,
|
|
335
|
-
repoRoots: resolvedWorkDirs,
|
|
336
|
-
dashboardUrl,
|
|
337
|
-
intervalSeconds: intervalMinutes * 60,
|
|
338
|
-
nodeExecutable,
|
|
339
|
-
cliEntryPoint,
|
|
340
|
-
})
|
|
341
|
-
: null;
|
|
342
|
-
const loaded = verified?.status === "loaded";
|
|
343
|
-
// windowsTaskStatus() above already logs the needs-repair case (with the
|
|
344
|
-
// problem list) when the rewrite ran but the read-back still disagrees; log
|
|
345
|
-
// the converged branch here too, tagged as a repair outcome and by name, so
|
|
346
|
-
// "did the rewrite actually fix it this run" never depends on inferring it
|
|
347
|
-
// from a routine status log emitted for an unrelated reason.
|
|
348
|
-
if (created.code === 0 && loaded) {
|
|
349
|
-
console.error("[autostart] windows task repair converged", JSON.stringify({ task_name: WINDOWS_AUTOSTART_TASK_NAME }));
|
|
350
|
-
}
|
|
351
|
-
return {
|
|
352
|
-
status: "installed",
|
|
353
|
-
label: AUTOSTART_LABEL,
|
|
354
|
-
plist_path: scriptPath,
|
|
355
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
356
|
-
registration_path: registrationPath,
|
|
357
|
-
loaded,
|
|
358
|
-
interval_seconds: intervalMinutes * 60,
|
|
359
|
-
work_dir: workDir,
|
|
360
|
-
work_dirs: resolvedWorkDirs,
|
|
361
|
-
dashboard_url: dashboardUrl,
|
|
362
|
-
...(loaded
|
|
363
|
-
? {}
|
|
364
|
-
: {
|
|
365
|
-
message: created.code === 0
|
|
366
|
-
? verified?.message ??
|
|
367
|
-
"Windows task registration could not be verified"
|
|
368
|
-
: `PowerShell task registration exited ${created.code}: ${created.stderr.trim() ||
|
|
369
|
-
created.stdout.trim() ||
|
|
370
|
-
"unknown error"}`,
|
|
371
|
-
}),
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
async function uninstallWindowsTask(options) {
|
|
375
|
-
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
376
|
-
const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
|
|
377
|
-
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
378
|
-
const current = await windowsTaskStatus(options);
|
|
379
|
-
if (current.status === "absent") {
|
|
380
|
-
await rm(scriptPath, { force: true });
|
|
381
|
-
await rm(registrationPath, { force: true });
|
|
382
|
-
await rm(launcherPath, { force: true });
|
|
383
|
-
return {
|
|
384
|
-
...current,
|
|
385
|
-
plist_path: scriptPath,
|
|
386
|
-
registration_path: registrationPath,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
const removed = await options.exec("schtasks.exe", [
|
|
390
|
-
"/Delete",
|
|
391
|
-
"/TN",
|
|
392
|
-
WINDOWS_AUTOSTART_TASK_NAME,
|
|
393
|
-
"/F",
|
|
394
|
-
]);
|
|
395
|
-
if (removed.code === 0) {
|
|
396
|
-
await rm(scriptPath, { force: true });
|
|
397
|
-
await rm(registrationPath, { force: true });
|
|
398
|
-
await rm(launcherPath, { force: true });
|
|
399
|
-
}
|
|
400
|
-
return {
|
|
401
|
-
status: removed.code === 0 ? "uninstalled" : "not_loaded",
|
|
402
|
-
label: AUTOSTART_LABEL,
|
|
403
|
-
plist_path: scriptPath,
|
|
404
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
405
|
-
registration_path: registrationPath,
|
|
406
|
-
...(removed.code === 0
|
|
407
|
-
? {}
|
|
408
|
-
: {
|
|
409
|
-
message: `schtasks /Delete exited ${removed.code}: ${removed.stderr.trim() || removed.stdout.trim() || "unknown error"}`,
|
|
410
|
-
}),
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
async function windowsTaskStatus(options) {
|
|
414
|
-
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
415
|
-
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
416
|
-
const query = await options.exec("schtasks.exe", [
|
|
417
|
-
"/Query",
|
|
418
|
-
"/TN",
|
|
419
|
-
WINDOWS_AUTOSTART_TASK_NAME,
|
|
420
|
-
"/XML",
|
|
421
|
-
]);
|
|
422
|
-
if (query.code !== 0) {
|
|
423
|
-
const queryMessage = `${query.stderr}\n${query.stdout}`;
|
|
424
|
-
const taskIsKnownAbsent = query.code === 1 &&
|
|
425
|
-
/cannot find|not found|does not exist/iu.test(queryMessage);
|
|
426
|
-
// Metadata only: schtasks stderr can carry the state directory, so the exit
|
|
427
|
-
// code and the classification travel, never the text.
|
|
428
|
-
console.error("[autostart] windows task query failed", JSON.stringify({
|
|
429
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
430
|
-
exit_code: query.code,
|
|
431
|
-
status: taskIsKnownAbsent ? "absent" : "not_loaded",
|
|
432
|
-
}));
|
|
433
|
-
return {
|
|
434
|
-
status: taskIsKnownAbsent ? "absent" : "not_loaded",
|
|
435
|
-
label: AUTOSTART_LABEL,
|
|
436
|
-
plist_path: scriptPath,
|
|
437
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
438
|
-
message: query.stderr.trim() || query.stdout.trim() || undefined,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const disabled = /<Enabled>\s*false\s*<\/Enabled>/iu.test(query.stdout);
|
|
442
|
-
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
443
|
-
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
444
|
-
const registrationProblems = windowsTaskRegistrationProblems(query.stdout, {
|
|
445
|
-
launcherPath,
|
|
446
|
-
intervalMinutes,
|
|
447
|
-
wscriptPath: windowsWScriptPath(),
|
|
448
|
-
});
|
|
449
|
-
if (disabled)
|
|
450
|
-
registrationProblems.unshift("task is disabled");
|
|
451
|
-
const scriptExists = await fileExists(scriptPath);
|
|
452
|
-
if (!scriptExists) {
|
|
453
|
-
registrationProblems.push("sync script is missing");
|
|
454
|
-
}
|
|
455
|
-
else if (options.repoRoots && options.repoRoots.length > 0) {
|
|
456
|
-
const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
|
|
457
|
-
discoveryArgs: await savedDiscoveryLimitArgs(options.homeDir),
|
|
458
|
-
workDirs: normalizeWindowsWorkDirs(options.repoRoots),
|
|
459
|
-
dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
|
|
460
|
-
nodeExecutable: (await schedulerNodeExecutable(options, "win32")).path,
|
|
461
|
-
cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
|
|
462
|
-
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
463
|
-
})}`;
|
|
464
|
-
const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
|
|
465
|
-
const scriptDifference = renderedFileDifference(currentScript, expectedScript);
|
|
466
|
-
if (scriptDifference) {
|
|
467
|
-
registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
// Same shape as the sync-script checks: existence always, content only when
|
|
471
|
-
// the caller supplied roots (a bare `autostart status` stays permissive by
|
|
472
|
-
// design — see BLI-2362). The launcher's content depends on nothing but the
|
|
473
|
-
// PowerShell path and the sync-script path, but gating keeps the two
|
|
474
|
-
// commands' disagreement surface identical to the script check above.
|
|
475
|
-
const launcherExists = await fileExists(launcherPath);
|
|
476
|
-
if (!launcherExists) {
|
|
477
|
-
registrationProblems.push("sync launcher is missing");
|
|
478
|
-
}
|
|
479
|
-
else if (options.repoRoots && options.repoRoots.length > 0) {
|
|
480
|
-
const expectedLauncher = renderWindowsLauncherScript({
|
|
481
|
-
powershellPath: windowsPowerShellPath(),
|
|
482
|
-
scriptPath,
|
|
483
|
-
});
|
|
484
|
-
const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
|
|
485
|
-
const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
|
|
486
|
-
if (launcherDifference) {
|
|
487
|
-
registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
// Both branches log. A line that only fires on failure cannot answer "did
|
|
491
|
-
// background collection validate at all today?", which is the question that
|
|
492
|
-
// would have caught a validator rejecting every healthy Windows task.
|
|
493
|
-
// Problem labels carry no paths, so they are safe to emit verbatim.
|
|
494
|
-
if (registrationProblems.length > 0) {
|
|
495
|
-
console.error("[autostart] windows task needs repair", JSON.stringify({
|
|
496
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
497
|
-
problem_count: registrationProblems.length,
|
|
498
|
-
problems: registrationProblems,
|
|
499
|
-
}));
|
|
500
|
-
}
|
|
501
|
-
else {
|
|
502
|
-
console.error("[autostart] windows task validated", JSON.stringify({
|
|
503
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
504
|
-
interval_minutes: intervalMinutes,
|
|
505
|
-
}));
|
|
506
|
-
}
|
|
507
|
-
return {
|
|
508
|
-
status: registrationProblems.length > 0 ? "not_loaded" : "loaded",
|
|
509
|
-
label: AUTOSTART_LABEL,
|
|
510
|
-
plist_path: scriptPath,
|
|
511
|
-
task_name: WINDOWS_AUTOSTART_TASK_NAME,
|
|
512
|
-
...(registrationProblems.length > 0
|
|
513
|
-
? {
|
|
514
|
-
message: `Windows task needs repair: ${registrationProblems.join("; ")}`,
|
|
515
|
-
}
|
|
516
|
-
: {}),
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
function renderedFileDifference(current, expected) {
|
|
520
|
-
const expectedLines = normalizedScriptLines(expected);
|
|
521
|
-
if (current === null) {
|
|
522
|
-
return {
|
|
523
|
-
reason: "file_unreadable",
|
|
524
|
-
first_differing_line: null,
|
|
525
|
-
current_line_count: null,
|
|
526
|
-
expected_line_count: expectedLines.length,
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
|
|
530
|
-
return {
|
|
531
|
-
reason: "byte_order_mark",
|
|
532
|
-
first_differing_line: 0,
|
|
533
|
-
current_line_count: normalizedScriptLines(current).length,
|
|
534
|
-
expected_line_count: expectedLines.length,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
const currentLines = normalizedScriptLines(current);
|
|
538
|
-
const shared = Math.min(currentLines.length, expectedLines.length);
|
|
539
|
-
for (let index = 0; index < shared; index += 1) {
|
|
540
|
-
if (currentLines[index] !== expectedLines[index]) {
|
|
541
|
-
return {
|
|
542
|
-
reason: "line_content",
|
|
543
|
-
first_differing_line: index,
|
|
544
|
-
current_line_count: currentLines.length,
|
|
545
|
-
expected_line_count: expectedLines.length,
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
if (currentLines.length !== expectedLines.length) {
|
|
550
|
-
return {
|
|
551
|
-
reason: "line_count",
|
|
552
|
-
first_differing_line: shared,
|
|
553
|
-
current_line_count: currentLines.length,
|
|
554
|
-
expected_line_count: expectedLines.length,
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
return null;
|
|
558
|
-
}
|
|
559
|
-
/** Safe to print: a reason label, a line index, two counts. No content. */
|
|
560
|
-
function describeDifference(difference) {
|
|
561
|
-
if (difference.reason === "file_unreadable")
|
|
562
|
-
return "file could not be read";
|
|
563
|
-
if (difference.reason === "byte_order_mark")
|
|
564
|
-
return "byte-order mark differs";
|
|
565
|
-
return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
|
|
566
|
-
}
|
|
567
|
-
function normalizedScriptLines(value) {
|
|
568
|
-
const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
|
|
569
|
-
return withoutBom
|
|
570
|
-
.replace(/\r\n/gu, "\n")
|
|
571
|
-
.replace(/\s+$/u, "")
|
|
572
|
-
.split("\n");
|
|
573
|
-
}
|
|
574
|
-
function hasByteOrderMark(value) {
|
|
575
|
-
return value.startsWith(UTF8_BOM);
|
|
576
|
-
}
|
|
577
|
-
function windowsAutostartScriptPath(homeDir) {
|
|
578
|
-
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
579
|
-
}
|
|
580
|
-
function windowsAutostartRegistrationScriptPath(homeDir) {
|
|
581
|
-
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
582
|
-
}
|
|
583
|
-
function windowsAutostartLauncherPath(homeDir) {
|
|
584
|
-
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_LAUNCHER_NAME);
|
|
585
|
-
}
|
|
586
|
-
function windowsSyncLogPath(homeDir) {
|
|
587
|
-
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_SYNC_LOG_NAME);
|
|
588
|
-
}
|
|
589
|
-
function renderWindowsRegistrationScript(options) {
|
|
590
|
-
return [
|
|
591
|
-
"$ErrorActionPreference = 'Stop'",
|
|
592
|
-
`$taskName = ${powershellLiteral(options.taskName)}`,
|
|
593
|
-
`$launcherPath = ${powershellLiteral(options.launcherPath)}`,
|
|
594
|
-
// wscript.exe is the windowless script host: the task runs the .vbs
|
|
595
|
-
// launcher, which starts PowerShell with its window hidden, so the
|
|
596
|
-
// 15-minute sync never flashes a terminal (BLI-2677). //B is batch mode —
|
|
597
|
-
// a script error becomes a nonzero exit instead of a modal dialog that
|
|
598
|
-
// would hang the scheduled run.
|
|
599
|
-
"$wscriptPath = Join-Path (Join-Path $env:SystemRoot 'System32') 'wscript.exe'",
|
|
600
|
-
// Windows PowerShell 5.1 passes a native argument's embedded quotes to
|
|
601
|
-
// CommandLineToArgvW unescaped, so a /TR value with bare quotes falls out
|
|
602
|
-
// of the quoted region at the first space inside a quoted path:
|
|
603
|
-
// `C:\Users\Brandon Chiem\...` reached schtasks as a stray `Chiem\...`
|
|
604
|
-
// argument and /Create exited 0x80004005 on every CLI version (BLI-2598).
|
|
605
|
-
// Escaping the embedded quotes as \" keeps the whole /TR value one
|
|
606
|
-
// argument; Task Scheduler still normalizes what it stores (BLI-2541),
|
|
607
|
-
// which the read-back validator already compares path-wise.
|
|
608
|
-
"$actionArgs = '//B \\\"' + $launcherPath + '\\\"'",
|
|
609
|
-
"$taskCommand = '\\\"' + $wscriptPath + '\\\" ' + $actionArgs",
|
|
610
|
-
`& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
|
|
611
|
-
"if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
|
|
612
|
-
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
|
|
613
|
-
"Set-ScheduledTask -TaskName $taskName -Settings $settings | Out-Null",
|
|
614
|
-
"",
|
|
615
|
-
].join("\r\n");
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* The .vbs launcher the scheduled task actually runs. WshShell.Run with window
|
|
619
|
-
* style 0 starts PowerShell with its console hidden — no flash, no focus
|
|
620
|
-
* steal — and waiting on the child propagates the sync exit code back to Task
|
|
621
|
-
* Scheduler as LastTaskResult. VBScript escapes a quote inside a string by
|
|
622
|
-
* doubling it; Windows paths cannot contain quotes, so the doubled quotes here
|
|
623
|
-
* are only the fixed delimiters around the two paths.
|
|
624
|
-
*/
|
|
625
|
-
function renderWindowsLauncherScript(options) {
|
|
626
|
-
const quotedPowershell = `""${options.powershellPath}""`;
|
|
627
|
-
const quotedScript = `""${options.scriptPath}""`;
|
|
628
|
-
return [
|
|
629
|
-
"' BLI Cockpit sync launcher (BLI-2677). wscript.exe never allocates a",
|
|
630
|
-
"' console and starts PowerShell hidden, so the 15-minute background sync",
|
|
631
|
-
"' does not flash a terminal over the operator. Sync output lands in",
|
|
632
|
-
"' sync.log next to this file instead of a visible window.",
|
|
633
|
-
"Option Explicit",
|
|
634
|
-
"Dim windowsShell, syncExitCode",
|
|
635
|
-
'Set windowsShell = CreateObject("WScript.Shell")',
|
|
636
|
-
`syncExitCode = windowsShell.Run("${quotedPowershell} -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${quotedScript}", 0, True)`,
|
|
637
|
-
"WScript.Quit syncExitCode",
|
|
638
|
-
"",
|
|
639
|
-
].join("\r\n");
|
|
640
|
-
}
|
|
641
|
-
function renderWindowsSyncScript(options) {
|
|
642
|
-
const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
643
|
-
? ""
|
|
644
|
-
: ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
|
|
645
|
-
const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
646
|
-
const commands = options.workDirs.flatMap((root) => [
|
|
647
|
-
"try {",
|
|
648
|
-
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
|
|
649
|
-
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
650
|
-
"} catch {",
|
|
651
|
-
" Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
|
|
652
|
-
" $exitCode = 1",
|
|
653
|
-
"}",
|
|
654
|
-
]);
|
|
655
|
-
return [
|
|
656
|
-
// 'Continue', not 'Stop': the collector logs to stderr on success as well
|
|
657
|
-
// as failure (that is the repo's logging contract), and Windows PowerShell
|
|
658
|
-
// 5.1 under 'Stop' turns the first redirected stderr line of a native
|
|
659
|
-
// command into a terminating NativeCommandError — 'Stop' would kill every
|
|
660
|
-
// healthy run at its first log line. Failures are tracked by exit code.
|
|
661
|
-
"$ErrorActionPreference = 'Continue'",
|
|
662
|
-
"$exitCode = 0",
|
|
663
|
-
`$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
|
|
664
|
-
`$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
|
|
665
|
-
// The task window is hidden (BLI-2677), so this log is the only place the
|
|
666
|
-
// sync's output exists; sync.err.log stays macOS-only because launchd does
|
|
667
|
-
// stream separation for free and PowerShell 5.1 does not.
|
|
668
|
-
`$syncLogPath = ${powershellLiteral(options.syncLogPath)}`,
|
|
669
|
-
`$syncLogFile = Get-Item -LiteralPath $syncLogPath -ErrorAction SilentlyContinue`,
|
|
670
|
-
`if ($syncLogFile -and $syncLogFile.Length -gt ${WINDOWS_SYNC_LOG_LIMIT_BYTES}) { Set-Content -LiteralPath $syncLogPath -Value '' -Encoding UTF8 }`,
|
|
671
|
-
...commands,
|
|
672
|
-
"exit $exitCode",
|
|
673
|
-
"",
|
|
674
|
-
].join("\r\n");
|
|
675
|
-
}
|
|
676
|
-
function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
677
|
-
const decodedXml = decodeXmlEntities(taskXml);
|
|
678
|
-
const expectedInterval = windowsTaskInterval(expected.intervalMinutes);
|
|
679
|
-
const principalsBody = decodedXml
|
|
680
|
-
.match(/<Principals(?:\s[^>]*)?>([\s\S]*?)<\/Principals>/iu)?.[1] ??
|
|
681
|
-
"";
|
|
682
|
-
const principalBlocks = [
|
|
683
|
-
...principalsBody.matchAll(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu),
|
|
684
|
-
].map((match) => match[0]);
|
|
685
|
-
const principalRemainder = principalsBody
|
|
686
|
-
.replace(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu, "")
|
|
687
|
-
.trim();
|
|
688
|
-
const exactPrincipalBlock = principalBlocks.length === 1 && principalRemainder === ""
|
|
689
|
-
? (principalBlocks[0] ?? "")
|
|
690
|
-
: "";
|
|
691
|
-
const principalId = exactPrincipalBlock
|
|
692
|
-
.match(/<Principal\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
|
|
693
|
-
null;
|
|
694
|
-
const triggersBody = decodedXml.match(/<Triggers(?:\s[^>]*)?>([\s\S]*?)<\/Triggers>/iu)?.[1] ??
|
|
695
|
-
"";
|
|
696
|
-
const triggerBlocks = [
|
|
697
|
-
...triggersBody.matchAll(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu),
|
|
698
|
-
].map((match) => match[0]);
|
|
699
|
-
const triggerRemainder = triggersBody
|
|
700
|
-
.replace(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu, "")
|
|
701
|
-
.trim();
|
|
702
|
-
const exactTriggerBlock = triggerBlocks.length === 1 && triggerRemainder === ""
|
|
703
|
-
? (triggerBlocks[0] ?? "")
|
|
704
|
-
: "";
|
|
705
|
-
const repetitionBlocks = exactTriggerBlock
|
|
706
|
-
? [
|
|
707
|
-
...exactTriggerBlock.matchAll(/<Repetition(?:\s[^>]*)?>[\s\S]*?<\/Repetition>/giu),
|
|
708
|
-
].map((match) => match[0])
|
|
709
|
-
: [];
|
|
710
|
-
const repetition = repetitionBlocks.length === 1 ? repetitionBlocks[0] ?? "" : "";
|
|
711
|
-
const actionsBody = decodedXml.match(/<Actions(?:\s[^>]*)?>([\s\S]*?)<\/Actions>/iu)?.[1] ?? "";
|
|
712
|
-
const actionContext = decodedXml
|
|
713
|
-
.match(/<Actions\s[^>]*\bContext\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
|
|
714
|
-
null;
|
|
715
|
-
const execBlocks = [
|
|
716
|
-
...actionsBody.matchAll(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu),
|
|
717
|
-
].map((match) => match[0]);
|
|
718
|
-
const actionRemainder = actionsBody
|
|
719
|
-
.replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
|
|
720
|
-
.trim();
|
|
721
|
-
const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
|
|
722
|
-
// Strip a wrapping quote pair defensively: every real capture we have shows
|
|
723
|
-
// Task Scheduler storing <Command> unquoted, but the writer builds the /TR
|
|
724
|
-
// executable segment with the same \"-escaped quoting it uses for the
|
|
725
|
-
// launcher argument (BLI-2598), and there is no verified capture proving
|
|
726
|
-
// schtasks' own TR-splitting heuristic always discards those quotes rather
|
|
727
|
-
// than leaving them as literal text (BLI-2996 canary-gated gap). A stray
|
|
728
|
-
// pair of quotes should never be the reason a correctly-registered task
|
|
729
|
-
// reads as broken.
|
|
730
|
-
const command = stripSurroundingQuotes(exactExecBlock
|
|
731
|
-
?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
|
|
732
|
-
?.trim() ?? "");
|
|
733
|
-
const actionArguments = exactExecBlock
|
|
734
|
-
.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
|
|
735
|
-
?.trim() ?? "";
|
|
736
|
-
const expectedArgumentFlags = "//B";
|
|
737
|
-
const requirements = [
|
|
738
|
-
[
|
|
739
|
-
/<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
|
|
740
|
-
"task is blocked on battery",
|
|
741
|
-
],
|
|
742
|
-
[
|
|
743
|
-
/<StopIfGoingOnBatteries>\s*false\s*<\/StopIfGoingOnBatteries>/iu,
|
|
744
|
-
"task stops on battery",
|
|
745
|
-
],
|
|
746
|
-
[
|
|
747
|
-
/<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/iu,
|
|
748
|
-
"overlap policy is not IgnoreNew",
|
|
749
|
-
],
|
|
750
|
-
[
|
|
751
|
-
/<StartWhenAvailable>\s*true\s*<\/StartWhenAvailable>/iu,
|
|
752
|
-
"missed-run recovery is disabled",
|
|
753
|
-
],
|
|
754
|
-
];
|
|
755
|
-
const problems = requirements
|
|
756
|
-
.filter(([pattern]) => !pattern.test(decodedXml))
|
|
757
|
-
.map(([, message]) => message);
|
|
758
|
-
if (!exactPrincipalBlock) {
|
|
759
|
-
problems.push("task must contain exactly one principal");
|
|
760
|
-
}
|
|
761
|
-
else {
|
|
762
|
-
if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(exactPrincipalBlock)) {
|
|
763
|
-
problems.push("logon type is not InteractiveToken");
|
|
764
|
-
}
|
|
765
|
-
// Windows omits <RunLevel> entirely when a task runs at the default
|
|
766
|
-
// LeastPrivilege, so `schtasks /Create ... /RL LIMITED` stores a principal
|
|
767
|
-
// block with no RunLevel element at all. Demanding the element asks Windows
|
|
768
|
-
// to state a default it never states, which is why this check failed on
|
|
769
|
-
// every Windows machine in the fleet and passed on none (0 of 4
|
|
770
|
-
// autostart-alive events ok, 2026-08-12). Absent means LeastPrivilege; only
|
|
771
|
-
// an explicit elevated value is a real problem.
|
|
772
|
-
const runLevel = exactPrincipalBlock
|
|
773
|
-
.match(/<RunLevel>\s*([^<]*?)\s*<\/RunLevel>/iu)?.[1]
|
|
774
|
-
?.trim() || "LeastPrivilege";
|
|
775
|
-
if (!/^LeastPrivilege$/iu.test(runLevel)) {
|
|
776
|
-
problems.push(`task does not run with limited privileges (RunLevel=${runLevel})`);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
if (actionContext && actionContext !== principalId) {
|
|
780
|
-
problems.push("task action context does not match its principal");
|
|
781
|
-
}
|
|
782
|
-
if (!exactTriggerBlock) {
|
|
783
|
-
problems.push("task must contain exactly one TimeTrigger");
|
|
784
|
-
}
|
|
785
|
-
if (repetitionBlocks.length !== 1) {
|
|
786
|
-
problems.push("task must contain exactly one repetition schedule");
|
|
787
|
-
}
|
|
788
|
-
else if (!new RegExp(`<Interval>\\s*${escapeRegExp(expectedInterval)}\\s*</Interval>`, "iu").test(repetition)) {
|
|
789
|
-
problems.push(`repetition interval is not ${expectedInterval}`);
|
|
790
|
-
}
|
|
791
|
-
if (/<EndBoundary>/iu.test(exactTriggerBlock)) {
|
|
792
|
-
problems.push("task trigger has an end boundary");
|
|
793
|
-
}
|
|
794
|
-
const randomDelay = exactTriggerBlock
|
|
795
|
-
.match(/<RandomDelay>\s*([^<]+?)\s*<\/RandomDelay>/iu)?.[1]
|
|
796
|
-
?.trim()
|
|
797
|
-
.toUpperCase() ?? null;
|
|
798
|
-
if (randomDelay &&
|
|
799
|
-
!["PT0S", "PT0M", "PT0H"].includes(randomDelay)) {
|
|
800
|
-
problems.push("task trigger has a nonzero random delay");
|
|
801
|
-
}
|
|
802
|
-
if (!exactExecBlock) {
|
|
803
|
-
problems.push("task must contain exactly one Exec action");
|
|
804
|
-
}
|
|
805
|
-
if (!sameWindowsPath(command, expected.wscriptPath)) {
|
|
806
|
-
problems.push("task action does not run the expected windowless script host");
|
|
807
|
-
}
|
|
808
|
-
const actionArgumentProblem = windowsActionArgumentProblem(actionArguments, expectedArgumentFlags, expected.launcherPath);
|
|
809
|
-
if (actionArgumentProblem) {
|
|
810
|
-
problems.push(actionArgumentProblem);
|
|
811
|
-
}
|
|
812
|
-
if (/<Duration>/iu.test(repetition)) {
|
|
813
|
-
problems.push("repetition has a finite duration");
|
|
814
|
-
}
|
|
815
|
-
return problems;
|
|
816
|
-
}
|
|
817
|
-
function windowsPowerShellPath(env = process.env) {
|
|
818
|
-
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
819
|
-
return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
820
|
-
}
|
|
821
|
-
function windowsWScriptPath(env = process.env) {
|
|
822
|
-
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
823
|
-
return path.win32.join(windowsRoot, "System32", "wscript.exe");
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Task Scheduler stores a normalized form of the action it was given, not the
|
|
827
|
-
* string we passed: `schtasks /Create /TR "<host> <args>"` is split into
|
|
828
|
-
* <Command> plus <Arguments>, and the quotes around a space-free script path
|
|
829
|
-
* are dropped. Comparing <Arguments> against the exact string we built
|
|
830
|
-
* therefore fails on a task that is registered perfectly — observed on
|
|
831
|
-
* DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
|
|
832
|
-
* from the expected one only by those quotes (BLI-2541).
|
|
833
|
-
*
|
|
834
|
-
* BLI-2996: that lesson was only ever applied to the pre-BLI-2677 `-File`
|
|
835
|
-
* action. The wscript launcher pair (`//B "<launcher>"`) has NO verified
|
|
836
|
-
* real-Windows capture — `windowlessWindowsTaskXml()` in autostart.test.ts is
|
|
837
|
-
* SYNTHETIC, built by hand-editing the one real capture we have rather than
|
|
838
|
-
* exported from a machine actually running this action. A repair that rewrites
|
|
839
|
-
* the task correctly but is judged against a guessed shape can fail forever
|
|
840
|
-
* without ever being wrong (Brandon's machine: same non-convergence under two
|
|
841
|
-
* different validator messages across two CLI versions, neither of which ever
|
|
842
|
-
* repaired anything). So this comparison is deliberately structural, not
|
|
843
|
-
* positional: tokenize the way CommandLineToArgvW groups quoted/unquoted
|
|
844
|
-
* arguments, then check flag-token-then-path-token semantically (a real path
|
|
845
|
-
* comparison, case- and quote-insensitive) instead of demanding the exact
|
|
846
|
-
* byte layout we happened to send. Whatever the wscript action's real
|
|
847
|
-
* normalization turns out to be once a canary captures it, this only fails on
|
|
848
|
-
* an actual different flag or different script — not on Windows' own
|
|
849
|
-
* reformatting.
|
|
850
|
-
*/
|
|
851
|
-
function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
|
|
852
|
-
const tokens = splitWindowsArgumentTokens(actionArguments);
|
|
853
|
-
if (tokens.length === 0) {
|
|
854
|
-
return "task action names no sync launcher to run";
|
|
855
|
-
}
|
|
856
|
-
if ((tokens[0] ?? "").toUpperCase() !== expectedFlags.toUpperCase()) {
|
|
857
|
-
return "task action does not run the launcher with the expected batch-mode flag";
|
|
858
|
-
}
|
|
859
|
-
if (tokens.length > 2) {
|
|
860
|
-
return "task action passes unexpected extra arguments to the sync launcher";
|
|
861
|
-
}
|
|
862
|
-
const launcherArgument = tokens[1];
|
|
863
|
-
if (!launcherArgument) {
|
|
864
|
-
return "task action names no sync launcher to run";
|
|
865
|
-
}
|
|
866
|
-
if (!sameWindowsPath(launcherArgument, expectedLauncherPath)) {
|
|
867
|
-
return "task action runs a script other than the Tower sync launcher";
|
|
868
|
-
}
|
|
869
|
-
return null;
|
|
870
|
-
}
|
|
871
|
-
/**
|
|
872
|
-
* Quote-aware whitespace tokenizer for an already-XML-decoded `<Arguments>`
|
|
873
|
-
* value: a `"..."` run is one token (quotes stripped, whitespace inside kept),
|
|
874
|
-
* everything else splits on whitespace. Windows paths never contain a quote
|
|
875
|
-
* character, so this does not need CommandLineToArgvW's backslash-escaping
|
|
876
|
-
* rules — only its quoting rule — to tell "one argument with a space in it"
|
|
877
|
-
* apart from "two arguments".
|
|
878
|
-
*/
|
|
879
|
-
function splitWindowsArgumentTokens(value) {
|
|
880
|
-
const tokens = [];
|
|
881
|
-
let current = "";
|
|
882
|
-
let inQuotes = false;
|
|
883
|
-
let hasToken = false;
|
|
884
|
-
for (const char of value) {
|
|
885
|
-
if (char === '"') {
|
|
886
|
-
inQuotes = !inQuotes;
|
|
887
|
-
hasToken = true;
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
if (!inQuotes && /\s/u.test(char)) {
|
|
891
|
-
if (hasToken) {
|
|
892
|
-
tokens.push(current);
|
|
893
|
-
current = "";
|
|
894
|
-
hasToken = false;
|
|
895
|
-
}
|
|
896
|
-
continue;
|
|
897
|
-
}
|
|
898
|
-
current += char;
|
|
899
|
-
hasToken = true;
|
|
900
|
-
}
|
|
901
|
-
if (hasToken)
|
|
902
|
-
tokens.push(current);
|
|
903
|
-
return tokens;
|
|
904
|
-
}
|
|
905
|
-
/** Removes one wrapping pair of double quotes, if present. Used only to
|
|
906
|
-
* normalize before a path comparison — never to reconstruct a shell-safe
|
|
907
|
-
* value. */
|
|
908
|
-
function stripSurroundingQuotes(value) {
|
|
909
|
-
const match = value.match(/^"([\s\S]*)"$/u);
|
|
910
|
-
return match ? match[1] : value;
|
|
911
|
-
}
|
|
912
|
-
function sameWindowsPath(left, right) {
|
|
913
|
-
if (!left || !right)
|
|
914
|
-
return false;
|
|
915
|
-
return path.win32.resolve(left).toLowerCase() ===
|
|
916
|
-
path.win32.resolve(right).toLowerCase();
|
|
917
|
-
}
|
|
918
|
-
function powershellLiteral(value) {
|
|
919
|
-
return `'${value.replace(/'/gu, "''")}'`;
|
|
920
|
-
}
|
|
921
|
-
function windowsTaskInterval(minutes) {
|
|
922
|
-
const hours = Math.floor(minutes / 60);
|
|
923
|
-
const remainingMinutes = minutes % 60;
|
|
924
|
-
return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
|
|
925
|
-
}
|
|
926
|
-
function decodeXmlEntities(value) {
|
|
927
|
-
return value
|
|
928
|
-
.replace(/"/giu, '"')
|
|
929
|
-
.replace(/'/giu, "'")
|
|
930
|
-
.replace(/</giu, "<")
|
|
931
|
-
.replace(/>/giu, ">")
|
|
932
|
-
.replace(/&/giu, "&");
|
|
933
|
-
}
|
|
934
|
-
function escapeRegExp(value) {
|
|
935
|
-
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* The transcript directories whose changes should re-trigger a sync, in the
|
|
939
|
-
* order they appear in WatchPaths. Resolved from the same homeDir as the plist
|
|
940
|
-
* and log paths so `--home` redirects them together.
|
|
941
|
-
*/
|
|
942
|
-
function watchPathCandidates(homeDir) {
|
|
943
|
-
return [
|
|
944
|
-
path.join(homeDir, ".claude", "projects"),
|
|
945
|
-
path.join(homeDir, ".codex", "sessions"),
|
|
946
|
-
];
|
|
947
|
-
}
|
|
948
|
-
/** Subset of the transcript dirs that exist now (see the WatchPaths comment). */
|
|
949
|
-
async function existingWatchPaths(homeDir) {
|
|
950
|
-
const candidates = watchPathCandidates(homeDir);
|
|
951
|
-
const present = await Promise.all(candidates.map((dir) => fileExists(dir)));
|
|
952
|
-
return candidates.filter((_, i) => present[i]);
|
|
953
|
-
}
|
|
954
|
-
function renderPlist(options) {
|
|
955
|
-
const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
|
|
956
|
-
const command = renderDarwinSyncCommand({
|
|
957
|
-
workDirs: roots,
|
|
958
|
-
dashboardUrl: options.dashboardUrl,
|
|
959
|
-
nodeExecutable: options.nodeExecutable,
|
|
960
|
-
cliEntryPoint: options.cliEntryPoint,
|
|
961
|
-
discoveryArgs: options.discoveryArgs,
|
|
962
|
-
});
|
|
963
|
-
return [
|
|
964
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
965
|
-
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
966
|
-
'<plist version="1.0">',
|
|
967
|
-
"<dict>",
|
|
968
|
-
" <key>Label</key>",
|
|
969
|
-
` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`,
|
|
970
|
-
" <key>ProgramArguments</key>",
|
|
971
|
-
" <array>",
|
|
972
|
-
" <string>/bin/zsh</string>",
|
|
973
|
-
" <string>-c</string>",
|
|
974
|
-
` <string>${xmlEscape(command)}</string>`,
|
|
975
|
-
" </array>",
|
|
976
|
-
" <key>StartInterval</key>",
|
|
977
|
-
` <integer>${options.intervalSeconds}</integer>`,
|
|
978
|
-
" <key>RunAtLoad</key>",
|
|
979
|
-
" <true/>",
|
|
980
|
-
// WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
|
|
981
|
-
// captures land near-real-time; StartInterval above is the safety-net floor.
|
|
982
|
-
// It fires often, but launchd single-flights the job, sync holds its own
|
|
983
|
-
// lock, and growing-transcript re-upload is damped — so cost stays bounded
|
|
984
|
-
// and no extra debounce is needed.
|
|
985
|
-
...watchPathsBlock(options.watchPaths),
|
|
986
|
-
" <key>StandardOutPath</key>",
|
|
987
|
-
` <string>${xmlEscape(options.stdoutPath)}</string>`,
|
|
988
|
-
" <key>StandardErrorPath</key>",
|
|
989
|
-
` <string>${xmlEscape(options.stderrPath)}</string>`,
|
|
990
|
-
"</dict>",
|
|
991
|
-
"</plist>",
|
|
992
|
-
"",
|
|
993
|
-
].join("\n");
|
|
994
|
-
}
|
|
995
|
-
function renderDarwinSyncCommand(options) {
|
|
996
|
-
const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
997
|
-
? ""
|
|
998
|
-
: ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
|
|
999
|
-
const discoveryArg = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
1000
|
-
const commands = options.workDirs.map((root) => `"$node_bin" ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg}${discoveryArg} --json || exit_code=1`);
|
|
1001
|
-
return [
|
|
1002
|
-
"exit_code=0",
|
|
1003
|
-
...darwinNodeResolutionPreamble(options.nodeExecutable),
|
|
1004
|
-
...commands,
|
|
1005
|
-
'exit "$exit_code"',
|
|
1006
|
-
].join("; ");
|
|
1007
|
-
}
|
|
1008
|
-
/**
|
|
1009
|
-
* The registered node path first, a login-shell PATH lookup second, a named
|
|
1010
|
-
* failure third (BLI-3553).
|
|
1011
|
-
*
|
|
1012
|
-
* The hardcoded absolute path stays FIRST and stays the normal case — launchd
|
|
1013
|
-
* hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin` and nothing this fleet
|
|
1014
|
-
* runs lives there, which is the whole reason the plist names absolutes
|
|
1015
|
-
* (scheduled-self-update.ts:55-62). The fallback only fires when that path is
|
|
1016
|
-
* gone: `brew upgrade node` deleted the Cellar directory, `nvm uninstall`
|
|
1017
|
-
* removed a version, someone moved a prefix. Before this, that state was a
|
|
1018
|
-
* silent `exit 127` every 15 minutes forever, on every tick, with nothing in
|
|
1019
|
-
* sync.err.log naming it.
|
|
1020
|
-
*
|
|
1021
|
-
* `/bin/zsh -lc` because a login shell is where Homebrew's `brew shellenv` and
|
|
1022
|
-
* a machine's own PATH edits live; `command -v` because it is a builtin and
|
|
1023
|
-
* cannot itself be missing. Recovering here is deliberately temporary: running
|
|
1024
|
-
* under a node the plist does not name makes the next status read disagree,
|
|
1025
|
-
* which is exactly what wakes the self-heal up to re-register the new path.
|
|
1026
|
-
* A machine whose node is only on an INTERACTIVE shell's PATH (nvm sourced from
|
|
1027
|
-
* .zshrc) still fails — but now it fails with a reason in the log instead of a
|
|
1028
|
-
* bare 127.
|
|
1029
|
-
*/
|
|
1030
|
-
function darwinNodeResolutionPreamble(nodeExecutable) {
|
|
1031
|
-
return [
|
|
1032
|
-
`node_bin=${shellQuote(nodeExecutable)}`,
|
|
1033
|
-
`[ -x "$node_bin" ] || node_bin="$(${DARWIN_LOGIN_SHELL} -lc 'command -v node' 2>/dev/null)"`,
|
|
1034
|
-
`[ -x "$node_bin" ] || { echo ${shellQuote(DARWIN_NODE_MISSING_MESSAGE)} >&2; exit 127; }`,
|
|
1035
|
-
];
|
|
1036
|
-
}
|
|
1037
|
-
function darwinAgentRegistrationProblems(plist, expected) {
|
|
1038
|
-
if (!plist)
|
|
1039
|
-
return ["plist could not be read"];
|
|
1040
|
-
const problems = [];
|
|
1041
|
-
if (!plist.includes(` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`)) {
|
|
1042
|
-
problems.push("label does not match");
|
|
1043
|
-
}
|
|
1044
|
-
if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
|
|
1045
|
-
problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
|
|
1046
|
-
}
|
|
1047
|
-
const expectedCommand = renderDarwinSyncCommand({
|
|
1048
|
-
discoveryArgs: expected.discoveryArgs,
|
|
1049
|
-
workDirs: expected.workDirs,
|
|
1050
|
-
dashboardUrl: expected.dashboardUrl,
|
|
1051
|
-
nodeExecutable: expected.nodeExecutable,
|
|
1052
|
-
cliEntryPoint: expected.cliEntryPoint,
|
|
1053
|
-
});
|
|
1054
|
-
const expectedProgramArguments = [
|
|
1055
|
-
" <key>ProgramArguments</key>",
|
|
1056
|
-
" <array>",
|
|
1057
|
-
" <string>/bin/zsh</string>",
|
|
1058
|
-
" <string>-c</string>",
|
|
1059
|
-
` <string>${xmlEscape(expectedCommand)}</string>`,
|
|
1060
|
-
" </array>",
|
|
1061
|
-
].join("\n");
|
|
1062
|
-
if (!plist.includes(expectedProgramArguments)) {
|
|
1063
|
-
problems.push("command does not match the current roots, dashboard URL, or Tower runtime");
|
|
1064
|
-
}
|
|
1065
|
-
return problems;
|
|
1066
|
-
}
|
|
1067
|
-
function normalizeWorkDirs(repoRoots) {
|
|
1068
|
-
if (!repoRoots)
|
|
1069
|
-
return [];
|
|
1070
|
-
const seen = new Set();
|
|
1071
|
-
const normalized = [];
|
|
1072
|
-
for (const root of repoRoots) {
|
|
1073
|
-
const resolved = path.resolve(root);
|
|
1074
|
-
if (seen.has(resolved))
|
|
1075
|
-
continue;
|
|
1076
|
-
seen.add(resolved);
|
|
1077
|
-
normalized.push(resolved);
|
|
1078
|
-
}
|
|
1079
|
-
return normalized;
|
|
1080
|
-
}
|
|
1081
|
-
function normalizeWindowsWorkDirs(repoRoots) {
|
|
1082
|
-
if (!repoRoots)
|
|
1083
|
-
return [];
|
|
1084
|
-
const seen = new Set();
|
|
1085
|
-
const normalized = [];
|
|
1086
|
-
for (const root of repoRoots) {
|
|
1087
|
-
const resolved = path.win32.resolve(root);
|
|
1088
|
-
const key = resolved.toLowerCase();
|
|
1089
|
-
if (seen.has(key))
|
|
1090
|
-
continue;
|
|
1091
|
-
seen.add(key);
|
|
1092
|
-
normalized.push(resolved);
|
|
1093
|
-
}
|
|
1094
|
-
return normalized;
|
|
1095
|
-
}
|
|
1096
|
-
/**
|
|
1097
|
-
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
1098
|
-
* transcript dir exists yet (an empty array would tell launchd to watch
|
|
1099
|
-
* everything-and-nothing; omitting the key leaves StartInterval as the floor).
|
|
1100
|
-
*/
|
|
1101
|
-
function watchPathsBlock(watchPaths) {
|
|
1102
|
-
if (watchPaths.length === 0)
|
|
1103
|
-
return [];
|
|
1104
|
-
return [
|
|
1105
|
-
" <key>WatchPaths</key>",
|
|
1106
|
-
" <array>",
|
|
1107
|
-
...watchPaths.map((dir) => ` <string>${xmlEscape(dir)}</string>`),
|
|
1108
|
-
" </array>",
|
|
1109
|
-
];
|
|
61
|
+
return darwinAgentStatus(options);
|
|
1110
62
|
}
|
|
1111
63
|
/**
|
|
1112
64
|
* What the platform says it will run, parsed out of the registration itself
|
|
@@ -1117,19 +69,10 @@ function watchPathsBlock(watchPaths) {
|
|
|
1117
69
|
export async function readRegisteredRuntimePaths(options) {
|
|
1118
70
|
const platform = options.platform ?? process.platform;
|
|
1119
71
|
if (platform === "darwin") {
|
|
1120
|
-
|
|
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
|
-
};
|
|
72
|
+
return readRegisteredDarwinRuntimePaths(options.homeDir ?? os.homedir());
|
|
1126
73
|
}
|
|
1127
74
|
if (platform === "win32") {
|
|
1128
|
-
|
|
1129
|
-
return {
|
|
1130
|
-
node_executable: /\$nodeExecutable = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
1131
|
-
cli_entry_point: /\$cliEntryPoint = '([^']*)'/u.exec(script)?.[1] ?? null,
|
|
1132
|
-
};
|
|
75
|
+
return readRegisteredWindowsRuntimePaths(options.homeDir);
|
|
1133
76
|
}
|
|
1134
77
|
return { node_executable: null, cli_entry_point: null };
|
|
1135
78
|
}
|
|
@@ -1160,60 +103,4 @@ export async function registeredRuntimePathProblems(options) {
|
|
|
1160
103
|
problems.push("registered Tower CLI entry point is missing");
|
|
1161
104
|
}
|
|
1162
105
|
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
|
-
}
|
|
1206
|
-
function shellQuote(value) {
|
|
1207
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1208
|
-
}
|
|
1209
|
-
function xmlEscape(value) {
|
|
1210
|
-
return value
|
|
1211
|
-
.replace(/&/g, "&")
|
|
1212
|
-
.replace(/</g, "<")
|
|
1213
|
-
.replace(/>/g, ">")
|
|
1214
|
-
.replace(/"/g, """)
|
|
1215
|
-
.replace(/'/g, "'");
|
|
1216
|
-
}
|
|
1217
|
-
async function fileExists(filePath) {
|
|
1218
|
-
return stat(filePath).then(() => true, () => false);
|
|
1219
106
|
}
|