@bli-cockpit/cli 0.2.47 → 0.2.49

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 (42) hide show
  1. package/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
  2. package/dist/adapters/raw-evidence.js +360 -349
  3. package/dist/autostart-contract.js +79 -0
  4. package/dist/autostart-darwin-plist.js +265 -0
  5. package/dist/autostart-darwin.js +171 -0
  6. package/dist/autostart-windows-scripts.js +310 -0
  7. package/dist/autostart-windows-task-xml.js +260 -0
  8. package/dist/autostart-windows.js +237 -0
  9. package/dist/autostart-xml.js +23 -0
  10. package/dist/autostart.js +35 -1148
  11. package/dist/commands/agent-rules-command.js +55 -0
  12. package/dist/commands/agent-session-report.js +290 -0
  13. package/dist/commands/analyze.js +131 -0
  14. package/dist/commands/autostart-command.js +105 -0
  15. package/dist/commands/backfill.js +824 -551
  16. package/dist/commands/cli-io.js +13 -0
  17. package/dist/commands/heartbeat.js +18 -0
  18. package/dist/commands/install-receipts.js +34 -0
  19. package/dist/commands/jarvis.js +179 -3
  20. package/dist/commands/local-arg-values.js +169 -0
  21. package/dist/commands/local-args-collector.js +578 -0
  22. package/dist/commands/local-args-tower.js +870 -0
  23. package/dist/commands/local-args.js +8 -1549
  24. package/dist/commands/local-help.js +11 -3
  25. package/dist/commands/local.js +18 -1786
  26. package/dist/commands/login.js +53 -0
  27. package/dist/commands/logout.js +66 -0
  28. package/dist/commands/onboard-receipts.js +66 -0
  29. package/dist/commands/onboard-report.js +274 -0
  30. package/dist/commands/onboard.js +449 -0
  31. package/dist/commands/ops-render.js +36 -0
  32. package/dist/commands/public-root.js +1 -1
  33. package/dist/commands/serve.js +13 -0
  34. package/dist/commands/session-sync.js +513 -534
  35. package/dist/commands/settings-render.js +28 -0
  36. package/dist/commands/settings.js +66 -2
  37. package/dist/commands/start.js +47 -0
  38. package/dist/commands/sync-followups.js +203 -0
  39. package/dist/commands/sync.js +381 -0
  40. package/dist/dev-build.js +186 -0
  41. package/dist/tower-stream.js +20 -4
  42. package/package.json +2 -2
