@bli-cockpit/cli 0.2.57 → 0.2.59

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 (53) hide show
  1. package/dist/commands/browser-open.js +88 -0
  2. package/dist/commands/docs.js +27 -6
  3. package/dist/commands/doctor-report.js +17 -1
  4. package/dist/commands/doctor.js +12 -2
  5. package/dist/commands/heartbeat.js +65 -1
  6. package/dist/commands/issue-contracts.js +99 -0
  7. package/dist/commands/issue-write.js +129 -0
  8. package/dist/commands/issue.js +189 -0
  9. package/dist/commands/jarvis-answer-envelope.js +80 -0
  10. package/dist/commands/jarvis-turn.js +16 -1
  11. package/dist/commands/jarvis.js +3 -0
  12. package/dist/commands/local-args-collector-setup.js +17 -0
  13. package/dist/commands/local-args-tower-admin.js +12 -2
  14. package/dist/commands/local-args-tower-docs-msg.js +95 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower-work.js +178 -0
  17. package/dist/commands/local-args-tower.js +7 -1
  18. package/dist/commands/local-args.js +29 -14
  19. package/dist/commands/local-command-shapes.js +12 -0
  20. package/dist/commands/local-help-commands.js +643 -0
  21. package/dist/commands/local-help.js +12 -551
  22. package/dist/commands/local.js +9 -0
  23. package/dist/commands/login.js +91 -8
  24. package/dist/commands/memory-install-claude.js +35 -15
  25. package/dist/commands/memory-install-codex-hooks.js +200 -0
  26. package/dist/commands/memory-install-codex.js +12 -2
  27. package/dist/commands/memory-install-config.js +140 -0
  28. package/dist/commands/memory-install-receipt.js +222 -0
  29. package/dist/commands/memory-install-report.js +113 -0
  30. package/dist/commands/memory-install.js +99 -276
  31. package/dist/commands/msg.js +85 -2
  32. package/dist/commands/notes-door.js +120 -0
  33. package/dist/commands/notes-reads.js +134 -0
  34. package/dist/commands/notes-writes.js +208 -0
  35. package/dist/commands/notes.js +16 -442
  36. package/dist/commands/onboard-completion.js +47 -0
  37. package/dist/commands/onboard-setup.js +82 -2
  38. package/dist/commands/ops-render-memory.js +76 -0
  39. package/dist/commands/ops-render.js +13 -1
  40. package/dist/commands/ops.js +65 -3
  41. package/dist/commands/project.js +38 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/search.js +122 -0
  44. package/dist/commands/setup-receipt-lines.js +71 -0
  45. package/dist/commands/setup-receipt.js +241 -0
  46. package/dist/commands/status.js +20 -1
  47. package/dist/local-state-pairing-code.js +200 -0
  48. package/dist/local-state.js +6 -0
  49. package/dist/repo-identity-fingerprint.js +88 -0
  50. package/dist/repo-identity-git.js +76 -0
  51. package/dist/repo-identity-linked-worktrees.js +81 -0
  52. package/dist/repo-identity.js +5 -222
  53. package/package.json +7 -7
