@bli-cockpit/cli 0.2.58 → 0.2.60

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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/browser-open.js +88 -0
  3. package/dist/commands/docs.js +32 -6
  4. package/dist/commands/doctor-report.js +17 -1
  5. package/dist/commands/doctor.js +12 -2
  6. package/dist/commands/heartbeat.js +65 -1
  7. package/dist/commands/jarvis-answer-envelope.js +82 -0
  8. package/dist/commands/jarvis-render.js +18 -0
  9. package/dist/commands/jarvis-turn.js +29 -2
  10. package/dist/commands/jarvis.js +3 -0
  11. package/dist/commands/local-args-collector-setup.js +17 -0
  12. package/dist/commands/local-args-tower-admin.js +12 -2
  13. package/dist/commands/local-args-tower-chat.js +5 -0
  14. package/dist/commands/local-args-tower-docs-msg.js +105 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower.js +4 -1
  17. package/dist/commands/local-args.js +28 -13
  18. package/dist/commands/local-command-shapes.js +12 -0
  19. package/dist/commands/local-help-commands.js +655 -0
  20. package/dist/commands/local-help.js +11 -582
  21. package/dist/commands/local.js +3 -0
  22. package/dist/commands/login.js +91 -8
  23. package/dist/commands/memory-install-claude.js +35 -15
  24. package/dist/commands/memory-install-codex-hooks.js +200 -0
  25. package/dist/commands/memory-install-codex.js +12 -2
  26. package/dist/commands/memory-install-receipt.js +222 -0
  27. package/dist/commands/memory-install-report.js +25 -1
  28. package/dist/commands/memory-install.js +76 -2
  29. package/dist/commands/msg.js +85 -2
  30. package/dist/commands/onboard-completion.js +47 -0
  31. package/dist/commands/onboard-setup.js +82 -2
  32. package/dist/commands/ops-render-memory.js +76 -0
  33. package/dist/commands/ops-render.js +6 -0
  34. package/dist/commands/ops.js +59 -2
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/search.js +122 -0
  37. package/dist/commands/setup-receipt-lines.js +71 -0
  38. package/dist/commands/setup-receipt.js +241 -0
  39. package/dist/commands/status.js +20 -1
  40. package/dist/commands/tower-mcp-install.js +4 -2
  41. package/dist/local-state-pairing-code.js +200 -0
  42. package/dist/local-state.js +6 -0
  43. package/package.json +4 -4
