@bli-cockpit/cli 0.2.2 → 0.2.4
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 +23 -5
- package/dist/commands/doctor.js +55 -11
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local.js +211 -26
- package/dist/onboarding-roots.js +95 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,16 +45,16 @@ The OTP proves you own an approved BLI mailbox. The JWT is used once to register
|
|
|
45
45
|
|
|
46
46
|
## Every command, what it does, and why it's called that
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
recovery and maintenance.
|
|
48
|
+
On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
|
|
50
49
|
|
|
51
50
|
| Command | What it does | Why it exists / why this name |
|
|
52
51
|
|---|---|---|
|
|
53
52
|
| `cockpit onboard` | The everything-command: signs you in (email code), registers this Mac, starts capture, uploads once, installs the 15-min background sync, and prints proof you're live. | You are boarding the crew. Run it once per machine; rerunning is always safe. |
|
|
54
|
-
| `cockpit do-everything` / `cockpit fix` | Converges
|
|
53
|
+
| `cockpit do-everything` / `cockpit fix` | Converges a Mac from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
|
|
55
54
|
| `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
|
|
56
55
|
| `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this Mac). | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
|
|
57
56
|
| `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
|
|
57
|
+
| `cockpit analyze` | Runs a fresh sync, then queues batch analysis of your latest uploaded sessions. It returns immediately; status and results appear in My Work. | One command when you want newly finished work uploaded and judged now. |
|
|
58
58
|
| `cockpit start --ticket <id>` | Tags your CURRENT work with a Linear ticket so sessions attribute to it. `--clear-ticket` returns to general capture. | "Start (working on) X." Agents usually run this for you per the agent rules. |
|
|
59
59
|
| `cockpit login` / `cockpit pair` | Just the sign-in + device-registration step, standalone (onboard already includes it). Two names, one command: `login` is what humans guess, `pair` is what the dashboard's device screen calls it. | Recovery path when a session expires or you switch accounts. |
|
|
60
60
|
| `cockpit logout` | Deletes this machine's session. | The undo of login. |
|
|
@@ -83,16 +83,34 @@ cockpit onboard --no-auth
|
|
|
83
83
|
|
|
84
84
|
- `--email` skips the email prompt.
|
|
85
85
|
- `--workspace` pins one collection root; repeat it for multiple unrelated roots.
|
|
86
|
-
-
|
|
86
|
+
- Interactive onboarding from your home folder asks whether to sync all projects on the machine. Answer `y` only on a company machine where collecting every current and future repo under `$HOME` is intended. `n` or Enter asks for the actual work folder; if no valid non-home folder is chosen, Cockpit captures nothing and tells you to rerun with `--workspace <path-to-your-work-folder>`.
|
|
87
|
+
- `--allow-home-root` is the headless/scripted form of that full-home opt-in.
|
|
87
88
|
- `--device-name` changes only the human label shown in Cockpit.
|
|
88
89
|
- `--dashboard-url` is for staging/custom dashboards only. Production is the default.
|
|
89
90
|
- `--no-auth` forces the old manual approval queue.
|
|
90
91
|
- `--repo` still works as a legacy alias for `--workspace`.
|
|
91
92
|
|
|
92
|
-
`cockpit do-everything` is the normal fleet convergence command
|
|
93
|
+
`cockpit do-everything` is the normal fleet convergence command. On an interactive first run it uses the same email OTP login and root-picker flow as onboarding, then re-checks the machine. In headless, launchd, `--json`, or no-TTY runs it never prompts; missing auth or roots stay red with the repair text. `--dry-run` previews without writing auth or config.
|
|
93
94
|
|
|
94
95
|
`cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
|
|
95
96
|
|
|
97
|
+
## Analyze latest work
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cockpit analyze --workspace "$PWD"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`cockpit analyze` first performs the same durable upload as `cockpit sync`. It
|
|
104
|
+
queues the analysis only after that upload succeeds, then exits without waiting
|
|
105
|
+
for the Fireworks batch. Use `--json` for a single machine-readable object that
|
|
106
|
+
contains both the sync receipt and queued job status. The dashboard button
|
|
107
|
+
analyzes the latest upload already stored by the background collector; run the
|
|
108
|
+
CLI command when the work ended too recently for the 15-minute background sync.
|
|
109
|
+
Each person can queue analysis once every three hours, subject to the team's
|
|
110
|
+
shared `$2` UTC-day judge cap. Completion can take several minutes plus the wait
|
|
111
|
+
for the next 15-minute collector poll; My Work shows queued, running, done, or
|
|
112
|
+
failed status while you wait.
|
|
113
|
+
|
|
96
114
|
## Parent Mode
|
|
97
115
|
|
|
98
116
|
Use a parent folder such as `~/BLI` when it contains multiple repos. Cockpit scans child git repos/worktrees, creates one stable work context per worktree, and rolls them up under repo rows in the dashboard.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -22,20 +22,28 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
22
22
|
rows.push(checked);
|
|
23
23
|
continue;
|
|
24
24
|
}
|
|
25
|
-
if (
|
|
26
|
-
rows.push(checked);
|
|
27
|
-
|
|
25
|
+
if (command.dryRun && invariant.fix) {
|
|
26
|
+
rows.push(dryRunPreview(checked));
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const canFix = Boolean(invariant.fix) &&
|
|
30
|
+
(!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
|
|
31
|
+
if (!canFix) {
|
|
32
|
+
rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
|
|
33
|
+
if (checked.hardStop)
|
|
34
|
+
break;
|
|
35
|
+
continue;
|
|
28
36
|
}
|
|
29
|
-
if (
|
|
30
|
-
rows.push({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
message: `would fix: ${checked.message}`,
|
|
34
|
-
});
|
|
37
|
+
if (!invariant.fix) {
|
|
38
|
+
rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
|
|
39
|
+
if (checked.hardStop)
|
|
40
|
+
break;
|
|
35
41
|
continue;
|
|
36
42
|
}
|
|
37
43
|
const fixed = await invariant.fix(context, checked);
|
|
38
44
|
rows.push({ ...fixed, fixed: fixed.status !== "fail" });
|
|
45
|
+
if (fixed.hardStop)
|
|
46
|
+
break;
|
|
39
47
|
if (fixed.reexecExitCode !== undefined) {
|
|
40
48
|
await maybeReportDoctorEvents(context, rows);
|
|
41
49
|
writeDoctorOutput(command, io, rows);
|
|
@@ -49,8 +57,18 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
49
57
|
function doctorInvariants() {
|
|
50
58
|
return [
|
|
51
59
|
{ id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
|
|
52
|
-
{
|
|
53
|
-
|
|
60
|
+
{
|
|
61
|
+
id: "authed",
|
|
62
|
+
check: (context) => context.deps.readAuth(context),
|
|
63
|
+
fix: fixAuthState,
|
|
64
|
+
requiresInteractiveFix: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "roots-ok",
|
|
68
|
+
check: (context) => context.deps.readRoots(context),
|
|
69
|
+
fix: fixRootState,
|
|
70
|
+
requiresInteractiveFix: true,
|
|
71
|
+
},
|
|
54
72
|
{
|
|
55
73
|
id: "autostart-alive",
|
|
56
74
|
check: (context) => context.deps.checkAutostart(context),
|
|
@@ -80,7 +98,9 @@ function defaultDoctorDeps(hooks) {
|
|
|
80
98
|
reexecDoctor: reexecDoctor,
|
|
81
99
|
reportInstallEvents: hooks.reportInstallEvents,
|
|
82
100
|
readAuth: readAuthState,
|
|
101
|
+
runLogin: (context) => hooks.runLogin(context.command, context.io),
|
|
83
102
|
readRoots: readRootState,
|
|
103
|
+
resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
|
|
84
104
|
checkAutostart: checkAutostartState,
|
|
85
105
|
fixAutostart: fixAutostartState,
|
|
86
106
|
checkBackfill: checkBackfillState,
|
|
@@ -120,6 +140,18 @@ async function fixCliLatest(context, state) {
|
|
|
120
140
|
reexecExitCode: code,
|
|
121
141
|
};
|
|
122
142
|
}
|
|
143
|
+
async function fixAuthState(context, state) {
|
|
144
|
+
const code = await context.deps.runLogin(context).catch(() => 1);
|
|
145
|
+
if (code !== 0)
|
|
146
|
+
return state;
|
|
147
|
+
const checked = await context.deps.readAuth(context);
|
|
148
|
+
return checked.status === "ok" ? checked : state;
|
|
149
|
+
}
|
|
150
|
+
async function fixRootState(context, state) {
|
|
151
|
+
await context.deps.resolveAndSaveRoots(context).catch(() => undefined);
|
|
152
|
+
const checked = await context.deps.readRoots(context);
|
|
153
|
+
return checked.status === "ok" ? checked : state;
|
|
154
|
+
}
|
|
123
155
|
async function latestCliVersionFromNpm(context) {
|
|
124
156
|
const exec = context.io.exec;
|
|
125
157
|
if (!exec)
|
|
@@ -350,6 +382,18 @@ function fail(id, code, message) {
|
|
|
350
382
|
function hardStop(id, code, message) {
|
|
351
383
|
return { id, status: "fail", code, message, hardStop: true };
|
|
352
384
|
}
|
|
385
|
+
function dryRunPreview(state) {
|
|
386
|
+
const preview = { ...state };
|
|
387
|
+
delete preview.hardStop;
|
|
388
|
+
return {
|
|
389
|
+
...preview,
|
|
390
|
+
status: "needs_fix",
|
|
391
|
+
message: `would fix: ${oneLine(state.message)}`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function isInteractiveDoctorFix(context) {
|
|
395
|
+
return !context.command.json && Boolean(context.io.stdin.isTTY);
|
|
396
|
+
}
|
|
353
397
|
async function savedRoots() {
|
|
354
398
|
const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
|
|
355
399
|
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
@@ -29,6 +29,8 @@ export function parseLocalArgs(argv) {
|
|
|
29
29
|
return parseStartArgs(argv.slice(1));
|
|
30
30
|
case "sync":
|
|
31
31
|
return parseSyncArgs(argv.slice(1));
|
|
32
|
+
case "analyze":
|
|
33
|
+
return parseAnalyzeArgs(argv.slice(1));
|
|
32
34
|
case "backfill":
|
|
33
35
|
return parseBackfillArgs(argv.slice(1));
|
|
34
36
|
case "status":
|
|
@@ -302,6 +304,12 @@ function parseStartArgs(args) {
|
|
|
302
304
|
};
|
|
303
305
|
}
|
|
304
306
|
function parseSyncArgs(args) {
|
|
307
|
+
return parseSyncLikeArgs(args, "sync");
|
|
308
|
+
}
|
|
309
|
+
function parseAnalyzeArgs(args) {
|
|
310
|
+
return parseSyncLikeArgs(args, "analyze");
|
|
311
|
+
}
|
|
312
|
+
function parseSyncLikeArgs(args, command) {
|
|
305
313
|
const values = parseNamedArgs(args, {
|
|
306
314
|
allowedFlags: [
|
|
307
315
|
"--home",
|
|
@@ -321,9 +329,9 @@ function parseSyncArgs(args) {
|
|
|
321
329
|
"--max-repos",
|
|
322
330
|
],
|
|
323
331
|
});
|
|
324
|
-
assertNoPositionals(values.positionals,
|
|
332
|
+
assertNoPositionals(values.positionals, command);
|
|
325
333
|
return {
|
|
326
|
-
kind:
|
|
334
|
+
kind: command,
|
|
327
335
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
328
336
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
329
337
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
package/dist/commands/local.js
CHANGED
|
@@ -30,6 +30,7 @@ export const rootCommandNames = new Set([
|
|
|
30
30
|
"logout",
|
|
31
31
|
"start",
|
|
32
32
|
"sync",
|
|
33
|
+
"analyze",
|
|
33
34
|
"backfill",
|
|
34
35
|
"status",
|
|
35
36
|
"sessions",
|
|
@@ -65,6 +66,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
65
66
|
return await runDoctor(command, io, {
|
|
66
67
|
reportInstallEvents: reportInstallEventsBestEffort,
|
|
67
68
|
selfUpdate: runSelfUpdate,
|
|
69
|
+
runLogin: runDoctorLogin,
|
|
70
|
+
resolveAndSaveRoots: resolveAndSaveDoctorRoots,
|
|
68
71
|
});
|
|
69
72
|
case "login":
|
|
70
73
|
return await runLogin(command, io);
|
|
@@ -74,6 +77,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
74
77
|
return await runStart(command, io);
|
|
75
78
|
case "sync":
|
|
76
79
|
return await runSync(command, io);
|
|
80
|
+
case "analyze":
|
|
81
|
+
return await runAnalyze(command, io);
|
|
77
82
|
case "backfill":
|
|
78
83
|
return await runBackfillCommand(command, io);
|
|
79
84
|
case "status":
|
|
@@ -110,6 +115,7 @@ export function localCommandHelp(command) {
|
|
|
110
115
|
" cockpit logout",
|
|
111
116
|
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
112
117
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
118
|
+
" cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
113
119
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
|
|
114
120
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
115
121
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -234,6 +240,17 @@ function localSubcommandHelp(command) {
|
|
|
234
240
|
"--max-depth and --max-repos.",
|
|
235
241
|
],
|
|
236
242
|
],
|
|
243
|
+
[
|
|
244
|
+
"analyze",
|
|
245
|
+
[
|
|
246
|
+
"Usage: cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--json]",
|
|
247
|
+
"",
|
|
248
|
+
"Uploads the latest local ambient evidence, then queues one analysis job.",
|
|
249
|
+
"The command returns after the batch is queued; view status and results in My Work.",
|
|
250
|
+
"Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
|
|
251
|
+
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
252
|
+
],
|
|
253
|
+
],
|
|
237
254
|
[
|
|
238
255
|
"backfill",
|
|
239
256
|
[
|
|
@@ -1053,6 +1070,64 @@ function backgroundSyncLine(result) {
|
|
|
1053
1070
|
}
|
|
1054
1071
|
return result.status;
|
|
1055
1072
|
}
|
|
1073
|
+
async function resolveOnboardingRootsForCommand(command, io) {
|
|
1074
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1075
|
+
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
1076
|
+
const interactive = !command.json && isInteractiveStdin(io);
|
|
1077
|
+
const rootsResult = await resolveOnboardingRoots({
|
|
1078
|
+
homeDir: command.homeDir,
|
|
1079
|
+
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
1080
|
+
config: existingConfig,
|
|
1081
|
+
interactive,
|
|
1082
|
+
allowHomeRoot: command.allowHomeRoot,
|
|
1083
|
+
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
1084
|
+
});
|
|
1085
|
+
const collectionRoots = rootsResult.roots;
|
|
1086
|
+
const primaryRoot = collectionRoots[0];
|
|
1087
|
+
if (!primaryRoot) {
|
|
1088
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
1089
|
+
}
|
|
1090
|
+
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
1091
|
+
!command.collectionRoots?.length &&
|
|
1092
|
+
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
1093
|
+
return {
|
|
1094
|
+
existingConfig,
|
|
1095
|
+
rootsResult,
|
|
1096
|
+
collectionRoots,
|
|
1097
|
+
primaryRoot,
|
|
1098
|
+
replaceRepoRoots,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
async function persistOnboardingRootConfig(command, resolution) {
|
|
1102
|
+
return installLocalCollector({
|
|
1103
|
+
homeDir: command.homeDir,
|
|
1104
|
+
repoRoot: resolution.primaryRoot,
|
|
1105
|
+
repoRoots: resolution.collectionRoots,
|
|
1106
|
+
replaceRepoRoots: resolution.replaceRepoRoots,
|
|
1107
|
+
dashboardUrl: command.dashboardUrl,
|
|
1108
|
+
deviceName: command.deviceName,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
async function runDoctorLogin(command, io) {
|
|
1112
|
+
return runLogin({
|
|
1113
|
+
kind: "login",
|
|
1114
|
+
dashboardUrl: command.dashboardUrl,
|
|
1115
|
+
json: command.json,
|
|
1116
|
+
noAuth: false,
|
|
1117
|
+
}, io);
|
|
1118
|
+
}
|
|
1119
|
+
async function resolveAndSaveDoctorRoots(command, io) {
|
|
1120
|
+
const rootCommand = {
|
|
1121
|
+
repoRoot: command.repoRoot,
|
|
1122
|
+
dashboardUrl: command.dashboardUrl,
|
|
1123
|
+
json: command.json,
|
|
1124
|
+
};
|
|
1125
|
+
const resolution = await resolveOnboardingRootsForCommand(rootCommand, io);
|
|
1126
|
+
await persistOnboardingRootConfig(rootCommand, resolution);
|
|
1127
|
+
if (!command.json) {
|
|
1128
|
+
writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1056
1131
|
async function runOnboard(command, io) {
|
|
1057
1132
|
const installEvents = [];
|
|
1058
1133
|
const finish = async (code) => {
|
|
@@ -1080,31 +1155,17 @@ async function runOnboard(command, io) {
|
|
|
1080
1155
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
1081
1156
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
1082
1157
|
}
|
|
1083
|
-
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1084
1158
|
backfillHint = await onboardBackfillHint(command.homeDir);
|
|
1085
|
-
const
|
|
1086
|
-
const
|
|
1087
|
-
rootsResult =
|
|
1088
|
-
homeDir: command.homeDir,
|
|
1089
|
-
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
1090
|
-
config: existingConfig,
|
|
1091
|
-
interactive,
|
|
1092
|
-
allowHomeRoot: command.allowHomeRoot,
|
|
1093
|
-
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
1094
|
-
});
|
|
1159
|
+
const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
|
|
1160
|
+
const existingConfig = resolvedRoots.existingConfig;
|
|
1161
|
+
rootsResult = resolvedRoots.rootsResult;
|
|
1095
1162
|
const collectionRoots = rootsResult.roots;
|
|
1096
|
-
const primaryRoot =
|
|
1097
|
-
if (!primaryRoot) {
|
|
1098
|
-
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
1099
|
-
}
|
|
1163
|
+
const primaryRoot = resolvedRoots.primaryRoot;
|
|
1100
1164
|
const resolvedCommand = {
|
|
1101
1165
|
...command,
|
|
1102
1166
|
repoRoot: primaryRoot,
|
|
1103
1167
|
collectionRoots,
|
|
1104
1168
|
};
|
|
1105
|
-
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
1106
|
-
!command.collectionRoots?.length &&
|
|
1107
|
-
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
1108
1169
|
if (!command.json) {
|
|
1109
1170
|
writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
|
|
1110
1171
|
}
|
|
@@ -1112,14 +1173,7 @@ async function runOnboard(command, io) {
|
|
|
1112
1173
|
addInstallEvent(installEvents, "home_root_optin", "ok");
|
|
1113
1174
|
}
|
|
1114
1175
|
const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
|
|
1115
|
-
install = await
|
|
1116
|
-
homeDir: command.homeDir,
|
|
1117
|
-
repoRoot: primaryRoot,
|
|
1118
|
-
repoRoots: collectionRoots,
|
|
1119
|
-
replaceRepoRoots,
|
|
1120
|
-
dashboardUrl: command.dashboardUrl,
|
|
1121
|
-
deviceName: command.deviceName,
|
|
1122
|
-
});
|
|
1176
|
+
install = await persistOnboardingRootConfig(command, resolvedRoots);
|
|
1123
1177
|
addInstallEvent(installEvents, "install", "ok");
|
|
1124
1178
|
if (!command.json) {
|
|
1125
1179
|
writeLine(io.stdout, "1/5 Installed local collector.");
|
|
@@ -1786,6 +1840,137 @@ async function runSyncLocked(command, io) {
|
|
|
1786
1840
|
writeLine(io.stderr, `Retry: ${result.retry_command}`);
|
|
1787
1841
|
return 1;
|
|
1788
1842
|
}
|
|
1843
|
+
async function runAnalyze(command, io) {
|
|
1844
|
+
const syncStdout = [];
|
|
1845
|
+
const syncStderr = [];
|
|
1846
|
+
const syncIo = {
|
|
1847
|
+
...io,
|
|
1848
|
+
stdout: bufferedWritable(syncStdout),
|
|
1849
|
+
stderr: bufferedWritable(syncStderr),
|
|
1850
|
+
};
|
|
1851
|
+
const syncCommand = {
|
|
1852
|
+
...command,
|
|
1853
|
+
kind: "sync",
|
|
1854
|
+
json: true,
|
|
1855
|
+
};
|
|
1856
|
+
const syncExitCode = await runSync(syncCommand, syncIo);
|
|
1857
|
+
const syncOutput = parseCapturedJson(syncStdout);
|
|
1858
|
+
if (syncExitCode !== 0 || !isUploadedSyncResult(syncOutput)) {
|
|
1859
|
+
if (command.json) {
|
|
1860
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1861
|
+
status: syncExitCode === 0 ? "sync_not_completed" : "sync_failed",
|
|
1862
|
+
sync: syncOutput,
|
|
1863
|
+
error: syncStderr.join("").trim()
|
|
1864
|
+
|| (syncExitCode === 0
|
|
1865
|
+
? "Cockpit sync did not upload fresh evidence."
|
|
1866
|
+
: "Cockpit sync failed."),
|
|
1867
|
+
}, null, 2));
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
replayCaptured(io.stderr, syncStderr);
|
|
1871
|
+
writeLine(io.stderr, syncExitCode === 0
|
|
1872
|
+
? "Analysis was not queued because a fresh upload did not complete."
|
|
1873
|
+
: "Analysis was not queued because the latest upload failed.");
|
|
1874
|
+
}
|
|
1875
|
+
return syncExitCode === 0 ? 1 : syncExitCode;
|
|
1876
|
+
}
|
|
1877
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1878
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => {
|
|
1879
|
+
throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
|
|
1880
|
+
});
|
|
1881
|
+
const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
|
|
1882
|
+
const response = await io.fetch(`${dashboardUrl}/api/ambient/analyze`, {
|
|
1883
|
+
method: "POST",
|
|
1884
|
+
headers: {
|
|
1885
|
+
authorization: `Bearer ${session.device_token}`,
|
|
1886
|
+
"content-type": "application/json",
|
|
1887
|
+
},
|
|
1888
|
+
body: "{}",
|
|
1889
|
+
});
|
|
1890
|
+
const body = await readAnalyzeApiResponse(response);
|
|
1891
|
+
if (!response.ok) {
|
|
1892
|
+
const message = typeof body.message === "string"
|
|
1893
|
+
? body.message
|
|
1894
|
+
: `Analysis request failed with HTTP ${response.status}.`;
|
|
1895
|
+
if (command.json) {
|
|
1896
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1897
|
+
status: "analyze_failed",
|
|
1898
|
+
sync: syncOutput,
|
|
1899
|
+
http_status: response.status,
|
|
1900
|
+
code: typeof body.code === "string" ? body.code : null,
|
|
1901
|
+
message,
|
|
1902
|
+
retry_after_seconds: typeof body.retry_after_seconds === "number"
|
|
1903
|
+
? body.retry_after_seconds
|
|
1904
|
+
: null,
|
|
1905
|
+
}, null, 2));
|
|
1906
|
+
}
|
|
1907
|
+
else {
|
|
1908
|
+
replayCaptured(io.stderr, syncStderr);
|
|
1909
|
+
writeLine(io.stderr, `Analysis was not queued: ${message}`);
|
|
1910
|
+
}
|
|
1911
|
+
return 1;
|
|
1912
|
+
}
|
|
1913
|
+
const jobId = typeof body.job?.id === "string" ? body.job.id : null;
|
|
1914
|
+
const jobStatus = typeof body.job?.status === "string" ? body.job.status : "pending";
|
|
1915
|
+
if (command.json) {
|
|
1916
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1917
|
+
status: "queued",
|
|
1918
|
+
sync: syncOutput,
|
|
1919
|
+
job: body.job ?? null,
|
|
1920
|
+
dashboard_url: `${dashboardUrl}/my-work`,
|
|
1921
|
+
}, null, 2));
|
|
1922
|
+
return 0;
|
|
1923
|
+
}
|
|
1924
|
+
replayCaptured(io.stderr, syncStderr);
|
|
1925
|
+
writeLine(io.stdout, "Cockpit latest evidence uploaded.");
|
|
1926
|
+
writeLine(io.stdout, "Cockpit analysis queued.");
|
|
1927
|
+
if (jobId)
|
|
1928
|
+
writeLine(io.stdout, `Job: ${jobId}`);
|
|
1929
|
+
writeLine(io.stdout, `Status: ${jobStatus === "pending" ? "queued" : jobStatus}`);
|
|
1930
|
+
writeLine(io.stdout, `Results: ${dashboardUrl}/my-work`);
|
|
1931
|
+
return 0;
|
|
1932
|
+
}
|
|
1933
|
+
async function readAnalyzeApiResponse(response) {
|
|
1934
|
+
const text = await response.text();
|
|
1935
|
+
if (!text)
|
|
1936
|
+
return {};
|
|
1937
|
+
try {
|
|
1938
|
+
const value = JSON.parse(text);
|
|
1939
|
+
return value && typeof value === "object" ? value : {};
|
|
1940
|
+
}
|
|
1941
|
+
catch {
|
|
1942
|
+
return {};
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
function parseCapturedJson(chunks) {
|
|
1946
|
+
const text = chunks.join("").trim();
|
|
1947
|
+
if (!text)
|
|
1948
|
+
return null;
|
|
1949
|
+
try {
|
|
1950
|
+
return JSON.parse(text);
|
|
1951
|
+
}
|
|
1952
|
+
catch {
|
|
1953
|
+
return text;
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
function isUploadedSyncResult(value) {
|
|
1957
|
+
return Boolean(value
|
|
1958
|
+
&& typeof value === "object"
|
|
1959
|
+
&& !Array.isArray(value)
|
|
1960
|
+
&& value["status"] === "uploaded");
|
|
1961
|
+
}
|
|
1962
|
+
function bufferedWritable(chunks) {
|
|
1963
|
+
return {
|
|
1964
|
+
write(chunk) {
|
|
1965
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
1966
|
+
return true;
|
|
1967
|
+
},
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
function replayCaptured(stream, chunks) {
|
|
1971
|
+
for (const chunk of chunks)
|
|
1972
|
+
stream.write(chunk);
|
|
1973
|
+
}
|
|
1789
1974
|
async function runSyncRawEvidenceGc(command, io) {
|
|
1790
1975
|
return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
|
|
1791
1976
|
}
|
package/dist/onboarding-roots.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { discoverGitWorktrees } from "./repo-identity.js";
|
|
5
5
|
import { normalizeCollectionRoots } from "./root-normalization.js";
|
|
6
6
|
export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
|
|
7
|
-
export
|
|
7
|
+
export function homeRootConsentPrompt(homeDirInput) {
|
|
8
|
+
const homeDir = path.resolve(homeDirInput ?? os.homedir());
|
|
9
|
+
return `You're in your home folder (${homeDir}).\n Sync ALL projects on this machine? Every git repo under here gets captured now and in the future.\n This is a work machine — exclude personal projects yourself if needed. [y/N]: `;
|
|
10
|
+
}
|
|
11
|
+
export const HOME_ROOT_DECLINE_PATH_PROMPT = "Okay — which folder should I sync? Enter the full path to your work directory: ";
|
|
12
|
+
export const HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE = "No folder chosen. Re-run: cockpit onboard --workspace <path-to-your-work-folder>";
|
|
8
13
|
export async function resolveOnboardingRoots(options) {
|
|
9
14
|
const explicitInput = options.explicitRoots ?? [];
|
|
10
15
|
const explicit = normalizeRootsDetailed(explicitInput, {
|
|
@@ -19,10 +24,7 @@ export async function resolveOnboardingRoots(options) {
|
|
|
19
24
|
if (!options.interactive) {
|
|
20
25
|
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(explicit.rejected[0], options)}`);
|
|
21
26
|
}
|
|
22
|
-
explainRejectedRoots(options, explicit.rejected);
|
|
23
|
-
const homeOffer = await counterOfferHomeDirectoryRepos(options, explicit.rejected);
|
|
24
|
-
if (homeOffer)
|
|
25
|
-
return homeOffer;
|
|
27
|
+
explainRejectedRoots(options, withoutHomeRejections(explicit.rejected));
|
|
26
28
|
const homeOptIn = await promptForHomeRootOptIn(options, explicit.rejected);
|
|
27
29
|
if (homeOptIn)
|
|
28
30
|
return homeOptIn;
|
|
@@ -45,10 +47,24 @@ export async function resolveOnboardingRoots(options) {
|
|
|
45
47
|
}
|
|
46
48
|
return promptForRoots(options, "Collection root(s), comma-separated: ");
|
|
47
49
|
}
|
|
50
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
51
|
+
if (isHomeRoot(cwd, options.homeDir)) {
|
|
52
|
+
const homeDir = path.resolve(options.homeDir ?? os.homedir());
|
|
53
|
+
if (options.allowHomeRoot) {
|
|
54
|
+
return resolvedRoots(options, [homeDir], "cwd_likely_root", true);
|
|
55
|
+
}
|
|
56
|
+
if (!options.interactive) {
|
|
57
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${homeRootTutorial(homeDir)}`);
|
|
58
|
+
}
|
|
59
|
+
const homeOptIn = await promptForHomeRootOptIn(options, [
|
|
60
|
+
{ input: homeDir, reason: "home_dir" },
|
|
61
|
+
]);
|
|
62
|
+
if (homeOptIn)
|
|
63
|
+
return homeOptIn;
|
|
64
|
+
}
|
|
48
65
|
if (!options.interactive) {
|
|
49
66
|
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(options)}`);
|
|
50
67
|
}
|
|
51
|
-
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
52
68
|
const cwdRoot = likelyBliRootFromCwd(cwd);
|
|
53
69
|
if (cwdRoot) {
|
|
54
70
|
const confirmed = await requirePrompt(options).confirm(`Collect from ${cwdRoot}? [Y/n] `);
|
|
@@ -115,10 +131,7 @@ async function promptForRoots(options, message) {
|
|
|
115
131
|
return resolvedRoots(options, detailed.roots, "prompt", true);
|
|
116
132
|
}
|
|
117
133
|
if (detailed.rejected.length > 0) {
|
|
118
|
-
explainRejectedRoots(options, detailed.rejected);
|
|
119
|
-
const homeOffer = await counterOfferHomeDirectoryRepos(options, detailed.rejected);
|
|
120
|
-
if (homeOffer)
|
|
121
|
-
return homeOffer;
|
|
134
|
+
explainRejectedRoots(options, withoutHomeRejections(detailed.rejected));
|
|
122
135
|
const homeOptIn = await promptForHomeRootOptIn(options, detailed.rejected);
|
|
123
136
|
if (homeOptIn)
|
|
124
137
|
return homeOptIn;
|
|
@@ -164,6 +177,9 @@ export function rootRejectionExplanation(rejection, options = {}) {
|
|
|
164
177
|
return filesystemRootTutorial(options.homeDir);
|
|
165
178
|
}
|
|
166
179
|
}
|
|
180
|
+
function withoutHomeRejections(rejections) {
|
|
181
|
+
return rejections.filter((rejection) => rejection.reason !== "home_dir");
|
|
182
|
+
}
|
|
167
183
|
function rootRejectionPromptHint(rejection, options = {}) {
|
|
168
184
|
switch (rejection.reason) {
|
|
169
185
|
case "home_dir":
|
|
@@ -172,45 +188,63 @@ function rootRejectionPromptHint(rejection, options = {}) {
|
|
|
172
188
|
return filesystemRootTutorial(options.homeDir);
|
|
173
189
|
}
|
|
174
190
|
}
|
|
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
191
|
async function promptForHomeRootOptIn(options, rejections) {
|
|
203
192
|
if (!rejections.some((rejection) => rejection.reason === "home_dir"))
|
|
204
193
|
return null;
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
194
|
+
const homeDir = path.resolve(options.homeDir ?? os.homedir());
|
|
195
|
+
const answer = await requirePrompt(options).input(homeRootConsentPrompt(homeDir));
|
|
196
|
+
if (isHomeRootYes(answer)) {
|
|
197
|
+
return {
|
|
198
|
+
roots: [homeDir],
|
|
199
|
+
source: "prompt",
|
|
200
|
+
confirmed: true,
|
|
201
|
+
homeRootOptIn: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return promptForDeclinedHomeRoot(options, homeDir);
|
|
205
|
+
}
|
|
206
|
+
function isHomeRootYes(raw) {
|
|
207
|
+
const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
|
|
208
|
+
return answer === "y" || answer === "yes";
|
|
209
|
+
}
|
|
210
|
+
async function promptForDeclinedHomeRoot(options, homeDir) {
|
|
211
|
+
const prompt = requirePrompt(options);
|
|
212
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
213
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
214
|
+
const answer = await prompt.input(HOME_ROOT_DECLINE_PATH_PROMPT);
|
|
215
|
+
const requestedRoot = answer.trim();
|
|
216
|
+
if (!requestedRoot) {
|
|
217
|
+
if (attempt < 2)
|
|
218
|
+
continue;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const resolvedRoot = resolveRootInputPath(requestedRoot, homeDir, cwd);
|
|
222
|
+
if (isHomeRoot(resolvedRoot, homeDir))
|
|
223
|
+
break;
|
|
224
|
+
if (!(await directoryExists(resolvedRoot))) {
|
|
225
|
+
prompt.message?.(`That folder doesn't exist: ${resolvedRoot}`);
|
|
226
|
+
if (attempt < 2)
|
|
227
|
+
continue;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
const detailed = normalizeRootsDetailed([resolvedRoot], {
|
|
231
|
+
homeDir,
|
|
232
|
+
allowHomeRoot: options.allowHomeRoot,
|
|
233
|
+
});
|
|
234
|
+
if (detailed.roots.length > 0) {
|
|
235
|
+
return resolvedRoots(options, detailed.roots, "prompt", true);
|
|
236
|
+
}
|
|
237
|
+
explainRejectedRoots(options, withoutHomeRejections(detailed.rejected));
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
prompt.message?.(HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE);
|
|
241
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE}`);
|
|
242
|
+
}
|
|
243
|
+
function resolveRootInputPath(input, homeDir, cwd) {
|
|
244
|
+
const expanded = input === "~" || input.startsWith("~/")
|
|
245
|
+
? path.join(homeDir, input.slice(2))
|
|
246
|
+
: input;
|
|
247
|
+
return path.resolve(cwd, expanded);
|
|
214
248
|
}
|
|
215
249
|
function resolvedRoots(options, roots, source, confirmed) {
|
|
216
250
|
const result = { roots, source, confirmed };
|
|
@@ -219,8 +253,21 @@ function resolvedRoots(options, roots, source, confirmed) {
|
|
|
219
253
|
}
|
|
220
254
|
return result;
|
|
221
255
|
}
|
|
256
|
+
// Canonicalize through realpath so a symlink pointing at $HOME can't slip past
|
|
257
|
+
// the home-root guard on the decline path (e.g. `~/home-link -> /Users/me`).
|
|
258
|
+
// Non-existent paths (or realpath errors) fall back to the lexical resolve so
|
|
259
|
+
// behavior is unchanged for the retry branch and for tests using fake homedirs.
|
|
260
|
+
function canonicalPath(input) {
|
|
261
|
+
const resolved = path.resolve(input);
|
|
262
|
+
try {
|
|
263
|
+
return realpathSync(resolved);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return resolved;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
222
269
|
function isHomeRoot(root, homeDir) {
|
|
223
|
-
return
|
|
270
|
+
return canonicalPath(root) === canonicalPath(homeDir ?? os.homedir());
|
|
224
271
|
}
|
|
225
272
|
function homeRootTutorial(homeDirInput) {
|
|
226
273
|
const homeDir = path.resolve(homeDirInput ?? os.homedir());
|