@bli-cockpit/cli 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,12 +45,13 @@ 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
- You only need three commands (`onboard`, `status`, and `backfill` once). The rest exist for
48
+ You only need `onboard` once and `do-everything` whenever Edward asks the fleet to converge. The rest exist for
49
49
  recovery and maintenance.
50
50
 
51
51
  | Command | What it does | Why it exists / why this name |
52
52
  |---|---|---|
53
53
  | `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 an already-onboarded Mac: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
54
55
  | `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
55
56
  | `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
57
  | `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. |
@@ -88,6 +89,8 @@ cockpit onboard --no-auth
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 after onboarding. It exits non-zero only when a checked invariant remains red after its fix runs, or when a human action such as login/onboard-rerun is required.
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,502 @@
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 (checked.hardStop) {
26
+ rows.push(checked);
27
+ break;
28
+ }
29
+ if (command.dryRun || !invariant.fix) {
30
+ rows.push({
31
+ ...checked,
32
+ status: "needs_fix",
33
+ message: `would fix: ${checked.message}`,
34
+ });
35
+ continue;
36
+ }
37
+ const fixed = await invariant.fix(context, checked);
38
+ rows.push({ ...fixed, fixed: fixed.status !== "fail" });
39
+ if (fixed.reexecExitCode !== undefined) {
40
+ await maybeReportDoctorEvents(context, rows);
41
+ writeDoctorOutput(command, io, rows);
42
+ return fixed.reexecExitCode;
43
+ }
44
+ }
45
+ await maybeReportDoctorEvents(context, rows);
46
+ writeDoctorOutput(command, io, rows);
47
+ return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
48
+ }
49
+ function doctorInvariants() {
50
+ return [
51
+ { id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
52
+ { id: "authed", check: (context) => context.deps.readAuth(context) },
53
+ { id: "roots-ok", check: (context) => context.deps.readRoots(context) },
54
+ {
55
+ id: "autostart-alive",
56
+ check: (context) => context.deps.checkAutostart(context),
57
+ fix: (context, _state) => context.deps.fixAutostart(context),
58
+ },
59
+ {
60
+ id: "backfill-complete",
61
+ check: (context) => context.deps.checkBackfill(context),
62
+ fix: (context, _state) => context.deps.fixBackfill(context),
63
+ },
64
+ {
65
+ id: "gc-checked",
66
+ check: (context) => context.deps.checkGc(context),
67
+ fix: (context, _state) => context.deps.fixGc(context),
68
+ },
69
+ {
70
+ id: "sync-fresh",
71
+ check: (context) => context.deps.checkSync(context),
72
+ fix: (context, _state) => context.deps.fixSync(context),
73
+ },
74
+ ];
75
+ }
76
+ function defaultDoctorDeps(hooks) {
77
+ return {
78
+ latestCliVersion: latestCliVersionFromNpm,
79
+ selfUpdate: hooks.selfUpdate,
80
+ reexecDoctor: reexecDoctor,
81
+ reportInstallEvents: hooks.reportInstallEvents,
82
+ readAuth: readAuthState,
83
+ readRoots: readRootState,
84
+ checkAutostart: checkAutostartState,
85
+ fixAutostart: fixAutostartState,
86
+ checkBackfill: checkBackfillState,
87
+ fixBackfill: fixBackfillState,
88
+ checkGc: checkGcState,
89
+ fixGc: fixGcState,
90
+ checkSync: checkSyncState,
91
+ fixSync: fixSyncState,
92
+ };
93
+ }
94
+ async function checkCliLatest(context) {
95
+ const latest = await context.deps.latestCliVersion(context);
96
+ if (!latest) {
97
+ return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
98
+ }
99
+ if (latest === LOCAL_COLLECTOR_VERSION) {
100
+ return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
101
+ }
102
+ return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
103
+ }
104
+ async function fixCliLatest(context, state) {
105
+ try {
106
+ await context.deps.selfUpdate(context.io, { json: context.command.json });
107
+ }
108
+ catch (error) {
109
+ return fail("cli-latest", selfUpdateFailureCode(error), selfUpdateFailureMessage(error));
110
+ }
111
+ if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
112
+ if (state.code === "stale_cli") {
113
+ return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit do-everything`.");
114
+ }
115
+ return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
116
+ }
117
+ const code = await context.deps.reexecDoctor(context.command, context.io);
118
+ return {
119
+ ...ok("cli-latest", "reexeced", "self-update ran; re-execed the new cockpit binary."),
120
+ reexecExitCode: code,
121
+ };
122
+ }
123
+ async function latestCliVersionFromNpm(context) {
124
+ const exec = context.io.exec;
125
+ if (!exec)
126
+ return null;
127
+ const result = await exec("npm", ["view", "@bli-cockpit/cli", "version", "--json"]);
128
+ if (result.code !== 0)
129
+ return null;
130
+ return parseNpmVersion(result.stdout);
131
+ }
132
+ async function readAuthState(context) {
133
+ const paths = getCollectorRuntimePaths();
134
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
135
+ if (session?.session_state === "valid" &&
136
+ typeof session.device_token === "string" &&
137
+ session.device_token) {
138
+ return ok("authed", "device_token_present", "device token present");
139
+ }
140
+ return hardStop("authed", "pairing_required", [
141
+ "device is not signed in.",
142
+ "What you can do:",
143
+ " 1) Run `cockpit login` and complete the email/device approval.",
144
+ ` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
145
+ " 3) Send this output to Edward if approval is blocked.",
146
+ ].join("\n"));
147
+ }
148
+ async function readRootState(context) {
149
+ const paths = getCollectorRuntimePaths();
150
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
151
+ const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
152
+ if (roots.length > 0) {
153
+ return {
154
+ ...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
155
+ roots,
156
+ };
157
+ }
158
+ return hardStop("roots-ok", "no_roots", [
159
+ "no saved collection roots were found.",
160
+ "What you can do:",
161
+ ` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
162
+ " 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
163
+ " 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
164
+ ].join("\n"));
165
+ }
166
+ async function checkAutostartState(context) {
167
+ const exec = context.io.exec;
168
+ if (!exec) {
169
+ return needsFix("autostart-alive", "runner_unavailable", "launchd runner unavailable; would refresh autostart");
170
+ }
171
+ const result = await autostartStatus({ exec });
172
+ if (result.status === "loaded") {
173
+ return ok("autostart-alive", "already_installed", "launchd agent loaded");
174
+ }
175
+ if (result.status === "unsupported") {
176
+ return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
177
+ }
178
+ return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "autostart is not loaded");
179
+ }
180
+ async function fixAutostartState(context) {
181
+ const exec = context.io.exec;
182
+ if (!exec) {
183
+ return fail("autostart-alive", "runner_unavailable", "launchd runner unavailable");
184
+ }
185
+ const roots = await savedRoots();
186
+ const result = await installAutostartAgent({
187
+ repoRoot: context.command.repoRoot ?? roots[0],
188
+ repoRoots: context.command.repoRoot ? [context.command.repoRoot] : roots,
189
+ dashboardUrl: context.command.dashboardUrl,
190
+ exec,
191
+ });
192
+ if (result.status === "unsupported") {
193
+ return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
194
+ }
195
+ if (result.loaded === false) {
196
+ return fail("autostart-alive", "autostart_load_failed", result.message ?? "launchctl load failed");
197
+ }
198
+ return ok("autostart-alive", "installed", "autostart installed and loaded");
199
+ }
200
+ async function checkBackfillState(_context) {
201
+ const paths = getCollectorRuntimePaths();
202
+ if (await hasBackfillCompletionMarker(paths)) {
203
+ return ok("backfill-complete", "complete", "backfill completion marker exists");
204
+ }
205
+ const lock = await inspectBackfillLock(paths);
206
+ if (lock.held) {
207
+ return skipped("backfill-complete", "backfill_already_running", `backfill already running since ${lock.held_since ?? "unknown"}`);
208
+ }
209
+ const cursor = await readBackfillCursor(paths);
210
+ return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "backfill completion marker missing");
211
+ }
212
+ async function fixBackfillState(context) {
213
+ const capture = capturedIo(context.io, !context.command.json);
214
+ const code = await runBackfillCommand({
215
+ repoRoot: context.command.repoRoot,
216
+ all: true,
217
+ dryRun: false,
218
+ yes: true,
219
+ json: true,
220
+ }, capture.io);
221
+ const output = capture.stdout() + "\n" + capture.stderr();
222
+ if (code === 0) {
223
+ return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
224
+ }
225
+ const reason = jsonField(output, "failure_reason");
226
+ if (reason === "backfill_already_running") {
227
+ return skipped("backfill-complete", "backfill_already_running", "backfill lock held; skipping as healthy");
228
+ }
229
+ return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
230
+ }
231
+ async function checkGcState(context) {
232
+ if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
233
+ return skipped("gc-checked", "skipped_disabled", "raw-evidence GC disabled");
234
+ }
235
+ const paths = getCollectorRuntimePaths();
236
+ const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
237
+ const info = await fs.stat(marker).catch(() => null);
238
+ if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
239
+ return skipped("gc-checked", "skipped_throttled", "raw-evidence GC ran within 24h");
240
+ }
241
+ return needsFix("gc-checked", "due", "raw-evidence GC is due");
242
+ }
243
+ async function fixGcState(context) {
244
+ const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
245
+ if (result.skipped) {
246
+ return skipped("gc-checked", "skipped_throttled", "raw-evidence GC skipped");
247
+ }
248
+ if (result.removed_dirs === 0) {
249
+ return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
250
+ }
251
+ return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
252
+ }
253
+ async function checkSyncState(context) {
254
+ const status = await inspectLocalCollectorStatus({
255
+ repoRoot: context.command.repoRoot,
256
+ }).catch(() => null);
257
+ if (status?.collector_freshness === "fresh") {
258
+ return ok("sync-fresh", "fresh", "last sync is fresh");
259
+ }
260
+ return needsFix("sync-fresh", "stale", "last sync is stale or missing");
261
+ }
262
+ async function fixSyncState(context) {
263
+ const exec = context.io.exec;
264
+ if (!exec)
265
+ return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
266
+ const args = ["sync", "--json"];
267
+ if (context.command.repoRoot)
268
+ args.push("--workspace", context.command.repoRoot);
269
+ if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
270
+ args.push("--dashboard-url", context.command.dashboardUrl);
271
+ }
272
+ const result = await exec("cockpit", args);
273
+ const output = `${result.stdout}\n${result.stderr}`;
274
+ const status = jsonField(output, "status");
275
+ if (result.code === 0 &&
276
+ (status === "sync_already_running" ||
277
+ status === "live_sync_paused_during_backfill")) {
278
+ return skipped("sync-fresh", status, "sync already running; skipping as healthy");
279
+ }
280
+ if (result.code === 0) {
281
+ return ok("sync-fresh", "synced", "ran `cockpit sync`");
282
+ }
283
+ return fail("sync-fresh", status ?? "sync_failed", "sync failed");
284
+ }
285
+ async function maybeReportDoctorEvents(context, rows) {
286
+ if (context.command.dryRun)
287
+ return;
288
+ await context.deps.reportInstallEvents({
289
+ dashboardUrl: context.command.dashboardUrl,
290
+ command: "doctor",
291
+ events: rows.map(doctorEvent),
292
+ json: context.command.json,
293
+ io: context.io,
294
+ });
295
+ }
296
+ function writeDoctorOutput(command, io, rows) {
297
+ if (command.json) {
298
+ writeLine(io.stdout, JSON.stringify({
299
+ status: rows.some((row) => row.status === "fail" || row.hardStop)
300
+ ? "blocked"
301
+ : "pass",
302
+ dry_run: command.dryRun,
303
+ steps: rows,
304
+ }, null, 2));
305
+ return;
306
+ }
307
+ writeLine(io.stdout, command.dryRun ? "Cockpit doctor dry-run" : "Cockpit doctor");
308
+ writeLine(io.stdout, "state step code result");
309
+ for (const row of rows) {
310
+ writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${row.code.padEnd(23)} ${oneLine(row.message)}`);
311
+ }
312
+ const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
313
+ for (const row of explanations) {
314
+ writeLine(io.stderr, "");
315
+ writeLine(io.stderr, `${row.id}:`);
316
+ writeLine(io.stderr, row.message);
317
+ }
318
+ }
319
+ function doctorEvent(row) {
320
+ const status = row.status === "fail" || row.hardStop
321
+ ? "fail"
322
+ : row.status === "skipped"
323
+ ? "skipped"
324
+ : "ok";
325
+ return {
326
+ step: row.id,
327
+ status,
328
+ ...(status === "ok" ? {} : { error_code: sanitizeEventCode(row.code) }),
329
+ };
330
+ }
331
+ function doctorMark(row) {
332
+ if (row.status === "fail" || row.hardStop)
333
+ return "❌";
334
+ if (row.status === "needs_fix" || row.fixed)
335
+ return "🔧";
336
+ return "✅";
337
+ }
338
+ function ok(id, code, message) {
339
+ return { id, status: "ok", code, message };
340
+ }
341
+ function skipped(id, code, message) {
342
+ return { id, status: "skipped", code, message };
343
+ }
344
+ function needsFix(id, code, message) {
345
+ return { id, status: "needs_fix", code, message };
346
+ }
347
+ function fail(id, code, message) {
348
+ return { id, status: "fail", code, message };
349
+ }
350
+ function hardStop(id, code, message) {
351
+ return { id, status: "fail", code, message, hardStop: true };
352
+ }
353
+ async function savedRoots() {
354
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
355
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
356
+ }
357
+ async function hasBackfillCompletionMarker(paths) {
358
+ try {
359
+ const raw = JSON.parse(await fs.readFile(backfillCompletionMarkerPath(paths), "utf8"));
360
+ return (raw.schema_version === "cockpit-backfill-complete.v1" &&
361
+ typeof raw.completed_at === "string");
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ function parseNpmVersion(stdout) {
368
+ const trimmed = stdout.trim();
369
+ if (!trimmed)
370
+ return null;
371
+ try {
372
+ const parsed = JSON.parse(trimmed);
373
+ return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
374
+ }
375
+ catch {
376
+ return trimmed.replace(/^"|"$/gu, "") || null;
377
+ }
378
+ }
379
+ function reexecDoctor(command, io) {
380
+ const args = ["do-everything"];
381
+ if (command.repoRoot)
382
+ args.push("--workspace", command.repoRoot);
383
+ if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
384
+ args.push("--dashboard-url", command.dashboardUrl);
385
+ }
386
+ if (command.json)
387
+ args.push("--json");
388
+ return new Promise((resolve) => {
389
+ const child = spawn("cockpit", args, {
390
+ stdio: "inherit",
391
+ env: {
392
+ ...process.env,
393
+ ...io.env,
394
+ COCKPIT_DOCTOR_REEXEC: "1",
395
+ },
396
+ });
397
+ child.on("error", () => resolve(1));
398
+ child.on("close", (code) => resolve(code ?? 1));
399
+ });
400
+ }
401
+ function capturedIo(io, forward) {
402
+ const stdoutChunks = [];
403
+ const stderrChunks = [];
404
+ return {
405
+ io: {
406
+ ...io,
407
+ stdout: captureStream(io.stdout, stdoutChunks, forward),
408
+ stderr: captureStream(io.stderr, stderrChunks, forward),
409
+ },
410
+ stdout: () => stdoutChunks.join(""),
411
+ stderr: () => stderrChunks.join(""),
412
+ };
413
+ }
414
+ function captureStream(target, chunks, forward) {
415
+ return {
416
+ write(chunk, encoding, callback) {
417
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
418
+ chunks.push(text);
419
+ if (forward) {
420
+ if (typeof encoding === "function") {
421
+ target.write(chunk, encoding);
422
+ }
423
+ else {
424
+ target.write(chunk, encoding, callback);
425
+ }
426
+ }
427
+ else if (typeof encoding === "function") {
428
+ encoding();
429
+ }
430
+ else {
431
+ callback?.();
432
+ }
433
+ return true;
434
+ },
435
+ };
436
+ }
437
+ function jsonField(output, field) {
438
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
439
+ const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
440
+ return match?.[1] ?? null;
441
+ }
442
+ function selfUpdateFailureCode(error) {
443
+ const record = asRecord(error);
444
+ if (record && record["eacces"] === true)
445
+ return "eacces_needs_chown";
446
+ return "npm_install_failed";
447
+ }
448
+ function selfUpdateFailureMessage(error) {
449
+ const record = asRecord(error);
450
+ const stderr = asRecord(record?.["result"])?.["stderr"] &&
451
+ typeof asRecord(record?.["result"])?.["stderr"] === "string"
452
+ ? String(asRecord(record?.["result"])?.["stderr"])
453
+ : "";
454
+ if (record?.["eacces"] === true) {
455
+ const prefix = npmPrefixFromError(stderr);
456
+ return [
457
+ "npm global install hit a permissions problem.",
458
+ "What you can do:",
459
+ ` 1) Fix npm ownership once: sudo chown -R $(whoami) ${prefix}/lib/node_modules/@bli-cockpit ${prefix}/bin/cockpit`,
460
+ " 2) No sudo? Send this output to Edward.",
461
+ " 3) Do not use `sudo npm i -g`; it makes the ownership problem come back.",
462
+ ].join("\n");
463
+ }
464
+ return "npm install failed; Cockpit CLI was not refreshed.";
465
+ }
466
+ function npmPrefixFromError(stderr) {
467
+ if (stderr.includes("/usr/local/"))
468
+ return "/usr/local";
469
+ if (stderr.includes("/opt/homebrew/"))
470
+ return "/opt/homebrew";
471
+ const nvm = stderr.match(/(\/Users\/[^/\s]+\/\.nvm\/versions\/node\/[^/\s]+)/u);
472
+ return nvm?.[1] ?? "/opt/homebrew";
473
+ }
474
+ function onboardOneLiner(command) {
475
+ const workspace = command.repoRoot ?? "$PWD";
476
+ const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
477
+ ? ""
478
+ : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
479
+ return `cockpit onboard --workspace ${shellQuote(workspace)}${dashboard}`;
480
+ }
481
+ function shellQuote(value) {
482
+ if (value === "$PWD")
483
+ return '"$PWD"';
484
+ return `'${value.replace(/'/gu, "'\\''")}'`;
485
+ }
486
+ function sanitizeEventCode(value) {
487
+ return (value
488
+ .trim()
489
+ .toLowerCase()
490
+ .replace(/[^a-z0-9_]+/gu, "_")
491
+ .replace(/^_+|_+$/gu, "")
492
+ .slice(0, 120) || "unknown");
493
+ }
494
+ function oneLine(value) {
495
+ return value.split("\n")[0] ?? value;
496
+ }
497
+ function asRecord(value) {
498
+ return value && typeof value === "object" ? value : null;
499
+ }
500
+ function writeLine(stream, text) {
501
+ stream.write(`${text}\n`);
502
+ }
@@ -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: [
@@ -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,11 @@ 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
+ });
61
69
  case "login":
