@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.
- package/README.md +1 -1
- package/dist/commands/browser-open.js +88 -0
- package/dist/commands/docs.js +32 -6
- package/dist/commands/doctor-report.js +17 -1
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/heartbeat.js +65 -1
- package/dist/commands/jarvis-answer-envelope.js +82 -0
- package/dist/commands/jarvis-render.js +18 -0
- package/dist/commands/jarvis-turn.js +29 -2
- package/dist/commands/jarvis.js +3 -0
- package/dist/commands/local-args-collector-setup.js +17 -0
- package/dist/commands/local-args-tower-admin.js +12 -2
- package/dist/commands/local-args-tower-chat.js +5 -0
- package/dist/commands/local-args-tower-docs-msg.js +105 -6
- package/dist/commands/local-args-tower-search.js +50 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +28 -13
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +655 -0
- package/dist/commands/local-help.js +11 -582
- package/dist/commands/local.js +3 -0
- package/dist/commands/login.js +91 -8
- package/dist/commands/memory-install-claude.js +35 -15
- package/dist/commands/memory-install-codex-hooks.js +200 -0
- package/dist/commands/memory-install-codex.js +12 -2
- package/dist/commands/memory-install-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +25 -1
- package/dist/commands/memory-install.js +76 -2
- package/dist/commands/msg.js +85 -2
- package/dist/commands/onboard-completion.js +47 -0
- package/dist/commands/onboard-setup.js +82 -2
- package/dist/commands/ops-render-memory.js +76 -0
- package/dist/commands/ops-render.js +6 -0
- package/dist/commands/ops.js +59 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +122 -0
- package/dist/commands/setup-receipt-lines.js +71 -0
- package/dist/commands/setup-receipt.js +241 -0
- package/dist/commands/status.js +20 -1
- package/dist/commands/tower-mcp-install.js +4 -2
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- 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":
|
package/dist/commands/ops.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|