@bli-cockpit/cli 0.1.0
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/README.md +26 -0
- package/dist/adapters/car-state.js +103 -0
- package/dist/adapters/common.js +35 -0
- package/dist/adapters/git-state.js +133 -0
- package/dist/adapters/local-sources.js +59 -0
- package/dist/adapters/risk-flags.js +67 -0
- package/dist/adapters/ticket-binding.js +65 -0
- package/dist/cli.js +5 -0
- package/dist/commands/local.js +699 -0
- package/dist/commands/public-root.js +32 -0
- package/dist/local-state.js +471 -0
- package/dist/server.js +99 -0
- package/dist/spool/local-spool.js +145 -0
- package/dist/upload.js +321 -0
- package/package.json +30 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { localCommandHelp, runLocalCockpitCli, rootCommandNames } from "./local.js";
|
|
2
|
+
|
|
3
|
+
export async function runCockpitCli(argv, io) {
|
|
4
|
+
const command = argv[0];
|
|
5
|
+
if (!command || command === "--help" || command === "-h") {
|
|
6
|
+
writeLine(io?.stdout ?? process.stdout, cockpitHelp());
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (rootCommandNames.has(command)) {
|
|
11
|
+
return runLocalCockpitCli(argv, io);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
writeLine(io?.stderr ?? process.stderr, `Unknown command: ${command}`);
|
|
15
|
+
writeLine(io?.stderr ?? process.stderr, "");
|
|
16
|
+
writeLine(io?.stderr ?? process.stderr, cockpitHelp());
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cockpitHelp() {
|
|
21
|
+
return [
|
|
22
|
+
"Usage:",
|
|
23
|
+
localCommandHelp(),
|
|
24
|
+
"",
|
|
25
|
+
"Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
|
|
26
|
+
"Manual collector path: `install`, `login`, `start [--ticket <id>]`, `sync`, `status`.",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeLine(stream, text) {
|
|
31
|
+
stream.write(`${text}\n`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { getUserLocalCockpitPaths, LocalCollectorSessionFileSchema, LocalCollectorConfigSchema, LocalUserSessionReferenceSchema, LocalWorkContextSchema, } 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 path from "node:path";
|
|
6
|
+
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
7
|
+
export const LOCAL_COLLECTOR_VERSION = "0.1.0";
|
|
8
|
+
export const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:3100";
|
|
9
|
+
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
10
|
+
const paths = getUserLocalCockpitPaths(homeDir);
|
|
11
|
+
return {
|
|
12
|
+
...paths,
|
|
13
|
+
active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export async function installLocalCollector(options = {}) {
|
|
17
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
18
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
19
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
20
|
+
await ensureRuntimeDirectories(paths);
|
|
21
|
+
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
22
|
+
const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
|
|
23
|
+
defaultRepoPaths.add(repoRoot);
|
|
24
|
+
const config = LocalCollectorConfigSchema.parse({
|
|
25
|
+
schema_version: "telemetry-core.v1",
|
|
26
|
+
dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
|
|
27
|
+
supabase_url: options.supabaseUrl ?? existingConfig?.supabase_url,
|
|
28
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
29
|
+
device_id: existingConfig?.device_id ?? `device-${crypto.randomUUID()}`,
|
|
30
|
+
device_name: normalizeDeviceName(options.deviceName) ??
|
|
31
|
+
existingConfig?.device_name ??
|
|
32
|
+
defaultDeviceName(),
|
|
33
|
+
claimed_owner_email: existingConfig?.claimed_owner_email,
|
|
34
|
+
operator_id: existingConfig?.operator_id,
|
|
35
|
+
default_repo_paths: [...defaultRepoPaths],
|
|
36
|
+
raw_evidence_upload: existingConfig?.raw_evidence_upload ?? "disabled",
|
|
37
|
+
session_file_path: paths.session_file,
|
|
38
|
+
state_dir_path: paths.state_dir,
|
|
39
|
+
});
|
|
40
|
+
await writeJsonFile(paths.config_file, config);
|
|
41
|
+
return {
|
|
42
|
+
config,
|
|
43
|
+
paths,
|
|
44
|
+
auth_pairing_state: "missing",
|
|
45
|
+
message: "Local collector installed. Pair/login is still required before remote upload.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function pairLocalCollector(options = {}) {
|
|
49
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
50
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
51
|
+
await ensureRuntimeDirectories(paths);
|
|
52
|
+
const config = await readLocalCollectorConfig(paths).catch(() => {
|
|
53
|
+
throw new Error("Local config missing. Run `cockpit install` before `cockpit login`.");
|
|
54
|
+
});
|
|
55
|
+
const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
|
|
56
|
+
const deviceId = config.device_id ?? `device-${crypto.randomUUID()}`;
|
|
57
|
+
const deviceName = normalizeDeviceName(options.deviceName) ??
|
|
58
|
+
config.device_name ??
|
|
59
|
+
defaultDeviceName();
|
|
60
|
+
const claimedOwnerEmail = normalizeOptionalEmail(options.claimedOwnerEmail) ??
|
|
61
|
+
config.claimed_owner_email;
|
|
62
|
+
if (!config.device_id ||
|
|
63
|
+
config.device_name !== deviceName ||
|
|
64
|
+
(claimedOwnerEmail && config.claimed_owner_email !== claimedOwnerEmail)) {
|
|
65
|
+
await writeJsonFile(paths.config_file, {
|
|
66
|
+
...config,
|
|
67
|
+
device_id: deviceId,
|
|
68
|
+
device_name: deviceName,
|
|
69
|
+
claimed_owner_email: claimedOwnerEmail,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
73
|
+
if (!fetchImpl) {
|
|
74
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
75
|
+
}
|
|
76
|
+
const startResponse = await postPairStart(fetchImpl, dashboardUrl, {
|
|
77
|
+
device_id: deviceId,
|
|
78
|
+
device_name: deviceName,
|
|
79
|
+
claimed_owner_email: claimedOwnerEmail,
|
|
80
|
+
collector_version: config.collector_version ?? LOCAL_COLLECTOR_VERSION,
|
|
81
|
+
});
|
|
82
|
+
options.onPairStarted?.(startResponse);
|
|
83
|
+
const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
|
|
84
|
+
paths,
|
|
85
|
+
startResponse,
|
|
86
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
87
|
+
timeoutMs: options.timeoutMs,
|
|
88
|
+
sleep: options.sleep,
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
status: "paired",
|
|
92
|
+
session: toSessionReference(sessionFile),
|
|
93
|
+
session_file: paths.session_file,
|
|
94
|
+
dashboard_url: dashboardUrl,
|
|
95
|
+
approve_url: startResponse.approve_url,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export async function logoutLocalCollector(options = {}) {
|
|
99
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
100
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
101
|
+
let removed = false;
|
|
102
|
+
try {
|
|
103
|
+
await fs.unlink(paths.session_file);
|
|
104
|
+
removed = true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!isMissingFileError(error))
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
return { removed, session_file: paths.session_file };
|
|
111
|
+
}
|
|
112
|
+
export async function startLocalWorkContext(options = {}) {
|
|
113
|
+
const now = options.now ?? new Date();
|
|
114
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
115
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
116
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
117
|
+
await ensureRuntimeDirectories(paths);
|
|
118
|
+
const session = await readLocalSessionReference(paths, {
|
|
119
|
+
operatorId: options.operatorId,
|
|
120
|
+
sessionId: options.sessionId,
|
|
121
|
+
});
|
|
122
|
+
const branch = options.branch ?? (await resolveGitBranch(repoRoot));
|
|
123
|
+
const existingContext = await readLocalWorkContext(paths).catch(() => null);
|
|
124
|
+
const workContextId = existingContext?.work_context_id ?? `work-${crypto.randomUUID()}`;
|
|
125
|
+
const sessionId = options.sessionId ??
|
|
126
|
+
(session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
|
|
127
|
+
const operatorId = options.operatorId ?? session.operator_id;
|
|
128
|
+
const ticketBindingCandidates = options.activeTicketId
|
|
129
|
+
? [
|
|
130
|
+
{
|
|
131
|
+
ticket_id: options.activeTicketId,
|
|
132
|
+
binding_source: "active_work_context",
|
|
133
|
+
confidence: 1,
|
|
134
|
+
evidence_labels: ["cockpit_start_ticket"],
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
: [];
|
|
138
|
+
const context = LocalWorkContextSchema.parse({
|
|
139
|
+
work_context_id: workContextId,
|
|
140
|
+
repo: repoRoot,
|
|
141
|
+
branch,
|
|
142
|
+
operator_id: operatorId,
|
|
143
|
+
session_id: sessionId,
|
|
144
|
+
started_at: existingContext?.started_at ?? now.toISOString(),
|
|
145
|
+
updated_at: now.toISOString(),
|
|
146
|
+
active_ticket_id: options.activeTicketId ?? undefined,
|
|
147
|
+
ticket_binding_candidates: ticketBindingCandidates,
|
|
148
|
+
pull_request_url: existingContext?.pull_request_url,
|
|
149
|
+
provenance: {
|
|
150
|
+
capture_source: "collector_runtime",
|
|
151
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
152
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
153
|
+
repo: repoRoot,
|
|
154
|
+
branch,
|
|
155
|
+
operator_id: operatorId,
|
|
156
|
+
session_id: sessionId,
|
|
157
|
+
work_context_id: workContextId,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await writeJsonFile(paths.active_work_context_file, context);
|
|
161
|
+
return context;
|
|
162
|
+
}
|
|
163
|
+
export async function inspectLocalCollectorStatus(options = {}) {
|
|
164
|
+
const now = options.now ?? new Date();
|
|
165
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
166
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
167
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
168
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
169
|
+
const session = await readLocalSessionReference(paths, {
|
|
170
|
+
operatorId: options.operatorId,
|
|
171
|
+
sessionId: options.sessionId,
|
|
172
|
+
});
|
|
173
|
+
const context = await readLocalWorkContext(paths).catch(() => null);
|
|
174
|
+
const branch = options.branch ?? (await resolveGitBranch(repoRoot));
|
|
175
|
+
const freshness = classifyCollectorFreshness(context, now);
|
|
176
|
+
const uploadSpool = await summarizeLocalUploadSpool(paths);
|
|
177
|
+
const uploadState = !config
|
|
178
|
+
? "not_installed"
|
|
179
|
+
: uploadSpool.pending_upload_count > 0
|
|
180
|
+
? "retry_pending"
|
|
181
|
+
: session.session_state === "valid"
|
|
182
|
+
? "ready"
|
|
183
|
+
: "local_only_missing_auth";
|
|
184
|
+
const details = [];
|
|
185
|
+
details.push(config
|
|
186
|
+
? `Config exists at ${paths.config_file}.`
|
|
187
|
+
: "Local config missing. Run `cockpit install`.");
|
|
188
|
+
details.push(session.session_state === "valid"
|
|
189
|
+
? "Local user session is valid."
|
|
190
|
+
: "Local user session missing or not paired; upload remains local-only.");
|
|
191
|
+
details.push(context
|
|
192
|
+
? `Active context ${context.work_context_id} last updated ${context.updated_at ?? context.started_at}.`
|
|
193
|
+
: "Active work context missing. Run `cockpit start`.");
|
|
194
|
+
details.push(uploadSpool.last_upload_attempt_at
|
|
195
|
+
? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
|
|
196
|
+
: "No upload has been attempted yet.");
|
|
197
|
+
details.push(uploadSpool.last_upload_success_at
|
|
198
|
+
? `Last upload success: ${uploadSpool.last_upload_success_at}.`
|
|
199
|
+
: "No successful upload recorded yet.");
|
|
200
|
+
if (uploadSpool.last_upload_failure_reason) {
|
|
201
|
+
details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
|
|
202
|
+
}
|
|
203
|
+
if (uploadSpool.pending_upload_count > 0) {
|
|
204
|
+
details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
installed: Boolean(config),
|
|
208
|
+
config_file: paths.config_file,
|
|
209
|
+
session_file: paths.session_file,
|
|
210
|
+
session_state: session.session_state,
|
|
211
|
+
repo: repoRoot,
|
|
212
|
+
branch,
|
|
213
|
+
active_ticket_id: context?.active_ticket_id ?? null,
|
|
214
|
+
work_context_id: context?.work_context_id ?? null,
|
|
215
|
+
collector_freshness: freshness,
|
|
216
|
+
upload_state: uploadState,
|
|
217
|
+
last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
|
|
218
|
+
last_upload_success_at: uploadSpool.last_upload_success_at,
|
|
219
|
+
last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
|
|
220
|
+
pending_upload_count: uploadSpool.pending_upload_count,
|
|
221
|
+
upload_retry_command: uploadSpool.retry_command,
|
|
222
|
+
details,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export async function readLocalCollectorConfig(paths) {
|
|
226
|
+
return LocalCollectorConfigSchema.parse(await readJsonFile(paths.config_file));
|
|
227
|
+
}
|
|
228
|
+
export async function readLocalWorkContext(paths) {
|
|
229
|
+
return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
|
|
230
|
+
}
|
|
231
|
+
export async function readLocalSessionReference(paths, fallback = {}) {
|
|
232
|
+
try {
|
|
233
|
+
const rawSession = await readJsonFile(paths.session_file);
|
|
234
|
+
const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
|
|
235
|
+
if (collectorSession.success) {
|
|
236
|
+
return toSessionReference(collectorSession.data);
|
|
237
|
+
}
|
|
238
|
+
return LocalUserSessionReferenceSchema.parse(rawSession);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return LocalUserSessionReferenceSchema.parse({
|
|
242
|
+
operator_id: fallback.operatorId ?? "unknown",
|
|
243
|
+
auth_subject_id: "unknown",
|
|
244
|
+
session_id: fallback.sessionId ?? "missing",
|
|
245
|
+
session_file_path: paths.session_file,
|
|
246
|
+
session_state: "missing",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export async function readLocalCollectorSessionFile(paths) {
|
|
251
|
+
return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
|
|
252
|
+
}
|
|
253
|
+
export async function resolveGitBranch(repoRoot) {
|
|
254
|
+
try {
|
|
255
|
+
const gitPath = path.join(repoRoot, ".git");
|
|
256
|
+
const stat = await fs.stat(gitPath);
|
|
257
|
+
const headPath = stat.isFile()
|
|
258
|
+
? path.join(await resolveWorktreeGitDir(gitPath), "HEAD")
|
|
259
|
+
: path.join(gitPath, "HEAD");
|
|
260
|
+
const head = (await fs.readFile(headPath, "utf8")).trim();
|
|
261
|
+
if (head.startsWith("ref: refs/heads/")) {
|
|
262
|
+
return head.slice("ref: refs/heads/".length);
|
|
263
|
+
}
|
|
264
|
+
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function resolveWorktreeGitDir(gitFile) {
|
|
271
|
+
const raw = await fs.readFile(gitFile, "utf8");
|
|
272
|
+
const match = raw.match(/^gitdir:\s*(.+)$/m);
|
|
273
|
+
if (!match)
|
|
274
|
+
return path.dirname(gitFile);
|
|
275
|
+
const gitDir = match[1].trim();
|
|
276
|
+
return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
|
|
277
|
+
}
|
|
278
|
+
async function ensureRuntimeDirectories(paths) {
|
|
279
|
+
await fs.mkdir(paths.config_dir, { recursive: true, mode: 0o700 });
|
|
280
|
+
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
281
|
+
await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
|
|
282
|
+
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
283
|
+
if (process.platform !== "win32") {
|
|
284
|
+
await Promise.all([
|
|
285
|
+
fs.chmod(paths.config_dir, 0o700).catch(() => undefined),
|
|
286
|
+
fs.chmod(paths.state_dir, 0o700).catch(() => undefined),
|
|
287
|
+
fs.chmod(paths.spool_dir, 0o700).catch(() => undefined),
|
|
288
|
+
fs.chmod(paths.cursors_dir, 0o700).catch(() => undefined),
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function readJsonFile(filePath) {
|
|
293
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
294
|
+
}
|
|
295
|
+
async function writeJsonFile(filePath, value) {
|
|
296
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
297
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
298
|
+
mode: 0o600,
|
|
299
|
+
});
|
|
300
|
+
if (process.platform !== "win32") {
|
|
301
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function classifyCollectorFreshness(context, now) {
|
|
305
|
+
if (!context)
|
|
306
|
+
return "missing";
|
|
307
|
+
const updatedAt = Date.parse(context.updated_at ?? context.started_at);
|
|
308
|
+
if (!Number.isFinite(updatedAt))
|
|
309
|
+
return "stale";
|
|
310
|
+
return now.getTime() - updatedAt <= 5 * 60 * 1000 ? "fresh" : "stale";
|
|
311
|
+
}
|
|
312
|
+
function normalizeDashboardUrl(value) {
|
|
313
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
314
|
+
if (!normalized)
|
|
315
|
+
throw new Error("Dashboard URL cannot be empty.");
|
|
316
|
+
return normalized;
|
|
317
|
+
}
|
|
318
|
+
async function postPairStart(fetchImpl, dashboardUrl, body) {
|
|
319
|
+
const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "Content-Type": "application/json" },
|
|
322
|
+
body: JSON.stringify(body),
|
|
323
|
+
});
|
|
324
|
+
const parsed = await readResponseJson(response);
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
throw new Error(responseErrorMessage(parsed, "Pair request failed"));
|
|
327
|
+
}
|
|
328
|
+
return parsePairStartResponse(parsed);
|
|
329
|
+
}
|
|
330
|
+
async function pollPairRequest(fetchImpl, dashboardUrl, options) {
|
|
331
|
+
const pollIntervalMs = options.pollIntervalMs ?? options.startResponse.poll_after_ms;
|
|
332
|
+
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
|
333
|
+
const sleepImpl = options.sleep ??
|
|
334
|
+
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
335
|
+
const deadline = Date.now() + timeoutMs;
|
|
336
|
+
while (Date.now() <= deadline) {
|
|
337
|
+
const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/poll`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "Content-Type": "application/json" },
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
request_id: options.startResponse.request_id,
|
|
342
|
+
client_secret: options.startResponse.client_secret,
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
const parsed = await readResponseJson(response);
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
throw new Error(responseErrorMessage(parsed, "Pair polling failed"));
|
|
348
|
+
}
|
|
349
|
+
const status = readStringField(parsed, "status");
|
|
350
|
+
if (status === "approved") {
|
|
351
|
+
const session = parsePairApprovedSession(parsed, options.paths.session_file);
|
|
352
|
+
await writeJsonFile(options.paths.session_file, session);
|
|
353
|
+
return session;
|
|
354
|
+
}
|
|
355
|
+
if (status === "expired") {
|
|
356
|
+
throw new Error("Pair request expired. Run `cockpit login` again.");
|
|
357
|
+
}
|
|
358
|
+
if (status === "revoked") {
|
|
359
|
+
throw new Error("Pair request was revoked. Run `cockpit login` again.");
|
|
360
|
+
}
|
|
361
|
+
if (status !== "pending") {
|
|
362
|
+
throw new Error(`Unexpected pair request status: ${status}`);
|
|
363
|
+
}
|
|
364
|
+
await sleepImpl(Math.max(250, pollIntervalMs));
|
|
365
|
+
}
|
|
366
|
+
throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
|
|
367
|
+
}
|
|
368
|
+
async function readResponseJson(response) {
|
|
369
|
+
const text = await response.text();
|
|
370
|
+
if (!text)
|
|
371
|
+
return {};
|
|
372
|
+
try {
|
|
373
|
+
return JSON.parse(text);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return { message: text };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function parsePairStartResponse(value) {
|
|
380
|
+
if (!value || typeof value !== "object") {
|
|
381
|
+
throw new Error("Pair request response was not an object.");
|
|
382
|
+
}
|
|
383
|
+
const record = value;
|
|
384
|
+
return {
|
|
385
|
+
request_id: requiredString(record, "request_id"),
|
|
386
|
+
user_code: requiredString(record, "user_code"),
|
|
387
|
+
approve_url: requiredString(record, "approve_url"),
|
|
388
|
+
expires_at: requiredString(record, "expires_at"),
|
|
389
|
+
poll_after_ms: requiredNumber(record, "poll_after_ms"),
|
|
390
|
+
client_secret: requiredString(record, "client_secret"),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function parsePairApprovedSession(value, sessionFilePath) {
|
|
394
|
+
if (!value || typeof value !== "object") {
|
|
395
|
+
throw new Error("Pair approval response was not an object.");
|
|
396
|
+
}
|
|
397
|
+
const session = value["session"];
|
|
398
|
+
if (!session || typeof session !== "object") {
|
|
399
|
+
throw new Error("Pair approval response did not include a session.");
|
|
400
|
+
}
|
|
401
|
+
return LocalCollectorSessionFileSchema.parse({
|
|
402
|
+
...session,
|
|
403
|
+
session_file_path: sessionFilePath,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function toSessionReference(session) {
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const expiresAt = Date.parse(session.expires_at ?? "");
|
|
409
|
+
const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
|
|
410
|
+
return LocalUserSessionReferenceSchema.parse({
|
|
411
|
+
operator_id: session.operator_id,
|
|
412
|
+
auth_subject_id: session.auth_subject_id,
|
|
413
|
+
email: session.email,
|
|
414
|
+
team_id: session.team_id,
|
|
415
|
+
device_id: session.device_id,
|
|
416
|
+
device_name: session.device_name,
|
|
417
|
+
session_id: session.session_id,
|
|
418
|
+
session_file_path: session.session_file_path,
|
|
419
|
+
session_state: sessionState,
|
|
420
|
+
auth_method: session.auth_method,
|
|
421
|
+
issued_at: session.issued_at,
|
|
422
|
+
expires_at: session.expires_at,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function defaultDeviceName() {
|
|
426
|
+
return normalizeDeviceName(os.hostname()) ?? "Local machine";
|
|
427
|
+
}
|
|
428
|
+
function normalizeDeviceName(value) {
|
|
429
|
+
const trimmed = value?.trim().replace(/\s+/g, " ");
|
|
430
|
+
return trimmed ? trimmed.slice(0, 120) : undefined;
|
|
431
|
+
}
|
|
432
|
+
function normalizeOptionalEmail(value) {
|
|
433
|
+
const trimmed = value?.trim().toLowerCase();
|
|
434
|
+
return trimmed && trimmed.includes("@") ? trimmed : undefined;
|
|
435
|
+
}
|
|
436
|
+
function responseErrorMessage(value, fallback) {
|
|
437
|
+
if (value && typeof value === "object") {
|
|
438
|
+
const record = value;
|
|
439
|
+
const message = record["message"] ?? record["error"];
|
|
440
|
+
if (typeof message === "string" && message.trim())
|
|
441
|
+
return message;
|
|
442
|
+
}
|
|
443
|
+
return fallback;
|
|
444
|
+
}
|
|
445
|
+
function readStringField(value, field) {
|
|
446
|
+
if (value && typeof value === "object") {
|
|
447
|
+
const entry = value[field];
|
|
448
|
+
if (typeof entry === "string")
|
|
449
|
+
return entry;
|
|
450
|
+
}
|
|
451
|
+
throw new Error(`Pair response missing ${field}.`);
|
|
452
|
+
}
|
|
453
|
+
function requiredString(record, field) {
|
|
454
|
+
const value = record[field];
|
|
455
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
456
|
+
throw new Error(`Pair response missing ${field}.`);
|
|
457
|
+
}
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
function requiredNumber(record, field) {
|
|
461
|
+
const value = record[field];
|
|
462
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
463
|
+
throw new Error(`Pair response missing ${field}.`);
|
|
464
|
+
}
|
|
465
|
+
return value;
|
|
466
|
+
}
|
|
467
|
+
function isMissingFileError(error) {
|
|
468
|
+
return (error instanceof Error &&
|
|
469
|
+
"code" in error &&
|
|
470
|
+
error.code === "ENOENT");
|
|
471
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { getCollectorRuntimePaths, inspectLocalCollectorStatus, readLocalSessionReference, readLocalWorkContext, } from "./local-state.js";
|
|
2
|
+
import { runLocalSourceCollectors } from "./adapters/local-sources.js";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
export function createCollectorServer(options = {}) {
|
|
5
|
+
return http.createServer(async (request, response) => {
|
|
6
|
+
if (!isLoopbackHostHeader(request.headers.host)) {
|
|
7
|
+
sendJson(response, 403, {
|
|
8
|
+
ok: false,
|
|
9
|
+
error: "Non-loopback Host header rejected.",
|
|
10
|
+
});
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (request.method !== "GET") {
|
|
14
|
+
sendJson(response, 405, { ok: false, error: "Only GET is supported." });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
18
|
+
try {
|
|
19
|
+
switch (url.pathname) {
|
|
20
|
+
case "/health":
|
|
21
|
+
sendJson(response, 200, {
|
|
22
|
+
ok: true,
|
|
23
|
+
service: "bli-cockpit-local-collector",
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
26
|
+
case "/context":
|
|
27
|
+
sendJson(response, 200, await contextPayload(options));
|
|
28
|
+
return;
|
|
29
|
+
case "/sources":
|
|
30
|
+
sendJson(response, 200, await sourcesPayload(options));
|
|
31
|
+
return;
|
|
32
|
+
case "/flags":
|
|
33
|
+
sendJson(response, 200, await flagsPayload(options));
|
|
34
|
+
return;
|
|
35
|
+
default:
|
|
36
|
+
sendJson(response, 404, { ok: false, error: "Not found." });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
sendJson(response, 500, {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: error instanceof Error ? error.message : String(error),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function isLoopbackHostHeader(hostHeader) {
|
|
48
|
+
if (!hostHeader)
|
|
49
|
+
return true;
|
|
50
|
+
const normalized = hostHeader.trim().toLowerCase();
|
|
51
|
+
const withoutPort = normalized.startsWith("[")
|
|
52
|
+
? normalized.slice(0, normalized.indexOf("]") + 1)
|
|
53
|
+
: normalized.split(":")[0];
|
|
54
|
+
return withoutPort === "127.0.0.1" || withoutPort === "localhost" || withoutPort === "[::1]";
|
|
55
|
+
}
|
|
56
|
+
async function contextPayload(options) {
|
|
57
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
58
|
+
const context = await readLocalWorkContext(paths).catch(() => null);
|
|
59
|
+
return { context };
|
|
60
|
+
}
|
|
61
|
+
async function sourcesPayload(options) {
|
|
62
|
+
const collected = await collectLocalSources(options);
|
|
63
|
+
return {
|
|
64
|
+
status: collected.status,
|
|
65
|
+
sources: collected.sources.scans,
|
|
66
|
+
binding: collected.sources.binding,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function flagsPayload(options) {
|
|
70
|
+
const collected = await collectLocalSources(options);
|
|
71
|
+
return { flags: collected.sources.risk_flags };
|
|
72
|
+
}
|
|
73
|
+
async function collectLocalSources(options) {
|
|
74
|
+
const status = await inspectLocalCollectorStatus(options);
|
|
75
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
76
|
+
const activeContext = await readLocalWorkContext(paths).catch(() => null);
|
|
77
|
+
const session = await readLocalSessionReference(paths);
|
|
78
|
+
const workContextId = activeContext?.work_context_id ?? status.work_context_id ?? "status-only";
|
|
79
|
+
const sources = await runLocalSourceCollectors({
|
|
80
|
+
repoRoot: status.repo,
|
|
81
|
+
branch: status.branch,
|
|
82
|
+
operatorId: session.operator_id,
|
|
83
|
+
sessionId: session.session_id,
|
|
84
|
+
workContextId,
|
|
85
|
+
activeWorkContext: activeContext,
|
|
86
|
+
now: options.now,
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
status,
|
|
90
|
+
sources,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function sendJson(response, statusCode, value) {
|
|
94
|
+
response.writeHead(statusCode, {
|
|
95
|
+
"content-type": "application/json; charset=utf-8",
|
|
96
|
+
"cache-control": "no-store",
|
|
97
|
+
});
|
|
98
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
99
|
+
}
|