@bli-cockpit/cli 0.1.0 → 0.1.2

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 CHANGED
@@ -2,25 +2,90 @@
2
2
 
3
3
  Public BLI Cockpit command-line interface for approved operators and interns.
4
4
 
5
- The CLI pairs a local laptop with the private Cockpit dashboard, records safe
6
- metadata-only work context, and uploads that metadata after dashboard-approved
5
+ The npm package is public; the Cockpit backend and admin tooling are not. The
6
+ CLI pairs a local laptop with the private Cockpit dashboard, records safe
7
+ work context, uploads consented raw Codex/diff evidence to private
8
+ durable private storage, and uploads metadata refs after dashboard-approved
7
9
  device pairing.
8
10
 
11
+ ## One-paste install
12
+
13
+ Run this from the repo where work will happen:
14
+
9
15
  ```bash
10
- npm exec --yes --package=@bli-cockpit/cli -- cockpit onboard \
11
- --dashboard-url <dashboard-url> \
12
- --email <approved-email> \
13
- --device-name "<device-name>" \
16
+ COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
17
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit onboard \
18
+ --dashboard-url <DASHBOARD_URL> \
19
+ --email <APPROVED_EMAIL> \
20
+ --device-name "$COCKPIT_DEVICE_NAME" \
14
21
  --repo "$PWD"
15
22
  ```
16
23
 
24
+ What happens:
25
+
26
+ 1. npm downloads the public `@bli-cockpit/cli` package.
27
+ 2. `cockpit onboard` writes local user config.
28
+ 3. Cockpit prints a dashboard pairing URL and code.
29
+ 4. The signed-in intern approves that exact code, or an Admin approves it while
30
+ watching the same code.
31
+ 5. The CLI starts general ambient capture, uploads private raw evidence objects
32
+ when present, then uploads one safe metadata/ref envelope.
33
+ 6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
34
+
35
+ `--device-name` is just a readable label in Cockpit. It can be
36
+ `"Savina MacBook"`, `"Box VM 42"`, or the auto-filled macOS computer name.
37
+ On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
38
+ `cockpit onboard` skips pairing only when the existing valid session belongs to
39
+ that same email and dashboard URL; a different email or dashboard forces a new
40
+ approval.
41
+
42
+ Before the command runs, an Admin must create or approve the email in the
43
+ private dashboard. Default launch path is email + temporary password, handed
44
+ over out of band. Magic link remains available as fallback.
45
+ For temporary-password accounts, the intern signs in directly; there is no
46
+ separate invite acceptance step.
47
+
17
48
  When ticket work starts later:
18
49
 
19
50
  ```bash
20
- npm exec --yes --package=@bli-cockpit/cli -- cockpit start \
51
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit start \
21
52
  --ticket <ticket-id> \
22
53
  --repo "$PWD"
54
+
55
+ npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync \
56
+ --dashboard-url <DASHBOARD_URL> \
57
+ --repo "$PWD" \
58
+ --json
23
59
  ```
24
60
 
61
+ No ticket is required for setup, chatting, planning, or general ambient capture.
62
+ Only pass `--ticket` when the work really belongs to a visible ticket.
63
+
64
+ ## What gets saved
65
+
66
+ Local files:
67
+
68
+ - `~/.config/bli-cockpit/config.json`: dashboard URL and local install config.
69
+ - `~/.config/bli-cockpit/session.json`: normal paired device session.
70
+ - `~/.local/state/bli-cockpit/spool/`: safe retry records when upload fails.
71
+ - `.codex-autorunner/contextspace/active_context.md` in the work repo when a
72
+ work context is active.
73
+
74
+ Remote dashboard:
75
+
76
+ - approved user and device identity;
77
+ - repo, branch, optional ticket, source availability, risk flags, and upload
78
+ timestamps;
79
+ - raw evidence refs accepted by `/api/ambient/ingest`;
80
+ - durable private Storage objects accepted by
81
+ `/api/ambient/evidence/upload`.
82
+
83
+ Never provide Supabase service-role keys, raw DB URLs, root env files, cookies,
84
+ or deployment tokens to this CLI. The collector must never read env files.
85
+
86
+ ## Public package boundary
87
+
25
88
  This public package intentionally excludes Cockpit admin bootstrap commands,
26
89
  service-role credential handling, source maps, tests, and internal runbooks.
90
+ Clean `npm pack` and `npm publish` run the public CLI build before packaging so
91
+ `dist/cli.js` is present in emergency releases.
@@ -1,6 +1,7 @@
1
1
  import { collectCarState } from "./car-state.js";
2
2
  import { makeUnavailableScan, } from "./common.js";
3
3
  import { collectGitState } from "./git-state.js";
4
+ import { collectRawEvidencePack, } from "./raw-evidence.js";
4
5
  import { generateDumbRiskFlags } from "./risk-flags.js";
5
6
  import { resolveTicketBinding, } from "./ticket-binding.js";
6
7
  export async function runLocalSourceCollectors(options) {
@@ -14,8 +15,17 @@ export async function runLocalSourceCollectors(options) {
14
15
  };
15
16
  const git = await collectGitState(context);
16
17
  const car = await collectCarState(context);
18
+ const rawEvidence = options.rawEvidenceStateDir
19
+ ? await collectRawEvidencePack(context, {
20
+ stateDir: options.rawEvidenceStateDir,
21
+ repoRoot: options.repoRoot,
22
+ sessionsDir: options.rawEvidenceSessionsDir,
23
+ })
24
+ : {
25
+ scan: makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "raw_evidence_state_dir_not_configured"),
26
+ facts: null,
27
+ };
17
28
  const unavailableScans = [
18
- makeUnavailableScan(context, "codex_jsonl", "codex-jsonl", "codex_session_file_not_configured"),
19
29
  makeUnavailableScan(context, "codex_otel", "codex-otel", "codex_otel_export_not_configured"),
20
30
  makeUnavailableScan(context, "github_state", "github-state", "github_auth_not_configured"),
21
31
  makeUnavailableScan(context, "linear_state", "linear-state", "linear_auth_not_configured"),
@@ -25,6 +35,7 @@ export async function runLocalSourceCollectors(options) {
25
35
  const candidates = collectBindingCandidates([
26
36
  git.scan,
27
37
  car.scan,
38
+ rawEvidence.scan,
28
39
  ...unavailableScans,
29
40
  ]);
30
41
  const binding = resolveTicketBinding({
@@ -33,16 +44,20 @@ export async function runLocalSourceCollectors(options) {
33
44
  });
34
45
  const gitFacts = isGitStateFacts(git.facts) ? git.facts : null;
35
46
  const carFacts = isCarStateFacts(car.facts) ? car.facts : null;
47
+ const rawEvidenceFacts = isRawEvidenceFacts(rawEvidence.facts)
48
+ ? rawEvidence.facts
49
+ : null;
36
50
  const riskFlags = generateDumbRiskFlags({
37
51
  context,
38
52
  git: gitFacts,
39
53
  binding,
40
54
  });
41
55
  return {
42
- scans: [git.scan, car.scan, ...unavailableScans],
56
+ scans: [git.scan, car.scan, rawEvidence.scan, ...unavailableScans],
43
57
  facts: {
44
58
  git: gitFacts,
45
59
  car: carFacts,
60
+ raw_evidence: rawEvidenceFacts,
46
61
  },
47
62
  binding,
48
63
  risk_flags: riskFlags,
@@ -56,4 +71,7 @@ function isGitStateFacts(value) {
56
71
  }
57
72
  function isCarStateFacts(value) {
58
73
  return Boolean(value && typeof value === "object" && "present" in value);
74
+ }
75
+ function isRawEvidenceFacts(value) {
76
+ return Boolean(value && typeof value === "object" && "pack_id" in value);
59
77
  }
@@ -0,0 +1,395 @@
1
+ import { SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
+ import { execFile } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { makeSourceAdapterIdentity, } from "./common.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const DEFAULT_SINCE_MINUTES = 24 * 60;
11
+ const DEFAULT_SESSION_LIMIT = 50;
12
+ const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
13
+ export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
14
+ export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
15
+ const SECRET_FILE_SEGMENT_PATTERN = /(^|[/\\])(?:\.env(?:\..*)?|.*(?:secret|credential|private[_-]?key|service[_-]?role).*)$/i;
16
+ export async function collectRawEvidencePack(context, options) {
17
+ const startedAt = context.now.toISOString();
18
+ const packId = rawEvidencePackId(context);
19
+ const evidenceDir = path.join(options.stateDir, "raw-evidence", packId);
20
+ const filesDir = path.join(evidenceDir, "files");
21
+ const entries = [];
22
+ const skipped = [];
23
+ try {
24
+ await ensurePrivateDir(evidenceDir);
25
+ await ensurePrivateDir(filesDir);
26
+ await collectCodexJsonlFiles({
27
+ context,
28
+ filesDir,
29
+ packId,
30
+ entries,
31
+ skipped,
32
+ sessionsDir: options.sessionsDir,
33
+ sinceMinutes: options.sinceMinutes ?? DEFAULT_SINCE_MINUTES,
34
+ limit: options.sessionLimit ?? DEFAULT_SESSION_LIMIT,
35
+ });
36
+ await collectGitDiffFiles({
37
+ context,
38
+ filesDir,
39
+ packId,
40
+ repoRoot: options.repoRoot,
41
+ entries,
42
+ skipped,
43
+ });
44
+ if (entries.length === 0) {
45
+ const facts = {
46
+ pack_id: packId,
47
+ manifest_path: path.join(evidenceDir, "manifest.json"),
48
+ evidence_dir: evidenceDir,
49
+ storage_bucket: RAW_EVIDENCE_BUCKET,
50
+ file_count: 0,
51
+ byte_size: 0,
52
+ skipped_count: skipped.length,
53
+ content_kinds: [],
54
+ pointers: [],
55
+ upload_files: [],
56
+ };
57
+ return {
58
+ facts,
59
+ scan: makeRawEvidenceScan({
60
+ context,
61
+ startedAt,
62
+ status: "partial",
63
+ facts,
64
+ }),
65
+ };
66
+ }
67
+ const manifestWithoutSelf = makeManifest({
68
+ context,
69
+ packId,
70
+ evidenceDir,
71
+ entries,
72
+ skipped,
73
+ });
74
+ const manifestPath = path.join(evidenceDir, "manifest.json");
75
+ const manifestBytes = Buffer.from(`${JSON.stringify(manifestWithoutSelf, null, 2)}\n`, "utf8");
76
+ await fs.writeFile(manifestPath, manifestBytes, { mode: 0o600 });
77
+ await chmodPrivate(manifestPath, 0o600);
78
+ const manifestEntry = evidenceEntry({
79
+ kind: "manifest",
80
+ packId,
81
+ operatorId: context.operatorId,
82
+ workContextId: context.workContextId,
83
+ localPath: manifestPath,
84
+ relativePath: "manifest.json",
85
+ mediaType: "application/json",
86
+ redactedSummary: "Local raw evidence pack manifest.",
87
+ bytes: manifestBytes,
88
+ });
89
+ entries.push(manifestEntry);
90
+ const facts = {
91
+ pack_id: packId,
92
+ manifest_path: manifestPath,
93
+ evidence_dir: evidenceDir,
94
+ storage_bucket: RAW_EVIDENCE_BUCKET,
95
+ file_count: entries.length,
96
+ byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
97
+ skipped_count: skipped.length,
98
+ content_kinds: [...new Set(entries.map((entry) => entry.kind))],
99
+ pointers: entries.map(pointerFromEntry),
100
+ upload_files: entries.map((entry) => ({
101
+ pointer: pointerFromEntry(entry),
102
+ local_path: entry.local_path,
103
+ })),
104
+ };
105
+ return {
106
+ facts,
107
+ scan: makeRawEvidenceScan({
108
+ context,
109
+ startedAt,
110
+ status: entries.length > 1 ? "ok" : "partial",
111
+ facts,
112
+ }),
113
+ };
114
+ }
115
+ catch (error) {
116
+ const scan = SourceScanResultSchema.parse({
117
+ adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
118
+ work_context_id: context.workContextId,
119
+ status: "failed",
120
+ started_at: startedAt,
121
+ finished_at: context.now.toISOString(),
122
+ diagnostic_labels: [
123
+ `raw_evidence_failed:${error instanceof Error ? error.message : String(error)}`,
124
+ ],
125
+ });
126
+ return { scan, facts: null };
127
+ }
128
+ }
129
+ function makeRawEvidenceScan(options) {
130
+ return SourceScanResultSchema.parse({
131
+ adapter: makeSourceAdapterIdentity("collector_runtime", "raw-evidence-pack"),
132
+ work_context_id: options.context.workContextId,
133
+ status: options.status,
134
+ started_at: options.startedAt,
135
+ finished_at: options.context.now.toISOString(),
136
+ events: [
137
+ {
138
+ source_event_id: `raw-evidence-pack:${options.facts.pack_id}`,
139
+ event_type: "raw_evidence_pack_written",
140
+ occurred_at: options.context.now.toISOString(),
141
+ redaction: {
142
+ privacy_classification: "remote_durable_raw_evidence",
143
+ redaction_status: "raw_remote_durable",
144
+ redacted_fields: ["local_path"],
145
+ raw_evidence_pointer_ids: options.facts.pointers.map((pointer) => pointer.raw_evidence_pointer_id),
146
+ redacted_summary: "Raw prompt/response/tool/session/diff evidence harvested to a private durable remote evidence pack.",
147
+ },
148
+ raw_evidence_pointers: options.facts.pointers,
149
+ },
150
+ ],
151
+ diagnostic_labels: [
152
+ `pack_id:${options.facts.pack_id}`,
153
+ `files:${options.facts.file_count}`,
154
+ `bytes:${options.facts.byte_size}`,
155
+ `skipped:${options.facts.skipped_count}`,
156
+ ...options.facts.content_kinds.map((kind) => `kind:${kind}`),
157
+ ],
158
+ });
159
+ }
160
+ async function collectCodexJsonlFiles(options) {
161
+ const sessionsDir = options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
162
+ const cutoffMs = options.context.now.getTime() - options.sinceMinutes * 60 * 1000;
163
+ const files = (await walkJsonlFiles(sessionsDir, cutoffMs)).slice(0, options.limit);
164
+ let index = 0;
165
+ for (const filePath of files) {
166
+ const fileName = path.basename(filePath);
167
+ if (isSecretLikePath(fileName)) {
168
+ options.skipped.push({
169
+ kind: "codex_jsonl",
170
+ label: fileName,
171
+ reason: "secret_like_file_name",
172
+ });
173
+ continue;
174
+ }
175
+ const raw = await fs.readFile(filePath);
176
+ if (containsSecretLikeContent(raw.toString("utf8"))) {
177
+ options.skipped.push({
178
+ kind: "codex_jsonl",
179
+ label: fileName,
180
+ reason: "secret_like_content_guard",
181
+ });
182
+ continue;
183
+ }
184
+ index += 1;
185
+ const relativePath = path.join("files", `${String(index).padStart(3, "0")}-${shortHash(filePath)}-${fileName}`);
186
+ const destination = path.join(options.filesDir, path.basename(relativePath));
187
+ await fs.writeFile(destination, raw, { mode: 0o600 });
188
+ await chmodPrivate(destination, 0o600);
189
+ options.entries.push(evidenceEntry({
190
+ kind: "codex_jsonl",
191
+ packId: options.packId,
192
+ operatorId: options.context.operatorId,
193
+ workContextId: options.context.workContextId,
194
+ localPath: destination,
195
+ relativePath,
196
+ mediaType: "application/jsonl",
197
+ redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
198
+ bytes: raw,
199
+ }));
200
+ }
201
+ }
202
+ async function collectGitDiffFiles(options) {
203
+ const diffTargets = [
204
+ { label: "unstaged", args: ["diff", "--no-ext-diff", "--"] },
205
+ { label: "staged", args: ["diff", "--cached", "--no-ext-diff", "--"] },
206
+ ];
207
+ for (const target of diffTargets) {
208
+ const diff = await runGitDiff(target.args, options.repoRoot);
209
+ if (!diff.trim())
210
+ continue;
211
+ if (containsSecretLikeContent(diff)) {
212
+ options.skipped.push({
213
+ kind: "git_diff",
214
+ label: target.label,
215
+ reason: "secret_like_content_guard",
216
+ });
217
+ continue;
218
+ }
219
+ const raw = Buffer.from(diff.slice(0, MAX_GIT_DIFF_BYTES), "utf8");
220
+ const relativePath = path.join("files", `git-${target.label}.diff`);
221
+ const destination = path.join(options.filesDir, path.basename(relativePath));
222
+ await fs.writeFile(destination, raw, { mode: 0o600 });
223
+ await chmodPrivate(destination, 0o600);
224
+ options.entries.push(evidenceEntry({
225
+ kind: "git_diff",
226
+ packId: options.packId,
227
+ operatorId: options.context.operatorId,
228
+ workContextId: options.context.workContextId,
229
+ localPath: destination,
230
+ relativePath,
231
+ mediaType: "text/x-diff",
232
+ redactedSummary: `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
233
+ bytes: raw,
234
+ }));
235
+ }
236
+ }
237
+ async function runGitDiff(args, repoRoot) {
238
+ const pathspec = [
239
+ ".",
240
+ ":(exclude).env",
241
+ ":(exclude).env.*",
242
+ ":(exclude)**/.env",
243
+ ":(exclude)**/.env.*",
244
+ ":(exclude)**/*secret*",
245
+ ":(exclude)**/*credential*",
246
+ ":(exclude)**/*private-key*",
247
+ ":(exclude)**/*.pem",
248
+ ":(exclude)**/*.key",
249
+ ];
250
+ const { stdout } = await execFileAsync("git", [...args, ...pathspec], {
251
+ cwd: repoRoot,
252
+ timeout: 3_000,
253
+ maxBuffer: MAX_GIT_DIFF_BYTES + 1024,
254
+ });
255
+ return stdout;
256
+ }
257
+ async function walkJsonlFiles(dir, cutoffMs) {
258
+ const out = [];
259
+ const stack = [dir];
260
+ while (stack.length > 0) {
261
+ const current = stack.pop();
262
+ if (!current || isSecretLikePath(current))
263
+ continue;
264
+ let entries;
265
+ try {
266
+ entries = await fs.readdir(current, { withFileTypes: true });
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ for (const entry of entries) {
272
+ const full = path.join(current, entry.name);
273
+ if (isSecretLikePath(full))
274
+ continue;
275
+ if (entry.isDirectory()) {
276
+ stack.push(full);
277
+ continue;
278
+ }
279
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
280
+ continue;
281
+ const stat = await fs.stat(full);
282
+ if (stat.mtimeMs >= cutoffMs)
283
+ out.push({ file: full, mtimeMs: stat.mtimeMs });
284
+ }
285
+ }
286
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
287
+ return out.map((entry) => entry.file);
288
+ }
289
+ function makeManifest(options) {
290
+ return {
291
+ schema: "bli.local_raw_evidence_pack.v1",
292
+ pack_id: options.packId,
293
+ created_at: options.context.now.toISOString(),
294
+ work_context_id: options.context.workContextId,
295
+ session_id: options.context.sessionId,
296
+ operator_id: options.context.operatorId,
297
+ repo_basename: path.basename(options.context.repoRoot),
298
+ branch: options.context.branch,
299
+ storage_bucket: RAW_EVIDENCE_BUCKET,
300
+ raw_policy: {
301
+ raw_prompts: "preserved_private_durable_remote",
302
+ raw_responses: "preserved_private_durable_remote",
303
+ transcripts: "preserved_private_durable_remote",
304
+ tool_payloads: "preserved_private_durable_remote",
305
+ git_diffs: "preserved_private_durable_remote_env_secret_paths_excluded",
306
+ env_files: "never_read",
307
+ stdout: "manifest_only_no_raw_content",
308
+ },
309
+ files: options.entries.map(redactManifestEntry),
310
+ skipped: options.skipped,
311
+ };
312
+ }
313
+ function evidenceEntry(options) {
314
+ const digest = sha256(options.bytes);
315
+ return {
316
+ kind: options.kind,
317
+ local_path: options.localPath,
318
+ relative_path: options.relativePath,
319
+ object_key: remoteObjectKey({
320
+ operatorId: options.operatorId,
321
+ workContextId: options.workContextId,
322
+ packId: options.packId,
323
+ relativePath: options.relativePath,
324
+ }),
325
+ content_hash_sha256: digest,
326
+ byte_size: options.bytes.byteLength,
327
+ media_type: options.mediaType,
328
+ redacted_summary: options.redactedSummary,
329
+ };
330
+ }
331
+ function redactManifestEntry(entry) {
332
+ return {
333
+ kind: entry.kind,
334
+ relative_path: entry.relative_path,
335
+ object_key: entry.object_key,
336
+ content_hash_sha256: entry.content_hash_sha256,
337
+ byte_size: entry.byte_size,
338
+ media_type: entry.media_type,
339
+ redacted_summary: entry.redacted_summary,
340
+ };
341
+ }
342
+ function pointerFromEntry(entry) {
343
+ return {
344
+ raw_evidence_pointer_id: entry.object_key,
345
+ privacy_classification: "remote_durable_raw_evidence",
346
+ retention_policy: {
347
+ mode: RAW_EVIDENCE_RETENTION_MODE,
348
+ privacy_classification: "remote_durable_raw_evidence",
349
+ },
350
+ storage_scope: "remote_object",
351
+ storage_bucket: RAW_EVIDENCE_BUCKET,
352
+ object_key: entry.object_key,
353
+ content_hash_sha256: entry.content_hash_sha256,
354
+ byte_size: entry.byte_size,
355
+ media_type: entry.media_type,
356
+ redacted_summary: entry.redacted_summary,
357
+ };
358
+ }
359
+ function remoteObjectKey(options) {
360
+ return posixPath([
361
+ options.operatorId,
362
+ options.workContextId,
363
+ options.packId,
364
+ options.relativePath,
365
+ ]);
366
+ }
367
+ function posixPath(parts) {
368
+ return parts.join("/").replace(/\\/g, "/").replace(/\/+/g, "/");
369
+ }
370
+ function rawEvidencePackId(context) {
371
+ const material = [
372
+ context.workContextId,
373
+ context.sessionId,
374
+ context.now.toISOString(),
375
+ ].join(":");
376
+ return `${context.workContextId}-${shortHash(material)}`;
377
+ }
378
+ function shortHash(value) {
379
+ return crypto.createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12);
380
+ }
381
+ function sha256(value) {
382
+ return crypto.createHash("sha256").update(value).digest("hex");
383
+ }
384
+ async function ensurePrivateDir(dir) {
385
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
386
+ await chmodPrivate(dir, 0o700);
387
+ }
388
+ async function chmodPrivate(target, mode) {
389
+ if (process.platform === "win32")
390
+ return;
391
+ await fs.chmod(target, mode).catch(() => undefined);
392
+ }
393
+ function isSecretLikePath(value) {
394
+ return SECRET_FILE_SEGMENT_PATTERN.test(value);
395
+ }
@@ -1,5 +1,5 @@
1
1
  import { createCollectorServer } from "../server.js";
2
- import { DEFAULT_DASHBOARD_URL, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, startLocalWorkContext, } from "../local-state.js";
2
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
3
3
  import { syncLocalAmbientEnvelope } from "../upload.js";
4
4
  export const rootCommandNames = new Set([
5
5
  "onboard",
@@ -13,6 +13,10 @@ export const rootCommandNames = new Set([
13
13
  "serve",
14
14
  ]);
15
15
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
16
+ if (isLocalHelpRequest(argv)) {
17
+ writeLine(io.stdout, localCommandHelp(argv[0]));
18
+ return 0;
19
+ }
16
20
  let command;
17
21
  try {
18
22
  command = parseLocalArgs(argv);
@@ -48,19 +52,98 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
48
52
  return 1;
49
53
  }
50
54
  }
51
- export function localCommandHelp() {
55
+ export function localCommandHelp(command) {
56
+ if (command)
57
+ return localSubcommandHelp(command);
52
58
  return [
53
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>]",
54
- " cockpit install [--dashboard-url <url>] [--repo <path>]",
55
- " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>]",
56
- " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>]",
59
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
60
+ " cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
61
+ " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
62
+ " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
57
63
  " cockpit logout",
58
- " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>]",
59
- " cockpit sync [--repo <path>] [--dashboard-url <url>]",
60
- " cockpit status [--repo <path>]",
64
+ " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
65
+ " cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
66
+ " cockpit status [--repo <path>] [--json]",
61
67
  " cockpit serve [--port <port>] [--repo <path>]",
62
68
  ].join("\n");
63
69
  }
70
+ function localSubcommandHelp(command) {
71
+ const helpByCommand = new Map([
72
+ [
73
+ "onboard",
74
+ [
75
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
76
+ "",
77
+ "Installs, pairs, starts a work context, syncs once, and prints readiness proof.",
78
+ "Use --email on shared or reused machines; mismatched existing sessions are re-paired.",
79
+ ],
80
+ ],
81
+ [
82
+ "install",
83
+ [
84
+ "Usage: cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
85
+ "",
86
+ "Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
87
+ ],
88
+ ],
89
+ [
90
+ "login",
91
+ [
92
+ "Usage: cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
93
+ "",
94
+ "Starts dashboard device pairing and stores the approved local session.",
95
+ ],
96
+ ],
97
+ [
98
+ "pair",
99
+ [
100
+ "Usage: cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
101
+ "",
102
+ "Alias for `cockpit login`.",
103
+ ],
104
+ ],
105
+ ["logout", ["Usage: cockpit logout [--json]", "", "Removes the local device session."]],
106
+ [
107
+ "start",
108
+ [
109
+ "Usage: cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
110
+ "",
111
+ "Starts local ambient capture. Add --ticket only when the work already has a visible ticket.",
112
+ ],
113
+ ],
114
+ [
115
+ "sync",
116
+ [
117
+ "Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
118
+ "",
119
+ "Uploads the latest local ambient envelope or spools a safe retry if blocked.",
120
+ ],
121
+ ],
122
+ [
123
+ "status",
124
+ [
125
+ "Usage: cockpit status [--repo <path>] [--json]",
126
+ "",
127
+ "Prints install, pairing, active work, upload, and retry state.",
128
+ ],
129
+ ],
130
+ [
131
+ "serve",
132
+ [
133
+ "Usage: cockpit serve [--port <port>] [--repo <path>]",
134
+ "",
135
+ "Starts the local collector HTTP status server.",
136
+ ],
137
+ ],
138
+ ]);
139
+ return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
140
+ }
141
+ function isLocalHelpRequest(argv) {
142
+ const command = argv[0];
143
+ if (!command || !rootCommandNames.has(command))
144
+ return false;
145
+ return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
146
+ }
64
147
  function parseLocalArgs(argv) {
65
148
  const command = argv[0];
66
149
  switch (command) {
@@ -311,7 +394,7 @@ async function runInstall(command, io) {
311
394
  writeLine(io.stdout, `Config: ${result.paths.config_file}`);
312
395
  writeLine(io.stdout, `Session: ${result.paths.session_file}`);
313
396
  writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
314
- writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start --ticket <id>`.");
397
+ writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
315
398
  return 0;
316
399
  }
317
400
  async function runOnboard(command, io) {
@@ -340,12 +423,20 @@ async function runOnboard(command, io) {
340
423
  repoRoot: command.repoRoot,
341
424
  branch: command.branch,
342
425
  });
343
- if (installedStatus.session_state === "valid") {
426
+ const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
427
+ const canReuseInstalledSession = canReuseOnboardSession(installedSession, command.claimedOwnerEmail, command.dashboardUrl);
428
+ if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
344
429
  if (!command.json) {
345
430
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
346
431
  }
347
432
  }
348
433
  else {
434
+ if (installedStatus.session_state === "valid" && !canReuseInstalledSession) {
435
+ await logoutLocalCollector({ homeDir: command.homeDir });
436
+ if (!command.json) {
437
+ writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
438
+ }
439
+ }
349
440
  pair = await pairLocalCollector({
350
441
  homeDir: command.homeDir,
351
442
  dashboardUrl: command.dashboardUrl,
@@ -408,6 +499,7 @@ async function runOnboard(command, io) {
408
499
  writeLine(io.stdout, `Facts: ${sync.event_count}`);
409
500
  writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
410
501
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
502
+ writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
411
503
  writeLine(io.stdout, "5/5 Status ready.");
412
504
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
413
505
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -438,6 +530,36 @@ async function runOnboard(command, io) {
438
530
  return 1;
439
531
  }
440
532
  }
533
+ function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
534
+ if (session.session_state !== "valid")
535
+ return false;
536
+ const expectedEmail = normalizeEmailForComparison(claimedOwnerEmail);
537
+ if (expectedEmail && normalizeEmailForComparison(session.email) !== expectedEmail) {
538
+ return false;
539
+ }
540
+ const expectedDashboardUrl = normalizeUrlForComparison(dashboardUrl);
541
+ if (expectedDashboardUrl &&
542
+ normalizeUrlForComparison(session.dashboard_url) !== expectedDashboardUrl) {
543
+ return false;
544
+ }
545
+ return true;
546
+ }
547
+ function normalizeEmailForComparison(value) {
548
+ const normalized = value?.trim().toLowerCase();
549
+ return normalized ? normalized : null;
550
+ }
551
+ async function readOnboardSessionReuseCandidate(homeDir) {
552
+ const paths = getCollectorRuntimePaths(homeDir);
553
+ try {
554
+ return await readLocalCollectorSessionFile(paths);
555
+ }
556
+ catch {
557
+ return readLocalSessionReference(paths);
558
+ }
559
+ }
560
+ function normalizeUrlForComparison(value) {
561
+ return value ? normalizeUrl(value) : null;
562
+ }
441
563
  async function runLogin(command, io) {
442
564
  const result = await pairLocalCollector({
443
565
  homeDir: command.homeDir,
@@ -566,6 +688,7 @@ async function runSync(command, io) {
566
688
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
567
689
  writeLine(io.stdout, `Facts: ${result.event_count}`);
568
690
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
691
+ writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
569
692
  return 0;
570
693
  }
571
694
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
7
- export const LOCAL_COLLECTOR_VERSION = "0.1.0";
7
+ export const LOCAL_COLLECTOR_VERSION = "0.1.2";
8
8
  export const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:3100";
9
9
  export function getCollectorRuntimePaths(homeDir = os.homedir()) {
10
10
  const paths = getUserLocalCockpitPaths(homeDir);
@@ -21,6 +21,10 @@ export async function installLocalCollector(options = {}) {
21
21
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
22
22
  const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
23
23
  defaultRepoPaths.add(repoRoot);
24
+ const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
25
+ existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
26
+ ? "remote_durable_opt_in"
27
+ : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
24
28
  const config = LocalCollectorConfigSchema.parse({
25
29
  schema_version: "telemetry-core.v1",
26
30
  dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
@@ -33,7 +37,7 @@ export async function installLocalCollector(options = {}) {
33
37
  claimed_owner_email: existingConfig?.claimed_owner_email,
34
38
  operator_id: existingConfig?.operator_id,
35
39
  default_repo_paths: [...defaultRepoPaths],
36
- raw_evidence_upload: existingConfig?.raw_evidence_upload ?? "disabled",
40
+ raw_evidence_upload: rawEvidenceUpload,
37
41
  session_file_path: paths.session_file,
38
42
  state_dir_path: paths.state_dir,
39
43
  });
@@ -120,6 +120,7 @@ function parseUploadSpoolEntry(value) {
120
120
  event_count: optionalNumber(record["event_count"]),
121
121
  source_scan_count: optionalNumber(record["source_scan_count"]),
122
122
  risk_flag_count: optionalNumber(record["risk_flag_count"]),
123
+ raw_evidence_file_count: optionalNumber(record["raw_evidence_file_count"]),
123
124
  failure_reason: failureReason,
124
125
  retry_command: retryCommand ?? "cockpit sync",
125
126
  };
package/dist/upload.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
2
+ import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContext, } from "./local-state.js";
4
5
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
@@ -46,6 +47,8 @@ export async function buildLocalAmbientEnvelope(options = {}) {
46
47
  sessionId: session.session_id,
47
48
  workContextId: uploadContext.work_context_id,
48
49
  activeWorkContext: activeContext,
50
+ rawEvidenceStateDir: paths.state_dir,
51
+ rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
49
52
  now,
50
53
  });
51
54
  const binding = sourceCollection.binding;
@@ -67,6 +70,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
67
70
  gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
68
71
  gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
69
72
  carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
73
+ rawEvidenceFacts: sourceCollection.facts.raw_evidence,
70
74
  riskFlags: safeRiskFlags,
71
75
  }),
72
76
  ];
@@ -89,6 +93,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
89
93
  source_scan_count: envelope.source_scan_results.length,
90
94
  risk_flag_count: safeRiskFlags.length,
91
95
  repo_label: repoLabel,
96
+ raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
92
97
  };
93
98
  }
94
99
  export async function syncLocalAmbientEnvelope(options = {}) {
@@ -111,7 +116,15 @@ export async function syncLocalAmbientEnvelope(options = {}) {
111
116
  if (!fetchImpl) {
112
117
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
113
118
  }
119
+ let uploadedRawEvidenceObjects = [];
114
120
  try {
121
+ uploadedRawEvidenceObjects = await uploadRawEvidenceFiles({
122
+ fetchImpl,
123
+ dashboardUrl: built.dashboard_url,
124
+ deviceToken: built.device_token,
125
+ envelope: built.envelope,
126
+ files: built.raw_evidence_upload_files,
127
+ });
115
128
  const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
116
129
  method: "POST",
117
130
  headers: {
@@ -133,11 +146,22 @@ export async function syncLocalAmbientEnvelope(options = {}) {
133
146
  event_count: built.event_count,
134
147
  source_scan_count: built.source_scan_count,
135
148
  risk_flag_count: built.risk_flag_count,
149
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
136
150
  http_status: response.status,
137
151
  };
138
152
  }
139
153
  catch (error) {
140
154
  const failureReason = error instanceof Error ? error.message : String(error);
155
+ const cleanupFailureReason = await cleanupUploadedRawEvidenceObjects({
156
+ fetchImpl,
157
+ dashboardUrl: built.dashboard_url,
158
+ deviceToken: built.device_token,
159
+ envelope: built.envelope,
160
+ uploadedObjects: uploadedRawEvidenceObjects,
161
+ });
162
+ const spooledFailureReason = cleanupFailureReason
163
+ ? `${failureReason}; raw evidence cleanup failed: ${cleanupFailureReason}`
164
+ : failureReason;
141
165
  const entry = await recordUploadFailure(paths, {
142
166
  last_attempt_at: attemptedAt,
143
167
  dashboard_url: built.dashboard_url,
@@ -148,7 +172,8 @@ export async function syncLocalAmbientEnvelope(options = {}) {
148
172
  event_count: built.event_count,
149
173
  source_scan_count: built.source_scan_count,
150
174
  risk_flag_count: built.risk_flag_count,
151
- failure_reason: failureReason,
175
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
176
+ failure_reason: spooledFailureReason,
152
177
  retry_command: "cockpit sync",
153
178
  });
154
179
  return {
@@ -159,7 +184,8 @@ export async function syncLocalAmbientEnvelope(options = {}) {
159
184
  event_count: built.event_count,
160
185
  source_scan_count: built.source_scan_count,
161
186
  risk_flag_count: built.risk_flag_count,
162
- failure_reason: failureReason,
187
+ raw_evidence_file_count: built.raw_evidence_upload_files.length,
188
+ failure_reason: spooledFailureReason,
163
189
  spool_entry_id: entry.spool_id,
164
190
  retry_command: entry.retry_command,
165
191
  };
@@ -184,15 +210,23 @@ function makeSourceScanCompletedEvent(options) {
184
210
  if (!options.context.provenance) {
185
211
  throw new Error("Upload work context is missing provenance.");
186
212
  }
213
+ const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
214
+ const hasRawEvidence = rawEvidencePointers.length > 0;
187
215
  return TelemetryIngestEventDtoSchema.parse({
188
216
  event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
189
217
  event_type: "source_scan_completed",
190
218
  occurred_at: options.generatedAt,
191
219
  provenance: options.context.provenance,
192
- privacy_classification: "metadata",
220
+ privacy_classification: hasRawEvidence
221
+ ? "remote_durable_raw_evidence"
222
+ : "metadata",
193
223
  redaction: {
194
- privacy_classification: "metadata",
195
- redaction_status: "metadata_only",
224
+ privacy_classification: hasRawEvidence
225
+ ? "remote_durable_raw_evidence"
226
+ : "metadata",
227
+ redaction_status: hasRawEvidence
228
+ ? "raw_remote_durable"
229
+ : "metadata_only",
196
230
  redacted_fields: [
197
231
  "prompt_body",
198
232
  "response_body",
@@ -201,10 +235,14 @@ function makeSourceScanCompletedEvent(options) {
201
235
  "git.changed_paths",
202
236
  "local_file_paths",
203
237
  ],
204
- raw_evidence_pointer_ids: [],
205
- redacted_summary: "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
238
+ raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
239
+ redacted_summary: hasRawEvidence
240
+ ? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
241
+ : "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
206
242
  },
207
- redacted_summary: "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
243
+ redacted_summary: hasRawEvidence
244
+ ? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
245
+ : "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
208
246
  metrics: {
209
247
  git_changed_file_count: options.gitChangedFileCount,
210
248
  git_added_lines: options.gitAddedLines,
@@ -212,6 +250,9 @@ function makeSourceScanCompletedEvent(options) {
212
250
  car_open_ticket_count: options.carOpenTicketCount,
213
251
  source_scan_count: options.scans.length,
214
252
  risk_flag_count: options.riskFlags.length,
253
+ raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
254
+ raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
255
+ raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
215
256
  },
216
257
  attributes: {
217
258
  repo_label: options.context.repo,
@@ -221,12 +262,87 @@ function makeSourceScanCompletedEvent(options) {
221
262
  ticket_id: options.binding.selected_ticket_id ?? "unbound",
222
263
  source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
223
264
  source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
224
- redaction_mode: "metadata_only",
265
+ redaction_mode: hasRawEvidence
266
+ ? "remote_durable_raw_evidence"
267
+ : "metadata_only",
225
268
  raw_payload_included: false,
226
269
  },
227
270
  ticket_binding: options.ticketBinding ?? undefined,
228
271
  risk_flags: options.riskFlags,
229
- raw_evidence_pointers: [],
272
+ raw_evidence_pointers: rawEvidencePointers,
273
+ });
274
+ }
275
+ async function uploadRawEvidenceFiles(options) {
276
+ if (options.files.length === 0)
277
+ return [];
278
+ const provenance = options.envelope.work_context.provenance;
279
+ if (!provenance) {
280
+ throw new Error("Raw evidence upload requires collector provenance.");
281
+ }
282
+ const files = await Promise.all(options.files.map(async (file) => ({
283
+ pointer: file.pointer,
284
+ content_base64: (await fs.readFile(file.local_path)).toString("base64"),
285
+ })));
286
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
287
+ method: "POST",
288
+ headers: {
289
+ "Authorization": `Bearer ${options.deviceToken}`,
290
+ "Content-Type": "application/json",
291
+ },
292
+ body: JSON.stringify({
293
+ schema_version: "ambient-raw-evidence-upload.v1",
294
+ generated_at: options.envelope.generated_at,
295
+ provenance,
296
+ files,
297
+ }),
298
+ });
299
+ const responseBody = await readResponseJson(response);
300
+ if (!response.ok) {
301
+ throw new Error(responseErrorMessage(responseBody, `Raw evidence upload failed with HTTP ${response.status}`));
302
+ }
303
+ return readUploadedRawEvidenceObjects(responseBody);
304
+ }
305
+ async function cleanupUploadedRawEvidenceObjects(options) {
306
+ if (options.uploadedObjects.length === 0)
307
+ return null;
308
+ const provenance = options.envelope.work_context.provenance;
309
+ if (!provenance)
310
+ return "missing collector provenance";
311
+ try {
312
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/upload`, {
313
+ method: "DELETE",
314
+ headers: {
315
+ "Authorization": `Bearer ${options.deviceToken}`,
316
+ "Content-Type": "application/json",
317
+ },
318
+ body: JSON.stringify({
319
+ schema_version: "ambient-raw-evidence-cleanup.v1",
320
+ generated_at: options.envelope.generated_at,
321
+ provenance,
322
+ object_keys: options.uploadedObjects.map((object) => object.object_key),
323
+ }),
324
+ });
325
+ const responseBody = await readResponseJson(response);
326
+ if (!response.ok) {
327
+ return responseErrorMessage(responseBody, `Raw evidence cleanup failed with HTTP ${response.status}`);
328
+ }
329
+ return null;
330
+ }
331
+ catch (error) {
332
+ return error instanceof Error ? error.message : String(error);
333
+ }
334
+ }
335
+ function readUploadedRawEvidenceObjects(value) {
336
+ if (!value || typeof value !== "object")
337
+ return [];
338
+ const uploaded = value.uploaded;
339
+ if (!Array.isArray(uploaded))
340
+ return [];
341
+ return uploaded.flatMap((entry) => {
342
+ if (!entry || typeof entry !== "object")
343
+ return [];
344
+ const objectKey = entry.object_key;
345
+ return typeof objectKey === "string" && objectKey ? [{ object_key: objectKey }] : [];
230
346
  });
231
347
  }
232
348
  function makeCollectorProvenance(options) {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
7
- "cockpit": "dist/cli.js"
7
+ "cockpit": "./dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
@@ -19,12 +19,13 @@
19
19
  "scripts": {
20
20
  "prebuild": "npm run build --workspace=@bli-cockpit/local-collector",
21
21
  "build": "node ../../scripts/build-public-cli.mjs",
22
+ "prepack": "npm run build",
22
23
  "pretypecheck": "npm run build",
23
24
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
24
25
  "pretest": "npm run build",
25
26
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
26
27
  },
27
28
  "dependencies": {
28
- "@bli-cockpit/telemetry-core": "0.1.0"
29
+ "@bli-cockpit/telemetry-core": "0.1.1"
29
30
  }
30
31
  }