@bli-cockpit/cli 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/autostart.js CHANGED
@@ -1,11 +1,15 @@
1
- import { mkdir, rm, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
5
  /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
6
6
  export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
7
+ export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
8
+ const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
9
+ const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
10
+ const UTF8_BOM = "\uFEFF";
7
11
  export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
8
- const UNSUPPORTED_MESSAGE = "macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md";
12
+ const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
9
13
  function plistPathFor(homeDir) {
10
14
  return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
11
15
  }
@@ -26,6 +30,8 @@ function unsupportedResult(homeDir) {
26
30
  */
27
31
  export async function installAutostartAgent(options) {
28
32
  const platform = options.platform ?? process.platform;
33
+ if (platform === "win32")
34
+ return installWindowsTask(options);
29
35
  if (platform !== "darwin")
30
36
  return unsupportedResult(options.homeDir);
31
37
  const homeDir = options.homeDir ?? os.homedir();
@@ -34,6 +40,8 @@ export async function installAutostartAgent(options) {
34
40
  const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
35
41
  const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
36
42
  const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
43
+ const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
44
+ const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
37
45
  const paths = getCollectorRuntimePaths(homeDir);
38
46
  const plistPath = plistPathFor(homeDir);
39
47
  const stdoutPath = path.join(paths.state_dir, "sync.log");
@@ -49,6 +57,8 @@ export async function installAutostartAgent(options) {
49
57
  workDirs: resolvedWorkDirs,
50
58
  dashboardUrl,
51
59
  intervalSeconds,
60
+ nodeExecutable,
61
+ cliEntryPoint,
52
62
  stdoutPath,
53
63
  stderrPath,
54
64
  watchPaths,
@@ -80,6 +90,8 @@ export async function installAutostartAgent(options) {
80
90
  */
81
91
  export async function uninstallAutostartAgent(options) {
82
92
  const platform = options.platform ?? process.platform;
93
+ if (platform === "win32")
94
+ return uninstallWindowsTask(options);
83
95
  if (platform !== "darwin")
84
96
  return unsupportedResult(options.homeDir);
85
97
  const homeDir = options.homeDir ?? os.homedir();
@@ -92,12 +104,13 @@ export async function uninstallAutostartAgent(options) {
92
104
  return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
93
105
  }
94
106
  /**
95
- * Reports whether the agent is installed and loaded. `absent` when no plist
96
- * exists; otherwise `launchctl list <label>` exit code distinguishes `loaded`
97
- * (0) from `not_loaded` (non-zero).
107
+ * Reports whether the agent is installed, loaded, and still matches the
108
+ * configured roots, cadence, dashboard URL, and Cockpit runtime.
98
109
  */
99
110
  export async function autostartStatus(options) {
100
111
  const platform = options.platform ?? process.platform;
112
+ if (platform === "win32")
113
+ return windowsTaskStatus(options);
101
114
  if (platform !== "darwin")
102
115
  return unsupportedResult(options.homeDir);
103
116
  const homeDir = options.homeDir ?? os.homedir();
@@ -105,13 +118,396 @@ export async function autostartStatus(options) {
105
118
  if (!(await fileExists(plistPath))) {
106
119
  return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
107
120
  }
121
+ const workDirs = normalizeWorkDirs(options.repoRoots);
122
+ const workDir = path.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
123
+ const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
124
+ const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
125
+ const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
126
+ const nodeExecutable = path.resolve(options.nodeExecutable ?? process.execPath);
127
+ const cliEntryPoint = path.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
128
+ const plist = await readFile(plistPath, "utf8").catch(() => "");
129
+ const registrationProblems = darwinAgentRegistrationProblems(plist, {
130
+ workDirs: resolvedWorkDirs,
131
+ dashboardUrl,
132
+ intervalSeconds,
133
+ nodeExecutable,
134
+ cliEntryPoint,
135
+ });
108
136
  const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
137
+ if (list.code !== 0)
138
+ registrationProblems.unshift("agent is not loaded");
109
139
  return {
110
- status: list.code === 0 ? "loaded" : "not_loaded",
140
+ status: registrationProblems.length === 0 ? "loaded" : "not_loaded",
111
141
  label: AUTOSTART_LABEL,
112
142
  plist_path: plistPath,
143
+ interval_seconds: intervalSeconds,
144
+ work_dir: workDir,
145
+ work_dirs: resolvedWorkDirs,
146
+ dashboard_url: dashboardUrl,
147
+ ...(registrationProblems.length > 0
148
+ ? {
149
+ message: `launchd agent needs repair: ${registrationProblems.join("; ")}`,
150
+ }
151
+ : {}),
152
+ };
153
+ }
154
+ async function installWindowsTask(options) {
155
+ const homeDir = options.homeDir ?? os.homedir();
156
+ const workDirs = normalizeWindowsWorkDirs(options.repoRoots);
157
+ const workDir = path.win32.resolve(options.repoRoot ?? workDirs[0] ?? process.cwd());
158
+ const resolvedWorkDirs = workDirs.length > 0 ? workDirs : [workDir];
159
+ const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
160
+ const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
161
+ const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
162
+ const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
163
+ const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
164
+ const nodeExecutable = path.win32.resolve(options.nodeExecutable ?? process.execPath);
165
+ const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
166
+ await mkdir(path.dirname(scriptPath), { recursive: true });
167
+ await writeFile(scriptPath, `${UTF8_BOM}${renderWindowsSyncScript({
168
+ workDirs: resolvedWorkDirs,
169
+ dashboardUrl,
170
+ nodeExecutable,
171
+ cliEntryPoint,
172
+ })}`, "utf8");
173
+ await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
174
+ taskName: WINDOWS_AUTOSTART_TASK_NAME,
175
+ scriptPath,
176
+ intervalMinutes,
177
+ })}`, "utf8");
178
+ const created = await options.exec("powershell.exe", [
179
+ "-NoProfile",
180
+ "-NonInteractive",
181
+ "-ExecutionPolicy",
182
+ "Bypass",
183
+ "-File",
184
+ registrationPath,
185
+ ]);
186
+ const verified = created.code === 0
187
+ ? await windowsTaskStatus({
188
+ ...options,
189
+ repoRoot: workDir,
190
+ repoRoots: resolvedWorkDirs,
191
+ dashboardUrl,
192
+ intervalSeconds: intervalMinutes * 60,
193
+ nodeExecutable,
194
+ cliEntryPoint,
195
+ })
196
+ : null;
197
+ const loaded = verified?.status === "loaded";
198
+ return {
199
+ status: "installed",
200
+ label: AUTOSTART_LABEL,
201
+ plist_path: scriptPath,
202
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
203
+ registration_path: registrationPath,
204
+ loaded,
205
+ interval_seconds: intervalMinutes * 60,
206
+ work_dir: workDir,
207
+ work_dirs: resolvedWorkDirs,
208
+ dashboard_url: dashboardUrl,
209
+ ...(loaded
210
+ ? {}
211
+ : {
212
+ message: created.code === 0
213
+ ? verified?.message ??
214
+ "Windows task registration could not be verified"
215
+ : `PowerShell task registration exited ${created.code}: ${created.stderr.trim() ||
216
+ created.stdout.trim() ||
217
+ "unknown error"}`,
218
+ }),
219
+ };
220
+ }
221
+ async function uninstallWindowsTask(options) {
222
+ const scriptPath = windowsAutostartScriptPath(options.homeDir);
223
+ const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
224
+ const current = await windowsTaskStatus(options);
225
+ if (current.status === "absent") {
226
+ await rm(scriptPath, { force: true });
227
+ await rm(registrationPath, { force: true });
228
+ return {
229
+ ...current,
230
+ plist_path: scriptPath,
231
+ registration_path: registrationPath,
232
+ };
233
+ }
234
+ const removed = await options.exec("schtasks.exe", [
235
+ "/Delete",
236
+ "/TN",
237
+ WINDOWS_AUTOSTART_TASK_NAME,
238
+ "/F",
239
+ ]);
240
+ if (removed.code === 0) {
241
+ await rm(scriptPath, { force: true });
242
+ await rm(registrationPath, { force: true });
243
+ }
244
+ return {
245
+ status: removed.code === 0 ? "uninstalled" : "not_loaded",
246
+ label: AUTOSTART_LABEL,
247
+ plist_path: scriptPath,
248
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
249
+ registration_path: registrationPath,
250
+ ...(removed.code === 0
251
+ ? {}
252
+ : {
253
+ message: `schtasks /Delete exited ${removed.code}: ${removed.stderr.trim() || removed.stdout.trim() || "unknown error"}`,
254
+ }),
113
255
  };
114
256
  }
257
+ async function windowsTaskStatus(options) {
258
+ const scriptPath = windowsAutostartScriptPath(options.homeDir);
259
+ const query = await options.exec("schtasks.exe", [
260
+ "/Query",
261
+ "/TN",
262
+ WINDOWS_AUTOSTART_TASK_NAME,
263
+ "/XML",
264
+ ]);
265
+ if (query.code !== 0) {
266
+ const queryMessage = `${query.stderr}\n${query.stdout}`;
267
+ const taskIsKnownAbsent = query.code === 1 &&
268
+ /cannot find|not found|does not exist/iu.test(queryMessage);
269
+ return {
270
+ status: taskIsKnownAbsent ? "absent" : "not_loaded",
271
+ label: AUTOSTART_LABEL,
272
+ plist_path: scriptPath,
273
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
274
+ message: query.stderr.trim() || query.stdout.trim() || undefined,
275
+ };
276
+ }
277
+ const disabled = /<Enabled>\s*false\s*<\/Enabled>/iu.test(query.stdout);
278
+ const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
279
+ const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
280
+ const registrationProblems = windowsTaskRegistrationProblems(query.stdout, {
281
+ scriptPath,
282
+ intervalMinutes,
283
+ powershellPath: windowsPowerShellPath(),
284
+ });
285
+ if (disabled)
286
+ registrationProblems.unshift("task is disabled");
287
+ const scriptExists = await fileExists(scriptPath);
288
+ if (!scriptExists) {
289
+ registrationProblems.push("sync script is missing");
290
+ }
291
+ else if (options.repoRoots && options.repoRoots.length > 0) {
292
+ const expectedScript = `${UTF8_BOM}${renderWindowsSyncScript({
293
+ workDirs: normalizeWindowsWorkDirs(options.repoRoots),
294
+ dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
295
+ nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
296
+ cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
297
+ })}`;
298
+ const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
299
+ if (currentScript !== expectedScript) {
300
+ registrationProblems.push("sync script does not match the current roots or Cockpit runtime");
301
+ }
302
+ }
303
+ return {
304
+ status: registrationProblems.length > 0 ? "not_loaded" : "loaded",
305
+ label: AUTOSTART_LABEL,
306
+ plist_path: scriptPath,
307
+ task_name: WINDOWS_AUTOSTART_TASK_NAME,
308
+ ...(registrationProblems.length > 0
309
+ ? {
310
+ message: `Windows task needs repair: ${registrationProblems.join("; ")}`,
311
+ }
312
+ : {}),
313
+ };
314
+ }
315
+ function windowsAutostartScriptPath(homeDir) {
316
+ return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
317
+ }
318
+ function windowsAutostartRegistrationScriptPath(homeDir) {
319
+ return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
320
+ }
321
+ function renderWindowsRegistrationScript(options) {
322
+ return [
323
+ "$ErrorActionPreference = 'Stop'",
324
+ `$taskName = ${powershellLiteral(options.taskName)}`,
325
+ `$syncScriptPath = ${powershellLiteral(options.scriptPath)}`,
326
+ "$powershellPath = Join-Path $PSHOME 'powershell.exe'",
327
+ "$actionArgs = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"' + $syncScriptPath + '\"'",
328
+ "$taskCommand = '\"' + $powershellPath + '\" ' + $actionArgs",
329
+ `& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
330
+ "if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
331
+ "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
332
+ "Set-ScheduledTask -TaskName $taskName -Settings $settings | Out-Null",
333
+ "",
334
+ ].join("\r\n");
335
+ }
336
+ function renderWindowsSyncScript(options) {
337
+ const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
338
+ ? ""
339
+ : ` --dashboard-url ${powershellLiteral(options.dashboardUrl)}`;
340
+ const commands = options.workDirs.flatMap((root) => [
341
+ "try {",
342
+ ` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs} --json`,
343
+ " if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
344
+ "} catch {",
345
+ " [Console]::Error.WriteLine($_.Exception.Message)",
346
+ " $exitCode = 1",
347
+ "}",
348
+ ]);
349
+ return [
350
+ "$ErrorActionPreference = 'Stop'",
351
+ "$exitCode = 0",
352
+ `$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
353
+ `$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
354
+ ...commands,
355
+ "exit $exitCode",
356
+ "",
357
+ ].join("\r\n");
358
+ }
359
+ function windowsTaskRegistrationProblems(taskXml, expected) {
360
+ const decodedXml = decodeXmlEntities(taskXml);
361
+ const expectedInterval = windowsTaskInterval(expected.intervalMinutes);
362
+ const principalsBody = decodedXml
363
+ .match(/<Principals(?:\s[^>]*)?>([\s\S]*?)<\/Principals>/iu)?.[1] ??
364
+ "";
365
+ const principalBlocks = [
366
+ ...principalsBody.matchAll(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu),
367
+ ].map((match) => match[0]);
368
+ const principalRemainder = principalsBody
369
+ .replace(/<Principal(?:\s[^>]*)?>[\s\S]*?<\/Principal>/giu, "")
370
+ .trim();
371
+ const exactPrincipalBlock = principalBlocks.length === 1 && principalRemainder === ""
372
+ ? (principalBlocks[0] ?? "")
373
+ : "";
374
+ const principalId = exactPrincipalBlock
375
+ .match(/<Principal\s[^>]*\bid\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
376
+ null;
377
+ const triggersBody = decodedXml.match(/<Triggers(?:\s[^>]*)?>([\s\S]*?)<\/Triggers>/iu)?.[1] ??
378
+ "";
379
+ const triggerBlocks = [
380
+ ...triggersBody.matchAll(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu),
381
+ ].map((match) => match[0]);
382
+ const triggerRemainder = triggersBody
383
+ .replace(/<(?:TimeTrigger)(?:\s[^>]*)?>[\s\S]*?<\/(?:TimeTrigger)>/giu, "")
384
+ .trim();
385
+ const exactTriggerBlock = triggerBlocks.length === 1 && triggerRemainder === ""
386
+ ? (triggerBlocks[0] ?? "")
387
+ : "";
388
+ const repetitionBlocks = exactTriggerBlock
389
+ ? [
390
+ ...exactTriggerBlock.matchAll(/<Repetition(?:\s[^>]*)?>[\s\S]*?<\/Repetition>/giu),
391
+ ].map((match) => match[0])
392
+ : [];
393
+ const repetition = repetitionBlocks.length === 1 ? repetitionBlocks[0] ?? "" : "";
394
+ const actionsBody = decodedXml.match(/<Actions(?:\s[^>]*)?>([\s\S]*?)<\/Actions>/iu)?.[1] ?? "";
395
+ const actionContext = decodedXml
396
+ .match(/<Actions\s[^>]*\bContext\s*=\s*["']([^"']+)["'][^>]*>/iu)?.[1] ??
397
+ null;
398
+ const execBlocks = [
399
+ ...actionsBody.matchAll(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu),
400
+ ].map((match) => match[0]);
401
+ const actionRemainder = actionsBody
402
+ .replace(/<Exec(?:\s[^>]*)?>[\s\S]*?<\/Exec>/giu, "")
403
+ .trim();
404
+ const exactExecBlock = execBlocks.length === 1 && actionRemainder === "" ? execBlocks[0] : "";
405
+ const command = exactExecBlock
406
+ ?.match(/<Command>\s*([^<]*?)\s*<\/Command>/iu)?.[1]
407
+ ?.trim() ??
408
+ "";
409
+ const actionArguments = exactExecBlock
410
+ .match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
411
+ ?.trim() ?? "";
412
+ const expectedActionArguments = `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${expected.scriptPath}"`;
413
+ const requirements = [
414
+ [
415
+ /<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
416
+ "task is blocked on battery",
417
+ ],
418
+ [
419
+ /<StopIfGoingOnBatteries>\s*false\s*<\/StopIfGoingOnBatteries>/iu,
420
+ "task stops on battery",
421
+ ],
422
+ [
423
+ /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/iu,
424
+ "overlap policy is not IgnoreNew",
425
+ ],
426
+ [
427
+ /<StartWhenAvailable>\s*true\s*<\/StartWhenAvailable>/iu,
428
+ "missed-run recovery is disabled",
429
+ ],
430
+ ];
431
+ const problems = requirements
432
+ .filter(([pattern]) => !pattern.test(decodedXml))
433
+ .map(([, message]) => message);
434
+ if (!exactPrincipalBlock) {
435
+ problems.push("task must contain exactly one principal");
436
+ }
437
+ else {
438
+ if (!/<LogonType>\s*InteractiveToken\s*<\/LogonType>/iu.test(exactPrincipalBlock)) {
439
+ problems.push("logon type is not InteractiveToken");
440
+ }
441
+ if (!/<RunLevel>\s*LeastPrivilege\s*<\/RunLevel>/iu.test(exactPrincipalBlock)) {
442
+ problems.push("task does not run with limited privileges");
443
+ }
444
+ }
445
+ if (actionContext && actionContext !== principalId) {
446
+ problems.push("task action context does not match its principal");
447
+ }
448
+ if (!exactTriggerBlock) {
449
+ problems.push("task must contain exactly one TimeTrigger");
450
+ }
451
+ if (repetitionBlocks.length !== 1) {
452
+ problems.push("task must contain exactly one repetition schedule");
453
+ }
454
+ else if (!new RegExp(`<Interval>\\s*${escapeRegExp(expectedInterval)}\\s*</Interval>`, "iu").test(repetition)) {
455
+ problems.push(`repetition interval is not ${expectedInterval}`);
456
+ }
457
+ if (/<EndBoundary>/iu.test(exactTriggerBlock)) {
458
+ problems.push("task trigger has an end boundary");
459
+ }
460
+ const randomDelay = exactTriggerBlock
461
+ .match(/<RandomDelay>\s*([^<]+?)\s*<\/RandomDelay>/iu)?.[1]
462
+ ?.trim()
463
+ .toUpperCase() ?? null;
464
+ if (randomDelay &&
465
+ !["PT0S", "PT0M", "PT0H"].includes(randomDelay)) {
466
+ problems.push("task trigger has a nonzero random delay");
467
+ }
468
+ if (!exactExecBlock) {
469
+ problems.push("task must contain exactly one Exec action");
470
+ }
471
+ if (!sameWindowsPath(command, expected.powershellPath)) {
472
+ problems.push("task action does not run the expected Windows PowerShell");
473
+ }
474
+ if (actionArguments !== expectedActionArguments) {
475
+ problems.push("task action arguments do not exactly run the Cockpit sync script");
476
+ }
477
+ if (/<Duration>/iu.test(repetition)) {
478
+ problems.push("repetition has a finite duration");
479
+ }
480
+ return problems;
481
+ }
482
+ function windowsPowerShellPath(env = process.env) {
483
+ const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
484
+ return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
485
+ }
486
+ function sameWindowsPath(left, right) {
487
+ if (!left || !right)
488
+ return false;
489
+ return path.win32.resolve(left).toLowerCase() ===
490
+ path.win32.resolve(right).toLowerCase();
491
+ }
492
+ function powershellLiteral(value) {
493
+ return `'${value.replace(/'/gu, "''")}'`;
494
+ }
495
+ function windowsTaskInterval(minutes) {
496
+ const hours = Math.floor(minutes / 60);
497
+ const remainingMinutes = minutes % 60;
498
+ return `PT${hours > 0 ? `${hours}H` : ""}${remainingMinutes > 0 ? `${remainingMinutes}M` : ""}`;
499
+ }
500
+ function decodeXmlEntities(value) {
501
+ return value
502
+ .replace(/&quot;/giu, '"')
503
+ .replace(/&apos;/giu, "'")
504
+ .replace(/&lt;/giu, "<")
505
+ .replace(/&gt;/giu, ">")
506
+ .replace(/&amp;/giu, "&");
507
+ }
508
+ function escapeRegExp(value) {
509
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
510
+ }
115
511
  /**
116
512
  * The transcript directories whose changes should re-trigger a sync, in the
117
513
  * order they appear in WatchPaths. Resolved from the same homeDir as the plist
@@ -130,15 +526,13 @@ async function existingWatchPaths(homeDir) {
130
526
  return candidates.filter((_, i) => present[i]);
131
527
  }
132
528
  function renderPlist(options) {
133
- // The work path is shell-quoted because it lands inside a `/bin/zsh -lc "…"`
134
- // command string; the whole command is then XML-escaped for the <string>.
135
- const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
136
- ? ""
137
- : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
138
529
  const roots = options.workDirs.length > 0 ? options.workDirs : [options.workDir];
139
- const command = roots
140
- .map((root) => `cockpit sync --workspace ${shellQuote(root)}${dashboardArg} --json`)
141
- .join("; ");
530
+ const command = renderDarwinSyncCommand({
531
+ workDirs: roots,
532
+ dashboardUrl: options.dashboardUrl,
533
+ nodeExecutable: options.nodeExecutable,
534
+ cliEntryPoint: options.cliEntryPoint,
535
+ });
142
536
  return [
143
537
  '<?xml version="1.0" encoding="UTF-8"?>',
144
538
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -149,7 +543,7 @@ function renderPlist(options) {
149
543
  " <key>ProgramArguments</key>",
150
544
  " <array>",
151
545
  " <string>/bin/zsh</string>",
152
- " <string>-lc</string>",
546
+ " <string>-c</string>",
153
547
  ` <string>${xmlEscape(command)}</string>`,
154
548
  " </array>",
155
549
  " <key>StartInterval</key>",
@@ -162,11 +556,6 @@ function renderPlist(options) {
162
556
  // lock, and growing-transcript re-upload is damped — so cost stays bounded
163
557
  // and no extra debounce is needed.
164
558
  ...watchPathsBlock(options.watchPaths),
165
- " <key>EnvironmentVariables</key>",
166
- " <dict>",
167
- " <key>PATH</key>",
168
- " <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
169
- " </dict>",
170
559
  " <key>StandardOutPath</key>",
171
560
  ` <string>${xmlEscape(options.stdoutPath)}</string>`,
172
561
  " <key>StandardErrorPath</key>",
@@ -176,6 +565,42 @@ function renderPlist(options) {
176
565
  "",
177
566
  ].join("\n");
178
567
  }
568
+ function renderDarwinSyncCommand(options) {
569
+ const dashboardArg = options.dashboardUrl === DEFAULT_DASHBOARD_URL
570
+ ? ""
571
+ : ` --dashboard-url ${shellQuote(options.dashboardUrl)}`;
572
+ const commands = options.workDirs.map((root) => `${shellQuote(options.nodeExecutable)} ${shellQuote(options.cliEntryPoint)} sync --workspace ${shellQuote(root)}${dashboardArg} --json || exit_code=1`);
573
+ return ["exit_code=0", ...commands, 'exit "$exit_code"'].join("; ");
574
+ }
575
+ function darwinAgentRegistrationProblems(plist, expected) {
576
+ if (!plist)
577
+ return ["plist could not be read"];
578
+ const problems = [];
579
+ if (!plist.includes(` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`)) {
580
+ problems.push("label does not match");
581
+ }
582
+ if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
583
+ problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
584
+ }
585
+ const expectedCommand = renderDarwinSyncCommand({
586
+ workDirs: expected.workDirs,
587
+ dashboardUrl: expected.dashboardUrl,
588
+ nodeExecutable: expected.nodeExecutable,
589
+ cliEntryPoint: expected.cliEntryPoint,
590
+ });
591
+ const expectedProgramArguments = [
592
+ " <key>ProgramArguments</key>",
593
+ " <array>",
594
+ " <string>/bin/zsh</string>",
595
+ " <string>-c</string>",
596
+ ` <string>${xmlEscape(expectedCommand)}</string>`,
597
+ " </array>",
598
+ ].join("\n");
599
+ if (!plist.includes(expectedProgramArguments)) {
600
+ problems.push("command does not match the current roots, dashboard URL, or Cockpit runtime");
601
+ }
602
+ return problems;
603
+ }
179
604
  function normalizeWorkDirs(repoRoots) {
180
605
  if (!repoRoots)
181
606
  return [];
@@ -190,6 +615,21 @@ function normalizeWorkDirs(repoRoots) {
190
615
  }
191
616
  return normalized;
192
617
  }
618
+ function normalizeWindowsWorkDirs(repoRoots) {
619
+ if (!repoRoots)
620
+ return [];
621
+ const seen = new Set();
622
+ const normalized = [];
623
+ for (const root of repoRoots) {
624
+ const resolved = path.win32.resolve(root);
625
+ const key = resolved.toLowerCase();
626
+ if (seen.has(key))
627
+ continue;
628
+ seen.add(key);
629
+ normalized.push(resolved);
630
+ }
631
+ return normalized;
632
+ }
193
633
  /**
194
634
  * Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
195
635
  * transcript dir exists yet (an empty array would tell launchd to watch