@bli-cockpit/cli 0.2.22 → 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.
@@ -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.22");
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.22",
3
+ "version": "0.2.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {