@bli-cockpit/cli 0.2.58 → 0.2.59
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/dist/commands/browser-open.js +88 -0
- package/dist/commands/docs.js +27 -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 +80 -0
- package/dist/commands/jarvis-turn.js +16 -1
- 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-docs-msg.js +95 -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 +24 -13
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +643 -0
- package/dist/commands/local-help.js +8 -581
- 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 +1 -0
- package/dist/commands/ops.js +56 -1
- 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/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- package/package.json +4 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MEMORY RECEIPT (BLI-3729) — six words this machine can prove.
|
|
3
|
+
*
|
|
4
|
+
* `cockpit memory install` already knew, per target, what it had just done.
|
|
5
|
+
* What nobody could ask was the fleet question: "does Savina's machine have
|
|
6
|
+
* memory hooks?" This module turns the install's own per-target results into
|
|
7
|
+
* the small fixed shape that rides the heartbeat
|
|
8
|
+
* (`@bli-cockpit/telemetry-core`'s `MemoryInstallReceipt`) and that
|
|
9
|
+
* `lib/ops/fleet-liveness.ts` reads back on the board.
|
|
10
|
+
*
|
|
11
|
+
* **It is built from targets that were READ BACK, never from what was sent**
|
|
12
|
+
* (BLI-2541). Every target it consumes has already re-read and re-parsed the
|
|
13
|
+
* stored file the way the host will: `installed` means the bytes on disk parse
|
|
14
|
+
* back to what we wrote, `already` means they already did. This module adds no
|
|
15
|
+
* new claim; it names the six pieces and carries the reason each one is not
|
|
16
|
+
* `ok`.
|
|
17
|
+
*
|
|
18
|
+
* **One module, on purpose.** BLI-3731 folds this receipt into a one-login
|
|
19
|
+
* ceremony, so the shape has exactly one producer here and exactly one reader
|
|
20
|
+
* on the server. Anything that wants to know whether a machine has memory on
|
|
21
|
+
* calls `buildMemoryInstallReceipt` — it does not re-derive six words from a
|
|
22
|
+
* target list of its own.
|
|
23
|
+
*
|
|
24
|
+
* ## The one word that is not about a file
|
|
25
|
+
*
|
|
26
|
+
* `codex.hooks` can be `needs_trust`: installed correctly, and not running,
|
|
27
|
+
* because Codex requires a person to review and trust a non-managed command
|
|
28
|
+
* hook before it fires (`/hooks`; see `memory-install-codex-hooks.ts` for the
|
|
29
|
+
* citation). That state exists in no other piece and is the reason this is six
|
|
30
|
+
* words rather than a boolean.
|
|
31
|
+
*/
|
|
32
|
+
import { MEMORY_INSTALL_RECEIPT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
33
|
+
/**
|
|
34
|
+
* The one place the six words are decided.
|
|
35
|
+
*
|
|
36
|
+
* A target this run never produced (an older CLI, a half-run that threw) is
|
|
37
|
+
* `unknown`, never `missing`: "we did not look" and "it is not there" are
|
|
38
|
+
* different facts and the board acts on them differently.
|
|
39
|
+
*/
|
|
40
|
+
export function buildMemoryInstallReceipt(input) {
|
|
41
|
+
const reasons = {};
|
|
42
|
+
const piece = (id, key) => {
|
|
43
|
+
const target = input.targets.find((entry) => entry.target === id);
|
|
44
|
+
if (!target) {
|
|
45
|
+
reasons[key] = input.binFound ? "target_not_checked" : "bin_missing";
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
const word = pieceFor(target.status);
|
|
49
|
+
if (word !== "ok")
|
|
50
|
+
reasons[key] = reasonLabel(target.reason);
|
|
51
|
+
return word;
|
|
52
|
+
};
|
|
53
|
+
const codexHooks = judgeCodexHooks(input, reasons);
|
|
54
|
+
const receipt = {
|
|
55
|
+
schema_version: MEMORY_INSTALL_RECEIPT_SCHEMA_VERSION,
|
|
56
|
+
checked_at: (input.now ?? new Date()).toISOString(),
|
|
57
|
+
claude: {
|
|
58
|
+
mcp: piece("claude_mcp", "claude.mcp"),
|
|
59
|
+
hooks: piece("claude_hooks", "claude.hooks"),
|
|
60
|
+
},
|
|
61
|
+
codex: {
|
|
62
|
+
mcp: piece("codex_mcp", "codex.mcp"),
|
|
63
|
+
skill: piece("codex_skills", "codex.skill"),
|
|
64
|
+
hooks: codexHooks,
|
|
65
|
+
},
|
|
66
|
+
bin_found: input.binFound,
|
|
67
|
+
...(Object.keys(reasons).length > 0 ? { reasons } : {}),
|
|
68
|
+
};
|
|
69
|
+
return receipt;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `codex.hooks` is judged in three steps, in this order, and the order is the
|
|
73
|
+
* point:
|
|
74
|
+
*
|
|
75
|
+
* 1. A person who turned hooks OFF is `unsupported`. That is a decision, and
|
|
76
|
+
* reporting it as a missing install would put a machine on somebody's
|
|
77
|
+
* morning board for doing what its owner asked.
|
|
78
|
+
* 2. A file target that is not installed loses; there is nothing to trust.
|
|
79
|
+
* 3. Only then does trust decide, and it can only demote: a correctly written
|
|
80
|
+
* hooks file with no trust rows is `needs_trust`, which is INSTALLED and
|
|
81
|
+
* NOT RUNNING.
|
|
82
|
+
*/
|
|
83
|
+
function judgeCodexHooks(input, reasons) {
|
|
84
|
+
const key = "codex.hooks";
|
|
85
|
+
if (input.codexTrust.feature === "disabled") {
|
|
86
|
+
reasons[key] = "hooks_disabled_in_config";
|
|
87
|
+
return "unsupported";
|
|
88
|
+
}
|
|
89
|
+
const target = input.targets.find((entry) => entry.target === "codex_hooks");
|
|
90
|
+
if (!target) {
|
|
91
|
+
reasons[key] = input.binFound ? "target_not_checked" : "bin_missing";
|
|
92
|
+
return "unknown";
|
|
93
|
+
}
|
|
94
|
+
const word = pieceFor(target.status);
|
|
95
|
+
if (word !== "ok") {
|
|
96
|
+
reasons[key] = reasonLabel(target.reason);
|
|
97
|
+
return word;
|
|
98
|
+
}
|
|
99
|
+
if (input.codexTrust.feature === "config_unreadable") {
|
|
100
|
+
// The hooks are written and correct; whether Codex will run them could not
|
|
101
|
+
// be read. Say which half is unknown rather than claiming either.
|
|
102
|
+
reasons[key] = "trust_unreadable";
|
|
103
|
+
return "unknown";
|
|
104
|
+
}
|
|
105
|
+
if (input.codexTrust.trustedRows >= input.expectedTrustRows)
|
|
106
|
+
return "ok";
|
|
107
|
+
reasons[key] =
|
|
108
|
+
input.codexTrust.disabledRows > 0 ? "trust_rows_disabled" : "no_trust_rows";
|
|
109
|
+
return "needs_trust";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A target's status collapsed to one of the receipt's words.
|
|
113
|
+
*
|
|
114
|
+
* `would_install` (a dry run) is `unknown` on purpose: a dry run proves what
|
|
115
|
+
* WOULD happen and nothing about what is on disk, and a receipt that treated
|
|
116
|
+
* it as a reading would let `--dry-run` publish a state nobody wrote.
|
|
117
|
+
*/
|
|
118
|
+
function pieceFor(status) {
|
|
119
|
+
switch (status) {
|
|
120
|
+
case "installed":
|
|
121
|
+
case "already":
|
|
122
|
+
return "ok";
|
|
123
|
+
case "mismatch":
|
|
124
|
+
return "stale";
|
|
125
|
+
case "missing":
|
|
126
|
+
return "missing";
|
|
127
|
+
case "skipped":
|
|
128
|
+
// Nothing was written, on purpose — today only `bin_missing`. The config
|
|
129
|
+
// is untouched, so "missing" is the true reading of the file.
|
|
130
|
+
return "missing";
|
|
131
|
+
case "would_install":
|
|
132
|
+
case "failed":
|
|
133
|
+
default:
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** The receipt's reason labels are charset-bounded; a stray reason is clipped. */
|
|
138
|
+
function reasonLabel(reason) {
|
|
139
|
+
const cleaned = reason.trim().replace(/[^A-Za-z0-9_:.-]/gu, "_");
|
|
140
|
+
if (cleaned.length === 0)
|
|
141
|
+
return "unlabelled";
|
|
142
|
+
return cleaned.slice(0, 60);
|
|
143
|
+
}
|
|
144
|
+
/** `claude ✓ hooks ✓ · codex mcp ✓ skill ✓ hooks needs_trust` — one line. */
|
|
145
|
+
export function memoryReceiptLine(receipt) {
|
|
146
|
+
const mark = (word) => (word === "ok" ? "✓" : word);
|
|
147
|
+
return (`claude mcp ${mark(receipt.claude.mcp)} hooks ${mark(receipt.claude.hooks)} · ` +
|
|
148
|
+
`codex mcp ${mark(receipt.codex.mcp)} skill ${mark(receipt.codex.skill)} ` +
|
|
149
|
+
`hooks ${mark(receipt.codex.hooks)}`);
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Where the receipt lives between runs
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
/**
|
|
155
|
+
* The receipt is CACHED on disk, and the heartbeat reads the cache.
|
|
156
|
+
*
|
|
157
|
+
* The alternative — recomputing it inside the heartbeat — costs five file
|
|
158
|
+
* reads AND a `bli-memory-mcp --print-config` spawn every fifteen minutes on
|
|
159
|
+
* every machine, to re-prove a state that changes about once a month. The
|
|
160
|
+
* install and status paths already do that work; this is where they leave the
|
|
161
|
+
* answer. `checked_at` travels with it, so a stale reading says how stale it
|
|
162
|
+
* is rather than pretending to be now.
|
|
163
|
+
*
|
|
164
|
+
* A cache that cannot be written is logged and dropped: the install still
|
|
165
|
+
* happened, and a heartbeat with no receipt reads as "not reported", never as
|
|
166
|
+
* "not installed".
|
|
167
|
+
*/
|
|
168
|
+
export const MEMORY_RECEIPT_FILE = "memory-install-receipt.json";
|
|
169
|
+
/** How old a cached receipt may be before the heartbeat stops sending it. */
|
|
170
|
+
export const MEMORY_RECEIPT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
171
|
+
export async function writeMemoryReceiptFile(options) {
|
|
172
|
+
const file = joinState(options.stateDir, MEMORY_RECEIPT_FILE);
|
|
173
|
+
try {
|
|
174
|
+
await options.io.mkdir(options.stateDir);
|
|
175
|
+
await options.io.writeFile(file, `${JSON.stringify(options.receipt, null, 2)}\n`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error("[memory-install] the memory receipt could not be cached for the heartbeat", JSON.stringify({
|
|
180
|
+
reason: "receipt_cache_write_failed",
|
|
181
|
+
error_name: error instanceof Error ? error.name : "unknown",
|
|
182
|
+
}));
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The cached receipt, or a named reason there is none. Never throws: a
|
|
188
|
+
* heartbeat must not fail over a cache file.
|
|
189
|
+
*/
|
|
190
|
+
export async function readMemoryReceiptFile(options) {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = await options.readText(joinState(options.stateDir, MEMORY_RECEIPT_FILE));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return { receipt: null, reason: "receipt_absent" };
|
|
197
|
+
}
|
|
198
|
+
if (raw === null || !raw.trim())
|
|
199
|
+
return { receipt: null, reason: "receipt_absent" };
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(raw);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return { receipt: null, reason: "receipt_unparseable" };
|
|
206
|
+
}
|
|
207
|
+
const receipt = options.parse(parsed);
|
|
208
|
+
if (!receipt)
|
|
209
|
+
return { receipt: null, reason: "receipt_unparseable" };
|
|
210
|
+
const age = (options.now ?? new Date()).getTime() - Date.parse(receipt.checked_at);
|
|
211
|
+
// A reading a week old is not a reading. Better to send nothing and let the
|
|
212
|
+
// board say "not reported" than to publish a claim about a machine that has
|
|
213
|
+
// not been looked at since.
|
|
214
|
+
if (Number.isFinite(age) && age > MEMORY_RECEIPT_MAX_AGE_MS) {
|
|
215
|
+
return { receipt: null, reason: "receipt_expired" };
|
|
216
|
+
}
|
|
217
|
+
return { receipt, reason: "receipt_read" };
|
|
218
|
+
}
|
|
219
|
+
function joinState(stateDir, name) {
|
|
220
|
+
const separator = stateDir.includes("\\") && !stateDir.includes("/") ? "\\" : "/";
|
|
221
|
+
return stateDir.endsWith(separator) ? `${stateDir}${name}` : `${stateDir}${separator}${name}`;
|
|
222
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard URL to register against, aggregating per-target results into
|
|
3
|
+
* one outcome, and rendering that outcome for a log line or a terminal.
|
|
4
|
+
* Sibling of `memory-install.ts`, named in its header.
|
|
5
|
+
*/
|
|
6
|
+
import { memoryInstallGaps } from "@bli-cockpit/telemetry-core";
|
|
7
|
+
import { memoryReceiptLine } from "./memory-install-receipt.js";
|
|
1
8
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
2
9
|
export async function resolveDashboardUrl(command, deps) {
|
|
3
10
|
if (command.dashboardUrl)
|
|
@@ -60,6 +67,13 @@ export function logMemoryOutcome(outcome, platform) {
|
|
|
60
67
|
failed: outcome.targets
|
|
61
68
|
.filter((target) => target.status === "failed")
|
|
62
69
|
.map((target) => `${target.target}:${target.reason}`),
|
|
70
|
+
// BLI-3729: the six words, on the same line. `sync.err.log` is where a
|
|
71
|
+
// person looks when the board says their machine is missing a piece, and
|
|
72
|
+
// the log that only named per-target statuses could not answer "is memory
|
|
73
|
+
// ON here?" without the reader collapsing five rows in their head.
|
|
74
|
+
memory_on: memoryInstallGaps(outcome.receipt).length === 0,
|
|
75
|
+
memory_gaps: memoryInstallGaps(outcome.receipt),
|
|
76
|
+
codex_hooks: outcome.receipt.codex.hooks,
|
|
63
77
|
};
|
|
64
78
|
// stderr on both branches: launchd captures it to sync.err.log, and stdout is
|
|
65
79
|
// reserved for `--json`.
|
|
@@ -79,7 +93,17 @@ export function memoryOutcomeLines(outcome) {
|
|
|
79
93
|
: outcome.status === "skipped"
|
|
80
94
|
? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
|
|
81
95
|
: `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
82
|
-
const lines = [headline];
|
|
96
|
+
const lines = [headline, ` ${memoryReceiptLine(outcome.receipt)}`];
|
|
97
|
+
// The trust step is the one thing a person has to do by hand, so it is said
|
|
98
|
+
// here in words rather than left as a status word they have to look up.
|
|
99
|
+
if (outcome.receipt.codex.hooks === "needs_trust") {
|
|
100
|
+
lines.push(" Codex will NOT run these hooks until you trust them: open Codex in any folder, " +
|
|
101
|
+
"run /hooks, and trust the three bli-memory entries. One time, per machine.");
|
|
102
|
+
}
|
|
103
|
+
if (outcome.receipt.codex.hooks === "unsupported") {
|
|
104
|
+
lines.push(" Codex hooks are turned off in your ~/.codex/config.toml ([features] hooks = false), " +
|
|
105
|
+
"so the hooks were written and will not run. That is your setting; nothing here changes it.");
|
|
106
|
+
}
|
|
83
107
|
for (const target of outcome.targets) {
|
|
84
108
|
const where = target.path ? ` ${target.path}` : "";
|
|
85
109
|
const detail = target.detail ? ` — ${target.detail}` : "";
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*
|
|
41
41
|
* Every public name is still importable from this file.
|
|
42
42
|
*/
|
|
43
|
+
import fs from "node:fs/promises";
|
|
43
44
|
import os from "node:os";
|
|
44
45
|
import { writeLine } from "./cli-io.js";
|
|
45
46
|
import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-install.js";
|
|
@@ -47,19 +48,42 @@ import { defaultMemoryFileIo, } from "./memory-install-files.js";
|
|
|
47
48
|
import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
|
|
48
49
|
import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
|
|
49
50
|
import { resolveMemoryConfig } from "./memory-install-config.js";
|
|
51
|
+
import { expectedCodexTrustRows, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
|
|
52
|
+
import { buildMemoryInstallReceipt, writeMemoryReceiptFile, } from "./memory-install-receipt.js";
|
|
53
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
50
54
|
import { aggregate, logMemoryOutcome, memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
55
|
+
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
56
|
+
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
51
57
|
export { resolveMemoryMcpBin } from "./memory-install-config.js";
|
|
52
58
|
export { memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
|
|
59
|
+
export { buildMemoryInstallReceipt, memoryReceiptLine, readMemoryReceiptFile, writeMemoryReceiptFile, MEMORY_RECEIPT_FILE, } from "./memory-install-receipt.js";
|
|
60
|
+
export { codexHooksFile, readCodexHookTrust, } from "./memory-install-codex-hooks.js";
|
|
53
61
|
export async function runMemoryInstall(command, io, deps = {}) {
|
|
54
62
|
const outcome = command.action === "status"
|
|
55
63
|
? await inspectMemoryIntegration(command, io, deps)
|
|
56
64
|
: await installMemoryIntegration(command, io, deps);
|
|
65
|
+
// BLI-3731. Read the WHOLE machine back after acting on part of it, so the
|
|
66
|
+
// heartbeat's cache is fresh and a person sees the same seven words here as
|
|
67
|
+
// on the ops board. A dry run reads nothing and caches nothing: `--dry-run`
|
|
68
|
+
// says what WOULD happen, which is not a reading of anything.
|
|
69
|
+
const receipt = command.dryRun
|
|
70
|
+
? null
|
|
71
|
+
: await refreshSetupReceipt(io, {
|
|
72
|
+
...(command.homeDir ? { homeDir: command.homeDir } : {}),
|
|
73
|
+
...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
|
|
74
|
+
});
|
|
57
75
|
if (command.json) {
|
|
58
|
-
writeLine(io.stdout, JSON.stringify(outcome, null, 2));
|
|
76
|
+
writeLine(io.stdout, JSON.stringify({ ...outcome, setup_receipt: receipt?.receipt ?? null }, null, 2));
|
|
59
77
|
}
|
|
60
78
|
else {
|
|
61
79
|
for (const line of memoryOutcomeLines(outcome))
|
|
62
80
|
writeLine(io.stdout, line);
|
|
81
|
+
if (receipt) {
|
|
82
|
+
writeLine(io.stdout, "Connected:");
|
|
83
|
+
for (const line of setupReceiptBlock(receipt, { indent: " " })) {
|
|
84
|
+
writeLine(io.stdout, line);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
63
87
|
}
|
|
64
88
|
return outcome.status === "failed" ? 1 : 0;
|
|
65
89
|
}
|
|
@@ -107,10 +131,49 @@ export async function installMemoryIntegration(command, io, deps = {}) {
|
|
|
107
131
|
bin_found: resolved.config !== null,
|
|
108
132
|
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
109
133
|
targets,
|
|
134
|
+
receipt: buildMemoryInstallReceipt({
|
|
135
|
+
targets,
|
|
136
|
+
binFound: resolved.config !== null,
|
|
137
|
+
codexTrust: await codexTrustFor(homeDir, fileIo),
|
|
138
|
+
expectedTrustRows: resolved.config ? expectedCodexTrustRows(resolved.config) : 0,
|
|
139
|
+
}),
|
|
110
140
|
};
|
|
111
141
|
logMemoryOutcome(outcome, platform);
|
|
142
|
+
// The heartbeat reads the cache, not the machine — see the receipt module's
|
|
143
|
+
// "Where the receipt lives between runs". A dry run caches nothing: it proves
|
|
144
|
+
// what WOULD happen and nothing about what is on disk.
|
|
145
|
+
if (!command.dryRun) {
|
|
146
|
+
await cacheReceipt(homeDir ?? command.homeDir, outcome.receipt);
|
|
147
|
+
}
|
|
112
148
|
return outcome;
|
|
113
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Reading the trust state never fails an install. A `config.toml` we cannot
|
|
152
|
+
* read is `config_unreadable`, which the receipt reports as `unknown` for
|
|
153
|
+
* `codex.hooks` rather than as an absent installation.
|
|
154
|
+
*/
|
|
155
|
+
async function codexTrustFor(homeDir, io) {
|
|
156
|
+
return readCodexHookTrustFromDisk({ homeDir, io }).catch(() => ({
|
|
157
|
+
feature: "config_unreadable",
|
|
158
|
+
trustedRows: 0,
|
|
159
|
+
disabledRows: 0,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
async function cacheReceipt(homeDir, receipt) {
|
|
163
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
164
|
+
await writeMemoryReceiptFile({
|
|
165
|
+
stateDir: paths.state_dir,
|
|
166
|
+
receipt,
|
|
167
|
+
io: {
|
|
168
|
+
mkdir: async (dir) => {
|
|
169
|
+
await fs.mkdir(dir, { recursive: true });
|
|
170
|
+
},
|
|
171
|
+
writeFile: async (file, body) => {
|
|
172
|
+
await fs.writeFile(file, body, "utf8");
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
114
177
|
export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
115
178
|
const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
|
|
116
179
|
const platform = deps.platform ?? process.platform;
|
|
@@ -143,12 +206,23 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
|
|
|
143
206
|
cliEntryPoint: deps.cliEntryPoint,
|
|
144
207
|
realpath: deps.realpath,
|
|
145
208
|
})));
|
|
146
|
-
|
|
209
|
+
const outcome = {
|
|
147
210
|
action: "status",
|
|
148
211
|
...aggregate(targets),
|
|
149
212
|
config_source: resolved.source,
|
|
150
213
|
bin_found: resolved.config !== null,
|
|
151
214
|
...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
|
|
152
215
|
targets,
|
|
216
|
+
receipt: buildMemoryInstallReceipt({
|
|
217
|
+
targets,
|
|
218
|
+
binFound: resolved.config !== null,
|
|
219
|
+
codexTrust: await codexTrustFor(homeDir, fileIo),
|
|
220
|
+
expectedTrustRows: resolved.config ? expectedCodexTrustRows(resolved.config) : 0,
|
|
221
|
+
}),
|
|
153
222
|
};
|
|
223
|
+
// `status` is a pure read and its reading is exactly as good as an install's
|
|
224
|
+
// — both re-parse the stored files — so it refreshes the cache too. A person
|
|
225
|
+
// who runs `cockpit memory status` has just made the fleet board fresher.
|
|
226
|
+
await cacheReceipt(homeDir ?? command.homeDir, outcome.receipt);
|
|
227
|
+
return outcome;
|
|
154
228
|
}
|
package/dist/commands/msg.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cockpit msg` — channels and messages, typed (BLI-3706).
|
|
2
|
+
* `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Six verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
|
|
5
5
|
* collector device token through `resolveCaller({ allowDeviceToken: true })`
|
|
6
6
|
* on every route — no server-side door change was needed for this terminal.
|
|
7
7
|
*
|
|
8
8
|
* `<channel>` is a channel id, or its name with or without a leading `#`,
|
|
9
9
|
* resolved locally against `GET /api/msg/channels` — exact match only, never
|
|
10
10
|
* fuzzy, same discipline `docs.ts` uses for a slug.
|
|
11
|
+
*
|
|
12
|
+
* `create` and `dm` (BLI-3749) name PEOPLE by email and never by uuid: the
|
|
13
|
+
* door resolves each address exactly (`lib/msg/people.ts`), so the terminal
|
|
14
|
+
* carries no directory of its own and a typo is refused by name rather than
|
|
15
|
+
* silently adding a stranger. `dm` does not have to name the caller — the
|
|
16
|
+
* door unions them in.
|
|
11
17
|
*/
|
|
12
18
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
13
19
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
@@ -26,6 +32,10 @@ export async function runMsg(command, io) {
|
|
|
26
32
|
return sendMessage(command, door);
|
|
27
33
|
case "thread":
|
|
28
34
|
return readThread(command, door);
|
|
35
|
+
case "create":
|
|
36
|
+
return createChannel(command, door);
|
|
37
|
+
case "dm":
|
|
38
|
+
return openDm(command, door);
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
41
|
async function fetchChannels(door) {
|
|
@@ -185,4 +195,77 @@ async function sendMessage(command, door) {
|
|
|
185
195
|
return emitAgentDoor(door, { ok: true, message });
|
|
186
196
|
writeLine(door.io.stdout, `Sent to ${ref} (${message?.id ?? "?"}).`);
|
|
187
197
|
return 0;
|
|
198
|
+
}
|
|
199
|
+
async function createChannel(command, door) {
|
|
200
|
+
const name = command.channelName ?? "";
|
|
201
|
+
if (name === "")
|
|
202
|
+
return failAgentDoor(door, TAG, "invalid_body", "msg create needs a channel name.");
|
|
203
|
+
const answer = await askAgentDoor(door, {
|
|
204
|
+
path: "/api/msg/channels",
|
|
205
|
+
method: "POST",
|
|
206
|
+
label: "msg create",
|
|
207
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
208
|
+
body: {
|
|
209
|
+
name,
|
|
210
|
+
is_private: command.isPrivate,
|
|
211
|
+
...(command.description ? { description: command.description } : {}),
|
|
212
|
+
...(command.memberEmails ? { member_emails: command.memberEmails } : {}),
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
if (!answer.ok)
|
|
216
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
217
|
+
const body = answer.body;
|
|
218
|
+
const channel = body.channel ?? null;
|
|
219
|
+
const membersAdded = body.members_added ?? [];
|
|
220
|
+
const membersFailed = body.members_failed ?? [];
|
|
221
|
+
const requestedMembers = command.memberEmails?.length ?? 0;
|
|
222
|
+
writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({
|
|
223
|
+
channel_id: channel?.id ?? null,
|
|
224
|
+
is_private: command.isPrivate,
|
|
225
|
+
members_requested: requestedMembers,
|
|
226
|
+
members_added: membersAdded.length,
|
|
227
|
+
members_failed: membersFailed.length,
|
|
228
|
+
})}`);
|
|
229
|
+
if (door.json) {
|
|
230
|
+
return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
|
|
231
|
+
}
|
|
232
|
+
writeLine(door.io.stdout, `Created #${channel?.name ?? name} (${channel?.id ?? "?"}).`);
|
|
233
|
+
if (membersAdded.length > 0) {
|
|
234
|
+
writeLine(door.io.stdout, `${membersAdded.length} member(s) added.`);
|
|
235
|
+
}
|
|
236
|
+
// A CLI newer than the deployment is a normal fleet state, and a Tower that
|
|
237
|
+
// predates BLI-3749 STRIPS `member_emails` rather than refusing it — the
|
|
238
|
+
// create succeeds and the members quietly never happen. Say so; a silent
|
|
239
|
+
// success is the failure mode this whole ticket exists to end.
|
|
240
|
+
if (requestedMembers > 0 && membersAdded.length === 0 && membersFailed.length === 0) {
|
|
241
|
+
writeLine(door.io.stdout, `Tower reported no members added, though ${requestedMembers} were asked for. It may be older than this CLI — add them from the channel, or retry after the next deploy.`);
|
|
242
|
+
}
|
|
243
|
+
// A member the channel could not take is said out loud, not left to a log:
|
|
244
|
+
// the channel exists, so this is the only place a person would learn it.
|
|
245
|
+
for (const failure of membersFailed) {
|
|
246
|
+
writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
async function openDm(command, door) {
|
|
251
|
+
const email = command.dmEmail ?? "";
|
|
252
|
+
if (email === "")
|
|
253
|
+
return failAgentDoor(door, TAG, "invalid_body", "msg dm needs an email address.");
|
|
254
|
+
const answer = await askAgentDoor(door, {
|
|
255
|
+
path: "/api/msg/channels",
|
|
256
|
+
method: "POST",
|
|
257
|
+
label: "msg dm",
|
|
258
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
259
|
+
body: { dm_participant_emails: [email] },
|
|
260
|
+
});
|
|
261
|
+
if (!answer.ok)
|
|
262
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
263
|
+
const channel = answer.body.channel ?? null;
|
|
264
|
+
writeLine(door.io.stderr, `${TAG} dm resolved ${JSON.stringify({ channel_id: channel?.id ?? null })}`);
|
|
265
|
+
if (door.json)
|
|
266
|
+
return emitAgentDoor(door, { ok: true, channel });
|
|
267
|
+
// Idempotent by contract (`get_or_create_dm`), so the wording says what is
|
|
268
|
+
// true either way rather than claiming a creation that may not have happened.
|
|
269
|
+
writeLine(door.io.stdout, `Direct message with ${email}: ${channel?.id ?? "?"}`);
|
|
270
|
+
return 0;
|
|
188
271
|
}
|
|
@@ -3,6 +3,9 @@ import { agentRuleHostLabel } from "./agent-rules-command.js";
|
|
|
3
3
|
import { autostartLocationLine } from "./autostart-command.js";
|
|
4
4
|
import { backfillRetryCommand, runBackfill, } from "./backfill.js";
|
|
5
5
|
import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
|
|
6
|
+
import { installMemoryIntegration } from "./memory-install.js";
|
|
7
|
+
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
8
|
+
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
6
9
|
import { addAutostartInstallEvent, addOnboardFailureEvent, classifyOnboardBlocker, onboardAutostartFailed, } from "./onboard-receipts.js";
|
|
7
10
|
import { nextStepForOnboardBlocker, onboardAgentRulesInstallLine, onboardJsonPayload, writeOnboardJson, } from "./onboard-report.js";
|
|
8
11
|
import { installAgentRules } from "../agent-rules.js";
|
|
@@ -91,7 +94,51 @@ async function refreshOnboardAutostart(command, roots, io) {
|
|
|
91
94
|
* report always fires, pass or blocked — replaces a `finish` closure that used
|
|
92
95
|
* to live inside `runOnboard` so the flow functions below can call it too.
|
|
93
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* BLI-3731. The last thing onboarding does: install the agent integrations
|
|
99
|
+
* unasked, read the whole machine back, and print ONE block saying what is
|
|
100
|
+
* connected and what is not. It runs on every exit path, success or blocked,
|
|
101
|
+
* because "what did I actually end up with?" is the question a person has
|
|
102
|
+
* either way — and a failure that also prints the receipt is a failure
|
|
103
|
+
* somebody can act on.
|
|
104
|
+
*
|
|
105
|
+
* Never throws and never changes the exit code: this is a report.
|
|
106
|
+
*/
|
|
107
|
+
async function reportOnboardSetupReceipt(command, io) {
|
|
108
|
+
try {
|
|
109
|
+
await installMemoryIntegration({
|
|
110
|
+
kind: "memory",
|
|
111
|
+
action: "install",
|
|
112
|
+
...(command.homeDir ? { homeDir: command.homeDir } : {}),
|
|
113
|
+
...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
|
|
114
|
+
dryRun: false,
|
|
115
|
+
json: true,
|
|
116
|
+
}, io, { ...(command.homeDir ? { homeDir: command.homeDir } : {}) });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// The receipt below will read whatever is actually there, so a failed
|
|
120
|
+
// install is reported by the words rather than by stopping onboarding.
|
|
121
|
+
console.error("[onboard] agent integrations could not be installed", JSON.stringify({
|
|
122
|
+
reason: "memory_install_threw",
|
|
123
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
const receipt = await refreshSetupReceipt(io, {
|
|
127
|
+
...(command.homeDir ? { homeDir: command.homeDir } : {}),
|
|
128
|
+
...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
|
|
129
|
+
});
|
|
130
|
+
if (command.json)
|
|
131
|
+
return;
|
|
132
|
+
writeLine(io.stdout, "");
|
|
133
|
+
writeLine(io.stdout, "Connected:");
|
|
134
|
+
for (const line of receipt
|
|
135
|
+
? setupReceiptBlock(receipt, { indent: " " })
|
|
136
|
+
: [" unknown — run `cockpit doctor` to read this machine."]) {
|
|
137
|
+
writeLine(io.stdout, line);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
94
140
|
export async function finishOnboardRun(command, installEvents, io, code) {
|
|
141
|
+
await reportOnboardSetupReceipt(command, io);
|
|
95
142
|
await reportInstallEventsBestEffort({
|
|
96
143
|
homeDir: command.homeDir,
|
|
97
144
|
dashboardUrl: command.dashboardUrl,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openInBrowser } from "./browser-open.js";
|
|
2
|
+
import { errorMessage, writeLine } from "./cli-io.js";
|
|
2
3
|
import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
|
|
3
4
|
import { addInstallEvent } from "./install-receipts.js";
|
|
4
5
|
import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessTokenDetailed, } from "./local-auth.js";
|
|
5
6
|
import { writePairingInstructions } from "./onboard-report.js";
|
|
6
|
-
import { inspectLocalCollectorStatus, logoutLocalCollector, } from "../local-state.js";
|
|
7
|
+
import { inspectLocalCollectorStatus, logoutLocalCollector, pairLocalCollectorViaLink, } from "../local-state.js";
|
|
7
8
|
/** Step 0: which folders are approved, and who claims to own this machine's uploads. */
|
|
8
9
|
export async function prepareOnboardRoots(command, installEvents, io) {
|
|
9
10
|
const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
|
|
@@ -72,6 +73,22 @@ export async function pairForOnboarding(command, input, installEvents, io) {
|
|
|
72
73
|
writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
// BLI-3731: one sign-in first. The link both signs the browser in and
|
|
77
|
+
// finishes the pairing, so nobody reads a second email. Every other arm
|
|
78
|
+
// below is the fallback, and it runs unchanged when this one cannot finish.
|
|
79
|
+
if (!command.legacyPair) {
|
|
80
|
+
const linked = await tryOneLinkOnboardPairing(command, input, io);
|
|
81
|
+
if (linked) {
|
|
82
|
+
addInstallEvent(installEvents, "auth", "skipped", "one_link_pairing");
|
|
83
|
+
addInstallEvent(installEvents, "pair", "ok");
|
|
84
|
+
if (!command.json) {
|
|
85
|
+
writeLine(io.stdout, "2/5 Device paired from one sign-in.");
|
|
86
|
+
writeLine(io.stdout, `User: ${linked.session.email ?? linked.session.auth_subject_id}`);
|
|
87
|
+
writeLine(io.stdout, `Device: ${linked.session.device_name ?? linked.session.device_id ?? "unknown"}`);
|
|
88
|
+
}
|
|
89
|
+
return linked;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
75
92
|
const authResult = await requestPairingAccessTokenDetailed({
|
|
76
93
|
dashboardUrl: command.dashboardUrl,
|
|
77
94
|
email: input.claimedOwnerEmail,
|
|
@@ -99,4 +116,67 @@ export async function pairForOnboarding(command, input, installEvents, io) {
|
|
|
99
116
|
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
100
117
|
}
|
|
101
118
|
return pair;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The one-sign-in ceremony, inside onboarding (BLI-3731).
|
|
122
|
+
*
|
|
123
|
+
* Returns null when it could not finish, having already said why — onboarding
|
|
124
|
+
* then runs the older email-code arm rather than stopping. A regression in the
|
|
125
|
+
* new door must never leave a machine unable to onboard at all.
|
|
126
|
+
*
|
|
127
|
+
* `PairViaLinkResult` is mapped onto `PairLocalCollectorResult` here because
|
|
128
|
+
* the session underneath is the same one: the server issues it from the same
|
|
129
|
+
* `pollDevicePairRequest` whichever ceremony asked. `approve_url` carries the
|
|
130
|
+
* connect link so the onboarding receipt still names the address a person
|
|
131
|
+
* opened.
|
|
132
|
+
*/
|
|
133
|
+
async function tryOneLinkOnboardPairing(command, input, io) {
|
|
134
|
+
try {
|
|
135
|
+
const linked = await pairLocalCollectorViaLink({
|
|
136
|
+
homeDir: command.homeDir,
|
|
137
|
+
dashboardUrl: command.dashboardUrl,
|
|
138
|
+
deviceName: command.deviceName,
|
|
139
|
+
claimedOwnerEmail: input.claimedOwnerEmail,
|
|
140
|
+
pairCode: command.pairCode,
|
|
141
|
+
pollIntervalMs: command.pollIntervalMs,
|
|
142
|
+
timeoutMs: command.timeoutMs,
|
|
143
|
+
fetch: io.fetch,
|
|
144
|
+
onLinkReady: command.json
|
|
145
|
+
? undefined
|
|
146
|
+
: (link) => announceOnboardLink(command, io, link),
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
status: "paired",
|
|
150
|
+
session: linked.session,
|
|
151
|
+
session_file: linked.session_file,
|
|
152
|
+
dashboard_url: linked.dashboard_url,
|
|
153
|
+
approve_url: linked.connect_url ?? linked.dashboard_url,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const reason = error && typeof error === "object" && "reason" in error
|
|
158
|
+
? String(error.reason)
|
|
159
|
+
: "pair_link_failed";
|
|
160
|
+
console.error("[onboard] one-link pairing did not finish", JSON.stringify({ reason, falling_back: true }));
|
|
161
|
+
if (!command.json) {
|
|
162
|
+
writeLine(io.stderr, `Sign-in link did not finish: ${errorMessage(error)}`);
|
|
163
|
+
writeLine(io.stderr, "Falling back to the email-code sign-in.");
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Print the link, then try to open it. Printing first is what makes the open optional. */
|
|
169
|
+
function announceOnboardLink(command, io, link) {
|
|
170
|
+
writeLine(io.stdout, "2/5 Sign in to Tower once, and this machine is connected.");
|
|
171
|
+
writeLine(io.stdout, `Open: ${link.connect_url}`);
|
|
172
|
+
writeLine(io.stdout, "Waiting for that sign-in...");
|
|
173
|
+
if (command.noBrowser) {
|
|
174
|
+
console.error("[onboard] browser not opened", JSON.stringify({ reason: "no_browser_flag" }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const opened = openInBrowser(link.connect_url);
|
|
178
|
+
console.error("[onboard] browser open attempted", JSON.stringify(opened.ok ? { reason: opened.reason, program: opened.program } : { reason: opened.reason }));
|
|
179
|
+
if (!opened.ok) {
|
|
180
|
+
writeLine(io.stdout, `(${opened.detail} The link above still works.)`);
|
|
181
|
+
}
|
|
102
182
|
}
|