@@ -0,0 +1,76 @@
1
+ /**
2
+ * How the BLI Memory adoption gauge reads in a terminal (BLI-3729). Pure
3
+ * layout: no io, no network, no clock of its own — the sibling of
4
+ * `ops-render.ts`, split out to keep both under the repo's readability band
5
+ * (`repo-shape/file-size-ratchet.test.ts`).
6
+ *
7
+ * Same rule as the fleet and coverage sections next door: every SENTENCE was
8
+ * written on the server (`lib/ops/memory-usage.ts`) and is printed verbatim;
9
+ * this file decides column widths and order and nothing else.
10
+ *
11
+ * Both public names are re-exported from `./ops-render.js`, so a caller of that
12
+ * module never has to know the split happened.
13
+ */
14
+ /**
15
+ * Is the org using BLI Memory? One row per person, one column per day, and the
16
+ * two agent sources counted apart from the import — an imported row is a
17
+ * backfill and must never be read as somebody using the thing.
18
+ */
19
+ export function renderMemoryUsage(memory, dim) {
20
+ const lines = ["", `MEMORY ${memory.summary ?? "(no summary)"}`];
21
+ if (memory.readError) {
22
+ lines.push(` usage could not be read (${memory.readError}); nothing is known about whether anybody is using BLI Memory`);
23
+ return lines;
24
+ }
25
+ const days = memory.days ?? [];
26
+ const people = memory.people ?? [];
27
+ if (people.length === 0) {
28
+ lines.push(dim(" nobody on the roster has any memory activity to show"));
29
+ return lines;
30
+ }
31
+ const nameWidth = Math.max(6, ...people.map((person) => (person.displayName ?? "").length));
32
+ // `MM-DD` per column: the year is the same on every one of seven days and
33
+ // spending four characters on it costs the terminal a column per day.
34
+ const header = ` ${"person".padEnd(nameWidth)} ` +
35
+ days.map((day) => day.slice(5).padStart(5)).join(" ") +
36
+ ` ${"mcp".padStart(5)} ${"hook".padStart(5)} ${"impt".padStart(5)}`;
37
+ lines.push(dim(header));
38
+ // Most active first, so the answer to "who is using it" is the top of the
39
+ // list; the unattributed bucket sinks to the bottom whatever its total,
40
+ // because it is not a person and its number is mostly the import.
41
+ const ranked = [...people].sort((left, right) => {
42
+ const leftOrphan = left.personId === null || left.personId === undefined ? 1 : 0;
43
+ const rightOrphan = right.personId === null || right.personId === undefined ? 1 : 0;
44
+ if (leftOrphan !== rightOrphan)
45
+ return leftOrphan - rightOrphan;
46
+ return agentSaves(right.counts) - agentSaves(left.counts);
47
+ });
48
+ for (const person of ranked) {
49
+ const byDay = new Map((person.days ?? []).map((entry) => [entry.day ?? "", entry]));
50
+ const cells = days
51
+ .map((day) => {
52
+ const entry = byDay.get(day);
53
+ const saved = agentSaves(entry?.counts);
54
+ // A zero prints as `·`, not `0`: seven zeroes in a row is the shape a
55
+ // reader is scanning for, and seven `0`s hide it in the noise.
56
+ return (saved === 0 ? "·" : String(saved)).padStart(5);
57
+ })
58
+ .join(" ");
59
+ const counts = person.counts ?? {};
60
+ const row = ` ${(person.displayName ?? "?").padEnd(nameWidth)} ${cells} ` +
61
+ `${String(counts.mcp ?? 0).padStart(5)} ${String(counts.stop_hook ?? 0).padStart(5)} ` +
62
+ `${String(counts.import ?? 0).padStart(5)}`;
63
+ lines.push(agentSaves(counts) > 0 ? row : dim(row));
64
+ }
65
+ if (memory.truncated) {
66
+ lines.push(dim(" this read stopped at its page cap, so every number above is a FLOOR — it can understate use and never invent it"));
67
+ }
68
+ if (memory.searches?.counted === false) {
69
+ lines.push(dim(` searches are not counted (${memory.searches.reason ?? "no ledger"}): nothing in Tower records one, so this is saves only`));
70
+ }
71
+ return lines;
72
+ }
73
+ /** The two sources that mean a PERSON's agent used memory. Never the import. */
74
+ function agentSaves(counts) {
75
+ return (counts?.mcp ?? 0) + (counts?.stop_hook ?? 0);
76
+ }
@@ -12,6 +12,7 @@
12
12
  * artifact does and does not prove — because that is exactly the moment
13
13
  * somebody is about to conclude something from it.
14
14
  */
15
+ export { renderMemoryUsage } from "./ops-render-memory.js";
15
16
  /** The word a person reads. Short, fixed width, and never a bare colour. */
