@apicircle/cli 1.0.9 → 1.1.2

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