@bli-cockpit/cli 0.2.48 → 0.2.50

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.
Files changed (83) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  3. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  4. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  5. package/dist/adapters/raw-evidence-facts.js +338 -0
  6. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  7. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  9. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  10. package/dist/adapters/raw-evidence.js +94 -1203
  11. package/dist/autostart-contract.js +79 -0
  12. package/dist/autostart-darwin-plist.js +265 -0
  13. package/dist/autostart-darwin.js +171 -0
  14. package/dist/autostart-windows-scripts.js +310 -0
  15. package/dist/autostart-windows-task-xml.js +260 -0
  16. package/dist/autostart-windows.js +237 -0
  17. package/dist/autostart-xml.js +23 -0
  18. package/dist/autostart.js +35 -1148
  19. package/dist/commands/agent-rules-command.js +55 -0
  20. package/dist/commands/agent-session-report.js +290 -0
  21. package/dist/commands/analyze.js +131 -0
  22. package/dist/commands/autostart-command.js +105 -0
  23. package/dist/commands/backfill-batches.js +34 -0
  24. package/dist/commands/backfill-candidates.js +54 -0
  25. package/dist/commands/backfill-checkpoint.js +101 -0
  26. package/dist/commands/backfill-command-line.js +70 -0
  27. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  28. package/dist/commands/backfill-issues.js +265 -0
  29. package/dist/commands/backfill-output.js +75 -0
  30. package/dist/commands/backfill-plan.js +71 -0
  31. package/dist/commands/backfill-reasons.js +107 -0
  32. package/dist/commands/backfill-report.js +298 -0
  33. package/dist/commands/backfill-result.js +150 -0
  34. package/dist/commands/backfill-scan.js +274 -0
  35. package/dist/commands/backfill-scope.js +114 -0
  36. package/dist/commands/backfill-session-report.js +145 -0
  37. package/dist/commands/backfill-types.js +1 -0
  38. package/dist/commands/backfill-upload.js +212 -0
  39. package/dist/commands/backfill.js +58 -1705
  40. package/dist/commands/cli-io.js +13 -0
  41. package/dist/commands/doctor.js +57 -0
  42. package/dist/commands/jarvis-trace.js +184 -0
  43. package/dist/commands/jarvis.js +323 -7
  44. package/dist/commands/local-arg-values.js +169 -0
  45. package/dist/commands/local-args-collector.js +604 -0
  46. package/dist/commands/local-args-tower.js +891 -0
  47. package/dist/commands/local-args.js +10 -1549
  48. package/dist/commands/local-help.js +30 -5
  49. package/dist/commands/local.js +21 -1786
  50. package/dist/commands/login.js +53 -0
  51. package/dist/commands/logout.js +66 -0
  52. package/dist/commands/memory-install-claude.js +294 -0
  53. package/dist/commands/memory-install-codex.js +205 -0
  54. package/dist/commands/memory-install-contract.js +231 -0
  55. package/dist/commands/memory-install-files.js +63 -0
  56. package/dist/commands/memory-install-skills.js +121 -0
  57. package/dist/commands/memory-install-toml.js +265 -0
  58. package/dist/commands/memory-install.js +378 -0
  59. package/dist/commands/onboard-receipts.js +66 -0
  60. package/dist/commands/onboard-report.js +274 -0
  61. package/dist/commands/onboard.js +449 -0
  62. package/dist/commands/ops-render.js +36 -0
  63. package/dist/commands/public-root.js +1 -1
  64. package/dist/commands/serve.js +13 -0
  65. package/dist/commands/session-sync.js +513 -534
  66. package/dist/commands/settings-render.js +28 -0
  67. package/dist/commands/settings.js +66 -2
  68. package/dist/commands/start.js +47 -0
  69. package/dist/commands/sync-followups.js +308 -0
  70. package/dist/commands/sync.js +387 -0
  71. package/dist/local-state-attributed-target.js +75 -0
  72. package/dist/local-state-config.js +147 -0
  73. package/dist/local-state-files.js +59 -0
  74. package/dist/local-state-identity.js +73 -0
  75. package/dist/local-state-pairing.js +263 -0
  76. package/dist/local-state-paths.js +61 -0
  77. package/dist/local-state-session.js +68 -0
  78. package/dist/local-state-status.js +163 -0
  79. package/dist/local-state-work-context.js +190 -0
  80. package/dist/local-state.js +34 -848
  81. package/dist/tower-client.js +3 -2
  82. package/dist/tower-stream.js +76 -6
  83. package/package.json +2 -1
