@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 +8 -1
- package/dist/commands/local-args.js +4 -0
- package/dist/commands/local.js +287 -30
- package/dist/onboarding-roots.js +183 -18
- package/dist/raw-evidence-gc.js +122 -0
- package/package.json +1 -1
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
|
-
|
|
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) {
|
package/dist/commands/local.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,
|
|
594
|
-
if (!/^\d{6}$/.test(code)) {
|
|
595
|
-
throw new Error(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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}:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
+
}
|