@bli-cockpit/cli 0.2.22 → 0.2.24

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.
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { autostartStatus, installAutostartAgent, } from "./autostart.js";
4
+ export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
5
+ const AUTOSTART_REPAIR_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
6
+ const DETAIL_MAX_CHARS = 300;
7
+ export async function runAutostartSelfHeal(paths, options) {
8
+ const platform = options.platform ?? process.platform;
9
+ if (platform !== "win32")
10
+ return null;
11
+ if (options.repoRoots.length === 0)
12
+ return null;
13
+ const status = await autostartStatus({
14
+ homeDir: options.homeDir,
15
+ repoRoot: options.repoRoots[0],
16
+ repoRoots: options.repoRoots,
17
+ dashboardUrl: options.dashboardUrl,
18
+ exec: options.exec,
19
+ platform,
20
+ });
21
+ // Healthy is the steady state and stays silent; absent means the operator
22
+ // (or onboarding) owns the decision, not this tick.
23
+ if (status.status !== "not_loaded")
24
+ return null;
25
+ // Attempts are throttled like the self-update's (marker mtime, written for
26
+ // the attempt not the outcome) so a persistently failing repair cannot spawn
27
+ // a registration every 15 minutes. The status probe above still runs every
28
+ // tick — it is one schtasks query.
29
+ const now = options.now ?? new Date();
30
+ const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
31
+ const lastAttempt = await fs.stat(marker).catch(() => null);
32
+ if (lastAttempt &&
33
+ now.getTime() - lastAttempt.mtimeMs < AUTOSTART_REPAIR_MIN_INTERVAL_MS) {
34
+ return { status: "skipped", reason: "repair_throttled_recent_attempt" };
35
+ }
36
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
37
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
38
+ const problem = (status.message ?? "task not loaded").slice(0, DETAIL_MAX_CHARS);
39
+ const repaired = await installAutostartAgent({
40
+ homeDir: options.homeDir,
41
+ repoRoot: options.repoRoots[0],
42
+ repoRoots: options.repoRoots,
43
+ dashboardUrl: options.dashboardUrl,
44
+ exec: options.exec,
45
+ platform,
46
+ });
47
+ if (repaired.loaded) {
48
+ return { status: "ok", reason: "autostart_repaired", detail: problem };
49
+ }
50
+ return {
51
+ status: "fail",
52
+ reason: "autostart_repair_failed",
53
+ detail: (repaired.message ?? problem).slice(0, DETAIL_MAX_CHARS),
54
+ };
55
+ }
@@ -22,6 +22,7 @@ import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-s
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
24
  import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
25
+ import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
25
26
  import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
26
27
  import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
27
28
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -711,9 +712,16 @@ function sanitizeInstallErrorCode(value) {
711
712
  .slice(0, 120);
712
713
  return normalized || "unknown";
713
714
  }
