@bli-cockpit/cli 0.2.49 → 0.2.51

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 (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -0,0 +1,75 @@
1
+ import path from "node:path";
2
+ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
3
+ import { isSamePath } from "./root-normalization.js";
4
+ /**
5
+ * A transcript-attributed target is an identity a CALLER built — for a wrapper
6
+ * folder or a repo that no longer exists on disk — so every field of it is
7
+ * re-derived here before a single byte of context state is written. Nothing
8
+ * downstream can tell an invented fingerprint from a real one.
9
+ *
10
+ * The checks run in a fixed order and each throws its own sentence, so the
11
+ * first thing wrong is the thing the operator is told about.
12
+ */
13
+ export async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
14
+ const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
15
+ const identityRoot = await stableWorktreeRoot(identity.repo_root);
16
+ const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
17
+ if (!isSamePath(canonicalRoot, identityRoot) ||
18
+ !isSamePath(canonicalRoot, identityRequestedPath)) {
19
+ throw new Error("Attributed target identity paths do not match the requested repo root.");
20
+ }
21
+ const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
22
+ if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
23
+ throw new Error("Attributed target worktree fingerprint does not match its repo root.");
24
+ }
25
+ const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
26
+ if (identity.worktree_label !== expectedWorktreeLabel) {
27
+ throw new Error("Attributed target worktree label does not match its repo root.");
28
+ }
29
+ if (identity.repo_origin_url) {
30
+ assertRepoFieldsMatchOrigin(identity, identity.repo_origin_url);
31
+ }
32
+ else {
33
+ assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel);
34
+ }
35
+ return {
36
+ ...identity,
37
+ requested_path: canonicalRoot,
38
+ repo_root: canonicalRoot,
39
+ };
40
+ }
41
+ /**
42
+ * An origin-derived target is a repo reconstructed from transcript
43
+ * provenance, so it may not claim to be the live primary worktree of anything.
44
+ */
45
+ function assertRepoFieldsMatchOrigin(identity, repoOriginUrl) {
46
+ const normalizedOrigin = normalizeGitOrigin(repoOriginUrl);
47
+ if (normalizedOrigin !== repoOriginUrl) {
48
+ throw new Error("Attributed target repo origin is not normalized.");
49
+ }
50
+ if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
51
+ throw new Error("Attributed target repo fingerprint does not match its origin.");
52
+ }
53
+ if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
54
+ throw new Error("Attributed target repo label does not match its origin.");
55
+ }
56
+ if (identity.worktree_is_primary) {
57
+ throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
58
+ }
59
+ }
60
+ /**
61
+ * Without an origin the only thing an identity can honestly be derived from is
62
+ * the local root, so the fingerprint, the label and primacy all follow from it.
63
+ */
64
+ function assertRepoFieldsMatchLocalRoot(identity, canonicalRoot, expectedWorktreeLabel) {
65
+ if (identity.repo_fingerprint !==
66
+ repoFingerprintFromLocalRoot(canonicalRoot)) {
67
+ throw new Error("Attributed target repo fingerprint does not match its local root.");
68
+ }
69
+ if (identity.repo_label !== expectedWorktreeLabel) {
70
+ throw new Error("Attributed target repo label does not match its local root.");
71
+ }
72
+ if (!identity.worktree_is_primary) {
73
+ throw new Error("Local attributed targets must use their primary local identity.");
74
+ }
75
+ }
@@ -0,0 +1,147 @@
1
+ import { LocalCollectorConfigSchema, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { normalizeCollectionRoots } from "./root-normalization.js";
7
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
8
+ import { isMissingFileError, readJsonFile, writeJsonFile, } from "./local-state-files.js";
9
+ const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
10
+ export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
11
+ ? localCollectorPackage.version
12
+ : "0.0.0";
13
+ export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
14
+ /**
15
+ * `cockpit install` — the one write that registers collection roots, because
16
+ * widening an operator's boundary is a consent decision and nothing else may
17
+ * make it.
18
+ */
19
+ export async function installLocalCollector(options = {}) {
20
+ const homeDir = options.homeDir ?? os.homedir();
21
+ const repoRoots = normalizeRepoRoots(options.repoRoots);
22
+ const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
23
+ const paths = getCollectorRuntimePaths(homeDir);
24
+ await ensureRuntimeDirectories(paths);
25
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
26
+ const defaultRepoPaths = normalizeRepoRoots([
27
+ ...(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? [])),
28
+ ...(repoRoots.length > 0 ? repoRoots : [repoRoot]),
29
+ ]);
30
+ const rawEvidenceUpload = migrateRawEvidenceUploadChoice(existingConfig);
31
+ const config = LocalCollectorConfigSchema.parse({
32
+ schema_version: "telemetry-core.v1",
33
+ dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
34
+ supabase_url: options.supabaseUrl ?? existingConfig?.supabase_url,
35
+ collector_version: LOCAL_COLLECTOR_VERSION,
36
+ device_id: existingConfig?.device_id ?? `device-${crypto.randomUUID()}`,
37
+ device_name: normalizeDeviceName(options.deviceName) ??
38
+ existingConfig?.device_name ??
39
+ defaultDeviceName(),
40
+ claimed_owner_email: existingConfig?.claimed_owner_email,
41
+ operator_id: existingConfig?.operator_id,
42
+ default_repo_paths: defaultRepoPaths,
43
+ raw_evidence_upload: rawEvidenceUpload,
44
+ session_file_path: paths.session_file,
45
+ state_dir_path: paths.state_dir,
46
+ });
47
+ await writeJsonFile(paths.config_file, config);
48
+ return {
49
+ config,
50
+ paths,
51
+ auth_pairing_state: "missing",
52
+ message: "Local collector installed. Pair/login is still required before remote upload.",
53
+ };
54
+ }
55
+ /**
56
+ * The one on-disk migration this file performs: both retired raw-evidence
57
+ * choices become the durable one, so a machine installed before durable
58
+ * evidence existed stops holding the old answer forever.
59
+ */
60
+ function migrateRawEvidenceUploadChoice(existingConfig) {
61
+ return existingConfig?.raw_evidence_upload === "disabled" ||
62
+ existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
63
+ ? "remote_durable_opt_in"
64
+ : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
65
+ }
66
+ function normalizeRepoRoots(repoRoots) {
67
+ if (!repoRoots)
68
+ return [];
69
+ const seen = new Set();
70
+ const candidates = [];
71
+ for (const root of repoRoots) {
72
+ const resolved = path.resolve(root);
73
+ if (seen.has(resolved))
74
+ continue;
75
+ seen.add(resolved);
76
+ candidates.push(resolved);
77
+ }
78
+ return normalizeCollectionRoots(candidates);
79
+ }
80
+ /**
81
+ * Reads the collector config, creating a minimal rootless one when the file
82
+ * does not exist yet (fresh machine where `cockpit onboard`/`install` has not
83
+ * run). Never registers collection roots: root consent stays with
84
+ * onboard/install. A corrupt existing config is never overwritten.
85
+ */
86
+ export async function ensureLocalCollectorConfig(options = {}) {
87
+ const homeDir = options.homeDir ?? os.homedir();
88
+ const paths = getCollectorRuntimePaths(homeDir);
89
+ await ensureRuntimeDirectories(paths);
90
+ let existing = null;
91
+ try {
92
+ existing = await readLocalCollectorConfig(paths);
93
+ }
94
+ catch (error) {
95
+ if (!isMissingFileError(error)) {
96
+ const reason = error instanceof Error ? error.message : String(error);
97
+ throw new Error(`Local collector config at ${paths.config_file} is unreadable (${reason}). ` +
98
+ "Run `cockpit onboard` to repair it.");
99
+ }
100
+ }
101
+ if (existing)
102
+ return { config: existing, paths, created: false };
103
+ const config = LocalCollectorConfigSchema.parse({
104
+ schema_version: "telemetry-core.v1",
105
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? DEFAULT_DASHBOARD_URL),
106
+ collector_version: LOCAL_COLLECTOR_VERSION,
107
+ device_id: `device-${crypto.randomUUID()}`,
108
+ device_name: defaultDeviceName(),
109
+ default_repo_paths: [],
110
+ session_file_path: paths.session_file,
111
+ state_dir_path: paths.state_dir,
112
+ });
113
+ await writeJsonFile(paths.config_file, config);
114
+ return { config, paths, created: true };
115
+ }
116
+ /**
117
+ * Every caller that needs to know how this machine is configured comes through
118
+ * here, so a config that exists and will not parse fails the same way for all
119
+ * of them instead of each inventing a default.
120
+ */
121
+ export async function readLocalCollectorConfig(paths) {
122
+ return LocalCollectorConfigSchema.parse(await readJsonFile(paths.config_file));
123
+ }
124
+ /**
125
+ * A trailing slash in a dashboard URL produces `//api/...` request paths that
126
+ * some proxies answer differently, so the stored value is normalized once.
127
+ */
128
+ export function normalizeDashboardUrl(value) {
129
+ const normalized = value.trim().replace(/\/+$/, "");
130
+ if (!normalized)
131
+ throw new Error("Dashboard URL cannot be empty.");
132
+ return normalized;
133
+ }
134
+ /** The device name an operator sees in the dashboard before they pick one. */
135
+ export function defaultDeviceName() {
136
+ return normalizeDeviceName(os.hostname()) ?? "Local machine";
137
+ }
138
+ /** Keeps a hand-typed or hostname-derived device name inside what the schema accepts. */
139
+ export function normalizeDeviceName(value) {
140
+ const trimmed = value?.trim().replace(/\s+/g, " ");
141
+ return trimmed ? trimmed.slice(0, 120) : undefined;
142
+ }
143
+ /** A claimed owner email is a label a person types, so an unusable one is dropped rather than stored. */
144
+ export function normalizeOptionalEmail(value) {
145
+ const trimmed = value?.trim().toLowerCase();
146
+ return trimmed && trimmed.includes("@") ? trimmed : undefined;
147
+ }
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
4
+ /**
5
+ * The one reader behind the config file, the session file and every work
6
+ * context — and therefore the one place worth reporting from.
7
+ *
8
+ * Roughly twenty call sites swallow this to `null` or a default with
9
+ * `.catch(() => null)`, each of them asking a reasonable question ("is this
10
+ * machine set up?") to which "no" is a legitimate answer. What none of them
11
+ * could distinguish is "no, nothing is there" from "yes, and it is corrupt or
12
+ * unreadable" — so the distinction is drawn HERE, once, rather than in twenty
13
+ * places where it would be twenty chances to forget (BLI-3238).
14
+ *
15
+ * The error still propagates unchanged; callers keep whatever they decided.
16
+ */
17
+ export async function readJsonFile(filePath) {
18
+ try {
19
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
20
+ }
21
+ catch (error) {
22
+ // Absent is the ordinary pre-onboarding state on every one of these files
23
+ // and stays quiet; anything else means state exists and cannot be used.
24
+ if (!isMissingFileFailure(error)) {
25
+ console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
26
+ reason: "state_file_unreadable",
27
+ // Which file, without the path: the basename of these is a fixed
28
+ // vocabulary (`config.json`, `session.json`, a work-context
29
+ // fingerprint) and carries no repo or operator name.
30
+ state_file: path.basename(filePath),
31
+ ...describeError(error),
32
+ }));
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ /**
38
+ * The one writer behind every collector state file, because each of them holds
39
+ * a device token or an operator's work and must land owner-only on any host.
40
+ */
41
+ export async function writeJsonFile(filePath, value) {
42
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
43
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
44
+ mode: 0o600,
45
+ });
46
+ if (process.platform !== "win32") {
47
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
48
+ }
49
+ }
50
+ /**
51
+ * Separate from `isMissingFileFailure`: the two callers here hold a raw `fs`
52
+ * rejection rather than a wrapped failure, and "the file was never there" is
53
+ * the one outcome they treat as success rather than an error to report.
54
+ */
55
+ export function isMissingFileError(error) {
56
+ return (error instanceof Error &&
57
+ "code" in error &&
58
+ error.code === "ENOENT");
59
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { repoFingerprintFromLocalRoot, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
4
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
5
+ /**
6
+ * A folder that is not a git repo is a legitimate workspace under the
7
+ * session-first commandment, so this always answers with an identity: git's
8
+ * own when there is one, a path-derived one otherwise. Nothing may be withheld
9
+ * for lack of a repo.
10
+ */
11
+ export async function resolveIdentityOrFallback(repoRoot, branchOverride) {
12
+ const resolvedRoot = await stableWorktreeRoot(repoRoot);
13
+ const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
14
+ if (identity) {
15
+ return branchOverride ? { ...identity, branch: branchOverride } : identity;
16
+ }
17
+ const repoLabel = path.basename(resolvedRoot) || "workspace";
18
+ const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
19
+ const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
20
+ const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
21
+ return {
22
+ requested_path: resolvedRoot,
23
+ repo_root: resolvedRoot,
24
+ repo_label: repoLabel,
25
+ repo_fingerprint: repoFingerprint,
26
+ repo_origin_url: null,
27
+ branch,
28
+ head_sha: null,
29
+ worktree_label: repoLabel,
30
+ worktree_fingerprint: worktreeFingerprint,
31
+ worktree_is_primary: true,
32
+ };
33
+ }
34
+ /**
35
+ * Reads HEAD directly rather than shelling out to git, because this runs on
36
+ * every sync tick on machines where a spawned process is the expensive part.
37
+ */
38
+ export async function resolveGitBranch(repoRoot) {
39
+ try {
40
+ const gitPath = path.join(repoRoot, ".git");
41
+ const stat = await fs.stat(gitPath);
42
+ const headPath = stat.isFile()
43
+ ? path.join(await resolveWorktreeGitDir(gitPath), "HEAD")
44
+ : path.join(gitPath, "HEAD");
45
+ const head = (await fs.readFile(headPath, "utf8")).trim();
46
+ if (head.startsWith("ref: refs/heads/")) {
47
+ return head.slice("ref: refs/heads/".length);
48
+ }
49
+ return head ? `detached:${head.slice(0, 12)}` : "unknown";
50
+ }
51
+ catch (error) {
52
+ // A folder that is not a git repo is a legitimate workspace under the
53
+ // session-first commandment, so a missing `.git` stays quiet. A `.git`
54
+ // that exists and cannot be read is a different thing: every session
55
+ // collected from this repo gets branch `unknown` and nothing says why.
56
+ if (!isMissingFileFailure(error)) {
57
+ console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
58
+ reason: "git_head_unreadable",
59
+ ...describeError(error),
60
+ }));
61
+ }
62
+ return "unknown";
63
+ }
64
+ }
65
+ /** A linked worktree's `.git` is a file pointing elsewhere, so HEAD is not where it looks. */
66
+ async function resolveWorktreeGitDir(gitFile) {
67
+ const raw = await fs.readFile(gitFile, "utf8");
68
+ const match = raw.match(/^gitdir:\s*(.+)$/m);
69
+ if (!match)
70
+ return path.dirname(gitFile);
71
+ const gitDir = match[1].trim();
72
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
73
+ }
@@ -0,0 +1,263 @@
1
+ import { LocalCollectorSessionFileSchema, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
6
+ import { serverFailureDetail } from "./upload-http.js";
7
+ import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
8
+ import { isMissingFileError, writeJsonFile } from "./local-state-files.js";
9
+ import { defaultDeviceName, LOCAL_COLLECTOR_VERSION, normalizeDashboardUrl, normalizeDeviceName, normalizeOptionalEmail, readLocalCollectorConfig, } from "./local-state-config.js";
10
+ import { toSessionReference } from "./local-state-session.js";
11
+ /**
12
+ * `cockpit login` — the whole device-pairing handshake, because a machine can
13
+ * collect locally without it but can never upload until the dashboard has
14
+ * approved this device.
15
+ */
16
+ export async function pairLocalCollector(options = {}) {
17
+ const homeDir = options.homeDir ?? os.homedir();
18
+ const paths = getCollectorRuntimePaths(homeDir);
19
+ await ensureRuntimeDirectories(paths);
20
+ const config = await readConfigOrRefusePairing(paths);
21
+ const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
22
+ const device = resolvePairingDeviceIdentity(config, options);
23
+ await recordDeviceIdentityWhenChanged(paths, config, device);
24
+ const fetchImpl = options.fetch ?? globalThis.fetch;
25
+ if (!fetchImpl) {
26
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
27
+ }
28
+ const startResponse = await postPairStart(fetchImpl, dashboardUrl, {
29
+ device_id: device.deviceId,
30
+ device_name: device.deviceName,
31
+ claimed_owner_email: device.claimedOwnerEmail,
32
+ collector_version: LOCAL_COLLECTOR_VERSION,
33
+ }, options.pairingAccessToken);
34
+ options.onPairStarted?.(startResponse);
35
+ const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
36
+ paths,
37
+ startResponse,
38
+ pollIntervalMs: options.pollIntervalMs,
39
+ timeoutMs: options.timeoutMs,
40
+ sleep: options.sleep,
41
+ });
42
+ return {
43
+ status: "paired",
44
+ session: toSessionReference(sessionFile),
45
+ session_file: paths.session_file,
46
+ dashboard_url: dashboardUrl,
47
+ approve_url: startResponse.approve_url,
48
+ };
49
+ }
50
+ async function readConfigOrRefusePairing(paths) {
51
+ return readLocalCollectorConfig(paths).catch((error) => {
52
+ // "Local config missing" is correct when it is absent. A config that
53
+ // exists and will not parse gets the same sentence and the same useless
54
+ // advice — run onboard again, which will not fix it (BLI-3238).
55
+ if (!isMissingFileFailure(error)) {
56
+ console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
57
+ reason: "config_unreadable",
58
+ ...describeError(error),
59
+ }));
60
+ }
61
+ throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
62
+ });
63
+ }
64
+ function resolvePairingDeviceIdentity(config, options) {
65
+ return {
66
+ deviceId: config.device_id ?? `device-${crypto.randomUUID()}`,
67
+ deviceName: normalizeDeviceName(options.deviceName) ??
68
+ config.device_name ??
69
+ defaultDeviceName(),
70
+ claimedOwnerEmail: normalizeOptionalEmail(options.claimedOwnerEmail) ??
71
+ config.claimed_owner_email,
72
+ };
73
+ }
74
+ /**
75
+ * The dashboard will approve the identity this request claims, so the config
76
+ * has to be holding that same identity before the request goes out.
77
+ */
78
+ async function recordDeviceIdentityWhenChanged(paths, config, device) {
79
+ if (!config.device_id ||
80
+ config.device_name !== device.deviceName ||
81
+ (device.claimedOwnerEmail &&
82
+ config.claimed_owner_email !== device.claimedOwnerEmail)) {
83
+ await writeJsonFile(paths.config_file, {
84
+ ...config,
85
+ device_id: device.deviceId,
86
+ device_name: device.deviceName,
87
+ claimed_owner_email: device.claimedOwnerEmail,
88
+ });
89
+ }
90
+ }
91
+ /**
92
+ * `cockpit logout` — removing the session file is the whole revocation on this
93
+ * side, and an absent file is success, not a failure to report.
94
+ */
95
+ export async function logoutLocalCollector(options = {}) {
96
+ const homeDir = options.homeDir ?? os.homedir();
97
+ const paths = getCollectorRuntimePaths(homeDir);
98
+ let removed = false;
99
+ try {
100
+ await fs.unlink(paths.session_file);
101
+ removed = true;
102
+ }
103
+ catch (error) {
104
+ if (!isMissingFileError(error))
105
+ throw error;
106
+ }
107
+ return { removed, session_file: paths.session_file };
108
+ }
109
+ async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
110
+ const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
111
+ method: "POST",
112
+ headers: {
113
+ "Content-Type": "application/json",
114
+ ...(accessToken ? { "Authorization": `Bearer ${accessToken}` } : {}),
115
+ },
116
+ body: JSON.stringify(body),
117
+ });
118
+ const parsed = await readResponseJson(response);
119
+ if (!response.ok) {
120
+ throw new Error(pairFailureMessage("Pair request failed", response, parsed));
121
+ }
122
+ return parsePairStartResponse(parsed);
123
+ }
124
+ async function pollPairRequest(fetchImpl, dashboardUrl, options) {
125
+ const pollIntervalMs = options.pollIntervalMs ?? options.startResponse.poll_after_ms;
126
+ const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
127
+ const sleepImpl = options.sleep ??
128
+ ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() <= deadline) {
131
+ const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/poll`, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({
135
+ request_id: options.startResponse.request_id,
136
+ client_secret: options.startResponse.client_secret,
137
+ }),
138
+ });
139
+ const parsed = await readResponseJson(response);
140
+ if (!response.ok) {
141
+ throw new Error(pairFailureMessage("Pair polling failed", response, parsed));
142
+ }
143
+ const status = readStringField(parsed, "status");
144
+ if (status === "approved") {
145
+ const session = parsePairApprovedSession(parsed, options.paths.session_file);
146
+ await writeJsonFile(options.paths.session_file, session);
147
+ return session;
148
+ }
149
+ if (status === "expired") {
150
+ throw new Error("Pair request expired. Run `cockpit login` again.");
151
+ }
152
+ if (status === "revoked") {
153
+ throw new Error("Pair request was revoked. Run `cockpit login` again.");
154
+ }
155
+ if (status !== "pending") {
156
+ throw new Error(`Unexpected pair request status: ${status}`);
157
+ }
158
+ await sleepImpl(Math.max(250, pollIntervalMs));
159
+ }
160
+ throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
161
+ }
162
+ /**
163
+ * What `cockpit login` tells the operator when pairing is refused.
164
+ *
165
+ * "Pair request failed" was the whole message — five words, while the status
166
+ * code sat in hand (BLI-3483). A 401 (this build's token is not accepted), a
167
+ * 403 (the dashboard knows the device and is refusing it), a 404 (wrong
168
+ * dashboard URL) and a 502 (something in front of the dashboard answered) are
169
+ * four different next actions, and the operator could not tell them apart.
170
+ * `upload.ts` has named its HTTP status since it was written; this is the same
171
+ * shape. The server's own words come first when it supplied any, and the status
172
+ * always rides at the end so it is never the thing that got dropped.
173
+ *
174
+ * Logged as well as thrown: `cockpit login` failures happen on a machine that
175
+ * is not collecting yet, so the terminal is the only receipt there is.
176
+ */
177
+ function pairFailureMessage(fallback, response, body) {
178
+ const serverWords = responseErrorMessage(body, fallback);
179
+ console.error("[local-state] pairing request refused", JSON.stringify({
180
+ reason: fallback === "Pair request failed" ? "pair_start_refused" : "pair_poll_refused",
181
+ http_status: response.status,
182
+ server_reason: serverFailureDetail(body) ?? "none",
183
+ }));
184
+ return `${serverWords} (HTTP ${response.status})`;
185
+ }
186
+ async function readResponseJson(response) {
187
+ const text = await response.text();
188
+ if (!text)
189
+ return {};
190
+ try {
191
+ return JSON.parse(text);
192
+ }
193
+ catch {
194
+ // Pairing path. A captive portal or a proxy answering with HTML is the
195
+ // classic reason `cockpit login` fails on a new machine and the operator
196
+ // sees only "pair request failed". The body is never logged; its shape is.
197
+ console.error("[local-state] pairing reply was not JSON", JSON.stringify({
198
+ reason: "response_body_not_json",
199
+ http_status: response.status,
200
+ byte_size: text.length,
201
+ content_type: response.headers.get("content-type") ?? "none",
202
+ }));
203
+ return { message: text };
204
+ }
205
+ }
206
+ function parsePairStartResponse(value) {
207
+ if (!value || typeof value !== "object") {
208
+ throw new Error("Pair request response was not an object.");
209
+ }
210
+ const record = value;
211
+ return {
212
+ request_id: requiredString(record, "request_id"),
213
+ user_code: requiredString(record, "user_code"),
214
+ approve_url: requiredString(record, "approve_url"),
215
+ expires_at: requiredString(record, "expires_at"),
216
+ poll_after_ms: requiredNumber(record, "poll_after_ms"),
217
+ client_secret: requiredString(record, "client_secret"),
218
+ };
219
+ }
220
+ function parsePairApprovedSession(value, sessionFilePath) {
221
+ if (!value || typeof value !== "object") {
222
+ throw new Error("Pair approval response was not an object.");
223
+ }
224
+ const session = value["session"];
225
+ if (!session || typeof session !== "object") {
226
+ throw new Error("Pair approval response did not include a session.");
227
+ }
228
+ return LocalCollectorSessionFileSchema.parse({
229
+ ...session,
230
+ session_file_path: sessionFilePath,
231
+ });
232
+ }
233
+ function responseErrorMessage(value, fallback) {
234
+ if (value && typeof value === "object") {
235
+ const record = value;
236
+ const message = record["message"] ?? record["error"];
237
+ if (typeof message === "string" && message.trim())
238
+ return message;
239
+ }
240
+ return fallback;
241
+ }
242
+ function readStringField(value, field) {
243
+ if (value && typeof value === "object") {
244
+ const entry = value[field];
245
+ if (typeof entry === "string")
246
+ return entry;
247
+ }
248
+ throw new Error(`Pair response missing ${field}.`);
249
+ }
250
+ function requiredString(record, field) {
251
+ const value = record[field];
252
+ if (typeof value !== "string" || !value.trim()) {
253
+ throw new Error(`Pair response missing ${field}.`);
254
+ }
255
+ return value;
256
+ }
257
+ function requiredNumber(record, field) {
258
+ const value = record[field];
259
+ if (typeof value !== "number" || !Number.isFinite(value)) {
260
+ throw new Error(`Pair response missing ${field}.`);
261
+ }
262
+ return value;
263
+ }
@@ -0,0 +1,61 @@
1
+ import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describeError } from "./health-detail.js";
6
+ /**
7
+ * The one place a collector file's address is computed, so no caller joins a
8
+ * state path by hand and drifts from the durable on-disk layout.
9
+ */
10
+ export function getCollectorRuntimePaths(homeDir = os.homedir()) {
11
+ const paths = getUserLocalCockpitPaths(homeDir);
12
+ return {
13
+ ...paths,
14
+ active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
15
+ work_contexts_dir: path.join(paths.state_dir, "work-contexts"),
16
+ };
17
+ }
18
+ /**
19
+ * Every write below assumes its directory exists and is owner-only, so this
20
+ * runs before the first one on a fresh machine and after every upgrade.
21
+ */
22
+ export async function ensureRuntimeDirectories(paths) {
23
+ await fs.mkdir(paths.config_dir, { recursive: true, mode: 0o700 });
24
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
25
+ await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
26
+ await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
27
+ await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
28
+ if (process.platform !== "win32") {
29
+ // The `mode` above only applies to directories this call CREATES, so these
30
+ // chmods are what actually tightens a directory that already existed with
31
+ // looser bits. Failing means the operator's device token and cursors stay
32
+ // world-readable — non-fatal, deliberately, but not something to find out
33
+ // about never (BLI-3238). Reported by directory name only, never a path.
34
+ const tightened = [
35
+ ["config_dir", fs.chmod(paths.config_dir, 0o700)],
36
+ ["state_dir", fs.chmod(paths.state_dir, 0o700)],
37
+ ["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
38
+ ["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
39
+ ["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
40
+ ];
41
+ await Promise.all(tightened.map(async ([name, work]) => {
42
+ try {
43
+ await work;
44
+ }
45
+ catch (error) {
46
+ console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
47
+ reason: "runtime_dir_chmod_failed",
48
+ directory: name,
49
+ ...describeError(error),
50
+ }));
51
+ }
52
+ }));
53
+ }
54
+ }
55
+ /**
56
+ * One file per worktree fingerprint, so two checkouts of the same repo keep
57
+ * separate contexts instead of overwriting each other.
58
+ */
59
+ export function workContextFile(paths, worktreeFingerprint) {
60
+ return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
61
+ }