@@ -0,0 +1,241 @@
1
+ /**
2
+ * "Is this machine actually connected?" — computed here, on the machine
3
+ * (BLI-3731).
4
+ *
5
+ * Seven words, every one of them READ BACK off this host rather than
6
+ * remembered from what we wrote. That rule is BLI-2541's, and it is the whole
7
+ * value of this module: `cockpit autostart` once registered a correct Windows
8
+ * task and then rejected it on every machine for months because it compared
9
+ * the readback against the strings Cockpit itself had sent.
10
+ *
11
+ * Where each word comes from:
12
+ *
13
+ * browser the device session on disk names a person. A device
14
+ * token exists ONLY because somebody signed a browser
15
+ * in, so the session file is the evidence — there is no
16
+ * second thing to ask.
17
+ * device that same session, still unexpired.
18
+ * collector.autostart `autostartStatus`, which asks launchd or Task
19
+ * Scheduler what it actually has registered.
20
+ *
21
+ * The five AGENT-HOST words in the printed block are NOT computed here. They
22
+ * are BLI-3729's `MemoryInstallReceipt`, already read back off
23
+ * `~/.claude.json`, `~/.claude/settings.json` and `~/.codex/config.toml`,
24
+ * already cached beside this one and already carried on the heartbeat as
25
+ * `memory_install`. This module reads that cache and hands both halves to the
26
+ * renderer. A second computation of the same five words would be a second
27
+ * place for them to be wrong.
28
+ *
29
+ * A piece this run did not inspect is `unknown`, never `missing`. The two are
30
+ * different facts and only one of them is somebody's problem.
31
+ *
32
+ * The SHAPE lives in `@bli-cockpit/telemetry-core` so the CLI and the
33
+ * dashboard word the same machine the same way; `memory-install-contract.ts`
34
+ * owns the target vocabulary this translates from.
35
+ */
36
+ import os from "node:os";
37
+ import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
38
+ import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
39
+ import { autostartStatus } from "../autostart.js";
40
+ import { readLocalSessionReference } from "../local-state.js";
41
+ import { getCollectorRuntimePaths } from "../local-state-paths.js";
42
+ import { readJsonFile, writeJsonFile } from "../local-state-files.js";
43
+ const TAG = "[setup receipt]";
44
+ /** Where the last computed receipt is cached, so a heartbeat need not re-probe. */
45
+ export const SETUP_RECEIPT_CACHE_FILE = "setup-receipt.json";
46
+ /**
47
+ * How stale a cached receipt may be before the heartbeat stops sending it.
48
+ * Seven days: these facts change about monthly, and re-probing them costs five
49
+ * file reads and a `--print-config` spawn that a 15-minute tick must not pay.
50
+ */
51
+ export const SETUP_RECEIPT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
52
+ export async function buildSetupReceipt(io, options = {}) {
53
+ const homeDir = options.homeDir ?? os.homedir();
54
+ const checkedAt = (options.now ?? new Date()).toISOString();
55
+ const probes = withDefaultProbes(io, homeDir, options);
56
+ const memory = await probes.memory().catch((error) => {
57
+ console.error(`${TAG} the memory receipt could not be read`, JSON.stringify({ reason: "memory_receipt_read_failed", error_name: errorName(error) }));
58
+ return null;
59
+ });
60
+ const session = await probes.session().catch((error) => {
61
+ console.error(`${TAG} device session could not be read`, JSON.stringify({ reason: "session_probe_failed", error_name: errorName(error) }));
62
+ return null;
63
+ });
64
+ const autostart = await probes.autostart().catch((error) => {
65
+ console.error(`${TAG} scheduler could not be read`, JSON.stringify({ reason: "autostart_probe_failed", error_name: errorName(error) }));
66
+ return { status: "unreadable" };
67
+ });
68
+ const receipt = {
69
+ schema_version: SETUP_RECEIPT_SCHEMA_VERSION,
70
+ checked_at: checkedAt,
71
+ browser: browserPiece(session),
72
+ device: devicePiece(session),
73
+ collector: { autostart: autostartPiece(autostart) },
74
+ };
75
+ const gaps = setupReceiptGaps(receipt, memory);
76
+ console.error(`${TAG} read back`, JSON.stringify({
77
+ reason: gaps.length === 0 ? "everything_connected" : "gaps_found",
78
+ gap_count: gaps.length,
79
+ gaps,
80
+ memory_receipt: memory ? "read" : "absent",
81
+ }));
82
+ return { receipt, memory };
83
+ }
84
+ function withDefaultProbes(io, homeDir, options) {
85
+ return {
86
+ memory: options.probes?.memory ??
87
+ (async () => (await readHeartbeatMemoryReceipt({
88
+ homeDir,
89
+ ...(options.now ? { now: options.now } : {}),
90
+ })).receipt),
91
+ session: options.probes?.session ??
92
+ (async () => {
93
+ const reference = await readLocalSessionReference(getCollectorRuntimePaths(homeDir));
94
+ return {
95
+ state: reference.session_state,
96
+ ...(reference.email ? { email: reference.email } : {}),
97
+ };
98
+ }),
99
+ autostart: options.probes?.autostart ??
100
+ (async () => {
101
+ // The scheduler can only be READ through a process runner; a machine
102
+ // whose io carries none is unknown, never "absent" (BLI-2541: never
103
+ // report a state you did not observe).
104
+ const exec = io.exec;
105
+ if (!exec)
106
+ return { status: "unreadable", message: "runner_unavailable" };
107
+ const result = await autostartStatus({ homeDir, exec });
108
+ return {
109
+ status: result.status,
110
+ ...(result.message ? { message: result.message } : {}),
111
+ };
112
+ }),
113
+ };
114
+ }
115
+ /**
116
+ * A device token exists only because a person signed a browser in and claimed
117
+ * this machine, so a session that names an email IS the browser receipt. A
118
+ * session with no email came from the manual admin-approval path, which is a
119
+ * real way to pair and not a browser sign-in — it says so rather than claiming
120
+ * a click nobody made.
121
+ */
122
+ function browserPiece(session) {
123
+ if (!session)
124
+ return { status: "unknown", reason: "session_unreadable" };
125
+ if (session.state !== "valid") {
126
+ return { status: "missing", reason: "never_signed_in" };
127
+ }
128
+ return session.email
129
+ ? { status: "ok", reason: "signed_in" }
130
+ : { status: "ok", reason: "paired_without_email" };
131
+ }
132
+ function devicePiece(session) {
133
+ if (!session)
134
+ return { status: "unknown", reason: "session_unreadable" };
135
+ if (session.state === "valid")
136
+ return { status: "ok", reason: "session_valid" };
137
+ if (session.state === "expired") {
138
+ return { status: "missing", reason: "session_expired" };
139
+ }
140
+ return { status: "missing", reason: `session_${session.state}` };
141
+ }
142
+ /**
143
+ * The scheduler is `ok` only when the host says it is registered AND loaded.
144
+ * `installed` on macOS means the plist is on disk; `loaded` means launchd has
145
+ * it. A plist nobody loaded runs nothing, which is exactly the failure that
146
+ * looks fine from the outside.
147
+ */
148
+ function autostartPiece(result) {
149
+ if (result.status === "loaded" || result.status === "installed") {
150
+ return { status: "ok", reason: `autostart_${result.status}` };
151
+ }
152
+ if (result.status === "not_loaded") {
153
+ return { status: "missing", reason: "autostart_not_loaded" };
154
+ }
155
+ if (result.status === "absent" || result.status === "uninstalled") {
156
+ return { status: "missing", reason: "autostart_absent" };
157
+ }
158
+ if (result.status === "unsupported") {
159
+ return { status: "skipped", reason: "platform_unsupported" };
160
+ }
161
+ return { status: "unknown", reason: normalizeReason(result.status) };
162
+ }
163
+ /** Reason labels are `[a-z0-9_:.-]` by schema; anything else becomes one word. */
164
+ function normalizeReason(value) {
165
+ const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9_:.-]+/gu, "_");
166
+ return cleaned.slice(0, 120) || "unlabelled";
167
+ }
168
+ function errorName(error) {
169
+ return error instanceof Error ? error.name : typeof error;
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // The cache — so a 15-minute tick does not re-probe a monthly fact
173
+ // ---------------------------------------------------------------------------
174
+ export function setupReceiptCachePath(homeDir) {
175
+ const paths = getCollectorRuntimePaths(homeDir ?? os.homedir());
176
+ return `${paths.state_dir}/${SETUP_RECEIPT_CACHE_FILE}`;
177
+ }
178
+ /** Never fails a caller: a cache that could not be written names itself and moves on. */
179
+ export async function cacheSetupReceipt(receipt, homeDir) {
180
+ try {
181
+ await writeJsonFile(setupReceiptCachePath(homeDir), receipt);
182
+ }
183
+ catch (error) {
184
+ console.error(`${TAG} cache not written`, JSON.stringify({ reason: "cache_write_failed", error_name: errorName(error) }));
185
+ }
186
+ }
187
+ /**
188
+ * The cached receipt, or null with a named reason. A receipt older than
189
+ * `SETUP_RECEIPT_MAX_AGE_MS` is refused rather than sent: a stale word is
190
+ * worse than an absent one, because absence asks and staleness answers.
191
+ */
192
+ export async function readCachedSetupReceipt(homeDir, now = new Date()) {
193
+ let raw;
194
+ try {
195
+ raw = await readJsonFile(setupReceiptCachePath(homeDir));
196
+ }
197
+ catch {
198
+ // Absent on a machine that has never run the install. Not an error, and
199
+ // the caller logs the absence with its own reason.
200
+ return null;
201
+ }
202
+ const parsed = safeParseReceipt(raw);
203
+ if (!parsed) {
204
+ console.error(`${TAG} cache unusable`, JSON.stringify({ reason: "cache_not_a_receipt" }));
205
+ return null;
206
+ }
207
+ const age = now.getTime() - Date.parse(parsed.checked_at);
208
+ if (!Number.isFinite(age) || age > SETUP_RECEIPT_MAX_AGE_MS) {
209
+ console.error(`${TAG} cache too old to send`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
210
+ return null;
211
+ }
212
+ return parsed;
213
+ }
214
+ function safeParseReceipt(raw) {
215
+ const record = raw;
216
+ if (!record || record.schema_version !== SETUP_RECEIPT_SCHEMA_VERSION)
217
+ return null;
218
+ return raw;
219
+ }
220
+ /** The one line, plus the one fix per gap. Rendering lives in `setup-receipt-lines.ts`. */
221
+ export { setupReceiptLine };
222
+ /**
223
+ * Read the machine, cache what it said, and hand it back. The ONE entry point
224
+ * for every surface that shows the receipt (`status`, `doctor`, `onboard`,
225
+ * `memory install`) — so the block a person reads in a terminal and the block
226
+ * the ops board renders are always the same reading, not two.
227
+ *
228
+ * Never throws: a receipt that could not be computed is `null` with a named
229
+ * reason, and the caller prints nothing rather than a wrong reassurance.
230
+ */
231
+ export async function refreshSetupReceipt(io, options = {}) {
232
+ try {
233
+ const reading = await buildSetupReceipt(io, options);
234
+ await cacheSetupReceipt(reading.receipt, options.homeDir);
235
+ return reading;
236
+ }
237
+ catch (error) {
238
+ console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed", error_name: errorName(error) }));
239
+ return null;
240
+ }
241
+ }
@@ -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
  /**
@@ -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";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Turning a filesystem path or a git origin URL into the stable ids this
3
+ * package hands out (`repo_fingerprint`, `worktree_fingerprint`, the label),
4
+ * plus the collection-root canonicalization built on the same realpath call.
5
+ * Sibling of `repo-identity.ts`, named in its header.
6
+ */
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ export async function stableWorktreeRoot(repoRoot) {
11
+ const resolvedRoot = path.resolve(repoRoot);
12
+ return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
13
+ }
14
+ /**
15
+ * Canonicalizes existing collection roots through the filesystem so transcript
16
+ * paths and consent roots use the same spelling (for example `/private/var`
17
+ * versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
18
+ */
19
+ export async function canonicalizeCollectionRootPaths(roots) {
20
+ const canonical = await Promise.all(roots.map(stableWorktreeRoot));
21
+ return [...new Set(canonical)];
22
+ }
23
+ /**
24
+ * Returns the operator-entered resolved paths plus filesystem-canonical aliases.
25
+ * Both are needed for absent child repos because the child itself cannot be
26
+ * realpathed after deletion, while its transcript may retain either spelling.
27
+ */
28
+ export async function collectionRootPathAliases(roots) {
29
+ const resolved = roots.map((root) => path.resolve(root));
30
+ const canonical = await canonicalizeCollectionRootPaths(resolved);
31
+ return [...new Set([...resolved, ...canonical])];
32
+ }
33
+ function normalizeFingerprintPath(repoRoot, pathApi = path) {
34
+ const resolved = pathApi.resolve(repoRoot);
35
+ return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
36
+ }
37
+ export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
38
+ return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
39
+ }
40
+ export function repoFingerprintFromLocalRoot(root, pathApi = path) {
41
+ return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
42
+ }
43
+ export function repoFingerprintFromOrigin(origin) {
44
+ const normalizedOrigin = normalizeGitOrigin(origin);
45
+ return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
46
+ }
47
+ export function normalizeGitOrigin(rawOrigin) {
48
+ const trimmed = rawOrigin.trim();
49
+ if (!trimmed)
50
+ return "";
51
+ const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
52
+ if (scpLike && !trimmed.includes("://")) {
53
+ return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
54
+ }
55
+ try {
56
+ const url = new URL(trimmed);
57
+ return normalizeOriginParts(url.hostname, url.pathname);
58
+ }
59
+ catch {
60
+ // Deliberately silent (BLI-3238). `new URL` is being used as the test for
61
+ // "is this origin URL-shaped?", and a plain path or an unusual remote form
62
+ // failing to parse IS the answer — the fallback below is the intended
63
+ // normalization for exactly that case, not a degradation.
64
+ return trimmed
65
+ .replace(/\.git$/i, "")
66
+ .replace(/^\/+|\/+$/g, "")
67
+ .toLowerCase();
68
+ }
69
+ }
70
+ export function repoLabelFromOrigin(origin) {
71
+ const segments = origin.split("/").filter(Boolean);
72
+ return segments.at(-1) ?? origin;
73
+ }
74
+ function normalizeOriginParts(host, repoPath) {
75
+ return [
76
+ host.trim().toLowerCase(),
77
+ repoPath
78
+ .trim()
79
+ .replace(/\.git$/i, "")
80
+ .replace(/^\/+|\/+$/g, "")
81
+ .toLowerCase(),
82
+ ]
83
+ .filter(Boolean)
84
+ .join("/");
85
+ }
86
+ export function sha256(value) {
87
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex");
88
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Every place this package shells out to `git`, plus the two ways a branch
3
+ * is read when git itself cannot be asked (a `.git` file's HEAD, for a
4
+ * linked worktree whose git process is unavailable). Sibling of
5
+ * `repo-identity.ts`, named in its header.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { promisify } from "node:util";
11
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
12
+ const execFileAsync = promisify(execFile);
13
+ export async function runGit(args, cwd) {
14
+ const { stdout } = await execFileAsync("git", args, {
15
+ cwd,
16
+ timeout: 2_000,
17
+ maxBuffer: 1024 * 1024,
18
+ });
19
+ return stdout;
20
+ }
21
+ export async function hasGitMarker(dir) {
22
+ return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
23
+ }
24
+ export async function resolveGitBranchWithGit(repoRoot) {
25
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
26
+ if (branch && branch !== "HEAD")
27
+ return branch;
28
+ const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
29
+ return head ? `detached:${head}` : "unknown";
30
+ }
31
+ export async function resolveBranchFromHead(repoRoot) {
32
+ try {
33
+ const gitPath = path.join(repoRoot, ".git");
34
+ const stat = await fs.stat(gitPath);
35
+ const headPath = stat.isFile()
36
+ ? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
37
+ : path.join(gitPath, "HEAD");
38
+ const head = (await fs.readFile(headPath, "utf8")).trim();
39
+ if (head.startsWith("ref: refs/heads/")) {
40
+ return head.slice("ref: refs/heads/".length);
41
+ }
42
+ return head ? `detached:${head.slice(0, 12)}` : "unknown";
43
+ }
44
+ catch (error) {
45
+ // Not a repo → quiet, that is an ordinary approved folder. A `.git` that
46
+ // exists and will not read → every session from this worktree is labelled
47
+ // branch `unknown` and, until BLI-3238, nothing said why.
48
+ if (!isMissingFileFailure(error)) {
49
+ console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
50
+ reason: "git_head_unreadable",
51
+ ...describeError(error),
52
+ }));
53
+ }
54
+ return "unknown";
55
+ }
56
+ }
57
+ async function resolveLinkedGitDir(gitFile) {
58
+ const raw = await fs.readFile(gitFile, "utf8");
59
+ const match = raw.match(/^gitdir:\s*(.+)$/m);
60
+ if (!match)
61
+ return path.dirname(gitFile);
62
+ const gitDir = match[1].trim();
63
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
64
+ }
65
+ export async function listLinkedWorktreePaths(repoRoot) {
66
+ const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
67
+ const paths = [];
68
+ for (const line of porcelain.split("\n")) {
69
+ if (line.startsWith("worktree ")) {
70
+ const value = line.slice("worktree ".length).trim();
71
+ if (value)
72
+ paths.push(value);
73
+ }
74
+ }
75
+ return paths;
76
+ }