@bli-cockpit/cli 0.1.8 → 0.1.11

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.
@@ -1,15 +1,15 @@
1
+ import { execFile } from "node:child_process";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { createCollectorServer } from "../server.js";
4
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
5
- import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
6
- import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
7
- import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
8
- import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
9
- import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
10
- import { readLocalCollectorConfig } from "../local-state.js";
5
+ import { parseLocalArgs, normalizeUrl } from "./local-args.js";
6
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
7
+ import { getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
8
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
9
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
11
10
  import { acquireSyncLock } from "../sync-lock.js";
12
- import { discoverGitWorktrees, } from "../repo-identity.js";
11
+ import { discoverGitWorktrees } from "../repo-identity.js";
12
+ import { runAttributedWorktreeSync } from "./session-sync.js";
13
13
  export const rootCommandNames = new Set([
14
14
  "onboard",
15
15
  "install",
@@ -21,6 +21,7 @@ export const rootCommandNames = new Set([
21
21
  "status",
22
22
  "sessions",
23
23
  "serve",
24
+ "autostart",
24
25
  ]);
25
26
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
26
27
  if (isLocalHelpRequest(argv)) {
@@ -57,6 +58,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
57
58
  return await runSessions(command, io);
58
59
  case "serve":
59
60
  return await runServe(command, io);
61
+ case "autostart":
62
+ return await runAutostart(command, io);
60
63
  }
61
64
  }
62
65
  catch (error) {
@@ -78,6 +81,7 @@ export function localCommandHelp(command) {
78
81
  " cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
79
82
  " cockpit sessions [--source codex|claude] [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
80
83
  " cockpit serve [--port <port>] [--repo <path>]",
84
+ " cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
81
85
  ].join("\n");
82
86
  }
83
87
  function localSubcommandHelp(command) {
@@ -169,6 +173,17 @@ function localSubcommandHelp(command) {
169
173
  "Starts the local collector HTTP status server.",
170
174
  ],
171
175
  ],
176
+ [
177
+ "autostart",
178
+ [
179
+ "Usage: cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
180
+ "",
181
+ "Installs a macOS launchd LaunchAgent that runs `cockpit sync` at login and",
182
+ "every 30 min (default), surviving reboots — so machines never drift to Stale.",
183
+ "Action defaults to `install`. `--repo` is the parent work folder to sync.",
184
+ "macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md.",
185
+ ],
186
+ ],
172
187
  ]);
173
188
  return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
174
189
  }
@@ -178,284 +193,6 @@ function isLocalHelpRequest(argv) {
178
193
  return false;
179
194
  return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
180
195
  }
