@bli-cockpit/cli 0.2.100 → 0.2.101
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/dist/agent-rules.js +2 -1
- package/dist/backfill-lock.js +1 -1
- package/dist/commands/doctor-access.js +34 -10
- package/dist/commands/doctor-lock-wait.js +46 -0
- package/dist/commands/doctor-pipeline.js +18 -10
- package/dist/commands/doctor-registration.js +23 -2
- package/dist/commands/doctor-report.js +48 -9
- package/dist/commands/doctor-update.js +16 -5
- package/dist/commands/doctor.js +96 -56
- package/dist/commands/local-args-collector-setup.js +6 -0
- package/dist/commands/local-args-tower-usage.js +2 -2
- package/dist/commands/local-help-commands-tower.js +3 -2
- package/dist/commands/local-help-commands.js +21 -11
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +1 -0
- package/dist/commands/public-root.js +4 -4
- package/dist/commands/usage-format.js +18 -0
- package/dist/commands/usage.js +13 -3
- package/dist/scheduled-self-update.js +1 -1
- package/dist/sync-lock.js +5 -0
- package/package.json +2 -2
package/dist/agent-rules.js
CHANGED
|
@@ -141,13 +141,14 @@ export function cockpitAgentRulesBlock(options = {}) {
|
|
|
141
141
|
? `- Only applies when the current working directory is inside the Tower-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
|
|
142
142
|
: scopePaths.length > 1
|
|
143
143
|
? `- Only applies when the current working directory is inside one of these Tower-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
|
|
144
|
-
: "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit
|
|
144
|
+
: "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit doctor` or `cockpit agent-rules install`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.";
|
|
145
145
|
return [
|
|
146
146
|
MANAGED_BLOCK_START,
|
|
147
147
|
"## Tower Ticket Binding",
|
|
148
148
|
"",
|
|
149
149
|
scopeLine,
|
|
150
150
|
"- Ticketed work (implement / debug / review / PR / ship): run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit (the flag is `--ticket`). No ticket ID visible → search Linear first; none exists and the work is ticket-worthy → create a narrow Linear ticket, then bind.",
|
|
151
|
+
"- If setup or collection needs repair, run `cockpit doctor`. It repairs this machine and prints any remaining action.",
|
|
151
152
|
"- Truly no ticket → say the session stays in general ambient capture; never invent one.",
|
|
152
153
|
"- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json`.",
|
|
153
154
|
"",
|
package/dist/backfill-lock.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function inspectBackfillLock(paths, now = new Date()) {
|
|
|
54
54
|
now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
55
55
|
return { held: false, held_since: null };
|
|
56
56
|
}
|
|
57
|
-
return { held: true, held_since: record.heartbeat_at };
|
|
57
|
+
return { held: true, held_since: record.heartbeat_at, pid: record.pid };
|
|
58
58
|
}
|
|
59
59
|
export function backfillLockPath(paths) {
|
|
60
60
|
return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
|
|
2
|
+
import { isInteractiveDoctorFix } from "./doctor-report.js";
|
|
1
3
|
import { describeError } from "../health-detail.js";
|
|
2
|
-
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
|
|
3
5
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
4
6
|
import { detectSecondCockpitInstall } from "../second-install.js";
|
|
5
7
|
import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
|
|
@@ -12,6 +14,21 @@ import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
|
|
|
12
14
|
* operator decide.
|
|
13
15
|
*/
|
|
14
16
|
export async function fixAuthState(context, state) {
|
|
17
|
+
const renewed = await sendCollectorHeartbeatBestEffort({
|
|
18
|
+
homeDir: context.command.homeDir,
|
|
19
|
+
dashboardUrl: context.command.dashboardUrl,
|
|
20
|
+
roots: await doctorRoots(context),
|
|
21
|
+
facts: { status: "skipped", reason: "doctor_auth_renewal" },
|
|
22
|
+
io: context.io,
|
|
23
|
+
});
|
|
24
|
+
if (renewed) {
|
|
25
|
+
context.authVerified = true;
|
|
26
|
+
const checked = await context.deps.readAuth(context);
|
|
27
|
+
if (checked.status === "ok")
|
|
28
|
+
return checked;
|
|
29
|
+
}
|
|
30
|
+
if (!isInteractiveDoctorFix(context))
|
|
31
|
+
return { ...state, nextAction: "cockpit login" };
|
|
15
32
|
const code = await context.deps.runLogin(context).catch((error) => {
|
|
16
33
|
// Exit code 1 with no reason at all is what an operator saw when doctor
|
|
17
34
|
// tried and failed to repair their auth — the same output as a login that
|
|
@@ -24,6 +41,7 @@ export async function fixAuthState(context, state) {
|
|
|
24
41
|
});
|
|
25
42
|
if (code !== 0)
|
|
26
43
|
return state;
|
|
44
|
+
context.authVerified = true;
|
|
27
45
|
const checked = await context.deps.readAuth(context);
|
|
28
46
|
return checked.status === "ok" ? checked : state;
|
|
29
47
|
}
|
|
@@ -42,11 +60,14 @@ export async function fixRootState(context, state) {
|
|
|
42
60
|
return checked.status === "ok" ? checked : state;
|
|
43
61
|
}
|
|
44
62
|
export async function readAuthState(context) {
|
|
45
|
-
const paths = getCollectorRuntimePaths();
|
|
63
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
46
64
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
47
|
-
if (session
|
|
65
|
+
if (session && toSessionReference(session).session_state === "valid" &&
|
|
48
66
|
typeof session.device_token === "string" &&
|
|
49
67
|
session.device_token) {
|
|
68
|
+
if (!context.command.checkOnly && !context.command.dryRun && !context.authVerified) {
|
|
69
|
+
return needsFix("authed", "token_validation_required", "checking that the saved device token is accepted");
|
|
70
|
+
}
|
|
50
71
|
return ok("authed", "device_token_present", "this machine is signed in");
|
|
51
72
|
}
|
|
52
73
|
return hardStop("authed", "pairing_required", [
|
|
@@ -58,10 +79,11 @@ export async function readAuthState(context) {
|
|
|
58
79
|
].join("\n"));
|
|
59
80
|
}
|
|
60
81
|
export async function readRootState(context) {
|
|
61
|
-
const paths = getCollectorRuntimePaths();
|
|
82
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
62
83
|
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
63
84
|
const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
64
|
-
|
|
85
|
+
const requested = normalizeCollectionRoots(context.command.collectionRoots ?? (context.command.repoRoot ? [context.command.repoRoot] : []));
|
|
86
|
+
if (roots.length > 0 && requested.every((root) => roots.includes(root))) {
|
|
65
87
|
return {
|
|
66
88
|
...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
|
|
67
89
|
roots,
|
|
@@ -72,7 +94,7 @@ export async function readRootState(context) {
|
|
|
72
94
|
"What you can do:",
|
|
73
95
|
` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
|
|
74
96
|
" 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
|
|
75
|
-
" 3)
|
|
97
|
+
" 3) Run `cockpit doctor` interactively to choose approved roots.",
|
|
76
98
|
].join("\n"));
|
|
77
99
|
}
|
|
78
100
|
/**
|
|
@@ -107,12 +129,14 @@ export async function checkSingleInstallState(context) {
|
|
|
107
129
|
].join("\n"));
|
|
108
130
|
}
|
|
109
131
|
export async function doctorRoots(context) {
|
|
132
|
+
if (context.command.collectionRoots?.length)
|
|
133
|
+
return context.command.collectionRoots;
|
|
110
134
|
if (context.command.repoRoot)
|
|
111
135
|
return [context.command.repoRoot];
|
|
112
|
-
return savedRoots();
|
|
136
|
+
return savedRoots(context.command.homeDir);
|
|
113
137
|
}
|
|
114
|
-
export async function savedRoots() {
|
|
115
|
-
const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
|
|
138
|
+
export async function savedRoots(homeDir) {
|
|
139
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
116
140
|
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
117
141
|
}
|
|
118
142
|
function onboardOneLiner(command) {
|
|
@@ -120,7 +144,7 @@ function onboardOneLiner(command) {
|
|
|
120
144
|
const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
121
145
|
? ""
|
|
122
146
|
: ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
|
|
123
|
-
return `cockpit
|
|
147
|
+
return `cockpit doctor --workspace ${shellQuote(workspace)}${dashboard}`;
|
|
124
148
|
}
|
|
125
149
|
function shellQuote(value) {
|
|
126
150
|
if (value === "$PWD")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
2
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
3
|
+
import { inspectSyncLock } from "../sync-lock.js";
|
|
4
|
+
const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
|
|
5
|
+
/** Retry the operation itself: its existing exclusive lock acquisition owns the
|
|
6
|
+
* handoff. Merely observing a free lock never grants permission to collect. */
|
|
7
|
+
export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
8
|
+
const started = timing.now();
|
|
9
|
+
const limit = (context.command.lockWaitSeconds ?? 600) * 1000;
|
|
10
|
+
let nextProgress = 0;
|
|
11
|
+
let row = await run();
|
|
12
|
+
while (BUSY.has(row.code)) {
|
|
13
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
14
|
+
const sync = await inspectSyncLock(paths);
|
|
15
|
+
const backfill = await inspectBackfillLock(paths);
|
|
16
|
+
const owner = backfill.held
|
|
17
|
+
? { pid: backfill.pid, heartbeat_at: backfill.held_since }
|
|
18
|
+
: sync;
|
|
19
|
+
const elapsed = timing.now() - started;
|
|
20
|
+
if (elapsed >= limit) {
|
|
21
|
+
const pid = owner?.pid;
|
|
22
|
+
let name = "unknown process";
|
|
23
|
+
if (pid && pid > 0 && context.io.exec) {
|
|
24
|
+
const result = process.platform === "win32"
|
|
25
|
+
? await context.io.exec("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"])
|
|
26
|
+
: await context.io.exec("ps", ["-p", String(pid), "-o", "comm="]);
|
|
27
|
+
if (result.code === 0 && result.stdout.trim())
|
|
28
|
+
name = result.stdout.trim().split("\n")[0] ?? name;
|
|
29
|
+
}
|
|
30
|
+
return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
|
|
31
|
+
}
|
|
32
|
+
if (elapsed >= nextProgress) {
|
|
33
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
34
|
+
console.error(`[doctor] ${row.id} waiting`, JSON.stringify({ reason: row.code, owner_pid: owner?.pid ?? null, heartbeat_at: owner?.heartbeat_at ?? null, elapsed_seconds: seconds }));
|
|
35
|
+
context.io.stderr.write(`waiting for the background sync to finish, ${Math.floor(seconds / 60)}m${seconds % 60}s\n`);
|
|
36
|
+
nextProgress = elapsed + 30_000;
|
|
37
|
+
}
|
|
38
|
+
await timing.sleep(Math.min(1000, limit - elapsed));
|
|
39
|
+
// A live heartbeat means there is no point spawning a collector yet.
|
|
40
|
+
const currentSync = await inspectSyncLock(paths);
|
|
41
|
+
const currentBackfill = await inspectBackfillLock(paths);
|
|
42
|
+
if (!currentSync?.held && !currentBackfill.held)
|
|
43
|
+
row = await run();
|
|
44
|
+
}
|
|
45
|
+
return row;
|
|
46
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withDoctorLockWait } from "./doctor-lock-wait.js";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
@@ -32,7 +33,7 @@ import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
|
32
33
|
*/
|
|
33
34
|
const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
34
35
|
export async function checkBackfillState(context) {
|
|
35
|
-
const paths = getCollectorRuntimePaths();
|
|
36
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
36
37
|
const roots = await doctorRoots(context);
|
|
37
38
|
const marker = await readBackfillCompletionMarker(paths);
|
|
38
39
|
const covered = backfillCompletionStepState(marker, roots);
|
|
@@ -46,8 +47,12 @@ export async function checkBackfillState(context) {
|
|
|
46
47
|
return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
|
|
47
48
|
}
|
|
48
49
|
export async function fixBackfillState(context) {
|
|
50
|
+
return withDoctorLockWait(context, () => runBackfillRepair(context));
|
|
51
|
+
}
|
|
52
|
+
async function runBackfillRepair(context) {
|
|
49
53
|
const capture = capturedIo(context.io, !context.command.json);
|
|
50
54
|
const code = await runBackfillCommand({
|
|
55
|
+
homeDir: context.command.homeDir,
|
|
51
56
|
repoRoot: context.command.repoRoot,
|
|
52
57
|
all: true,
|
|
53
58
|
dryRun: false,
|
|
@@ -64,7 +69,7 @@ export async function fixBackfillState(context) {
|
|
|
64
69
|
const recheck = await checkBackfillState(context);
|
|
65
70
|
if (recheck.status === "ok")
|
|
66
71
|
return recheck;
|
|
67
|
-
return
|
|
72
|
+
return recheck;
|
|
68
73
|
}
|
|
69
74
|
const verdict = backfillFixVerdict(parseDoctorBackfillJson(stdout), jsonField(output, "failure_reason"));
|
|
70
75
|
console.error("[cockpit-doctor] catch-up run did not finish", JSON.stringify({ reason: verdict.code, row_status: verdict.status, exit_code: code }));
|
|
@@ -74,7 +79,7 @@ export async function checkGcState(context) {
|
|
|
74
79
|
if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
|
|
75
80
|
return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
|
|
76
81
|
}
|
|
77
|
-
const paths = getCollectorRuntimePaths();
|
|
82
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
78
83
|
const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
|
|
79
84
|
const info = await fs.stat(marker).catch(() => null);
|
|
80
85
|
if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
|
|
@@ -83,7 +88,7 @@ export async function checkGcState(context) {
|
|
|
83
88
|
return needsFix("gc-checked", "due", "cleanup is due");
|
|
84
89
|
}
|
|
85
90
|
export async function fixGcState(context) {
|
|
86
|
-
const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
|
|
91
|
+
const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(context.command.homeDir), context.io.env);
|
|
87
92
|
if (result.skipped) {
|
|
88
93
|
return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
|
|
89
94
|
}
|
|
@@ -190,6 +195,9 @@ export async function checkSyncState(context) {
|
|
|
190
195
|
return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
|
|
191
196
|
}
|
|
192
197
|
export async function fixSyncState(context) {
|
|
198
|
+
return withDoctorLockWait(context, () => runSyncRepair(context));
|
|
199
|
+
}
|
|
200
|
+
async function runSyncRepair(context) {
|
|
193
201
|
const exec = context.io.exec;
|
|
194
202
|
if (!exec)
|
|
195
203
|
return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
|
|
@@ -203,6 +211,8 @@ export async function fixSyncState(context) {
|
|
|
203
211
|
const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
|
|
204
212
|
for (const repoRoot of roots) {
|
|
205
213
|
const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
|
|
214
|
+
if (context.command.homeDir)
|
|
215
|
+
args.push("--home", context.command.homeDir);
|
|
206
216
|
if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
|
|
207
217
|
args.push("--dashboard-url", context.command.dashboardUrl);
|
|
208
218
|
}
|
|
@@ -220,12 +230,10 @@ export async function fixSyncState(context) {
|
|
|
220
230
|
owner_alive: standAside.ownerAlive,
|
|
221
231
|
held_since: standAside.heldSince,
|
|
222
232
|
}));
|
|
223
|
-
return standAside.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
`reporting (last heartbeat ${standAside.heldSince ?? "unknown"}); ` +
|
|
228
|
-
"rerun `cockpit doctor` — the next sync takes the lock over");
|
|
233
|
+
return needsFix("sync-fresh", standAside.code, "waiting for the collection lock");
|
|
234
|
+
}
|
|
235
|
+
if (status === "live_sync_paused_during_backfill") {
|
|
236
|
+
return needsFix("sync-fresh", status, "waiting for historical catch-up");
|
|
229
237
|
}
|
|
230
238
|
if (result.code !== 0) {
|
|
231
239
|
const draining = syncBacklogDrainingVerdict(parsed);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
|
|
2
|
+
import { doctorRoots } from "./doctor-access.js";
|
|
1
3
|
import { installAutostartAgent } from "../autostart.js";
|
|
2
4
|
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
3
5
|
import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
|
|
@@ -38,6 +40,8 @@ export async function fixAutostartState(context) {
|
|
|
38
40
|
// fix that registers a different boundary than the check inspected would
|
|
39
41
|
// leave the row red forever (BLI-3793).
|
|
40
42
|
const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
|
|
43
|
+
if (roots.length === 0)
|
|
44
|
+
return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` to choose approved collection roots first.");
|
|
41
45
|
const result = await installAutostartAgent({
|
|
42
46
|
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
43
47
|
repoRoots: roots,
|
|
@@ -67,7 +71,13 @@ export async function checkMemoryState(context) {
|
|
|
67
71
|
return memoryStepState(outcome, "check");
|
|
68
72
|
}
|
|
69
73
|
export async function fixMemoryState(context) {
|
|
70
|
-
|
|
74
|
+
let outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
|
|
75
|
+
if (!outcome.bin_found) {
|
|
76
|
+
// The public CLI declares the memory and Tower MCP packages as dependencies.
|
|
77
|
+
// Reinstall through the existing updater to restore missing package files.
|
|
78
|
+
await context.deps.selfUpdate(context.io, { json: true, tag: context.command.updateTag });
|
|
79
|
+
outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
|
|
80
|
+
}
|
|
71
81
|
return memoryStepState(outcome, "fix");
|
|
72
82
|
}
|
|
73
83
|
function memoryCommandFor(context) {
|
|
@@ -95,7 +105,18 @@ function memoryStepState(outcome, phase) {
|
|
|
95
105
|
return needsFix("memory-registered", "registration_incomplete", `BLI Memory is not registered with ${pending.map((target) => target.target).join(", ")}`);
|
|
96
106
|
}
|
|
97
107
|
if (!outcome.bin_found) {
|
|
98
|
-
return
|
|
108
|
+
return needsFix("memory-registered", "bin_missing", "bli-memory-mcp is not on this machine yet; nothing was written and the next run will try again");
|
|
99
109
|
}
|
|
100
110
|
return ok("memory-registered", phase === "fix" ? "installed" : "already_installed", "BLI Memory is registered with both agent hosts");
|
|
111
|
+
}
|
|
112
|
+
export async function checkDoctorAgentRules(context) {
|
|
113
|
+
const result = await inspectAgentRules({ homeDir: context.command.homeDir, scopePaths: await doctorRoots(context) });
|
|
114
|
+
return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "run `cockpit agent-rules install`");
|
|
115
|
+
}
|
|
116
|
+
export async function fixDoctorAgentRules(context) {
|
|
117
|
+
const roots = await doctorRoots(context);
|
|
118
|
+
if (roots.length === 0)
|
|
119
|
+
return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` to choose approved roots first.");
|
|
120
|
+
await installAgentRules({ homeDir: context.command.homeDir, scopePaths: roots });
|
|
121
|
+
return checkDoctorAgentRules(context);
|
|
101
122
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setupReceiptPieces } from "@bli-cockpit/telemetry-core";
|
|
1
2
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
2
3
|
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
3
4
|
export function ok(id, code, message) {
|
|
@@ -28,9 +29,9 @@ export function isInteractiveDoctorFix(context) {
|
|
|
28
29
|
return !context.command.json && Boolean(context.io.stdin.isTTY);
|
|
29
30
|
}
|
|
30
31
|
export async function maybeReportDoctorEvents(context, rows) {
|
|
31
|
-
if (context.command.dryRun)
|
|
32
|
-
return;
|
|
33
|
-
|
|
32
|
+
if (context.command.dryRun || context.command.checkOnly)
|
|
33
|
+
return null;
|
|
34
|
+
return context.deps.reportInstallEvents({
|
|
34
35
|
dashboardUrl: context.command.dashboardUrl,
|
|
35
36
|
command: "doctor",
|
|
36
37
|
events: rows.map(doctorEvent),
|
|
@@ -40,14 +41,17 @@ export async function maybeReportDoctorEvents(context, rows) {
|
|
|
40
41
|
}
|
|
41
42
|
export function writeDoctorOutput(command, io, rows,
|
|
42
43
|
/** BLI-3731. Absent means it could not be read; doctor says so rather than nothing. */
|
|
43
|
-
setupReceipt) {
|
|
44
|
+
setupReceipt, repairs = []) {
|
|
45
|
+
const needsPerson = doctorNeedsPerson(rows, setupReceipt);
|
|
44
46
|
if (command.json) {
|
|
45
47
|
writeLine(io.stdout, JSON.stringify({
|
|
46
|
-
status:
|
|
48
|
+
status: needsPerson.length > 0
|
|
47
49
|
? "blocked"
|
|
48
50
|
: "pass",
|
|
49
51
|
dry_run: command.dryRun,
|
|
50
52
|
steps: rows,
|
|
53
|
+
repairs,
|
|
54
|
+
needs_person: needsPerson,
|
|
51
55
|
setup_receipt: setupReceipt?.receipt ?? null,
|
|
52
56
|
memory_install: setupReceipt?.memory ?? null,
|
|
53
57
|
}, null, 2));
|
|
@@ -58,13 +62,13 @@ setupReceipt) {
|
|
|
58
62
|
// sentence, not the label (BLI-3194).
|
|
59
63
|
writeLine(io.stdout, "state step result");
|
|
60
64
|
for (const row of rows) {
|
|
61
|
-
writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
|
|
65
|
+
writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message).replaceAll("—", ";")}`);
|
|
62
66
|
}
|
|
63
67
|
const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
|
|
64
68
|
for (const row of explanations) {
|
|
65
69
|
writeLine(io.stderr, "");
|
|
66
70
|
writeLine(io.stderr, `${row.id}:`);
|
|
67
|
-
writeLine(io.stderr, row.message);
|
|
71
|
+
writeLine(io.stderr, row.message.replaceAll("—", ";"));
|
|
68
72
|
}
|
|
69
73
|
// BLI-3731. The invariant table says whether collection is healthy; this
|
|
70
74
|
// says whether the machine is CONNECTED — the browser sign-in, the device
|
|
@@ -75,11 +79,14 @@ setupReceipt) {
|
|
|
75
79
|
for (const line of setupReceipt
|
|
76
80
|
? setupReceiptBlock(setupReceipt, { indent: " " })
|
|
77
81
|
: [" unknown — this machine could not be read this run."]) {
|
|
78
|
-
writeLine(io.stdout, line);
|
|
82
|
+
writeLine(io.stdout, line.replaceAll("—", ";"));
|
|
79
83
|
}
|
|
84
|
+
writeLine(io.stdout, needsPerson.length === 0 ? "Everything is fixed." : `${needsPerson.length} things still need you:`);
|
|
85
|
+
for (const item of needsPerson)
|
|
86
|
+
writeLine(io.stdout, ` ${item.step}: ${item.action}`);
|
|
80
87
|
}
|
|
81
88
|
function doctorEvent(row) {
|
|
82
|
-
const status = row.status === "fail" || row.hardStop
|
|
89
|
+
const status = row.status === "fail" || row.status === "needs_fix" || row.hardStop
|
|
83
90
|
? "fail"
|
|
84
91
|
: row.status === "skipped"
|
|
85
92
|
? "skipped"
|
|
@@ -124,4 +131,36 @@ export function asRecord(value) {
|
|
|
124
131
|
}
|
|
125
132
|
export function writeLine(stream, text) {
|
|
126
133
|
stream.write(`${text}\n`);
|
|
134
|
+
}
|
|
135
|
+
export function doctorNeedsPerson(rows, receipt) {
|
|
136
|
+
const defaults = {
|
|
137
|
+
"cli-latest": "npm install -g @bli-cockpit/cli@latest",
|
|
138
|
+
"authed": "cockpit login",
|
|
139
|
+
"roots-ok": "cockpit doctor",
|
|
140
|
+
"single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
|
|
141
|
+
"autostart-alive": "cockpit autostart install",
|
|
142
|
+
"memory-registered": "cockpit memory install",
|
|
143
|
+
"agent-rules": "cockpit agent-rules install",
|
|
144
|
+
"mcp-answers": "cockpit doctor",
|
|
145
|
+
"memory-daemon": "cockpit memory install",
|
|
146
|
+
"backfill-complete": "cockpit backfill --all --yes",
|
|
147
|
+
"sync-fresh": "cockpit sync --json",
|
|
148
|
+
"gc-checked": "cockpit doctor",
|
|
149
|
+
"disk-bounded": "cockpit clean --all-committed",
|
|
150
|
+
};
|
|
151
|
+
const items = rows.filter((row) => row.status === "fail" || row.status === "needs_fix" || row.hardStop).map((row) => ({
|
|
152
|
+
step: row.id, reason: row.code,
|
|
153
|
+
action: row.nextAction ?? row.message.match(/`([^`]+)`/u)?.[1] ?? defaults[row.id],
|
|
154
|
+
}));
|
|
155
|
+
if (receipt) {
|
|
156
|
+
for (const { key, piece } of setupReceiptPieces(receipt.receipt, receipt.memory)) {
|
|
157
|
+
if (piece.status === "ok" || piece.status === "skipped" || piece.status === "unsupported")
|
|
158
|
+
continue;
|
|
159
|
+
const step = key === "device" || key === "browser" ? "authed" : key === "collector.autostart" ? "autostart-alive" : "memory-registered";
|
|
160
|
+
if (piece.status !== "needs_trust" && items.some((item) => item.step === step))
|
|
161
|
+
continue;
|
|
162
|
+
items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ? "Open Codex and run /hooks to trust the installed hooks." : defaults[step] });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return items;
|
|
127
166
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSemverBelow } from "../scheduled-self-update.js";
|
|
1
2
|
import { DEFAULT_DASHBOARD_URL, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
2
3
|
import { createInteractiveExecRunner } from "../process-runner.js";
|
|
3
4
|
import { asRecord, fail, needsFix, ok } from "./doctor-report.js";
|
|
@@ -11,7 +12,7 @@ export async function checkCliLatest(context) {
|
|
|
11
12
|
if (!latest) {
|
|
12
13
|
return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
|
|
13
14
|
}
|
|
14
|
-
if (latest === LOCAL_COLLECTOR_VERSION) {
|
|
15
|
+
if (latest === LOCAL_COLLECTOR_VERSION || (!context.command.updateTag && /^\d+\.\d+\.\d+$/u.test(latest) && !isSemverBelow(LOCAL_COLLECTOR_VERSION, latest))) {
|
|
15
16
|
return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
|
|
16
17
|
}
|
|
17
18
|
return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
|
|
@@ -28,7 +29,7 @@ export async function fixCliLatest(context, state) {
|
|
|
28
29
|
}
|
|
29
30
|
if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
|
|
30
31
|
if (state.code === "stale_cli") {
|
|
31
|
-
return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit
|
|
32
|
+
return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit doctor`.");
|
|
32
33
|
}
|
|
33
34
|
return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
|
|
34
35
|
}
|
|
@@ -67,9 +68,19 @@ function parseNpmVersion(stdout) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
export function reexecDoctor(command, io) {
|
|
70
|
-
const args = ["
|
|
71
|
-
if (command.
|
|
72
|
-
args.push("--
|
|
71
|
+
const args = ["doctor"];
|
|
72
|
+
if (command.homeDir)
|
|
73
|
+
args.push("--home", command.homeDir);
|
|
74
|
+
if (command.lockWaitSeconds)
|
|
75
|
+
args.push("--lock-wait", String(command.lockWaitSeconds));
|
|
76
|
+
if (command.allowHomeRoot)
|
|
77
|
+
args.push("--allow-home-root");
|
|
78
|
+
if (command.maxDepth)
|
|
79
|
+
args.push("--max-depth", String(command.maxDepth));
|
|
80
|
+
if (command.maxRepos)
|
|
81
|
+
args.push("--max-repos", String(command.maxRepos));
|
|
82
|
+
for (const root of command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []))
|
|
83
|
+
args.push("--workspace", root);
|
|
73
84
|
if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
|
|
74
85
|
args.push("--dashboard-url", command.dashboardUrl);
|
|
75
86
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
2
|
+
import { isSemverBelow } from "../scheduled-self-update.js";
|
|
3
|
+
import { describeError } from "../health-detail.js";
|
|
1
4
|
import { withStage } from "../crash-guard.js";
|
|
2
5
|
import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
3
6
|
import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
|
|
4
7
|
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
5
8
|
import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
|
|
6
|
-
import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
7
|
-
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
|
|
8
|
-
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
9
|
+
import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
10
|
+
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
|
|
11
|
+
import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
|
|
9
12
|
import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
|
|
10
13
|
export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
11
14
|
const deps = { ...defaultDoctorDeps(hooks), ...overrides };
|
|
@@ -14,58 +17,93 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
|
14
17
|
export async function runDoctorWithDeps(command, io, deps) {
|
|
15
18
|
const context = { command, io, deps };
|
|
16
19
|
const rows = [];
|
|
20
|
+
const repairs = [];
|
|
21
|
+
const diagnoseOnly = command.dryRun || command.checkOnly;
|
|
22
|
+
const diagnosed = new Map();
|
|
17
23
|
for (const invariant of doctorInvariants()) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
rows.push(checked);
|
|
21
|
-
continue;
|
|
24
|
+
try {
|
|
25
|
+
diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
|
|
22
26
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
|
|
29
|
+
diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
}
|
|
32
|
+
for (const invariant of doctorInvariants()) {
|
|
33
|
+
let row;
|
|
34
|
+
try {
|
|
35
|
+
row = diagnosed.get(invariant.id);
|
|
36
|
+
const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
|
|
37
|
+
const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
|
|
38
|
+
const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
|
|
39
|
+
(!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
|
|
40
|
+
if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
|
|
41
|
+
row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
|
|
42
|
+
repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
|
|
43
|
+
// The replacement process owns the one final receipt, including JSON.
|
|
44
|
+
if (row.reexecExitCode !== undefined)
|
|
45
|
+
return row.reexecExitCode;
|
|
46
|
+
row = { ...row, fixed: row.status === "ok" };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
repairs.push({ step: invariant.id, outcome: diagnoseOnly ? "check_only" : row.status === "ok" ? "already_ok" : row.status === "skipped" ? "not_needed" : "needs_person", reason: row.code });
|
|
50
|
+
if (command.dryRun && invariant.fix && row.status !== "ok" && row.status !== "skipped")
|
|
51
|
+
row = dryRunPreview(row);
|
|
52
|
+
}
|
|
34
53
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
rows.push({ ...fixed, fixed: fixed.status !== "fail" });
|
|
40
|
-
if (fixed.hardStop)
|
|
41
|
-
break;
|
|
42
|
-
if (fixed.reexecExitCode !== undefined) {
|
|
43
|
-
await maybeReportDoctorEvents(context, rows);
|
|
44
|
-
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
45
|
-
return fixed.reexecExitCode;
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(`[doctor] ${invariant.id} failed`, JSON.stringify({ reason: "step_threw", ...describeError(error) }));
|
|
56
|
+
row = { id: invariant.id, status: "fail", code: "step_threw", message: "The step could not finish. Run `cockpit doctor`." };
|
|
57
|
+
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
46
58
|
}
|
|
59
|
+
console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
|
|
60
|
+
rows.push(row);
|
|
47
61
|
}
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
// Re-read host state after repairs. Sync is proven by its upload receipts;
|
|
63
|
+
// re-running its diagnostic would always request a new upload by design.
|
|
64
|
+
if (!diagnoseOnly) {
|
|
65
|
+
for (const invariant of doctorInvariants()) {
|
|
66
|
+
if (["cli-latest", "authed", "roots-ok", "backfill-complete", "sync-fresh", "gc-checked", "disk-bounded"].includes(invariant.id))
|
|
67
|
+
continue;
|
|
68
|
+
const index = rows.findIndex((row) => row.id === invariant.id);
|
|
69
|
+
if (rows[index]?.status === "fail")
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const checked = await invariant.check(context);
|
|
73
|
+
rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
|
|
74
|
+
console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
|
|
75
|
+
if (checked.status === "needs_fix" || checked.status === "fail") {
|
|
76
|
+
const repair = repairs.find((entry) => entry.step === invariant.id);
|
|
77
|
+
if (repair) {
|
|
78
|
+
repair.outcome = "needs_person";
|
|
79
|
+
repair.reason = checked.code;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
console.error(`[doctor] ${invariant.id} recheck_failed`, JSON.stringify({ reason: "recheck_threw", ...describeError(error) }));
|
|
85
|
+
rows[index] = { id: invariant.id, status: "fail", code: "recheck_threw", message: "Run `cockpit doctor` to retry the host check." };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const floor = await maybeReportDoctorEvents(context, rows);
|
|
90
|
+
if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
|
|
91
|
+
const index = rows.findIndex((row) => row.id === "cli-latest");
|
|
92
|
+
rows[index] = { id: "cli-latest", status: "needs_fix", code: "below_fleet_floor", message: `This process is below the fleet floor ${floor}. Run \`cockpit doctor\` to update.`, nextAction: "cockpit doctor" };
|
|
93
|
+
console.error("[doctor] cli-latest below_fleet_floor", JSON.stringify({ reason: "below_fleet_floor", current_version: LOCAL_COLLECTOR_VERSION, minimum_version: floor }));
|
|
94
|
+
}
|
|
95
|
+
let receipt = null;
|
|
96
|
+
if (!command.dryRun) {
|
|
97
|
+
try {
|
|
98
|
+
receipt = await deps.readSetupReceipt(context);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.error("[doctor] setup_receipt failed", JSON.stringify({ reason: "receipt_read_threw", ...describeError(error) }));
|
|
102
|
+
rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
writeDoctorOutput(command, io, rows, receipt, repairs);
|
|
106
|
+
return doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
|
|
69
107
|
}
|
|
70
108
|
function doctorInvariants() {
|
|
71
109
|
return [
|
|
@@ -74,7 +112,6 @@ function doctorInvariants() {
|
|
|
74
112
|
id: "authed",
|
|
75
113
|
check: (context) => context.deps.readAuth(context),
|
|
76
114
|
fix: fixAuthState,
|
|
77
|
-
requiresInteractiveFix: true,
|
|
78
115
|
},
|
|
79
116
|
{
|
|
80
117
|
id: "roots-ok",
|
|
@@ -99,6 +136,7 @@ function doctorInvariants() {
|
|
|
99
136
|
check: (context) => context.deps.checkMemory(context),
|
|
100
137
|
fix: (context, _state) => context.deps.fixMemory(context),
|
|
101
138
|
},
|
|
139
|
+
{ id: "agent-rules", check: (context) => context.deps.checkAgentRules(context), fix: (context) => context.deps.fixAgentRules(context) },
|
|
102
140
|
// BLI-3804. Right after the registration row, because it asks the second
|
|
103
141
|
// half of the same question: `memory-registered` proves the entry exists
|
|
104
142
|
// and names a bin, this one proves the server behind it starts, speaks the
|
|
@@ -122,6 +160,11 @@ function doctorInvariants() {
|
|
|
122
160
|
check: (context) => context.deps.checkBackfill(context),
|
|
123
161
|
fix: (context, _state) => context.deps.fixBackfill(context),
|
|
124
162
|
},
|
|
163
|
+
{
|
|
164
|
+
id: "sync-fresh",
|
|
165
|
+
check: (context) => context.deps.checkSync(context),
|
|
166
|
+
fix: (context, _state) => context.deps.fixSync(context),
|
|
167
|
+
},
|
|
125
168
|
{
|
|
126
169
|
id: "gc-checked",
|
|
127
170
|
check: (context) => context.deps.checkGc(context),
|
|
@@ -136,15 +179,12 @@ function doctorInvariants() {
|
|
|
136
179
|
check: (context) => context.deps.checkDisk(context),
|
|
137
180
|
fix: (context, _state) => context.deps.fixDisk(context),
|
|
138
181
|
},
|
|
139
|
-
{
|
|
140
|
-
id: "sync-fresh",
|
|
141
|
-
check: (context) => context.deps.checkSync(context),
|
|
142
|
-
fix: (context, _state) => context.deps.fixSync(context),
|
|
143
|
-
},
|
|
144
182
|
];
|
|
145
183
|
}
|
|
146
184
|
function defaultDoctorDeps(hooks) {
|
|
147
185
|
return {
|
|
186
|
+
checkAgentRules: checkDoctorAgentRules,
|
|
187
|
+
fixAgentRules: fixDoctorAgentRules,
|
|
148
188
|
latestCliVersion: latestCliVersionFromNpm,
|
|
149
189
|
selfUpdate: hooks.selfUpdate,
|
|
150
190
|
reexecDoctor: reexecDoctor,
|
|
@@ -168,7 +208,7 @@ function defaultDoctorDeps(hooks) {
|
|
|
168
208
|
fixDisk: fixDiskState,
|
|
169
209
|
checkSync: checkSyncState,
|
|
170
210
|
fixSync: fixSyncState,
|
|
171
|
-
readSetupReceipt: (context) => refreshSetupReceipt(context.io, {
|
|
211
|
+
readSetupReceipt: (context) => (context.command.checkOnly ? buildSetupReceipt : refreshSetupReceipt)(context.io, {
|
|
172
212
|
...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
|
|
173
213
|
...(context.command.dashboardUrl
|
|
174
214
|
? { dashboardUrl: context.command.dashboardUrl }
|
|
@@ -98,6 +98,9 @@ export function parseDoctorArgs(alias, args) {
|
|
|
98
98
|
"--dashboard-url",
|
|
99
99
|
"--update-tag",
|
|
100
100
|
"--dry-run",
|
|
101
|
+
"--check",
|
|
102
|
+
"--no-repair",
|
|
103
|
+
"--lock-wait",
|
|
101
104
|
"--json",
|
|
102
105
|
"--allow-home-root",
|
|
103
106
|
"--max-depth",
|
|
@@ -113,6 +116,7 @@ export function parseDoctorArgs(alias, args) {
|
|
|
113
116
|
"--timeout-ms",
|
|
114
117
|
],
|
|
115
118
|
valueFlags: [
|
|
119
|
+
"--lock-wait",
|
|
116
120
|
"--home",
|
|
117
121
|
"--repo",
|
|
118
122
|
"--workspace",
|
|
@@ -135,6 +139,8 @@ export function parseDoctorArgs(alias, args) {
|
|
|
135
139
|
}
|
|
136
140
|
return {
|
|
137
141
|
kind: "doctor",
|
|
142
|
+
checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
|
|
143
|
+
lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
|
|
138
144
|
alias,
|
|
139
145
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
140
146
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
2
|
export function parseUsageArgs(args) {
|
|
3
|
-
const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
|
|
3
|
+
const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
|
|
4
4
|
const action = values.positionals[0] ?? "people";
|
|
5
5
|
if (action !== "people" || values.positionals.length > 1)
|
|
6
6
|
throw new Error("usage takes one verb: people.");
|
|
7
|
-
return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
7
|
+
return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
8
8
|
}
|
|
@@ -143,12 +143,13 @@ export const TOWER_COMMAND_HELP = [
|
|
|
143
143
|
[
|
|
144
144
|
"usage",
|
|
145
145
|
[
|
|
146
|
-
"Usage: cockpit usage people [--since
|
|
146
|
+
"Usage: cockpit usage people [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
|
|
147
147
|
"",
|
|
148
148
|
"Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
|
|
149
149
|
"and the input / cache split when the row carries it), and an API list-price equivalent that is",
|
|
150
150
|
"labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
|
|
151
|
-
"--since takes
|
|
151
|
+
"--since takes <n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp; --until an ISO timestamp (default now).",
|
|
152
|
+
"--detail adds Output, Input, Cache read and Cache creation. Counts use K/M/B; dollars are rounded.",
|
|
152
153
|
"--include-automated adds harness, subagent and scheduled sessions, which are excluded by default.",
|
|
153
154
|
"It presses the same door as the /usage page and the usage_people MCP tool (GET /api/usage/people).",
|
|
154
155
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
@@ -71,30 +71,40 @@ export function localSubcommandHelp(command) {
|
|
|
71
71
|
[
|
|
72
72
|
"do-everything",
|
|
73
73
|
[
|
|
74
|
-
"Usage: cockpit do-everything [--workspace <path>] [--
|
|
74
|
+
"Usage: cockpit do-everything [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
|
|
75
75
|
"",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"--
|
|
76
|
+
"Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
|
|
77
|
+
"Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
|
|
78
|
+
"--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
|
|
79
|
+
"--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
|
|
80
|
+
"--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
|
|
81
|
+
"Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
|
|
80
82
|
],
|
|
81
83
|
],
|
|
82
84
|
[
|
|
83
85
|
"fix",
|
|
84
86
|
[
|
|
85
|
-
"Usage: cockpit fix [
|
|
87
|
+
"Usage: cockpit fix [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
|
|
86
88
|
"",
|
|
87
|
-
"
|
|
89
|
+
"Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
|
|
90
|
+
"Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
|
|
91
|
+
"--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
|
|
92
|
+
"--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
|
|
93
|
+
"--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
|
|
94
|
+
"Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
|
|
88
95
|
],
|
|
89
96
|
],
|
|
90
97
|
[
|
|
91
98
|
"doctor",
|
|
92
99
|
[
|
|
93
|
-
"Usage: cockpit doctor [
|
|
100
|
+
"Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--json]",
|
|
94
101
|
"",
|
|
95
|
-
"
|
|
96
|
-
"CLI
|
|
97
|
-
"
|
|
102
|
+
"Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
|
|
103
|
+
"Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
|
|
104
|
+
"--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
|
|
105
|
+
"--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
|
|
106
|
+
"--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
|
|
107
|
+
"Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
|
|
98
108
|
],
|
|
99
109
|
],
|
|
100
110
|
[
|
|
@@ -64,7 +64,7 @@ export function localCommandHelp(command) {
|
|
|
64
64
|
" cockpit upgrade [same flags as update]",
|
|
65
65
|
" cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
|
|
66
66
|
" cockpit fix [same flags as do-everything]",
|
|
67
|
-
" cockpit doctor [
|
|
67
|
+
" cockpit doctor [--check | --no-repair] [--lock-wait <seconds>] [--json]",
|
|
68
68
|
" cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
|
|
69
69
|
" cockpit login [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
|
70
70
|
" cockpit pair [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -209,6 +209,7 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
209
209
|
async function runDoctorLogin(command, io) {
|
|
210
210
|
return runLogin({
|
|
211
211
|
kind: "login",
|
|
212
|
+
homeDir: command.homeDir,
|
|
212
213
|
dashboardUrl: command.dashboardUrl,
|
|
213
214
|
json: command.json,
|
|
214
215
|
noAuth: false,
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.101");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -40,12 +40,12 @@ function cockpitHelp() {
|
|
|
40
40
|
localCommandHelp(),
|
|
41
41
|
"",
|
|
42
42
|
"Install: `npm install -g @bli-cockpit/cli@latest`.",
|
|
43
|
-
"Fix everything: run `cockpit do-everything`
|
|
43
|
+
"Fix everything: run `cockpit doctor`. `do-everything` and `fix` are exact aliases; --check diagnoses without repairs.",
|
|
44
44
|
"Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
|
|
45
45
|
"Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
|
|
46
46
|
"Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
|
|
47
|
-
"Already onboarded: run `cockpit
|
|
48
|
-
"Agent setup: `cockpit
|
|
47
|
+
"Already onboarded: run `cockpit doctor` from anywhere to repair and verify this machine.",
|
|
48
|
+
"Agent setup: `cockpit doctor` installs and verifies AGENTS.md/CLAUDE.md rules.",
|
|
49
49
|
"Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
|
|
50
50
|
"Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
|
|
51
51
|
"Maintainer release path: merge to main, publish to `next` with `cockpit release`, canary Windows plus Apple Silicon, then deliberately promote that exact version to `latest`.",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Compact counts for human-facing usage tables. JSON keeps the original numbers. */
|
|
2
|
+
export function formatCount(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return "Unavailable";
|
|
5
|
+
// Promote rounded values at unit boundaries instead of printing 1000K or 1000M.
|
|
6
|
+
if (value >= 999_950_000)
|
|
7
|
+
return `${Number((value / 1_000_000_000).toFixed(1))}B`;
|
|
8
|
+
if (value >= 999_500)
|
|
9
|
+
return `${Number((value / 1_000_000).toFixed(1))}M`;
|
|
10
|
+
if (value >= 1_000)
|
|
11
|
+
return `${Math.round(value / 1_000)}K`;
|
|
12
|
+
return String(value);
|
|
13
|
+
}
|
|
14
|
+
export function formatUsageDollars(value) {
|
|
15
|
+
return new Intl.NumberFormat("en-US", {
|
|
16
|
+
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
|
17
|
+
}).format(value);
|
|
18
|
+
}
|
package/dist/commands/usage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatCount, formatUsageDollars } from "./usage-format.js";
|
|
1
2
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
3
|
import { writeLine } from "./cli-io.js";
|
|
3
4
|
export async function runUsage(command, io) {
|
|
@@ -13,9 +14,18 @@ export async function runUsage(command, io) {
|
|
|
13
14
|
const body = answer.body;
|
|
14
15
|
if (door.json)
|
|
15
16
|
return emitAgentDoor(door, body);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const widths = [20, 8, 15, 12, 10, 10, 12, 14];
|
|
18
|
+
const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
|
|
19
|
+
printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
|
|
20
|
+
for (const row of body.people ?? []) {
|
|
21
|
+
printRow([
|
|
22
|
+
(row.display_name ?? row.email ?? "Unknown").slice(0, 20),
|
|
23
|
+
formatCount(row.tokens_total),
|
|
24
|
+
formatUsageDollars(row.api_list_price_equivalent_usd),
|
|
25
|
+
`${row.sessions_extracted}/${row.sessions_observed}`,
|
|
26
|
+
...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
19
29
|
writeLine(io.stdout, "");
|
|
20
30
|
writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
|
|
21
31
|
writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
|
|
@@ -146,7 +146,7 @@ function parseSemverTriple(value) {
|
|
|
146
146
|
return null;
|
|
147
147
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
148
148
|
}
|
|
149
|
-
function isSemverBelow(left, right) {
|
|
149
|
+
export function isSemverBelow(left, right) {
|
|
150
150
|
const parsedLeft = parseSemverTriple(left);
|
|
151
151
|
const parsedRight = parseSemverTriple(right);
|
|
152
152
|
if (!parsedLeft || !parsedRight)
|
package/dist/sync-lock.js
CHANGED
|
@@ -142,4 +142,9 @@ async function readLock(lockPath) {
|
|
|
142
142
|
// ACQUIRE above is the branch that owns the reporting.
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
|
+
}
|
|
146
|
+
/** Read owner metadata without acquiring or disturbing the collection lock. */
|
|
147
|
+
export async function inspectSyncLock(paths) {
|
|
148
|
+
const record = await readLock(path.join(paths.state_dir, LOCK_FILENAME));
|
|
149
|
+
return record ? { pid: record.pid, heartbeat_at: record.heartbeat_at, held: Date.now() - record.heartbeat_ms <= STALE_TAKEOVER_MS } : null;
|
|
145
150
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.101",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@bli-cockpit/memory-mcp": "0.1.26",
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.32",
|
|
32
32
|
"@bli-cockpit/telemetry-core": "0.1.43"
|
|
33
33
|
}
|
|
34
34
|
}
|