@bli-cockpit/cli 0.2.1 → 0.2.3
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 +6 -3
- package/dist/commands/doctor.js +546 -0
- package/dist/commands/local-args.js +18 -0
- package/dist/commands/local.js +132 -43
- package/dist/commands/public-root.js +1 -0
- package/dist/onboarding-roots.js +95 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,12 +45,12 @@ 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. |
|
|
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. |
|
|
54
54
|
| `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
|
|
55
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. |
|
|
56
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. |
|
|
@@ -82,12 +82,15 @@ cockpit onboard --no-auth
|
|
|
82
82
|
|
|
83
83
|
- `--email` skips the email prompt.
|
|
84
84
|
- `--workspace` pins one collection root; repeat it for multiple unrelated roots.
|
|
85
|
-
-
|
|
85
|
+
- 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>`.
|
|
86
|
+
- `--allow-home-root` is the headless/scripted form of that full-home opt-in.
|
|
86
87
|
- `--device-name` changes only the human label shown in Cockpit.
|
|
87
88
|
- `--dashboard-url` is for staging/custom dashboards only. Production is the default.
|
|
88
89
|
- `--no-auth` forces the old manual approval queue.
|
|
89
90
|
- `--repo` still works as a legacy alias for `--workspace`.
|
|
90
91
|
|
|
92
|
+
`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
|
+
|
|
91
94
|
`cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
|
|
92
95
|
|
|
93
96
|
## Parent Mode
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { autostartStatus, installAutostartAgent } from "../autostart.js";
|
|
5
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
6
|
+
import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
7
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
8
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
9
|
+
import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
|
|
10
|
+
import { runBackfillCommand } from "./backfill.js";
|
|
11
|
+
const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
13
|
+
const deps = { ...defaultDoctorDeps(hooks), ...overrides };
|
|
14
|
+
return runDoctorWithDeps(command, io, deps);
|
|
15
|
+
}
|
|
16
|
+
export async function runDoctorWithDeps(command, io, deps) {
|
|
17
|
+
const context = { command, io, deps };
|
|
18
|
+
const rows = [];
|
|
19
|
+
for (const invariant of doctorInvariants()) {
|
|
20
|
+
const checked = await invariant.check(context);
|
|
21
|
+
if (checked.status === "ok" || checked.status === "skipped") {
|
|
22
|
+
rows.push(checked);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
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;
|
|
36
|
+
}
|
|
37
|
+
if (!invariant.fix) {
|
|
38
|
+
rows.push(checked.hardStop ? checked : { ...checked, status: "needs_fix" });
|
|
39
|
+
if (checked.hardStop)
|
|
40
|
+
break;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const fixed = await invariant.fix(context, checked);
|
|
44
|
+
rows.push({ ...fixed, fixed: fixed.status !== "fail" });
|
|
45
|
+
if (fixed.hardStop)
|
|
46
|
+
break;
|
|
47
|
+
if (fixed.reexecExitCode !== undefined) {
|
|
48
|
+
await maybeReportDoctorEvents(context, rows);
|
|
49
|
+
writeDoctorOutput(command, io, rows);
|
|
50
|
+
return fixed.reexecExitCode;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
await maybeReportDoctorEvents(context, rows);
|
|
54
|
+
writeDoctorOutput(command, io, rows);
|
|
55
|
+
return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
function doctorInvariants() {
|
|
58
|
+
return [
|
|
59
|
+
{ id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
|
|
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
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "autostart-alive",
|
|
74
|
+
check: (context) => context.deps.checkAutostart(context),
|
|
75
|
+
fix: (context, _state) => context.deps.fixAutostart(context),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "backfill-complete",
|
|
79
|
+
check: (context) => context.deps.checkBackfill(context),
|
|
80
|
+
fix: (context, _state) => context.deps.fixBackfill(context),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "gc-checked",
|
|
84
|
+
check: (context) => context.deps.checkGc(context),
|
|
85
|
+
fix: (context, _state) => context.deps.fixGc(context),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "sync-fresh",
|
|
89
|
+
check: (context) => context.deps.checkSync(context),
|
|
90
|
+
fix: (context, _state) => context.deps.fixSync(context),
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
function defaultDoctorDeps(hooks) {
|
|
95
|
+
return {
|
|
96
|
+
latestCliVersion: latestCliVersionFromNpm,
|
|
97
|
+
selfUpdate: hooks.selfUpdate,
|
|
98
|
+
reexecDoctor: reexecDoctor,
|
|
99
|
+
reportInstallEvents: hooks.reportInstallEvents,
|
|
100
|
+
readAuth: readAuthState,
|
|
101
|
+
runLogin: (context) => hooks.runLogin(context.command, context.io),
|
|
102
|
+
readRoots: readRootState,
|
|
103
|
+
resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
|
|
104
|
+
checkAutostart: checkAutostartState,
|
|
105
|
+
fixAutostart: fixAutostartState,
|
|
106
|
+
checkBackfill: checkBackfillState,
|
|
107
|
+
fixBackfill: fixBackfillState,
|
|
108
|
+
checkGc: checkGcState,
|
|
109
|
+
fixGc: fixGcState,
|
|
110
|
+
checkSync: checkSyncState,
|
|
111
|
+
fixSync: fixSyncState,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function checkCliLatest(context) {
|
|
115
|
+
const latest = await context.deps.latestCliVersion(context);
|
|
116
|
+
if (!latest) {
|
|
117
|
+
return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
|
|
118
|
+
}
|
|
119
|
+
if (latest === LOCAL_COLLECTOR_VERSION) {
|
|
120
|
+
return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
|
|
121
|
+
}
|
|
122
|
+
return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
|
|
123
|
+
}
|
|
124
|
+
async function fixCliLatest(context, state) {
|
|
125
|
+
try {
|
|
126
|
+
await context.deps.selfUpdate(context.io, { json: context.command.json });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
return fail("cli-latest", selfUpdateFailureCode(error), selfUpdateFailureMessage(error));
|
|
130
|
+
}
|
|
131
|
+
if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
|
|
132
|
+
if (state.code === "stale_cli") {
|
|
133
|
+
return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit do-everything`.");
|
|
134
|
+
}
|
|
135
|
+
return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
|
|
136
|
+
}
|
|
137
|
+
const code = await context.deps.reexecDoctor(context.command, context.io);
|
|
138
|
+
return {
|
|
139
|
+
...ok("cli-latest", "reexeced", "self-update ran; re-execed the new cockpit binary."),
|
|
140
|
+
reexecExitCode: code,
|
|
141
|
+
};
|
|
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
|
+
}
|
|
155
|
+
async function latestCliVersionFromNpm(context) {
|
|
156
|
+
const exec = context.io.exec;
|
|
157
|
+
if (!exec)
|
|
158
|
+
return null;
|
|
159
|
+
const result = await exec("npm", ["view", "@bli-cockpit/cli", "version", "--json"]);
|
|
160
|
+
if (result.code !== 0)
|
|
161
|
+
return null;
|
|
162
|
+
return parseNpmVersion(result.stdout);
|
|
163
|
+
}
|
|
164
|
+
async function readAuthState(context) {
|
|
165
|
+
const paths = getCollectorRuntimePaths();
|
|
166
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
167
|
+
if (session?.session_state === "valid" &&
|
|
168
|
+
typeof session.device_token === "string" &&
|
|
169
|
+
session.device_token) {
|
|
170
|
+
return ok("authed", "device_token_present", "device token present");
|
|
171
|
+
}
|
|
172
|
+
return hardStop("authed", "pairing_required", [
|
|
173
|
+
"device is not signed in.",
|
|
174
|
+
"What you can do:",
|
|
175
|
+
" 1) Run `cockpit login` and complete the email/device approval.",
|
|
176
|
+
` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
|
|
177
|
+
" 3) Send this output to Edward if approval is blocked.",
|
|
178
|
+
].join("\n"));
|
|
179
|
+
}
|
|
180
|
+
async function readRootState(context) {
|
|
181
|
+
const paths = getCollectorRuntimePaths();
|
|
182
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
183
|
+
const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
184
|
+
if (roots.length > 0) {
|
|
185
|
+
return {
|
|
186
|
+
...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
|
|
187
|
+
roots,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return hardStop("roots-ok", "no_roots", [
|
|
191
|
+
"no saved collection roots were found.",
|
|
192
|
+
"What you can do:",
|
|
193
|
+
` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
|
|
194
|
+
" 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
|
|
195
|
+
" 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
|
|
196
|
+
].join("\n"));
|
|
197
|
+
}
|
|
198
|
+
async function checkAutostartState(context) {
|
|
199
|
+
const exec = context.io.exec;
|
|
200
|
+
if (!exec) {
|
|
201
|
+
return needsFix("autostart-alive", "runner_unavailable", "launchd runner unavailable; would refresh autostart");
|
|
202
|
+
}
|
|
203
|
+
const result = await autostartStatus({ exec });
|
|
204
|
+
if (result.status === "loaded") {
|
|
205
|
+
return ok("autostart-alive", "already_installed", "launchd agent loaded");
|
|
206
|
+
}
|
|
207
|
+
if (result.status === "unsupported") {
|
|
208
|
+
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
209
|
+
}
|
|
210
|
+
return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "autostart is not loaded");
|
|
211
|
+
}
|
|
212
|
+
async function fixAutostartState(context) {
|
|
213
|
+
const exec = context.io.exec;
|
|
214
|
+
if (!exec) {
|
|
215
|
+
return fail("autostart-alive", "runner_unavailable", "launchd runner unavailable");
|
|
216
|
+
}
|
|
217
|
+
const roots = await savedRoots();
|
|
218
|
+
const result = await installAutostartAgent({
|
|
219
|
+
repoRoot: context.command.repoRoot ?? roots[0],
|
|
220
|
+
repoRoots: context.command.repoRoot ? [context.command.repoRoot] : roots,
|
|
221
|
+
dashboardUrl: context.command.dashboardUrl,
|
|
222
|
+
exec,
|
|
223
|
+
});
|
|
224
|
+
if (result.status === "unsupported") {
|
|
225
|
+
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
226
|
+
}
|
|
227
|
+
if (result.loaded === false) {
|
|
228
|
+
return fail("autostart-alive", "autostart_load_failed", result.message ?? "launchctl load failed");
|
|
229
|
+
}
|
|
230
|
+
return ok("autostart-alive", "installed", "autostart installed and loaded");
|
|
231
|
+
}
|
|
232
|
+
async function checkBackfillState(_context) {
|
|
233
|
+
const paths = getCollectorRuntimePaths();
|
|
234
|
+
if (await hasBackfillCompletionMarker(paths)) {
|
|
235
|
+
return ok("backfill-complete", "complete", "backfill completion marker exists");
|
|
236
|
+
}
|
|
237
|
+
const lock = await inspectBackfillLock(paths);
|
|
238
|
+
if (lock.held) {
|
|
239
|
+
return skipped("backfill-complete", "backfill_already_running", `backfill already running since ${lock.held_since ?? "unknown"}`);
|
|
240
|
+
}
|
|
241
|
+
const cursor = await readBackfillCursor(paths);
|
|
242
|
+
return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "backfill completion marker missing");
|
|
243
|
+
}
|
|
244
|
+
async function fixBackfillState(context) {
|
|
245
|
+
const capture = capturedIo(context.io, !context.command.json);
|
|
246
|
+
const code = await runBackfillCommand({
|
|
247
|
+
repoRoot: context.command.repoRoot,
|
|
248
|
+
all: true,
|
|
249
|
+
dryRun: false,
|
|
250
|
+
yes: true,
|
|
251
|
+
json: true,
|
|
252
|
+
}, capture.io);
|
|
253
|
+
const output = capture.stdout() + "\n" + capture.stderr();
|
|
254
|
+
if (code === 0) {
|
|
255
|
+
return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
|
|
256
|
+
}
|
|
257
|
+
const reason = jsonField(output, "failure_reason");
|
|
258
|
+
if (reason === "backfill_already_running") {
|
|
259
|
+
return skipped("backfill-complete", "backfill_already_running", "backfill lock held; skipping as healthy");
|
|
260
|
+
}
|
|
261
|
+
return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
|
|
262
|
+
}
|
|
263
|
+
async function checkGcState(context) {
|
|
264
|
+
if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
|
|
265
|
+
return skipped("gc-checked", "skipped_disabled", "raw-evidence GC disabled");
|
|
266
|
+
}
|
|
267
|
+
const paths = getCollectorRuntimePaths();
|
|
268
|
+
const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
|
|
269
|
+
const info = await fs.stat(marker).catch(() => null);
|
|
270
|
+
if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
|
|
271
|
+
return skipped("gc-checked", "skipped_throttled", "raw-evidence GC ran within 24h");
|
|
272
|
+
}
|
|
273
|
+
return needsFix("gc-checked", "due", "raw-evidence GC is due");
|
|
274
|
+
}
|
|
275
|
+
async function fixGcState(context) {
|
|
276
|
+
const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
|
|
277
|
+
if (result.skipped) {
|
|
278
|
+
return skipped("gc-checked", "skipped_throttled", "raw-evidence GC skipped");
|
|
279
|
+
}
|
|
280
|
+
if (result.removed_dirs === 0) {
|
|
281
|
+
return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
|
|
282
|
+
}
|
|
283
|
+
return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
|
|
284
|
+
}
|
|
285
|
+
async function checkSyncState(context) {
|
|
286
|
+
const status = await inspectLocalCollectorStatus({
|
|
287
|
+
repoRoot: context.command.repoRoot,
|
|
288
|
+
}).catch(() => null);
|
|
289
|
+
if (status?.collector_freshness === "fresh") {
|
|
290
|
+
return ok("sync-fresh", "fresh", "last sync is fresh");
|
|
291
|
+
}
|
|
292
|
+
return needsFix("sync-fresh", "stale", "last sync is stale or missing");
|
|
293
|
+
}
|
|
294
|
+
async function fixSyncState(context) {
|
|
295
|
+
const exec = context.io.exec;
|
|
296
|
+
if (!exec)
|
|
297
|
+
return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
|
|
298
|
+
const args = ["sync", "--json"];
|
|
299
|
+
if (context.command.repoRoot)
|
|
300
|
+
args.push("--workspace", context.command.repoRoot);
|
|
301
|
+
if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
|
|
302
|
+
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
303
|
+
}
|
|
304
|
+
const result = await exec("cockpit", args);
|
|
305
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
306
|
+
const status = jsonField(output, "status");
|
|
307
|
+
if (result.code === 0 &&
|
|
308
|
+
(status === "sync_already_running" ||
|
|
309
|
+
status === "live_sync_paused_during_backfill")) {
|
|
310
|
+
return skipped("sync-fresh", status, "sync already running; skipping as healthy");
|
|
311
|
+
}
|
|
312
|
+
if (result.code === 0) {
|
|
313
|
+
return ok("sync-fresh", "synced", "ran `cockpit sync`");
|
|
314
|
+
}
|
|
315
|
+
return fail("sync-fresh", status ?? "sync_failed", "sync failed");
|
|
316
|
+
}
|
|
317
|
+
async function maybeReportDoctorEvents(context, rows) {
|
|
318
|
+
if (context.command.dryRun)
|
|
319
|
+
return;
|
|
320
|
+
await context.deps.reportInstallEvents({
|
|
321
|
+
dashboardUrl: context.command.dashboardUrl,
|
|
322
|
+
command: "doctor",
|
|
323
|
+
events: rows.map(doctorEvent),
|
|
324
|
+
json: context.command.json,
|
|
325
|
+
io: context.io,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function writeDoctorOutput(command, io, rows) {
|
|
329
|
+
if (command.json) {
|
|
330
|
+
writeLine(io.stdout, JSON.stringify({
|
|
331
|
+
status: rows.some((row) => row.status === "fail" || row.hardStop)
|
|
332
|
+
? "blocked"
|
|
333
|
+
: "pass",
|
|
334
|
+
dry_run: command.dryRun,
|
|
335
|
+
steps: rows,
|
|
336
|
+
}, null, 2));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
writeLine(io.stdout, command.dryRun ? "Cockpit doctor dry-run" : "Cockpit doctor");
|
|
340
|
+
writeLine(io.stdout, "state step code result");
|
|
341
|
+
for (const row of rows) {
|
|
342
|
+
writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${row.code.padEnd(23)} ${oneLine(row.message)}`);
|
|
343
|
+
}
|
|
344
|
+
const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
|
|
345
|
+
for (const row of explanations) {
|
|
346
|
+
writeLine(io.stderr, "");
|
|
347
|
+
writeLine(io.stderr, `${row.id}:`);
|
|
348
|
+
writeLine(io.stderr, row.message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function doctorEvent(row) {
|
|
352
|
+
const status = row.status === "fail" || row.hardStop
|
|
353
|
+
? "fail"
|
|
354
|
+
: row.status === "skipped"
|
|
355
|
+
? "skipped"
|
|
356
|
+
: "ok";
|
|
357
|
+
return {
|
|
358
|
+
step: row.id,
|
|
359
|
+
status,
|
|
360
|
+
...(status === "ok" ? {} : { error_code: sanitizeEventCode(row.code) }),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function doctorMark(row) {
|
|
364
|
+
if (row.status === "fail" || row.hardStop)
|
|
365
|
+
return "❌";
|
|
366
|
+
if (row.status === "needs_fix" || row.fixed)
|
|
367
|
+
return "🔧";
|
|
368
|
+
return "✅";
|
|
369
|
+
}
|
|
370
|
+
function ok(id, code, message) {
|
|
371
|
+
return { id, status: "ok", code, message };
|
|
372
|
+
}
|
|
373
|
+
function skipped(id, code, message) {
|
|
374
|
+
return { id, status: "skipped", code, message };
|
|
375
|
+
}
|
|
376
|
+
function needsFix(id, code, message) {
|
|
377
|
+
return { id, status: "needs_fix", code, message };
|
|
378
|
+
}
|
|
379
|
+
function fail(id, code, message) {
|
|
380
|
+
return { id, status: "fail", code, message };
|
|
381
|
+
}
|
|
382
|
+
function hardStop(id, code, message) {
|
|
383
|
+
return { id, status: "fail", code, message, hardStop: true };
|
|
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
|
+
}
|
|
397
|
+
async function savedRoots() {
|
|
398
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
|
|
399
|
+
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
400
|
+
}
|
|
401
|
+
async function hasBackfillCompletionMarker(paths) {
|
|
402
|
+
try {
|
|
403
|
+
const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
404
|
+
return (raw.schema_version === "cockpit-backfill-complete.v1" &&
|
|
405
|
+
typeof raw.completed_at === "string");
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function parseNpmVersion(stdout) {
|
|
412
|
+
const trimmed = stdout.trim();
|
|
413
|
+
if (!trimmed)
|
|
414
|
+
return null;
|
|
415
|
+
try {
|
|
416
|
+
const parsed = JSON.parse(trimmed);
|
|
417
|
+
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function reexecDoctor(command, io) {
|
|
424
|
+
const args = ["do-everything"];
|
|
425
|
+
if (command.repoRoot)
|
|
426
|
+
args.push("--workspace", command.repoRoot);
|
|
427
|
+
if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
|
|
428
|
+
args.push("--dashboard-url", command.dashboardUrl);
|
|
429
|
+
}
|
|
430
|
+
if (command.json)
|
|
431
|
+
args.push("--json");
|
|
432
|
+
return new Promise((resolve) => {
|
|
433
|
+
const child = spawn("cockpit", args, {
|
|
434
|
+
stdio: "inherit",
|
|
435
|
+
env: {
|
|
436
|
+
...process.env,
|
|
437
|
+
...io.env,
|
|
438
|
+
COCKPIT_DOCTOR_REEXEC: "1",
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
child.on("error", () => resolve(1));
|
|
442
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function capturedIo(io, forward) {
|
|
446
|
+
const stdoutChunks = [];
|
|
447
|
+
const stderrChunks = [];
|
|
448
|
+
return {
|
|
449
|
+
io: {
|
|
450
|
+
...io,
|
|
451
|
+
stdout: captureStream(io.stdout, stdoutChunks, forward),
|
|
452
|
+
stderr: captureStream(io.stderr, stderrChunks, forward),
|
|
453
|
+
},
|
|
454
|
+
stdout: () => stdoutChunks.join(""),
|
|
455
|
+
stderr: () => stderrChunks.join(""),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function captureStream(target, chunks, forward) {
|
|
459
|
+
return {
|
|
460
|
+
write(chunk, encoding, callback) {
|
|
461
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
462
|
+
chunks.push(text);
|
|
463
|
+
if (forward) {
|
|
464
|
+
if (typeof encoding === "function") {
|
|
465
|
+
target.write(chunk, encoding);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
target.write(chunk, encoding, callback);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else if (typeof encoding === "function") {
|
|
472
|
+
encoding();
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
callback?.();
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function jsonField(output, field) {
|
|
482
|
+
const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
483
|
+
const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
|
|
484
|
+
return match?.[1] ?? null;
|
|
485
|
+
}
|
|
486
|
+
function selfUpdateFailureCode(error) {
|
|
487
|
+
const record = asRecord(error);
|
|
488
|
+
if (record && record["eacces"] === true)
|
|
489
|
+
return "eacces_needs_chown";
|
|
490
|
+
return "npm_install_failed";
|
|
491
|
+
}
|
|
492
|
+
function selfUpdateFailureMessage(error) {
|
|
493
|
+
const record = asRecord(error);
|
|
494
|
+
const stderr = asRecord(record?.["result"])?.["stderr"] &&
|
|
495
|
+
typeof asRecord(record?.["result"])?.["stderr"] === "string"
|
|
496
|
+
? String(asRecord(record?.["result"])?.["stderr"])
|
|
497
|
+
: "";
|
|
498
|
+
if (record?.["eacces"] === true) {
|
|
499
|
+
const prefix = npmPrefixFromError(stderr);
|
|
500
|
+
return [
|
|
501
|
+
"npm global install hit a permissions problem.",
|
|
502
|
+
"What you can do:",
|
|
503
|
+
` 1) Fix npm ownership once: sudo chown -R $(whoami) ${prefix}/lib/node_modules/@bli-cockpit ${prefix}/bin/cockpit`,
|
|
504
|
+
" 2) No sudo? Send this output to Edward.",
|
|
505
|
+
" 3) Do not use `sudo npm i -g`; it makes the ownership problem come back.",
|
|
506
|
+
].join("\n");
|
|
507
|
+
}
|
|
508
|
+
return "npm install failed; Cockpit CLI was not refreshed.";
|
|
509
|
+
}
|
|
510
|
+
function npmPrefixFromError(stderr) {
|
|
511
|
+
if (stderr.includes("/usr/local/"))
|
|
512
|
+
return "/usr/local";
|
|
513
|
+
if (stderr.includes("/opt/homebrew/"))
|
|
514
|
+
return "/opt/homebrew";
|
|
515
|
+
const nvm = stderr.match(/(\/Users\/[^/\s]+\/\.nvm\/versions\/node\/[^/\s]+)/u);
|
|
516
|
+
return nvm?.[1] ?? "/opt/homebrew";
|
|
517
|
+
}
|
|
518
|
+
function onboardOneLiner(command) {
|
|
519
|
+
const workspace = command.repoRoot ?? "$PWD";
|
|
520
|
+
const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
521
|
+
? ""
|
|
522
|
+
: ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
|
|
523
|
+
return `cockpit onboard --workspace ${shellQuote(workspace)}${dashboard}`;
|
|
524
|
+
}
|
|
525
|
+
function shellQuote(value) {
|
|
526
|
+
if (value === "$PWD")
|
|
527
|
+
return '"$PWD"';
|
|
528
|
+
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
529
|
+
}
|
|
530
|
+
function sanitizeEventCode(value) {
|
|
531
|
+
return (value
|
|
532
|
+
.trim()
|
|
533
|
+
.toLowerCase()
|
|
534
|
+
.replace(/[^a-z0-9_]+/gu, "_")
|
|
535
|
+
.replace(/^_+|_+$/gu, "")
|
|
536
|
+
.slice(0, 120) || "unknown");
|
|
537
|
+
}
|
|
538
|
+
function oneLine(value) {
|
|
539
|
+
return value.split("\n")[0] ?? value;
|
|
540
|
+
}
|
|
541
|
+
function asRecord(value) {
|
|
542
|
+
return value && typeof value === "object" ? value : null;
|
|
543
|
+
}
|
|
544
|
+
function writeLine(stream, text) {
|
|
545
|
+
stream.write(`${text}\n`);
|
|
546
|
+
}
|
|
@@ -15,6 +15,9 @@ export function parseLocalArgs(argv) {
|
|
|
15
15
|
case "update":
|
|
16
16
|
case "upgrade":
|
|
17
17
|
return parseUpdateArgs(command, argv.slice(1));
|
|
18
|
+
case "do-everything":
|
|
19
|
+
case "fix":
|
|
20
|
+
return parseDoctorArgs(command, argv.slice(1));
|
|
18
21
|
case "install":
|
|
19
22
|
return parseInstallArgs(argv.slice(1));
|
|
20
23
|
case "login":
|
|
@@ -108,6 +111,21 @@ function parseUpdateArgs(alias, args) {
|
|
|
108
111
|
...parseOnboardLikeArgs(args, alias),
|
|
109
112
|
};
|
|
110
113
|
}
|
|
114
|
+
function parseDoctorArgs(alias, args) {
|
|
115
|
+
const values = parseNamedArgs(args, {
|
|
116
|
+
allowedFlags: ["--workspace", "--dashboard-url", "--dry-run", "--json"],
|
|
117
|
+
valueFlags: ["--workspace", "--dashboard-url"],
|
|
118
|
+
});
|
|
119
|
+
assertNoPositionals(values.positionals, alias);
|
|
120
|
+
return {
|
|
121
|
+
kind: "doctor",
|
|
122
|
+
alias,
|
|
123
|
+
repoRoot: optionalNonEmpty(values.flags.get("--workspace")),
|
|
124
|
+
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
125
|
+
dryRun: values.booleans.has("--dry-run"),
|
|
126
|
+
json: values.booleans.has("--json"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
111
129
|
function parseInstallArgs(args) {
|
|
112
130
|
const values = parseNamedArgs(args, {
|
|
113
131
|
allowedFlags: [
|
package/dist/commands/local.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { createCollectorServer } from "../server.js";
|
|
6
6
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
7
7
|
import { runBackfillCommand } from "./backfill.js";
|
|
8
|
+
import { runDoctor } from "./doctor.js";
|
|
8
9
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
9
10
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
10
11
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
@@ -21,6 +22,8 @@ export const rootCommandNames = new Set([
|
|
|
21
22
|
"onboard",
|
|
22
23
|
"update",
|
|
23
24
|
"upgrade",
|
|
25
|
+
"do-everything",
|
|
26
|
+
"fix",
|
|
24
27
|
"install",
|
|
25
28
|
"login",
|
|
26
29
|
"pair",
|
|
@@ -58,6 +61,13 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
58
61
|
return await runOnboard(command, io);
|
|
59
62
|
case "update":
|
|
60
63
|
return await runUpdate(command, io);
|
|
64
|
+
case "doctor":
|
|
65
|
+
return await runDoctor(command, io, {
|
|
66
|
+
reportInstallEvents: reportInstallEventsBestEffort,
|
|
67
|
+
selfUpdate: runSelfUpdate,
|
|
68
|
+
runLogin: runDoctorLogin,
|
|
69
|
+
resolveAndSaveRoots: resolveAndSaveDoctorRoots,
|
|
70
|
+
});
|
|
61
71
|
case "login":
|
|
62
72
|
return await runLogin(command, io);
|
|
63
73
|
case "logout":
|
|
@@ -94,6 +104,8 @@ export function localCommandHelp(command) {
|
|
|
94
104
|
" 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
105
|
" cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
|
|
96
106
|
" cockpit upgrade [same flags as update]",
|
|
107
|
+
" cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
108
|
+
" cockpit fix [same flags as do-everything]",
|
|
97
109
|
" cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
|
|
98
110
|
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
99
111
|
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
@@ -156,6 +168,24 @@ function localSubcommandHelp(command) {
|
|
|
156
168
|
"Alias for `cockpit update`.",
|
|
157
169
|
],
|
|
158
170
|
],
|
|
171
|
+
[
|
|
172
|
+
"do-everything",
|
|
173
|
+
[
|
|
174
|
+
"Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
175
|
+
"",
|
|
176
|
+
"Converges an already-onboarded intern machine: latest CLI, auth, saved roots, autostart, backfill, raw-evidence GC, and sync freshness.",
|
|
177
|
+
"`cockpit fix` is an alias.",
|
|
178
|
+
"--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
|
|
179
|
+
],
|
|
180
|
+
],
|
|
181
|
+
[
|
|
182
|
+
"fix",
|
|
183
|
+
[
|
|
184
|
+
"Usage: cockpit fix [same flags as cockpit do-everything]",
|
|
185
|
+
"",
|
|
186
|
+
"Alias for `cockpit do-everything`.",
|
|
187
|
+
],
|
|
188
|
+
],
|
|
159
189
|
[
|
|
160
190
|
"login",
|
|
161
191
|
[
|
|
@@ -359,37 +389,31 @@ async function runUpdate(command, io) {
|
|
|
359
389
|
return code;
|
|
360
390
|
};
|
|
361
391
|
const exec = io.exec ?? defaultExec();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
"-g",
|
|
365
|
-
"@bli-cockpit/cli@latest",
|
|
366
|
-
"--prefer-online",
|
|
367
|
-
];
|
|
368
|
-
if (!command.json) {
|
|
369
|
-
writeLine(io.stdout, "Updating Cockpit CLI from npm...");
|
|
392
|
+
try {
|
|
393
|
+
await runSelfUpdate(io, { json: command.json });
|
|
370
394
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
addInstallEvent(installEvents, "npm_install", "fail",
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (!(error instanceof SelfUpdateError))
|
|
397
|
+
throw error;
|
|
398
|
+
addInstallEvent(installEvents, "npm_install", "fail", error.eacces
|
|
375
399
|
? "npm_install_eacces"
|
|
376
400
|
: "npm_install_failed");
|
|
377
401
|
if (command.json) {
|
|
378
402
|
writeLine(io.stdout, JSON.stringify({
|
|
379
403
|
status: "blocked",
|
|
380
404
|
step: "npm_install",
|
|
381
|
-
command: `npm ${
|
|
382
|
-
exit_code:
|
|
405
|
+
command: `npm ${SELF_UPDATE_INSTALL_ARGS.join(" ")}`,
|
|
406
|
+
exit_code: error.result.code,
|
|
383
407
|
}, null, 2));
|
|
384
408
|
}
|
|
385
409
|
else {
|
|
386
410
|
writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
|
|
387
|
-
if (
|
|
411
|
+
if (error.eacces) {
|
|
388
412
|
writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
|
|
389
413
|
writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
|
|
390
414
|
}
|
|
391
415
|
}
|
|
392
|
-
return finish(
|
|
416
|
+
return finish(error.result.code || 1);
|
|
393
417
|
}
|
|
394
418
|
addInstallEvent(installEvents, "npm_install", "ok");
|
|
395
419
|
if (!command.json) {
|
|
@@ -403,6 +427,34 @@ async function runUpdate(command, io) {
|
|
|
403
427
|
addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
|
|
404
428
|
return finish(onboard.code);
|
|
405
429
|
}
|
|
430
|
+
const SELF_UPDATE_INSTALL_ARGS = [
|
|
431
|
+
"install",
|
|
432
|
+
"-g",
|
|
433
|
+
"@bli-cockpit/cli@latest",
|
|
434
|
+
"--prefer-online",
|
|
435
|
+
];
|
|
436
|
+
export class SelfUpdateError extends Error {
|
|
437
|
+
result;
|
|
438
|
+
eacces;
|
|
439
|
+
constructor(result) {
|
|
440
|
+
super("npm install failed; Cockpit CLI was not refreshed.");
|
|
441
|
+
this.name = "SelfUpdateError";
|
|
442
|
+
this.result = result;
|
|
443
|
+
this.eacces = isNpmEaccesFailure(result.stderr);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
export async function runSelfUpdate(io, options = {}) {
|
|
447
|
+
const exec = io.exec ?? defaultExec();
|
|
448
|
+
if (!options.json) {
|
|
449
|
+
writeLine(io.stdout, "Updating Cockpit CLI from npm...");
|
|
450
|
+
}
|
|
451
|
+
const install = await exec("npm", [...SELF_UPDATE_INSTALL_ARGS]);
|
|
452
|
+
writeExecOutput(io, install, { stdout: !options.json, stderr: true });
|
|
453
|
+
if (install.code !== 0) {
|
|
454
|
+
throw new SelfUpdateError(install);
|
|
455
|
+
}
|
|
456
|
+
return { updated: true, version: LOCAL_COLLECTOR_VERSION };
|
|
457
|
+
}
|
|
406
458
|
async function runRelease(command, io) {
|
|
407
459
|
const releaseRoot = await findPublicReleaseRoot(process.cwd());
|
|
408
460
|
if (!releaseRoot) {
|
|
@@ -600,7 +652,7 @@ function sanitizeInstallErrorCode(value) {
|
|
|
600
652
|
.slice(0, 120);
|
|
601
653
|
return normalized || "unknown";
|
|
602
654
|
}
|
|
603
|
-
async function reportInstallEventsBestEffort(options) {
|
|
655
|
+
export async function reportInstallEventsBestEffort(options) {
|
|
604
656
|
if (options.events.length === 0)
|
|
605
657
|
return;
|
|
606
658
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
@@ -1003,6 +1055,64 @@ function backgroundSyncLine(result) {
|
|
|
1003
1055
|
}
|
|
1004
1056
|
return result.status;
|
|
1005
1057
|
}
|
|
1058
|
+
async function resolveOnboardingRootsForCommand(command, io) {
|
|
1059
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1060
|
+
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
1061
|
+
const interactive = !command.json && isInteractiveStdin(io);
|
|
1062
|
+
const rootsResult = await resolveOnboardingRoots({
|
|
1063
|
+
homeDir: command.homeDir,
|
|
1064
|
+
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
1065
|
+
config: existingConfig,
|
|
1066
|
+
interactive,
|
|
1067
|
+
allowHomeRoot: command.allowHomeRoot,
|
|
1068
|
+
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
1069
|
+
});
|
|
1070
|
+
const collectionRoots = rootsResult.roots;
|
|
1071
|
+
const primaryRoot = collectionRoots[0];
|
|
1072
|
+
if (!primaryRoot) {
|
|
1073
|
+
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
1074
|
+
}
|
|
1075
|
+
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
1076
|
+
!command.collectionRoots?.length &&
|
|
1077
|
+
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
1078
|
+
return {
|
|
1079
|
+
existingConfig,
|
|
1080
|
+
rootsResult,
|
|
1081
|
+
collectionRoots,
|
|
1082
|
+
primaryRoot,
|
|
1083
|
+
replaceRepoRoots,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
async function persistOnboardingRootConfig(command, resolution) {
|
|
1087
|
+
return installLocalCollector({
|
|
1088
|
+
homeDir: command.homeDir,
|
|
1089
|
+
repoRoot: resolution.primaryRoot,
|
|
1090
|
+
repoRoots: resolution.collectionRoots,
|
|
1091
|
+
replaceRepoRoots: resolution.replaceRepoRoots,
|
|
1092
|
+
dashboardUrl: command.dashboardUrl,
|
|
1093
|
+
deviceName: command.deviceName,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
async function runDoctorLogin(command, io) {
|
|
1097
|
+
return runLogin({
|
|
1098
|
+
kind: "login",
|
|
1099
|
+
dashboardUrl: command.dashboardUrl,
|
|
1100
|
+
json: command.json,
|
|
1101
|
+
noAuth: false,
|
|
1102
|
+
}, io);
|
|
1103
|
+
}
|
|
1104
|
+
async function resolveAndSaveDoctorRoots(command, io) {
|
|
1105
|
+
const rootCommand = {
|
|
1106
|
+
repoRoot: command.repoRoot,
|
|
1107
|
+
dashboardUrl: command.dashboardUrl,
|
|
1108
|
+
json: command.json,
|
|
1109
|
+
};
|
|
1110
|
+
const resolution = await resolveOnboardingRootsForCommand(rootCommand, io);
|
|
1111
|
+
await persistOnboardingRootConfig(rootCommand, resolution);
|
|
1112
|
+
if (!command.json) {
|
|
1113
|
+
writeLine(io.stdout, `Saved collection roots: ${resolution.collectionRoots.join(", ")}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1006
1116
|
async function runOnboard(command, io) {
|
|
1007
1117
|
const installEvents = [];
|
|
1008
1118
|
const finish = async (code) => {
|
|
@@ -1030,31 +1140,17 @@ async function runOnboard(command, io) {
|
|
|
1030
1140
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
1031
1141
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
1032
1142
|
}
|
|
1033
|
-
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1034
1143
|
backfillHint = await onboardBackfillHint(command.homeDir);
|
|
1035
|
-
const
|
|
1036
|
-
const
|
|
1037
|
-
rootsResult =
|
|
1038
|
-
homeDir: command.homeDir,
|
|
1039
|
-
explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
|
|
1040
|
-
config: existingConfig,
|
|
1041
|
-
interactive,
|
|
1042
|
-
allowHomeRoot: command.allowHomeRoot,
|
|
1043
|
-
prompt: interactive ? onboardingRootPrompt(io) : undefined,
|
|
1044
|
-
});
|
|
1144
|
+
const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
|
|
1145
|
+
const existingConfig = resolvedRoots.existingConfig;
|
|
1146
|
+
rootsResult = resolvedRoots.rootsResult;
|
|
1045
1147
|
const collectionRoots = rootsResult.roots;
|
|
1046
|
-
const primaryRoot =
|
|
1047
|
-
if (!primaryRoot) {
|
|
1048
|
-
throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
|
|
1049
|
-
}
|
|
1148
|
+
const primaryRoot = resolvedRoots.primaryRoot;
|
|
1050
1149
|
const resolvedCommand = {
|
|
1051
1150
|
...command,
|
|
1052
1151
|
repoRoot: primaryRoot,
|
|
1053
1152
|
collectionRoots,
|
|
1054
1153
|
};
|
|
1055
|
-
const replaceRepoRoots = rootsResult.source === "prompt" &&
|
|
1056
|
-
!command.collectionRoots?.length &&
|
|
1057
|
-
(existingConfig?.default_repo_paths.length ?? 0) > 0;
|
|
1058
1154
|
if (!command.json) {
|
|
1059
1155
|
writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
|
|
1060
1156
|
}
|
|
@@ -1062,14 +1158,7 @@ async function runOnboard(command, io) {
|
|
|
1062
1158
|
addInstallEvent(installEvents, "home_root_optin", "ok");
|
|
1063
1159
|
}
|
|
1064
1160
|
const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
|
|
1065
|
-
install = await
|
|
1066
|
-
homeDir: command.homeDir,
|
|
1067
|
-
repoRoot: primaryRoot,
|
|
1068
|
-
repoRoots: collectionRoots,
|
|
1069
|
-
replaceRepoRoots,
|
|
1070
|
-
dashboardUrl: command.dashboardUrl,
|
|
1071
|
-
deviceName: command.deviceName,
|
|
1072
|
-
});
|
|
1161
|
+
install = await persistOnboardingRootConfig(command, resolvedRoots);
|
|
1073
1162
|
addInstallEvent(installEvents, "install", "ok");
|
|
1074
1163
|
if (!command.json) {
|
|
1075
1164
|
writeLine(io.stdout, "1/5 Installed local collector.");
|
|
@@ -23,6 +23,7 @@ function cockpitHelp() {
|
|
|
23
23
|
localCommandHelp(),
|
|
24
24
|
"",
|
|
25
25
|
"Install: `npm install -g @bli-cockpit/cli@latest`.",
|
|
26
|
+
"Fix everything: run `cockpit do-everything` to update, verify auth/roots, refresh autostart, backfill, GC, and sync.",
|
|
26
27
|
"Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
|
|
27
28
|
"Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
|
|
28
29
|
"Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
|
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());
|