@bli-cockpit/cli 0.2.58 → 0.2.60

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/browser-open.js +88 -0
  3. package/dist/commands/docs.js +32 -6
  4. package/dist/commands/doctor-report.js +17 -1
  5. package/dist/commands/doctor.js +12 -2
  6. package/dist/commands/heartbeat.js +65 -1
  7. package/dist/commands/jarvis-answer-envelope.js +82 -0
  8. package/dist/commands/jarvis-render.js +18 -0
  9. package/dist/commands/jarvis-turn.js +29 -2
  10. package/dist/commands/jarvis.js +3 -0
  11. package/dist/commands/local-args-collector-setup.js +17 -0
  12. package/dist/commands/local-args-tower-admin.js +12 -2
  13. package/dist/commands/local-args-tower-chat.js +5 -0
  14. package/dist/commands/local-args-tower-docs-msg.js +105 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower.js +4 -1
  17. package/dist/commands/local-args.js +28 -13
  18. package/dist/commands/local-command-shapes.js +12 -0
  19. package/dist/commands/local-help-commands.js +655 -0
  20. package/dist/commands/local-help.js +11 -582
  21. package/dist/commands/local.js +3 -0
  22. package/dist/commands/login.js +91 -8
  23. package/dist/commands/memory-install-claude.js +35 -15
  24. package/dist/commands/memory-install-codex-hooks.js +200 -0
  25. package/dist/commands/memory-install-codex.js +12 -2
  26. package/dist/commands/memory-install-receipt.js +222 -0
  27. package/dist/commands/memory-install-report.js +25 -1
  28. package/dist/commands/memory-install.js +76 -2
  29. package/dist/commands/msg.js +85 -2
  30. package/dist/commands/onboard-completion.js +47 -0
  31. package/dist/commands/onboard-setup.js +82 -2
  32. package/dist/commands/ops-render-memory.js +76 -0
  33. package/dist/commands/ops-render.js +6 -0
  34. package/dist/commands/ops.js +59 -2
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/search.js +122 -0
  37. package/dist/commands/setup-receipt-lines.js +71 -0
  38. package/dist/commands/setup-receipt.js +241 -0
  39. package/dist/commands/status.js +20 -1
  40. package/dist/commands/tower-mcp-install.js +4 -2
  41. package/dist/local-state-pairing-code.js +200 -0
  42. package/dist/local-state.js +6 -0
  43. package/package.json +4 -4
@@ -15,6 +15,8 @@ import path from "node:path";
15
15
  import { writeLine } from "./cli-io.js";
16
16
  import { displayTicketId, displayWorkLabel, shortSha, stuckEvidenceLine, } from "./collection-report.js";
17
17
  import { discoverCommandWorktrees } from "./local-discovery.js";
18
+ import { refreshSetupReceipt } from "./setup-receipt.js";
19
+ import { setupReceiptBlock } from "./setup-receipt-lines.js";
18
20
  import { defaultCodexSessionDirs } from "../adapters/codex-attribution.js";
19
21
  import { describeError } from "../health-detail.js";
20
22
  import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
@@ -43,8 +45,19 @@ export async function runStatus(command, io) {
43
45
  return 0;
44
46
  }
45
47
  const status = await inspectLocalCollectorStatus(command);
48
+ // BLI-3731: "is this machine actually connected?" belongs beside "is it
49
+ // collecting?", and it is read back off the host rather than remembered.
50
+ // Refreshing here also keeps the heartbeat's cache warm without the 15-minute
51
+ // tick paying for the probe.
52
+ const setupReceipt = await refreshSetupReceipt(io, {
53
+ ...(command.homeDir ? { homeDir: command.homeDir } : {}),
54
+ });
46
55
  if (command.json) {
47
- writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
56
+ writeLine(io.stdout, JSON.stringify({
57
+ ...status,
58
+ backfill_cursor: backfillCursor,
59
+ setup_receipt: setupReceipt?.receipt ?? null,
60
+ }, null, 2));
48
61
  return 0;
49
62
  }
50
63
  writeLine(io.stdout, "Tower status");
