@bli-cockpit/cli 0.2.5 → 0.2.7

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.
@@ -1,13 +1,13 @@
1
- import { spawn } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { autostartStatus, installAutostartAgent } from "../autostart.js";
5
4
  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";
5
+ import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
6
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
8
7
  import { normalizeCollectionRoots } from "../root-normalization.js";
9
8
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
10
9
  import { runBackfillCommand } from "./backfill.js";
10
+ import { createInteractiveExecRunner } from "../process-runner.js";
11
11
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
12
  export async function runDoctor(command, io, hooks, overrides = {}) {
13
13
  const deps = { ...defaultDoctorDeps(hooks), ...overrides };
@@ -123,7 +123,10 @@ async function checkCliLatest(context) {
123
123
  }
124
124
  async function fixCliLatest(context, state) {
125
125
  try {
126
- await context.deps.selfUpdate(context.io, { json: context.command.json });
126
+ await context.deps.selfUpdate(context.io, {
127
+ json: context.command.json,
128
+ tag: context.command.updateTag,
129
+ });
127
130
  }
128
131
  catch (error) {
129
132
  return fail("cli-latest", selfUpdateFailureCode(error), selfUpdateFailureMessage(error));
@@ -156,7 +159,10 @@ async function latestCliVersionFromNpm(context) {
156
159
  const exec = context.io.exec;
157
160
  if (!exec)
158
161
  return null;
159
- const result = await exec("npm", ["view", "@bli-cockpit/cli", "version", "--json"]);
162
+ const packageSpec = context.command.updateTag
163
+ ? `@bli-cockpit/cli@${context.command.updateTag}`
164
+ : "@bli-cockpit/cli";
165
+ const result = await exec("npm", ["view", packageSpec, "version", "--json"]);
160
166
  if (result.code !== 0)
161
167
  return null;
162
168
  return parseNpmVersion(result.stdout);
@@ -198,11 +204,17 @@ async function readRootState(context) {
198
204
  async function checkAutostartState(context) {
199
205
  const exec = context.io.exec;
200
206
  if (!exec) {
201
- return needsFix("autostart-alive", "runner_unavailable", "launchd runner unavailable; would refresh autostart");
207
+ return needsFix("autostart-alive", "runner_unavailable", "autostart runner unavailable; would refresh autostart");
202
208
  }
203
- const result = await autostartStatus({ exec });
209
+ const roots = await doctorRoots(context);
210
+ const result = await autostartStatus({
211
+ repoRoot: roots[0],
212
+ repoRoots: roots,
213
+ dashboardUrl: context.command.dashboardUrl,
214
+ exec,
215
+ });
204
216
  if (result.status === "loaded") {
205
- return ok("autostart-alive", "already_installed", "launchd agent loaded");
217
+ return ok("autostart-alive", "already_installed", "autostart scheduler loaded");
206
218
  }
207
219
  if (result.status === "unsupported") {
208
220
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
@@ -212,7 +224,7 @@ async function checkAutostartState(context) {
212
224
  async function fixAutostartState(context) {
213
225
  const exec = context.io.exec;
214
226
  if (!exec) {
215
- return fail("autostart-alive", "runner_unavailable", "launchd runner unavailable");
227
+ return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
216
228
  }
217
229
  const roots = await savedRoots();
218
230
  const result = await installAutostartAgent({
@@ -225,18 +237,20 @@ async function fixAutostartState(context) {
225
237
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
226
238
  }
227
239
  if (result.loaded === false) {
228
- return fail("autostart-alive", "autostart_load_failed", result.message ?? "launchctl load failed");
240
+ return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
229
241
  }
230
242
  return ok("autostart-alive", "installed", "autostart installed and loaded");
231
243
  }
232
- async function checkBackfillState(_context) {
244
+ async function checkBackfillState(context) {
233
245
  const paths = getCollectorRuntimePaths();
234
- if (await hasBackfillCompletionMarker(paths)) {
235
- return ok("backfill-complete", "complete", "backfill completion marker exists");
246
+ const roots = await doctorRoots(context);
247
+ const marker = await readBackfillCompletionMarker(paths);
248
+ if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
249
+ return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
236
250
  }
237
251
  const lock = await inspectBackfillLock(paths);
238
252
  if (lock.held) {
239
- return skipped("backfill-complete", "backfill_already_running", `backfill already running since ${lock.held_since ?? "unknown"}`);
253
+ return needsFix("backfill-complete", "backfill_already_running", `backfill completion is not yet proven; another run holds the lock since ${lock.held_since ?? "unknown"}`);
240
254
  }
241
255
  const cursor = await readBackfillCursor(paths);
242
256
  return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "backfill completion marker missing");
@@ -256,7 +270,7 @@ async function fixBackfillState(context) {
256
270
  }
257
271
  const reason = jsonField(output, "failure_reason");
258
272
  if (reason === "backfill_already_running") {
259
- return skipped("backfill-complete", "backfill_already_running", "backfill lock held; skipping as healthy");
273
+ return fail("backfill-complete", "backfill_already_running", "backfill lock is still held and no scope-valid completion marker exists");
260
274
  }
261
275
  return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
262
276
  }
@@ -283,36 +297,46 @@ async function fixGcState(context) {
283
297
  return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
284
298
  }
285
299
  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");
300
+ const roots = await doctorRoots(context);
301
+ if (roots.length === 0) {
302
+ return needsFix("sync-fresh", "no_roots", "sync has no saved workspace roots");
291
303
  }
292
- return needsFix("sync-fresh", "stale", "last sync is stale or missing");
304
+ // Upload state is currently device-global, so it cannot prove that every
305
+ // saved root succeeded. Doctor therefore performs one explicit sync per root
306
+ // and only turns green from those command receipts.
307
+ return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
293
308
  }
294
309
  async function fixSyncState(context) {
295
310
  const exec = context.io.exec;
296
311
  if (!exec)
297
312
  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`");
313
+ const roots = await doctorRoots(context);
314
+ if (roots.length === 0) {
315
+ return fail("sync-fresh", "no_roots", "sync has no saved workspace roots");
316
+ }
317
+ for (const repoRoot of roots) {
318
+ const args = ["sync", "--json", "--workspace", repoRoot];
319
+ if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
320
+ args.push("--dashboard-url", context.command.dashboardUrl);
321
+ }
322
+ const result = await exec("cockpit", args);
323
+ const output = `${result.stdout}\n${result.stderr}`;
324
+ const status = jsonField(output, "status");
325
+ if (result.code !== 0) {
326
+ return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
327
+ }
328
+ if (status !== "uploaded") {
329
+ return fail("sync-fresh", status ?? "sync_unverified", `sync did not return an uploaded receipt for ${repoRoot}`);
330
+ }
314
331
  }
315
- return fail("sync-fresh", status ?? "sync_failed", "sync failed");
332
+ return ok("sync-fresh", "synced", roots.length === 1
333
+ ? "ran `cockpit sync` and received an uploaded receipt"
334
+ : `received uploaded receipts for ${roots.length} saved roots`);
335
+ }
336
+ async function doctorRoots(context) {
337
+ if (context.command.repoRoot)
338
+ return [context.command.repoRoot];
339
+ return savedRoots();
316
340
  }
317
341
  async function maybeReportDoctorEvents(context, rows) {
318
342
  if (context.command.dryRun)
@@ -398,16 +422,6 @@ async function savedRoots() {
398
422
  const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
399
423
  return normalizeCollectionRoots(config?.default_repo_paths ?? []);
400
424
  }
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
425
  function parseNpmVersion(stdout) {
412
426
  const trimmed = stdout.trim();
413
427
  if (!trimmed)
@@ -427,20 +441,18 @@ function reexecDoctor(command, io) {
427
441
  if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
428
442
  args.push("--dashboard-url", command.dashboardUrl);
429
443
  }
444
+ if (command.updateTag)
445
+ args.push("--update-tag", command.updateTag);
430
446
  if (command.json)
431
447
  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
- });
448
+ const exec = io.interactiveExec ?? createInteractiveExecRunner();
449
+ return exec("cockpit", args, {
450
+ env: {
451
+ ...process.env,
452
+ ...io.env,
453
+ COCKPIT_DOCTOR_REEXEC: "1",
454
+ },
455
+ }).then((result) => result.code);
444
456
  }
445
457
  function capturedIo(io, forward) {
446
458
  const stdoutChunks = [];
@@ -115,15 +115,26 @@ function parseUpdateArgs(alias, args) {
115
115
  }
116
116
  function parseDoctorArgs(alias, args) {
117
117
  const values = parseNamedArgs(args, {
118
- allowedFlags: ["--workspace", "--dashboard-url", "--dry-run", "--json"],
119
- valueFlags: ["--workspace", "--dashboard-url"],
118
+ allowedFlags: [
119
+ "--workspace",
120
+ "--dashboard-url",
121
+ "--update-tag",
122
+ "--dry-run",
123
+ "--json",
124
+ ],
125
+ valueFlags: ["--workspace", "--dashboard-url", "--update-tag"],
120
126
  });
121
127
  assertNoPositionals(values.positionals, alias);
128
+ const updateTag = optionalNonEmpty(values.flags.get("--update-tag"));
129
+ if (updateTag && !/^[a-z0-9][a-z0-9._-]*$/u.test(updateTag)) {
130
+ throw new Error("--update-tag must be a lowercase npm dist-tag such as 'next' or 'latest'.");
131
+ }
122
132
  return {
123
133
  kind: "doctor",
124
134
  alias,
125
135
  repoRoot: optionalNonEmpty(values.flags.get("--workspace")),
126
136
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
137
+ updateTag,
127
138
  dryRun: values.booleans.has("--dry-run"),
128
139
  json: values.booleans.has("--json"),
129
140
  };
@@ -351,6 +362,8 @@ function parseBackfillArgs(args) {
351
362
  "--source",
352
363
  "--dry-run",
353
364
  "--max-files",
365
+ "--max-depth",
366
+ "--max-repos",
354
367
  "--yes",
355
368
  "--json",
356
369
  ],
@@ -361,6 +374,8 @@ function parseBackfillArgs(args) {
361
374
  "--since-days",
362
375
  "--source",
363
376
  "--max-files",
377
+ "--max-depth",
378
+ "--max-repos",
364
379
  ],
365
380
  });
366
381
  assertNoPositionals(values.positionals, "backfill");
@@ -382,6 +397,8 @@ function parseBackfillArgs(args) {
382
397
  all,
383
398
  dryRun: values.booleans.has("--dry-run"),
384
399
  maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
400
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
401
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
385
402
  yes: values.booleans.has("--yes"),
386
403
  json: values.booleans.has("--json"),
387
404
  };