181
- function parseLocalArgs(argv) {
182
- const command = argv[0];
183
- switch (command) {
184
- case "onboard":
185
- return parseOnboardArgs(argv.slice(1));
186
- case "install":
187
- return parseInstallArgs(argv.slice(1));
188
- case "login":
189
- case "pair":
190
- return parseLoginArgs(argv.slice(1));
191
- case "logout":
192
- return parseLogoutArgs(argv.slice(1));
193
- case "start":
194
- return parseStartArgs(argv.slice(1));
195
- case "sync":
196
- return parseSyncArgs(argv.slice(1));
197
- case "status":
198
- return parseStatusArgs(argv.slice(1));
199
- case "sessions":
200
- return parseSessionsArgs(argv.slice(1));
201
- case "serve":
202
- return parseServeArgs(argv.slice(1));
203
- default:
204
- throw new Error(`Unknown local command: ${command ?? ""}`);
205
- }
206
- }
207
- function parseOnboardArgs(args) {
208
- const values = parseNamedArgs(args, {
209
- allowedFlags: [
210
- "--home",
211
- "--repo",
212
- "--dashboard-url",
213
- "--email",
214
- "--device-name",
215
- "--ticket",
216
- "--branch",
217
- "--json",
218
- "--poll-interval-ms",
219
- "--timeout-ms",
220
- "--max-depth",
221
- "--max-repos",
222
- ],
223
- valueFlags: [
224
- "--home",
225
- "--repo",
226
- "--dashboard-url",
227
- "--email",
228
- "--device-name",
229
- "--ticket",
230
- "--branch",
231
- "--poll-interval-ms",
232
- "--timeout-ms",
233
- "--max-depth",
234
- "--max-repos",
235
- ],
236
- });
237
- assertNoPositionals(values.positionals, "onboard");
238
- return {
239
- kind: "onboard",
240
- homeDir: optionalNonEmpty(values.flags.get("--home")),
241
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
242
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
243
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
244
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
245
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
246
- branch: optionalNonEmpty(values.flags.get("--branch")),
247
- json: values.booleans.has("--json"),
248
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
249
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
250
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
251
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
252
- };
253
- }
254
- function parseInstallArgs(args) {
255
- const values = parseNamedArgs(args, {
256
- allowedFlags: [
257
- "--home",
258
- "--repo",
259
- "--dashboard-url",
260
- "--supabase-url",
261
- "--json",
262
- ],
263
- valueFlags: ["--home", "--repo", "--dashboard-url", "--supabase-url"],
264
- });
265
- assertNoPositionals(values.positionals, "install");
266
- return {
267
- kind: "install",
268
- homeDir: optionalNonEmpty(values.flags.get("--home")),
269
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
270
- dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
271
- supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
272
- json: values.booleans.has("--json"),
273
- };
274
- }
275
- function parseLoginArgs(args) {
276
- const values = parseNamedArgs(args, {
277
- allowedFlags: [
278
- "--home",
279
- "--dashboard-url",
280
- "--email",
281
- "--device-name",
282
- "--json",
283
- "--poll-interval-ms",
284
- "--timeout-ms",
285
- ],
286
- valueFlags: [
287
- "--home",
288
- "--dashboard-url",
289
- "--email",
290
- "--device-name",
291
- "--poll-interval-ms",
292
- "--timeout-ms",
293
- ],
294
- });
295
- assertNoPositionals(values.positionals, "login");
296
- return {
297
- kind: "login",
298
- homeDir: optionalNonEmpty(values.flags.get("--home")),
299
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
300
- claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
301
- deviceName: optionalNonEmpty(values.flags.get("--device-name")),
302
- json: values.booleans.has("--json"),
303
- pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
304
- timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
305
- };
306
- }
307
- function parseLogoutArgs(args) {
308
- const values = parseNamedArgs(args, {
309
- allowedFlags: ["--home", "--json"],
310
- valueFlags: ["--home"],
311
- });
312
- assertNoPositionals(values.positionals, "logout");
313
- return {
314
- kind: "logout",
315
- homeDir: optionalNonEmpty(values.flags.get("--home")),
316
- json: values.booleans.has("--json"),
317
- };
318
- }
319
- function parseStartArgs(args) {
320
- const values = parseNamedArgs(args, {
321
- allowedFlags: [
322
- "--home",
323
- "--repo",
324
- "--branch",
325
- "--ticket",
326
- "--operator-id",
327
- "--session-id",
328
- "--json",
329
- "--max-depth",
330
- "--max-repos",
331
- ],
332
- valueFlags: [
333
- "--home",
334
- "--repo",
335
- "--branch",
336
- "--ticket",
337
- "--operator-id",
338
- "--session-id",
339
- "--max-depth",
340
- "--max-repos",
341
- ],
342
- });
343
- assertNoPositionals(values.positionals, "start");
344
- return {
345
- kind: "start",
346
- homeDir: optionalNonEmpty(values.flags.get("--home")),
347
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
348
- branch: optionalNonEmpty(values.flags.get("--branch")),
349
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
350
- operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
351
- sessionId: optionalNonEmpty(values.flags.get("--session-id")),
352
- json: values.booleans.has("--json"),
353
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
354
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
355
- };
356
- }
357
- function parseSyncArgs(args) {
358
- const values = parseNamedArgs(args, {
359
- allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
360
- valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
361
- });
362
- assertNoPositionals(values.positionals, "sync");
363
- return {
364
- kind: "sync",
365
- homeDir: optionalNonEmpty(values.flags.get("--home")),
366
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
367
- dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
368
- json: values.booleans.has("--json"),
369
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
370
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
371
- };
372
- }
373
- function parseStatusArgs(args) {
374
- const values = parseNamedArgs(args, {
375
- allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
376
- valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
377
- });
378
- assertNoPositionals(values.positionals, "status");
379
- return {
380
- kind: "status",
381
- homeDir: optionalNonEmpty(values.flags.get("--home")),
382
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
383
- json: values.booleans.has("--json"),
384
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
385
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
386
- };
387
- }
388
- function parseSessionsArgs(args) {
389
- const values = parseNamedArgs(args, {
390
- allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
391
- valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
392
- });
393
- assertNoPositionals(values.positionals, "sessions");
394
- const source = values.flags.get("--source");
395
- if (source !== undefined && source !== "codex" && source !== "claude") {
396
- throw new Error("--source must be 'codex' or 'claude'.");
397
- }
398
- return {
399
- kind: "sessions",
400
- homeDir: optionalNonEmpty(values.flags.get("--home")),
401
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
402
- source,
403
- json: values.booleans.has("--json"),
404
- maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
405
- maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
406
- };
407
- }
408
- function parseServeArgs(args) {
409
- const values = parseNamedArgs(args, {
410
- allowedFlags: ["--home", "--repo", "--port"],
411
- valueFlags: ["--home", "--repo", "--port"],
412
- });
413
- assertNoPositionals(values.positionals, "serve");
414
- const port = Number(values.flags.get("--port") ?? "4174");
415
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
416
- throw new Error("--port must be a TCP port between 1 and 65535.");
417
- }
418
- return {
419
- kind: "serve",
420
- homeDir: optionalNonEmpty(values.flags.get("--home")),
421
- repoRoot: optionalNonEmpty(values.flags.get("--repo")),
422
- port,
423
- };
424
- }
425
- function parseNamedArgs(args, options) {
426
- const allowed = new Set(options.allowedFlags);
427
- const valueFlags = new Set(options.valueFlags);
428
- const flags = new Map();
429
- const booleans = new Set();
430
- const positionals = [];
431
- for (let index = 0; index < args.length; index += 1) {
432
- const arg = args[index] ?? "";
433
- rejectServiceRoleLikeArgument(arg);
434
- if (!arg.startsWith("--")) {
435
- positionals.push(arg);
436
- continue;
437
- }
438
- const [flag, inlineValue] = arg.split("=", 2);
439
- if (!allowed.has(flag))
440
- throw new Error(`Unknown flag: ${flag}`);
441
- if (valueFlags.has(flag)) {
442
- const value = inlineValue ?? args[index + 1];
443
- if (!value || value.startsWith("--")) {
444
- throw new Error(`${flag} requires a value.`);
445
- }
446
- rejectServiceRoleLikeArgument(value);
447
- flags.set(flag, value);
448
- if (inlineValue === undefined)
449
- index += 1;
450
- }
451
- else {
452
- if (inlineValue !== undefined)
453
- throw new Error(`${flag} does not accept a value.`);
454
- booleans.add(flag);
455
- }
456
- }
457
- return { flags, booleans, positionals };
458
- }
459
196
  async function runInstall(command, io) {
460
197
  const result = await installLocalCollector(command);
461
198
  if (command.json) {
@@ -473,17 +210,14 @@ function isInteractiveStdin(io) {
473
210
  return Boolean(io.stdin.isTTY);
474
211
  }
475
212
  /**
476
- * Reads one line from stdin so `cockpit onboard` (no flags) can ask for the
477
- * dashboard email instead of forcing a `--email` flag. Only called when stdin
478
- * is a TTY and we're not in --json mode, so headless / piped / spawned runs
479
- * never block on input — they keep the existing behaviour (email optional; the
480
- * approving admin's account owns the device). An empty answer or a non-email
481
- * skips rather than failing, matching `optionalEmail`'s leniency.
213
+ * Reads one line from stdin after writing a prompt. Shared by the onboard email
214
+ * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
215
+ * headless / piped / spawned runs never block on input.
482
216
  */
483
- async function promptOnboardEmail(io) {
484
- io.stdout.write("Dashboard email (press enter to skip): ");
217
+ async function readLine(io, prompt) {
218
+ io.stdout.write(prompt);
485
219
  io.stdin.setEncoding("utf8");
486
- const raw = await new Promise((resolve) => {
220
+ return new Promise((resolve) => {
487
221
  const onData = (chunk) => {
488
222
  io.stdin.removeListener("data", onData);
489
223
  io.stdin.pause();
@@ -492,6 +226,15 @@ async function promptOnboardEmail(io) {
492
226
  io.stdin.resume();
493
227
  io.stdin.on("data", onData);
494
228
  });
229
+ }
230
+ /**
231
+ * Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
232
+ * `--email`. Only called when stdin is a TTY and not in --json mode. An empty
233
+ * answer or a non-email skips rather than failing, matching `optionalEmail`'s
234
+ * leniency (the approving admin's account then owns the device).
235
+ */
236
+ async function promptOnboardEmail(io) {
237
+ const raw = await readLine(io, "Dashboard email (press enter to skip): ");
495
238
  const answer = raw.trim().toLowerCase();
496
239
  if (!answer)
497
240
  return undefined;
@@ -501,6 +244,38 @@ async function promptOnboardEmail(io) {
501
244
  }
502
245
  return answer;
503
246
  }
247
+ /**
248
+ * After a successful onboard, offers to install the launchd autostart agent so a
249
+ * Mac mini keeps syncing without anyone re-running cockpit — this is the fix for
250
+ * interns drifting to Stale. Only in an interactive, non-JSON run with a real
251
+ * exec runner (`io.exec`): headless / piped / spawned onboards and tests that
252
+ * pass no exec skip it entirely. Declining leaves onboarding's success untouched.
253
+ */
254
+ async function maybeOfferAutostart(command, io) {
255
+ if (command.json || !isInteractiveStdin(io) || !io.exec)
256
+ return;
257
+ const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
258
+ .trim()
259
+ .toLowerCase();
260
+ if (answer === "n" || answer === "no") {
261
+ writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
262
+ return;
263
+ }
264
+ const result = await installAutostartAgent({
265
+ homeDir: command.homeDir,
266
+ repoRoot: command.repoRoot,
267
+ dashboardUrl: command.dashboardUrl,
268
+ exec: io.exec,
269
+ });
270
+ if (result.status === "unsupported") {
271
+ writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
272
+ return;
273
+ }
274
+ writeLine(io.stdout, result.loaded
275
+ ? "Background autostart installed; Cockpit syncs at login and every 30 min."
276
+ : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
277
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
278
+ }
504
279
  async function runOnboard(command, io) {
505
280
  let install = null;
506
281
  let pair = null;
@@ -574,6 +349,8 @@ async function runOnboard(command, io) {
574
349
  codex_sessions: multi.codex_sessions,
575
350
  }, null, 2));
576
351
  }
352
+ if (multi.ok)
353
+ await maybeOfferAutostart(command, io);
577
354
  return multi.ok ? 0 : 1;
578
355
  }
579
356
  const context = await startLocalWorkContext({
@@ -638,6 +415,7 @@ async function runOnboard(command, io) {
638
415
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
639
416
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
640
417
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
418
+ await maybeOfferAutostart(command, io);
641
419
  return 0;
642
420
  }
643
421
  catch (error) {
@@ -694,497 +472,6 @@ async function readOnboardSessionReuseCandidate(homeDir) {
694
472
  function normalizeUrlForComparison(value) {
695
473
  return value ? normalizeUrl(value) : null;
696
474
  }
697
- const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
698
- const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
699
- const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
700
- /**
701
- * Shared dual-source sync orchestration for single-repo and parent-folder
702
- * modes. Codex AND Claude Code sessions are scanned and attributed once across
703
- * every discovered worktree; each worktree syncs with only its own attributed
704
- * transcripts (codex + claude main + sidecars), and the
705
- * ambiguous/unattributed/skipped remainder is reported with reason labels and a
706
- * `source` discriminator instead of being duplicated into every repo or
707
- * silently dropped. The session row's upload state maps ONLY from the main-file
708
- * outcome (D3); sidecar outcomes aggregate into CLI counts.
709
- */
710
- async function runAttributedWorktreeSync(options) {
711
- const now = new Date();
712
- const homeDir = options.homeDir ?? os.homedir();
713
- const paths = getCollectorRuntimePaths(options.homeDir);
714
- const claudeEnabled = await isClaudeCollectionEnabled(paths);
715
- const codexAttribution = await scanAndAttributeCodexSessions({
716
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
717
- worktrees: options.worktrees,
718
- now,
719
- });
720
- // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
721
- // days so the first sync captures retroactive history instead of only 24h.
722
- const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
723
- const firstRunBackfill = claudeEnabled && !claudeCursorExists;
724
- const claudeAttribution = claudeEnabled
725
- ? await scanAndAttributeClaudeSessions({
726
- projectsDir: path.join(homeDir, ".claude", "projects"),
727
- worktrees: options.worktrees,
728
- now,
729
- sinceMinutes: firstRunBackfill
730
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
731
- : undefined,
732
- })
733
- : emptyClaudeScan();
734
- // Read the Claude cursor up front: damping decisions need prior upload state.
735
- const claudeCursorBefore = claudeEnabled
736
- ? await readRawEvidenceCursor(paths, {
737
- filename: CLAUDE_CURSOR_FILENAME,
738
- }).catch(() => emptyRawEvidenceCursorState())
739
- : emptyRawEvidenceCursorState();
740
- // sessionId -> prior durable pointer for damped sessions (drives the
741
- // growth_damped count + the skip_main decision).
742
- const dampedClaudePointers = new Map();
743
- // sessionId -> prior durable pointer for EVERY already-durable Claude session
744
- // (superset of damped). A session that was durable before but had no fresh
745
- // upload this sync (damped, spooled, budget-deferred) reports reused_existing
746
- // with this pointer instead of not_uploaded, so the store row never flips.
747
- const claudePriorDurablePointers = new Map();
748
- for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
749
- if (entry.uploaded_object_key) {
750
- claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
751
- }
752
- }
753
- // One shared budget for the whole sync (D7b is per-sync): a parent-folder
754
- // sync over many worktrees honors a single byte/object cap rather than N×.
755
- const rawEvidenceBudget = {
756
- remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
757
- remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
758
- };
759
- const outcomes = [];
760
- let ok = true;
761
- for (const worktree of options.worktrees) {
762
- let context = null;
763
- if (options.startContexts) {
764
- context = await startLocalWorkContext({
765
- homeDir: options.homeDir,
766
- repoRoot: worktree.repo_root,
767
- branch: options.branch,
768
- activeTicketId: options.activeTicketId,
769
- operatorId: options.operatorId,
770
- sessionId: options.sessionId,
771
- });
772
- }
773
- const claudeSessionFiles = claudeAttribution.results
774
- .filter((result) => result.state === "attributed" &&
775
- result.worktree?.worktree_fingerprint ===
776
- worktree.worktree_fingerprint)
777
- .map((result) => {
778
- const damped = !result.main_file_oversized &&
779
- shouldDampClaudeMain(result, claudeCursorBefore, now);
780
- if (damped) {
781
- dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
782
- ?.uploaded_object_key ?? null);
783
- }
784
- return {
785
- local_path: result.file_path,
786
- claude_session_id: result.claude_session_id,
787
- main_file_oversized: result.main_file_oversized,
788
- skip_main: damped,
789
- sidecar_files: result.sidecar_files
790
- .filter((sidecar) => !sidecar.skipped_reason)
791
- .map((sidecar) => ({ local_path: sidecar.local_path })),
792
- };
793
- });
794
- const syncOptions = {
795
- homeDir: options.homeDir,
796
- repoRoot: worktree.repo_root,
797
- dashboardUrl: options.dashboardUrl,
798
- codexSessionFiles: codexAttribution.results
799
- .filter((result) => result.state === "attributed" &&
800
- result.worktree?.worktree_fingerprint ===
801
- worktree.worktree_fingerprint)
802
- .map((result) => ({
803
- local_path: result.file_path,
804
- codex_session_id: result.codex_session_id,
805
- })),
806
- claudeSessionFiles,
807
- rawEvidenceBudget,
808
- fetch: options.fetchImpl,
809
- };
810
- let sync;
811
- try {
812
- sync = await syncLocalAmbientEnvelope(syncOptions);
813
- }
814
- catch (error) {
815
- // A newly cloned repo has no work context yet. Capture is permissive
816
- // and ticket binding comes later, so start general ambient capture for
817
- // it instead of blocking every other repo's sync until someone runs
818
- // `cockpit start` by hand.
819
- if (error instanceof LocalUploadBlockedError &&
820
- error.blocker === "missing_context") {
821
- context = await startLocalWorkContext({
822
- homeDir: options.homeDir,
823
- repoRoot: worktree.repo_root,
824
- branch: options.branch,
825
- });
826
- sync = await syncLocalAmbientEnvelope(syncOptions);
827
- }
828
- else {
829
- throw error;
830
- }
831
- }
832
- ok = ok && sync.status === "uploaded";
833
- outcomes.push({ worktree, context, sync });
834
- }
835
- const sessions = buildAgentSessionReport({
836
- codexResults: codexAttribution.results,
837
- claudeResults: claudeAttribution.results,
838
- outcomes,
839
- now,
840
- claudePriorDurablePointers,
841
- });
842
- // The sessions cursor is an optimization; a broken local state dir must not
843
- // turn already-completed syncs into a CLI crash.
844
- let codexStaleCount = 0;
845
- let claudeStaleCount = 0;
846
- try {
847
- const codexCursor = await readRawEvidenceCursor(paths);
848
- codexStaleCount = recordSourceObservations({
849
- cursor: codexCursor,
850
- results: codexAttribution.results,
851
- sessions,
852
- source: "codex",
853
- sessionIdOf: (result) => result.codex_session_id,
854
- now,
855
- });
856
- codexCursor.updated_at = now.toISOString();
857
- await writeRawEvidenceCursor(paths, codexCursor);
858
- }
859
- catch {
860
- // Best-effort: stale counts read 0 and observations re-record next sync.
861
- }
862
- if (claudeEnabled) {
863
- try {
864
- claudeStaleCount = recordSourceObservations({
865
- cursor: claudeCursorBefore,
866
- results: claudeAttribution.results,
867
- sessions,
868
- source: "claude_code",
869
- sessionIdOf: (result) => result.claude_session_id,
870
- now,
871
- priorCursor: claudeCursorBefore,
872
- });
873
- claudeCursorBefore.updated_at = now.toISOString();
874
- await writeRawEvidenceCursor(paths, claudeCursorBefore, {
875
- filename: CLAUDE_CURSOR_FILENAME,
876
- sessionsOnly: true,
877
- });
878
- }
879
- catch {
880
- // Best-effort: a broken Claude cursor must not fail the sync.
881
- }
882
- }
883
- const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
884
- const report = firstUploaded
885
- ? await postCodexSessionReport({
886
- homeDir: options.homeDir,
887
- repoRoot: firstUploaded.worktree.repo_root,
888
- dashboardUrl: options.dashboardUrl,
889
- sessions,
890
- fetch: options.fetchImpl,
891
- now,
892
- })
893
- : {
894
- posted: false,
895
- reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
896
- };
897
- const summary = buildAgentSessionSummary({
898
- codexAttribution,
899
- claudeAttribution,
900
- outcomes,
901
- codexStaleCount,
902
- claudeStaleCount,
903
- firstRunBackfill,
904
- growthDamped: dampedClaudePointers.size,
905
- report,
906
- });
907
- return { ok, outcomes, codexAttribution, claudeAttribution, summary };
908
- }
909
- const ATTRIBUTION_STATE_RANK = {
910
- attributed: 3,
911
- ambiguous: 2,
912
- unattributed: 1,
913
- skipped: 0,
914
- };
915
- function normalizeCodexResult(result) {
916
- return {
917
- source: "codex",
918
- session_id: result.codex_session_id,
919
- state: result.state,
920
- reason: result.reason,
921
- signals: result.signals,
922
- attribution_score: result.attribution_score,
923
- path_score: result.path_score,
924
- content_hash_sha256: result.content_hash_sha256,
925
- byte_size: result.byte_size,
926
- session_file_mtime: result.session_file_mtime,
927
- session_file_mtime_ms: result.session_file_mtime_ms,
928
- worktree: result.worktree,
929
- cwd_basename: result.cwd_basename,
930
- cwd_hash: result.cwd_hash,
931
- };
932
- }
933
- function normalizeClaudeResult(result) {
934
- return {
935
- source: "claude_code",
936
- session_id: result.claude_session_id,
937
- state: result.state,
938
- reason: result.reason,
939
- signals: result.signals,
940
- attribution_score: result.attribution_score,
941
- path_score: result.path_score,
942
- content_hash_sha256: result.content_hash_sha256,
943
- byte_size: result.byte_size,
944
- session_file_mtime: result.session_file_mtime,
945
- session_file_mtime_ms: result.session_file_mtime_ms,
946
- worktree: result.worktree,
947
- cwd_basename: result.cwd_basename,
948
- cwd_hash: result.cwd_hash,
949
- };
950
- }
951
- /**
952
- * Generalizes the per-session report across sources. Dedupe is per
953
- * `(source, session_id)` so a Codex session and a Claude session that happen to
954
- * share an id are never collapsed. Upload state maps ONLY from the main-file
955
- * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
956
- * session's upload state (D3). Damped Claude sessions report `reused_existing`
957
- * carrying their prior durable pointer.
958
- */
959
- function buildAgentSessionReport(options) {
960
- const normalized = [
961
- ...options.codexResults.map(normalizeCodexResult),
962
- ...options.claudeResults.map(normalizeClaudeResult),
963
- ];
964
- const bestByKey = new Map();
965
- for (const result of normalized) {
966
- const key = `${result.source}:${result.session_id}`;
967
- const existing = bestByKey.get(key);
968
- if (!existing ||
969
- (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
970
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
971
- ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
972
- (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
973
- result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
974
- bestByKey.set(key, result);
975
- }
976
- }
977
- // Main-file outcomes only (D3): a sidecar making it must never mark a session
978
- // uploaded when the main did not.
979
- const uploadByKey = new Map();
980
- for (const outcome of options.outcomes) {
981
- if (outcome.sync.status !== "uploaded")
982
- continue;
983
- for (const upload of outcome.sync.raw_evidence_outcomes) {
984
- if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
985
- continue;
986
- const source = upload.kind === "claude_jsonl"
987
- ? "claude_code"
988
- : upload.kind === "codex_jsonl"
989
- ? "codex"
990
- : null;
991
- if (!source)
992
- continue; // sidecars and other kinds do not set session state
993
- uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
994
- }
995
- }
996
- return [...bestByKey.values()].map((result) => {
997
- const key = `${result.source}:${result.session_id}`;
998
- const upload = uploadByKey.get(key);
999
- // A previously-durable Claude session with no fresh main upload this sync
1000
- // (damped / spooled / budget-deferred) reports reused_existing + its prior
1001
- // pointer rather than not_uploaded, so the store row never flips.
1002
- const priorDurablePointer = result.source === "claude_code"
1003
- ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
1004
- : null;
1005
- return {
1006
- codex_session_id: result.session_id,
1007
- source: result.source,
1008
- observed_at: options.now.toISOString(),
1009
- attribution_state: result.state,
1010
- attribution_reason: result.reason,
1011
- attribution_score: result.attribution_score,
1012
- path_score: result.path_score,
1013
- signals: result.signals,
1014
- ...(result.content_hash_sha256
1015
- ? { session_file_hash_sha256: result.content_hash_sha256 }
1016
- : {}),
1017
- session_file_byte_size: result.byte_size,
1018
- session_file_mtime: result.session_file_mtime,
1019
- ...(result.worktree
1020
- ? {
1021
- repo_fingerprint: result.worktree.repo_fingerprint,
1022
- worktree_fingerprint: result.worktree.worktree_fingerprint,
1023
- repo_label: result.worktree.repo_label,
1024
- branch: result.worktree.branch,
1025
- }
1026
- : {}),
1027
- ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
1028
- ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
1029
- ...(upload
1030
- ? {
1031
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
1032
- upload_state: upload.upload_state,
1033
- }
1034
- : priorDurablePointer
1035
- ? {
1036
- raw_evidence_pointer_id: priorDurablePointer,
1037
- upload_state: "reused_existing",
1038
- }
1039
- : result.state === "attributed"
1040
- ? { upload_state: "not_uploaded" }
1041
- : {}),
1042
- };
1043
- });
1044
- }
1045
- /**
1046
- * Records per-source session observations into its cursor and returns the stale
1047
- * count. Damped/reused Claude sessions carry forward their prior upload
1048
- * timestamp + byte size so the 6h damping window keeps counting from the real
1049
- * last upload (otherwise a slowly-growing file would never re-upload — D21).
1050
- */
1051
- function recordSourceObservations(options) {
1052
- const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
1053
- const stale = countStaleSessions(options.cursor, seen);
1054
- for (const result of options.results) {
1055
- const sessionId = options.sessionIdOf(result);
1056
- const reported = options.sessions.find((session) => session.source === options.source &&
1057
- session.codex_session_id === sessionId);
1058
- const uploadedThisSync = reported?.upload_state === "uploaded";
1059
- const durableThisSync = reported?.upload_state === "uploaded" ||
1060
- reported?.upload_state === "reused_existing";
1061
- const prior = options.priorCursor?.sessions[sessionId];
1062
- // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
1063
- // upload-failed for a session that was ALREADY durable must NOT wipe the
1064
- // prior durable state — otherwise damping is forfeited forever and the
1065
- // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
1066
- // durable pointer/timestamp/size forward unless we durably uploaded anew.
1067
- const uploadedObjectKey = durableThisSync
1068
- ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
1069
- : (prior?.uploaded_object_key ?? null);
1070
- const uploadedAt = uploadedThisSync
1071
- ? options.now.toISOString()
1072
- : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
1073
- const uploadedByteSize = uploadedThisSync
1074
- ? result.byte_size
1075
- : (prior?.uploaded_byte_size ??
1076
- (durableThisSync ? result.byte_size : null));
1077
- const entry = {
1078
- file_hash_sha256: result.content_hash_sha256,
1079
- file_mtime_ms: result.session_file_mtime_ms,
1080
- byte_size: result.byte_size,
1081
- // Durable byte offset reflects how many bytes are durable remotely (the
1082
- // last uploaded size), not the current file size.
1083
- byte_offset: uploadedByteSize ?? 0,
1084
- state: result.state,
1085
- reason: result.reason,
1086
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
1087
- uploaded_object_key: uploadedObjectKey,
1088
- uploaded_at: uploadedAt,
1089
- uploaded_byte_size: uploadedByteSize,
1090
- last_seen_at: options.now.toISOString(),
1091
- };
1092
- recordSessionObservation(options.cursor, sessionId, entry);
1093
- }
1094
- return stale;
1095
- }
1096
- function shouldDampClaudeMain(result, cursor, now) {
1097
- const entry = cursor.sessions[result.claude_session_id];
1098
- if (!entry ||
1099
- !entry.uploaded_object_key ||
1100
- !entry.uploaded_at ||
1101
- entry.uploaded_byte_size == null) {
1102
- return false;
1103
- }
1104
- const grew = result.byte_size > entry.uploaded_byte_size;
1105
- if (!grew)
1106
- return false; // unchanged content reuses via the object cursor
1107
- const growth = result.byte_size - entry.uploaded_byte_size;
1108
- const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
1109
- return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
1110
- Number.isFinite(ageMs) &&
1111
- ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
1112
- }
1113
- function buildAgentSessionSummary(options) {
1114
- const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
1115
- const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
1116
- const sidecarsCollected = attributedClaude.reduce((total, result) => total +
1117
- result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
1118
- const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
1119
- result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
1120
- const codex = {
1121
- scanned: options.codexAttribution.scanned_file_count,
1122
- attributed: options.codexAttribution.counts.attributed,
1123
- ambiguous: options.codexAttribution.counts.ambiguous,
1124
- unattributed: options.codexAttribution.counts.unattributed,
1125
- skipped: options.codexAttribution.counts.skipped,
1126
- stale: options.codexStaleCount,
1127
- };
1128
- const claude = {
1129
- scanned: options.claudeAttribution.scanned_session_count,
1130
- attributed: options.claudeAttribution.counts.attributed,
1131
- ambiguous: options.claudeAttribution.counts.ambiguous,
1132
- unattributed: options.claudeAttribution.counts.unattributed,
1133
- skipped: options.claudeAttribution.counts.skipped,
1134
- stale: options.claudeStaleCount,
1135
- sidecars_collected: sidecarsCollected,
1136
- sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
1137
- upload.upload_state === "reused_existing").length,
1138
- sidecars_skipped: sidecarsSkipped,
1139
- sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
1140
- sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
1141
- mains_oversized: options.claudeAttribution.counts.mains_oversized,
1142
- oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
1143
- project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
1144
- sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
1145
- growth_damped: options.growthDamped,
1146
- first_run_backfill: options.firstRunBackfill,
1147
- };
1148
- return {
1149
- scanned: codex.scanned,
1150
- attributed: codex.attributed,
1151
- ambiguous: codex.ambiguous,
1152
- unattributed: codex.unattributed,
1153
- skipped: codex.skipped,
1154
- stale: codex.stale,
1155
- report_posted: options.report.posted,
1156
- report_reason: options.report.reason,
1157
- codex,
1158
- claude,
1159
- files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
1160
- files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
1161
- };
1162
- }
1163
- function emptyClaudeScan() {
1164
- return {
1165
- results: [],
1166
- scanned_session_count: 0,
1167
- project_dirs_skipped: 0,
1168
- counts: {
1169
- attributed: 0,
1170
- ambiguous: 0,
1171
- unattributed: 0,
1172
- skipped: 0,
1173
- mains_oversized: 0,
1174
- oversized_lines_skipped: 0,
1175
- sessions_schema_drift: 0,
1176
- sidecars_capped: 0,
1177
- },
1178
- };
1179
- }
1180
- async function isClaudeCollectionEnabled(paths) {
1181
- const config = await readLocalCollectorConfig(paths).catch(() => null);
1182
- return config?.collect_claude_jsonl !== false;
1183
- }
1184
- async function fileExists(filePath) {
1185
- const { stat } = await import("node:fs/promises");
1186
- return stat(filePath).then(() => true, () => false);
1187
- }
1188
475
  function shortSha(value) {
1189
476
  return value ? value.slice(0, 12) : "unknown";
1190
477
  }
@@ -1685,74 +972,70 @@ async function runServe(command, io) {
1685
972
  });
1686
973
  return 0;
1687
974
  }
1688
- function assertNoPositionals(positionals, command) {
1689
- if (positionals.length > 0) {
1690
- throw new Error(`${command} does not accept positional arguments.`);
1691
- }
1692
- }
1693
- function optionalNonEmpty(value) {
1694
- const trimmed = value?.trim();
1695
- return trimmed ? trimmed : undefined;
1696
- }
1697
- function optionalUrl(value) {
1698
- return value === undefined ? undefined : normalizeUrl(value);
1699
- }
1700
- function optionalEmail(value) {
1701
- const trimmed = value?.trim().toLowerCase();
1702
- if (!trimmed)
1703
- return undefined;
1704
- if (!trimmed.includes("@")) {
1705
- throw new Error("--email must be a valid email address.");
1706
- }
1707
- return trimmed;
1708
- }
1709
- function optionalPositiveInteger(value, flag) {
1710
- if (value === undefined)
1711
- return undefined;
1712
- const parsed = Number(value);
1713
- if (!Number.isInteger(parsed) || parsed < 1) {
1714
- throw new Error(`${flag} must be a positive integer.`);
1715
- }
1716
- return parsed;
1717
- }
1718
- function normalizeUrl(value) {
1719
- const trimmed = value.trim().replace(/\/+$/, "");
1720
- if (!trimmed)
1721
- throw new Error("URL value cannot be empty.");
1722
- return trimmed;
1723
- }
1724
- function rejectServiceRoleLikeArgument(value) {
1725
- if (!looksLikeServiceRoleSecret(value))
1726
- return;
1727
- throw new Error("Service-role credentials are not accepted by local collector commands.");
1728
- }
1729
- function looksLikeServiceRoleSecret(value) {
1730
- if (serviceCredentialNamePattern().test(value))
1731
- return true;
1732
- const parts = value.split(".");
1733
- if (parts.length !== 3)
1734
- return false;
1735
- try {
1736
- const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
1737
- return serviceCredentialPayloadPattern().test(payload);
975
+ async function runAutostart(command, io) {
976
+ const exec = io.exec ?? defaultExec();
977
+ const result = command.action === "install"
978
+ ? await installAutostartAgent({
979
+ homeDir: command.homeDir,
980
+ repoRoot: command.repoRoot,
981
+ dashboardUrl: command.dashboardUrl,
982
+ intervalSeconds: command.intervalSeconds,
983
+ exec,
984
+ })
985
+ : command.action === "uninstall"
986
+ ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
987
+ : await autostartStatus({ homeDir: command.homeDir, exec });
988
+ if (command.json) {
989
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
990
+ return result.status === "unsupported" ? 1 : 0;
1738
991
  }
1739
- catch {
1740
- return false;
992
+ writeAutostartResult(io, result);
993
+ return result.status === "unsupported" ? 1 : 0;
994
+ }
995
+ function writeAutostartResult(io, result) {
996
+ switch (result.status) {
997
+ case "installed":
998
+ writeLine(io.stdout, result.loaded
999
+ ? "Cockpit autostart installed and loaded."
1000
+ : "Cockpit autostart installed (launchctl load reported a problem).");
1001
+ writeLine(io.stdout, `Label: ${result.label}`);
1002
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1003
+ writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
1004
+ writeLine(io.stdout, `Repo: ${result.work_dir}`);
1005
+ writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
1006
+ if (!result.loaded && result.message)
1007
+ writeLine(io.stderr, result.message);
1008
+ return;
1009
+ case "uninstalled":
1010
+ writeLine(io.stdout, "Cockpit autostart removed.");
1011
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1012
+ return;
1013
+ case "absent":
1014
+ writeLine(io.stdout, "Cockpit autostart is not installed.");
1015
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1016
+ return;
1017
+ case "loaded":
1018
+ writeLine(io.stdout, "Cockpit autostart is installed and loaded.");
1019
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1020
+ return;
1021
+ case "not_loaded":
1022
+ writeLine(io.stdout, "Cockpit autostart plist exists but is not loaded; run `cockpit autostart install` to reload.");
1023
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1024
+ return;
1025
+ case "unsupported":
1026
+ writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
1027
+ return;
1741
1028
  }
1742
1029
  }
1743
- function serviceCredentialNamePattern() {
1744
- return new RegExp([
1745
- ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
1746
- ["service", "role"].join("[_-]?"),
1747
- ].join("|"), "i");
1748
- }
1749
- function serviceCredentialPayloadPattern() {
1750
- const privilegedRole = ["service", "role"].join("_");
1751
- return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
1752
- }
1753
- function base64UrlToBase64(value) {
1754
- const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
1755
- return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
1030
+ function defaultExec() {
1031
+ return (cmd, args) => new Promise((resolve) => {
1032
+ execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
1033
+ // execFile's error `.code` is the exit code when numeric, but a string
1034
+ // (e.g. "ENOENT") for spawn failures — treat those as a generic 1.
1035
+ const code = err == null ? 0 : typeof err.code === "number" ? err.code : 1;
1036
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
1037
+ });
1038
+ });
1756
1039
  }
1757
1040
  function defaultIo() {
1758
1041
  if (!globalThis.fetch) {
@@ -1764,6 +1047,7 @@ function defaultIo() {
1764
1047
  stderr: process.stderr,
1765
1048
  env: process.env,
1766
1049
  fetch: globalThis.fetch.bind(globalThis),
1050
+ exec: defaultExec(),
1767
1051
  };
1768
1052
  }
1769
1053
  function writeLine(stream, text) {