@@ -69,6 +82,12 @@ export async function runStatus(command, io) {
69
82
  writeLine(io.stdout, `Stuck files: ${stuckEvidenceLine(status)}`);
70
83
  for (const detail of status.details)
71
84
  writeLine(io.stdout, `- ${detail}`);
85
+ writeLine(io.stdout, "Connected:");
86
+ for (const line of setupReceipt
87
+ ? setupReceiptBlock(setupReceipt, { indent: " " })
88
+ : [" unknown — run `cockpit doctor` to read this machine."]) {
89
+ writeLine(io.stdout, line);
90
+ }
72
91
  return 0;
73
92
  }
74
93
  /**
@@ -6,9 +6,11 @@
6
6
  * verb: that command already runs unasked from `do-everything` and the daily
7
7
  * sync tick (`memory-install.ts`'s own header), which is the ONLY way a
8
8
  * registration reaches every intern machine without anyone typing anything.
9
- * A second, un-invoked `cockpit mcp install` command would ship the feature
9
+ * A second, un-invoked `mcp install` verb of its own would ship the feature
10
10
  * and still leave every existing machine unregistered until someone learned
11
- * to type the new verb.
11
+ * to type it. (Deliberately not written as a `cockpit ...` invocation here:
12
+ * BLI-3768's census refuses any spelled-out command the router would answer
13
+ * `Unknown command` to, and this one is a road not taken.)
12
14
  *
13
15
  * `no_bin_no_write` applies here too, for the identical reason
14
16
  * `memory-install.ts` states it for `bli-memory-mcp`: a Claude Code entry
@@ -0,0 +1,200 @@
1
+ import { LocalCollectorSessionFileSchema, } from "@bli-cockpit/telemetry-core";
2
+ import os from "node:os";
3
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
4
+ import { writeJsonFile } from "./local-state-files.js";
5
+ import { DEFAULT_DASHBOARD_URL, defaultDeviceName, LOCAL_COLLECTOR_VERSION, normalizeDashboardUrl, normalizeDeviceName, readLocalCollectorConfig, } from "./local-state-config.js";
6
+ import { toSessionReference } from "./local-state-session.js";
7
+ /** Everything that can go wrong, with the same name on both sides of the wire. */
8
+ export class PairingCodeError extends Error {
9
+ reason;
10
+ constructor(reason, message) {
11
+ super(message);
12
+ this.name = "PairingCodeError";
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ const TAG = "[pair link]";
17
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
18
+ export async function pairLocalCollectorViaLink(options = {}) {
19
+ const homeDir = options.homeDir ?? os.homedir();
20
+ const paths = getCollectorRuntimePaths(homeDir);
21
+ await ensureRuntimeDirectories(paths);
22
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
23
+ const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL);
24
+ const deviceId = config?.device_id;
25
+ if (!deviceId) {
26
+ throw new PairingCodeError("config_missing_device_id", "Local config has no device id. Run `cockpit onboard` (or `cockpit install`) first.");
27
+ }
28
+ const deviceName = normalizeDeviceName(options.deviceName) ??
29
+ config?.device_name ??
30
+ defaultDeviceName();
31
+ const fetchImpl = options.fetch ?? globalThis.fetch;
32
+ if (!fetchImpl) {
33
+ throw new PairingCodeError("fetch_unavailable", "global fetch is unavailable; use Node.js 20 or newer.");
34
+ }
35
+ const minted = options.pairCode
36
+ ? null
37
+ : await mintPairingCode(fetchImpl, dashboardUrl, {
38
+ device_id: deviceId,
39
+ device_name: deviceName,
40
+ claimed_owner_email: options.claimedOwnerEmail,
41
+ collector_version: LOCAL_COLLECTOR_VERSION,
42
+ });
43
+ if (minted) {
44
+ options.onLinkReady?.({
45
+ connect_url: minted.connect_url,
46
+ expires_at: minted.expires_at,
47
+ });
48
+ }
49
+ const session = await pollExchange(fetchImpl, dashboardUrl, {
50
+ paths,
51
+ code: options.pairCode ?? minted?.code ?? "",
52
+ clientSecret: minted?.client_secret,
53
+ deviceId,
54
+ deviceName,
55
+ pollIntervalMs: options.pollIntervalMs ?? minted?.poll_after_ms,
56
+ timeoutMs: options.timeoutMs,
57
+ sleep: options.sleep,
58
+ now: options.now,
59
+ });
60
+ return {
61
+ status: "paired",
62
+ session: toSessionReference(session),
63
+ session_file: paths.session_file,
64
+ dashboard_url: dashboardUrl,
65
+ ...(minted ? { connect_url: minted.connect_url } : {}),
66
+ direction: minted ? "cli_first" : "browser_first",
67
+ };
68
+ }
69
+ async function mintPairingCode(fetchImpl, dashboardUrl, body) {
70
+ const response = await fetchImpl(`${dashboardUrl}/api/devices/pair/code`, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify(body),
74
+ });
75
+ const parsed = await readResponseJson(response, "mint");
76
+ if (!response.ok) {
77
+ throw refusalFrom("pair_code_mint_refused", response, parsed);
78
+ }
79
+ const record = asRecord(parsed);
80
+ const code = readString(record, "code");
81
+ const connectUrl = readString(record, "connect_url");
82
+ const expiresAt = readString(record, "expires_at");
83
+ if (!code || !connectUrl || !expiresAt) {
84
+ throw new PairingCodeError("pair_code_mint_malformed", "The dashboard did not return a usable pairing code.");
85
+ }
86
+ return {
87
+ code,
88
+ client_secret: readString(record, "client_secret") ?? undefined,
89
+ connect_url: connectUrl,
90
+ expires_at: expiresAt,
91
+ poll_after_ms: readNumber(record, "poll_after_ms") ?? 2_000,
92
+ direction: readString(record, "direction") === "browser_first"
93
+ ? "browser_first"
94
+ : "cli_first",
95
+ };
96
+ }
97
+ async function pollExchange(fetchImpl, dashboardUrl, options) {
98
+ if (!options.code) {
99
+ throw new PairingCodeError("pair_code_missing", "No pairing code to exchange.");
100
+ }
101
+ const pollIntervalMs = Math.max(250, options.pollIntervalMs ?? 2_000);
102
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
103
+ const sleepImpl = options.sleep ??
104
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
105
+ const startedAt = options.now?.getTime() ?? Date.now();
106
+ const deadline = startedAt + timeoutMs;
107
+ let attempts = 0;
108
+ while (Date.now() <= deadline) {
109
+ attempts += 1;
110
+ const response = await fetchImpl(`${dashboardUrl}/api/devices/pair/exchange`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ code: options.code,
115
+ ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
116
+ device_id: options.deviceId,
117
+ device_name: options.deviceName,
118
+ collector_version: LOCAL_COLLECTOR_VERSION,
119
+ }),
120
+ });
121
+ const parsed = await readResponseJson(response, "exchange");
122
+ const record = asRecord(parsed);
123
+ const status = readString(record, "status");
124
+ if (!response.ok) {
125
+ throw refusalFrom(readString(record, "reason") ?? "pair_exchange_refused", response, parsed);
126
+ }
127
+ if (status === "paired") {
128
+ const session = parseExchangedSession(record, options.paths.session_file);
129
+ await writeJsonFile(options.paths.session_file, session);
130
+ console.error(`${TAG} paired`, JSON.stringify({ reason: "exchange_ok", attempts }));
131
+ return session;
132
+ }
133
+ if (status === "refused") {
134
+ throw new PairingCodeError(readString(record, "reason") ?? "pair_exchange_refused", readString(record, "message") ?? "Pairing was refused.");
135
+ }
136
+ if (status !== "pending_claim") {
137
+ throw new PairingCodeError("pair_exchange_unexpected_status", `Unexpected pairing status: ${status ?? "none"}`);
138
+ }
139
+ await sleepImpl(pollIntervalMs);
140
+ }
141
+ console.error(`${TAG} gave up waiting`, JSON.stringify({ reason: "pair_exchange_timeout", attempts }));
142
+ throw new PairingCodeError("pair_exchange_timeout", "Timed out waiting for the browser sign-in. Run `cockpit login` again.");
143
+ }
144
+ function parseExchangedSession(record, sessionFilePath) {
145
+ const session = record?.["session"];
146
+ if (!session || typeof session !== "object") {
147
+ throw new PairingCodeError("pair_exchange_no_session", "Pairing succeeded but the dashboard returned no session.");
148
+ }
149
+ return LocalCollectorSessionFileSchema.parse({
150
+ ...session,
151
+ session_file_path: sessionFilePath,
152
+ });
153
+ }
154
+ /**
155
+ * The server's own words first, then the status, so a captive portal, a wrong
156
+ * dashboard URL and a real refusal never read the same. Logged as well as
157
+ * thrown: pairing runs on a machine that is not collecting yet, so the
158
+ * terminal is the only receipt there is.
159
+ */
160
+ function refusalFrom(reason, response, body) {
161
+ const record = asRecord(body);
162
+ const words = readString(record, "message") ??
163
+ readString(record, "error") ??
164
+ "Pairing request failed";
165
+ console.error(`${TAG} refused`, JSON.stringify({ reason, http_status: response.status }));
166
+ return new PairingCodeError(reason, `${words} (HTTP ${response.status})`);
167
+ }
168
+ async function readResponseJson(response, stage) {
169
+ const text = await response.text();
170
+ if (!text)
171
+ return {};
172
+ try {
173
+ return JSON.parse(text);
174
+ }
175
+ catch {
176
+ // A captive portal or a proxy answering with HTML is the classic reason a
177
+ // new machine cannot pair. The body is never logged; its shape is.
178
+ console.error(`${TAG} reply was not JSON`, JSON.stringify({
179
+ reason: "response_body_not_json",
180
+ stage,
181
+ http_status: response.status,
182
+ byte_size: text.length,
183
+ content_type: response.headers.get("content-type") ?? "none",
184
+ }));
185
+ return { message: text.slice(0, 200) };
186
+ }
187
+ }
188
+ function asRecord(value) {
189
+ return value && typeof value === "object"
190
+ ? value
191
+ : null;
192
+ }
193
+ function readString(record, field) {
194
+ const value = record?.[field];
195
+ return typeof value === "string" && value.trim() ? value : null;
196
+ }
197
+ function readNumber(record, field) {
198
+ const value = record?.[field];
199
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
200
+ }
@@ -18,6 +18,11 @@
18
18
  * - `local-state-pairing.ts` — `cockpit login` and `cockpit logout`: the two
