@bli-cockpit/cli 0.2.0 → 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.
package/README.md CHANGED
@@ -19,7 +19,13 @@ Ticket: general ambient
19
19
  What's your @buildlaunchiterate.ca email? (press enter to skip): ian@buildlaunchiterate.ca
20
20
  Signing in as ian@buildlaunchiterate.ca.
21
21
  Code sent; valid 1h, resend in 60s by rerunning this command.
22
- Enter the 6-digit code: 482913
22
+ Email code needed:
23
+ Check your latest Cockpit email for a 6- to 10-digit code.
24
+ What you can do:
25
+ 1) Paste the code here.
26
+ 2) No code yet: wait for the resend window, then rerun this command.
27
+ 3) Can't use email: rerun with --no-auth for manual approval.
28
+ Code: 482913
23
29
  Signed in as ian@buildlaunchiterate.ca.
24
30
  2/5 Device paired.
25
31
  PASS: Cockpit collector is ready for harvest.
@@ -76,6 +82,7 @@ cockpit onboard --no-auth
76
82
 
77
83
  - `--email` skips the email prompt.
78
84
  - `--workspace` pins one collection root; repeat it for multiple unrelated roots.
85
+ - `--allow-home-root` deliberately collects your whole home folder. Use it only on a company machine where that is intended.
79
86
  - `--device-name` changes only the human label shown in Cockpit.
80
87
  - `--dashboard-url` is for staging/custom dashboards only. Production is the default.
81
88
  - `--no-auth` forces the old manual approval queue.
@@ -61,6 +61,7 @@ function parseOnboardLikeArgs(args, command) {
61
61
  "--timeout-ms",
62
62
  "--max-depth",
63
63
  "--max-repos",
64
+ "--allow-home-root",
64
65
  ],
65
66
  valueFlags: [
66
67
  "--home",
@@ -94,6 +95,7 @@ function parseOnboardLikeArgs(args, command) {
94
95
  timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
95
96
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
96
97
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
98
+ allowHomeRoot: values.booleans.has("--allow-home-root"),
97
99
  };
98
100
  }
99
101
  function parseOnboardArgs(args) {
@@ -115,6 +117,7 @@ function parseInstallArgs(args) {
115
117
  "--dashboard-url",
116
118
  "--supabase-url",
117
119
  "--json",
120
+ "--allow-home-root",
118
121
  ],
119
122
  valueFlags: [
120
123
  "--home",
@@ -132,6 +135,7 @@ function parseInstallArgs(args) {
132
135
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
133
136
  supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
134
137
  json: values.booleans.has("--json"),
138
+ allowHomeRoot: values.booleans.has("--allow-home-root"),
135
139
  };
136
140
  }
137
141
  function parseReleaseArgs(args) {
@@ -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,10 +91,10 @@ 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>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
94
- " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--no-auth] [--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 install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
97
98
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
98
99
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
100
  " cockpit logout",
@@ -115,7 +116,7 @@ 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>] [--no-auth] [--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.",
@@ -129,7 +130,7 @@ function localSubcommandHelp(command) {
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>] [--no-auth] [--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,",
@@ -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",
@@ -335,8 +389,9 @@ async function runUpdate(command, io) {
335
389
  writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
336
390
  }
337
391
  }
338
- return install.code || 1;
392
+ return finish(install.code || 1);
339
393
  }
394
+ addInstallEvent(installEvents, "npm_install", "ok");
340
395
  if (!command.json) {
341
396
  writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
342
397
  }
@@ -345,7 +400,8 @@ async function runUpdate(command, io) {
345
400
  ...updateOnboardArgs(command),
346
401
  ]);
347
402
  writeExecOutput(io, onboard, { stdout: true, stderr: true });
348
- return onboard.code;
403
+ addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
404
+ return finish(onboard.code);
349
405
  }