16
17
  export function verdictWord(verdict) {
17
18
  switch (verdict) {
@@ -24,6 +25,11 @@ export function verdictWord(verdict) {
24
25
  // rather than to a cron config.
25
26
  case "failing":
26
27
  return "BAD";
28
+ // BLI-3762. Between "on time" and "dead": the job ran, produced something,
29
+ // and produced LESS than it owed. A partial result that is still moving
30
+ // needs a different next step from either neighbour, so it gets a word.
31
+ case "degraded":
32
+ return "PART";
27
33
  case "never_produced":
28
34
  return "EMPTY";
29
35
  case "unreadable":
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
27
27
  import { renderOpsStatus } from "./ops-render.js";
28
+ import { renderMemoryUsage, } from "./ops-render-memory.js";
28
29
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
29
30
  /**
30
31
  * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
@@ -60,6 +61,13 @@ async function runOpsStatus(command, io, tower) {
60
61
  }
61
62
  const payload = asRecord(result.body);
62
63
  const rows = payload.pipelines ?? [];
64
+ // BLI-3729. A SECOND door, asked for only when `--memory` was: the adoption
65
+ // gauge is a different question from pipeline health and a different read,
66
+ // and putting it on every `cockpit ops` would make the common run slower for
67
+ // a number most runs do not want. Its failure is its own line and never the
68
+ // status board's exit code — "is the org using memory" going unread is not a
69
+ // pipeline outage.
70
+ const memory = command.memory ? await readMemoryUsage(command, io, tower) : null;
63
71
  // BLI-3723: `failing` joins the list. An older CLI that has never heard of it
64
72
  // simply does not count it — which is why the server keeps the sentence in
65
73
  // `detail`, so an un-upgraded terminal still PRINTS the outage even when it
@@ -67,15 +75,28 @@ async function runOpsStatus(command, io, tower) {
67
75
  const unhealthy = rows.filter((row) => row.verdict === "stale" ||
68
76
  row.verdict === "never_produced" ||
69
77
  row.verdict === "unreadable" ||
70
- row.verdict === "failing");
78
+ row.verdict === "failing" ||
79
+ // BLI-3762: a job that ran and wrote less than it owed is not healthy.
80
+ row.verdict === "degraded");
71
81
  if (command.json) {
72
- writeLine(io.stdout, JSON.stringify(payload));
82
+ writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section } : payload));
73
83
  }
74
84
  else {
75
85
  const styled = colorEnabled(io);
76
86
  for (const line of renderOpsStatus(payload, (text) => dim(text, styled))) {
77
87
  writeLine(io.stdout, line);
78
88
  }
89
+ if (memory?.section) {
90
+ for (const line of renderMemoryUsage(memory.section, (text) => dim(text, styled))) {
91
+ writeLine(io.stdout, line);
92
+ }
93
+ }
94
+ else if (memory) {
95
+ // Never a silent gap where a section was asked for: the reason the gauge
96
+ // could not be read is printed where the gauge would have been.
97
+ writeLine(io.stdout, "");
98
+ writeLine(io.stdout, `MEMORY not read (${memory.reason})`);
99
+ }
79
100
  }
80
101
  // Both branches log — an all-green board that says nothing cannot answer
81
102
  // "did anybody look today?".
@@ -91,9 +112,45 @@ async function runOpsStatus(command, io, tower) {
91
112
  fleet_devices: payload.fleet?.counts?.devices ?? null,
92
113
  fleet_red: payload.fleet?.counts?.red ?? null,
93
114
  fleet_amber: payload.fleet?.counts?.amber ?? null,
115
+ // BLI-3729: whether the adoption gauge was asked for, and what it said.
116
+ // Both branches, as everywhere else here.
117
+ memory_asked: Boolean(command.memory),
118
+ memory_reason: memory?.reason ?? null,
119
+ memory_agent_saves: memory?.section
120
+ ? (memory.section.counts?.mcp ?? 0) + (memory.section.counts?.stop_hook ?? 0)
121
+ : null,
94
122
  })}`);
95
123
  return unhealthy.length > 0 ? 1 : 0;
96
124
  }
125
+ /**
126
+ * The adoption gauge, read through its own door.
127
+ *
128
+ * Never fails the board: a `cockpit ops --memory` whose memory half is
129
+ * unreadable still prints the pipelines, the fleet and the coverage, and says
130
+ * in one line which half is missing and why. The status board's exit code is
131
+ * about collection health, and adoption is not that.
132
+ */
133
+ async function readMemoryUsage(command, io, tower) {
134
+ const params = new URLSearchParams();
135
+ if (command.memoryDays !== undefined)
136
+ params.set("days", String(command.memoryDays));
137
+ const query = params.toString();
138
+ const result = await callTower(tower, {
139
+ path: `/api/ops/memory-usage${query ? `?${query}` : ""}`,
140
+ label: "ops-memory-usage",
141
+ });
142
+ if (!result.ok) {
143
+ writeLine(io.stderr, `[ops cli] memory usage not read ${JSON.stringify({
144
+ reason: result.reason,
145
+ http_status: result.httpStatus ?? null,
146
+ })}`);
147
+ return { section: null, reason: result.reason };
148
+ }
149
+ const body = asRecord(result.body);
150
+ if (!body.memory)
151
+ return { section: null, reason: "memory_section_absent" };
152
+ return { section: body.memory, reason: "ok" };
153
+ }
97
154
  async function runOpsRecompile(command, io, tower) {
98
155
  const person = command.person ?? "";
99
156
  if (!command.json) {
@@ -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.58");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.60");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cockpit search` — one bar over five corpora, in a terminal (BLI-3728).
3
+ *
4
+ * The SAME door the browser's search bar presses: `GET /api/search`, opted
5
+ * into the collector device token in `route-caller-census.test.ts`. So a
6
+ * result a person reads in the rail is the same row, the same ranking and the
7
+ * same snippet an agent gets here. That is the whole point of the ticket's
8
+ * "agent-friendly is the definition of done" clause; nothing about search is
9
+ * computed on this side.
10
+ *
11
+ * WHAT THIS PRINTS THAT THE BROWSER ALSO SHOWS
12
+ * --------------------------------------------
13
+ * A corpus that could not answer gets its own line. "Messages did not answer"
14
+ * and "no messages matched" are different facts, and a terminal that folds
15
+ * them together lets a person conclude the record is silent when the search
16
+ * was simply broken. Same rule, same words, as the overlay.
17
+ *
18
+ * `[[` / `]]` are `ts_headline`'s markers, chosen server-side precisely
19
+ * BECAUSE they are inert in a terminal (`src/lib/search/snippet.ts`). Without
20
+ * a TTY they are stripped; with one they become reverse video, and nothing
21
+ * here parses HTML.
22
+ *
23
+ * A refusal keeps the door's own `reason` label verbatim (`agent-door.ts`):
24
+ * `needs_rls_client`, `query_too_short`, `query_too_long`, `unknown_kind`,
25
+ * `read_failed` — never rephrased here.
26
+ */
27
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
28
+ import { writeLine } from "./cli-io.js";
29
+ const TAG = "[search cli]";
30
+ const READ_DEADLINE_MS = 30_000;
31
+ /** Group headings, in the order a person reads them. Mirrors the overlay. */
32
+ const KIND_LABELS = {
33
+ doc: "DOCUMENTS",
34
+ msg: "MESSAGES",
35
+ issue: "ISSUES",
36
+ note: "MEETING NOTES",
37
+ memory: "MEMORY",
38
+ };
39
+ const KIND_ORDER = ["doc", "msg", "issue", "note", "memory"];
40
+ export async function runSearch(command, io) {
41
+ const door = await openAgentDoor("search", command, io);
42
+ const params = new URLSearchParams({ q: command.query });
43
+ if (command.kinds && command.kinds.length > 0)
44
+ params.set("kinds", command.kinds.join(","));
45
+ if (command.limit !== undefined)
46
+ params.set("limit", String(command.limit));
47
+ const answer = await askAgentDoor(door, {
48
+ path: `/api/search?${params.toString()}`,
49
+ method: "GET",
50
+ label: "search",
51
+ timeoutMs: READ_DEADLINE_MS,
52
+ });
53
+ if (!answer.ok)
54
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
55
+ const body = answer.body;
56
+ if (door.json)
57
+ return emitAgentDoor(door, body);
58
+ return render(door, body);
59
+ }
60
+ function render(door, body) {
61
+ const hits = body.hits ?? [];
62
+ const failures = body.failures ?? {};
63
+ const colour = Boolean(door.io.stdout.isTTY);
64
+ if (hits.length === 0) {
65
+ if (Object.keys(failures).length === 0) {
66
+ writeLine(door.io.stdout, "Nothing matched. Every corpus answered — this is silence, not a failure.");
67
+ }
68
+ else {
69
+ writeLine(door.io.stdout, "Nothing matched in the corpora that answered.");
70
+ }
71
+ }
72
+ for (const kind of KIND_ORDER) {
73
+ const group = hits.filter((hit) => hit.kind === kind);
74
+ if (group.length === 0)
75
+ continue;
76
+ writeLine(door.io.stdout, "");
77
+ writeLine(door.io.stdout, KIND_LABELS[kind] ?? kind.toUpperCase());
78
+ for (const hit of group) {
79
+ writeLine(door.io.stdout, ` ${hit.title}`);
80
+ const snippet = paintSnippet(hit.snippet, colour);
81
+ if (snippet.length > 0)
82
+ writeLine(door.io.stdout, ` ${snippet}`);
83
+ const meta = [
84
+ hit.author,
85
+ hit.date ? hit.date.slice(0, 10) : null,
86
+ (hit.channels ?? []).join("+"),
87
+ `score ${hit.score}`,
88
+ // A memory has no address, and saying so is better than printing a
89
+ // blank column somebody reads as a missing link.
90
+ hit.href ?? "(no page — the text above is the whole record)",
91
+ ]
92
+ .filter(Boolean)
93
+ .join(" · ");
94
+ writeLine(door.io.stdout, ` ${meta}`);
95
+ }
96
+ }
97
+ // Never folded into "nothing matched". Same rule as the overlay.
98
+ const failed = Object.entries(failures);
99
+ if (failed.length > 0) {
100
+ writeLine(door.io.stdout, "");
101
+ for (const [kind, reason] of failed) {
102
+ writeLine(door.io.stdout, `${KIND_LABELS[kind] ?? kind.toUpperCase()} did not answer (${reason}). Nothing from there is in this list.`);
103
+ }
104
+ }
105
+ writeLine(door.io.stdout, "");
106
+ writeLine(door.io.stdout, `${hits.length} result(s) in ${body.elapsedMs ?? 0} ms.`);
107
+ return 0;
108
+ }
109
+ const REVERSE_ON = "\u001B[7m";
110
+ const REVERSE_OFF = "\u001B[27m";
111
+ /**
112
+ * `[[match]]` into reverse video, or into plain text without a TTY.
113
+ *
114
+ * A pipe gets clean text — `cockpit search x | grep` must not have to know
115
+ * about escape codes — and a terminal gets the highlight the browser shows.
116
+ */
117
+ export function paintSnippet(snippet, colour) {
118
+ const collapsed = snippet.replace(/\s+/g, " ").trim();
119
+ if (!colour)
120
+ return collapsed.split("[[").join("").split("]]").join("");
121
+ return collapsed.split("[[").join(REVERSE_ON).split("]]").join(REVERSE_OFF);
122
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What the setup receipt SAYS (BLI-3731). One line, then one fix per gap.
3
+ *
4
+ * Split from `setup-receipt.ts` for the reason the repo splits everything:
5
+ * computing a state and wording it are different jobs, and only one of them
6
+ * touches the machine. This file touches nothing.
7
+ *
8
+ * The rule for the fix lines is the one the message standard already sets — a
9
+ * refusal that does not say what to do next is half a message. Every gap here
10
+ * gets ONE command or ONE click, never a paragraph and never "see the docs".
11
+ */
12
+ import { setupPieceMark, setupReceiptLine, setupReceiptPieces, } from "@bli-cockpit/telemetry-core";
13
+ /** The one fix for each piece, by key. Present for every key the block prints. */
14
+ const FIXES = {
15
+ browser: "Run `cockpit login` and open the link it prints.",
16
+ device: "Run `cockpit login` — this machine's session has lapsed.",
17
+ "claude.mcp": "Run `cockpit memory install`.",
18
+ "claude.hooks": "Run `cockpit memory install`.",
19
+ "codex.mcp": "Run `cockpit memory install`.",
20
+ "codex.skill": "Run `cockpit memory install`.",
21
+ "codex.hooks": "Run `cockpit memory install`.",
22
+ "collector.autostart": "Run `cockpit autostart install`.",
23
+ };
24
+ /** Codex hooks a person switched OFF is a decision, not a fault. */
25
+ const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
26
+ /**
27
+ * The one exception to "a gap gets a command": Codex hooks that are installed
28
+ * and waiting to be trusted are finished by a PERSON, in the Codex UI, and no
29
+ * command we could print would do it.
30
+ */
31
+ const NEEDS_TRUST_FIX = "Open Codex and press `/hooks` to trust them (once, ~15s).";
32
+ /**
33
+ * The block a terminal prints: the one line, then one fix per gap, then
34
+ * nothing. A fully connected machine gets one line and no advice — a receipt
35
+ * that keeps talking after saying yes trains people to stop reading it.
36
+ */
37
+ export function setupReceiptBlock(reading, options = {}) {
38
+ const { receipt, memory } = reading;
39
+ const indent = options.indent ?? "";
40
+ const lines = [`${indent}${setupReceiptLine(receipt, memory)}`];
41
+ for (const { key, label, piece } of setupReceiptPieces(receipt, memory)) {
42
+ if (piece.status === "ok")
43
+ continue;
44
+ lines.push(`${indent} ${label} ${setupPieceMark(piece.status)}${reasonSuffix(piece)} — ${fixFor(key, piece)}`);
45
+ }
46
+ if (options.showCheckedAt) {
47
+ lines.push(`${indent}Last read: ${receipt.checked_at}`);
48
+ }
49
+ return lines;
50
+ }
51
+ /** The whole receipt in one string, for a single `writeLine`. */
52
+ export function setupReceiptText(reading, options = {}) {
53
+ return setupReceiptBlock(reading, options).join("\n");
54
+ }
55
+ function fixFor(key, piece) {
56
+ if (piece.status === "needs_trust")
57
+ return NEEDS_TRUST_FIX;
58
+ if (piece.status === "unsupported")
59
+ return UNSUPPORTED_FIX;
60
+ if (piece.status === "unknown") {
61
+ // "We did not look" is not somebody's chore. Say what would look.
62
+ return "Not read this run. Run `cockpit doctor` to check it.";
63
+ }
64
+ if (piece.status === "skipped") {
65
+ return "Skipped on purpose; nothing to do.";
66
+ }
67
+ return FIXES[key] ?? "Run `cockpit doctor`.";
68
+ }
69
+ function reasonSuffix(piece) {
70
+ return piece.reason ? ` (${piece.reason})` : "";
71
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * "Is this machine actually connected?" — computed here, on the machine
3
+ * (BLI-3731).
4
+ *
5
+ * Seven words, every one of them READ BACK off this host rather than
6
+ * remembered from what we wrote. That rule is BLI-2541's, and it is the whole
7
+ * value of this module: `cockpit autostart` once registered a correct Windows
8
+ * task and then rejected it on every machine for months because it compared
9
+ * the readback against the strings Cockpit itself had sent.
10
+ *
11
+ * Where each word comes from:
12
+ *
13
+ * browser the device session on disk names a person. A device
14
+ * token exists ONLY because somebody signed a browser
15
+ * in, so the session file is the evidence — there is no
16
+ * second thing to ask.
17
+ * device that same session, still unexpired.
18
+ * collector.autostart `autostartStatus`, which asks launchd or Task
19
+ * Scheduler what it actually has registered.
20
+ *
21
+ * The five AGENT-HOST words in the printed block are NOT computed here. They
22
+ * are BLI-3729's `MemoryInstallReceipt`, already read back off
23
+ * `~/.claude.json`, `~/.claude/settings.json` and `~/.codex/config.toml`,
24
+ * already cached beside this one and already carried on the heartbeat as
25
+ * `memory_install`. This module reads that cache and hands both halves to the
26
+ * renderer. A second computation of the same five words would be a second
27
+ * place for them to be wrong.
28
+ *
29
+ * A piece this run did not inspect is `unknown`, never `missing`. The two are
30
+ * different facts and only one of them is somebody's problem.
31
+ *
32
+ * The SHAPE lives in `@bli-cockpit/telemetry-core` so the CLI and the
33
+ * dashboard word the same machine the same way; `memory-install-contract.ts`
34
+ * owns the target vocabulary this translates from.
35
+ */
36
+ import os from "node:os";
37
+ import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
38
+ import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
39
+ import { autostartStatus } from "../autostart.js";
40
+ import { readLocalSessionReference } from "../local-state.js";
41
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
42
+ import { readJsonFile, writeJsonFile } from "../local-state-files.js";
43
+ const TAG = "[setup receipt]";
44
+ /** Where the last computed receipt is cached, so a heartbeat need not re-probe. */
45
+ export const SETUP_RECEIPT_CACHE_FILE = "setup-receipt.json";
46
+ /**
47
+ * How stale a cached receipt may be before the heartbeat stops sending it.
48
+ * Seven days: these facts change about monthly, and re-probing them costs five
49
+ * file reads and a `--print-config` spawn that a 15-minute tick must not pay.
50
+ */
51
+ export const SETUP_RECEIPT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
52
+ export async function buildSetupReceipt(io, options = {}) {
53
+ const homeDir = options.homeDir ?? os.homedir();
54
+ const checkedAt = (options.now ?? new Date()).toISOString();
55
+ const probes = withDefaultProbes(io, homeDir, options);
56
+ const memory = await probes.memory().catch((error) => {
57
+ console.error(`${TAG} the memory receipt could not be read`, JSON.stringify({ reason: "memory_receipt_read_failed", error_name: errorName(error) }));
58
+ return null;
59
+ });
60
+ const session = await probes.session().catch((error) => {
61
+ console.error(`${TAG} device session could not be read`, JSON.stringify({ reason: "session_probe_failed", error_name: errorName(error) }));
62
+ return null;
63
+ });
64
+ const autostart = await probes.autostart().catch((error) => {
65
+ console.error(`${TAG} scheduler could not be read`, JSON.stringify({ reason: "autostart_probe_failed", error_name: errorName(error) }));
66
+ return { status: "unreadable" };
67
+ });
68
+ const receipt = {
69
+ schema_version: SETUP_RECEIPT_SCHEMA_VERSION,
70
+ checked_at: checkedAt,
71
+ browser: browserPiece(session),
72
+ device: devicePiece(session),
73
+ collector: { autostart: autostartPiece(autostart) },
74
+ };
75
+ const gaps = setupReceiptGaps(receipt, memory);
76
+ console.error(`${TAG} read back`, JSON.stringify({
77
+ reason: gaps.length === 0 ? "everything_connected" : "gaps_found",
78
+ gap_count: gaps.length,
79
+ gaps,
80
+ memory_receipt: memory ? "read" : "absent",
81
+ }));
82
+ return { receipt, memory };
83
+ }
84
+ function withDefaultProbes(io, homeDir, options) {
85
+ return {
86
+ memory: options.probes?.memory ??
87
+ (async () => (await readHeartbeatMemoryReceipt({
88
+ homeDir,
89
+ ...(options.now ? { now: options.now } : {}),
90
+ })).receipt),
91
+ session: options.probes?.session ??
92
+ (async () => {
93
+ const reference = await readLocalSessionReference(getCollectorRuntimePaths(homeDir));
94
+ return {
95
+ state: reference.session_state,
96
+ ...(reference.email ? { email: reference.email } : {}),
97
+ };
98
+ }),
99
+ autostart: options.probes?.autostart ??
100
+ (async () => {
101
+ // The scheduler can only be READ through a process runner; a machine
102
+ // whose io carries none is unknown, never "absent" (BLI-2541: never
103
+ // report a state you did not observe).
104
+ const exec = io.exec;
105
+ if (!exec)
106
+ return { status: "unreadable", message: "runner_unavailable" };
107
+ const result = await autostartStatus({ homeDir, exec });
108
+ return {
109
+ status: result.status,
110
+ ...(result.message ? { message: result.message } : {}),
111
+ };
112
+ }),
113
+ };
114
+ }
115
+ /**
116
+ * A device token exists only because a person signed a browser in and claimed
117
+ * this machine, so a session that names an email IS the browser receipt. A
118
+ * session with no email came from the manual admin-approval path, which is a
119
+ * real way to pair and not a browser sign-in — it says so rather than claiming
120
+ * a click nobody made.
121
+ */
122
+ function browserPiece(session) {
123
+ if (!session)
124
+ return { status: "unknown", reason: "session_unreadable" };
125
+ if (session.state !== "valid") {
126
+ return { status: "missing", reason: "never_signed_in" };
127
+ }
128
+ return session.email
129
+ ? { status: "ok", reason: "signed_in" }
130
+ : { status: "ok", reason: "paired_without_email" };
131
+ }
132
+ function devicePiece(session) {
133
+ if (!session)
134
+ return { status: "unknown", reason: "session_unreadable" };
135
+ if (session.state === "valid")
136
+ return { status: "ok", reason: "session_valid" };
137
+ if (session.state === "expired") {
138
+ return { status: "missing", reason: "session_expired" };
139
+ }
140
+ return { status: "missing", reason: `session_${session.state}` };
141
+ }
142
+ /**
143
+ * The scheduler is `ok` only when the host says it is registered AND loaded.
144
+ * `installed` on macOS means the plist is on disk; `loaded` means launchd has
145
+ * it. A plist nobody loaded runs nothing, which is exactly the failure that
146
+ * looks fine from the outside.
147
+ */
148
+ function autostartPiece(result) {
149
+ if (result.status === "loaded" || result.status === "installed") {
150
+ return { status: "ok", reason: `autostart_${result.status}` };
151
+ }
152
+ if (result.status === "not_loaded") {
153
+ return { status: "missing", reason: "autostart_not_loaded" };
154
+ }
155
+ if (result.status === "absent" || result.status === "uninstalled") {
156
+ return { status: "missing", reason: "autostart_absent" };
157
+ }
158
+ if (result.status === "unsupported") {
159
+ return { status: "skipped", reason: "platform_unsupported" };
160
+ }
161
+ return { status: "unknown", reason: normalizeReason(result.status) };
162
+ }
163
+ /** Reason labels are `[a-z0-9_:.-]` by schema; anything else becomes one word. */
164
+ function normalizeReason(value) {
165
+ const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9_:.-]+/gu, "_");
166
+ return cleaned.slice(0, 120) || "unlabelled";
167
+ }
168
+ function errorName(error) {
169
+ return error instanceof Error ? error.name : typeof error;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // The cache — so a 15-minute tick does not re-probe a monthly fact
173
+ // ---------------------------------------------------------------------------
174
+ export function setupReceiptCachePath(homeDir) {
175
+ const paths = getCollectorRuntimePaths(homeDir ?? os.homedir());
176
+ return `${paths.state_dir}/${SETUP_RECEIPT_CACHE_FILE}`;
177
+ }
178
+ /** Never fails a caller: a cache that could not be written names itself and moves on. */
179
+ export async function cacheSetupReceipt(receipt, homeDir) {
180
+ try {
181
+ await writeJsonFile(setupReceiptCachePath(homeDir), receipt);
182
+ }
183
+ catch (error) {
184
+ console.error(`${TAG} cache not written`, JSON.stringify({ reason: "cache_write_failed", error_name: errorName(error) }));
185
+ }
186
+ }
187
+ /**
188
+ * The cached receipt, or null with a named reason. A receipt older than
189
+ * `SETUP_RECEIPT_MAX_AGE_MS` is refused rather than sent: a stale word is
190
+ * worse than an absent one, because absence asks and staleness answers.
191
+ */
192
+ export async function readCachedSetupReceipt(homeDir, now = new Date()) {
193
+ let raw;
194
+ try {
195
+ raw = await readJsonFile(setupReceiptCachePath(homeDir));
196
+ }
197
+ catch {
198
+ // Absent on a machine that has never run the install. Not an error, and
199
+ // the caller logs the absence with its own reason.
200
+ return null;
201
+ }
202
+ const parsed = safeParseReceipt(raw);
203
+ if (!parsed) {
204
+ console.error(`${TAG} cache unusable`, JSON.stringify({ reason: "cache_not_a_receipt" }));
205
+ return null;
206
+ }
207
+ const age = now.getTime() - Date.parse(parsed.checked_at);
208
+ if (!Number.isFinite(age) || age > SETUP_RECEIPT_MAX_AGE_MS) {
209
+ console.error(`${TAG} cache too old to send`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
210
+ return null;
211
+ }
212
+ return parsed;
213
+ }
214
+ function safeParseReceipt(raw) {
215
+ const record = raw;
216
+ if (!record || record.schema_version !== SETUP_RECEIPT_SCHEMA_VERSION)
217
+ return null;
218
+ return raw;
219
+ }
220
+ /** The one line, plus the one fix per gap. Rendering lives in `setup-receipt-lines.ts`. */
221
+ export { setupReceiptLine };
222
+ /**
223
+ * Read the machine, cache what it said, and hand it back. The ONE entry point
224
+ * for every surface that shows the receipt (`status`, `doctor`, `onboard`,
225
+ * `memory install`) — so the block a person reads in a terminal and the block
226
+ * the ops board renders are always the same reading, not two.
227
+ *
228
+ * Never throws: a receipt that could not be computed is `null` with a named
229
+ * reason, and the caller prints nothing rather than a wrong reassurance.
230
+ */
231
+ export async function refreshSetupReceipt(io, options = {}) {
232
+ try {
233
+ const reading = await buildSetupReceipt(io, options);
234
+ await cacheSetupReceipt(reading.receipt, options.homeDir);
235
+ return reading;
236
+ }
237
+ catch (error) {
238
+ console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed", error_name: errorName(error) }));
239
+ return null;
240
+ }
241
+ }