19
19
  * operations that make and unmake that session file, plus the pair
20
20
  * start/poll HTTP client and its refusal message.
21
+ * - `local-state-pairing-code.ts` — the ONE-SIGN-IN ceremony (BLI-3731): mint
22
+ * a pairing code, print and open `<dashboard>/connect?pair=…`, and poll the
23
+ * exchange until the browser's single magic link has claimed it. Writes the
24
+ * same session file its older sibling does, because the server issues it
25
+ * from the same `pollDevicePairRequest`.
21
26
  * - `local-state-identity.ts` — which repo and worktree a folder is, with the
22
27
  * path-derived fallback that keeps a non-git folder collectable.
23
28
  * - `local-state-attributed-target.ts` — re-derives a caller-supplied identity
@@ -34,6 +39,7 @@ export { getCollectorRuntimePaths } from "./local-state-paths.js";
34
39
  export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
35
40
  export { readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state-session.js";
36
41
  export { logoutLocalCollector, pairLocalCollector, } from "./local-state-pairing.js";
42
+ export { PairingCodeError, pairLocalCollectorViaLink, } from "./local-state-pairing-code.js";
37
43
  export { resolveGitBranch } from "./local-state-identity.js";
38
44
  export { readLocalWorkContext, readLocalWorkContextForRepo, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "./local-state-work-context.js";
39
45
  export { classifyCollectorFreshness, inspectLocalCollectorStatus, } from "./local-state-status.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.58",
3
+ "version": "0.2.60",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.6",
31
- "@bli-cockpit/mcp": "0.1.1",
32
- "@bli-cockpit/telemetry-core": "0.1.28"
30
+ "@bli-cockpit/memory-mcp": "0.1.7",
31
+ "@bli-cockpit/mcp": "0.1.4",
32
+ "@bli-cockpit/telemetry-core": "0.1.29"
33
33
  }
34
34
  }