@@ -139,4 +139,32 @@ export function renderEnvBlobs(body) {
139
139
  return [`${INDENT}no env files stored`];
140
140
  return blobs.map((blob) => `${INDENT}${padEndDisplay(text(blob.project), 12)} ${padEndDisplay(text(blob.file_name), 16)} ` +
141
141
  `updated ${text(blob.updated_at)} id ${text(blob.id)}`);
142
+ }
143
+ /**
144
+ * The fleet forced-update floor (BLI-3557).
145
+ *
146
+ * Four different answers stay four different lines. "No floor" and "a floor
147
+ * nobody will honour" look identical to a collector and must never look
148
+ * identical here — the second one is the failure that hid for weeks, because a
149
+ * malformed value reads as configured while every machine ignores it.
150
+ */
151
+ export function renderCliFloor(body) {
152
+ const floor = record(body.floor);
153
+ const version = typeof floor.version === "string" ? floor.version : null;
154
+ const lines = [
155
+ `${INDENT}${padEndDisplay("min CLI version", 24)} ${padEndDisplay(version ?? "none", 12)} (${text(floor.source, "no floor")})`,
156
+ ];
157
+ if (typeof floor.ignoredReason === "string" && floor.ignoredReason) {
158
+ lines.push(`${INDENT}stored value ${text(floor.storedValue)} is ignored by every collector (${floor.ignoredReason})`);
159
+ }
160
+ if (typeof floor.takesEffect === "string")
161
+ lines.push(`${INDENT}${floor.takesEffect}`);
162
+ if (typeof floor.pointer === "string")
163
+ lines.push(`${INDENT}Read at run time in: ${floor.pointer}`);
164
+ if (typeof floor.honouredIn === "string")
165
+ lines.push(`${INDENT}Acted on in: ${floor.honouredIn}`);
166
+ if (typeof floor.readFailureReason === "string" && floor.readFailureReason) {
167
+ lines.push(`${INDENT}floor could not be read: ${floor.readFailureReason}`);
168
+ }
169
+ return lines;
142
170
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
23
23
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
24
- import { renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, renderTeamSummary, } from "./settings-render.js";
24
+ import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, renderTeamSummary, } from "./settings-render.js";
25
25
  export async function runSettings(command, io) {
26
26
  const tower = await openTower("settings", command, io);
27
27
  switch (command.section) {
@@ -39,6 +39,10 @@ export async function runSettings(command, io) {
39
39
  return command.action === "set"
40
40
  ? setModels(command, tower, io)
41
41
  : showModels(command, tower, io);
42
+ case "cli-floor":
43
+ return command.action === "set"
44
+ ? raiseCliFloor(command, tower, io)
45
+ : showCliFloor(command, tower, io);
42
46
  case "env":
43
47
  if (command.action === "set")
44
48
  return setEnvBlob(command, tower, io);
@@ -55,12 +59,16 @@ export async function runSettings(command, io) {
55
59
  * five sequential round trips would reasonably conclude the command had hung.
56
60
  */
57
61
  async function showOverview(command, tower, io) {
58
- const [personal, switches, models, env, team] = await Promise.all([
62
+ const [personal, switches, models, env, team, cliFloor] = await Promise.all([
59
63
  callTower(tower, { path: "/api/settings/personal", label: "settings personal" }),
60
64
  callTower(tower, { path: "/api/settings/switches", label: "settings switches" }),
61
65
  callTower(tower, { path: "/api/settings/model-routing", label: "settings models" }),
62
66
  callTower(tower, { path: "/api/settings/env-blobs", label: "settings env" }),
63
67
  callTower(tower, { path: "/api/team/members", label: "settings team" }),
68
+ // BLI-3557: the floor rides the overview because "which version is the
69
+ // fleet being pulled to?" is a question people ask about their own laptop,
70
+ // and it went unanswered for weeks precisely because nothing showed it.
71
+ callTower(tower, { path: "/api/settings/cli-floor", label: "settings cli-floor" }),
64
72
  ]);
65
73
  // The one read every signed-in person is entitled to. If THAT is refused,
66
74
  // nothing else is going to work either, so say so once and stop.
@@ -75,6 +83,7 @@ async function showOverview(command, tower, io) {
75
83
  models: sectionPayload(models),
76
84
  env: sectionPayload(env),
77
85
  team: sectionPayload(team),
86
+ cliFloor: sectionPayload(cliFloor),
78
87
  }));
79
88
  return 0;
80
89
  }
@@ -84,12 +93,14 @@ async function showOverview(command, tower, io) {
84
93
  writeSection(io, "SWITCHES", switches, (body) => renderSwitches(body));
85
94
  writeSection(io, "MODELS", models, (body) => renderModelRouting(body));
86
95
  writeSection(io, "ENV FILES", env, (body) => renderEnvBlobs(body));
96
+ writeSection(io, "FLEET CLI FLOOR", cliFloor, (body) => renderCliFloor(body));
87
97
  writeLine(io.stderr, `[settings cli] overview ${JSON.stringify({
88
98
  personal: outcome(personal),
89
99
  team: outcome(team),
90
100
  switches: outcome(switches),
91
101
  models: outcome(models),
92
102
  env: outcome(env),
103
+ cli_floor: outcome(cliFloor),
93
104
  })}`);
94
105
  return 0;
95
106
  }
@@ -180,6 +191,59 @@ async function setSwitch(command, tower, io) {
180
191
  writeLine(io.stdout, `Switch ${String(body.key)} is now ${String(body.value)}.`);
181
192
  return 0;
182
193
  }
194
+ // ── the fleet CLI floor ───────────────────────────────────────────────
195
+ /**
196
+ * BLI-3557. The floor every collector is pulled up to on its next sync tick.
197
+ *
198
+ * Reading it is open to anybody signed in — the person whose laptop is about to
199
+ * update is entitled to know what it is updating to — and raising it is
200
+ * super_admin, decided on the server. `scripts/publish-public-packages.mjs`
201
+ * spends the SET verb after a `--tag latest` publish, which is the whole reason
202
+ * this exists: a fix on npm that no machine installs is not shipped.
203
+ */
204
+ async function showCliFloor(command, tower, io) {
205
+ const result = await callTower(tower, {
206
+ path: "/api/settings/cli-floor",
207
+ label: "settings cli-floor",
208
+ });
209
+ if (!result.ok)
210
+ return writeCommandFailure(io, command.json, result);
211
+ return writeBody(io, command.json, result.body, () => renderCliFloor(asRecord(result.body)));
212
+ }
213
+ async function raiseCliFloor(command, tower, io) {
214
+ const result = await callTower(tower, {
215
+ path: "/api/settings/cli-floor",
216
+ method: "PUT",
217
+ label: "settings cli-floor set",
218
+ body: { version: command.floorVersion },
219
+ });
220
+ if (!result.ok) {
221
+ // The refusal a release most needs to read is "that would LOWER the floor",
222
+ // and it arrives as a 409 whose body already names both versions. Relaying
223
+ // the server's sentence keeps one wording in one place.
224
+ writeLine(io.stderr, `[settings cli] cli-floor ${JSON.stringify({
225
+ requested: command.floorVersion,
226
+ outcome: result.reason,
227
+ ...(result.httpStatus === undefined ? {} : { http_status: result.httpStatus }),
228
+ })}`);
229
+ return writeCommandFailure(io, command.json, result);
230
+ }
231
+ const body = asRecord(result.body);
232
+ const changed = body.changed === true;
233
+ writeLine(io.stderr, `[settings cli] cli-floor ${JSON.stringify({
234
+ from: body.previous ?? null,
235
+ to: body.version ?? command.floorVersion,
236
+ outcome: changed ? "raised" : "already_at_floor",
237
+ })}`);
238
+ if (command.json) {
239
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
240
+ return 0;
241
+ }
242
+ writeLine(io.stdout, changed
243
+ ? `Fleet CLI floor raised ${String(body.previous ?? "none")}→${String(body.version)}.`
244
+ : `Fleet CLI floor is already ${String(body.version)}; nothing changed.`);
245
+ return 0;
246
+ }
183
247
  // ── models (org-wide) ─────────────────────────────────────────────────
184
248
  async function showModels(command, tower, io) {
185
249
  const result = await callTower(tower, {
@@ -0,0 +1,47 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { displayTicketId } from "./collection-report.js";
3
+ import { discoverCommandWorktrees } from "./local-discovery.js";
4
+ import { startLocalWorkContext } from "../local-state.js";
5
+ export async function runStart(command, io) {
6
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
7
+ if (worktrees.length > 1) {
8
+ const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
9
+ homeDir: command.homeDir,
10
+ repoRoot: worktree.repo_root,
11
+ branch: command.branch,
12
+ activeTicketId: command.activeTicketId,
13
+ clearTicket: command.clearTicket,
14
+ topicLabel: command.topicLabel,
15
+ topicSummaryRedacted: command.topicSummaryRedacted,
16
+ workIntent: command.workIntent,
17
+ workPhase: command.workPhase,
18
+ intentSource: command.intentSource,
19
+ intentConfidence: command.intentConfidence,
20
+ operatorId: command.operatorId,
21
+ sessionId: command.sessionId,
22
+ })));
23
+ if (command.json) {
24
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
25
+ return 0;
26
+ }
27
+ writeLine(io.stdout, `Tower parent work context active for ${contexts.length} worktree(s).`);
28
+ for (const context of contexts) {
29
+ writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
30
+ }
31
+ return 0;
32
+ }
33
+ const context = await startLocalWorkContext(command);
34
+ if (command.json) {
35
+ writeLine(io.stdout, JSON.stringify(context, null, 2));
36
+ return 0;
37
+ }
38
+ writeLine(io.stdout, "Tower work context active.");
39
+ writeLine(io.stdout, `Repo: ${context.repo}`);
40
+ writeLine(io.stdout, `Branch: ${context.branch}`);
41
+ writeLine(io.stdout, `Ticket: ${displayTicketId(context.active_ticket_id)}`);
42
+ if (context.topic_label || context.work_intent || context.work_phase) {
43
+ writeLine(io.stdout, `Topic: ${context.topic_label ?? "unlabeled"} · ${context.work_intent ?? "unknown"} · ${context.work_phase ?? "unknown"}`);
44
+ }
45
+ writeLine(io.stdout, `Context: ${context.work_context_id}`);
46
+ return 0;
47
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * What the sync tick does AFTER collection's own outcome is decided and
3
+ * reported: keep this machine's CLI current on npm `latest` (BLI-2601), put a
4
+ * broken scheduler registration back (BLI-2721), and keep BLI Memory
5
+ * registered with both agent hosts (BLI-3580).
6
+ *
7
+ * Split out of commands/sync.ts (BLI-3578), moved verbatim. They belong
8
+ * together because they share one rule, and it is the reason both are called
9
+ * last: a follow-up may never block, delay or fail collection. Every error path
10
+ * here is swallowed on purpose and reported as its own named receipt — an
11
+ * `update` step, or an autostart repair step — never as a `sync` failure.
12
+ */
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { resolveAutostartRoots } from "./autostart-command.js";
16
+ import { installMemoryIntegration, } from "./memory-install.js";
17
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
18
+ import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
19
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
20
+ import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
21
+ import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
22
+ /**
23
+ * BLI-2721: after the tick's collection and self-update are done and
24
+ * reported, repair a broken/legacy autostart registration in place (Windows
25
+ * only — see autostart-self-heal.ts for why macOS is excluded). Every error
26
+ * path is swallowed like the self-update's: heal outcomes are their own
27
+ * receipts, never a sync failure.
28
+ */
29
+ export async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
30
+ let result;
31
+ try {
32
+ const rawExec = io.exec;
33
+ if (!rawExec) {
34
+ // BLI-3483: this was a bare `return`. On Windows the self-heal is the
35
+ // only thing that puts a broken scheduler back, so abandoning it here
36
+ // meant a machine could stop collecting forever and leave no receipt
37
+ // anywhere — the exact shape the fleet contract forbids. The packed CLI
38
+ // always supplies a runner (`commands/cli-io.ts`), so this fires only for
39
+ // an embedder that built its own `io`; it costs one line either way.
40
+ console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
41
+ reason: "runner_unavailable",
42
+ platform: process.platform,
43
+ next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
44
+ }));
45
+ result = {
46
+ status: "skipped",
47
+ reason: "runner_unavailable",
48
+ detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
49
+ };
50
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
51
+ return;
52
+ }
53
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
54
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
55
+ result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
56
+ homeDir: command.homeDir,
57
+ repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
58
+ dashboardUrl: command.dashboardUrl,
59
+ exec,
60
+ });
61
+ }
62
+ catch (error) {
63
+ result = {
64
+ status: "fail",
65
+ reason: "autostart_self_heal_threw",
66
+ detail: redactedSyncErrorDetail(error),
67
+ };
68
+ }
69
+ // Steady state (healthy, absent, non-Windows, no roots) and the daily
70
+ // throttle are silent; an actual repair attempt reports either way.
71
+ if (!result || result.reason === "repair_throttled_recent_attempt")
72
+ return;
73
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
74
+ }
75
+ /** One receipt for the repair, whichever branch above produced the outcome. */
76
+ async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
77
+ await reportInstallEventsBestEffort({
78
+ homeDir: command.homeDir,
79
+ dashboardUrl,
80
+ command: "sync",
81
+ events: [
82
+ {
83
+ // Windows repairs in place and keeps the name already in the receipts
84
+ // and the runbook; the macOS path only SCHEDULES a detached repair, so
85
+ // it reports under its own step (BLI-3553).
86
+ step: result.step ?? "autostart_repair",
87
+ status: result.status,
88
+ ...(result.status === "ok" ? {} : { error_code: result.reason }),
89
+ ...(result.detail ? { error_detail: result.detail } : {}),
90
+ },
91
+ ],
92
+ json: command.json,
93
+ io,
94
+ });
95
+ }
96
+ export const MEMORY_INSTALL_THROTTLE_MARKER = ".last-memory-install";
97
+ const MEMORY_INSTALL_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
98
+ /**
99
+ * BLI-3580: BLI Memory's registration converges on its own.
100
+ *
101
+ * Nobody is going to be asked to install a hook. `do-everything` registers it
102
+ * on the way through, and this puts it back if a host config is edited,
103
+ * replaced, or restored from a machine that never had it — at most once a day,
104
+ * because the steady state is "already current" and re-proving that every
105
+ * fifteen minutes is four file reads a tick for no new information.
106
+ *
107
+ * Same rule as the two follow-ups above: it runs only once collection's own
108
+ * outcome has been decided and reported, it never throws, and its outcome is
109
+ * its own named receipt rather than a sync failure.
110
+ */
111
+ export async function runMemoryInstallAfterSync(command, io, dashboardUrl, options = {}) {
112
+ const paths = getCollectorRuntimePaths(command.homeDir);
113
+ const now = options.now ?? new Date();
114
+ const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
115
+ const lastAttempt = await fs.stat(marker).catch(() => null);
116
+ if (lastAttempt &&
117
+ now.getTime() - lastAttempt.mtimeMs < MEMORY_INSTALL_MIN_INTERVAL_MS) {
118
+ return;
119
+ }
120
+ // Written for the ATTEMPT, not the outcome — the same idiom the self-update
121
+ // and autostart repair use, so a machine that cannot write a host config does
122
+ // not retry it every fifteen minutes.
123
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
124
+ await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
125
+ let event;
126
+ try {
127
+ const outcome = await installMemoryIntegration({
128
+ kind: "memory",
129
+ action: "install",
130
+ homeDir: command.homeDir,
131
+ dashboardUrl,
132
+ dryRun: false,
133
+ json: command.json,
134
+ }, io);
135
+ event = memoryInstallEvent(outcome);
136
+ }
137
+ catch (error) {
138
+ event = {
139
+ step: "memory_install",
140
+ status: "fail",
141
+ error_code: "memory_install_threw",
142
+ error_detail: redactedSyncErrorDetail(error),
143
+ };
144
+ }
145
+ await reportInstallEventsBestEffort({
146
+ homeDir: command.homeDir,
147
+ dashboardUrl,
148
+ command: "sync",
149
+ events: [event],
150
+ json: command.json,
151
+ io,
152
+ });
153
+ }
154
+ /**
155
+ * Target names and reason labels only. A target's `path` names a person's home
156
+ * directory and a `write_failed` detail can carry one, so neither travels: the
157
+ * receipt says `claude_hooks:read_back_mismatch`, which is the part an operator
158
+ * can act on.
159
+ */
160
+ function memoryInstallEvent(outcome) {
161
+ const detail = [
162
+ `source=${outcome.config_source}`,
163
+ ...outcome.targets.map((target) => `${target.target}:${target.status}/${target.reason}`),
164
+ ].join("; ");
165
+ if (outcome.status === "failed") {
166
+ return {
167
+ step: "memory_install",
168
+ status: "fail",
169
+ error_code: outcome.reason,
170
+ error_detail: detail,
171
+ };
172
+ }
173
+ if (outcome.status === "skipped") {
174
+ // Nothing was written, on purpose (`no_bin_no_write`). A fleet-wide
175
+ // `bin_missing` is the receipt that says the server package has not
176
+ // reached the machines yet — a fact, not a fault.
177
+ return {
178
+ step: "memory_install",
179
+ status: "skipped",
180
+ error_code: outcome.reason,
181
+ error_detail: detail,
182
+ };
183
+ }
184
+ return { step: "memory_install", status: "ok", error_detail: detail };
185
+ }
186
+ /**
187
+ * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
188
+ * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
189
+ * runs AFTER `runSync` has already decided and reported collection's own
190
+ * outcome above — a stuck or failing self-update can never block or delay
191
+ * collection, and a collection failure never blocks the chance to
192
+ * self-update. Every error path here is swallowed on purpose: a failure is
193
+ * reported as its own named `update` receipt, never surfaced as a `sync`
194
+ * failure or thrown from this function.
195
+ */
196
+ export async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
197
+ let event;
198
+ try {
199
+ event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
200
+ }
201
+ catch (error) {
202
+ // The throttle/probe/install machinery below is defensive already; this
203
+ // is the last-resort net so an update crash truly cannot touch the sync
204
+ // result above.
205
+ event = {
206
+ step: "update",
207
+ status: "fail",
208
+ error_code: "self_update_threw",
209
+ error_detail: redactedSyncErrorDetail(error),
210
+ };
211
+ }
212
+ if (!event)
213
+ return;
214
+ await reportInstallEventsBestEffort({
215
+ homeDir: command.homeDir,
216
+ dashboardUrl,
217
+ command: "update",
218
+ events: [event],
219
+ json: command.json,
220
+ io,
221
+ });
222
+ }
223
+ async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
224
+ const rawExec = io.exec;
225
+ if (!rawExec) {
226
+ // Only the real production `defaultIo()` supplies a process runner. A
227
+ // caller that omitted one gets a silent no-op rather than this reaching
228
+ // for a real npm binary it was never given — never observed in
229
+ // production, where `defaultIo()` always sets `exec`.
230
+ return null;
231
+ }
232
+ // Every spawn in the scheduled path carries the running node's bin dir on
233
+ // PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
234
+ // this; the scheduler's stripped environment does.
235
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
236
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
237
+ const scheduledIo = { ...io, exec };
238
+ const paths = getCollectorRuntimePaths(command.homeDir);
239
+ const result = await runScheduledSelfUpdate(paths, {
240
+ exec,
241
+ currentVersion: LOCAL_COLLECTOR_VERSION,
242
+ install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
243
+ }, { env: io.env, minVersion: minCliVersion });
244
+ return scheduledSelfUpdateInstallEvent(result);
245
+ }
246
+ async function attemptScheduledSelfUpdateInstall(io, tag) {
247
+ try {
248
+ // Reuses the exact npm-install machinery `cockpit doctor`'s
249
+ // `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
250
+ // knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
251
+ // this call never re-execs — see runScheduledSelfUpdate's doc comment.
252
+ await runSelfUpdate(io, { json: true, tag });
253
+ return { ok: true };
254
+ }
255
+ catch (error) {
256
+ if (!(error instanceof SelfUpdateError))
257
+ throw error;
258
+ return { ok: false, eacces: error.eacces };
259
+ }
260
+ }
261
+ function scheduledSelfUpdateInstallEvent(result) {
262
+ // The steady-state "already checked today" case is a pure no-op; reporting
263
+ // it would post a receipt on ~95 of every 96 sync ticks for no new
264
+ // information. Only a real attempt (ok, fail, or an explicit disable)
265
+ // produces a receipt.
266
+ if (result.reason === "throttled_recent_attempt")
267
+ return null;
268
+ // A forced attempt names its trigger in the receipt either way, so the
269
+ // ledger can tell "converged on the daily cadence" from "the floor pulled
270
+ // this machine forward" (BLI-2678).
271
+ const forcedDetail = result.forced && result.min_version
272
+ ? `forced_min_version ${result.min_version}`
273
+ : null;
274
+ if (result.status === "ok") {
275
+ // BLI-3551: this used to be `update ok` with an empty detail unless the
276
+ // floor forced it. One machine posted that receipt daily for nine releases
277
+ // while sitting on 0.2.37, and nobody could tell "already current" from
278
+ // "installed something" from "npm answered nothing" — three different
279
+ // situations wearing one word. The success branch names itself now.
280
+ const okDetail = [
281
+ forcedDetail,
282
+ result.reason === "updated" && result.previous_version && result.installed_version
283
+ ? `installed ${result.previous_version}→${result.installed_version}`
284
+ : result.reason,
285
+ result.target_version ? `target ${result.target_version}` : null,
286
+ ]
287
+ .filter((part) => Boolean(part))
288
+ .join("; ");
289
+ return {
290
+ step: "update",
291
+ status: "ok",
292
+ ...(okDetail ? { error_detail: okDetail } : {}),
293
+ };
294
+ }
295
+ const detail = [
296
+ forcedDetail,
297
+ result.target_version ? `target ${result.target_version}` : null,
298
+ result.installed_version ? `installed ${result.installed_version}` : null,
299
+ ]
300
+ .filter((part) => Boolean(part))
301
+ .join("; ");
302
+ return {
303
+ step: "update",
304
+ status: result.status,
305
+ error_code: result.reason,
306
+ ...(detail ? { error_detail: detail } : {}),
307
+ };
308
+ }