@@ -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,203 @@
1
+ import { resolveAutostartRoots } from "./autostart-command.js";
2
+ import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
3
+ import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
4
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
5
+ import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
6
+ import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
7
+ /**
8
+ * BLI-2721: after the tick's collection and self-update are done and
9
+ * reported, repair a broken/legacy autostart registration in place (Windows
10
+ * only — see autostart-self-heal.ts for why macOS is excluded). Every error
11
+ * path is swallowed like the self-update's: heal outcomes are their own
12
+ * receipts, never a sync failure.
13
+ */
14
+ export async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
15
+ let result;
16
+ try {
17
+ const rawExec = io.exec;
18
+ if (!rawExec) {
19
+ // BLI-3483: this was a bare `return`. On Windows the self-heal is the
20
+ // only thing that puts a broken scheduler back, so abandoning it here
21
+ // meant a machine could stop collecting forever and leave no receipt
22
+ // anywhere — the exact shape the fleet contract forbids. The packed CLI
23
+ // always supplies a runner (`commands/cli-io.ts`), so this fires only for
24
+ // an embedder that built its own `io`; it costs one line either way.
25
+ console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
26
+ reason: "runner_unavailable",
27
+ platform: process.platform,
28
+ next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
29
+ }));
30
+ result = {
31
+ status: "skipped",
32
+ reason: "runner_unavailable",
33
+ detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
34
+ };
35
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
36
+ return;
37
+ }
38
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
39
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
40
+ result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
41
+ homeDir: command.homeDir,
42
+ repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
43
+ dashboardUrl: command.dashboardUrl,
44
+ exec,
45
+ });
46
+ }
47
+ catch (error) {
48
+ result = {
49
+ status: "fail",
50
+ reason: "autostart_self_heal_threw",
51
+ detail: redactedSyncErrorDetail(error),
52
+ };
53
+ }
54
+ // Steady state (healthy, absent, non-Windows, no roots) and the daily
55
+ // throttle are silent; an actual repair attempt reports either way.
56
+ if (!result || result.reason === "repair_throttled_recent_attempt")
57
+ return;
58
+ await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
59
+ }
60
+ /** One receipt for the repair, whichever branch above produced the outcome. */
61
+ async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
62
+ await reportInstallEventsBestEffort({
63
+ homeDir: command.homeDir,
64
+ dashboardUrl,
65
+ command: "sync",
66
+ events: [
67
+ {
68
+ // Windows repairs in place and keeps the name already in the receipts
69
+ // and the runbook; the macOS path only SCHEDULES a detached repair, so
70
+ // it reports under its own step (BLI-3553).
71
+ step: result.step ?? "autostart_repair",
72
+ status: result.status,
73
+ ...(result.status === "ok" ? {} : { error_code: result.reason }),
74
+ ...(result.detail ? { error_detail: result.detail } : {}),
75
+ },
76
+ ],
77
+ json: command.json,
78
+ io,
79
+ });
80
+ }
81
+ /**
82
+ * BLI-2601: the fleet keeps itself current on npm `latest` without anyone
83
+ * re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
84
+ * runs AFTER `runSync` has already decided and reported collection's own
85
+ * outcome above — a stuck or failing self-update can never block or delay
86
+ * collection, and a collection failure never blocks the chance to
87
+ * self-update. Every error path here is swallowed on purpose: a failure is
88
+ * reported as its own named `update` receipt, never surfaced as a `sync`
89
+ * failure or thrown from this function.
90
+ */
91
+ export async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
92
+ let event;
93
+ try {
94
+ event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
95
+ }
96
+ catch (error) {
97
+ // The throttle/probe/install machinery below is defensive already; this
98
+ // is the last-resort net so an update crash truly cannot touch the sync
99
+ // result above.
100
+ event = {
101
+ step: "update",
102
+ status: "fail",
103
+ error_code: "self_update_threw",
104
+ error_detail: redactedSyncErrorDetail(error),
105
+ };
106
+ }
107
+ if (!event)
108
+ return;
109
+ await reportInstallEventsBestEffort({
110
+ homeDir: command.homeDir,
111
+ dashboardUrl,
112
+ command: "update",
113
+ events: [event],
114
+ json: command.json,
115
+ io,
116
+ });
117
+ }
118
+ async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
119
+ const rawExec = io.exec;
120
+ if (!rawExec) {
121
+ // Only the real production `defaultIo()` supplies a process runner. A
122
+ // caller that omitted one gets a silent no-op rather than this reaching
123
+ // for a real npm binary it was never given — never observed in
124
+ // production, where `defaultIo()` always sets `exec`.
125
+ return null;
126
+ }
127
+ // Every spawn in the scheduled path carries the running node's bin dir on
128
+ // PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
129
+ // this; the scheduler's stripped environment does.
130
+ const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
131
+ const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
132
+ const scheduledIo = { ...io, exec };
133
+ const paths = getCollectorRuntimePaths(command.homeDir);
134
+ const result = await runScheduledSelfUpdate(paths, {
135
+ exec,
136
+ currentVersion: LOCAL_COLLECTOR_VERSION,
137
+ install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
138
+ }, { env: io.env, minVersion: minCliVersion });
139
+ return scheduledSelfUpdateInstallEvent(result);
140
+ }
141
+ async function attemptScheduledSelfUpdateInstall(io, tag) {
142
+ try {
143
+ // Reuses the exact npm-install machinery `cockpit doctor`'s
144
+ // `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
145
+ // knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
146
+ // this call never re-execs — see runScheduledSelfUpdate's doc comment.
147
+ await runSelfUpdate(io, { json: true, tag });
148
+ return { ok: true };
149
+ }
150
+ catch (error) {
151
+ if (!(error instanceof SelfUpdateError))
152
+ throw error;
153
+ return { ok: false, eacces: error.eacces };
154
+ }
155
+ }
156
+ function scheduledSelfUpdateInstallEvent(result) {
157
+ // The steady-state "already checked today" case is a pure no-op; reporting
158
+ // it would post a receipt on ~95 of every 96 sync ticks for no new
159
+ // information. Only a real attempt (ok, fail, or an explicit disable)
160
+ // produces a receipt.
161
+ if (result.reason === "throttled_recent_attempt")
162
+ return null;
163
+ // A forced attempt names its trigger in the receipt either way, so the
164
+ // ledger can tell "converged on the daily cadence" from "the floor pulled
165
+ // this machine forward" (BLI-2678).
166
+ const forcedDetail = result.forced && result.min_version
167
+ ? `forced_min_version ${result.min_version}`
168
+ : null;
169
+ if (result.status === "ok") {
170
+ // BLI-3551: this used to be `update ok` with an empty detail unless the
171
+ // floor forced it. One machine posted that receipt daily for nine releases
172
+ // while sitting on 0.2.37, and nobody could tell "already current" from
173
+ // "installed something" from "npm answered nothing" — three different
174
+ // situations wearing one word. The success branch names itself now.
175
+ const okDetail = [
176
+ forcedDetail,
177
+ result.reason === "updated" && result.previous_version && result.installed_version
178
+ ? `installed ${result.previous_version}→${result.installed_version}`
179
+ : result.reason,
180
+ result.target_version ? `target ${result.target_version}` : null,
181
+ ]
182
+ .filter((part) => Boolean(part))
183
+ .join("; ");
184
+ return {
185
+ step: "update",
186
+ status: "ok",
187
+ ...(okDetail ? { error_detail: okDetail } : {}),
188
+ };
189
+ }
190
+ const detail = [
191
+ forcedDetail,
192
+ result.target_version ? `target ${result.target_version}` : null,
193
+ result.installed_version ? `installed ${result.installed_version}` : null,
194
+ ]
195
+ .filter((part) => Boolean(part))
196
+ .join("; ");
197
+ return {
198
+ step: "update",
199
+ status: result.status,
200
+ error_code: result.reason,
201
+ ...(detail ? { error_detail: detail } : {}),
202
+ };
203
+ }