@apicircle/cli 1.0.8 → 1.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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CLI_PACKAGE_VERSION
4
- } from "./chunk-M5PWIZZF.js";
4
+ } from "./chunk-P4CXF2MS.js";
5
5
 
6
6
  // src/index.ts
7
7
  import { Command } from "commander";
@@ -84,13 +84,9 @@ function installShutdown(handle) {
84
84
  process.on("SIGTERM", () => void shutdown());
85
85
  }
86
86
 
87
- // src/commands/mcp.ts
87
+ // src/commands/mocks.ts
88
88
  import kleur2 from "kleur";
89
- import {
90
- createMcpServer,
91
- FileBackedWorkspaceProvider,
92
- InProcessMockController
93
- } from "@apicircle/mcp-server";
89
+ import { saveToFile as saveToFile3 } from "@apicircle/core/workspace/file-backed";
94
90
 
95
91
  // src/util/loadWorkspace.ts
96
92
  import * as path2 from "path";
@@ -157,6 +153,7 @@ import * as os from "os";
157
153
  import * as path3 from "path";
158
154
  import { promises as fs3 } from "fs";
159
155
  import {
156
+ defaultApicircleRoot,
160
157
  findWorkspaceEntry,
161
158
  loadRegistry,
162
159
  registerWorkspace,
@@ -165,30 +162,10 @@ import {
165
162
  } from "@apicircle/core/workspace/registry";
166
163
  import { saveToFile as saveToFile2 } from "@apicircle/core/workspace/file-backed";
167
164
  import { generateId as generateId3 } from "@apicircle/shared";
168
- var APP_NAME = "@apicircle";
169
- var APP_SUBDIR = "desktop";
170
- var WORKSPACES_DIRNAME = "workspaces";
171
165
  function defaultWorkspacesRoot() {
172
166
  const override = process.env.APICIRCLE_WORKSPACES_ROOT;
173
167
  if (override && override.length > 0) return path3.resolve(override);
174
- return path3.join(electronUserDataDir(), WORKSPACES_DIRNAME);
175
- }
176
- function electronUserDataDir() {
177
- const home = os.homedir();
178
- switch (process.platform) {
179
- case "win32": {
180
- const appdata = process.env.APPDATA ?? path3.join(home, "AppData", "Roaming");
181
- return path3.join(appdata, APP_NAME, APP_SUBDIR);
182
- }
183
- case "darwin":
184
- return path3.join(home, "Library", "Application Support", APP_NAME, APP_SUBDIR);
185
- default:
186
- return path3.join(
187
- process.env.XDG_CONFIG_HOME ?? path3.join(home, ".config"),
188
- APP_NAME,
189
- APP_SUBDIR
190
- );
191
- }
168
+ return defaultApicircleRoot();
192
169
  }
193
170
  async function resolveWorkspace(opts = {}) {
194
171
  const root = opts.workspacesRoot ?? defaultWorkspacesRoot();
@@ -378,14 +355,156 @@ function buildEmptyState(workspaceId, now, withSample) {
378
355
  };
379
356
  }
380
357
 
358
+ // src/commands/mocks.ts
359
+ function registerMocksCommand(program) {
360
+ const mocks = program.command("mocks").description("Manage mock-server definitions in the active workspace");
361
+ mocks.command("list").description("List every mock server in the workspace + its default port").option(
362
+ "--workspace-name <name-or-id>",
363
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
364
+ ).option(
365
+ "-w, --workspace-path <dir>",
366
+ "Filesystem directory containing workspace.json (skips the registry)."
367
+ ).option("--json", "Emit JSON instead of a formatted table").action(async (opts) => {
368
+ const dir = await resolveDir(opts);
369
+ const state = await ensureWorkspace(dir);
370
+ const mockList = Object.values(state.synced.mockServers);
371
+ if (opts.json) {
372
+ process.stdout.write(
373
+ JSON.stringify(
374
+ mockList.map((m) => ({
375
+ id: m.id,
376
+ name: m.name,
377
+ defaultPort: m.defaultPort,
378
+ endpoints: m.endpoints.length
379
+ })),
380
+ null,
381
+ 2
382
+ ) + "\n"
383
+ );
384
+ return;
385
+ }
386
+ if (mockList.length === 0) {
387
+ process.stdout.write(`${kleur2.dim("No mock servers in this workspace.")}
388
+ `);
389
+ return;
390
+ }
391
+ const nameWidth = Math.max(4, ...mockList.map((m) => m.name.length));
392
+ const idWidth = Math.max(2, ...mockList.map((m) => m.id.length));
393
+ process.stdout.write(
394
+ kleur2.bold(
395
+ ` ${"NAME".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"PORT".padStart(6)} ENDPOINTS
396
+ `
397
+ )
398
+ );
399
+ for (const m of mockList) {
400
+ const portLabel = m.defaultPort === null ? kleur2.dim("auto") : String(m.defaultPort);
401
+ process.stdout.write(
402
+ ` ${m.name.padEnd(nameWidth)} ${kleur2.dim(m.id.padEnd(idWidth))} ${portLabel.padStart(6)} ${m.endpoints.length}
403
+ `
404
+ );
405
+ }
406
+ });
407
+ mocks.command("set-port").description("Set (or clear) the default port for a mock server in the active workspace").argument("<selector>", "Mock server id or case-insensitive name").argument(
408
+ "[port]",
409
+ 'Port 1024-65535, or omit / "auto" / "null" to clear back to free-port mode'
410
+ ).option(
411
+ "--workspace-name <name-or-id>",
412
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
413
+ ).option(
414
+ "-w, --workspace-path <dir>",
415
+ "Filesystem directory containing workspace.json (skips the registry)."
416
+ ).action(async (selector, portArg, opts) => {
417
+ const dir = await resolveDir(opts);
418
+ const state = await ensureWorkspace(dir);
419
+ const target = findMock(state.synced, selector);
420
+ if (!target) {
421
+ process.stderr.write(
422
+ `${kleur2.red("error")}: no mock named "${selector}" in this workspace. Run ${kleur2.cyan("apicircle mocks list")} to see what's available.
423
+ `
424
+ );
425
+ process.exit(2);
426
+ return;
427
+ }
428
+ const nextPort = parsePortArg(portArg);
429
+ if (nextPort === "invalid") {
430
+ process.stderr.write(
431
+ `${kleur2.red("error")}: port must be an integer in 1024-65535, or "auto" / "null" / omitted to clear.
432
+ `
433
+ );
434
+ process.exit(2);
435
+ return;
436
+ }
437
+ if (target.defaultPort === nextPort) {
438
+ process.stdout.write(
439
+ `${kleur2.dim("unchanged")}: "${target.name}" already has defaultPort ${nextPort === null ? "auto" : String(nextPort)}.
440
+ `
441
+ );
442
+ return;
443
+ }
444
+ const now = (/* @__PURE__ */ new Date()).toISOString();
445
+ const updated = { ...target, defaultPort: nextPort, updatedAt: now };
446
+ const nextSynced = {
447
+ ...state.synced,
448
+ mockServers: { ...state.synced.mockServers, [target.id]: updated },
449
+ meta: { ...state.synced.meta, updatedAt: now }
450
+ };
451
+ await saveToFile3(dir, { synced: nextSynced, local: state.local });
452
+ process.stdout.write(
453
+ `${kleur2.green("updated")} "${target.name}" defaultPort = ${nextPort === null ? kleur2.dim("auto (free port)") : kleur2.cyan(String(nextPort))}
454
+ `
455
+ );
456
+ });
457
+ }
458
+ async function resolveDir(opts) {
459
+ try {
460
+ const resolved = await resolveWorkspace({
461
+ name: opts.workspaceName,
462
+ path: opts.workspacePath,
463
+ expectExists: false
464
+ });
465
+ return resolved.dir;
466
+ } catch (err) {
467
+ if (err instanceof WorkspaceResolutionError) {
468
+ process.stderr.write(`${kleur2.red("error")}: ${err.message}
469
+ `);
470
+ process.exit(2);
471
+ }
472
+ throw err;
473
+ }
474
+ }
475
+ function findMock(synced, selector) {
476
+ const all = Object.values(synced.mockServers);
477
+ const byId = all.find((m) => m.id === selector);
478
+ if (byId) return byId;
479
+ const lower = selector.toLowerCase();
480
+ return all.find((m) => m.name.toLowerCase() === lower);
481
+ }
482
+ function parsePortArg(raw) {
483
+ if (raw === void 0) return null;
484
+ const trimmed = raw.trim();
485
+ if (trimmed === "" || trimmed.toLowerCase() === "auto" || trimmed.toLowerCase() === "null") {
486
+ return null;
487
+ }
488
+ const n = Number(trimmed);
489
+ if (!Number.isInteger(n)) return "invalid";
490
+ if (n < 1024 || n > 65535) return "invalid";
491
+ return n;
492
+ }
493
+
381
494
  // src/commands/mcp.ts
495
+ import kleur3 from "kleur";
496
+ import {
497
+ createMcpServer,
498
+ FileBackedWorkspaceProvider,
499
+ InProcessMockController
500
+ } from "@apicircle/mcp-server";
382
501
  function registerMcpCommand(program) {
383
502
  program.command("mcp").description("Run the API Circle MCP server (stdio transport)").option(
384
503
  "--workspace-name <name-or-id>",
385
504
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
386
505
  ).option(
387
506
  "-w, --workspace-path <dir>",
388
- "Filesystem directory containing workspace.synced.json (skips the registry)."
507
+ "Filesystem directory containing workspace.json (skips the registry)."
389
508
  ).action(async (opts) => {
390
509
  let dir;
391
510
  let label;
@@ -399,7 +518,7 @@ function registerMcpCommand(program) {
399
518
  label = resolved.fromRegistry ? `${resolved.name ?? resolved.id} (${dir})` : dir;
400
519
  } catch (err) {
401
520
  if (err instanceof WorkspaceResolutionError) {
402
- process.stderr.write(`${kleur2.red("error")}: ${err.message}
521
+ process.stderr.write(`${kleur3.red("error")}: ${err.message}
403
522
  `);
404
523
  process.exit(2);
405
524
  }
@@ -409,7 +528,7 @@ function registerMcpCommand(program) {
409
528
  await ensureWorkspace(dir);
410
529
  } catch (err) {
411
530
  process.stderr.write(
412
- `${kleur2.red("failed to initialise workspace")} at ${dir}: ${err instanceof Error ? err.message : String(err)}
531
+ `${kleur3.red("failed to initialise workspace")} at ${dir}: ${err instanceof Error ? err.message : String(err)}
413
532
  `
414
533
  );
415
534
  process.exit(1);
@@ -417,7 +536,7 @@ function registerMcpCommand(program) {
417
536
  const workspace = new FileBackedWorkspaceProvider(dir);
418
537
  const mock = new InProcessMockController();
419
538
  const host = createMcpServer({ workspace, mock });
420
- process.stderr.write(`${kleur2.green("apicircle-mcp")} ready \xB7 workspace=${label}
539
+ process.stderr.write(`${kleur3.green("apicircle-mcp")} ready \xB7 workspace=${label}
421
540
  `);
422
541
  await host.connect();
423
542
  });
@@ -426,9 +545,9 @@ function registerMcpCommand(program) {
426
545
  // src/commands/import.ts
427
546
  import { promises as fs4 } from "fs";
428
547
  import * as path4 from "path";
429
- import kleur3 from "kleur";
548
+ import kleur4 from "kleur";
430
549
  import { applyMutation, parseApicircleFolderExport } from "@apicircle/core";
431
- import { saveToFile as saveToFile3 } from "@apicircle/core/workspace/file-backed";
550
+ import { saveToFile as saveToFile4 } from "@apicircle/core/workspace/file-backed";
432
551
  import {
433
552
  parseInsomniaToEndpoints,
434
553
  parseOpenApiToEndpoints,
@@ -444,7 +563,7 @@ function registerImportCommand(program) {
444
563
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
445
564
  ).option(
446
565
  "-w, --workspace-path <dir>",
447
- "Filesystem directory containing workspace.synced.json (skips the registry)."
566
+ "Filesystem directory containing workspace.json (skips the registry)."
448
567
  ).option("-f, --format <format>", "OpenAPI format: json | yaml", "json").action(async (type, input, opts) => {
449
568
  let dir;
450
569
  try {
@@ -456,13 +575,13 @@ function registerImportCommand(program) {
456
575
  dir = resolved.dir;
457
576
  if (resolved.fromRegistry) {
458
577
  process.stderr.write(
459
- `${kleur3.dim("workspace")}: ${kleur3.cyan(resolved.name ?? resolved.id ?? "")} ${kleur3.dim(`(${dir})`)}
578
+ `${kleur4.dim("workspace")}: ${kleur4.cyan(resolved.name ?? resolved.id ?? "")} ${kleur4.dim(`(${dir})`)}
460
579
  `
461
580
  );
462
581
  }
463
582
  } catch (err) {
464
583
  if (err instanceof WorkspaceResolutionError) {
465
- process.stderr.write(`${kleur3.red("error")}: ${err.message}
584
+ process.stderr.write(`${kleur4.red("error")}: ${err.message}
466
585
  `);
467
586
  process.exit(2);
468
587
  }
@@ -535,7 +654,7 @@ function registerImportCommand(program) {
535
654
  parsedEnvelope = parseApicircleFolderExport(raw);
536
655
  } catch (err) {
537
656
  process.stderr.write(
538
- `${kleur3.red("error")}: ${err instanceof Error ? err.message : String(err)}
657
+ `${kleur4.red("error")}: ${err instanceof Error ? err.message : String(err)}
539
658
  `
540
659
  );
541
660
  process.exit(2);
@@ -548,29 +667,29 @@ function registerImportCommand(program) {
548
667
  nextLocal = out.next.local;
549
668
  for (const r of parsedEnvelope.requests) created.push(r.id);
550
669
  for (const w of parsedEnvelope.warnings) {
551
- process.stderr.write(`${kleur3.yellow("warning")}: ${w}
670
+ process.stderr.write(`${kleur4.yellow("warning")}: ${w}
552
671
  `);
553
672
  }
554
- await saveToFile3(dir, { synced: nextSynced, local: nextLocal });
673
+ await saveToFile4(dir, { synced: nextSynced, local: nextLocal });
555
674
  process.stdout.write(
556
- `${kleur3.green("imported")} folder "${parsedEnvelope.rootFolder.name}" (${parsedEnvelope.subfolders.length + 1} folders, ${parsedEnvelope.requests.length} requests) into ${dir}
675
+ `${kleur4.green("imported")} folder "${parsedEnvelope.rootFolder.name}" (${parsedEnvelope.subfolders.length + 1} folders, ${parsedEnvelope.requests.length} requests) into ${dir}
557
676
  `
558
677
  );
559
678
  if (parsedEnvelope.dependencies.files.length > 0) {
560
679
  process.stderr.write(
561
- `${kleur3.yellow("note")}: ${parsedEnvelope.dependencies.files.length} file asset${parsedEnvelope.dependencies.files.length === 1 ? "" : "s"} landed without bytes \u2014 re-attach them inside Global Assets \u2192 Global Files.
680
+ `${kleur4.yellow("note")}: ${parsedEnvelope.dependencies.files.length} file asset${parsedEnvelope.dependencies.files.length === 1 ? "" : "s"} landed without bytes \u2014 re-attach them inside Global Assets \u2192 Global Files.
562
681
  `
563
682
  );
564
683
  }
565
684
  return;
566
685
  } else {
567
- process.stderr.write(`${kleur3.red("error")}: unknown type '${String(type)}'
686
+ process.stderr.write(`${kleur4.red("error")}: unknown type '${String(type)}'
568
687
  `);
569
688
  process.exit(2);
570
689
  }
571
- await saveToFile3(dir, { synced: nextSynced, local: nextLocal });
690
+ await saveToFile4(dir, { synced: nextSynced, local: nextLocal });
572
691
  process.stdout.write(
573
- `${kleur3.green("imported")} ${created.length} request${created.length === 1 ? "" : "s"} into ${dir}
692
+ `${kleur4.green("imported")} ${created.length} request${created.length === 1 ? "" : "s"} into ${dir}
574
693
  `
575
694
  );
576
695
  });
@@ -610,7 +729,7 @@ function blankRequest(partial) {
610
729
  // src/commands/export.ts
611
730
  import { promises as fs5 } from "fs";
612
731
  import * as path5 from "path";
613
- import kleur4 from "kleur";
732
+ import kleur5 from "kleur";
614
733
  import {
615
734
  collectFolderExport,
616
735
  redactFolderExportCredentials,
@@ -635,7 +754,7 @@ function registerExportCommand(program) {
635
754
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
636
755
  ).option(
637
756
  "-w, --workspace-path <dir>",
638
- "Filesystem directory containing workspace.synced.json (skips the registry)."
757
+ "Filesystem directory containing workspace.json (skips the registry)."
639
758
  ).action(async (folder, opts) => {
640
759
  let dir;
641
760
  try {
@@ -646,7 +765,7 @@ function registerExportCommand(program) {
646
765
  dir = resolved.dir;
647
766
  } catch (err) {
648
767
  if (err instanceof WorkspaceResolutionError) {
649
- process.stderr.write(`${kleur4.red("error")}: ${err.message}
768
+ process.stderr.write(`${kleur5.red("error")}: ${err.message}
650
769
  `);
651
770
  process.exit(2);
652
771
  }
@@ -655,13 +774,13 @@ function registerExportCommand(program) {
655
774
  const state = await ensureWorkspace(dir);
656
775
  const folderId = resolveFolderId(state.synced.collections.folders, folder);
657
776
  if (!folderId) {
658
- process.stderr.write(`${kleur4.red("error")}: no folder matches "${folder}" in ${dir}
777
+ process.stderr.write(`${kleur5.red("error")}: no folder matches "${folder}" in ${dir}
659
778
  `);
660
779
  process.exit(2);
661
780
  }
662
781
  const collected = collectFolderExport({ synced: state.synced, folderId });
663
782
  if (!collected) {
664
- process.stderr.write(`${kleur4.red("error")}: folder "${folder}" no longer exists
783
+ process.stderr.write(`${kleur5.red("error")}: folder "${folder}" no longer exists
665
784
  `);
666
785
  process.exit(2);
667
786
  }
@@ -683,20 +802,20 @@ function registerExportCommand(program) {
683
802
  const outPath = path5.resolve(opts.out);
684
803
  await fs5.writeFile(outPath, json, "utf-8");
685
804
  process.stderr.write(
686
- `${kleur4.green("exported")} folder "${collected.report.folderName}" \u2192 ${outPath}
805
+ `${kleur5.green("exported")} folder "${collected.report.folderName}" \u2192 ${outPath}
687
806
  `
688
807
  );
689
808
  } else {
690
809
  process.stdout.write(json);
691
810
  process.stdout.write("\n");
692
811
  process.stderr.write(
693
- `${kleur4.green("exported")} folder "${collected.report.folderName}" (${collected.report.totalFolderCount} folders, ${collected.report.requestCount} requests, ${collected.report.credentials.length - includeIds.size} credentials redacted)
812
+ `${kleur5.green("exported")} folder "${collected.report.folderName}" (${collected.report.totalFolderCount} folders, ${collected.report.requestCount} requests, ${collected.report.credentials.length - includeIds.size} credentials redacted)
694
813
  `
695
814
  );
696
815
  }
697
816
  if (!opts.out) {
698
817
  process.stderr.write(
699
- `${kleur4.dim("hint")}: save with .apicircle.json, e.g. ${suggestFolderExportFilename(envelope)}
818
+ `${kleur5.dim("hint")}: save with .apicircle.json, e.g. ${suggestFolderExportFilename(envelope)}
700
819
  `
701
820
  );
702
821
  }
@@ -712,14 +831,14 @@ function resolveFolderId(folders, query) {
712
831
 
713
832
  // src/commands/run.ts
714
833
  import * as os2 from "os";
715
- import kleur5 from "kleur";
834
+ import kleur6 from "kleur";
716
835
  import {
717
836
  ANONYMOUS_ACTOR,
718
837
  PlanRunDeniedError,
719
838
  resolvePlanRef,
720
839
  runPlan
721
840
  } from "@apicircle/core";
722
- import { loadFromFile as loadFromFile2, saveToFile as saveToFile4 } from "@apicircle/core/workspace/file-backed";
841
+ import { loadFromFile as loadFromFile2, saveToFile as saveToFile5 } from "@apicircle/core/workspace/file-backed";
723
842
 
724
843
  // src/util/secrets.ts
725
844
  import * as path6 from "path";
@@ -760,7 +879,7 @@ import * as path7 from "path";
760
879
  import {
761
880
  collectAttachmentSlots
762
881
  } from "@apicircle/core";
763
- var ATTACHMENTS_DIR = path7.join(".apicircle", "attachments");
882
+ var ATTACHMENTS_DIR = "attachments";
764
883
  async function prepareExecutionAttachments(workspaceDir, state, plan) {
765
884
  const cacheDir = path7.resolve(workspaceDir, ATTACHMENTS_DIR);
766
885
  const requirements = collectExecutionAttachmentRequirements(state, plan);
@@ -774,6 +893,10 @@ async function prepareExecutionAttachments(workspaceDir, state, plan) {
774
893
  const entries = [];
775
894
  for (const requirement of requirements) {
776
895
  const localPath = path7.join(cacheDir, encodeURIComponent(requirement.slotId));
896
+ const resolvedLocal = path7.resolve(localPath);
897
+ if (!resolvedLocal.startsWith(path7.resolve(cacheDir) + path7.sep)) {
898
+ throw new Error(`Attachment path escapes cache directory: ${requirement.slotId}`);
899
+ }
777
900
  const present = await hasExpectedFile(localPath, requirement.sha256);
778
901
  if (present) {
779
902
  alreadyPresent++;
@@ -874,6 +997,7 @@ function collectExecutionAttachmentRequirements(state, plan) {
874
997
  addRequirement(seen, {
875
998
  ...slot,
876
999
  source: "workspace",
1000
+ sourceWorkspaceId: state.synced.workspaceId,
877
1001
  repoFullName: state.local.connectedRepo?.fullName ?? void 0,
878
1002
  branch: state.local.workingBranch?.name ?? void 0,
879
1003
  publicRepo: state.local.connectedRepo ? !state.local.connectedRepo.isPrivate : false,
@@ -905,6 +1029,7 @@ function collectExecutionAttachmentRequirements(state, plan) {
905
1029
  addRequirement(seen, {
906
1030
  ...slot,
907
1031
  source: "linked-workspace",
1032
+ sourceWorkspaceId: link.sourceWorkspaceId,
908
1033
  linkedWorkspaceId,
909
1034
  repoFullName: link.source.repoFullName,
910
1035
  branch: link.source.branch,
@@ -976,7 +1101,12 @@ async function downloadAttachment(requirement) {
976
1101
  "private linked attachments need a GitHub token (set APICIRCLE_GITHUB_TOKEN or GITHUB_TOKEN)"
977
1102
  );
978
1103
  }
979
- const apiPath = [".apicircle", "attachments", requirement.slotId].map(encodeURIComponent).join("/");
1104
+ const apiPath = [
1105
+ ".apicircle",
1106
+ `workspace-${requirement.sourceWorkspaceId}`,
1107
+ "attachments",
1108
+ requirement.slotId
1109
+ ].map(encodeURIComponent).join("/");
980
1110
  const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
981
1111
  repo
982
1112
  )}/contents/${apiPath}?ref=${encodeURIComponent(requirement.branch)}`;
@@ -1029,7 +1159,7 @@ function registerRunCommand(program) {
1029
1159
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
1030
1160
  ).option(
1031
1161
  "-w, --workspace-path <dir>",
1032
- "Filesystem directory containing workspace.synced.json (skips the registry)."
1162
+ "Filesystem directory containing workspace.json (skips the registry)."
1033
1163
  ).option("--no-assertions", "Run requests without evaluating their assertions").option("-s, --secrets <file>", "JSON file mapping secretKeyId \u2192 plaintext value").option("--no-save", "Do not write the plan run to workspace history").option("--reporter <format>", "Report format: text | json | junit", "text").option("--bail", "Stop the run at the first failed step").option("-e, --env <name>", "Layer a local environment on top of the run").option("--as <actor>", "Override the recorded runner identity").action(async (planRef, opts) => {
1034
1164
  let dir;
1035
1165
  try {
@@ -1041,7 +1171,7 @@ function registerRunCommand(program) {
1041
1171
  dir = resolved.dir;
1042
1172
  if (resolved.fromRegistry) {
1043
1173
  process.stderr.write(
1044
- `${kleur5.dim("workspace")}: ${kleur5.cyan(resolved.name ?? resolved.id ?? "")} ${kleur5.dim(`(${dir})`)}
1174
+ `${kleur6.dim("workspace")}: ${kleur6.cyan(resolved.name ?? resolved.id ?? "")} ${kleur6.dim(`(${dir})`)}
1045
1175
  `
1046
1176
  );
1047
1177
  }
@@ -1059,7 +1189,7 @@ function registerRunCommand(program) {
1059
1189
  }
1060
1190
  const state = await loadFromFile2(dir, { allowMissing: true });
1061
1191
  if (!state) {
1062
- fail(`no workspace found at ${dir} (expected workspace.synced.json)`);
1192
+ fail(`no workspace found at ${dir} (expected workspace.json)`);
1063
1193
  return;
1064
1194
  }
1065
1195
  const ref = resolvePlanRef(state.synced, planRef);
@@ -1129,7 +1259,7 @@ function registerRunCommand(program) {
1129
1259
  process.off("SIGINT", onSigint);
1130
1260
  const aborted = controller.signal.aborted;
1131
1261
  const saved = opts.save !== false;
1132
- if (saved) await saveToFile4(dir, result.nextState);
1262
+ if (saved) await saveToFile5(dir, result.nextState);
1133
1263
  if (reporter === "json") {
1134
1264
  process.stdout.write(
1135
1265
  JSON.stringify(
@@ -1170,17 +1300,17 @@ function formatHeader(plan, actor, withAssertions, opts) {
1170
1300
  opts.bail ? "bail" : null,
1171
1301
  opts.env ? `env=${opts.env}` : null
1172
1302
  ].filter((f) => f !== null);
1173
- return `${kleur5.bold("Plan")} ${plan.name} ${kleur5.dim(
1303
+ return `${kleur6.bold("Plan")} ${plan.name} ${kleur6.dim(
1174
1304
  `(${enabled}/${plan.steps.length} steps \xB7 ${flags.join(" \xB7 ")})`
1175
1305
  )}
1176
- ${kleur5.dim("Run by")} ${actor.name} ${kleur5.dim(`(${actor.kind})`)}
1306
+ ${kleur6.dim("Run by")} ${actor.name} ${kleur6.dim(`(${actor.kind})`)}
1177
1307
 
1178
1308
  `;
1179
1309
  }
1180
1310
  function formatAttachmentPreparation(summary) {
1181
1311
  const status = `${summary.downloaded} downloaded, ${summary.alreadyPresent} already local`;
1182
1312
  const lines = [
1183
- `${kleur5.bold("Attachments")} ${summary.total} required ${kleur5.dim(
1313
+ `${kleur6.bold("Attachments")} ${summary.total} required ${kleur6.dim(
1184
1314
  `(${status} - ${summary.cacheDir})`
1185
1315
  )}`
1186
1316
  ];
@@ -1188,7 +1318,7 @@ function formatAttachmentPreparation(summary) {
1188
1318
  const source = entry2.source === "linked-workspace" ? `linked:${entry2.linkedWorkspaceId ?? "unknown"}` : "workspace";
1189
1319
  const requiredBy = entry2.requiredBy.map((item) => item.requestName).join(", ");
1190
1320
  lines.push(
1191
- ` ${kleur5.dim("file")} ${entry2.filename} ${kleur5.dim(
1321
+ ` ${kleur6.dim("file")} ${entry2.filename} ${kleur6.dim(
1192
1322
  `${source} - ${requiredBy} - ${entry2.localPath}`
1193
1323
  )}`
1194
1324
  );
@@ -1201,31 +1331,31 @@ function formatStepLine(step) {
1201
1331
  const n = `${step.stepIndex + 1}.`.padEnd(3);
1202
1332
  const method = (step.requestMethod || "\u2014").padEnd(7);
1203
1333
  if (step.skipped) {
1204
- return ` ${kleur5.dim("\u2013")} ${kleur5.dim(n)} ${kleur5.dim(method)} ${kleur5.dim(
1334
+ return ` ${kleur6.dim("\u2013")} ${kleur6.dim(n)} ${kleur6.dim(method)} ${kleur6.dim(
1205
1335
  `${step.requestName} skipped`
1206
1336
  )}
1207
1337
  `;
1208
1338
  }
1209
- const mark = step.passed ? kleur5.green("\u2713") : kleur5.red("\u2717");
1339
+ const mark = step.passed ? kleur6.green("\u2713") : kleur6.red("\u2717");
1210
1340
  const status = step.result?.status != null ? String(step.result.status) : "\u2014";
1211
1341
  const duration = step.result ? `${step.result.durationMs}ms` : "";
1212
1342
  const name = step.requestName.padEnd(28);
1213
- let line = ` ${mark} ${n} ${method} ${name} ${status.padEnd(4)} ${kleur5.dim(duration)}`;
1343
+ let line = ` ${mark} ${n} ${method} ${name} ${status.padEnd(4)} ${kleur6.dim(duration)}`;
1214
1344
  if (step.assertionResults.length > 0) {
1215
1345
  const passed = step.assertionResults.filter((a) => a.passed).length;
1216
- line += ` ${kleur5.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
1346
+ line += ` ${kleur6.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
1217
1347
  }
1218
1348
  line += "\n";
1219
1349
  if (step.error) {
1220
- line += ` ${kleur5.red(step.error)}
1350
+ line += ` ${kleur6.red(step.error)}
1221
1351
  `;
1222
1352
  }
1223
1353
  for (const a of step.assertionResults) {
1224
- if (!a.passed) line += ` ${kleur5.red("\u2717")} ${a.detail ?? `${a.kind} ${a.op}`}
1354
+ if (!a.passed) line += ` ${kleur6.red("\u2717")} ${a.detail ?? `${a.kind} ${a.op}`}
1225
1355
  `;
1226
1356
  }
1227
1357
  if (step.missingVariables.length > 0) {
1228
- line += ` ${kleur5.yellow("\u26A0")} unresolved: ${step.missingVariables.map((v) => `{{${v}}}`).join(", ")}
1358
+ line += ` ${kleur6.yellow("\u26A0")} unresolved: ${step.missingVariables.map((v) => `{{${v}}}`).join(", ")}
1229
1359
  `;
1230
1360
  }
1231
1361
  return line;
@@ -1244,24 +1374,24 @@ function tally(result) {
1244
1374
  function formatSummary(result, saved, aborted) {
1245
1375
  if (result.steps.length === 0) {
1246
1376
  return `
1247
- ${kleur5.yellow("Plan has no steps.")}
1377
+ ${kleur6.yellow("Plan has no steps.")}
1248
1378
  `;
1249
1379
  }
1250
1380
  const { passed, failed, skipped } = tally(result);
1251
1381
  const parts = [
1252
- kleur5.green(`${passed} passed`),
1253
- failed > 0 ? kleur5.red(`${failed} failed`) : kleur5.dim(`${failed} failed`),
1254
- kleur5.dim(`${skipped} skipped`)
1382
+ kleur6.green(`${passed} passed`),
1383
+ failed > 0 ? kleur6.red(`${failed} failed`) : kleur6.dim(`${failed} failed`),
1384
+ kleur6.dim(`${skipped} skipped`)
1255
1385
  ];
1256
- const verdict = result.passed && !aborted ? kleur5.green("PASS") : kleur5.red("FAIL");
1386
+ const verdict = result.passed && !aborted ? kleur6.green("PASS") : kleur6.red("FAIL");
1257
1387
  let out = `
1258
- ${verdict} ${parts.join(kleur5.dim(" \xB7 "))} ${kleur5.dim(
1388
+ ${verdict} ${parts.join(kleur6.dim(" \xB7 "))} ${kleur6.dim(
1259
1389
  `\xB7 ${result.planRun.durationMs}ms`
1260
1390
  )}
1261
1391
  `;
1262
- if (aborted) out += `${kleur5.yellow("Run aborted before every step finished.")}
1392
+ if (aborted) out += `${kleur6.yellow("Run aborted before every step finished.")}
1263
1393
  `;
1264
- out += saved ? kleur5.dim("Plan run saved to workspace history.\n") : kleur5.dim("Plan run not saved (--no-save).\n");
1394
+ out += saved ? kleur6.dim("Plan run saved to workspace history.\n") : kleur6.dim("Plan run not saved (--no-save).\n");
1265
1395
  return out;
1266
1396
  }
1267
1397
  function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted, attachments) {
@@ -1337,13 +1467,13 @@ ${cases.join("\n")}
1337
1467
  `;
1338
1468
  }
1339
1469
  function fail(message, code = 2, kind = "error") {
1340
- process.stderr.write(`${kleur5.red(kind)}: ${message}
1470
+ process.stderr.write(`${kleur6.red(kind)}: ${message}
1341
1471
  `);
1342
1472
  process.exitCode = code;
1343
1473
  }
1344
1474
 
1345
1475
  // src/commands/workspaces.ts
1346
- import kleur6 from "kleur";
1476
+ import kleur7 from "kleur";
1347
1477
  import { findWorkspaceEntry as findWorkspaceEntry2, setActiveWorkspace } from "@apicircle/core/workspace/registry";
1348
1478
  function registerWorkspacesCommand(program) {
1349
1479
  const ws = program.command("workspaces").description("List, create, or switch the active workspace");
@@ -1355,15 +1485,15 @@ function registerWorkspacesCommand(program) {
1355
1485
  }
1356
1486
  if (registry.workspaces.length === 0) {
1357
1487
  process.stdout.write(
1358
- `${kleur6.dim("No workspaces registered yet at")} ${root}
1359
- ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces create <name>")} ${kleur6.dim(
1488
+ `${kleur7.dim("No workspaces registered yet at")} ${root}
1489
+ ${kleur7.dim("Run")} ${kleur7.cyan("apicircle workspaces create <name>")} ${kleur7.dim(
1360
1490
  "or open the desktop app to seed one."
1361
1491
  )}
1362
1492
  `
1363
1493
  );
1364
1494
  return;
1365
1495
  }
1366
- process.stdout.write(`${kleur6.dim("registry")}: ${root}
1496
+ process.stdout.write(`${kleur7.dim("registry")}: ${root}
1367
1497
 
1368
1498
  `);
1369
1499
  const rows = [...registry.workspaces].sort(
@@ -1372,22 +1502,22 @@ ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces create <name>")} ${kleu
1372
1502
  const nameWidth = Math.max(4, ...rows.map((r) => r.name.length));
1373
1503
  const idWidth = Math.max(2, ...rows.map((r) => r.id.length));
1374
1504
  process.stdout.write(
1375
- kleur6.bold(
1505
+ kleur7.bold(
1376
1506
  ` ${"".padEnd(1)} ${"NAME".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} LAST OPENED
1377
1507
  `
1378
1508
  )
1379
1509
  );
1380
1510
  for (const w of rows) {
1381
- const mark = w.id === registry.activeWorkspaceId ? kleur6.green("\u25CF") : " ";
1511
+ const mark = w.id === registry.activeWorkspaceId ? kleur7.green("\u25CF") : " ";
1382
1512
  process.stdout.write(
1383
- ` ${mark} ${w.name.padEnd(nameWidth)} ${kleur6.dim(
1513
+ ` ${mark} ${w.name.padEnd(nameWidth)} ${kleur7.dim(
1384
1514
  w.id.padEnd(idWidth)
1385
- )} ${kleur6.dim(w.lastOpenedAt)}
1515
+ )} ${kleur7.dim(w.lastOpenedAt)}
1386
1516
  `
1387
1517
  );
1388
1518
  }
1389
1519
  process.stdout.write(`
1390
- ${kleur6.dim("\u25CF = active")}
1520
+ ${kleur7.dim("\u25CF = active")}
1391
1521
  `);
1392
1522
  });
1393
1523
  ws.command("create").description("Create a new workspace and add it to the registry").argument("<name>", "Human-readable label for the workspace").option("--sample", "Seed the workspace with one sample request", false).action(async (name, opts) => {
@@ -1397,17 +1527,17 @@ ${kleur6.dim("\u25CF = active")}
1397
1527
  sampleRequest: opts.sample ?? false
1398
1528
  });
1399
1529
  process.stdout.write(
1400
- `${kleur6.green("created")} workspace ${kleur6.cyan(entry2.name)} ${kleur6.dim(`(${entry2.id})`)}
1530
+ `${kleur7.green("created")} workspace ${kleur7.cyan(entry2.name)} ${kleur7.dim(`(${entry2.id})`)}
1401
1531
  at ${dir}
1402
1532
  `
1403
1533
  );
1404
1534
  if (registry.activeWorkspaceId === entry2.id) {
1405
- process.stdout.write(`${kleur6.dim("marked as active")}
1535
+ process.stdout.write(`${kleur7.dim("marked as active")}
1406
1536
  `);
1407
1537
  }
1408
1538
  } catch (err) {
1409
1539
  process.stderr.write(
1410
- `${kleur6.red("error")}: ${err instanceof Error ? err.message : String(err)}
1540
+ `${kleur7.red("error")}: ${err instanceof Error ? err.message : String(err)}
1411
1541
  `
1412
1542
  );
1413
1543
  process.exit(2);
@@ -1418,8 +1548,8 @@ ${kleur6.dim("\u25CF = active")}
1418
1548
  const entry2 = findWorkspaceEntry2(registry, selector);
1419
1549
  if (!entry2) {
1420
1550
  process.stderr.write(
1421
- `${kleur6.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1422
- ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces list")} ${kleur6.dim("to see what is available.")}
1551
+ `${kleur7.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1552
+ ${kleur7.dim("Run")} ${kleur7.cyan("apicircle workspaces list")} ${kleur7.dim("to see what is available.")}
1423
1553
  `
1424
1554
  );
1425
1555
  process.exit(2);
@@ -1428,7 +1558,7 @@ ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces list")} ${kleur6.dim("t
1428
1558
  const next = await setActiveWorkspace(root, entry2.id);
1429
1559
  void next;
1430
1560
  process.stdout.write(
1431
- `${kleur6.green("active")} workspace is now ${kleur6.cyan(entry2.name)} ${kleur6.dim(`(${entry2.id})`)}
1561
+ `${kleur7.green("active")} workspace is now ${kleur7.cyan(entry2.name)} ${kleur7.dim(`(${entry2.id})`)}
1432
1562
  `
1433
1563
  );
1434
1564
  });
@@ -1441,7 +1571,7 @@ ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces list")} ${kleur6.dim("t
1441
1571
  const entry2 = findWorkspaceEntry2(registry, selector);
1442
1572
  if (!entry2) {
1443
1573
  process.stderr.write(
1444
- `${kleur6.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1574
+ `${kleur7.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1445
1575
  `
1446
1576
  );
1447
1577
  process.exit(2);
@@ -1453,16 +1583,1399 @@ ${kleur6.dim("Run")} ${kleur6.cyan("apicircle workspaces list")} ${kleur6.dim("t
1453
1583
  });
1454
1584
  }
1455
1585
 
1586
+ // src/commands/linked.ts
1587
+ import kleur8 from "kleur";
1588
+ import {
1589
+ fetchRemoteWorkspaceJson,
1590
+ applyMutation as applyMutation2,
1591
+ buildLinkedSnapshot,
1592
+ ledgerFromProbe,
1593
+ parseLinkedWorkspaceJson
1594
+ } from "@apicircle/core";
1595
+ import { saveToFile as saveToFile6 } from "@apicircle/core/workspace/file-backed";
1596
+ import { generateId as generateId5 } from "@apicircle/shared";
1597
+
1598
+ // ../git/src/github/errors.ts
1599
+ var GitHubError = class extends Error {
1600
+ constructor(message, status, body) {
1601
+ super(message);
1602
+ this.status = status;
1603
+ this.body = body;
1604
+ this.name = "GitHubError";
1605
+ }
1606
+ status;
1607
+ body;
1608
+ };
1609
+ var MissingScopeError = class extends GitHubError {
1610
+ /** Scope strings the API said are missing, e.g. ['pull_request']. */
1611
+ missingScopes;
1612
+ /** Scope strings the token currently grants, parsed from x-oauth-scopes. */
1613
+ grantedScopes;
1614
+ constructor(message, status, missingScopes, grantedScopes) {
1615
+ super(message, status);
1616
+ this.name = "MissingScopeError";
1617
+ this.missingScopes = missingScopes;
1618
+ this.grantedScopes = grantedScopes;
1619
+ }
1620
+ };
1621
+ var RateLimitedError = class extends GitHubError {
1622
+ /** Unix timestamp (ms) when the rate-limit window resets. */
1623
+ resetAtMs;
1624
+ constructor(message, status, resetAtMs) {
1625
+ super(message, status);
1626
+ this.name = "RateLimitedError";
1627
+ this.resetAtMs = resetAtMs;
1628
+ }
1629
+ };
1630
+ var UnauthorizedError = class extends GitHubError {
1631
+ constructor(message, status) {
1632
+ super(message, status);
1633
+ this.name = "UnauthorizedError";
1634
+ }
1635
+ };
1636
+ var TimeoutError = class extends GitHubError {
1637
+ /** Timeout that fired, in ms. Useful for the UI message. */
1638
+ timeoutMs;
1639
+ constructor(message, timeoutMs) {
1640
+ super(message, 0);
1641
+ this.name = "TimeoutError";
1642
+ this.timeoutMs = timeoutMs;
1643
+ }
1644
+ };
1645
+
1646
+ // ../git/src/github/api.ts
1647
+ var API_BASE = "https://api.github.com";
1648
+ var LOGIN_BASE = "https://github.com";
1649
+ var DEFAULT_TIMEOUT_MS = 15e3;
1650
+ var GitHubClient = class {
1651
+ baseUrl;
1652
+ loginBaseUrl;
1653
+ fetchImpl;
1654
+ timeoutMs;
1655
+ constructor(opts = {}) {
1656
+ this.baseUrl = opts.baseUrl ?? API_BASE;
1657
+ this.loginBaseUrl = (opts.loginBaseUrl ?? LOGIN_BASE).replace(/\/$/, "");
1658
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
1659
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1660
+ }
1661
+ /**
1662
+ * Fetch the authenticated user. Doubles as a "verify token" probe — used
1663
+ * by the Secret Vault Sessions tab to refresh the granted-scopes list.
1664
+ */
1665
+ async getViewer(token, opts = {}) {
1666
+ const { json, response } = await this.call(token, "/user", opts);
1667
+ return {
1668
+ viewer: {
1669
+ login: json.login,
1670
+ id: json.id,
1671
+ name: json.name ?? null,
1672
+ avatarUrl: json.avatar_url ?? null
1673
+ },
1674
+ scopes: parseScopes(response.headers)
1675
+ };
1676
+ }
1677
+ /**
1678
+ * List repositories the authenticated user can access. Used by the repo
1679
+ * picker. Capped at 100 sorted by recent push; users with thousands of
1680
+ * repos can paginate later.
1681
+ */
1682
+ async listAccessibleRepos(token, opts = {}) {
1683
+ const { json } = await this.call(
1684
+ token,
1685
+ "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
1686
+ opts
1687
+ );
1688
+ return json.map(normalizeRepo);
1689
+ }
1690
+ /**
1691
+ * Fetch a specific repo. Validates the user-supplied owner/name pair
1692
+ * exists + is accessible, and exposes the default branch.
1693
+ */
1694
+ async getRepo(token, owner, name, opts = {}) {
1695
+ const { json } = await this.call(
1696
+ token,
1697
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
1698
+ opts
1699
+ );
1700
+ return normalizeRepo(json);
1701
+ }
1702
+ /**
1703
+ * Read the head SHA of a branch. Used to seed a new working branch from
1704
+ * main before any edits land.
1705
+ */
1706
+ async getBranchHead(token, owner, name, branch, opts = {}) {
1707
+ const { json } = await this.call(
1708
+ token,
1709
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}`,
1710
+ opts
1711
+ );
1712
+ return { name: json.name, commitSha: json.commit.sha };
1713
+ }
1714
+ /**
1715
+ * List branches on a repo. Used by the Link Workspace repo-browser to
1716
+ * populate the branch dropdown after the user picks a repo. Capped at
1717
+ * 100 (GitHub's max page size); repos with more branches paginate.
1718
+ */
1719
+ async listBranches(token, owner, name, opts = {}) {
1720
+ const { json } = await this.call(
1721
+ token,
1722
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches?per_page=100`,
1723
+ opts
1724
+ );
1725
+ return json.map((b) => ({ name: b.name, commitSha: b.commit.sha }));
1726
+ }
1727
+ /**
1728
+ * Create a new branch ref pointing at `sha`. The auto-branch flow calls
1729
+ * this with the head SHA from `getBranchHead(main)`.
1730
+ *
1731
+ * GitHub returns 422 with "Reference already exists" when the branch
1732
+ * already exists; that surfaces as a GitHubError(422) so the UI can
1733
+ * prompt for a different name.
1734
+ */
1735
+ async createBranch(token, owner, name, branchName, sha, opts = {}) {
1736
+ const { json } = await this.call(
1737
+ token,
1738
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
1739
+ {
1740
+ ...opts,
1741
+ method: "POST",
1742
+ body: { ref: `refs/heads/${branchName}`, sha },
1743
+ requiredScopes: ["repo"]
1744
+ }
1745
+ );
1746
+ return { name: branchName, commitSha: json.object.sha };
1747
+ }
1748
+ /**
1749
+ * Read a branch ref's current commit SHA. Used at the start of push-to-
1750
+ * save to find the parent commit before building the new tree.
1751
+ */
1752
+ async getRef(token, owner, name, branch, opts = {}) {
1753
+ const { json } = await this.call(
1754
+ token,
1755
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(branch)}`,
1756
+ opts
1757
+ );
1758
+ return { ref: json.ref, sha: json.object.sha };
1759
+ }
1760
+ /**
1761
+ * Read a commit's tree SHA. Used so the new tree can be built `base_tree`
1762
+ * — every path we don't override is inherited from the parent.
1763
+ */
1764
+ async getCommit(token, owner, name, sha, opts = {}) {
1765
+ const { json } = await this.call(
1766
+ token,
1767
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(sha)}`,
1768
+ opts
1769
+ );
1770
+ return {
1771
+ sha: json.sha,
1772
+ treeSha: json.tree.sha,
1773
+ message: json.message
1774
+ };
1775
+ }
1776
+ /**
1777
+ * Upload a blob to the repo and return its SHA. Used by push-to-save
1778
+ * (P4.3b) for binary attachments — text files go straight into a tree
1779
+ * entry's `content`, but binary bytes have to go through a blob first.
1780
+ *
1781
+ * `content` is base64 when `encoding === 'base64'`. GitHub stores blobs
1782
+ * deduplicated by their git-sha1 (not our sha256), so re-uploading the
1783
+ * same bytes is cheap on their side; we save a roundtrip locally by
1784
+ * tracking lastPushedBlobSha per slot in a future revision.
1785
+ */
1786
+ async createBlob(token, owner, name, args, opts = {}) {
1787
+ const { json } = await this.call(
1788
+ token,
1789
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/blobs`,
1790
+ {
1791
+ ...opts,
1792
+ method: "POST",
1793
+ body: { content: args.content, encoding: args.encoding },
1794
+ requiredScopes: ["repo"]
1795
+ }
1796
+ );
1797
+ return { sha: json.sha, size: json.size ?? 0 };
1798
+ }
1799
+ /**
1800
+ * Build a new tree from `entries`, layered over `baseTreeSha`. Entries
1801
+ * with `content` are inlined (text path); entries with a pre-uploaded
1802
+ * `sha` reference an existing blob (binary path — used by attachments).
1803
+ */
1804
+ async createTree(token, owner, name, args, opts = {}) {
1805
+ const tree = args.entries.map((e) => ({
1806
+ path: e.path,
1807
+ mode: e.mode ?? "100644",
1808
+ type: e.type ?? "blob",
1809
+ ...e.content !== void 0 ? { content: e.content } : {},
1810
+ ...e.sha !== void 0 ? { sha: e.sha } : {}
1811
+ }));
1812
+ const { json } = await this.call(
1813
+ token,
1814
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/trees`,
1815
+ {
1816
+ ...opts,
1817
+ method: "POST",
1818
+ body: { base_tree: args.baseTreeSha, tree },
1819
+ requiredScopes: ["repo"]
1820
+ }
1821
+ );
1822
+ return { sha: json.sha };
1823
+ }
1824
+ /**
1825
+ * Create a new commit object pointing at the given tree, with the given
1826
+ * parents. Returns the new commit's SHA + the tree it points at.
1827
+ */
1828
+ async createCommit(token, owner, name, args, opts = {}) {
1829
+ const { json } = await this.call(
1830
+ token,
1831
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits`,
1832
+ {
1833
+ ...opts,
1834
+ method: "POST",
1835
+ body: {
1836
+ message: args.message,
1837
+ tree: args.treeSha,
1838
+ parents: args.parents
1839
+ },
1840
+ requiredScopes: ["repo"]
1841
+ }
1842
+ );
1843
+ return { sha: json.sha, treeSha: json.tree.sha };
1844
+ }
1845
+ /**
1846
+ * Fast-forward a branch ref to a new commit SHA. Pass `force: true` to
1847
+ * skip the FF check (we don't — push-to-save is always FF over the ref
1848
+ * we just read with getRef()).
1849
+ */
1850
+ async updateRef(token, owner, name, args, opts = {}) {
1851
+ const { json } = await this.call(
1852
+ token,
1853
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(args.branch)}`,
1854
+ {
1855
+ ...opts,
1856
+ method: "PATCH",
1857
+ body: { sha: args.sha, force: args.force ?? false },
1858
+ requiredScopes: ["repo"]
1859
+ }
1860
+ );
1861
+ return { ref: json.ref, sha: json.object.sha };
1862
+ }
1863
+ /**
1864
+ * Search GitHub for public API Circle workspaces. Appends
1865
+ * `topic:apicircle` to the user-supplied query so only repos carrying
1866
+ * the `apicircle` topic — the topic the Releases & Topics dialog
1867
+ * locks onto every workspace repo — surface in results. GitHub
1868
+ * matches the bare query against repository name, description, and
1869
+ * topics, so category words like `payments` narrow the marketplace by
1870
+ * topic. An empty query lists every public API Circle workspace. Top
1871
+ * 30 results. Token is optional — anonymous browsing is supported
1872
+ * (lower GitHub rate limits apply); pass a PAT when one is available
1873
+ * to lift them. `sort` controls ordering: omit for GitHub's
1874
+ * best-match relevance, or pass `'stars'` / `'updated'`.
1875
+ */
1876
+ async searchMarketplaceRepos(token, query, opts = {}) {
1877
+ const { sort, ...callOpts } = opts;
1878
+ const fullQuery = `${query.trim()} topic:apicircle`.trim();
1879
+ const sortParam = sort ? `&sort=${sort}&order=desc` : "";
1880
+ const path8 = `/search/repositories?q=${encodeURIComponent(fullQuery)}&per_page=30${sortParam}`;
1881
+ const { json } = await this.call(token, path8, callOpts);
1882
+ const items = json.items ?? [];
1883
+ return items.map(normalizeMarketplaceRepo);
1884
+ }
1885
+ /**
1886
+ * Start GitHub's OAuth Device Flow. Returns a user-facing code the
1887
+ * user types into github.com/login/device + a device_code the app
1888
+ * polls with. Pure browser-safe: no client_secret involved (device
1889
+ * flow is the only OAuth path GitHub supports for public clients).
1890
+ *
1891
+ * Requires the OAuth App to have "Enable Device Flow" turned on in
1892
+ * its GitHub settings — surface 400 with `not_supported` to the user
1893
+ * if the App owner hasn't done that yet.
1894
+ */
1895
+ async startDeviceFlow(clientId, scope, opts = {}) {
1896
+ const url = `${this.loginBaseUrl}/login/device/code`;
1897
+ const response = await this.fetchImpl(url, {
1898
+ method: "POST",
1899
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
1900
+ body: JSON.stringify({ client_id: clientId, scope }),
1901
+ signal: opts.signal
1902
+ });
1903
+ if (!response.ok) {
1904
+ throw new GitHubError(
1905
+ `Device-flow start failed: HTTP ${response.status}`,
1906
+ response.status,
1907
+ {}
1908
+ );
1909
+ }
1910
+ const json = await response.json();
1911
+ if (json.error) {
1912
+ throw new GitHubError(json.error_description ?? json.error, 400, json);
1913
+ }
1914
+ return {
1915
+ deviceCode: json.device_code,
1916
+ userCode: json.user_code,
1917
+ verificationUri: json.verification_uri,
1918
+ expiresIn: json.expires_in,
1919
+ interval: json.interval
1920
+ };
1921
+ }
1922
+ /**
1923
+ * Poll for the access token after the user has authorized the device
1924
+ * code. GitHub returns `authorization_pending` until the user
1925
+ * completes the flow, `slow_down` if we polled too fast, then a real
1926
+ * token. Caller wraps this in a polling loop bounded by `expiresIn`.
1927
+ */
1928
+ async pollDeviceToken(clientId, deviceCode, opts = {}) {
1929
+ const url = `${this.loginBaseUrl}/login/oauth/access_token`;
1930
+ const response = await this.fetchImpl(url, {
1931
+ method: "POST",
1932
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
1933
+ body: JSON.stringify({
1934
+ client_id: clientId,
1935
+ device_code: deviceCode,
1936
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
1937
+ }),
1938
+ signal: opts.signal
1939
+ });
1940
+ const json = await response.json();
1941
+ if (json.access_token) {
1942
+ return {
1943
+ kind: "granted",
1944
+ accessToken: json.access_token,
1945
+ tokenType: json.token_type ?? "bearer",
1946
+ scope: json.scope ?? ""
1947
+ };
1948
+ }
1949
+ if (json.error === "authorization_pending") return { kind: "pending", slowDown: false };
1950
+ if (json.error === "slow_down") return { kind: "pending", slowDown: true };
1951
+ if (json.error === "expired_token") return { kind: "expired" };
1952
+ if (json.error === "access_denied")
1953
+ return { kind: "denied", reason: json.error_description ?? "User denied authorization" };
1954
+ throw new GitHubError(
1955
+ json.error_description ?? json.error ?? "Device-token poll failed",
1956
+ response.status,
1957
+ json
1958
+ );
1959
+ }
1960
+ /**
1961
+ * Create a lightweight Git tag (a ref under `refs/tags/<name>`) on the
1962
+ * given commit SHA. Used by the publish-release flow when the user
1963
+ * opts in to "Create Git tag v<x.y.z>". Returns the resolved ref.
1964
+ *
1965
+ * GitHub returns 422 with "Reference already exists" when the tag is
1966
+ * a duplicate; that surfaces as a GitHubError(422) so the UI can warn
1967
+ * the user without ever overwriting an existing tag.
1968
+ */
1969
+ async createTag(token, owner, name, args, opts = {}) {
1970
+ const { json } = await this.call(
1971
+ token,
1972
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
1973
+ {
1974
+ ...opts,
1975
+ method: "POST",
1976
+ body: { ref: `refs/tags/${args.tagName}`, sha: args.sha },
1977
+ requiredScopes: ["repo"]
1978
+ }
1979
+ );
1980
+ return { ref: json.ref, sha: json.object.sha };
1981
+ }
1982
+ /**
1983
+ * Compare two commits. Returns the relationship classification GitHub
1984
+ * gives us: `ahead` (head is descendant of base), `behind` (base is
1985
+ * descendant of head), `identical`, or `diverged` (the two histories
1986
+ * share a base but neither contains the other — typical of a force-push
1987
+ * that rewrote history under us).
1988
+ *
1989
+ * Used by the refresh path so we never silently 3-way-merge across a
1990
+ * history rewrite — divergence steers the user through an explicit
1991
+ * "history rewritten" modal instead of corrupting local state.
1992
+ */
1993
+ async compareCommits(token, owner, name, base, head, opts = {}) {
1994
+ const { json } = await this.call(
1995
+ token,
1996
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${encodeURIComponent(
1997
+ base
1998
+ )}...${encodeURIComponent(head)}`,
1999
+ { ...opts, requiredScopes: ["repo"] }
2000
+ );
2001
+ return {
2002
+ status: json.status,
2003
+ aheadBy: json.ahead_by,
2004
+ behindBy: json.behind_by
2005
+ };
2006
+ }
2007
+ /**
2008
+ * Is `ancestor` reachable from `descendant`? Thin wrapper around
2009
+ * `compareCommits` — "ahead" or "identical" means yes; "behind" or
2010
+ * "diverged" means the histories don't fit, so the answer is no.
2011
+ */
2012
+ async isAncestor(token, owner, name, ancestor, descendant, opts = {}) {
2013
+ if (ancestor === descendant) return true;
2014
+ const cmp = await this.compareCommits(token, owner, name, ancestor, descendant, opts);
2015
+ return cmp.status === "ahead" || cmp.status === "identical";
2016
+ }
2017
+ /**
2018
+ * Create a GitHub Release pointing at an existing tag. Used by the
2019
+ * publish-release flow when the user opts in to "Create GitHub
2020
+ * Release". Returns the release's HTML URL so the UI can show a
2021
+ * "Released — view on GitHub" link.
2022
+ *
2023
+ * Pass `prerelease: true` for semver pre-release identifiers (e.g.
2024
+ * `1.0.0-rc.1`); GitHub's Releases UI flags those distinctly.
2025
+ */
2026
+ async createRelease(token, owner, name, args, opts = {}) {
2027
+ const { json } = await this.call(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`, {
2028
+ ...opts,
2029
+ method: "POST",
2030
+ body: {
2031
+ tag_name: args.tagName,
2032
+ name: args.releaseName ?? args.tagName,
2033
+ body: args.body ?? "",
2034
+ draft: args.draft ?? false,
2035
+ prerelease: args.prerelease ?? false
2036
+ },
2037
+ requiredScopes: ["repo"]
2038
+ });
2039
+ return { id: json.id, htmlUrl: json.html_url, tagName: json.tag_name };
2040
+ }
2041
+ /**
2042
+ * Read a tag ref's current commit SHA. Used by the Release & topics
2043
+ * modal to detect whether a tag with the chosen name already exists
2044
+ * (so the UI can surface an "Override existing tag" toggle instead of
2045
+ * silently 422'ing through createTag).
2046
+ *
2047
+ * Returns `null` when the tag doesn't exist (404). Other failures
2048
+ * surface as typed errors.
2049
+ */
2050
+ async getTagSha(token, owner, name, tagName, opts = {}) {
2051
+ try {
2052
+ const { json } = await this.call(
2053
+ token,
2054
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/tags/${encodeURIComponent(tagName)}`,
2055
+ opts
2056
+ );
2057
+ return json.object.sha;
2058
+ } catch (err) {
2059
+ if (err instanceof GitHubError && err.status === 404) return null;
2060
+ throw err;
2061
+ }
2062
+ }
2063
+ /**
2064
+ * Delete a ref. Used to support the "Override existing tag" path on
2065
+ * the Release & topics modal — we delete the existing tag ref, then
2066
+ * createTag against the new SHA. (GitHub doesn't have a single
2067
+ * "force-update tag" endpoint via the simple refs API.)
2068
+ *
2069
+ * `ref` is the bare suffix, e.g. `tags/v1.0.0` or `heads/feature-x`.
2070
+ */
2071
+ async deleteRef(token, owner, name, ref, opts = {}) {
2072
+ await this.call(
2073
+ token,
2074
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/${ref.split("/").map(encodeURIComponent).join("/")}`,
2075
+ {
2076
+ ...opts,
2077
+ method: "DELETE",
2078
+ requiredScopes: ["repo"]
2079
+ }
2080
+ );
2081
+ }
2082
+ /**
2083
+ * Read the repo's current topic list. Topics drive marketplace
2084
+ * discoverability — public API Circle workspaces include `apicircle`
2085
+ * plus user-chosen category topics.
2086
+ *
2087
+ * Note: GitHub's topics API uses a custom Accept header, but we treat
2088
+ * that as transport detail; the `application/vnd.github.mercy-preview+json`
2089
+ * preview is now stable so the default Accept works.
2090
+ */
2091
+ async listRepoTopics(token, owner, name, opts = {}) {
2092
+ const { json } = await this.call(
2093
+ token,
2094
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
2095
+ opts
2096
+ );
2097
+ return Array.isArray(json.names) ? json.names : [];
2098
+ }
2099
+ /**
2100
+ * Replace the repo's full topic list. GitHub's `PUT /topics` endpoint
2101
+ * is a full replace (not a merge), so the caller must pass the
2102
+ * complete desired list. Caps at 20 topics; each must match
2103
+ * `^[a-z0-9][a-z0-9-]*$` and be ≤ 50 chars (GitHub enforces this with
2104
+ * a 422). Returns the persisted list.
2105
+ */
2106
+ async setRepoTopics(token, owner, name, topics, opts = {}) {
2107
+ const { json } = await this.call(
2108
+ token,
2109
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
2110
+ {
2111
+ ...opts,
2112
+ method: "PUT",
2113
+ body: { names: topics },
2114
+ requiredScopes: ["repo"]
2115
+ }
2116
+ );
2117
+ return Array.isArray(json.names) ? json.names : [];
2118
+ }
2119
+ /**
2120
+ * Fetch a single file's contents from a branch / commit. Returns
2121
+ * `null` when GitHub answers 404 (file simply doesn't exist on that
2122
+ * ref — the common case for the very first pull). Other failures
2123
+ * surface as the usual typed errors.
2124
+ *
2125
+ * Used by the refresh flow to read remote `workspace.json` so the
2126
+ * 3-way diff can compare it against the local doc.
2127
+ */
2128
+ async getContents(token, owner, name, path8, ref, opts = {}) {
2129
+ const query = `?ref=${encodeURIComponent(ref)}`;
2130
+ try {
2131
+ const { json } = await this.call(
2132
+ token,
2133
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}${query}`,
2134
+ opts
2135
+ );
2136
+ if (Array.isArray(json) || json.type !== "file") {
2137
+ throw new GitHubError(`Path ${path8} is not a file`, 422, json);
2138
+ }
2139
+ const cleaned = json.content.replace(/\n/g, "");
2140
+ const decoded = decodeBase64Utf8(cleaned);
2141
+ return { content: decoded, sha: json.sha, path: json.path, size: json.size };
2142
+ } catch (err) {
2143
+ if (err instanceof GitHubError && err.status === 404) return null;
2144
+ throw err;
2145
+ }
2146
+ }
2147
+ /**
2148
+ * Create or update a file via the Contents API. The killer feature here
2149
+ * vs. the git-data flow (createBlob → createTree → createCommit →
2150
+ * updateRef) is that this works on **truly empty repos**: GitHub's git
2151
+ * database isn't initialized until the first commit lands, so all the
2152
+ * `/git/*` endpoints reject with 409 "Git Repository is empty" — but
2153
+ * `PUT /contents/{path}` atomically initializes the database with a
2154
+ * single-file commit on the supplied branch (defaulting to the repo's
2155
+ * default branch).
2156
+ *
2157
+ * Used by the seed-initial-commit flow to bootstrap a freshly-created
2158
+ * empty repo with a scaffold `workspace.json`.
2159
+ *
2160
+ * `contentBase64` must already be base64-encoded — caller chooses the
2161
+ * encoder (TextEncoder for UTF-8 strings, raw bytes for binaries).
2162
+ */
2163
+ async putContents(token, owner, name, path8, args, opts = {}) {
2164
+ const body = {
2165
+ message: args.message,
2166
+ content: args.contentBase64
2167
+ };
2168
+ if (args.branch) body.branch = args.branch;
2169
+ if (args.sha) body.sha = args.sha;
2170
+ const { json } = await this.call(
2171
+ token,
2172
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}`,
2173
+ {
2174
+ ...opts,
2175
+ method: "PUT",
2176
+ body,
2177
+ requiredScopes: ["repo"]
2178
+ }
2179
+ );
2180
+ return { commitSha: json.commit.sha, contentSha: json.content.sha };
2181
+ }
2182
+ /**
2183
+ * Same as `getContents` but returns the raw bytes instead of UTF-8
2184
+ * decoding the file. Used by the refresh flow to pull
2185
+ * `.apicircle/workspace-<id>/attachments/<slotId>` blobs into local IDB without
2186
+ * mangling binary data through TextDecoder.
2187
+ */
2188
+ async getBinaryContents(token, owner, name, path8, ref, opts = {}) {
2189
+ const query = `?ref=${encodeURIComponent(ref)}`;
2190
+ try {
2191
+ const { json } = await this.call(
2192
+ token,
2193
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}${query}`,
2194
+ opts
2195
+ );
2196
+ if (Array.isArray(json) || json.type !== "file") {
2197
+ throw new GitHubError(`Path ${path8} is not a file`, 422, json);
2198
+ }
2199
+ const cleaned = json.content.replace(/\n/g, "");
2200
+ const bytes = decodeBase64Bytes(cleaned);
2201
+ return { bytes, sha: json.sha, path: json.path, size: json.size };
2202
+ } catch (err) {
2203
+ if (err instanceof GitHubError && err.status === 404) return null;
2204
+ throw err;
2205
+ }
2206
+ }
2207
+ /**
2208
+ * Open a pull request from `head` (the working branch) into `base` (the
2209
+ * repo's default branch). PR creation needs the `pull_request` scope on
2210
+ * top of `repo`; missing-scope errors flow through MissingScopeError so
2211
+ * the UI can prompt the user to update the token without losing branch
2212
+ * state (Plan §3.7).
2213
+ *
2214
+ * GitHub returns 422 when:
2215
+ * - head/base are equal (nothing to merge)
2216
+ * - a PR already exists between this head and base
2217
+ * - the head branch doesn't exist
2218
+ * All three surface as a plain GitHubError(422); the UI message is
2219
+ * picked up from response.body.message.
2220
+ */
2221
+ /**
2222
+ * Fetch a single pull request by number. Used by the refresh flow to
2223
+ * detect whether a previously-opened PR has been merged on GitHub —
2224
+ * `merged: true` is what triggers the working-branch retirement path.
2225
+ *
2226
+ * Returns `null` on 404 (PR was deleted or never existed at this number);
2227
+ * other failures surface as the usual typed errors.
2228
+ */
2229
+ async getPullRequest(token, owner, name, number, opts = {}) {
2230
+ try {
2231
+ const { json } = await this.call(
2232
+ token,
2233
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
2234
+ opts
2235
+ );
2236
+ return {
2237
+ number: json.number,
2238
+ htmlUrl: json.html_url,
2239
+ state: json.state,
2240
+ merged: json.merged === true
2241
+ };
2242
+ } catch (err) {
2243
+ if (err instanceof GitHubError && err.status === 404) return null;
2244
+ throw err;
2245
+ }
2246
+ }
2247
+ /**
2248
+ * List pull requests on a repo. The capability-probe path uses this with
2249
+ * `perPage: 1` to determine whether the token can read PRs (and, by
2250
+ * extension on classic PATs, whether it can also create them).
2251
+ *
2252
+ * Caller declares `requiredScopes` to surface a `MissingScopeError` on
2253
+ * 403, so the capability probe can recognise the missing-scope case
2254
+ * cleanly vs. transient 5xx/network failures.
2255
+ */
2256
+ async listPullRequests(token, owner, name, args = {}, opts = {}) {
2257
+ const params = new URLSearchParams();
2258
+ params.set("per_page", String(args.perPage ?? 30));
2259
+ if (args.state) params.set("state", args.state);
2260
+ const { json } = await this.call(
2261
+ token,
2262
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?${params.toString()}`,
2263
+ {
2264
+ ...opts,
2265
+ requiredScopes: ["repo", "pull_request"]
2266
+ }
2267
+ );
2268
+ return json.map((pr) => ({
2269
+ number: pr.number,
2270
+ htmlUrl: pr.html_url,
2271
+ state: pr.state,
2272
+ title: pr.title
2273
+ }));
2274
+ }
2275
+ async createPullRequest(token, owner, name, args, opts = {}) {
2276
+ const { json } = await this.call(
2277
+ token,
2278
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
2279
+ {
2280
+ ...opts,
2281
+ method: "POST",
2282
+ body: {
2283
+ title: args.title,
2284
+ body: args.body,
2285
+ head: args.head,
2286
+ base: args.base,
2287
+ draft: args.draft ?? false
2288
+ },
2289
+ requiredScopes: ["repo", "pull_request"]
2290
+ }
2291
+ );
2292
+ return {
2293
+ number: json.number,
2294
+ htmlUrl: json.html_url,
2295
+ state: json.state,
2296
+ title: json.title
2297
+ };
2298
+ }
2299
+ // --- low-level call ----------------------------------------------------
2300
+ async call(token, path8, opts = {}) {
2301
+ const url = path8.startsWith("http") ? path8 : `${this.baseUrl}${path8}`;
2302
+ const controller = new AbortController();
2303
+ const onExternalAbort = () => controller.abort(opts.signal.reason);
2304
+ if (opts.signal) {
2305
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
2306
+ else opts.signal.addEventListener("abort", onExternalAbort, { once: true });
2307
+ }
2308
+ const timeoutHandle = setTimeout(
2309
+ () => controller.abort(new Error(`GitHub request timed out after ${this.timeoutMs}ms`)),
2310
+ this.timeoutMs
2311
+ );
2312
+ let response;
2313
+ let timedOut = false;
2314
+ try {
2315
+ response = await this.fetchImpl(url, {
2316
+ method: opts.method ?? "GET",
2317
+ headers: {
2318
+ Accept: "application/vnd.github+json",
2319
+ "X-GitHub-Api-Version": "2022-11-28",
2320
+ ...token ? { Authorization: `Bearer ${token}` } : {},
2321
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
2322
+ },
2323
+ cache: "no-store",
2324
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
2325
+ signal: controller.signal
2326
+ });
2327
+ } catch (err) {
2328
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
2329
+ const callerAborted = opts.signal?.aborted ?? false;
2330
+ if (isAbort && !callerAborted) {
2331
+ timedOut = true;
2332
+ throw new TimeoutError(
2333
+ `GitHub request timed out after ${this.timeoutMs}ms. The write may have partially landed \u2014 refresh before retrying.`,
2334
+ this.timeoutMs
2335
+ );
2336
+ }
2337
+ throw err;
2338
+ } finally {
2339
+ clearTimeout(timeoutHandle);
2340
+ if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort);
2341
+ void timedOut;
2342
+ }
2343
+ if (response.ok) {
2344
+ if (response.status === 204 || response.status === 205) {
2345
+ return { json: {}, response };
2346
+ }
2347
+ const json = await response.json();
2348
+ return { json, response };
2349
+ }
2350
+ const errBody = await safeReadJson(response);
2351
+ throw classifyError(response, errBody, opts.requiredScopes ?? []);
2352
+ }
2353
+ };
2354
+ function normalizeMarketplaceRepo(raw) {
2355
+ return {
2356
+ fullName: raw.full_name,
2357
+ owner: raw.owner.login,
2358
+ name: raw.name,
2359
+ description: raw.description ?? "",
2360
+ topics: raw.topics ?? [],
2361
+ stargazers: raw.stargazers_count ?? 0,
2362
+ defaultBranch: raw.default_branch ?? "main"
2363
+ };
2364
+ }
2365
+ function decodeBase64Utf8(b64) {
2366
+ return new TextDecoder("utf-8").decode(decodeBase64Bytes(b64));
2367
+ }
2368
+ function decodeBase64Bytes(b64) {
2369
+ const binary = atob(b64);
2370
+ const bytes = new Uint8Array(binary.length);
2371
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2372
+ return bytes;
2373
+ }
2374
+ function normalizeRepo(raw) {
2375
+ const visibility = raw.visibility ?? (raw.private === true ? "private" : "public");
2376
+ const isPrivate = raw.private ?? visibility !== "public";
2377
+ const pushable = raw.permissions?.push === true || raw.permissions?.admin === true;
2378
+ return {
2379
+ fullName: raw.full_name,
2380
+ owner: raw.owner.login,
2381
+ name: raw.name,
2382
+ defaultBranch: raw.default_branch,
2383
+ visibility,
2384
+ isPrivate,
2385
+ pushable
2386
+ };
2387
+ }
2388
+ function parseScopes(headers) {
2389
+ const raw = headers.get("x-oauth-scopes") ?? "";
2390
+ const granted = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2391
+ const acceptedHeader = headers.get("x-accepted-oauth-scopes") ?? "";
2392
+ const acceptedRequired = acceptedHeader.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2393
+ return acceptedRequired.length > 0 ? { granted, acceptedRequired } : { granted };
2394
+ }
2395
+ function classifyError(response, body, callerRequiredScopes) {
2396
+ const message = extractMessage(body) ?? response.statusText;
2397
+ const status = response.status;
2398
+ if (status === 401) {
2399
+ return new UnauthorizedError(message || "Unauthorized \u2014 token rejected", status);
2400
+ }
2401
+ if (status === 403) {
2402
+ const remaining = response.headers.get("x-ratelimit-remaining");
2403
+ const reset = response.headers.get("x-ratelimit-reset");
2404
+ if (remaining === "0" && reset) {
2405
+ const resetAtMs = Number(reset) * 1e3;
2406
+ const deltaMs = Math.max(0, resetAtMs - Date.now());
2407
+ const totalSeconds = Math.ceil(deltaMs / 1e3);
2408
+ const human = totalSeconds < 60 ? `${totalSeconds}s` : totalSeconds < 3600 ? `${Math.ceil(totalSeconds / 60)} min` : `${Math.ceil(totalSeconds / 3600)} h`;
2409
+ return new RateLimitedError(
2410
+ `GitHub rate limit reached. Resets in ${human} (at ${new Date(resetAtMs).toISOString()}).`,
2411
+ status,
2412
+ resetAtMs
2413
+ );
2414
+ }
2415
+ const accepted = (response.headers.get("x-accepted-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2416
+ const granted = (response.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
2417
+ const missing = accepted.length > 0 ? accepted.filter((s) => !granted.includes(s)) : callerRequiredScopes.filter((s) => !granted.includes(s));
2418
+ if (missing.length > 0) {
2419
+ return new MissingScopeError(
2420
+ `GitHub denied this action: missing scopes ${missing.join(", ")}.`,
2421
+ status,
2422
+ missing,
2423
+ granted
2424
+ );
2425
+ }
2426
+ }
2427
+ return new GitHubError(message || "GitHub API call failed", status, body);
2428
+ }
2429
+ function extractMessage(body) {
2430
+ if (typeof body === "object" && body !== null && "message" in body) {
2431
+ const m = body.message;
2432
+ if (typeof m === "string") return m;
2433
+ }
2434
+ return null;
2435
+ }
2436
+ async function safeReadJson(response) {
2437
+ try {
2438
+ return await response.json();
2439
+ } catch {
2440
+ return null;
2441
+ }
2442
+ }
2443
+
2444
+ // src/commands/linked.ts
2445
+ function resolveToken(opts) {
2446
+ return (opts.token ?? process.env.GITHUB_TOKEN ?? "").trim();
2447
+ }
2448
+ async function resolveDir2(opts) {
2449
+ try {
2450
+ const resolved = await resolveWorkspace({
2451
+ name: opts.workspaceName,
2452
+ path: opts.workspacePath,
2453
+ expectExists: false
2454
+ });
2455
+ if (resolved.fromRegistry) {
2456
+ process.stderr.write(
2457
+ `${kleur8.dim("workspace")}: ${kleur8.cyan(resolved.name ?? resolved.id ?? "")} ${kleur8.dim(`(${resolved.dir})`)}
2458
+ `
2459
+ );
2460
+ }
2461
+ return resolved.dir;
2462
+ } catch (err) {
2463
+ if (err instanceof WorkspaceResolutionError) {
2464
+ process.stderr.write(`${kleur8.red("error")}: ${err.message}
2465
+ `);
2466
+ process.exit(2);
2467
+ }
2468
+ throw err;
2469
+ }
2470
+ }
2471
+ function registerLinkedCommand(program) {
2472
+ const linked = program.command("linked").description("Manage linked workspaces (the workspaces this one consumes).");
2473
+ linked.command("list").description("List linked workspaces in the active workspace.").option("--workspace-name <name-or-id>", "Workspace name or id.").option("-w, --workspace-path <dir>", "Workspace folder path.").action(async (opts) => {
2474
+ const dir = await resolveDir2(opts);
2475
+ const state = await ensureWorkspace(dir);
2476
+ const links = Object.values(state.synced.linkedWorkspaces);
2477
+ if (links.length === 0) {
2478
+ process.stdout.write(`${kleur8.dim("No linked workspaces.")}
2479
+ `);
2480
+ return;
2481
+ }
2482
+ for (const l of links) {
2483
+ const pin = l.pinnedVersion ? `v${l.pinnedVersion}` : "unpinned";
2484
+ const ledger = state.synced.releases.perLink[l.id];
2485
+ const cur = ledger?.currentVersion ? ` \xB7 cached current v${ledger.currentVersion}` : "";
2486
+ process.stdout.write(
2487
+ `${kleur8.cyan(l.id)} ${kleur8.bold(l.name)} ${kleur8.dim(`${l.kind} \xB7 ${l.source.repoFullName}@${l.source.branch} \xB7 ${pin}${cur}`)}
2488
+ `
2489
+ );
2490
+ }
2491
+ });
2492
+ linked.command("link <repo>").description("Link a source workspace repo (owner/name).").option("-b, --branch <branch>", "Source branch.", "main").option("--pinned-version <version>", "Pin a specific version (defaults to source current).").option("--kind <kind>", "private | public", "private").option("--workspace-name <name-or-id>", "Workspace name or id.").option("-w, --workspace-path <dir>", "Workspace folder path.").option("--token <token>", "GitHub token (or set GITHUB_TOKEN).").action(
2493
+ async (repo, opts) => {
2494
+ if (!repo.includes("/")) {
2495
+ process.stderr.write(`${kleur8.red("error")}: repo must be owner/name
2496
+ `);
2497
+ process.exit(2);
2498
+ }
2499
+ if (opts.kind !== "public" && opts.kind !== "private") {
2500
+ process.stderr.write(`${kleur8.red("error")}: --kind must be private or public
2501
+ `);
2502
+ process.exit(2);
2503
+ }
2504
+ const token = resolveToken(opts);
2505
+ if (opts.kind === "private" && !token) {
2506
+ process.stderr.write(
2507
+ `${kleur8.red("error")}: a token is required for private repos (--token or GITHUB_TOKEN)
2508
+ `
2509
+ );
2510
+ process.exit(2);
2511
+ }
2512
+ const dir = await resolveDir2(opts);
2513
+ const state = await ensureWorkspace(dir);
2514
+ const dup = Object.values(state.synced.linkedWorkspaces).find(
2515
+ (l) => l.source.repoFullName === repo && l.source.branch === opts.branch
2516
+ );
2517
+ if (dup) {
2518
+ process.stderr.write(
2519
+ `${kleur8.red("error")}: already linked to ${repo}@${opts.branch} (${dup.id})
2520
+ `
2521
+ );
2522
+ process.exit(2);
2523
+ }
2524
+ const [owner, name] = repo.split("/", 2);
2525
+ const client = new GitHubClient();
2526
+ const result = await fetchRemoteWorkspaceJson(async (p) => {
2527
+ const f = await client.getContents(token, owner, name, p, opts.branch);
2528
+ return f?.content ?? null;
2529
+ });
2530
+ if ("error" in result) {
2531
+ process.stderr.write(`${kleur8.red("error")}: ${repo}@${opts.branch}: ${result.error}
2532
+ `);
2533
+ process.exit(2);
2534
+ }
2535
+ const probe = parseLinkedWorkspaceJson(result.content);
2536
+ const ledger = ledgerFromProbe(probe);
2537
+ const link = {
2538
+ id: generateId5(),
2539
+ kind: opts.kind,
2540
+ name: repo,
2541
+ sourceWorkspaceId: result.workspaceId,
2542
+ source: {
2543
+ provider: "github",
2544
+ repoFullName: repo,
2545
+ branch: opts.branch,
2546
+ sessionMode: "workspace"
2547
+ },
2548
+ scope: ["collections", "environments"],
2549
+ pinnedVersion: opts.pinnedVersion ?? ledger.currentVersion,
2550
+ updatePolicy: "manual",
2551
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2552
+ requiredSecretKeyIds: probe.secretKeys ? Object.keys(probe.secretKeys) : []
2553
+ };
2554
+ const snapshot = buildLinkedSnapshot(probe, link) ?? void 0;
2555
+ const out = applyMutation2(state, {
2556
+ kind: "linkedWorkspace.upsert",
2557
+ link,
2558
+ ledger,
2559
+ ...snapshot ? { snapshot } : {}
2560
+ });
2561
+ await saveToFile6(dir, out.next);
2562
+ process.stdout.write(
2563
+ `${kleur8.green("linked")} ${kleur8.bold(repo)} ${kleur8.dim(`(id ${link.id}, ${link.pinnedVersion ? `v${link.pinnedVersion}` : "unpinned"})`)}
2564
+ `
2565
+ );
2566
+ }
2567
+ );
2568
+ linked.command("refresh <id>").description("Re-pull a linked workspace's cached release ledger.").option("--workspace-name <name-or-id>", "Workspace name or id.").option("-w, --workspace-path <dir>", "Workspace folder path.").option("--token <token>", "GitHub token (or set GITHUB_TOKEN).").action(async (id, opts) => {
2569
+ const dir = await resolveDir2(opts);
2570
+ const state = await ensureWorkspace(dir);
2571
+ const link = state.synced.linkedWorkspaces[id];
2572
+ if (!link) {
2573
+ process.stderr.write(`${kleur8.red("error")}: linked workspace ${id} not found
2574
+ `);
2575
+ process.exit(2);
2576
+ }
2577
+ const token = resolveToken(opts);
2578
+ if (link.kind === "private" && !token) {
2579
+ process.stderr.write(
2580
+ `${kleur8.red("error")}: a token is required for private links (--token or GITHUB_TOKEN)
2581
+ `
2582
+ );
2583
+ process.exit(2);
2584
+ }
2585
+ const [owner, name] = link.source.repoFullName.split("/", 2);
2586
+ const client = new GitHubClient();
2587
+ const result = await fetchRemoteWorkspaceJson(async (p) => {
2588
+ const f = await client.getContents(token, owner, name, p, link.source.branch);
2589
+ return f?.content ?? null;
2590
+ });
2591
+ if ("error" in result) {
2592
+ process.stderr.write(
2593
+ `${kleur8.red("error")}: ${link.source.repoFullName}@${link.source.branch}: ${result.error}
2594
+ `
2595
+ );
2596
+ process.exit(2);
2597
+ }
2598
+ const probe = parseLinkedWorkspaceJson(result.content);
2599
+ const ledger = ledgerFromProbe(probe);
2600
+ const needsSnapshot = !state.local.linkedCollections[id];
2601
+ const snapshot = needsSnapshot ? buildLinkedSnapshot(probe, link) ?? void 0 : void 0;
2602
+ const out = applyMutation2(state, {
2603
+ kind: "linkedWorkspace.upsert",
2604
+ link,
2605
+ ledger,
2606
+ ...snapshot ? { snapshot } : {}
2607
+ });
2608
+ await saveToFile6(dir, out.next);
2609
+ process.stdout.write(
2610
+ `${kleur8.green("refreshed")} ${kleur8.bold(link.name)} ${kleur8.dim(`(${ledger.versions.length} version(s), current ${ledger.currentVersion ?? "none"})`)}
2611
+ `
2612
+ );
2613
+ });
2614
+ linked.command("unlink <id>").description("Unlink a workspace (drops cached ledger + overrides + snapshot).").option("--workspace-name <name-or-id>", "Workspace name or id.").option("-w, --workspace-path <dir>", "Workspace folder path.").action(async (id, opts) => {
2615
+ const dir = await resolveDir2(opts);
2616
+ const state = await ensureWorkspace(dir);
2617
+ if (!state.synced.linkedWorkspaces[id]) {
2618
+ process.stderr.write(`${kleur8.red("error")}: linked workspace ${id} not found
2619
+ `);
2620
+ process.exit(2);
2621
+ }
2622
+ const out = applyMutation2(state, { kind: "linkedWorkspace.remove", id });
2623
+ await saveToFile6(dir, out.next);
2624
+ process.stdout.write(`${kleur8.green("unlinked")} ${kleur8.dim(id)}
2625
+ `);
2626
+ });
2627
+ }
2628
+
2629
+ // src/commands/release.ts
2630
+ import kleur9 from "kleur";
2631
+ var TOPIC_RE = /^[a-z0-9][a-z0-9-]*$/;
2632
+ function resolveToken2(opts) {
2633
+ return (opts.token ?? process.env.GITHUB_TOKEN ?? "").trim();
2634
+ }
2635
+ function parseRepo(repo) {
2636
+ if (!repo.includes("/")) return null;
2637
+ const [owner, name] = repo.split("/", 2);
2638
+ return { owner, name };
2639
+ }
2640
+ function registerReleaseCommand(program) {
2641
+ const release = program.command("release").description("Tag releases and edit topics on the workspace's GitHub repo.");
2642
+ release.command("tag <repo> <version>").description("Create a v<version> tag on the default branch HEAD.").option("-r, --release", "Also create a GitHub Release for the tag.").option("-n, --notes <notes>", "Release notes (used when --release is set).", "").option("--override", "Replace an existing tag of the same name.").option("--token <token>", "GitHub token (or set GITHUB_TOKEN).").action(async (repo, version, opts) => {
2643
+ const parsed = parseRepo(repo);
2644
+ if (!parsed) {
2645
+ process.stderr.write(`${kleur9.red("error")}: repo must be owner/name
2646
+ `);
2647
+ process.exit(2);
2648
+ }
2649
+ const token = resolveToken2(opts);
2650
+ if (!token) {
2651
+ process.stderr.write(
2652
+ `${kleur9.red("error")}: a token is required (--token or GITHUB_TOKEN)
2653
+ `
2654
+ );
2655
+ process.exit(2);
2656
+ }
2657
+ const tagName = `v${version.replace(/^v/, "")}`;
2658
+ const client = new GitHubClient();
2659
+ try {
2660
+ const meta = await client.getRepo(token, parsed.owner, parsed.name);
2661
+ const ref = await client.getRef(token, parsed.owner, parsed.name, meta.defaultBranch);
2662
+ const existing = await client.getTagSha(token, parsed.owner, parsed.name, tagName);
2663
+ if (existing !== null) {
2664
+ if (!opts.override) {
2665
+ process.stderr.write(
2666
+ `${kleur9.red("error")}: tag ${tagName} already exists at ${existing.slice(0, 7)} \u2014 pass --override to replace
2667
+ `
2668
+ );
2669
+ process.exit(2);
2670
+ }
2671
+ await client.deleteRef(token, parsed.owner, parsed.name, `tags/${tagName}`);
2672
+ }
2673
+ await client.createTag(token, parsed.owner, parsed.name, { tagName, sha: ref.sha });
2674
+ process.stdout.write(
2675
+ `${kleur9.green("tagged")} ${kleur9.bold(tagName)} ${kleur9.dim(`on ${meta.defaultBranch} (${ref.sha.slice(0, 7)})`)}
2676
+ `
2677
+ );
2678
+ if (opts.release) {
2679
+ const r = await client.createRelease(token, parsed.owner, parsed.name, {
2680
+ tagName,
2681
+ releaseName: tagName,
2682
+ body: opts.notes ?? ""
2683
+ });
2684
+ process.stdout.write(`${kleur9.green("release")} ${kleur9.dim(r.htmlUrl)}
2685
+ `);
2686
+ }
2687
+ } catch (err) {
2688
+ process.stderr.write(
2689
+ `${kleur9.red("error")}: ${err instanceof Error ? err.message : String(err)}
2690
+ `
2691
+ );
2692
+ process.exit(2);
2693
+ }
2694
+ });
2695
+ release.command("topics <repo>").description("List or set the repo's topics ('apicircle' is always kept).").option("--set <topics>", "Comma-separated topics to set (replaces existing).").option("--token <token>", "GitHub token (or set GITHUB_TOKEN).").action(async (repo, opts) => {
2696
+ const parsed = parseRepo(repo);
2697
+ if (!parsed) {
2698
+ process.stderr.write(`${kleur9.red("error")}: repo must be owner/name
2699
+ `);
2700
+ process.exit(2);
2701
+ }
2702
+ const token = resolveToken2(opts);
2703
+ if (!token) {
2704
+ process.stderr.write(
2705
+ `${kleur9.red("error")}: a token is required (--token or GITHUB_TOKEN)
2706
+ `
2707
+ );
2708
+ process.exit(2);
2709
+ }
2710
+ const client = new GitHubClient();
2711
+ try {
2712
+ if (opts.set === void 0) {
2713
+ const list = await client.listRepoTopics(token, parsed.owner, parsed.name);
2714
+ if (list.length === 0) {
2715
+ process.stdout.write(`${kleur9.dim("(no topics)")}
2716
+ `);
2717
+ } else {
2718
+ for (const t of list) process.stdout.write(`${t}
2719
+ `);
2720
+ }
2721
+ return;
2722
+ }
2723
+ const normalized = Array.from(
2724
+ /* @__PURE__ */ new Set([
2725
+ "apicircle",
2726
+ ...opts.set.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean)
2727
+ ])
2728
+ );
2729
+ for (const t of normalized) {
2730
+ if (!TOPIC_RE.test(t)) {
2731
+ process.stderr.write(`${kleur9.red("error")}: invalid topic "${t}"
2732
+ `);
2733
+ process.exit(2);
2734
+ }
2735
+ if (t.length > 50) {
2736
+ process.stderr.write(`${kleur9.red("error")}: topic "${t}" exceeds 50 characters
2737
+ `);
2738
+ process.exit(2);
2739
+ }
2740
+ }
2741
+ if (normalized.length > 20) {
2742
+ process.stderr.write(`${kleur9.red("error")}: GitHub allows at most 20 topics
2743
+ `);
2744
+ process.exit(2);
2745
+ }
2746
+ const saved = await client.setRepoTopics(token, parsed.owner, parsed.name, normalized);
2747
+ process.stdout.write(`${kleur9.green("topics set")} ${kleur9.dim(`(${saved.length})`)}
2748
+ `);
2749
+ for (const t of saved) process.stdout.write(` ${t}
2750
+ `);
2751
+ } catch (err) {
2752
+ process.stderr.write(
2753
+ `${kleur9.red("error")}: ${err instanceof Error ? err.message : String(err)}
2754
+ `
2755
+ );
2756
+ process.exit(2);
2757
+ }
2758
+ });
2759
+ }
2760
+
2761
+ // src/commands/folder.ts
2762
+ import kleur10 from "kleur";
2763
+ import { generateId as generateId6 } from "@apicircle/shared";
2764
+ import { FileBackedWorkspaceProvider as FileBackedWorkspaceProvider2 } from "@apicircle/mcp-server";
2765
+ var COMMON_OPTS = (cmd) => cmd.option(
2766
+ "--workspace-name <name-or-id>",
2767
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
2768
+ ).option(
2769
+ "-w, --workspace-path <dir>",
2770
+ "Filesystem directory containing the .apicircle/ workspace (skips the registry)."
2771
+ );
2772
+ async function openWorkspace(opts) {
2773
+ let dir;
2774
+ try {
2775
+ const resolved = await resolveWorkspace({
2776
+ name: opts.workspaceName,
2777
+ path: opts.workspacePath,
2778
+ expectExists: true
2779
+ });
2780
+ dir = resolved.dir;
2781
+ } catch (err) {
2782
+ if (err instanceof WorkspaceResolutionError) {
2783
+ process.stderr.write(`${kleur10.red("error")}: ${err.message}
2784
+ `);
2785
+ process.exit(2);
2786
+ }
2787
+ throw err;
2788
+ }
2789
+ await ensureWorkspace(dir);
2790
+ return { provider: new FileBackedWorkspaceProvider2(dir), dir };
2791
+ }
2792
+ function registerFolderCommand(program) {
2793
+ const folder = program.command("folder").description("List, create, rename, move, set auth, or delete folders.");
2794
+ COMMON_OPTS(
2795
+ folder.command("list").description("Print the folder tree (with auth markers).").option("--json", "Emit JSON instead of a formatted tree")
2796
+ ).action(async (opts) => {
2797
+ const { provider } = await openWorkspace(opts);
2798
+ const state = await provider.read();
2799
+ const folders = state.synced.collections.folders;
2800
+ if (opts.json) {
2801
+ process.stdout.write(JSON.stringify(Object.values(folders), null, 2) + "\n");
2802
+ return;
2803
+ }
2804
+ if (Object.keys(folders).length === 0) {
2805
+ process.stdout.write(`${kleur10.dim("No folders in this workspace.")}
2806
+ `);
2807
+ return;
2808
+ }
2809
+ const roots = Object.values(folders).filter((f) => f.parentId === null);
2810
+ roots.sort((a, b) => a.name.localeCompare(b.name));
2811
+ for (const root of roots) printTree(root, folders, 0);
2812
+ });
2813
+ COMMON_OPTS(
2814
+ folder.command("create").description(
2815
+ "Create a new folder. Optionally seed folder-level auth in the same call (saves a follow-up `folder set-auth` round-trip). Prints the new id."
2816
+ ).requiredOption("--name <name>", "Folder name (must be unique among siblings)").option("--parent <id>", "Parent folder id (omit for top level)").option(
2817
+ "--type <type>",
2818
+ "Initial auth type: bearer | basic | api-key | custom-header | none | inherit"
2819
+ ).option("--token <token>", "Token (bearer)").option("--username <user>", "Username (basic)").option("--password <pass>", "Password (basic)").option("--key <key>", "Key (api-key / custom-header)").option("--value <value>", "Value (api-key / custom-header)").option("--add-to <where>", "Where to inject api-key: header | query | cookie", "header")
2820
+ ).action(async (opts) => {
2821
+ const initialAuth = opts.type ? buildAuthFromCli({ ...opts, type: opts.type }) : void 0;
2822
+ const { provider } = await openWorkspace(opts);
2823
+ const f = {
2824
+ id: generateId6(),
2825
+ name: opts.name.trim(),
2826
+ parentId: opts.parent ?? null,
2827
+ ...initialAuth ? { auth: initialAuth } : {}
2828
+ };
2829
+ const result = await provider.apply({ kind: "folder.create", folder: f });
2830
+ if ((result.changedIds.length ?? 0) === 0) {
2831
+ process.stderr.write(`${kleur10.red("error")}: folder.create no-op (duplicate id?)
2832
+ `);
2833
+ process.exit(1);
2834
+ }
2835
+ const authNote = initialAuth ? ` auth=${initialAuth.type}` : "";
2836
+ process.stdout.write(`${kleur10.green("created")} ${f.id} ${f.name}${authNote}
2837
+ `);
2838
+ });
2839
+ COMMON_OPTS(
2840
+ folder.command("rename").description("Rename a folder. Fails if a sibling already has the new name.").argument("<id>", "Folder id").requiredOption("--name <name>", "New name")
2841
+ ).action(async (id, opts) => {
2842
+ const { provider } = await openWorkspace(opts);
2843
+ const result = await provider.apply({
2844
+ kind: "folder.update",
2845
+ id,
2846
+ patch: { name: opts.name.trim() }
2847
+ });
2848
+ if (result.changedIds.length === 0) {
2849
+ process.stderr.write(
2850
+ `${kleur10.red("error")}: rename rejected \u2014 folder not found, or a sibling already has the name "${opts.name}".
2851
+ `
2852
+ );
2853
+ process.exit(1);
2854
+ }
2855
+ process.stdout.write(`${kleur10.green("renamed")} ${id} ${opts.name}
2856
+ `);
2857
+ });
2858
+ COMMON_OPTS(
2859
+ folder.command("set-auth").description("Set folder-level auth. Descendants with `auth.type: inherit` will pick it up.").argument("<id>", "Folder id").requiredOption(
2860
+ "--type <type>",
2861
+ "Auth type: bearer | basic | api-key | custom-header | none | inherit"
2862
+ ).option("--token <token>", "Token (bearer)").option("--username <user>", "Username (basic)").option("--password <pass>", "Password (basic)").option("--key <key>", "Key (api-key / custom-header)").option("--value <value>", "Value (api-key / custom-header)").option("--add-to <where>", "Where to inject api-key: header | query | cookie", "header")
2863
+ ).action(async (id, opts) => {
2864
+ const auth = buildAuthFromCli(opts);
2865
+ const { provider } = await openWorkspace(opts);
2866
+ const result = await provider.apply({
2867
+ kind: "folder.update",
2868
+ id,
2869
+ patch: { auth }
2870
+ });
2871
+ if (result.changedIds.length === 0) {
2872
+ process.stderr.write(`${kleur10.red("error")}: folder ${id} not found.
2873
+ `);
2874
+ process.exit(1);
2875
+ }
2876
+ process.stdout.write(`${kleur10.green("updated")} ${id} auth.type=${auth.type}
2877
+ `);
2878
+ });
2879
+ COMMON_OPTS(
2880
+ folder.command("clear-auth").description("Clear folder-level auth. Descendants `inherit` walks further up.").argument("<id>", "Folder id")
2881
+ ).action(async (id, opts) => {
2882
+ const { provider } = await openWorkspace(opts);
2883
+ const result = await provider.apply({
2884
+ kind: "folder.update",
2885
+ id,
2886
+ patch: { auth: void 0 }
2887
+ });
2888
+ if (result.changedIds.length === 0) {
2889
+ process.stderr.write(`${kleur10.red("error")}: folder ${id} not found.
2890
+ `);
2891
+ process.exit(1);
2892
+ }
2893
+ process.stdout.write(`${kleur10.green("cleared auth")} ${id}
2894
+ `);
2895
+ });
2896
+ COMMON_OPTS(
2897
+ folder.command("move").description("Reparent a folder. Cycles + self-parenting are rejected.").argument("<id>", "Folder id").option("--parent <id>", "New parent id (omit for top level)")
2898
+ ).action(async (id, opts) => {
2899
+ const { provider } = await openWorkspace(opts);
2900
+ const result = await provider.apply({
2901
+ kind: "folder.move",
2902
+ id,
2903
+ newParentId: opts.parent ?? null
2904
+ });
2905
+ if (result.changedIds.length === 0) {
2906
+ process.stderr.write(
2907
+ `${kleur10.red("error")}: move rejected \u2014 folder not found, same parent, self-parent, or cycle.
2908
+ `
2909
+ );
2910
+ process.exit(1);
2911
+ }
2912
+ process.stdout.write(`${kleur10.green("moved")} ${id} parent=${opts.parent ?? "(root)"}
2913
+ `);
2914
+ });
2915
+ COMMON_OPTS(
2916
+ folder.command("delete").description("Delete a folder. Direct children reparent to its parent.").argument("<id>", "Folder id")
2917
+ ).action(async (id, opts) => {
2918
+ const { provider } = await openWorkspace(opts);
2919
+ const result = await provider.apply({ kind: "folder.delete", id });
2920
+ if (result.changedIds.length === 0) {
2921
+ process.stderr.write(`${kleur10.red("error")}: folder ${id} not found.
2922
+ `);
2923
+ process.exit(1);
2924
+ }
2925
+ process.stdout.write(`${kleur10.green("deleted")} ${id}
2926
+ `);
2927
+ });
2928
+ }
2929
+ function buildAuthFromCli(opts) {
2930
+ switch (opts.type) {
2931
+ case "none":
2932
+ return { type: "none" };
2933
+ case "inherit":
2934
+ return { type: "inherit" };
2935
+ case "bearer":
2936
+ return { type: "bearer", token: opts.token ?? "" };
2937
+ case "basic":
2938
+ return { type: "basic", username: opts.username ?? "", password: opts.password ?? "" };
2939
+ case "api-key":
2940
+ return {
2941
+ type: "api-key",
2942
+ key: opts.key ?? "",
2943
+ value: opts.value ?? "",
2944
+ addTo: opts.addTo === "query" || opts.addTo === "cookie" ? opts.addTo : "header"
2945
+ };
2946
+ case "custom-header":
2947
+ return { type: "custom-header", key: opts.key ?? "", value: opts.value ?? "" };
2948
+ default:
2949
+ process.stderr.write(
2950
+ `${kleur10.red("error")}: --type "${opts.type}" not supported by the CLI. Use bearer | basic | api-key | custom-header | none | inherit. For OAuth2 / AWS / Hawk / NTLM / JWT, edit the folder YAML in VS Code or the web/desktop app.
2951
+ `
2952
+ );
2953
+ process.exit(2);
2954
+ }
2955
+ }
2956
+ function printTree(folder, all, depth) {
2957
+ const indent = " ".repeat(depth);
2958
+ const authTag = folder.auth && folder.auth.type !== "none" && folder.auth.type !== "inherit" ? ` ${kleur10.cyan(`[auth: ${folder.auth.type}]`)}` : "";
2959
+ process.stdout.write(`${indent}${kleur10.bold(folder.name)} ${kleur10.dim(folder.id)}${authTag}
2960
+ `);
2961
+ const children = Object.values(all).filter((f) => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name));
2962
+ for (const c of children) printTree(c, all, depth + 1);
2963
+ }
2964
+
1456
2965
  // src/index.ts
1457
2966
  function buildProgram() {
1458
2967
  const program = new Command();
1459
2968
  program.name("apicircle").description("Command-line companion to API Circle Studio.").version(CLI_PACKAGE_VERSION);
1460
2969
  registerMockCommand(program);
2970
+ registerMocksCommand(program);
1461
2971
  registerMcpCommand(program);
1462
2972
  registerImportCommand(program);
1463
2973
  registerExportCommand(program);
1464
2974
  registerRunCommand(program);
1465
2975
  registerWorkspacesCommand(program);
2976
+ registerLinkedCommand(program);
2977
+ registerReleaseCommand(program);
2978
+ registerFolderCommand(program);
1466
2979
  return program;
1467
2980
  }
1468
2981
  async function runCli(argv = process.argv) {