@bli-cockpit/cli 0.2.20 → 0.2.22
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 +125 -27
- package/dist/commands/local.js +105 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/scheduled-self-update.js +136 -0
- package/package.json +1 -1
package/dist/autostart.js
CHANGED
|
@@ -8,6 +8,21 @@ export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
|
8
8
|
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
9
9
|
const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
|
|
10
10
|
const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
|
|
11
|
+
/**
|
|
12
|
+
* Windowless launcher for the scheduled sync (BLI-2677). Task Scheduler ran
|
|
13
|
+
* powershell.exe directly under an interactive token, and PowerShell is a
|
|
14
|
+
* console-subsystem binary, so every 15-minute tick flashed a conhost window
|
|
15
|
+
* over whatever the operator was doing \u2014 on every Windows machine in the
|
|
16
|
+
* fleet. wscript.exe is a GUI-subsystem host, so it never allocates a console,
|
|
17
|
+
* and it starts the PowerShell child with window style 0 (hidden). S4U was
|
|
18
|
+
* considered and rejected: it breaks on Microsoft-account logons and the task
|
|
19
|
+
* deliberately stays on InteractiveToken with least privilege.
|
|
20
|
+
*/
|
|
21
|
+
const WINDOWS_AUTOSTART_LAUNCHER_NAME = "autostart-sync.vbs";
|
|
22
|
+
const WINDOWS_SYNC_LOG_NAME = "sync.log";
|
|
23
|
+
/** With the window hidden, the log is the only place output goes; cap it so an
|
|
24
|
+
* always-on machine syncing every 15 minutes cannot grow it without bound. */
|
|
25
|
+
const WINDOWS_SYNC_LOG_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
11
26
|
const UTF8_BOM = "\uFEFF";
|
|
12
27
|
export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
|
|
13
28
|
/**
|
|
@@ -179,6 +194,7 @@ async function installWindowsTask(options) {
|
|
|
179
194
|
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
180
195
|
const scriptPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
181
196
|
const registrationPath = path.join(getCollectorRuntimePaths(homeDir).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
197
|
+
const launcherPath = windowsAutostartLauncherPath(homeDir);
|
|
182
198
|
const nodeExecutable = path.win32.resolve(options.nodeExecutable ?? process.execPath);
|
|
183
199
|
const cliEntryPoint = path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? "");
|
|
184
200
|
await mkdir(path.dirname(scriptPath), { recursive: true });
|
|
@@ -188,10 +204,18 @@ async function installWindowsTask(options) {
|
|
|
188
204
|
dashboardUrl,
|
|
189
205
|
nodeExecutable,
|
|
190
206
|
cliEntryPoint,
|
|
207
|
+
syncLogPath: windowsSyncLogPath(homeDir),
|
|
191
208
|
})}`, "utf8");
|
|
209
|
+
// No BOM on the launcher: wscript.exe reads .vbs files as ANSI and treats a
|
|
210
|
+
// UTF-8 BOM as an invalid character. The rendered content is ASCII-safe for
|
|
211
|
+
// every path this fleet has.
|
|
212
|
+
await writeFile(launcherPath, renderWindowsLauncherScript({
|
|
213
|
+
powershellPath: windowsPowerShellPath(),
|
|
214
|
+
scriptPath,
|
|
215
|
+
}), "utf8");
|
|
192
216
|
await writeFile(registrationPath, `${UTF8_BOM}${renderWindowsRegistrationScript({
|
|
193
217
|
taskName: WINDOWS_AUTOSTART_TASK_NAME,
|
|
194
|
-
|
|
218
|
+
launcherPath,
|
|
195
219
|
intervalMinutes,
|
|
196
220
|
})}`, "utf8");
|
|
197
221
|
const created = await options.exec("powershell.exe", [
|
|
@@ -240,10 +264,12 @@ async function installWindowsTask(options) {
|
|
|
240
264
|
async function uninstallWindowsTask(options) {
|
|
241
265
|
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
242
266
|
const registrationPath = windowsAutostartRegistrationScriptPath(options.homeDir);
|
|
267
|
+
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
243
268
|
const current = await windowsTaskStatus(options);
|
|
244
269
|
if (current.status === "absent") {
|
|
245
270
|
await rm(scriptPath, { force: true });
|
|
246
271
|
await rm(registrationPath, { force: true });
|
|
272
|
+
await rm(launcherPath, { force: true });
|
|
247
273
|
return {
|
|
248
274
|
...current,
|
|
249
275
|
plist_path: scriptPath,
|
|
@@ -259,6 +285,7 @@ async function uninstallWindowsTask(options) {
|
|
|
259
285
|
if (removed.code === 0) {
|
|
260
286
|
await rm(scriptPath, { force: true });
|
|
261
287
|
await rm(registrationPath, { force: true });
|
|
288
|
+
await rm(launcherPath, { force: true });
|
|
262
289
|
}
|
|
263
290
|
return {
|
|
264
291
|
status: removed.code === 0 ? "uninstalled" : "not_loaded",
|
|
@@ -275,6 +302,7 @@ async function uninstallWindowsTask(options) {
|
|
|
275
302
|
}
|
|
276
303
|
async function windowsTaskStatus(options) {
|
|
277
304
|
const scriptPath = windowsAutostartScriptPath(options.homeDir);
|
|
305
|
+
const launcherPath = windowsAutostartLauncherPath(options.homeDir);
|
|
278
306
|
const query = await options.exec("schtasks.exe", [
|
|
279
307
|
"/Query",
|
|
280
308
|
"/TN",
|
|
@@ -304,9 +332,9 @@ async function windowsTaskStatus(options) {
|
|
|
304
332
|
const intervalSeconds = options.intervalSeconds ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS;
|
|
305
333
|
const intervalMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
306
334
|
const registrationProblems = windowsTaskRegistrationProblems(query.stdout, {
|
|
307
|
-
|
|
335
|
+
launcherPath,
|
|
308
336
|
intervalMinutes,
|
|
309
|
-
|
|
337
|
+
wscriptPath: windowsWScriptPath(),
|
|
310
338
|
});
|
|
311
339
|
if (disabled)
|
|
312
340
|
registrationProblems.unshift("task is disabled");
|
|
@@ -321,12 +349,32 @@ async function windowsTaskStatus(options) {
|
|
|
321
349
|
dashboardUrl: options.dashboardUrl ?? DEFAULT_DASHBOARD_URL,
|
|
322
350
|
nodeExecutable: path.win32.resolve(options.nodeExecutable ?? process.execPath),
|
|
323
351
|
cliEntryPoint: path.win32.resolve(options.cliEntryPoint ?? process.argv[1] ?? ""),
|
|
352
|
+
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
324
353
|
})}`;
|
|
325
354
|
const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
|
|
326
355
|
if (currentScript !== expectedScript) {
|
|
327
356
|
registrationProblems.push("sync script does not match the current roots or Cockpit runtime");
|
|
328
357
|
}
|
|
329
358
|
}
|
|
359
|
+
// Same shape as the sync-script checks: existence always, content only when
|
|
360
|
+
// the caller supplied roots (a bare `autostart status` stays permissive by
|
|
361
|
+
// design — see BLI-2362). The launcher's content depends on nothing but the
|
|
362
|
+
// PowerShell path and the sync-script path, but gating keeps the two
|
|
363
|
+
// commands' disagreement surface identical to the script check above.
|
|
364
|
+
const launcherExists = await fileExists(launcherPath);
|
|
365
|
+
if (!launcherExists) {
|
|
366
|
+
registrationProblems.push("sync launcher is missing");
|
|
367
|
+
}
|
|
368
|
+
else if (options.repoRoots && options.repoRoots.length > 0) {
|
|
369
|
+
const expectedLauncher = renderWindowsLauncherScript({
|
|
370
|
+
powershellPath: windowsPowerShellPath(),
|
|
371
|
+
scriptPath,
|
|
372
|
+
});
|
|
373
|
+
const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
|
|
374
|
+
if (currentLauncher !== expectedLauncher) {
|
|
375
|
+
registrationProblems.push("sync launcher does not match the current Cockpit runtime");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
330
378
|
// Both branches log. A line that only fires on failure cannot answer "did
|
|
331
379
|
// background collection validate at all today?", which is the question that
|
|
332
380
|
// would have caught a validator rejecting every healthy Windows task.
|
|
@@ -362,12 +410,23 @@ function windowsAutostartScriptPath(homeDir) {
|
|
|
362
410
|
function windowsAutostartRegistrationScriptPath(homeDir) {
|
|
363
411
|
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME);
|
|
364
412
|
}
|
|
413
|
+
function windowsAutostartLauncherPath(homeDir) {
|
|
414
|
+
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_LAUNCHER_NAME);
|
|
415
|
+
}
|
|
416
|
+
function windowsSyncLogPath(homeDir) {
|
|
417
|
+
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_SYNC_LOG_NAME);
|
|
418
|
+
}
|
|
365
419
|
function renderWindowsRegistrationScript(options) {
|
|
366
420
|
return [
|
|
367
421
|
"$ErrorActionPreference = 'Stop'",
|
|
368
422
|
`$taskName = ${powershellLiteral(options.taskName)}`,
|
|
369
|
-
`$
|
|
370
|
-
|
|
423
|
+
`$launcherPath = ${powershellLiteral(options.launcherPath)}`,
|
|
424
|
+
// wscript.exe is the windowless script host: the task runs the .vbs
|
|
425
|
+
// launcher, which starts PowerShell with its window hidden, so the
|
|
426
|
+
// 15-minute sync never flashes a terminal (BLI-2677). //B is batch mode —
|
|
427
|
+
// a script error becomes a nonzero exit instead of a modal dialog that
|
|
428
|
+
// would hang the scheduled run.
|
|
429
|
+
"$wscriptPath = Join-Path (Join-Path $env:SystemRoot 'System32') 'wscript.exe'",
|
|
371
430
|
// Windows PowerShell 5.1 passes a native argument's embedded quotes to
|
|
372
431
|
// CommandLineToArgvW unescaped, so a /TR value with bare quotes falls out
|
|
373
432
|
// of the quoted region at the first space inside a quoted path:
|
|
@@ -376,8 +435,8 @@ function renderWindowsRegistrationScript(options) {
|
|
|
376
435
|
// Escaping the embedded quotes as \" keeps the whole /TR value one
|
|
377
436
|
// argument; Task Scheduler still normalizes what it stores (BLI-2541),
|
|
378
437
|
// which the read-back validator already compares path-wise.
|
|
379
|
-
"$actionArgs = '
|
|
380
|
-
"$taskCommand = '\\\"' + $
|
|
438
|
+
"$actionArgs = '//B \\\"' + $launcherPath + '\\\"'",
|
|
439
|
+
"$taskCommand = '\\\"' + $wscriptPath + '\\\" ' + $actionArgs",
|
|
381
440
|
`& schtasks.exe /Create /TN $taskName /TR $taskCommand /SC MINUTE /MO ${options.intervalMinutes} /IT /RL LIMITED /F | Out-Null`,
|
|
382
441
|
"if ($LASTEXITCODE -ne 0) { throw \"schtasks /Create exited $LASTEXITCODE\" }",
|
|
383
442
|
"$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew",
|
|
@@ -385,6 +444,30 @@ function renderWindowsRegistrationScript(options) {
|
|
|
385
444
|
"",
|
|
386
445
|
].join("\r\n");
|
|
387
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* The .vbs launcher the scheduled task actually runs. WshShell.Run with window
|
|
449
|
+
* style 0 starts PowerShell with its console hidden — no flash, no focus
|
|
450
|
+
* steal — and waiting on the child propagates the sync exit code back to Task
|
|
451
|
+
* Scheduler as LastTaskResult. VBScript escapes a quote inside a string by
|
|
452
|
+
* doubling it; Windows paths cannot contain quotes, so the doubled quotes here
|
|
453
|
+
* are only the fixed delimiters around the two paths.
|
|
454
|
+
*/
|
|
455
|
+
function renderWindowsLauncherScript(options) {
|
|
456
|
+
const quotedPowershell = `""${options.powershellPath}""`;
|
|
457
|
+
const quotedScript = `""${options.scriptPath}""`;
|
|
458
|
+
return [
|
|
459
|
+
"' BLI Cockpit sync launcher (BLI-2677). wscript.exe never allocates a",
|
|
460
|
+
"' console and starts PowerShell hidden, so the 15-minute background sync",
|
|
461
|
+
"' does not flash a terminal over the operator. Sync output lands in",
|
|
462
|
+
"' sync.log next to this file instead of a visible window.",
|
|
463
|
+
"Option Explicit",
|
|
464
|
+
"Dim windowsShell, syncExitCode",
|
|
465
|
+
'Set windowsShell = CreateObject("WScript.Shell")',
|
|
466
|
+
`syncExitCode = windowsShell.Run("${quotedPowershell} -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ${quotedScript}", 0, True)`,
|
|
467
|
+
"WScript.Quit syncExitCode",
|
|
468
|
+
"",
|
|
469
|
+
].join("\r\n");
|
|
470
|
+
}
|
|
388
471
|
function renderWindowsSyncScript(options) {
|
|
389
472
|
const dashboardArgs = options.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
390
473
|
? ""
|
|
@@ -392,18 +475,29 @@ function renderWindowsSyncScript(options) {
|
|
|
392
475
|
const discoveryArgs = options.discoveryArgs.length > 0 ? ` ${options.discoveryArgs.join(" ")}` : "";
|
|
393
476
|
const commands = options.workDirs.flatMap((root) => [
|
|
394
477
|
"try {",
|
|
395
|
-
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json`,
|
|
478
|
+
` & $nodeExecutable $cliEntryPoint sync --workspace ${powershellLiteral(root)}${dashboardArgs}${discoveryArgs} --json 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $syncLogPath -Encoding UTF8`,
|
|
396
479
|
" if ($LASTEXITCODE -ne 0) { $exitCode = $LASTEXITCODE }",
|
|
397
480
|
"} catch {",
|
|
398
|
-
"
|
|
481
|
+
" Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
|
|
399
482
|
" $exitCode = 1",
|
|
400
483
|
"}",
|
|
401
484
|
]);
|
|
402
485
|
return [
|
|
403
|
-
|
|
486
|
+
// 'Continue', not 'Stop': the collector logs to stderr on success as well
|
|
487
|
+
// as failure (that is the repo's logging contract), and Windows PowerShell
|
|
488
|
+
// 5.1 under 'Stop' turns the first redirected stderr line of a native
|
|
489
|
+
// command into a terminating NativeCommandError — 'Stop' would kill every
|
|
490
|
+
// healthy run at its first log line. Failures are tracked by exit code.
|
|
491
|
+
"$ErrorActionPreference = 'Continue'",
|
|
404
492
|
"$exitCode = 0",
|
|
405
493
|
`$nodeExecutable = ${powershellLiteral(options.nodeExecutable)}`,
|
|
406
494
|
`$cliEntryPoint = ${powershellLiteral(options.cliEntryPoint)}`,
|
|
495
|
+
// The task window is hidden (BLI-2677), so this log is the only place the
|
|
496
|
+
// sync's output exists; sync.err.log stays macOS-only because launchd does
|
|
497
|
+
// stream separation for free and PowerShell 5.1 does not.
|
|
498
|
+
`$syncLogPath = ${powershellLiteral(options.syncLogPath)}`,
|
|
499
|
+
`$syncLogFile = Get-Item -LiteralPath $syncLogPath -ErrorAction SilentlyContinue`,
|
|
500
|
+
`if ($syncLogFile -and $syncLogFile.Length -gt ${WINDOWS_SYNC_LOG_LIMIT_BYTES}) { Set-Content -LiteralPath $syncLogPath -Value '' -Encoding UTF8 }`,
|
|
407
501
|
...commands,
|
|
408
502
|
"exit $exitCode",
|
|
409
503
|
"",
|
|
@@ -462,7 +556,7 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
|
462
556
|
const actionArguments = exactExecBlock
|
|
463
557
|
.match(/<Arguments>\s*([^<]*?)\s*<\/Arguments>/iu)?.[1]
|
|
464
558
|
?.trim() ?? "";
|
|
465
|
-
const expectedArgumentFlags = "
|
|
559
|
+
const expectedArgumentFlags = "//B";
|
|
466
560
|
const requirements = [
|
|
467
561
|
[
|
|
468
562
|
/<DisallowStartIfOnBatteries>\s*false\s*<\/DisallowStartIfOnBatteries>/iu,
|
|
@@ -531,10 +625,10 @@ function windowsTaskRegistrationProblems(taskXml, expected) {
|
|
|
531
625
|
if (!exactExecBlock) {
|
|
532
626
|
problems.push("task must contain exactly one Exec action");
|
|
533
627
|
}
|
|
534
|
-
if (!sameWindowsPath(command, expected.
|
|
535
|
-
problems.push("task action does not run the expected
|
|
628
|
+
if (!sameWindowsPath(command, expected.wscriptPath)) {
|
|
629
|
+
problems.push("task action does not run the expected windowless script host");
|
|
536
630
|
}
|
|
537
|
-
const actionArgumentProblem = windowsActionArgumentProblem(actionArguments, expectedArgumentFlags, expected.
|
|
631
|
+
const actionArgumentProblem = windowsActionArgumentProblem(actionArguments, expectedArgumentFlags, expected.launcherPath);
|
|
538
632
|
if (actionArgumentProblem) {
|
|
539
633
|
problems.push(actionArgumentProblem);
|
|
540
634
|
}
|
|
@@ -547,30 +641,34 @@ function windowsPowerShellPath(env = process.env) {
|
|
|
547
641
|
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
548
642
|
return path.win32.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
549
643
|
}
|
|
644
|
+
function windowsWScriptPath(env = process.env) {
|
|
645
|
+
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
|
646
|
+
return path.win32.join(windowsRoot, "System32", "wscript.exe");
|
|
647
|
+
}
|
|
550
648
|
/**
|
|
551
649
|
* Task Scheduler stores a normalized form of the action it was given, not the
|
|
552
|
-
* string we passed: `schtasks /Create /TR "<
|
|
650
|
+
* string we passed: `schtasks /Create /TR "<host> <args>"` is split into
|
|
553
651
|
* <Command> plus <Arguments>, and the quotes around a space-free script path
|
|
554
652
|
* are dropped. Comparing <Arguments> against the exact string we built
|
|
555
653
|
* therefore fails on a task that is registered perfectly — observed on
|
|
556
654
|
* DESKTOP-G2UO1GK (CLI 0.2.13, 2026-08-12), where the stored value differed
|
|
557
|
-
* from the expected one only by those quotes.
|
|
655
|
+
* from the expected one only by those quotes (BLI-2541).
|
|
558
656
|
*
|
|
559
|
-
* Compare the flags exactly, because those are ours, and compare the
|
|
560
|
-
* a path, because that is what Windows normalizes. Each failure returns its
|
|
561
|
-
* reason so a repair message says which half is wrong rather than
|
|
562
|
-
* differ".
|
|
657
|
+
* Compare the flags exactly, because those are ours, and compare the launcher
|
|
658
|
+
* as a path, because that is what Windows normalizes. Each failure returns its
|
|
659
|
+
* own reason so a repair message says which half is wrong rather than
|
|
660
|
+
* "arguments differ".
|
|
563
661
|
*/
|
|
564
|
-
function windowsActionArgumentProblem(actionArguments, expectedFlags,
|
|
662
|
+
function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
|
|
565
663
|
if (!actionArguments.startsWith(expectedFlags)) {
|
|
566
|
-
return "task action does not run
|
|
664
|
+
return "task action does not run the launcher with the expected batch-mode flag";
|
|
567
665
|
}
|
|
568
|
-
const
|
|
569
|
-
if (!
|
|
570
|
-
return "task action names no sync
|
|
666
|
+
const launcherArgument = unquoteWindowsArgument(actionArguments.slice(expectedFlags.length));
|
|
667
|
+
if (!launcherArgument) {
|
|
668
|
+
return "task action names no sync launcher to run";
|
|
571
669
|
}
|
|
572
|
-
if (!sameWindowsPath(
|
|
573
|
-
return "task action runs a script other than the Cockpit sync
|
|
670
|
+
if (!sameWindowsPath(launcherArgument, expectedLauncherPath)) {
|
|
671
|
+
return "task action runs a script other than the Cockpit sync launcher";
|
|
574
672
|
}
|
|
575
673
|
return null;
|
|
576
674
|
}
|
package/dist/commands/local.js
CHANGED
|
@@ -21,6 +21,7 @@ import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limit
|
|
|
21
21
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
22
22
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
23
23
|
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
24
|
+
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
24
25
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
25
26
|
import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
|
|
26
27
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -250,6 +251,9 @@ function localSubcommandHelp(command) {
|
|
|
250
251
|
"Newly discovered repos get a general ambient work context automatically.",
|
|
251
252
|
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
252
253
|
"--max-depth and --max-repos.",
|
|
254
|
+
"Also self-updates the CLI from npm latest once per day, strictly after",
|
|
255
|
+
"collection finishes; set COCKPIT_DISABLE_AUTO_UPDATE=1 to freeze the",
|
|
256
|
+
"installed version during incident triage.",
|
|
253
257
|
],
|
|
254
258
|
],
|
|
255
259
|
[
|
|
@@ -2149,6 +2153,9 @@ async function runSync(command, io) {
|
|
|
2149
2153
|
json: command.json,
|
|
2150
2154
|
io,
|
|
2151
2155
|
});
|
|
2156
|
+
// BLI-2601: self-update runs only after collection's own outcome above is
|
|
2157
|
+
// already decided and reported, win or lose. See the function doc.
|
|
2158
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
|
|
2152
2159
|
return result.exitCode;
|
|
2153
2160
|
}
|
|
2154
2161
|
catch (error) {
|
|
@@ -2167,9 +2174,107 @@ async function runSync(command, io) {
|
|
|
2167
2174
|
json: command.json,
|
|
2168
2175
|
io,
|
|
2169
2176
|
});
|
|
2177
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
|
|
2170
2178
|
throw error;
|
|
2171
2179
|
}
|
|
2172
2180
|
}
|
|
2181
|
+
/**
|
|
2182
|
+
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
2183
|
+
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
|
2184
|
+
* runs AFTER `runSync` has already decided and reported collection's own
|
|
2185
|
+
* outcome above — a stuck or failing self-update can never block or delay
|
|
2186
|
+
* collection, and a collection failure never blocks the chance to
|
|
2187
|
+
* self-update. Every error path here is swallowed on purpose: a failure is
|
|
2188
|
+
* reported as its own named `update` receipt, never surfaced as a `sync`
|
|
2189
|
+
* failure or thrown from this function.
|
|
2190
|
+
*/
|
|
2191
|
+
async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
|
|
2192
|
+
let event;
|
|
2193
|
+
try {
|
|
2194
|
+
event = await runScheduledSelfUpdateForSync(command, io);
|
|
2195
|
+
}
|
|
2196
|
+
catch (error) {
|
|
2197
|
+
// The throttle/probe/install machinery below is defensive already; this
|
|
2198
|
+
// is the last-resort net so an update crash truly cannot touch the sync
|
|
2199
|
+
// result above.
|
|
2200
|
+
event = {
|
|
2201
|
+
step: "update",
|
|
2202
|
+
status: "fail",
|
|
2203
|
+
error_code: "self_update_threw",
|
|
2204
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
if (!event)
|
|
2208
|
+
return;
|
|
2209
|
+
await reportInstallEventsBestEffort({
|
|
2210
|
+
homeDir: command.homeDir,
|
|
2211
|
+
dashboardUrl,
|
|
2212
|
+
command: "update",
|
|
2213
|
+
events: [event],
|
|
2214
|
+
json: command.json,
|
|
2215
|
+
io,
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
async function runScheduledSelfUpdateForSync(command, io) {
|
|
2219
|
+
const rawExec = io.exec;
|
|
2220
|
+
if (!rawExec) {
|
|
2221
|
+
// Only the real production `defaultIo()` supplies a process runner. A
|
|
2222
|
+
// caller that omitted one gets a silent no-op rather than this reaching
|
|
2223
|
+
// for a real npm binary it was never given — never observed in
|
|
2224
|
+
// production, where `defaultIo()` always sets `exec`.
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
// Every spawn in the scheduled path carries the running node's bin dir on
|
|
2228
|
+
// PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
|
|
2229
|
+
// this; the scheduler's stripped environment does.
|
|
2230
|
+
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
2231
|
+
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
2232
|
+
const scheduledIo = { ...io, exec };
|
|
2233
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
2234
|
+
const result = await runScheduledSelfUpdate(paths, {
|
|
2235
|
+
exec,
|
|
2236
|
+
currentVersion: LOCAL_COLLECTOR_VERSION,
|
|
2237
|
+
install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
|
|
2238
|
+
}, { env: io.env });
|
|
2239
|
+
return scheduledSelfUpdateInstallEvent(result);
|
|
2240
|
+
}
|
|
2241
|
+
async function attemptScheduledSelfUpdateInstall(io, tag) {
|
|
2242
|
+
try {
|
|
2243
|
+
// Reuses the exact npm-install machinery `cockpit doctor`'s
|
|
2244
|
+
// `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
|
|
2245
|
+
// knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
|
|
2246
|
+
// this call never re-execs — see runScheduledSelfUpdate's doc comment.
|
|
2247
|
+
await runSelfUpdate(io, { json: true, tag });
|
|
2248
|
+
return { ok: true };
|
|
2249
|
+
}
|
|
2250
|
+
catch (error) {
|
|
2251
|
+
if (!(error instanceof SelfUpdateError))
|
|
2252
|
+
throw error;
|
|
2253
|
+
return { ok: false, eacces: error.eacces };
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function scheduledSelfUpdateInstallEvent(result) {
|
|
2257
|
+
// The steady-state "already checked today" case is a pure no-op; reporting
|
|
2258
|
+
// it would post a receipt on ~95 of every 96 sync ticks for no new
|
|
2259
|
+
// information. Only a real attempt (ok, fail, or an explicit disable)
|
|
2260
|
+
// produces a receipt.
|
|
2261
|
+
if (result.reason === "throttled_recent_attempt")
|
|
2262
|
+
return null;
|
|
2263
|
+
if (result.status === "ok")
|
|
2264
|
+
return { step: "update", status: "ok" };
|
|
2265
|
+
const detail = [
|
|
2266
|
+
result.target_version ? `target ${result.target_version}` : null,
|
|
2267
|
+
result.installed_version ? `installed ${result.installed_version}` : null,
|
|
2268
|
+
]
|
|
2269
|
+
.filter((part) => Boolean(part))
|
|
2270
|
+
.join("; ");
|
|
2271
|
+
return {
|
|
2272
|
+
step: "update",
|
|
2273
|
+
status: result.status,
|
|
2274
|
+
error_code: result.reason,
|
|
2275
|
+
...(detail ? { error_detail: detail } : {}),
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2173
2278
|
async function runSyncWithHealthReceipt(command, io) {
|
|
2174
2279
|
const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
|
|
2175
2280
|
if (backfillLock.held) {
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.22");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SELF_UPDATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
export const SELF_UPDATE_THROTTLE_MARKER = ".last-self-update-check";
|
|
5
|
+
/**
|
|
6
|
+
* launchd starts the scheduled tick with a bare PATH (`/usr/bin:/bin:...`) —
|
|
7
|
+
* the plist hardcodes absolute node/CLI paths for exactly that reason — so a
|
|
8
|
+
* bare `npm` spawn that works in every interactive shell resolves to nothing
|
|
9
|
+
* inside the tick, and every scheduled update would fail `npm_install_failed`
|
|
10
|
+
* on the whole Mac fleet while interactive `cockpit doctor` kept working. The
|
|
11
|
+
* npm shim ships beside the node binary in the standard layouts on both host
|
|
12
|
+
* families, so the running node's own directory is the one PATH entry that is
|
|
13
|
+
* always right.
|
|
14
|
+
*/
|
|
15
|
+
export function envWithNodeRuntimeOnPath(env, nodeExecutable = process.execPath) {
|
|
16
|
+
const nodeBinDir = path.dirname(nodeExecutable);
|
|
17
|
+
const currentPath = env["PATH"] ?? "";
|
|
18
|
+
if (currentPath.split(path.delimiter).includes(nodeBinDir))
|
|
19
|
+
return env;
|
|
20
|
+
return {
|
|
21
|
+
...env,
|
|
22
|
+
PATH: [nodeBinDir, currentPath].filter(Boolean).join(path.delimiter),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* BLI-2601. Runs at most once per day from inside `cockpit sync`'s
|
|
27
|
+
* post-collection tail, so the fleet converges on npm `latest` without
|
|
28
|
+
* anyone re-running `npm i -g @bli-cockpit/cli` by hand after day 0.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately does not re-exec: this process already has the OLD code
|
|
31
|
+
* loaded in memory, so nothing in-process could prove a re-exec actually
|
|
32
|
+
* picked up the new build. The next scheduled tick launches a fresh
|
|
33
|
+
* `cockpit` process from disk and picks up whatever npm actually installed —
|
|
34
|
+
* that is the verification, not a re-exec here.
|
|
35
|
+
*/
|
|
36
|
+
export async function runScheduledSelfUpdate(paths, deps, options = {}) {
|
|
37
|
+
const env = options.env ?? process.env;
|
|
38
|
+
const now = options.now ?? new Date();
|
|
39
|
+
const tag = options.tag ?? "latest";
|
|
40
|
+
const marker = path.join(paths.state_dir, SELF_UPDATE_THROTTLE_MARKER);
|
|
41
|
+
const lastCheck = await fs.stat(marker).catch(() => null);
|
|
42
|
+
if (lastCheck && now.getTime() - lastCheck.mtimeMs < SELF_UPDATE_MIN_INTERVAL_MS) {
|
|
43
|
+
// The steady-state case: already checked today, nothing to do. Not
|
|
44
|
+
// reported as an "attempt" by the caller — this fires on ~95 of every 96
|
|
45
|
+
// ticks and would otherwise be pure noise.
|
|
46
|
+
return { status: "skipped", reason: "throttled_recent_attempt" };
|
|
47
|
+
}
|
|
48
|
+
// The marker is written for the attempt, not the outcome — same idiom as
|
|
49
|
+
// raw-evidence GC (raw-evidence-gc.ts). A machine stuck on a permissions
|
|
50
|
+
// error must not spend every 15-min tick re-hitting the npm registry.
|
|
51
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
52
|
+
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
53
|
+
// Incident triage freeze: an operator can pin a machine's installed
|
|
54
|
+
// version while debugging without touching the scheduler itself. Mirrors
|
|
55
|
+
// COCKPIT_DISABLE_GC. Checked after the throttle write on purpose, so a
|
|
56
|
+
// disabled machine reports itself at most once per day too, instead of on
|
|
57
|
+
// every tick.
|
|
58
|
+
if (env["COCKPIT_DISABLE_AUTO_UPDATE"] === "1") {
|
|
59
|
+
return { status: "skipped", reason: "disabled_by_env" };
|
|
60
|
+
}
|
|
61
|
+
const targetVersion = await latestCliVersionFromNpm(deps.exec, tag);
|
|
62
|
+
if (targetVersion && targetVersion === deps.currentVersion) {
|
|
63
|
+
return { status: "ok", reason: "already_latest", target_version: targetVersion };
|
|
64
|
+
}
|
|
65
|
+
const installed = await deps.install(tag);
|
|
66
|
+
if (!installed.ok) {
|
|
67
|
+
return {
|
|
68
|
+
status: "fail",
|
|
69
|
+
reason: installed.eacces ? "eacces_needs_chown" : "npm_install_failed",
|
|
70
|
+
...(targetVersion ? { target_version: targetVersion } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Verify against what the platform returns, not the exit code npm handed
|
|
74
|
+
// back (BLI-2541 lesson: a green exit code is not proof). Read the
|
|
75
|
+
// globally installed package's version back off disk via `npm ls -g
|
|
76
|
+
// --json` rather than trusting that install succeeding means the version
|
|
77
|
+
// this process expected is what is actually there.
|
|
78
|
+
const installedVersion = await probeInstalledCliVersion(deps.exec);
|
|
79
|
+
if (!installedVersion || (targetVersion && installedVersion !== targetVersion)) {
|
|
80
|
+
return {
|
|
81
|
+
status: "fail",
|
|
82
|
+
reason: "stale_after_self_update",
|
|
83
|
+
...(targetVersion ? { target_version: targetVersion } : {}),
|
|
84
|
+
...(installedVersion ? { installed_version: installedVersion } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
status: "ok",
|
|
89
|
+
reason: "updated",
|
|
90
|
+
target_version: targetVersion ?? installedVersion,
|
|
91
|
+
installed_version: installedVersion,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function latestCliVersionFromNpm(exec, tag) {
|
|
95
|
+
const result = await exec("npm", [
|
|
96
|
+
"view",
|
|
97
|
+
`@bli-cockpit/cli@${tag}`,
|
|
98
|
+
"version",
|
|
99
|
+
"--json",
|
|
100
|
+
]);
|
|
101
|
+
if (result.code !== 0)
|
|
102
|
+
return null;
|
|
103
|
+
return parseNpmVersionField(result.stdout);
|
|
104
|
+
}
|
|
105
|
+
async function probeInstalledCliVersion(exec) {
|
|
106
|
+
// `npm ls` can exit non-zero on unrelated extraneous/peer-dependency
|
|
107
|
+
// warnings even when the requested package resolved cleanly, so the JSON
|
|
108
|
+
// body is parsed regardless of exit code — the printed tree is the ground
|
|
109
|
+
// truth here, not the exit code.
|
|
110
|
+
const result = await exec("npm", ["ls", "-g", "@bli-cockpit/cli", "--json"]);
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(result.stdout);
|
|
113
|
+
const version = parsed.dependencies?.["@bli-cockpit/cli"]?.version;
|
|
114
|
+
return typeof version === "string" && version.trim() ? version.trim() : null;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Small, intentional duplication of doctor.ts's `parseNpmVersion`: importing
|
|
121
|
+
// it here would create a cycle (doctor.ts already imports types from
|
|
122
|
+
// commands/local.ts, which would need to import this file to wire the sync
|
|
123
|
+
// integration). The parsing itself is a few lines and has no behavior this
|
|
124
|
+
// module doesn't already own.
|
|
125
|
+
function parseNpmVersionField(stdout) {
|
|
126
|
+
const trimmed = stdout.trim();
|
|
127
|
+
if (!trimmed)
|
|
128
|
+
return null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(trimmed);
|
|
131
|
+
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
135
|
+
}
|
|
136
|
+
}
|