350
406
  async function runRelease(command, io) {
351
407
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
@@ -459,6 +515,8 @@ function updateOnboardArgs(command) {
459
515
  args.push("--max-depth", String(command.maxDepth));
460
516
  if (command.maxRepos !== undefined)
461
517
  args.push("--max-repos", String(command.maxRepos));
518
+ if (command.allowHomeRoot)
519
+ args.push("--allow-home-root");
462
520
  if (command.json)
463
521
  args.push("--json");
464
522
  return args;
@@ -466,6 +524,17 @@ function updateOnboardArgs(command) {
466
524
  function isNpmEaccesFailure(stderr) {
467
525
  return /EACCES|permission denied/i.test(stderr);
468
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
+ }
469
538
  function updateCollectionRoots(command) {
470
539
  const roots = command.collectionRoots?.length
471
540
  ? command.collectionRoots
@@ -482,6 +551,108 @@ function updateCollectionRoots(command) {
482
551
  }
483
552
  return deduped;
484
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
+ }
485
656
  function writeExecOutput(io, result, options) {
486
657
  if (options.stdout)
487
658
  writeRaw(io.stdout, result.stdout);
@@ -537,12 +708,29 @@ function onboardingRootPrompt(io) {
537
708
  return {
538
709
  confirm: async (message) => yesByDefault(await readLine(io, message)),
539
710
  input: (message) => readLine(io, message),
711
+ message: (message) => writeLine(io.stdout, message),
540
712
  };
541
713
  }
542
714
  function yesByDefault(raw) {
543
715
  const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
544
716
  return answer !== "n" && answer !== "no";
545
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");
546
734
  async function resolveOnboardEmail(command, roots, config, io) {
547
735
  if (command.claimedOwnerEmail)
548
736
  return command.claimedOwnerEmail;
@@ -576,11 +764,17 @@ async function resolveInteractiveLoginEmail(command, io) {
576
764
  return promptOnboardEmail(io);
577
765
  }
578
766
  async function requestPairingAccessToken(input, io) {
767
+ return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
768
+ }
769
+ async function requestPairingAccessTokenDetailed(input, io) {
579
770
  if (!input.email ||
580
771
  input.noAuth ||
581
772
  input.json ||
582
773
  !isInteractiveStdin(io)) {
583
- return undefined;
774
+ return {
775
+ status: "skipped",
776
+ errorCode: authSkippedReason(input, io),
777
+ };
584
778
  }
585
779
  try {
586
780
  const fetchImpl = io.fetch;
@@ -590,23 +784,43 @@ async function requestPairingAccessToken(input, io) {
590
784
  ? start.resend_after_seconds
591
785
  : 60;
592
786
  writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
593
- const code = (await readLine(io, "Enter the 6-digit code: ")).trim();
594
- if (!/^\d{6}$/.test(code)) {
595
- throw new Error("OTP code must be 6 digits.");
787
+ const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
788
+ if (!/^\d{6,10}$/.test(code)) {
789
+ throw new Error(OTP_INVALID_MESSAGE);
596
790
  }
597
791
  const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
598
792
  if (typeof verified.access_token !== "string" || !verified.access_token) {
599
793
  throw new Error("OTP verified but dashboard returned no access token.");
600
794
  }
601
795
  writeLine(io.stdout, `Signed in as ${input.email}.`);
602
- return verified.access_token;
796
+ return { status: "ok", accessToken: verified.access_token };
603
797
  }
604
798
  catch (error) {
605
799
  writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
606
800
  writeLine(io.stderr, "Continuing with manual dashboard approval.");
607
- return undefined;
801
+ return { status: "fail", errorCode: classifyAuthError(error) };
608
802
  }
609
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
+ }
610
824
  async function pairLocalCollectorWithAuthFallback(options, io) {
611
825
  try {
612
826
  return await pairLocalCollector(options);
@@ -790,6 +1004,18 @@ function backgroundSyncLine(result) {
790
1004
  return result.status;
791
1005
  }
792
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
+ };
793
1019
  let install = null;
794
1020
  let pair = null;
795
1021
  let sync = null;
@@ -813,6 +1039,7 @@ async function runOnboard(command, io) {
813
1039
  explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
814
1040
  config: existingConfig,
815
1041
  interactive,
1042
+ allowHomeRoot: command.allowHomeRoot,
816
1043
  prompt: interactive ? onboardingRootPrompt(io) : undefined,
817
1044
  });
818
1045
  const collectionRoots = rootsResult.roots;
@@ -831,6 +1058,9 @@ async function runOnboard(command, io) {
831
1058
  if (!command.json) {
832
1059
  writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
833
1060
  }
1061
+ if (rootsResult.homeRootOptIn) {
1062
+ addInstallEvent(installEvents, "home_root_optin", "ok");
1063
+ }
834
1064
  const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
835
1065
  install = await installLocalCollector({
836
1066
  homeDir: command.homeDir,
@@ -840,6 +1070,7 @@ async function runOnboard(command, io) {
840
1070
  dashboardUrl: command.dashboardUrl,
841
1071
  deviceName: command.deviceName,
842
1072
  });
1073
+ addInstallEvent(installEvents, "install", "ok");
843
1074
  if (!command.json) {
844
1075
  writeLine(io.stdout, "1/5 Installed local collector.");
845
1076
  writeLine(io.stdout, `Config: ${install.paths.config_file}`);
@@ -852,6 +1083,8 @@ async function runOnboard(command, io) {
852
1083
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
853
1084
  const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
854
1085
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
1086
+ addInstallEvent(installEvents, "auth", "skipped", "existing_session");
1087
+ addInstallEvent(installEvents, "pair", "skipped", "existing_session");
855
1088
  if (!command.json) {
856
1089
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
857
1090
  }
@@ -863,12 +1096,13 @@ async function runOnboard(command, io) {
863
1096
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
864
1097
  }
865
1098
  }
866
- const pairingAccessToken = await requestPairingAccessToken({
1099
+ const authResult = await requestPairingAccessTokenDetailed({
867
1100
  dashboardUrl: command.dashboardUrl,
868
1101
  email: claimedOwnerEmail,
869
1102
  noAuth: command.noAuth,
870
1103
  json: command.json,
871
1104
  }, io);
1105
+ addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
872
1106
  pair = await pairLocalCollectorWithAuthFallback({
873
1107
  homeDir: command.homeDir,
874
1108
  dashboardUrl: command.dashboardUrl,
@@ -876,12 +1110,13 @@ async function runOnboard(command, io) {
876
1110
  deviceName: command.deviceName,
877
1111
  pollIntervalMs: command.pollIntervalMs,
878
1112
  timeoutMs: command.timeoutMs,
879
- pairingAccessToken,
1113
+ pairingAccessToken: authResult.accessToken,
880
1114
  fetch: io.fetch,
881
1115
  onPairStarted: command.json
882
1116
  ? undefined
883
1117
  : (request) => writePairingInstructions(io, request),
884
1118
  }, io);
1119
+ addInstallEvent(installEvents, "pair", "ok");
885
1120
  if (!command.json) {
886
1121
  writeLine(io.stdout, "2/5 Device paired.");
887
1122
  writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
@@ -891,9 +1126,13 @@ async function runOnboard(command, io) {
891
1126
  const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
892
1127
  if (worktrees.length > 1) {
893
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");
894
1131
  if (multi.ok) {
895
1132
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1133
+ addInstallEvent(installEvents, "agent_rules", "ok");
896
1134
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1135
+ addAutostartInstallEvent(installEvents, autostart);
897
1136
  }
898
1137
  if (command.json) {
899
1138
  writeLine(io.stdout, JSON.stringify({
@@ -919,7 +1158,7 @@ async function runOnboard(command, io) {
919
1158
  backfillHint,
920
1159
  });
921
1160
  }
922
- return multi.ok ? 0 : 1;
1161
+ return finish(multi.ok ? 0 : 1);
923
1162
  }
924
1163
  const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
925
1164
  const context = await startLocalWorkContext({
@@ -935,6 +1174,7 @@ async function runOnboard(command, io) {
935
1174
  writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
936
1175
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
937
1176
  }
1177
+ addInstallEvent(installEvents, "work_context", "ok");
938
1178
  const run = await runAttributedWorktreeSync({
939
1179
  homeDir: command.homeDir,
940
1180
  dashboardUrl: command.dashboardUrl,
@@ -952,6 +1192,7 @@ async function runOnboard(command, io) {
952
1192
  branch: command.branch,
953
1193
  });
954
1194
  if (sync.status !== "uploaded") {
1195
+ addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
955
1196
  if (command.json) {
956
1197
  writeLine(io.stdout, JSON.stringify({
957
1198
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
@@ -965,11 +1206,14 @@ async function runOnboard(command, io) {
965
1206
  writeLine(io.stderr, `Failure: ${sync.failure_reason}`);
966
1207
  writeLine(io.stderr, `Retry: ${sync.retry_command}`);
967
1208
  }
968
- return 1;
1209
+ return finish(1);
969
1210
  }
1211
+ addInstallEvent(installEvents, "sync", "ok");
970
1212
  if (command.json) {
971
1213
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1214
+ addInstallEvent(installEvents, "agent_rules", "ok");
972
1215
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1216
+ addAutostartInstallEvent(installEvents, autostart);
973
1217
  writeLine(io.stdout, JSON.stringify({
974
1218
  ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
975
1219
  collection_roots: collectionRoots,
@@ -979,7 +1223,7 @@ async function runOnboard(command, io) {
979
1223
  backfill_hint: backfillHint,
980
1224
  codex_sessions: run.summary,
981
1225
  }, null, 2));
982
- return 0;
1226
+ return finish(0);
983
1227
  }
984
1228
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
985
1229
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
@@ -994,7 +1238,9 @@ async function runOnboard(command, io) {
994
1238
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
995
1239
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
996
1240
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1241
+ addInstallEvent(installEvents, "agent_rules", "ok");
997
1242
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1243
+ addAutostartInstallEvent(installEvents, autostart);
998
1244
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
999
1245
  pair,
1000
1246
  status,
@@ -1004,7 +1250,7 @@ async function runOnboard(command, io) {
1004
1250
  initialSyncOk: true,
1005
1251
  backfillHint,
1006
1252
  });
1007
- return 0;
1253
+ return finish(0);
1008
1254
  }
1009
1255
  catch (error) {
1010
1256
  const message = errorMessage(error);
@@ -1017,7 +1263,8 @@ async function runOnboard(command, io) {
1017
1263
  }).catch(() => null)
1018
1264
  : null;
1019
1265
  const blocker = classifyOnboardBlocker(message);
1020
- const nextStep = nextStepForOnboardBlocker(blocker);
1266
+ addOnboardFailureEvent(installEvents, blocker);
1267
+ const nextStep = nextStepForOnboardBlocker(blocker, command);
1021
1268
  if (command.json) {
1022
1269
  writeLine(io.stdout, JSON.stringify({
1023
1270
  ...onboardResult("blocked", command, install, pair, sync, status),
@@ -1032,7 +1279,7 @@ async function runOnboard(command, io) {
1032
1279
  writeLine(io.stderr, `BLOCKED: ${message}`);
1033
1280
  writeLine(io.stderr, `Next: ${nextStep}`);
1034
1281
  }
1035
- return 1;
1282
+ return finish(1);
1036
1283
  }
1037
1284
  }
1038
1285
  function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
@@ -1317,10 +1564,10 @@ function classifyOnboardBlocker(message) {
1317
1564
  return "ticket_binding";
1318
1565
  return "unknown";
1319
1566
  }
1320
- function nextStepForOnboardBlocker(blocker) {
1567
+ function nextStepForOnboardBlocker(blocker, options = {}) {
1321
1568
  switch (blocker) {
1322
1569
  case COLLECTION_ROOT_REQUIRED:
1323
- return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
1570
+ return missingCollectionRootMessage(options);
1324
1571
  case "ticket":
1325
1572
  case "ticket_binding":
1326
1573
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
@@ -1435,6 +1682,7 @@ async function runSyncLocked(command, io) {
1435
1682
  });
1436
1683
  if (worktrees.length > 1) {
1437
1684
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1685
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1438
1686
  if (command.json) {
1439
1687
  writeLine(io.stdout, JSON.stringify({
1440
1688
  mode: "multi_repo",
@@ -1442,6 +1690,7 @@ async function runSyncLocked(command, io) {
1442
1690
  results: run.outcomes.map((outcome) => outcome.sync),
1443
1691
  repos: rows,
1444
1692
  codex_sessions: run.summary,
1693
+ raw_evidence_gc: gc,
1445
1694
  }, null, 2));
1446
1695
  return run.ok ? 0 : 1;
1447
1696
  }
@@ -1453,14 +1702,17 @@ async function runSyncLocked(command, io) {
1453
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}`);
1454
1703
  }
1455
1704
  writeAgentSessionSummary(io, run.summary);
1705
+ if (gc && !gc.skipped)
1706
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1456
1707
  return run.ok ? 0 : 1;
1457
1708
  }
1458
1709
  const result = run.outcomes[0]?.sync;
1459
1710
  if (!result) {
1460
1711
  throw new Error("Sync produced no result for the repo worktree.");
1461
1712
  }
1713
+ const gc = result.status === "uploaded" ? await runSyncRawEvidenceGc(command, io) : null;
1462
1714
  if (command.json) {
1463
- 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));
1464
1716
  return result.status === "uploaded" ? 0 : 1;
1465
1717
  }
1466
1718
  if (result.status === "uploaded") {
@@ -1474,6 +1726,8 @@ async function runSyncLocked(command, io) {
1474
1726
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1475
1727
  writeAgentSessionSummary(io, run.summary);
1476
1728
  writeLine(io.stdout, cursorStatusLine(result));
1729
+ if (gc && !gc.skipped)
1730
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1477
1731
  return 0;
1478
1732
  }
1479
1733
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -1482,6 +1736,9 @@ async function runSyncLocked(command, io) {
1482
1736
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1483
1737
  return 1;
1484
1738
  }
1739
+ async function runSyncRawEvidenceGc(command, io) {
1740
+ return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
1741
+ }
1485
1742
  async function runStatus(command, io) {
1486
1743
  const backfillCursor = await inspectBackfillCursor(command.homeDir);
1487
1744
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
@@ -1,67 +1,101 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { discoverGitWorktrees } from "./repo-identity.js";
4
5
  import { normalizeCollectionRoots } from "./root-normalization.js";
5
6
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
7
+ export const HOME_ROOT_CONSENT_PROMPT = "Collect your ENTIRE home folder? Any git repo you ever create under it will be collected automatically. Type 'everything' to confirm: ";
6
8
  export async function resolveOnboardingRoots(options) {
7
- const explicitRoots = normalizeRoots(options.explicitRoots ?? []);
8
- if (explicitRoots.length > 0) {
9
- return { roots: explicitRoots, source: "explicit", confirmed: true };
9
+ const explicitInput = options.explicitRoots ?? [];
10
+ const explicit = normalizeRootsDetailed(explicitInput, {
11
+ homeDir: options.homeDir,
12
+ allowHomeRoot: options.allowHomeRoot,
13
+ });
14
+ if (explicit.roots.length > 0) {
15
+ explainRejectedRoots(options, explicit.rejected);
16
+ return resolvedRoots(options, explicit.roots, "explicit", true);
10
17
  }
11
- const savedRoots = normalizeRoots([
18
+ if (hasRootInput(explicitInput) && explicit.rejected.length > 0) {
19
+ if (!options.interactive) {
20
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(explicit.rejected[0], options)}`);
21
+ }
22
+ explainRejectedRoots(options, explicit.rejected);
23
+ const homeOffer = await counterOfferHomeDirectoryRepos(options, explicit.rejected);
24
+ if (homeOffer)
25
+ return homeOffer;
26
+ const homeOptIn = await promptForHomeRootOptIn(options, explicit.rejected);
27
+ if (homeOptIn)
28
+ return homeOptIn;
29
+ return promptForRoots(options, "Collection root(s), comma-separated: ");
30
+ }
31
+ const savedRoots = normalizeRootsDetailed([
12
32
  ...(options.savedRoots ?? []),
13
33
  ...(options.config?.default_repo_paths ?? []),
14
- ]);
34
+ ], {
35
+ homeDir: options.homeDir,
36
+ allowHomeRoot: options.allowHomeRoot,
37
+ }).roots;
15
38
  if (savedRoots.length > 0) {
16
39
  if (!options.interactive) {
17
- return { roots: savedRoots, source: "saved", confirmed: false };
40
+ return resolvedRoots(options, savedRoots, "saved", false);
18
41
  }
19
42
  const confirmed = await requirePrompt(options).confirm(savedRootsPrompt(savedRoots));
20
43
  if (confirmed) {
21
- return { roots: savedRoots, source: "saved", confirmed: true };
44
+ return resolvedRoots(options, savedRoots, "saved", true);
22
45
  }
23
46
  return promptForRoots(options, "Collection root(s), comma-separated: ");
24
47
  }
25
48
  if (!options.interactive) {
26
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: pass --workspace <path> or run cockpit onboard in a terminal to confirm a collection root.`);
49
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
27
50
  }
28
51
  const cwd = path.resolve(options.cwd ?? process.cwd());
29
52
  const cwdRoot = likelyBliRootFromCwd(cwd);
30
53
  if (cwdRoot) {
31
54
  const confirmed = await requirePrompt(options).confirm(`Collect from ${cwdRoot}? [Y/n] `);
32
55
  if (confirmed) {
33
- return { roots: [cwdRoot], source: "cwd_likely_root", confirmed: true };
56
+ return resolvedRoots(options, [cwdRoot], "cwd_likely_root", true);
34
57
  }
35
58
  }
36
59
  const homeRoot = path.join(options.homeDir ?? os.homedir(), "BLI");
37
60
  if (await directoryExists(homeRoot)) {
38
61
  const confirmed = await requirePrompt(options).confirm(`I found ${homeRoot}. Collect from there? [Y/n] `);
39
62
  if (confirmed) {
40
- return { roots: [path.resolve(homeRoot)], source: "home_likely_root", confirmed: true };
63
+ return resolvedRoots(options, [path.resolve(homeRoot)], "home_likely_root", true);
41
64
  }
42
65
  }
43
66
  return promptForRoots(options, "Collection root(s), comma-separated: ");
44
67
  }
45
68
  export function normalizeRoots(roots) {
69
+ return normalizeRootsDetailed(roots).roots;
70
+ }
71
+ export function normalizeRootsDetailed(roots, options = {}) {
46
72
  const seen = new Set();
47
73
  const candidates = [];
74
+ const rejected = [];
75
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
48
76
  for (const root of roots) {
49
77
  const trimmed = root.trim();
50
78
  if (!trimmed)
51
79
  continue;
52
80
  for (const candidate of splitRootInput(trimmed)) {
53
81
  const resolved = path.resolve(candidate);
54
- if (resolved === path.parse(resolved).root)
55
- continue;
56
- if (resolved === os.homedir())
82
+ if (resolved === path.parse(resolved).root) {
83
+ rejected.push({ input: candidate, reason: "fs_root" });
57
84
  continue;
85
+ }
86
+ if (resolved === homeDir) {
87
+ if (!options.allowHomeRoot) {
88
+ rejected.push({ input: candidate, reason: "home_dir" });
89
+ continue;
90
+ }
91
+ }
58
92
  if (seen.has(resolved))
59
93
  continue;
60
94
  seen.add(resolved);
61
95
  candidates.push(resolved);
62
96
  }
63
97
  }
64
- return normalizeCollectionRoots(candidates);
98
+ return { roots: normalizeCollectionRoots(candidates), rejected };
65
99
  }
66
100
  function splitRootInput(value) {
67
101
  return value
@@ -71,11 +105,29 @@ function splitRootInput(value) {
71
105
  }
72
106
  async function promptForRoots(options, message) {
73
107
  const prompt = requirePrompt(options);
74
- const roots = normalizeRoots([await prompt.input(message)]);
75
- if (roots.length === 0) {
76
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed; pass --workspace <path>.`);
108
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
109
+ const detailed = normalizeRootsDetailed([await prompt.input(message)], {
110
+ homeDir: options.homeDir,
111
+ allowHomeRoot: options.allowHomeRoot,
112
+ });
113
+ if (detailed.roots.length > 0) {
114
+ explainRejectedRoots(options, detailed.rejected);
115
+ return resolvedRoots(options, detailed.roots, "prompt", true);
116
+ }
117
+ if (detailed.rejected.length > 0) {
118
+ explainRejectedRoots(options, detailed.rejected);
119
+ const homeOffer = await counterOfferHomeDirectoryRepos(options, detailed.rejected);
120
+ if (homeOffer)
121
+ return homeOffer;
122
+ const homeOptIn = await promptForHomeRootOptIn(options, detailed.rejected);
123
+ if (homeOptIn)
124
+ return homeOptIn;
125
+ }
126
+ else {
127
+ prompt.message?.(missingCollectionRootMessage(options));
128
+ }
77
129
  }
78
- return { roots, source: "prompt", confirmed: true };
130
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
79
131
  }
80
132
  function savedRootsPrompt(roots) {
81
133
  if (roots.length === 1) {
@@ -93,6 +145,119 @@ function requirePrompt(options) {
93
145
  }
94
146
  return options.prompt;
95
147
  }
148
+ function hasRootInput(inputs) {
149
+ return inputs.some((input) => input.trim().length > 0);
150
+ }
151
+ function explainRejectedRoots(options, rejections) {
152
+ if (!options.interactive || rejections.length === 0)
153
+ return;
154
+ const prompt = requirePrompt(options);
155
+ for (const rejection of rejections) {
156
+ prompt.message?.(rootRejectionPromptHint(rejection, options));
157
+ }
158
+ }
159
+ export function rootRejectionExplanation(rejection, options = {}) {
160
+ switch (rejection.reason) {
161
+ case "home_dir":
162
+ return homeRootTutorial(options.homeDir);
163
+ case "fs_root":
164
+ return filesystemRootTutorial(options.homeDir);
165
+ }
166
+ }
167
+ function rootRejectionPromptHint(rejection, options = {}) {
168
+ switch (rejection.reason) {
169
+ case "home_dir":
170
+ return homeRootTutorial(options.homeDir);
171
+ case "fs_root":
172
+ return filesystemRootTutorial(options.homeDir);
173
+ }
174
+ }
175
+ async function counterOfferHomeDirectoryRepos(options, rejections) {
176
+ const homeRejection = rejections.find((rejection) => rejection.reason === "home_dir");
177
+ if (!homeRejection)
178
+ return null;
179
+ const prompt = requirePrompt(options);
180
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
181
+ const discover = options.discoverGitWorktrees ?? discoverGitWorktrees;
182
+ const worktrees = await discover(homeDir, {
183
+ maxDepth: 3,
184
+ maxWorktrees: 50,
185
+ }).catch(() => []);
186
+ const roots = normalizeCollectionRoots([...new Set(worktrees.map((worktree) => path.resolve(worktree.repo_root)))]);
187
+ if (roots.length === 0)
188
+ return null;
189
+ const confirmed = await prompt.confirm([
190
+ "Your home folder can't be a collection root by default:",
191
+ ` I found ${roots.length} git repos under ${homeDir} that are safer to collect.`,
192
+ "Discovered repos:",
193
+ ...roots.map((root) => `- ${root}`),
194
+ "What you can do:",
195
+ " 1) Use these repos: answer y",
196
+ " 2) Pick different folders: answer n, then paste specific paths",
197
+ " 3) Collect everything anyway: answer n, then type everything at the next prompt",
198
+ "Collect from these? [Y/n] ",
199
+ ].join("\n"));
200
+ return confirmed ? resolvedRoots(options, roots, "prompt", true) : null;
201
+ }
202
+ async function promptForHomeRootOptIn(options, rejections) {
203
+ if (!rejections.some((rejection) => rejection.reason === "home_dir"))
204
+ return null;
205
+ const answer = await requirePrompt(options).input(HOME_ROOT_CONSENT_PROMPT);
206
+ if (answer.trim().toLowerCase() !== "everything")
207
+ return null;
208
+ return {
209
+ roots: [path.resolve(options.homeDir ?? os.homedir())],
210
+ source: "prompt",
211
+ confirmed: true,
212
+ homeRootOptIn: true,
213
+ };
214
+ }
215
+ function resolvedRoots(options, roots, source, confirmed) {
216
+ const result = { roots, source, confirmed };
217
+ if (options.allowHomeRoot && roots.some((root) => isHomeRoot(root, options.homeDir))) {
218
+ result.homeRootOptIn = true;
219
+ }
220
+ return result;
221
+ }
222
+ function isHomeRoot(root, homeDir) {
223
+ return path.resolve(root) === path.resolve(homeDir ?? os.homedir());
224
+ }
225
+ function homeRootTutorial(homeDirInput) {
226
+ const homeDir = path.resolve(homeDirInput ?? os.homedir());
227
+ const bliRoot = path.join(homeDir, "BLI");
228
+ const otherRoot = path.join(homeDir, "other");
229
+ return [
230
+ "Your home folder can't be a collection root by default:",
231
+ ` scanning all of ${homeDir} would include personal folders and every repo you ever create.`,
232
+ "What you can do:",
233
+ ` 1) Specific folders: cockpit onboard --workspace "${bliRoot},${otherRoot}"`,
234
+ " 2) One work parent: mkdir ~/BLI && move your repos in, then: cockpit onboard --workspace ~/BLI",
235
+ " 3) Collect everything anyway (company machine): rerun with --allow-home-root",
236
+ ].join("\n");
237
+ }
238
+ function filesystemRootTutorial(homeDirInput) {
239
+ const homeDir = path.resolve(homeDirInput ?? os.homedir());
240
+ const bliRoot = path.join(homeDir, "BLI");
241
+ const otherRoot = path.join(homeDir, "other");
242
+ return [
243
+ "Your filesystem root can't be a collection root:",
244
+ ` scanning all of ${path.parse(homeDir).root} would include system folders, private data, and every mounted repo.`,
245
+ "What you can do:",
246
+ ` 1) Specific folders: cockpit onboard --workspace "${bliRoot},${otherRoot}"`,
247
+ " 2) One work parent: mkdir ~/BLI && move your repos in, then: cockpit onboard --workspace ~/BLI",
248
+ ].join("\n");
249
+ }
250
+ export function missingCollectionRootMessage(options = {}) {
251
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
252
+ return [
253
+ "No collection root was confirmed:",
254
+ " Cockpit only scans folders you name or confirm.",
255
+ "What you can do:",
256
+ ` 1) Paste one work parent: ${path.join(homeDir, "BLI")}`,
257
+ ` 2) Paste specific folders: ${path.join(homeDir, "repo-a")},${path.join(homeDir, "repo-b")}`,
258
+ " 3) Stop and rerun with a flag: cockpit onboard --workspace ~/BLI",
259
+ ].join("\n");
260
+ }
96
261
  function likelyBliRootFromCwd(cwd) {
97
262
  const parts = cwd.split(path.sep).filter(Boolean);
98
263
  const index = parts.lastIndexOf("BLI");
@@ -0,0 +1,122 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
5
+ const RAW_EVIDENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const SYNC_LOG_MAX_BYTES = 50 * 1024 * 1024;
7
+ // GC hashes every file in every old-but-kept dir to confirm uploads. Running
8
+ // that on each 15-min sync would re-read the same gigabytes ~96x/day on
9
+ // machines with unconfirmed evidence, so GC is throttled to once per day.
10
+ const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
11
+ export async function runRawEvidenceLocalGc(paths, env = process.env, now = new Date()) {
12
+ if (env["COCKPIT_DISABLE_GC"] === "1") {
13
+ return {
14
+ skipped: true,
15
+ removed_dirs: 0,
16
+ freed_bytes: 0,
17
+ rotated_sync_log: false,
18
+ };
19
+ }
20
+ const throttleMarker = path.join(paths.state_dir, ".last-raw-evidence-gc");
21
+ const lastRun = await fs.stat(throttleMarker).catch(() => null);
22
+ if (lastRun && now.getTime() - lastRun.mtimeMs < GC_MIN_INTERVAL_MS) {
23
+ return {
24
+ skipped: true,
25
+ removed_dirs: 0,
26
+ freed_bytes: 0,
27
+ rotated_sync_log: false,
28
+ };
29
+ }
30
+ await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
31
+ await fs.writeFile(throttleMarker, now.toISOString()).catch(() => undefined);
32
+ const cursor = await readRawEvidenceCursor(paths);
33
+ const uploadedHashes = new Set(Object.keys(cursor.objects));
34
+ const rawEvidenceRoot = path.join(paths.state_dir, "raw-evidence");
35
+ const entries = await fs.readdir(rawEvidenceRoot, { withFileTypes: true }).catch(() => []);
36
+ let removedDirs = 0;
37
+ let freedBytes = 0;
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory() || !entry.name.startsWith("work-"))
40
+ continue;
41
+ const dir = path.join(rawEvidenceRoot, entry.name);
42
+ const dirStats = await fs.stat(dir).catch(() => null);
43
+ if (!dirStats?.isDirectory())
44
+ continue;
45
+ if (now.getTime() - dirStats.mtimeMs < RAW_EVIDENCE_RETENTION_MS)
46
+ continue;
47
+ const inspection = await inspectRawEvidenceDirForGc(dir, uploadedHashes);
48
+ if (!inspection.deletable)
49
+ continue;
50
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
51
+ if (await exists(dir))
52
+ continue;
53
+ removedDirs += 1;
54
+ freedBytes += inspection.byteSize;
55
+ }
56
+ return {
57
+ skipped: false,
58
+ removed_dirs: removedDirs,
59
+ freed_bytes: freedBytes,
60
+ rotated_sync_log: await rotateSyncLog(paths),
61
+ };
62
+ }
63
+ export function rawEvidenceGcSummary(result) {
64
+ return `raw-evidence GC: removed ${result.removed_dirs} dirs, freed ~${formatMb(result.freed_bytes)} MB`;
65
+ }
66
+ async function inspectRawEvidenceDirForGc(dir, uploadedHashes) {
67
+ const files = await listFiles(dir).catch(() => null);
68
+ if (!files)
69
+ return { deletable: false, byteSize: 0 };
70
+ if (files.length === 0)
71
+ return { deletable: true, byteSize: 0 };
72
+ let byteSize = 0;
73
+ for (const file of files) {
74
+ const bytes = await fs.readFile(file).catch(() => null);
75
+ if (!bytes)
76
+ return { deletable: false, byteSize: 0 };
77
+ const digest = crypto.createHash("sha256").update(bytes).digest("hex");
78
+ if (!uploadedHashes.has(digest)) {
79
+ return { deletable: false, byteSize: 0 };
80
+ }
81
+ byteSize += bytes.byteLength;
82
+ }
83
+ return { deletable: true, byteSize };
84
+ }
85
+ async function listFiles(root) {
86
+ const files = [];
87
+ const stack = [root];
88
+ while (stack.length > 0) {
89
+ const current = stack.pop();
90
+ if (!current)
91
+ continue;
92
+ const entries = await fs.readdir(current, { withFileTypes: true });
93
+ for (const entry of entries) {
94
+ const full = path.join(current, entry.name);
95
+ if (entry.isDirectory()) {
96
+ stack.push(full);
97
+ }
98
+ else if (entry.isFile()) {
99
+ files.push(full);
100
+ }
101
+ else {
102
+ throw new Error("raw-evidence directory contains unsupported entry");
103
+ }
104
+ }
105
+ }
106
+ return files;
107
+ }
108
+ async function rotateSyncLog(paths) {
109
+ const syncLog = path.join(paths.state_dir, "sync.log");
110
+ const info = await fs.stat(syncLog).catch(() => null);
111
+ if (!info?.isFile() || info.size <= SYNC_LOG_MAX_BYTES)
112
+ return false;
113
+ await fs.truncate(syncLog, 0).catch(() => undefined);
114
+ const after = await fs.stat(syncLog).catch(() => null);
115
+ return after?.isFile() === true && after.size === 0;
116
+ }
117
+ async function exists(target) {
118
+ return fs.stat(target).then(() => true, () => false);
119
+ }
120
+ function formatMb(bytes) {
121
+ return (bytes / (1024 * 1024)).toFixed(2);
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {