@bli-cockpit/cli 0.2.0 → 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 +12 -2
- package/dist/commands/doctor.js +502 -0
- package/dist/commands/local-args.js +22 -0
- package/dist/commands/local.js +351 -44
- package/dist/commands/public-root.js +1 -0
- package/dist/onboarding-roots.js +183 -18
- package/dist/raw-evidence-gc.js +122 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,13 @@ Ticket: general ambient
|
|
|
19
19
|
What's your @buildlaunchiterate.ca email? (press enter to skip): ian@buildlaunchiterate.ca
|
|
20
20
|
Signing in as ian@buildlaunchiterate.ca.
|
|
21
21
|
Code sent; valid 1h, resend in 60s by rerunning this command.
|
|
22
|
-
|
|
22
|
+
Email code needed:
|
|
23
|
+
Check your latest Cockpit email for a 6- to 10-digit code.
|
|
24
|
+
What you can do:
|
|
25
|
+
1) Paste the code here.
|
|
26
|
+
2) No code yet: wait for the resend window, then rerun this command.
|
|
27
|
+
3) Can't use email: rerun with --no-auth for manual approval.
|
|
28
|
+
Code: 482913
|
|
23
29
|
Signed in as ian@buildlaunchiterate.ca.
|
|
24
30
|
2/5 Device paired.
|
|
25
31
|
PASS: Cockpit collector is ready for harvest.
|
|
@@ -39,12 +45,13 @@ The OTP proves you own an approved BLI mailbox. The JWT is used once to register
|
|
|
39
45
|
|
|
40
46
|
## Every command, what it does, and why it's called that
|
|
41
47
|
|
|
42
|
-
You only need
|
|
48
|
+
You only need `onboard` once and `do-everything` whenever Edward asks the fleet to converge. The rest exist for
|
|
43
49
|
recovery and maintenance.
|
|
44
50
|
|
|
45
51
|
| Command | What it does | Why it exists / why this name |
|
|
46
52
|
|---|---|---|
|
|
47
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. |
|
|
48
55
|
| `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
|
|
49
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. |
|
|
50
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. |
|
|
@@ -76,11 +83,14 @@ cockpit onboard --no-auth
|
|
|
76
83
|
|
|
77
84
|
- `--email` skips the email prompt.
|
|
78
85
|
- `--workspace` pins one collection root; repeat it for multiple unrelated roots.
|
|
86
|
+
- `--allow-home-root` deliberately collects your whole home folder. Use it only on a company machine where that is intended.
|
|
79
87
|
- `--device-name` changes only the human label shown in Cockpit.
|
|
80
88
|
- `--dashboard-url` is for staging/custom dashboards only. Production is the default.
|
|
81
89
|
- `--no-auth` forces the old manual approval queue.
|
|
82
90
|
- `--repo` still works as a legacy alias for `--workspace`.
|
|
83
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
|
+
|
|
84
94
|
`cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
|
|
85
95
|
|
|
86
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":
|
|
@@ -61,6 +64,7 @@ function parseOnboardLikeArgs(args, command) {
|
|
|
61
64
|
"--timeout-ms",
|
|
62
65
|
"--max-depth",
|
|
63
66
|
"--max-repos",
|
|
67
|
+
"--allow-home-root",
|
|
64
68
|
],
|
|
65
69
|
valueFlags: [
|
|
66
70
|
"--home",
|
|
@@ -94,6 +98,7 @@ function parseOnboardLikeArgs(args, command) {
|
|
|
94
98
|
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
95
99
|
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
96
100
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
101
|
+
allowHomeRoot: values.booleans.has("--allow-home-root"),
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
function parseOnboardArgs(args) {
|
|
@@ -106,6 +111,21 @@ function parseUpdateArgs(alias, args) {
|
|
|
106
111
|
...parseOnboardLikeArgs(args, alias),
|
|
107
112
|
};
|
|
108
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
|
+
}
|
|
109
129
|
function parseInstallArgs(args) {
|
|
110
130
|
const values = parseNamedArgs(args, {
|
|
111
131
|
allowedFlags: [
|
|
@@ -115,6 +135,7 @@ function parseInstallArgs(args) {
|
|
|
115
135
|
"--dashboard-url",
|
|
116
136
|
"--supabase-url",
|
|
117
137
|
"--json",
|
|
138
|
+
"--allow-home-root",
|
|
118
139
|
],
|
|
119
140
|
valueFlags: [
|
|
120
141
|
"--home",
|
|
@@ -132,6 +153,7 @@ function parseInstallArgs(args) {
|
|
|
132
153
|
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
133
154
|
supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
|
|
134
155
|
json: values.booleans.has("--json"),
|
|
156
|
+
allowHomeRoot: values.booleans.has("--allow-home-root"),
|
|
135
157
|
};
|
|
136
158
|
}
|
|
137
159
|
function parseReleaseArgs(args) {
|