715
+ /**
716
+ * Posts pending install events. Also the collector's only per-tick listening
717
+ * post: the response carries the server-published `min_cli_version` floor
718
+ * (BLI-2678), so the last one observed is returned for the scheduled
719
+ * self-update step to act on. Every early-out returns null — no receipt, no
720
+ * floor.
721
+ */
714
722
  export async function reportInstallEventsBestEffort(options) {
715
723
  if (options.events.length === 0)
716
- return;
724
+ return null;
717
725
  const paths = getCollectorRuntimePaths(options.homeDir);
718
726
  try {
719
727
  await enqueueInstallEventEntry(paths, {
@@ -744,17 +752,18 @@ export async function reportInstallEventsBestEffort(options) {
744
752
  if (options.json) {
745
753
  writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
746
754
  }
747
- return;
755
+ return null;
748
756
  }
749
757
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
750
758
  if (!session ||
751
759
  session.session_state !== "valid" ||
752
760
  typeof session.device_token !== "string" ||
753
761
  !session.device_token) {
754
- return;
762
+ return null;
755
763
  }
756
764
  const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
757
765
  const failures = [];
766
+ let observedMinCliVersion = null;
758
767
  for (let offset = 0; offset < pending.length; offset += 5) {
759
768
  await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
760
769
  const controller = new AbortController();
@@ -777,6 +786,13 @@ export async function reportInstallEventsBestEffort(options) {
777
786
  if (!response.ok) {
778
787
  throw new Error(`http_${response.status}`);
779
788
  }
789
+ const receipt = (await response
790
+ .json()
791
+ .catch(() => null));
792
+ if (typeof receipt?.min_cli_version === "string" &&
793
+ receipt.min_cli_version.trim()) {
794
+ observedMinCliVersion = receipt.min_cli_version.trim();
795
+ }
780
796
  await removeInstallEventEntry(paths, entry.outbox_id);
781
797
  }
782
798
  catch (error) {
@@ -795,6 +811,7 @@ export async function reportInstallEventsBestEffort(options) {
795
811
  if (options.json && failures.length > 0) {
796
812
  writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
797
813
  }
814
+ return observedMinCliVersion;
798
815
  }
799
816
  function classifyInstallTelemetryError(error) {
800
817
  if (error instanceof Error && error.name === "AbortError") {
@@ -2135,7 +2152,7 @@ async function runSync(command, io) {
2135
2152
  const paths = getCollectorRuntimePaths(command.homeDir);
2136
2153
  const config = await readLocalCollectorConfig(paths).catch(() => null);
2137
2154
  const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
2138
- await reportInstallEventsBestEffort({
2155
+ const minCliVersionAtStart = await reportInstallEventsBestEffort({
2139
2156
  homeDir: command.homeDir,
2140
2157
  dashboardUrl,
2141
2158
  command: "sync",
@@ -2145,7 +2162,7 @@ async function runSync(command, io) {
2145
2162
  });
2146
2163
  try {
2147
2164
  const result = await runSyncWithHealthReceipt(command, io);
2148
- await reportInstallEventsBestEffort({
2165
+ const minCliVersion = await reportInstallEventsBestEffort({
2149
2166
  homeDir: command.homeDir,
2150
2167
  dashboardUrl,
2151
2168
  command: "sync",
@@ -2155,11 +2172,12 @@ async function runSync(command, io) {
2155
2172
  });
2156
2173
  // BLI-2601: self-update runs only after collection's own outcome above is
2157
2174
  // already decided and reported, win or lose. See the function doc.
2158
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
2175
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2176
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
2159
2177
  return result.exitCode;
2160
2178
  }
2161
2179
  catch (error) {
2162
- await reportInstallEventsBestEffort({
2180
+ const minCliVersion = await reportInstallEventsBestEffort({
2163
2181
  homeDir: command.homeDir,
2164
2182
  dashboardUrl,
2165
2183
  command: "sync",
@@ -2174,10 +2192,60 @@ async function runSync(command, io) {
2174
2192
  json: command.json,
2175
2193
  io,
2176
2194
  });
2177
- await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
2195
+ await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion ?? minCliVersionAtStart);
2196
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
2178
2197
  throw error;
2179
2198
  }
2180
2199
  }
2200
+ /**
2201
+ * BLI-2721: after the tick's collection and self-update are done and
2202
+ * reported, repair a broken/legacy autostart registration in place (Windows
2203
+ * only — see autostart-self-heal.ts for why macOS is excluded). Every error
2204
+ * path is swallowed like the self-update's: heal outcomes are their own
2205
+ * receipts, never a sync failure.
2206
+ */
2207
+ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
2208
+ let result;
2209
+ try {
2210
+ const rawExec = io.exec;
2211
+ if (!rawExec)
2212
+ return;
2213
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
2214
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
2215
+ result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
2216
+ homeDir: command.homeDir,
2217
+ repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
2218
+ dashboardUrl: command.dashboardUrl,
2219
+ exec,
2220
+ });
2221
+ }
2222
+ catch (error) {
2223
+ result = {
2224
+ status: "fail",
2225
+ reason: "autostart_self_heal_threw",
2226
+ detail: redactedSyncErrorDetail(error),
2227
+ };
2228
+ }
2229
+ // Steady state (healthy, absent, non-Windows, no roots) and the daily
2230
+ // throttle are silent; an actual repair attempt reports either way.
2231
+ if (!result || result.reason === "repair_throttled_recent_attempt")
2232
+ return;
2233
+ await reportInstallEventsBestEffort({
2234
+ homeDir: command.homeDir,
2235
+ dashboardUrl,
2236
+ command: "sync",
2237
+ events: [
2238
+ {
2239
+ step: "autostart_repair",
2240
+ status: result.status,
2241
+ ...(result.status === "ok" ? {} : { error_code: result.reason }),
2242
+ ...(result.detail ? { error_detail: result.detail } : {}),
2243
+ },
2244
+ ],
2245
+ json: command.json,
2246
+ io,
2247
+ });
2248
+ }
2181
2249
  /**
2182
2250
  * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
2183
2251
  * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
@@ -2188,10 +2256,10 @@ async function runSync(command, io) {
2188
2256
  * reported as its own named `update` receipt, never surfaced as a `sync`
2189
2257
  * failure or thrown from this function.
2190
2258
  */
2191
- async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
2259
+ async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
2192
2260
  let event;
2193
2261
  try {
2194
- event = await runScheduledSelfUpdateForSync(command, io);
2262
+ event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
2195
2263
  }
2196
2264
  catch (error) {
2197
2265
  // The throttle/probe/install machinery below is defensive already; this
@@ -2215,7 +2283,7 @@ async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
2215
2283
  io,
2216
2284
  });
2217
2285
  }
2218
- async function runScheduledSelfUpdateForSync(command, io) {
2286
+ async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
2219
2287
  const rawExec = io.exec;
2220
2288
  if (!rawExec) {
2221
2289
  // Only the real production `defaultIo()` supplies a process runner. A
@@ -2235,7 +2303,7 @@ async function runScheduledSelfUpdateForSync(command, io) {
2235
2303
  exec,
2236
2304
  currentVersion: LOCAL_COLLECTOR_VERSION,
2237
2305
  install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
2238
- }, { env: io.env });
2306
+ }, { env: io.env, minVersion: minCliVersion });
2239
2307
  return scheduledSelfUpdateInstallEvent(result);
2240
2308
  }
2241
2309
  async function attemptScheduledSelfUpdateInstall(io, tag) {
@@ -2260,9 +2328,21 @@ function scheduledSelfUpdateInstallEvent(result) {
2260
2328
  // produces a receipt.
2261
2329
  if (result.reason === "throttled_recent_attempt")
2262
2330
  return null;
2263
- if (result.status === "ok")
2264
- return { step: "update", status: "ok" };
2331
+ // A forced attempt names its trigger in the receipt either way, so the
2332
+ // ledger can tell "converged on the daily cadence" from "the floor pulled
2333
+ // this machine forward" (BLI-2678).
2334
+ const forcedDetail = result.forced && result.min_version
2335
+ ? `forced_min_version ${result.min_version}`
2336
+ : null;
2337
+ if (result.status === "ok") {
2338
+ return {
2339
+ step: "update",
2340
+ status: "ok",
2341
+ ...(forcedDetail ? { error_detail: forcedDetail } : {}),
2342
+ };
2343
+ }
2265
2344
  const detail = [
2345
+ forcedDetail,
2266
2346
  result.target_version ? `target ${result.target_version}` : null,
2267
2347
  result.installed_version ? `installed ${result.installed_version}` : null,
2268
2348
  ]
@@ -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.22");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.24");
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.22",
3
+ "version": "0.2.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {