@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,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
- return {
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
  }
@@ -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
- * Four verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
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 { writeLine } from "./cli-io.js";
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
  }