62
70
  return await runLogin(command, io);
63
71
  case "logout":
@@ -94,6 +102,8 @@ export function localCommandHelp(command) {
94
102
  " 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
103
  " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
96
104
  " cockpit upgrade [same flags as update]",
105
+ " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
106
+ " cockpit fix [same flags as do-everything]",
97
107
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
98
108
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
109
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
@@ -156,6 +166,24 @@ function localSubcommandHelp(command) {
156
166
  "Alias for `cockpit update`.",
157
167
  ],
158
168
  ],
169
+ [
170
+ "do-everything",
171
+ [
172
+ "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
173
+ "",
174
+ "Converges an already-onboarded intern machine: latest CLI, auth, saved roots, autostart, backfill, raw-evidence GC, and sync freshness.",
175
+ "`cockpit fix` is an alias.",
176
+ "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
177
+ ],
178
+ ],
179
+ [
180
+ "fix",
181
+ [
182
+ "Usage: cockpit fix [same flags as cockpit do-everything]",
183
+ "",
184
+ "Alias for `cockpit do-everything`.",
185
+ ],
186
+ ],
159
187
  [
160
188
  "login",
161
189
  [
@@ -359,37 +387,31 @@ async function runUpdate(command, io) {
359
387
  return code;
360
388
  };
361
389
  const exec = io.exec ?? defaultExec();
362
- const installArgs = [
363
- "install",
364
- "-g",
365
- "@bli-cockpit/cli@latest",
366
- "--prefer-online",
367
- ];
368
- if (!command.json) {
369
- writeLine(io.stdout, "Updating Cockpit CLI from npm...");
390
+ try {
391
+ await runSelfUpdate(io, { json: command.json });
370
392
  }
371
- const install = await exec("npm", installArgs);
372
- writeExecOutput(io, install, { stdout: !command.json, stderr: true });
373
- if (install.code !== 0) {
374
- addInstallEvent(installEvents, "npm_install", "fail", isNpmEaccesFailure(install.stderr)
393
+ catch (error) {
394
+ if (!(error instanceof SelfUpdateError))
395
+ throw error;
396
+ addInstallEvent(installEvents, "npm_install", "fail", error.eacces
375
397
  ? "npm_install_eacces"
376
398
  : "npm_install_failed");
377
399
  if (command.json) {
378
400
  writeLine(io.stdout, JSON.stringify({
379
401
  status: "blocked",
380
402
  step: "npm_install",
381
- command: `npm ${installArgs.join(" ")}`,
382
- exit_code: install.code,
403
+ command: `npm ${SELF_UPDATE_INSTALL_ARGS.join(" ")}`,
404
+ exit_code: error.result.code,
383
405
  }, null, 2));
384
406
  }
385
407
  else {
386
408
  writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
387
- if (isNpmEaccesFailure(install.stderr)) {
409
+ if (error.eacces) {
388
410
  writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
389
411
  writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
390
412
  }
391
413
  }
392
- return finish(install.code || 1);
414
+ return finish(error.result.code || 1);
393
415
  }
394
416
  addInstallEvent(installEvents, "npm_install", "ok");
395
417
  if (!command.json) {
@@ -403,6 +425,34 @@ async function runUpdate(command, io) {
403
425
  addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
404
426
  return finish(onboard.code);
405
427
  }
428
+ const SELF_UPDATE_INSTALL_ARGS = [
429
+ "install",
430
+ "-g",
431
+ "@bli-cockpit/cli@latest",
432
+ "--prefer-online",
433
+ ];
434
+ export class SelfUpdateError extends Error {
435
+ result;
436
+ eacces;
437
+ constructor(result) {
438
+ super("npm install failed; Cockpit CLI was not refreshed.");
439
+ this.name = "SelfUpdateError";
440
+ this.result = result;
441
+ this.eacces = isNpmEaccesFailure(result.stderr);
442
+ }
443
+ }
444
+ export async function runSelfUpdate(io, options = {}) {
445
+ const exec = io.exec ?? defaultExec();
446
+ if (!options.json) {
447
+ writeLine(io.stdout, "Updating Cockpit CLI from npm...");
448
+ }
449
+ const install = await exec("npm", [...SELF_UPDATE_INSTALL_ARGS]);
450
+ writeExecOutput(io, install, { stdout: !options.json, stderr: true });
451
+ if (install.code !== 0) {
452
+ throw new SelfUpdateError(install);
453
+ }
454
+ return { updated: true, version: LOCAL_COLLECTOR_VERSION };
455
+ }
406
456
  async function runRelease(command, io) {
407
457
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
408
458
  if (!releaseRoot) {
@@ -600,7 +650,7 @@ function sanitizeInstallErrorCode(value) {
600
650
  .slice(0, 120);
601
651
  return normalized || "unknown";
602
652
  }
603
- async function reportInstallEventsBestEffort(options) {
653
+ export async function reportInstallEventsBestEffort(options) {
604
654
  if (options.events.length === 0)
605
655
  return;
606
656
  const paths = getCollectorRuntimePaths(options.homeDir);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {