@bli-cockpit/cli 0.2.57 → 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.
Files changed (53) hide show
  1. package/dist/commands/browser-open.js +88 -0
  2. package/dist/commands/docs.js +27 -6
  3. package/dist/commands/doctor-report.js +17 -1
  4. package/dist/commands/doctor.js +12 -2
  5. package/dist/commands/heartbeat.js +65 -1
  6. package/dist/commands/issue-contracts.js +99 -0
  7. package/dist/commands/issue-write.js +129 -0
  8. package/dist/commands/issue.js +189 -0
  9. package/dist/commands/jarvis-answer-envelope.js +80 -0
  10. package/dist/commands/jarvis-turn.js +16 -1
  11. package/dist/commands/jarvis.js +3 -0
  12. package/dist/commands/local-args-collector-setup.js +17 -0
  13. package/dist/commands/local-args-tower-admin.js +12 -2
  14. package/dist/commands/local-args-tower-docs-msg.js +95 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower-work.js +178 -0
  17. package/dist/commands/local-args-tower.js +7 -1
  18. package/dist/commands/local-args.js +29 -14
  19. package/dist/commands/local-command-shapes.js +12 -0
  20. package/dist/commands/local-help-commands.js +643 -0
  21. package/dist/commands/local-help.js +12 -551
  22. package/dist/commands/local.js +9 -0
  23. package/dist/commands/login.js +91 -8
  24. package/dist/commands/memory-install-claude.js +35 -15
  25. package/dist/commands/memory-install-codex-hooks.js +200 -0
  26. package/dist/commands/memory-install-codex.js +12 -2
  27. package/dist/commands/memory-install-config.js +140 -0
  28. package/dist/commands/memory-install-receipt.js +222 -0
  29. package/dist/commands/memory-install-report.js +113 -0
  30. package/dist/commands/memory-install.js +99 -276
  31. package/dist/commands/msg.js +85 -2
  32. package/dist/commands/notes-door.js +120 -0
  33. package/dist/commands/notes-reads.js +134 -0
  34. package/dist/commands/notes-writes.js +208 -0
  35. package/dist/commands/notes.js +16 -442
  36. package/dist/commands/onboard-completion.js +47 -0
  37. package/dist/commands/onboard-setup.js +82 -2
  38. package/dist/commands/ops-render-memory.js +76 -0
  39. package/dist/commands/ops-render.js +13 -1
  40. package/dist/commands/ops.js +65 -3
  41. package/dist/commands/project.js +38 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/search.js +122 -0
  44. package/dist/commands/setup-receipt-lines.js +71 -0
  45. package/dist/commands/setup-receipt.js +241 -0
  46. package/dist/commands/status.js +20 -1
  47. package/dist/local-state-pairing-code.js +200 -0
  48. package/dist/local-state.js +6 -0
  49. package/dist/repo-identity-fingerprint.js +88 -0
  50. package/dist/repo-identity-git.js +76 -0
  51. package/dist/repo-identity-linked-worktrees.js +81 -0
  52. package/dist/repo-identity.js +5 -222
  53. package/package.json +7 -7
@@ -22,20 +22,23 @@
22
22
  * `--yes` without one. `--json` implies `--yes`, because a machine-readable
23
23
  * run has nobody to ask and asking it to guess is worse than the flag it
24
24
  * already typed the command for.
25
+ *
26
+ * This file is the dispatch table (BLI-3717 readability pass, 680 lines to a
27
+ * table of contents). Every responsibility lives in a `notes-*.ts` sibling:
28
+ *
29
+ * - `notes-reads.ts` — the library, shelves list, one note, your own shelf.
30
+ * - `notes-writes.ts` — upload, paste, share/unshare, move.
31
+ * - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
32
+ * emit / say-* rendering both halves above call.
33
+ *
34
+ * Every public name is still importable from this file.
25
35
  */
26
- import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
27
- import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
28
- import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
29
- import { readResponseJson } from "../upload-http.js";
30
- /**
31
- * The upload route's own ceiling is `maxDuration = 300` — reading a note is one
32
- * model call over the whole file. The client waits slightly longer so the
33
- * server's named failure wins the race whenever it manages to send one.
34
- */
35
- const UPLOAD_DEADLINE_MS = 305_000;
36
- const READ_DEADLINE_MS = 60_000;
37
- const PASTE_MAX_CHARS = 2_000_000;
38
- const TAG = "[notes cli]";
36
+ import { loadPairedSession } from "../tower-client.js";
37
+ import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
38
+ import { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
39
+ export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
40
+ export { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
41
+ export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
39
42
  export async function runNotes(command, io) {
40
43
  const session = await loadPairedSession("notes", command.homeDir);
41
44
  const door = {
@@ -63,433 +66,4 @@ export async function runNotes(command, io) {
63
66
  case "move":
64
67
  return moveNote(command, door);
65
68
  }
66
- }
67
- function libraryQuery(command) {
68
- const query = new URLSearchParams();
69
- if (command.series)
70
- query.set("series", command.series);
71
- if (command.meetingKind)
72
- query.set("kind", command.meetingKind);
73
- if (command.since)
74
- query.set("since", command.since);
75
- if (command.until)
76
- query.set("until", command.until);
77
- if (command.limit !== undefined)
78
- query.set("limit", String(command.limit));
79
- const rendered = query.toString();
80
- return rendered === "" ? "" : `?${rendered}`;
81
- }
82
- async function listNotes(command, door) {
83
- const answer = await ask(door, {
84
- path: `/api/notes/library${libraryQuery(command)}`,
85
- method: "GET",
86
- label: "notes list",
87
- timeoutMs: READ_DEADLINE_MS,
88
- });
89
- if (!answer.ok)
90
- return fail(door, answer.reason, answer.detail);
91
- const body = answer.body;
92
- if (door.json)
93
- return emit(door, body);
94
- sayScope(door, body);
95
- const series = body.series ?? [];
96
- if (series.length === 0) {
97
- writeLine(door.io.stdout, "No meeting notes matched.");
98
- return 0;
99
- }
100
- for (const one of series) {
101
- writeLine(door.io.stdout, "");
102
- writeLine(door.io.stdout, `${one.heading} (${one.notes.length})`);
103
- for (const note of one.notes) {
104
- const who = note.participants.length > 0 ? ` — ${note.participants.join(", ")}` : "";
105
- writeLine(door.io.stdout, ` ${note.meetingDate} ${note.id} ${note.fileName}${who}`);
106
- }
107
- }
108
- writeLine(door.io.stdout, "");
109
- writeLine(door.io.stdout, `${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`);
110
- return 0;
111
- }
112
- async function listShelves(command, door) {
113
- const answer = await ask(door, {
114
- path: `/api/notes/library${libraryQuery(command)}`,
115
- method: "GET",
116
- label: "notes shelves",
117
- timeoutMs: READ_DEADLINE_MS,
118
- });
119
- if (!answer.ok)
120
- return fail(door, answer.reason, answer.detail);
121
- const body = answer.body;
122
- const shelves = (body.series ?? []).map((one) => ({
123
- shelf: one.heading,
124
- notes: one.notes.length,
125
- // A shelf somebody typed, rather than the one a kind implies. Only the
126
- // first kind is reported: a shelf is free text and can hold any of them.
127
- custom: (body.categories ?? []).includes(one.heading),
128
- }));
129
- if (door.json)
130
- return emit(door, { scope: body.scope, shelves });
131
- sayScope(door, body);
132
- if (shelves.length === 0) {
133
- writeLine(door.io.stdout, "No shelves yet.");
134
- return 0;
135
- }
136
- for (const shelf of shelves) {
137
- writeLine(door.io.stdout, `${String(shelf.notes).padStart(4)} ${shelf.shelf}${shelf.custom ? "" : " (from the meeting kind)"}`);
138
- }
139
- return 0;
140
- }
141
- async function showNote(command, door) {
142
- const answer = await ask(door, {
143
- path: `/api/notes/library/${encodeURIComponent(command.noteId ?? "")}`,
144
- method: "GET",
145
- label: "notes show",
146
- timeoutMs: READ_DEADLINE_MS,
147
- });
148
- if (!answer.ok)
149
- return fail(door, answer.reason, answer.detail);
150
- const body = answer.body;
151
- if (door.json)
152
- return emit(door, body);
153
- const note = body.note;
154
- if (!note)
155
- return fail(door, "no_note_in_answer", "Tower answered without a note.");
156
- sayScope(door, body);
157
- writeLine(door.io.stdout, note.title);
158
- writeLine(door.io.stdout, `${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`);
159
- if (note.participants.length > 0) {
160
- writeLine(door.io.stdout, `In the room: ${note.participants.join(", ")}`);
161
- }
162
- writeLine(door.io.stdout, note.visibility);
163
- writeLine(door.io.stdout, "");
164
- writeLine(door.io.stdout, note.content);
165
- return 0;
166
- }
167
- async function showShelf(command, door) {
168
- const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
169
- const answer = await ask(door, {
170
- path: `/api/notes/shelf${query}`,
171
- method: "GET",
172
- label: "notes shelf",
173
- timeoutMs: READ_DEADLINE_MS,
174
- });
175
- if (!answer.ok)
176
- return fail(door, answer.reason, answer.detail);
177
- const body = answer.body;
178
- if (door.json)
179
- return emit(door, body);
180
- sayScope(door, body);
181
- const notes = body.notes ?? [];
182
- if (notes.length === 0) {
183
- writeLine(door.io.stdout, "You have not put any notes in yet.");
184
- return 0;
185
- }
186
- for (const note of notes) {
187
- const counts = note.countsKnown
188
- ? `${note.statements} statements, ${note.openToTheTeam} open to the team, ${note.keptBack} kept back`
189
- : "counts unknown on this server";
190
- writeLine(door.io.stdout, `${note.meetingDate} ${note.id} ${note.shared ? "shared " : "yours "} ${note.name}`);
191
- writeLine(door.io.stdout, ` ${counts}`);
192
- }
193
- return 0;
194
- }
195
- async function uploadNotes(command, door) {
196
- const paths = command.paths ?? [];
197
- const results = [];
198
- let worstExit = 0;
199
- for (const filePath of paths) {
200
- // Read and screen locally BEFORE any network call — a refusal here never
201
- // reaches the dashboard, same discipline as `cockpit jarvis --image`.
202
- const read = await readNoteFile(filePath);
203
- if (!read.ok) {
204
- const sentence = noteFileRefusalSentence(read.refusal, filePath);
205
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
206
- if (!door.json)
207
- writeLine(door.io.stderr, sentence);
208
- results.push({ path: filePath, ok: false, reason: read.refusal });
209
- worstExit = 1;
210
- continue;
211
- }
212
- if (read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
213
- // The route reads the whole note with one model call and may take
214
- // minutes. A still cursor reads as a hang, so say what is happening.
215
- writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
216
- }
217
- const form = new FormData();
218
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
219
- if (command.exclude)
220
- form.set("exclusions", command.exclude);
221
- const answer = await ask(door, {
222
- path: "/api/notes/upload",
223
- method: "POST",
224
- label: "notes upload",
225
- timeoutMs: UPLOAD_DEADLINE_MS,
226
- body: form,
227
- });
228
- if (!answer.ok) {
229
- writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
230
- if (!door.json)
231
- writeLine(door.io.stderr, `${read.fileName}: ${answer.detail}`);
232
- results.push({ path: filePath, ok: false, reason: answer.reason });
233
- worstExit = 1;
234
- continue;
235
- }
236
- const body = answer.body;
237
- results.push({ path: filePath, ok: body.stored === true, body });
238
- if (body.stored !== true)
239
- worstExit = 1;
240
- if (!door.json)
241
- sayUpload(door, body);
242
- writeLine(door.io.stderr, `${TAG} upload answered ${JSON.stringify({
243
- stored: body.stored === true,
244
- note_id: body.noteId ?? null,
245
- scope: body.scope ?? null,
246
- byte_size: read.bytes.byteLength,
247
- extension: read.extension,
248
- })}`);
249
- }
250
- if (door.json) {
251
- emit(door, { ok: worstExit === 0, uploaded: results.length, results });
252
- }
253
- return worstExit;
254
- }
255
- async function pasteNote(command, door) {
256
- let text;
257
- if (command.filePath) {
258
- const read = await readNoteFile(command.filePath);
259
- if (!read.ok) {
260
- const sentence = noteFileRefusalSentence(read.refusal, command.filePath);
261
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
262
- return fail(door, read.refusal, sentence);
263
- }
264
- const decoded = decodeTextBytes(read.bytes);
265
- if (!decoded.ok) {
266
- writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
267
- return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
268
- }
269
- text = decoded.text;
270
- }
271
- else {
272
- if (isInteractiveStdin(door.io)) {
273
- return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
274
- + "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 — "
275
- + "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
276
- }
277
- try {
278
- text = await readPipedText(door.io.stdin, {
279
- maxChars: PASTE_MAX_CHARS,
280
- overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
281
- });
282
- }
283
- catch (error) {
284
- return fail(door, "paste_too_long", errorText(error));
285
- }
286
- }
287
- if (text.trim() === "") {
288
- return fail(door, "empty_paste", "There was nothing to paste. Nothing was sent.");
289
- }
290
- const form = new FormData();
291
- form.set("text", text);
292
- if (command.name)
293
- form.set("name", command.name);
294
- if (command.exclude)
295
- form.set("exclusions", command.exclude);
296
- // Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
297
- // threshold, and the file path above already compares `bytes.byteLength`
298
- // against it; `text.length` counts UTF-16 units, so a note in any non-Latin
299
- // script was measured at a third to a half of the size actually being sent
300
- // and the "this will take a while" line stayed silent through the wait.
301
- const pastedBytes = Buffer.byteLength(text, "utf8");
302
- if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
303
- writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
304
- }
305
- const answer = await ask(door, {
306
- path: "/api/notes/upload",
307
- method: "POST",
308
- label: "notes paste",
309
- timeoutMs: UPLOAD_DEADLINE_MS,
310
- body: form,
311
- });
312
- if (!answer.ok)
313
- return fail(door, answer.reason, answer.detail);
314
- const body = answer.body;
315
- writeLine(door.io.stderr, `${TAG} paste answered ${JSON.stringify({
316
- stored: body.stored === true,
317
- note_id: body.noteId ?? null,
318
- scope: body.scope ?? null,
319
- chars: text.length,
320
- byte_size: pastedBytes,
321
- named_by_caller: Boolean(command.name),
322
- })}`);
323
- if (door.json)
324
- return emit(door, body, body.stored === true ? 0 : 1);
325
- sayUpload(door, body);
326
- return body.stored === true ? 0 : 1;
327
- }
328
- async function shareNote(command, door) {
329
- const wantsToShare = command.action === "share";
330
- // Only sharing asks. Taking a note back narrows who can read it, and nobody
331
- // needs to be talked out of that.
332
- if (wantsToShare && !command.yes) {
333
- if (!isInteractiveStdin(door.io)) {
334
- return fail(door, "confirmation_required", "Sharing a note lets everyone signed in read it. Pass --yes to do it without being asked.");
335
- }
336
- const answer = await readLine(door.io, "Share this note with everyone signed in? [Y/n] ");
337
- if (!yesByDefault(answer)) {
338
- writeLine(door.io.stderr, `${TAG} share declined ${JSON.stringify({ reason: "operator_said_no" })}`);
339
- writeLine(door.io.stdout, "Left alone. Nothing changed.");
340
- return 0;
341
- }
342
- }
343
- const answer = await ask(door, {
344
- path: "/api/notes/share",
345
- method: "POST",
346
- label: wantsToShare ? "notes share" : "notes unshare",
347
- timeoutMs: READ_DEADLINE_MS,
348
- body: { note_id: command.noteId, share: wantsToShare },
349
- });
350
- if (!answer.ok)
351
- return fail(door, answer.reason, answer.detail);
352
- const body = answer.body;
353
- writeLine(door.io.stderr, `${TAG} ${wantsToShare ? "shared" : "taken back"} ${JSON.stringify({
354
- note_id: command.noteId ?? null,
355
- stored: body.stored === true,
356
- })}`);
357
- if (door.json)
358
- return emit(door, body);
359
- sayUpload(door, body);
360
- return 0;
361
- }
362
- async function moveNote(command, door) {
363
- const answer = await ask(door, {
364
- path: "/api/notes/move",
365
- method: "POST",
366
- label: "notes move",
367
- timeoutMs: READ_DEADLINE_MS,
368
- // `--clear-shelf` sends the empty string, which is what the browser's own
369
- // move box sends when a person empties it.
370
- body: { note_id: command.noteId, category: command.clearShelf ? "" : command.to },
371
- });
372
- if (!answer.ok)
373
- return fail(door, answer.reason, answer.detail);
374
- const body = answer.body;
375
- writeLine(door.io.stderr, `${TAG} move answered ${JSON.stringify({
376
- note_id: command.noteId ?? null,
377
- ok: body.ok === true,
378
- reason: body.reason ?? null,
379
- cleared: Boolean(command.clearShelf),
380
- })}`);
381
- if (door.json)
382
- return emit(door, body, body.ok === true ? 0 : 1);
383
- writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
384
- for (const line of body.lines ?? [])
385
- writeLine(door.io.stdout, line);
386
- return body.ok === true ? 0 : 1;
387
- }
388
- /**
389
- * One request, and a refusal that keeps the ROUTE'S own words.
390
- *
391
- * `towerJsonRequest` maps a non-2xx through `responseErrorMessage`, which looks
392
- * for `message` or `error` — the shape the ingest routes answer with. Every
393
- * notes door answers in the browser's shape instead (`headline` plus `lines`,
394
- * with a `reason` label beside them), because the same body is what the page
395
- * renders. Reading it here rather than widening `responseErrorMessage` keeps one
396
- * meaning per field: the sentence is written where the outcome is known, and the
397
- * terminal relays it rather than inventing a second wording for the same thing.
398
- */
399
- async function ask(door, options) {
400
- const result = await towerRequest({
401
- dashboardUrl: door.dashboardUrl,
402
- path: options.path,
403
- deviceToken: door.deviceToken,
404
- fetch: door.io.fetch,
405
- method: options.method,
406
- label: options.label,
407
- timeoutMs: options.timeoutMs,
408
- ...(options.body === undefined ? {} : { body: options.body }),
409
- log: (line) => writeLine(door.io.stderr, line),
410
- });
411
- if (!result.ok) {
412
- const failure = result;
413
- return {
414
- ok: false,
415
- reason: failure.reason,
416
- detail: towerFailureDetail(failure.reason, failure.detail),
417
- };
418
- }
419
- const body = await readResponseJson(result.response);
420
- if (!result.response.ok) {
421
- const status = result.response.status;
422
- return {
423
- ok: false,
424
- reason: refusalReason(body) ?? `http_${status}`,
425
- detail: refusalSentence(body) ?? `Tower answered ${status} and said nothing about why.`,
426
- };
427
- }
428
- return { ok: true, body };
429
- }
430
- function refusalReason(body) {
431
- if (!body || typeof body !== "object")
432
- return null;
433
- const record = body;
434
- for (const key of ["reason", "error", "code"]) {
435
- const value = record[key];
436
- if (typeof value === "string" && value.trim() !== "")
437
- return value;
438
- }
439
- return null;
440
- }
441
- function refusalSentence(body) {
442
- if (!body || typeof body !== "object")
443
- return null;
444
- const record = body;
445
- const headline = typeof record["headline"] === "string" ? record["headline"] : null;
446
- const message = typeof record["message"] === "string" ? record["message"] : null;
447
- const lines = Array.isArray(record["lines"])
448
- ? record["lines"].filter((line) => typeof line === "string")
449
- : [];
450
- const said = [headline ?? message, ...lines].filter(Boolean);
451
- return said.length > 0 ? said.join(" ") : null;
452
- }
453
- /** One machine-readable object on stdout, and nothing else on it. */
454
- function emit(door, body, exitCode = 0) {
455
- writeLine(door.io.stdout, JSON.stringify(body));
456
- return exitCode;
457
- }
458
- function fail(door, reason, detail) {
459
- writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
460
- if (door.json) {
461
- writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
462
- }
463
- else {
464
- writeLine(door.io.stderr, detail);
465
- }
466
- return 1;
467
- }
468
- /**
469
- * The route's own words for what happened. Never rephrased here — the sentences
470
- * are written where the outcome is known, and a second wording in the terminal
471
- * would be a second thing to keep in step.
472
- */
473
- function sayUpload(door, body) {
474
- writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
475
- for (const line of body.lines ?? [])
476
- writeLine(door.io.stdout, line);
477
- if (body.noteId)
478
- writeLine(door.io.stdout, `Note id: ${body.noteId}`);
479
- }
480
- /**
481
- * Says out loud when an answer is narrower than the one a browser would give.
482
- *
483
- * A degraded read is still a real answer and is never withheld — but a person
484
- * who cannot see their own unshared note has to be told that is why, not left
485
- * to conclude it was never stored.
486
- */
487
- function sayScope(door, body) {
488
- if (!body.degradedBecause)
489
- return;
490
- writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
491
- writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
492
- }
493
- function errorText(error) {
494
- return error instanceof Error ? error.message : String(error);
495
69
  }
@@ -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
  }