@bli-cockpit/cli 0.1.33 → 0.2.1

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.
@@ -8,14 +8,15 @@ import { runBackfillCommand } from "./backfill.js";
8
8
  import { inspectBackfillLock } from "../backfill-lock.js";
9
9
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
10
10
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
11
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
11
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
12
12
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
13
13
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
14
14
  import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
15
15
  import { acquireSyncLock } from "../sync-lock.js";
16
16
  import { discoverGitWorktrees } from "../repo-identity.js";
17
17
  import { runAttributedWorktreeSync, } from "./session-sync.js";
18
- import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
18
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
19
+ import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
19
20
  export const rootCommandNames = new Set([
20
21
  "onboard",
21
22
  "update",
@@ -90,12 +91,12 @@ export function localCommandHelp(command) {
90
91
  if (command)
91
92
  return localSubcommandHelp(command);
92
93
  return [
93
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
94
- " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
94
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
95
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
95
96
  " cockpit upgrade [same flags as update]",
96
- " cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
97
- " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
98
- " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
97
+ " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
98
+ " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
+ " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
100
  " cockpit logout",
100
101
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
101
102
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -115,21 +116,21 @@ function localSubcommandHelp(command) {
115
116
  [
116
117
  "onboard",
117
118
  [
118
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--json]",
119
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--json]",
119
120
  "",
120
121
  "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
121
122
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
122
123
  "`--repo <path>` remains supported as a backward-compatible alias.",
123
124
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
124
125
  "Pass --dashboard-url only for staging/custom dashboards or to force a different pairing.",
125
- "Run with no flags in a terminal and it prompts for the dashboard email; pass --email to skip the prompt (and on shared/reused machines, where mismatched sessions are re-paired).",
126
+ "Run with no flags in a terminal and it prompts for the dashboard email and OTP code; pass --email to skip the email prompt. Use --no-auth to force the manual approval fallback.",
126
127
  "Interactive runs also offer to add Cockpit ticket-binding rules to AGENTS.md and CLAUDE.md after readiness proof.",
127
128
  ],
128
129
  ],
129
130
  [
130
131
  "install",
131
132
  [
132
- "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
133
+ "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
133
134
  "",
134
135
  "Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
135
136
  "`--repo <path>` remains supported as a backward-compatible alias.",
@@ -139,7 +140,7 @@ function localSubcommandHelp(command) {
139
140
  [
140
141
  "update",
141
142
  [
142
- "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
143
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
143
144
  "",
144
145
  "Updates the global public CLI from npm, then reruns `cockpit onboard`",
145
146
  "with the same setup flags so pairing, saved roots, agent rules,",
@@ -158,16 +159,16 @@ function localSubcommandHelp(command) {
158
159
  [
159
160
  "login",
160
161
  [
161
- "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
162
+ "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
162
163
  "",
163
- "Starts dashboard device pairing and stores the approved local session.",
164
+ "Signs in with an email OTP when interactive, starts dashboard device pairing, and stores the approved local session.",
164
165
  "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
165
166
  ],
166
167
  ],
167
168
  [
168
169
  "pair",
169
170
  [
170
- "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
171
+ "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
171
172
  "",
172
173
  "Alias for `cockpit login`.",
173
174
  ],
@@ -294,19 +295,69 @@ function isLocalHelpRequest(argv) {
294
295
  return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
295
296
  }
296
297
  async function runInstall(command, io) {
297
- const result = await installLocalCollector(command);
298
+ const installEvents = [];
299
+ const finish = async (code) => {
300
+ await reportInstallEventsBestEffort({
301
+ homeDir: command.homeDir,
302
+ dashboardUrl: command.dashboardUrl,
303
+ command: "install",
304
+ events: installEvents,
305
+ json: command.json,
306
+ io,
307
+ });
308
+ return code;
309
+ };
310
+ const resolved = resolveInstallCommandRoots(command);
311
+ if (resolved.homeRootOptIn) {
312
+ addInstallEvent(installEvents, "home_root_optin", "ok");
313
+ }
314
+ const result = await installLocalCollector(resolved.command);
315
+ addInstallEvent(installEvents, "install", "ok");
298
316
  if (command.json) {
299
317
  writeLine(io.stdout, JSON.stringify(result, null, 2));
300
- return 0;
318
+ return finish(0);
301
319
  }
302
320
  writeLine(io.stdout, "Cockpit local collector installed.");
303
321
  writeLine(io.stdout, `Config: ${result.paths.config_file}`);
304
322
  writeLine(io.stdout, `Session: ${result.paths.session_file}`);
305
323
  writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
306
324
  writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
307
- return 0;
325
+ return finish(0);
326
+ }
327
+ function resolveInstallCommandRoots(command) {
328
+ const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
329
+ homeDir: command.homeDir,
330
+ allowHomeRoot: command.allowHomeRoot,
331
+ });
332
+ if (detailed.roots.length > 0) {
333
+ const root = detailed.roots[0];
334
+ return {
335
+ command: {
336
+ ...command,
337
+ repoRoot: root,
338
+ },
339
+ homeRootOptIn: Boolean(command.allowHomeRoot) &&
340
+ path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
341
+ };
342
+ }
343
+ if (detailed.rejected.length > 0) {
344
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
345
+ }
346
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
308
347
  }
309
348
  async function runUpdate(command, io) {
349
+ const installEvents = [];
350
+ const finish = async (code) => {
351
+ await reportInstallEventsBestEffort({
352
+ homeDir: command.homeDir,
353
+ dashboardUrl: command.dashboardUrl,
354
+ command: "update",
355
+ events: installEvents,
356
+ json: command.json,
357
+ io,
358
+ });
359
+ return code;
360
+ };
310
361
  const exec = io.exec ?? defaultExec();
311
362
  const installArgs = [
312
363
  "install",
@@ -320,6 +371,9 @@ async function runUpdate(command, io) {
320
371
  const install = await exec("npm", installArgs);
321
372
  writeExecOutput(io, install, { stdout: !command.json, stderr: true });
322
373
  if (install.code !== 0) {
374
+ addInstallEvent(installEvents, "npm_install", "fail", isNpmEaccesFailure(install.stderr)
375
+ ? "npm_install_eacces"
376
+ : "npm_install_failed");
323
377
  if (command.json) {
324
378
  writeLine(io.stdout, JSON.stringify({
325
379
  status: "blocked",
@@ -330,9 +384,14 @@ async function runUpdate(command, io) {
330
384
  }
331
385
  else {
332
386
  writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
387
+ if (isNpmEaccesFailure(install.stderr)) {
388
+ writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
389
+ writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
390
+ }
333
391
  }
334
- return install.code || 1;
392
+ return finish(install.code || 1);
335
393
  }
394
+ addInstallEvent(installEvents, "npm_install", "ok");
336
395
  if (!command.json) {
337
396
  writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
338
397
  }
@@ -341,7 +400,8 @@ async function runUpdate(command, io) {
341
400
  ...updateOnboardArgs(command),
342
401
  ]);
343
402
  writeExecOutput(io, onboard, { stdout: true, stderr: true });
344
- return onboard.code;
403
+ addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
404
+ return finish(onboard.code);
345
405
  }
346
406
  async function runRelease(command, io) {
347
407
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
@@ -437,6 +497,8 @@ function updateOnboardArgs(command) {
437
497
  }
438
498
  if (command.claimedOwnerEmail)
439
499
  args.push("--email", command.claimedOwnerEmail);
500
+ if (command.noAuth)
501
+ args.push("--no-auth");
440
502
  if (command.deviceName)
441
503
  args.push("--device-name", command.deviceName);
442
504
  if (command.activeTicketId)
@@ -453,10 +515,26 @@ function updateOnboardArgs(command) {
453
515
  args.push("--max-depth", String(command.maxDepth));
454
516
  if (command.maxRepos !== undefined)
455
517
  args.push("--max-repos", String(command.maxRepos));
518
+ if (command.allowHomeRoot)
519
+ args.push("--allow-home-root");
456
520
  if (command.json)
457
521
  args.push("--json");
458
522
  return args;
459
523
  }
524
+ function isNpmEaccesFailure(stderr) {
525
+ return /EACCES|permission denied/i.test(stderr);
526
+ }
527
+ function updateOnboardFailureCode(result) {
528
+ const output = `${result.stdout}\n${result.stderr}`;
529
+ if (output.includes(COLLECTION_ROOT_REQUIRED))
530
+ return COLLECTION_ROOT_REQUIRED;
531
+ if (/pairing|approval|device_pairing/i.test(output))
532
+ return "pairing_timeout";
533
+ if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
534
+ return "sync_blocked";
535
+ }
536
+ return "onboard_rerun_failed";
537
+ }
460
538
  function updateCollectionRoots(command) {
461
539
  const roots = command.collectionRoots?.length
462
540
  ? command.collectionRoots
@@ -473,6 +551,108 @@ function updateCollectionRoots(command) {
473
551
  }
474
552
  return deduped;
475
553
  }
554
+ function addInstallEvent(events, step, status, errorCode) {
555
+ events.push({
556
+ step,
557
+ status,
558
+ ...(errorCode ? { error_code: sanitizeInstallErrorCode(errorCode) } : {}),
559
+ });
560
+ }
561
+ function addOnboardFailureEvent(events, blocker) {
562
+ const step = onboardFailureStep(blocker, events);
563
+ const existing = events.find((event) => event.step === step && event.status === "fail");
564
+ if (existing)
565
+ return;
566
+ addInstallEvent(events, step, "fail", blocker);
567
+ }
568
+ function addAutostartInstallEvent(events, result) {
569
+ if (!result) {
570
+ addInstallEvent(events, "autostart", "skipped", "runner_unavailable");
571
+ return;
572
+ }
573
+ if (result.status === "unsupported") {
574
+ addInstallEvent(events, "autostart", "skipped", "unsupported");
575
+ return;
576
+ }
577
+ addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined);
578
+ }
579
+ function onboardFailureStep(blocker, events) {
580
+ if (blocker === COLLECTION_ROOT_REQUIRED)
581
+ return "install";
582
+ if (blocker === "device_pairing")
583
+ return "pair";
584
+ if (blocker === "work_context" || blocker === "ticket_binding" || blocker === "ticket") {
585
+ return "work_context";
586
+ }
587
+ if (blocker === "network_or_ingest")
588
+ return "sync";
589
+ if (blocker === "install")
590
+ return "install";
591
+ const lastIncomplete = ["install", "auth", "pair", "work_context", "sync"].find((step) => !events.some((event) => event.step === step));
592
+ return lastIncomplete ?? "sync";
593
+ }
594
+ function sanitizeInstallErrorCode(value) {
595
+ const normalized = value
596
+ .trim()
597
+ .toLowerCase()
598
+ .replace(/[^a-z0-9_]+/gu, "_")
599
+ .replace(/^_+|_+$/gu, "")
600
+ .slice(0, 120);
601
+ return normalized || "unknown";
602
+ }
603
+ async function reportInstallEventsBestEffort(options) {
604
+ if (options.events.length === 0)
605
+ return;
606
+ const paths = getCollectorRuntimePaths(options.homeDir);
607
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
608
+ if (!session ||
609
+ session.session_state !== "valid" ||
610
+ typeof session.device_token !== "string" ||
611
+ !session.device_token) {
612
+ return;
613
+ }
614
+ const controller = new AbortController();
615
+ const timeout = setTimeout(() => controller.abort(), 5_000);
616
+ try {
617
+ const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/install-events`, {
618
+ method: "POST",
619
+ headers: {
620
+ "Content-Type": "application/json",
621
+ Authorization: `Bearer ${session.device_token}`,
622
+ },
623
+ body: JSON.stringify({
624
+ cli_version: LOCAL_COLLECTOR_VERSION,
625
+ command: options.command,
626
+ os_platform: os.platform(),
627
+ events: options.events.slice(0, 40),
628
+ }),
629
+ signal: controller.signal,
630
+ });
631
+ if (!response.ok) {
632
+ throw new Error(`http_${response.status}`);
633
+ }
634
+ }
635
+ catch (error) {
636
+ if (options.json) {
637
+ writeLine(options.io.stderr, `Install event telemetry skipped: ${classifyInstallTelemetryError(error)}`);
638
+ }
639
+ }
640
+ finally {
641
+ clearTimeout(timeout);
642
+ }
643
+ }
644
+ function classifyInstallTelemetryError(error) {
645
+ if (error instanceof Error && error.name === "AbortError") {
646
+ return "timeout";
647
+ }
648
+ const message = errorMessage(error);
649
+ const status = message.match(/http_(\d{3})/i)?.[1];
650
+ if (status)
651
+ return `http_${status}`;
652
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
653
+ return "network";
654
+ return "failed";
655
+ }
476
656
  function writeExecOutput(io, result, options) {
477
657
  if (options.stdout)
478
658
  writeRaw(io.stdout, result.stdout);
@@ -514,7 +694,7 @@ async function readLine(io, prompt) {
514
694
  * leniency (the approving admin's account then owns the device).
515
695
  */
516
696
  async function promptOnboardEmail(io) {
517
- const raw = await readLine(io, "Dashboard email (press enter to skip): ");
697
+ const raw = await readLine(io, "What's your @buildlaunchiterate.ca email? (press enter to skip): ");
518
698
  const answer = raw.trim().toLowerCase();
519
699
  if (!answer)
520
700
  return undefined;
@@ -528,12 +708,29 @@ function onboardingRootPrompt(io) {
528
708
  return {
529
709
  confirm: async (message) => yesByDefault(await readLine(io, message)),
530
710
  input: (message) => readLine(io, message),
711
+ message: (message) => writeLine(io.stdout, message),
531
712
  };
532
713
  }
533
714
  function yesByDefault(raw) {
534
715
  const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
535
716
  return answer !== "n" && answer !== "no";
536
717
  }
718
+ const OTP_CODE_PROMPT = [
719
+ "Email code needed:",
720
+ " Check your latest Cockpit email for a 6- to 10-digit code.",
721
+ "What you can do:",
722
+ " 1) Paste the code here.",
723
+ " 2) No code yet: wait for the resend window, then rerun this command.",
724
+ " 3) Can't use email: rerun with --no-auth for manual approval.",
725
+ "Code: ",
726
+ ].join("\n");
727
+ const OTP_INVALID_MESSAGE = [
728
+ "Email code was not accepted:",
729
+ " Cockpit expects digits only, length 6 to 10.",
730
+ "What you can do:",
731
+ " 1) Rerun and paste the latest email code.",
732
+ " 2) Use manual approval: cockpit onboard --no-auth --email <you@buildlaunchiterate.ca>",
733
+ ].join("\n");
537
734
  async function resolveOnboardEmail(command, roots, config, io) {
538
735
  if (command.claimedOwnerEmail)
539
736
  return command.claimedOwnerEmail;
@@ -558,6 +755,138 @@ async function resolveOnboardEmail(command, roots, config, io) {
558
755
  }
559
756
  return promptOnboardEmail(io);
560
757
  }
758
+ async function resolveInteractiveLoginEmail(command, io) {
759
+ if (command.claimedOwnerEmail)
760
+ return command.claimedOwnerEmail;
761
+ if (command.noAuth || command.json || !isInteractiveStdin(io)) {
762
+ return undefined;
763
+ }
764
+ return promptOnboardEmail(io);
765
+ }
766
+ async function requestPairingAccessToken(input, io) {
767
+ return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
768
+ }
769
+ async function requestPairingAccessTokenDetailed(input, io) {
770
+ if (!input.email ||
771
+ input.noAuth ||
772
+ input.json ||
773
+ !isInteractiveStdin(io)) {
774
+ return {
775
+ status: "skipped",
776
+ errorCode: authSkippedReason(input, io),
777
+ };
778
+ }
779
+ try {
780
+ const fetchImpl = io.fetch;
781
+ writeLine(io.stdout, `Signing in as ${input.email}.`);
782
+ const start = await postOtpStart(fetchImpl, input.dashboardUrl, input.email);
783
+ const resendAfter = typeof start.resend_after_seconds === "number"
784
+ ? start.resend_after_seconds
785
+ : 60;
786
+ writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
787
+ const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
788
+ if (!/^\d{6,10}$/.test(code)) {
789
+ throw new Error(OTP_INVALID_MESSAGE);
790
+ }
791
+ const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
792
+ if (typeof verified.access_token !== "string" || !verified.access_token) {
793
+ throw new Error("OTP verified but dashboard returned no access token.");
794
+ }
795
+ writeLine(io.stdout, `Signed in as ${input.email}.`);
796
+ return { status: "ok", accessToken: verified.access_token };
797
+ }
798
+ catch (error) {
799
+ writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
800
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
801
+ return { status: "fail", errorCode: classifyAuthError(error) };
802
+ }
803
+ }
804
+ function authSkippedReason(input, io) {
805
+ if (!input.email)
806
+ return "email_missing";
807
+ if (input.noAuth)
808
+ return "no_auth";
809
+ if (input.json)
810
+ return "json_mode";
811
+ if (!isInteractiveStdin(io))
812
+ return "non_interactive";
813
+ return "auth_skipped";
814
+ }
815
+ function classifyAuthError(error) {
816
+ const message = errorMessage(error);
817
+ if (/\b(otp|code|digit|invalid)\b/i.test(message))
818
+ return "otp_invalid";
819
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
820
+ return "network_or_auth";
821
+ }
822
+ return "auth_failed";
823
+ }
824
+ async function pairLocalCollectorWithAuthFallback(options, io) {
825
+ try {
826
+ return await pairLocalCollector(options);
827
+ }
828
+ catch (error) {
829
+ if (!options.pairingAccessToken || !isPairingAuthFailure(error)) {
830
+ throw error;
831
+ }
832
+ writeLine(io.stderr, `Authenticated pairing failed: ${errorMessage(error)}`);
833
+ writeLine(io.stderr, "Continuing with manual dashboard approval.");
834
+ return pairLocalCollector({
835
+ ...options,
836
+ pairingAccessToken: undefined,
837
+ });
838
+ }
839
+ }
840
+ function isPairingAuthFailure(error) {
841
+ return /\b(auth|authorization|bearer|token|jwt|otp)\b/i.test(errorMessage(error));
842
+ }
843
+ async function postOtpStart(fetchImpl, dashboardUrl, email) {
844
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/start`, {
845
+ method: "POST",
846
+ headers: { "Content-Type": "application/json" },
847
+ body: JSON.stringify({ email }),
848
+ });
849
+ const parsed = await readJsonResponse(response);
850
+ if (!response.ok) {
851
+ throw new Error(responseErrorMessage(parsed, "OTP start failed"));
852
+ }
853
+ return parsed;
854
+ }
855
+ async function postOtpVerify(fetchImpl, dashboardUrl, email, code) {
856
+ const response = await fetchImpl(`${dashboardUrl}/api/auth/otp/verify`, {
857
+ method: "POST",
858
+ headers: { "Content-Type": "application/json" },
859
+ body: JSON.stringify({ email, code }),
860
+ });
861
+ const parsed = await readJsonResponse(response);
862
+ if (!response.ok) {
863
+ throw new Error(responseErrorMessage(parsed, "OTP verify failed"));
864
+ }
865
+ return parsed;
866
+ }
867
+ async function readJsonResponse(response) {
868
+ const text = await response.text();
869
+ if (!text)
870
+ return null;
871
+ try {
872
+ return JSON.parse(text);
873
+ }
874
+ catch {
875
+ return text;
876
+ }
877
+ }
878
+ function responseErrorMessage(parsed, fallback) {
879
+ if (parsed && typeof parsed === "object") {
880
+ const record = parsed;
881
+ if (typeof record["message"] === "string" && record["message"].trim()) {
882
+ return record["message"];
883
+ }
884
+ if (typeof record["error"] === "string" && record["error"].trim()) {
885
+ return record["error"];
886
+ }
887
+ }
888
+ return fallback;
889
+ }
561
890
  async function inferOnboardEmail(homeDir, roots, config) {
562
891
  const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
563
892
  const sessionEmail = normalizeEmailForComparison(session?.email);
@@ -675,6 +1004,18 @@ function backgroundSyncLine(result) {
675
1004
  return result.status;
676
1005
  }
677
1006
  async function runOnboard(command, io) {
1007
+ const installEvents = [];
1008
+ const finish = async (code) => {
1009
+ await reportInstallEventsBestEffort({
1010
+ homeDir: command.homeDir,
1011
+ dashboardUrl: command.dashboardUrl,
1012
+ command: "onboard",
1013
+ events: installEvents,
1014
+ json: command.json,
1015
+ io,
1016
+ });
1017
+ return code;
1018
+ };
678
1019
  let install = null;
679
1020
  let pair = null;
680
1021
  let sync = null;
@@ -698,6 +1039,7 @@ async function runOnboard(command, io) {
698
1039
  explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
699
1040
  config: existingConfig,
700
1041
  interactive,
1042
+ allowHomeRoot: command.allowHomeRoot,
701
1043
  prompt: interactive ? onboardingRootPrompt(io) : undefined,
702
1044
  });
703
1045
  const collectionRoots = rootsResult.roots;
@@ -716,6 +1058,9 @@ async function runOnboard(command, io) {
716
1058
  if (!command.json) {
717
1059
  writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
718
1060
  }
1061
+ if (rootsResult.homeRootOptIn) {
1062
+ addInstallEvent(installEvents, "home_root_optin", "ok");
1063
+ }
719
1064
  const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
720
1065
  install = await installLocalCollector({
721
1066
  homeDir: command.homeDir,
@@ -725,6 +1070,7 @@ async function runOnboard(command, io) {
725
1070
  dashboardUrl: command.dashboardUrl,
726
1071
  deviceName: command.deviceName,
727
1072
  });
1073
+ addInstallEvent(installEvents, "install", "ok");
728
1074
  if (!command.json) {
729
1075
  writeLine(io.stdout, "1/5 Installed local collector.");
730
1076
  writeLine(io.stdout, `Config: ${install.paths.config_file}`);
@@ -737,6 +1083,8 @@ async function runOnboard(command, io) {
737
1083
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
738
1084
  const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
739
1085
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
1086
+ addInstallEvent(installEvents, "auth", "skipped", "existing_session");
1087
+ addInstallEvent(installEvents, "pair", "skipped", "existing_session");
740
1088
  if (!command.json) {
741
1089
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
742
1090
  }
@@ -748,18 +1096,27 @@ async function runOnboard(command, io) {
748
1096
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
749
1097
  }
750
1098
  }
751
- pair = await pairLocalCollector({
1099
+ const authResult = await requestPairingAccessTokenDetailed({
1100
+ dashboardUrl: command.dashboardUrl,
1101
+ email: claimedOwnerEmail,
1102
+ noAuth: command.noAuth,
1103
+ json: command.json,
1104
+ }, io);
1105
+ addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
1106
+ pair = await pairLocalCollectorWithAuthFallback({
752
1107
  homeDir: command.homeDir,
753
1108
  dashboardUrl: command.dashboardUrl,
754
1109
  claimedOwnerEmail,
755
1110
  deviceName: command.deviceName,
756
1111
  pollIntervalMs: command.pollIntervalMs,
757
1112
  timeoutMs: command.timeoutMs,
1113
+ pairingAccessToken: authResult.accessToken,
758
1114
  fetch: io.fetch,
759
1115
  onPairStarted: command.json
760
1116
  ? undefined
761
1117
  : (request) => writePairingInstructions(io, request),
762
- });
1118
+ }, io);
1119
+ addInstallEvent(installEvents, "pair", "ok");
763
1120
  if (!command.json) {
764
1121
  writeLine(io.stdout, "2/5 Device paired.");
765
1122
  writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
@@ -769,9 +1126,13 @@ async function runOnboard(command, io) {
769
1126
  const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
770
1127
  if (worktrees.length > 1) {
771
1128
  const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
1129
+ addInstallEvent(installEvents, "work_context", "ok");
1130
+ addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
772
1131
  if (multi.ok) {
773
1132
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1133
+ addInstallEvent(installEvents, "agent_rules", "ok");
774
1134
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1135
+ addAutostartInstallEvent(installEvents, autostart);
775
1136
  }
776
1137
  if (command.json) {
777
1138
  writeLine(io.stdout, JSON.stringify({
@@ -797,7 +1158,7 @@ async function runOnboard(command, io) {
797
1158
  backfillHint,
798
1159
  });
799
1160
  }
800
- return multi.ok ? 0 : 1;
1161
+ return finish(multi.ok ? 0 : 1);
801
1162
  }
802
1163
  const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
803
1164
  const context = await startLocalWorkContext({
@@ -813,6 +1174,7 @@ async function runOnboard(command, io) {
813
1174
  writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
814
1175
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
815
1176
  }
1177
+ addInstallEvent(installEvents, "work_context", "ok");
816
1178
  const run = await runAttributedWorktreeSync({
817
1179
  homeDir: command.homeDir,
818
1180
  dashboardUrl: command.dashboardUrl,
@@ -830,6 +1192,7 @@ async function runOnboard(command, io) {
830
1192
  branch: command.branch,
831
1193
  });
832
1194
  if (sync.status !== "uploaded") {
1195
+ addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
833
1196
  if (command.json) {
834
1197
  writeLine(io.stdout, JSON.stringify({
835
1198
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
@@ -843,11 +1206,14 @@ async function runOnboard(command, io) {
843
1206
  writeLine(io.stderr, `Failure: ${sync.failure_reason}`);
844
1207
  writeLine(io.stderr, `Retry: ${sync.retry_command}`);
845
1208
  }
846
- return 1;
1209
+ return finish(1);
847
1210
  }
1211
+ addInstallEvent(installEvents, "sync", "ok");
848
1212
  if (command.json) {
849
1213
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1214
+ addInstallEvent(installEvents, "agent_rules", "ok");
850
1215
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1216
+ addAutostartInstallEvent(installEvents, autostart);
851
1217
  writeLine(io.stdout, JSON.stringify({
852
1218
  ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
853
1219
  collection_roots: collectionRoots,
@@ -857,7 +1223,7 @@ async function runOnboard(command, io) {
857
1223
  backfill_hint: backfillHint,
858
1224
  codex_sessions: run.summary,
859
1225
  }, null, 2));
860
- return 0;
1226
+ return finish(0);
861
1227
  }
862
1228
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
863
1229
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
@@ -872,7 +1238,9 @@ async function runOnboard(command, io) {
872
1238
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
873
1239
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
874
1240
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1241
+ addInstallEvent(installEvents, "agent_rules", "ok");
875
1242
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1243
+ addAutostartInstallEvent(installEvents, autostart);
876
1244
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
877
1245
  pair,
878
1246
  status,
@@ -882,7 +1250,7 @@ async function runOnboard(command, io) {
882
1250
  initialSyncOk: true,
883
1251
  backfillHint,
884
1252
  });
885
- return 0;
1253
+ return finish(0);
886
1254
  }
887
1255
  catch (error) {
888
1256
  const message = errorMessage(error);
@@ -895,7 +1263,8 @@ async function runOnboard(command, io) {
895
1263
  }).catch(() => null)
896
1264
  : null;
897
1265
  const blocker = classifyOnboardBlocker(message);
898
- const nextStep = nextStepForOnboardBlocker(blocker);
1266
+ addOnboardFailureEvent(installEvents, blocker);
1267
+ const nextStep = nextStepForOnboardBlocker(blocker, command);
899
1268
  if (command.json) {
900
1269
  writeLine(io.stdout, JSON.stringify({
901
1270
  ...onboardResult("blocked", command, install, pair, sync, status),
@@ -910,7 +1279,7 @@ async function runOnboard(command, io) {
910
1279
  writeLine(io.stderr, `BLOCKED: ${message}`);
911
1280
  writeLine(io.stderr, `Next: ${nextStep}`);
912
1281
  }
913
- return 1;
1282
+ return finish(1);
914
1283
  }
915
1284
  }
916
1285
  function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
@@ -1107,13 +1476,22 @@ function worktreeSyncRow(outcome, run) {
1107
1476
  };
1108
1477
  }
1109
1478
  async function runLogin(command, io) {
1110
- const result = await pairLocalCollector({
1479
+ const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
1480
+ const dashboardUrl = await resolveLoginDashboardUrl(command);
1481
+ const pairingAccessToken = await requestPairingAccessToken({
1482
+ dashboardUrl,
1483
+ email: claimedOwnerEmail,
1484
+ noAuth: command.noAuth,
1485
+ json: command.json,
1486
+ }, io);
1487
+ const result = await pairLocalCollectorWithAuthFallback({
1111
1488
  homeDir: command.homeDir,
1112
1489
  dashboardUrl: command.dashboardUrl,
1113
- claimedOwnerEmail: command.claimedOwnerEmail,
1490
+ claimedOwnerEmail,
1114
1491
  deviceName: command.deviceName,
1115
1492
  pollIntervalMs: command.pollIntervalMs,
1116
1493
  timeoutMs: command.timeoutMs,
1494
+ pairingAccessToken,
1117
1495
  fetch: io.fetch,
1118
1496
  onPairStarted: command.json
1119
1497
  ? undefined
@@ -1123,7 +1501,7 @@ async function runLogin(command, io) {
1123
1501
  writeLine(io.stdout, `Code: ${request.user_code}`);
1124
1502
  writeLine(io.stdout, "Waiting for dashboard approval...");
1125
1503
  },
1126
- });
1504
+ }, io);
1127
1505
  if (command.json) {
1128
1506
  writeLine(io.stdout, JSON.stringify(result, null, 2));
1129
1507
  return 0;
@@ -1135,6 +1513,13 @@ async function runLogin(command, io) {
1135
1513
  writeLine(io.stdout, "Next: run `cockpit start` inside the repo.");
1136
1514
  return 0;
1137
1515
  }
1516
+ async function resolveLoginDashboardUrl(command) {
1517
+ if (command.dashboardUrl)
1518
+ return command.dashboardUrl;
1519
+ const paths = getCollectorRuntimePaths(command.homeDir);
1520
+ const config = await readLocalCollectorConfig(paths);
1521
+ return config.dashboard_url;
1522
+ }
1138
1523
  function writePairingInstructions(io, request) {
1139
1524
  writeLine(io.stdout, "2/5 Device pairing started.");
1140
1525
  writeLine(io.stdout, `Open: ${request.approve_url}`);
@@ -1179,10 +1564,10 @@ function classifyOnboardBlocker(message) {
1179
1564
  return "ticket_binding";
1180
1565
  return "unknown";
1181
1566
  }
1182
- function nextStepForOnboardBlocker(blocker) {
1567
+ function nextStepForOnboardBlocker(blocker, options = {}) {
1183
1568
  switch (blocker) {
1184
1569
  case COLLECTION_ROOT_REQUIRED:
1185
- return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
1570
+ return missingCollectionRootMessage(options);
1186
1571
  case "ticket":
1187
1572
  case "ticket_binding":
1188
1573
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
@@ -1297,6 +1682,7 @@ async function runSyncLocked(command, io) {
1297
1682
  });
1298
1683
  if (worktrees.length > 1) {
1299
1684
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1685
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1300
1686
  if (command.json) {
1301
1687
  writeLine(io.stdout, JSON.stringify({
1302
1688
  mode: "multi_repo",
@@ -1304,6 +1690,7 @@ async function runSyncLocked(command, io) {
1304
1690
  results: run.outcomes.map((outcome) => outcome.sync),
1305
1691
  repos: rows,
1306
1692
  codex_sessions: run.summary,
1693
+ raw_evidence_gc: gc,
1307
1694
  }, null, 2));
1308
1695
  return run.ok ? 0 : 1;
1309
1696
  }
@@ -1315,14 +1702,17 @@ async function runSyncLocked(command, io) {
1315
1702
  writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
1316
1703
  }
1317
1704
  writeAgentSessionSummary(io, run.summary);
1705
+ if (gc && !gc.skipped)
1706
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1318
1707
  return run.ok ? 0 : 1;
1319
1708
  }
1320
1709
  const result = run.outcomes[0]?.sync;
1321
1710
  if (!result) {
1322
1711
  throw new Error("Sync produced no result for the repo worktree.");
1323
1712
  }
1713
+ const gc = result.status === "uploaded" ? await runSyncRawEvidenceGc(command, io) : null;
1324
1714
  if (command.json) {
1325
- writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
1715
+ writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary, raw_evidence_gc: gc }, null, 2));
1326
1716
  return result.status === "uploaded" ? 0 : 1;
1327
1717
  }
1328
1718
  if (result.status === "uploaded") {
@@ -1336,6 +1726,8 @@ async function runSyncLocked(command, io) {
1336
1726
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1337
1727
  writeAgentSessionSummary(io, run.summary);
1338
1728
  writeLine(io.stdout, cursorStatusLine(result));
1729
+ if (gc && !gc.skipped)
1730
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1339
1731
  return 0;
1340
1732
  }
1341
1733
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -1344,6 +1736,9 @@ async function runSyncLocked(command, io) {
1344
1736
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1345
1737
  return 1;
1346
1738
  }
1739
+ async function runSyncRawEvidenceGc(command, io) {
1740
+ return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
1741
+ }
1347
1742
  async function runStatus(command, io) {
1348
1743
  const backfillCursor = await inspectBackfillCursor(command.homeDir);
1349
1744
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);