@bli-cockpit/cli 0.2.21 → 0.2.23

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
@@ -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
- scriptPath,
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
- scriptPath,
335
+ launcherPath,
308
336
  intervalMinutes,
309
- powershellPath: windowsPowerShellPath(),
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
- `$syncScriptPath = ${powershellLiteral(options.scriptPath)}`,
370
- "$powershellPath = Join-Path $PSHOME 'powershell.exe'",
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 = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \\\"' + $syncScriptPath + '\\\"'",
380
- "$taskCommand = '\\\"' + $powershellPath + '\\\" ' + $actionArgs",
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
- " [Console]::Error.WriteLine($_.Exception.Message)",
481
+ " Add-Content -LiteralPath $syncLogPath -Encoding UTF8 -Value $_.Exception.Message",
399
482
  " $exitCode = 1",
400
483
  "}",
401
484
  ]);
402
485
  return [
403
- "$ErrorActionPreference = 'Stop'",
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 = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File";
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.powershellPath)) {
535
- problems.push("task action does not run the expected Windows PowerShell");
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.scriptPath);
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 "<powershell> <args>"` is split into
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 script as
560
- * a path, because that is what Windows normalizes. Each failure returns its own
561
- * reason so a repair message says which half is wrong rather than "arguments
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, expectedScriptPath) {
662
+ function windowsActionArgumentProblem(actionArguments, expectedFlags, expectedLauncherPath) {
565
663
  if (!actionArguments.startsWith(expectedFlags)) {
566
- return "task action does not run PowerShell with the expected Cockpit sync flags";
664
+ return "task action does not run the launcher with the expected batch-mode flag";
567
665
  }
568
- const scriptArgument = unquoteWindowsArgument(actionArguments.slice(expectedFlags.length));
569
- if (!scriptArgument) {
570
- return "task action names no sync script to run";
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(scriptArgument, expectedScriptPath)) {
573
- return "task action runs a script other than the Cockpit sync script";
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
  }
@@ -711,9 +711,16 @@ function sanitizeInstallErrorCode(value) {
711
711
  .slice(0, 120);
712
712
  return normalized || "unknown";
713
713
  }
714
+ /**
715
+ * Posts pending install events. Also the collector's only per-tick listening
716
+ * post: the response carries the server-published `min_cli_version` floor
717
+ * (BLI-2678), so the last one observed is returned for the scheduled
718
+ * self-update step to act on. Every early-out returns null — no receipt, no
719
+ * floor.
720
+ */
714
721
  export async function reportInstallEventsBestEffort(options) {
715
722
  if (options.events.length === 0)
716
- return;
723
+ return null;
717
724
  const paths = getCollectorRuntimePaths(options.homeDir);
718
725
  try {
719
726
  await enqueueInstallEventEntry(paths, {
@@ -744,17 +751,18 @@ export async function reportInstallEventsBestEffort(options) {
744
751
  if (options.json) {
745
752
  writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
746
753
  }
747
- return;
754
+ return null;
748
755
  }
749
756
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
750
757
  if (!session ||
751
758
  session.session_state !== "valid" ||
752
759
  typeof session.device_token !== "string" ||
753
760
  !session.device_token) {
754
- return;
761
+ return null;
755
762
  }
756
763
  const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
757
764
  const failures = [];
765
+ let observedMinCliVersion = null;
758
766
  for (let offset = 0; offset < pending.length; offset += 5) {
759
767
  await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
760
768
  const controller = new AbortController();
@@ -777,6 +785,13 @@ export async function reportInstallEventsBestEffort(options) {
777
785
  if (!response.ok) {
778
786
  throw new Error(`http_${response.status}`);
779
787
  }
788
+ const receipt = (await response
789
+ .json()
790
+ .catch(() => null));
791
+ if (typeof receipt?.min_cli_version === "string" &&
792
+ receipt.min_cli_version.trim()) {
793
+ observedMinCliVersion = receipt.min_cli_version.trim();
794
+ }
780
795
  await removeInstallEventEntry(paths, entry.outbox_id);
781
796
  }
782
797
  catch (error) {
@@ -795,6 +810,7 @@ export async function reportInstallEventsBestEffort(options) {
795
810
  if (options.json && failures.length > 0) {
796
811
  writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
797
812
  }
813
+ return observedMinCliVersion;
798
814
  }
799
815
  function classifyInstallTelemetryError(error) {
800
816
  if (error instanceof Error && error.name === "AbortError") {
@@ -2135,7 +2151,7 @@ async function runSync(command, io) {
2135
2151
  const paths = getCollectorRuntimePaths(command.homeDir);
2136
2152
  const config = await readLocalCollectorConfig(paths).catch(() => null);
2137
2153
  const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
2138
- await reportInstallEventsBestEffort({
2154
+ const minCliVersionAtStart = await reportInstallEventsBestEffort({
2139
2155
  homeDir: command.homeDir,
2140
2156
  dashboardUrl,
2141
2157
  command: "sync",
@@ -2145,7 +2161,7 @@ async function runSync(command, io) {
2145
2161
  });
2146
2162
  try {
2147
2163
  const result = await runSyncWithHealthReceipt(command, io);
2148
- await reportInstallEventsBestEffort({
2164
+ const minCliVersion = await reportInstallEventsBestEffort({
2149
2165
  homeDir: command.homeDir,
2150
2166
  dashboardUrl,
2151
2167
  command: "sync",
@@ -2155,11 +2171,11 @@ async function runSync(command, io) {
2155
2171
  });
2156
2172
  // BLI-2601: self-update runs only after collection's own outcome above is
2157
2173
  // already decided and reported, win or lose. See the function doc.
2158
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
2174
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2159
2175
  return result.exitCode;
2160
2176
  }
2161
2177
  catch (error) {
2162
- await reportInstallEventsBestEffort({
2178
+ const minCliVersion = await reportInstallEventsBestEffort({
2163
2179
  homeDir: command.homeDir,
2164
2180
  dashboardUrl,
2165
2181
  command: "sync",
@@ -2174,7 +2190,7 @@ async function runSync(command, io) {
2174
2190
  json: command.json,
2175
2191
  io,
2176
2192
  });
2177
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
2193
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2178
2194
  throw error;
2179
2195
  }
2180
2196
  }
@@ -2188,10 +2204,10 @@ async function runSync(command, io) {
2188
2204
  * reported as its own named `update` receipt, never surfaced as a `sync`
2189
2205
  * failure or thrown from this function.
2190
2206
  */
2191
- async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
2207
+ async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
2192
2208
  let event;
2193
2209
  try {
2194
- event = await runScheduledSelfUpdateForSync(command, io);
2210
+ event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
2195
2211
  }
2196
2212
  catch (error) {
2197
2213
  // The throttle/probe/install machinery below is defensive already; this
@@ -2215,7 +2231,7 @@ async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
2215
2231
  io,
2216
2232
  });
2217
2233
  }
2218
- async function runScheduledSelfUpdateForSync(command, io) {
2234
+ async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
2219
2235
  const rawExec = io.exec;
2220
2236
  if (!rawExec) {
2221
2237
  // Only the real production `defaultIo()` supplies a process runner. A
@@ -2235,7 +2251,7 @@ async function runScheduledSelfUpdateForSync(command, io) {
2235
2251
  exec,
2236
2252
  currentVersion: LOCAL_COLLECTOR_VERSION,
2237
2253
  install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
2238
- }, { env: io.env });
2254
+ }, { env: io.env, minVersion: minCliVersion });
2239
2255
  return scheduledSelfUpdateInstallEvent(result);
2240
2256
  }
2241
2257
  async function attemptScheduledSelfUpdateInstall(io, tag) {
@@ -2260,9 +2276,21 @@ function scheduledSelfUpdateInstallEvent(result) {
2260
2276
  // produces a receipt.
2261
2277
  if (result.reason === "throttled_recent_attempt")
2262
2278
  return null;
2263
- if (result.status === "ok")
2264
- return { step: "update", status: "ok" };
2279
+ // A forced attempt names its trigger in the receipt either way, so the
2280
+ // ledger can tell "converged on the daily cadence" from "the floor pulled
2281
+ // this machine forward" (BLI-2678).
2282
+ const forcedDetail = result.forced && result.min_version
2283
+ ? `forced_min_version ${result.min_version}`
2284
+ : null;
2285
+ if (result.status === "ok") {
2286
+ return {
2287
+ step: "update",
2288
+ status: "ok",
2289
+ ...(forcedDetail ? { error_detail: forcedDetail } : {}),
2290
+ };
2291
+ }
2265
2292
  const detail = [
2293
+ forcedDetail,
2266
2294
  result.target_version ? `target ${result.target_version}` : null,
2267
2295
  result.installed_version ? `installed ${result.installed_version}` : null,
2268
2296
  ]
@@ -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.21");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.23");
19
19
  return 0;
20
20
  }
21
21
 
@@ -37,9 +37,22 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
37
37
  const env = options.env ?? process.env;
38
38
  const now = options.now ?? new Date();
39
39
  const tag = options.tag ?? "latest";
40
+ // BLI-2678: the fleet forced-update floor. When the server says the minimum
41
+ // is above what this process is running, the daily throttle stops applying —
42
+ // the whole point of the floor is converging faster than once a day. This is
43
+ // NOT remote code execution: being below the floor only makes the exact
44
+ // same npm self-update that runs daily anyway run now. An unparseable floor
45
+ // is ignored rather than obeyed — a typo must fail toward the safe default.
46
+ const minVersion = parseSemverTriple(options.minVersion ?? null)
47
+ ? options.minVersion.trim()
48
+ : null;
49
+ const forced = minVersion !== null && isSemverBelow(deps.currentVersion, minVersion);
50
+ const forcedFields = forced ? { forced: true, min_version: minVersion } : {};
40
51
  const marker = path.join(paths.state_dir, SELF_UPDATE_THROTTLE_MARKER);
41
52
  const lastCheck = await fs.stat(marker).catch(() => null);
42
- if (lastCheck && now.getTime() - lastCheck.mtimeMs < SELF_UPDATE_MIN_INTERVAL_MS) {
53
+ if (!forced &&
54
+ lastCheck &&
55
+ now.getTime() - lastCheck.mtimeMs < SELF_UPDATE_MIN_INTERVAL_MS) {
43
56
  // The steady-state case: already checked today, nothing to do. Not
44
57
  // reported as an "attempt" by the caller — this fires on ~95 of every 96
45
58
  // ticks and would otherwise be pure noise.
@@ -56,11 +69,33 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
56
69
  // disabled machine reports itself at most once per day too, instead of on
57
70
  // every tick.
58
71
  if (env["COCKPIT_DISABLE_AUTO_UPDATE"] === "1") {
59
- return { status: "skipped", reason: "disabled_by_env" };
72
+ // The incident-triage freeze outranks the forced floor: a human pinned
73
+ // this machine on purpose, and the server must not be able to unpin it.
74
+ return { status: "skipped", reason: "disabled_by_env", ...forcedFields };
60
75
  }
61
76
  const targetVersion = await latestCliVersionFromNpm(deps.exec, tag);
77
+ if (forced &&
78
+ targetVersion &&
79
+ isSemverBelow(targetVersion, minVersion)) {
80
+ // The floor is above what npm actually serves — a typo'd floor, or one set
81
+ // before the release finished publishing. Installing would not clear the
82
+ // floor, so every forced tick would run `npm install` forever. Skip loudly
83
+ // instead: this reason posts a receipt on every tick until the floor is
84
+ // corrected, which is exactly the alarm a misconfig should raise.
85
+ return {
86
+ status: "skipped",
87
+ reason: "min_version_above_npm_latest",
88
+ target_version: targetVersion,
89
+ ...forcedFields,
90
+ };
91
+ }
62
92
  if (targetVersion && targetVersion === deps.currentVersion) {
63
- return { status: "ok", reason: "already_latest", target_version: targetVersion };
93
+ return {
94
+ status: "ok",
95
+ reason: "already_latest",
96
+ target_version: targetVersion,
97
+ ...forcedFields,
98
+ };
64
99
  }
65
100
  const installed = await deps.install(tag);
66
101
  if (!installed.ok) {
@@ -68,6 +103,7 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
68
103
  status: "fail",
69
104
  reason: installed.eacces ? "eacces_needs_chown" : "npm_install_failed",
70
105
  ...(targetVersion ? { target_version: targetVersion } : {}),
106
+ ...forcedFields,
71
107
  };
72
108
  }
73
109
  // Verify against what the platform returns, not the exit code npm handed
@@ -82,6 +118,7 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
82
118
  reason: "stale_after_self_update",
83
119
  ...(targetVersion ? { target_version: targetVersion } : {}),
84
120
  ...(installedVersion ? { installed_version: installedVersion } : {}),
121
+ ...forcedFields,
85
122
  };
86
123
  }
87
124
  return {
@@ -89,8 +126,37 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
89
126
  reason: "updated",
90
127
  target_version: targetVersion ?? installedVersion,
91
128
  installed_version: installedVersion,
129
+ ...forcedFields,
92
130
  };
93
131
  }
132
+ /**
133
+ * Ordering comparison for the forced-update floor. Mirrors the dashboard's
134
+ * parseSemver/compareParsedSemver (apps/dashboard/src/lib/ambient/rollups.ts)
135
+ * rather than adding a `semver` dependency for two ten-line functions. A
136
+ * version either side fails to parse → false: an unreadable floor or version
137
+ * must never force anything.
138
+ */
139
+ function parseSemverTriple(value) {
140
+ if (typeof value !== "string")
141
+ return null;
142
+ const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)/u);
143
+ if (!match)
144
+ return null;
145
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
146
+ }
147
+ function isSemverBelow(left, right) {
148
+ const parsedLeft = parseSemverTriple(left);
149
+ const parsedRight = parseSemverTriple(right);
150
+ if (!parsedLeft || !parsedRight)
151
+ return false;
152
+ for (let part = 0; part < 3; part += 1) {
153
+ if ((parsedLeft[part] ?? 0) < (parsedRight[part] ?? 0))
154
+ return true;
155
+ if ((parsedLeft[part] ?? 0) > (parsedRight[part] ?? 0))
156
+ return false;
157
+ }
158
+ return false;
159
+ }
94
160
  async function latestCliVersionFromNpm(exec, tag) {
95
161
  const result = await exec("npm", [
96
162
  "view",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {