@apicircle/cli 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/cli.cjs CHANGED
@@ -37,7 +37,7 @@ var init_package = __esm({
37
37
  "package.json"() {
38
38
  package_default = {
39
39
  name: "@apicircle/cli",
40
- version: "1.0.9",
40
+ version: "1.1.0",
41
41
  private: false,
42
42
  type: "module",
43
43
  description: "Command-line interface for API Circle Studio. Run mock servers, drive the MCP server, and import OpenAPI / Postman / Insomnia collections from any terminal.",
@@ -115,6 +115,7 @@ var init_package = __esm({
115
115
  kleur: "^4.1.5"
116
116
  },
117
117
  devDependencies: {
118
+ "@apicircle/git": "workspace:*",
118
119
  "@types/node": "^20.0.0",
119
120
  tsup: "^8.3.0",
120
121
  typescript: "^5.4.0",
@@ -292,24 +293,7 @@ var init_loadWorkspace = __esm({
292
293
  function defaultWorkspacesRoot() {
293
294
  const override = process.env.APICIRCLE_WORKSPACES_ROOT;
294
295
  if (override && override.length > 0) return path3.resolve(override);
295
- return path3.join(electronUserDataDir(), WORKSPACES_DIRNAME);
296
- }
297
- function electronUserDataDir() {
298
- const home = os.homedir();
299
- switch (process.platform) {
300
- case "win32": {
301
- const appdata = process.env.APPDATA ?? path3.join(home, "AppData", "Roaming");
302
- return path3.join(appdata, APP_NAME, APP_SUBDIR);
303
- }
304
- case "darwin":
305
- return path3.join(home, "Library", "Application Support", APP_NAME, APP_SUBDIR);
306
- default:
307
- return path3.join(
308
- process.env.XDG_CONFIG_HOME ?? path3.join(home, ".config"),
309
- APP_NAME,
310
- APP_SUBDIR
311
- );
312
- }
296
+ return (0, import_registry.defaultApicircleRoot)();
313
297
  }
314
298
  async function resolveWorkspace(opts = {}) {
315
299
  const root = opts.workspacesRoot ?? defaultWorkspacesRoot();
@@ -490,7 +474,7 @@ function buildEmptyState(workspaceId, now, withSample) {
490
474
  }
491
475
  };
492
476
  }
493
- var os, path3, import_node_fs3, import_registry, import_file_backed2, import_shared3, APP_NAME, APP_SUBDIR, WORKSPACES_DIRNAME, WorkspaceResolutionError;
477
+ var os, path3, import_node_fs3, import_registry, import_file_backed2, import_shared3, WorkspaceResolutionError;
494
478
  var init_resolveWorkspace = __esm({
495
479
  "src/util/resolveWorkspace.ts"() {
496
480
  "use strict";
@@ -500,9 +484,6 @@ var init_resolveWorkspace = __esm({
500
484
  import_registry = require("@apicircle/core/workspace/registry");
501
485
  import_file_backed2 = require("@apicircle/core/workspace/file-backed");
502
486
  import_shared3 = require("@apicircle/shared");
503
- APP_NAME = "@apicircle";
504
- APP_SUBDIR = "desktop";
505
- WORKSPACES_DIRNAME = "workspaces";
506
487
  WorkspaceResolutionError = class extends Error {
507
488
  code;
508
489
  constructor(message, code) {
@@ -514,6 +495,152 @@ var init_resolveWorkspace = __esm({
514
495
  }
515
496
  });
516
497
 
498
+ // src/commands/mocks.ts
499
+ function registerMocksCommand(program) {
500
+ const mocks = program.command("mocks").description("Manage mock-server definitions in the active workspace");
501
+ mocks.command("list").description("List every mock server in the workspace + its default port").option(
502
+ "--workspace-name <name-or-id>",
503
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
504
+ ).option(
505
+ "-w, --workspace-path <dir>",
506
+ "Filesystem directory containing workspace.json (skips the registry)."
507
+ ).option("--json", "Emit JSON instead of a formatted table").action(async (opts) => {
508
+ const dir = await resolveDir(opts);
509
+ const state = await ensureWorkspace(dir);
510
+ const mockList = Object.values(state.synced.mockServers);
511
+ if (opts.json) {
512
+ process.stdout.write(
513
+ JSON.stringify(
514
+ mockList.map((m) => ({
515
+ id: m.id,
516
+ name: m.name,
517
+ defaultPort: m.defaultPort,
518
+ endpoints: m.endpoints.length
519
+ })),
520
+ null,
521
+ 2
522
+ ) + "\n"
523
+ );
524
+ return;
525
+ }
526
+ if (mockList.length === 0) {
527
+ process.stdout.write(`${import_kleur2.default.dim("No mock servers in this workspace.")}
528
+ `);
529
+ return;
530
+ }
531
+ const nameWidth = Math.max(4, ...mockList.map((m) => m.name.length));
532
+ const idWidth = Math.max(2, ...mockList.map((m) => m.id.length));
533
+ process.stdout.write(
534
+ import_kleur2.default.bold(
535
+ ` ${"NAME".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} ${"PORT".padStart(6)} ENDPOINTS
536
+ `
537
+ )
538
+ );
539
+ for (const m of mockList) {
540
+ const portLabel = m.defaultPort === null ? import_kleur2.default.dim("auto") : String(m.defaultPort);
541
+ process.stdout.write(
542
+ ` ${m.name.padEnd(nameWidth)} ${import_kleur2.default.dim(m.id.padEnd(idWidth))} ${portLabel.padStart(6)} ${m.endpoints.length}
543
+ `
544
+ );
545
+ }
546
+ });
547
+ 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(
548
+ "[port]",
549
+ 'Port 1024-65535, or omit / "auto" / "null" to clear back to free-port mode'
550
+ ).option(
551
+ "--workspace-name <name-or-id>",
552
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
553
+ ).option(
554
+ "-w, --workspace-path <dir>",
555
+ "Filesystem directory containing workspace.json (skips the registry)."
556
+ ).action(async (selector, portArg, opts) => {
557
+ const dir = await resolveDir(opts);
558
+ const state = await ensureWorkspace(dir);
559
+ const target = findMock(state.synced, selector);
560
+ if (!target) {
561
+ process.stderr.write(
562
+ `${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.
563
+ `
564
+ );
565
+ process.exit(2);
566
+ return;
567
+ }
568
+ const nextPort = parsePortArg(portArg);
569
+ if (nextPort === "invalid") {
570
+ process.stderr.write(
571
+ `${import_kleur2.default.red("error")}: port must be an integer in 1024-65535, or "auto" / "null" / omitted to clear.
572
+ `
573
+ );
574
+ process.exit(2);
575
+ return;
576
+ }
577
+ if (target.defaultPort === nextPort) {
578
+ process.stdout.write(
579
+ `${import_kleur2.default.dim("unchanged")}: "${target.name}" already has defaultPort ${nextPort === null ? "auto" : String(nextPort)}.
580
+ `
581
+ );
582
+ return;
583
+ }
584
+ const now = (/* @__PURE__ */ new Date()).toISOString();
585
+ const updated = { ...target, defaultPort: nextPort, updatedAt: now };
586
+ const nextSynced = {
587
+ ...state.synced,
588
+ mockServers: { ...state.synced.mockServers, [target.id]: updated },
589
+ meta: { ...state.synced.meta, updatedAt: now }
590
+ };
591
+ await (0, import_file_backed3.saveToFile)(dir, { synced: nextSynced, local: state.local });
592
+ process.stdout.write(
593
+ `${import_kleur2.default.green("updated")} "${target.name}" defaultPort = ${nextPort === null ? import_kleur2.default.dim("auto (free port)") : import_kleur2.default.cyan(String(nextPort))}
594
+ `
595
+ );
596
+ });
597
+ }
598
+ async function resolveDir(opts) {
599
+ try {
600
+ const resolved = await resolveWorkspace({
601
+ name: opts.workspaceName,
602
+ path: opts.workspacePath,
603
+ expectExists: false
604
+ });
605
+ return resolved.dir;
606
+ } catch (err) {
607
+ if (err instanceof WorkspaceResolutionError) {
608
+ process.stderr.write(`${import_kleur2.default.red("error")}: ${err.message}
609
+ `);
610
+ process.exit(2);
611
+ }
612
+ throw err;
613
+ }
614
+ }
615
+ function findMock(synced, selector) {
616
+ const all = Object.values(synced.mockServers);
617
+ const byId = all.find((m) => m.id === selector);
618
+ if (byId) return byId;
619
+ const lower = selector.toLowerCase();
620
+ return all.find((m) => m.name.toLowerCase() === lower);
621
+ }
622
+ function parsePortArg(raw) {
623
+ if (raw === void 0) return null;
624
+ const trimmed = raw.trim();
625
+ if (trimmed === "" || trimmed.toLowerCase() === "auto" || trimmed.toLowerCase() === "null") {
626
+ return null;
627
+ }
628
+ const n = Number(trimmed);
629
+ if (!Number.isInteger(n)) return "invalid";
630
+ if (n < 1024 || n > 65535) return "invalid";
631
+ return n;
632
+ }
633
+ var import_kleur2, import_file_backed3;
634
+ var init_mocks = __esm({
635
+ "src/commands/mocks.ts"() {
636
+ "use strict";
637
+ import_kleur2 = __toESM(require("kleur"), 1);
638
+ import_file_backed3 = require("@apicircle/core/workspace/file-backed");
639
+ init_loadWorkspace();
640
+ init_resolveWorkspace();
641
+ }
642
+ });
643
+
517
644
  // src/commands/mcp.ts
518
645
  function registerMcpCommand(program) {
519
646
  program.command("mcp").description("Run the API Circle MCP server (stdio transport)").option(
@@ -521,7 +648,7 @@ function registerMcpCommand(program) {
521
648
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
522
649
  ).option(
523
650
  "-w, --workspace-path <dir>",
524
- "Filesystem directory containing workspace.synced.json (skips the registry)."
651
+ "Filesystem directory containing workspace.json (skips the registry)."
525
652
  ).action(async (opts) => {
526
653
  let dir;
527
654
  let label;
@@ -535,7 +662,7 @@ function registerMcpCommand(program) {
535
662
  label = resolved.fromRegistry ? `${resolved.name ?? resolved.id} (${dir})` : dir;
536
663
  } catch (err) {
537
664
  if (err instanceof WorkspaceResolutionError) {
538
- process.stderr.write(`${import_kleur2.default.red("error")}: ${err.message}
665
+ process.stderr.write(`${import_kleur3.default.red("error")}: ${err.message}
539
666
  `);
540
667
  process.exit(2);
541
668
  }
@@ -545,7 +672,7 @@ function registerMcpCommand(program) {
545
672
  await ensureWorkspace(dir);
546
673
  } catch (err) {
547
674
  process.stderr.write(
548
- `${import_kleur2.default.red("failed to initialise workspace")} at ${dir}: ${err instanceof Error ? err.message : String(err)}
675
+ `${import_kleur3.default.red("failed to initialise workspace")} at ${dir}: ${err instanceof Error ? err.message : String(err)}
549
676
  `
550
677
  );
551
678
  process.exit(1);
@@ -553,16 +680,16 @@ function registerMcpCommand(program) {
553
680
  const workspace = new import_mcp_server.FileBackedWorkspaceProvider(dir);
554
681
  const mock = new import_mcp_server.InProcessMockController();
555
682
  const host = (0, import_mcp_server.createMcpServer)({ workspace, mock });
556
- process.stderr.write(`${import_kleur2.default.green("apicircle-mcp")} ready \xB7 workspace=${label}
683
+ process.stderr.write(`${import_kleur3.default.green("apicircle-mcp")} ready \xB7 workspace=${label}
557
684
  `);
558
685
  await host.connect();
559
686
  });
560
687
  }
561
- var import_kleur2, import_mcp_server;
688
+ var import_kleur3, import_mcp_server;
562
689
  var init_mcp = __esm({
563
690
  "src/commands/mcp.ts"() {
564
691
  "use strict";
565
- import_kleur2 = __toESM(require("kleur"), 1);
692
+ import_kleur3 = __toESM(require("kleur"), 1);
566
693
  import_mcp_server = require("@apicircle/mcp-server");
567
694
  init_loadWorkspace();
568
695
  init_resolveWorkspace();
@@ -579,7 +706,7 @@ function registerImportCommand(program) {
579
706
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
580
707
  ).option(
581
708
  "-w, --workspace-path <dir>",
582
- "Filesystem directory containing workspace.synced.json (skips the registry)."
709
+ "Filesystem directory containing workspace.json (skips the registry)."
583
710
  ).option("-f, --format <format>", "OpenAPI format: json | yaml", "json").action(async (type, input, opts) => {
584
711
  let dir;
585
712
  try {
@@ -591,13 +718,13 @@ function registerImportCommand(program) {
591
718
  dir = resolved.dir;
592
719
  if (resolved.fromRegistry) {
593
720
  process.stderr.write(
594
- `${import_kleur3.default.dim("workspace")}: ${import_kleur3.default.cyan(resolved.name ?? resolved.id ?? "")} ${import_kleur3.default.dim(`(${dir})`)}
721
+ `${import_kleur4.default.dim("workspace")}: ${import_kleur4.default.cyan(resolved.name ?? resolved.id ?? "")} ${import_kleur4.default.dim(`(${dir})`)}
595
722
  `
596
723
  );
597
724
  }
598
725
  } catch (err) {
599
726
  if (err instanceof WorkspaceResolutionError) {
600
- process.stderr.write(`${import_kleur3.default.red("error")}: ${err.message}
727
+ process.stderr.write(`${import_kleur4.default.red("error")}: ${err.message}
601
728
  `);
602
729
  process.exit(2);
603
730
  }
@@ -670,7 +797,7 @@ function registerImportCommand(program) {
670
797
  parsedEnvelope = (0, import_core.parseApicircleFolderExport)(raw);
671
798
  } catch (err) {
672
799
  process.stderr.write(
673
- `${import_kleur3.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
800
+ `${import_kleur4.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
674
801
  `
675
802
  );
676
803
  process.exit(2);
@@ -683,29 +810,29 @@ function registerImportCommand(program) {
683
810
  nextLocal = out.next.local;
684
811
  for (const r of parsedEnvelope.requests) created.push(r.id);
685
812
  for (const w of parsedEnvelope.warnings) {
686
- process.stderr.write(`${import_kleur3.default.yellow("warning")}: ${w}
813
+ process.stderr.write(`${import_kleur4.default.yellow("warning")}: ${w}
687
814
  `);
688
815
  }
689
- await (0, import_file_backed3.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
816
+ await (0, import_file_backed4.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
690
817
  process.stdout.write(
691
- `${import_kleur3.default.green("imported")} folder "${parsedEnvelope.rootFolder.name}" (${parsedEnvelope.subfolders.length + 1} folders, ${parsedEnvelope.requests.length} requests) into ${dir}
818
+ `${import_kleur4.default.green("imported")} folder "${parsedEnvelope.rootFolder.name}" (${parsedEnvelope.subfolders.length + 1} folders, ${parsedEnvelope.requests.length} requests) into ${dir}
692
819
  `
693
820
  );
694
821
  if (parsedEnvelope.dependencies.files.length > 0) {
695
822
  process.stderr.write(
696
- `${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.
823
+ `${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.
697
824
  `
698
825
  );
699
826
  }
700
827
  return;
701
828
  } else {
702
- process.stderr.write(`${import_kleur3.default.red("error")}: unknown type '${String(type)}'
829
+ process.stderr.write(`${import_kleur4.default.red("error")}: unknown type '${String(type)}'
703
830
  `);
704
831
  process.exit(2);
705
832
  }
706
- await (0, import_file_backed3.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
833
+ await (0, import_file_backed4.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
707
834
  process.stdout.write(
708
- `${import_kleur3.default.green("imported")} ${created.length} request${created.length === 1 ? "" : "s"} into ${dir}
835
+ `${import_kleur4.default.green("imported")} ${created.length} request${created.length === 1 ? "" : "s"} into ${dir}
709
836
  `
710
837
  );
711
838
  });
@@ -741,15 +868,15 @@ function blankRequest(partial) {
741
868
  ...partial
742
869
  };
743
870
  }
744
- var import_node_fs4, path4, import_kleur3, import_core, import_file_backed3, import_mock_server_core2, import_shared4;
871
+ var import_node_fs4, path4, import_kleur4, import_core, import_file_backed4, import_mock_server_core2, import_shared4;
745
872
  var init_import = __esm({
746
873
  "src/commands/import.ts"() {
747
874
  "use strict";
748
875
  import_node_fs4 = require("fs");
749
876
  path4 = __toESM(require("path"), 1);
750
- import_kleur3 = __toESM(require("kleur"), 1);
877
+ import_kleur4 = __toESM(require("kleur"), 1);
751
878
  import_core = require("@apicircle/core");
752
- import_file_backed3 = require("@apicircle/core/workspace/file-backed");
879
+ import_file_backed4 = require("@apicircle/core/workspace/file-backed");
753
880
  import_mock_server_core2 = require("@apicircle/mock-server-core");
754
881
  import_shared4 = require("@apicircle/shared");
755
882
  init_loadWorkspace();
@@ -776,7 +903,7 @@ function registerExportCommand(program) {
776
903
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
777
904
  ).option(
778
905
  "-w, --workspace-path <dir>",
779
- "Filesystem directory containing workspace.synced.json (skips the registry)."
906
+ "Filesystem directory containing workspace.json (skips the registry)."
780
907
  ).action(async (folder, opts) => {
781
908
  let dir;
782
909
  try {
@@ -787,7 +914,7 @@ function registerExportCommand(program) {
787
914
  dir = resolved.dir;
788
915
  } catch (err) {
789
916
  if (err instanceof WorkspaceResolutionError) {
790
- process.stderr.write(`${import_kleur4.default.red("error")}: ${err.message}
917
+ process.stderr.write(`${import_kleur5.default.red("error")}: ${err.message}
791
918
  `);
792
919
  process.exit(2);
793
920
  }
@@ -796,13 +923,13 @@ function registerExportCommand(program) {
796
923
  const state = await ensureWorkspace(dir);
797
924
  const folderId = resolveFolderId(state.synced.collections.folders, folder);
798
925
  if (!folderId) {
799
- process.stderr.write(`${import_kleur4.default.red("error")}: no folder matches "${folder}" in ${dir}
926
+ process.stderr.write(`${import_kleur5.default.red("error")}: no folder matches "${folder}" in ${dir}
800
927
  `);
801
928
  process.exit(2);
802
929
  }
803
930
  const collected = (0, import_core2.collectFolderExport)({ synced: state.synced, folderId });
804
931
  if (!collected) {
805
- process.stderr.write(`${import_kleur4.default.red("error")}: folder "${folder}" no longer exists
932
+ process.stderr.write(`${import_kleur5.default.red("error")}: folder "${folder}" no longer exists
806
933
  `);
807
934
  process.exit(2);
808
935
  }
@@ -824,20 +951,20 @@ function registerExportCommand(program) {
824
951
  const outPath = path5.resolve(opts.out);
825
952
  await import_node_fs5.promises.writeFile(outPath, json, "utf-8");
826
953
  process.stderr.write(
827
- `${import_kleur4.default.green("exported")} folder "${collected.report.folderName}" \u2192 ${outPath}
954
+ `${import_kleur5.default.green("exported")} folder "${collected.report.folderName}" \u2192 ${outPath}
828
955
  `
829
956
  );
830
957
  } else {
831
958
  process.stdout.write(json);
832
959
  process.stdout.write("\n");
833
960
  process.stderr.write(
834
- `${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)
961
+ `${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)
835
962
  `
836
963
  );
837
964
  }
838
965
  if (!opts.out) {
839
966
  process.stderr.write(
840
- `${import_kleur4.default.dim("hint")}: save with .apicircle.json, e.g. ${(0, import_core2.suggestFolderExportFilename)(envelope)}
967
+ `${import_kleur5.default.dim("hint")}: save with .apicircle.json, e.g. ${(0, import_core2.suggestFolderExportFilename)(envelope)}
841
968
  `
842
969
  );
843
970
  }
@@ -850,13 +977,13 @@ function resolveFolderId(folders, query) {
850
977
  if (matches.length === 1) return matches[0].id;
851
978
  return null;
852
979
  }
853
- var import_node_fs5, path5, import_kleur4, import_core2;
980
+ var import_node_fs5, path5, import_kleur5, import_core2;
854
981
  var init_export = __esm({
855
982
  "src/commands/export.ts"() {
856
983
  "use strict";
857
984
  import_node_fs5 = require("fs");
858
985
  path5 = __toESM(require("path"), 1);
859
- import_kleur4 = __toESM(require("kleur"), 1);
986
+ import_kleur5 = __toESM(require("kleur"), 1);
860
987
  import_core2 = require("@apicircle/core");
861
988
  init_loadWorkspace();
862
989
  init_resolveWorkspace();
@@ -915,6 +1042,10 @@ async function prepareExecutionAttachments(workspaceDir, state, plan) {
915
1042
  const entries = [];
916
1043
  for (const requirement of requirements) {
917
1044
  const localPath = path7.join(cacheDir, encodeURIComponent(requirement.slotId));
1045
+ const resolvedLocal = path7.resolve(localPath);
1046
+ if (!resolvedLocal.startsWith(path7.resolve(cacheDir) + path7.sep)) {
1047
+ throw new Error(`Attachment path escapes cache directory: ${requirement.slotId}`);
1048
+ }
918
1049
  const present = await hasExpectedFile(localPath, requirement.sha256);
919
1050
  if (present) {
920
1051
  alreadyPresent++;
@@ -1015,6 +1146,7 @@ function collectExecutionAttachmentRequirements(state, plan) {
1015
1146
  addRequirement(seen, {
1016
1147
  ...slot,
1017
1148
  source: "workspace",
1149
+ sourceWorkspaceId: state.synced.workspaceId,
1018
1150
  repoFullName: state.local.connectedRepo?.fullName ?? void 0,
1019
1151
  branch: state.local.workingBranch?.name ?? void 0,
1020
1152
  publicRepo: state.local.connectedRepo ? !state.local.connectedRepo.isPrivate : false,
@@ -1046,6 +1178,7 @@ function collectExecutionAttachmentRequirements(state, plan) {
1046
1178
  addRequirement(seen, {
1047
1179
  ...slot,
1048
1180
  source: "linked-workspace",
1181
+ sourceWorkspaceId: link.sourceWorkspaceId,
1049
1182
  linkedWorkspaceId,
1050
1183
  repoFullName: link.source.repoFullName,
1051
1184
  branch: link.source.branch,
@@ -1117,7 +1250,12 @@ async function downloadAttachment(requirement) {
1117
1250
  "private linked attachments need a GitHub token (set APICIRCLE_GITHUB_TOKEN or GITHUB_TOKEN)"
1118
1251
  );
1119
1252
  }
1120
- const apiPath = [".apicircle", "attachments", requirement.slotId].map(encodeURIComponent).join("/");
1253
+ const apiPath = [
1254
+ ".apicircle",
1255
+ `workspace-${requirement.sourceWorkspaceId}`,
1256
+ "attachments",
1257
+ requirement.slotId
1258
+ ].map(encodeURIComponent).join("/");
1121
1259
  const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
1122
1260
  repo
1123
1261
  )}/contents/${apiPath}?ref=${encodeURIComponent(requirement.branch)}`;
@@ -1169,7 +1307,7 @@ var init_executionAttachments = __esm({
1169
1307
  import_node_fs7 = require("fs");
1170
1308
  path7 = __toESM(require("path"), 1);
1171
1309
  import_core3 = require("@apicircle/core");
1172
- ATTACHMENTS_DIR = path7.join(".apicircle", "attachments");
1310
+ ATTACHMENTS_DIR = "attachments";
1173
1311
  }
1174
1312
  });
1175
1313
 
@@ -1180,7 +1318,7 @@ function registerRunCommand(program) {
1180
1318
  "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
1181
1319
  ).option(
1182
1320
  "-w, --workspace-path <dir>",
1183
- "Filesystem directory containing workspace.synced.json (skips the registry)."
1321
+ "Filesystem directory containing workspace.json (skips the registry)."
1184
1322
  ).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) => {
1185
1323
  let dir;
1186
1324
  try {
@@ -1192,7 +1330,7 @@ function registerRunCommand(program) {
1192
1330
  dir = resolved.dir;
1193
1331
  if (resolved.fromRegistry) {
1194
1332
  process.stderr.write(
1195
- `${import_kleur5.default.dim("workspace")}: ${import_kleur5.default.cyan(resolved.name ?? resolved.id ?? "")} ${import_kleur5.default.dim(`(${dir})`)}
1333
+ `${import_kleur6.default.dim("workspace")}: ${import_kleur6.default.cyan(resolved.name ?? resolved.id ?? "")} ${import_kleur6.default.dim(`(${dir})`)}
1196
1334
  `
1197
1335
  );
1198
1336
  }
@@ -1208,9 +1346,9 @@ function registerRunCommand(program) {
1208
1346
  fail(`unknown --reporter "${reporter}" (expected: ${REPORTERS.join(", ")})`);
1209
1347
  return;
1210
1348
  }
1211
- const state = await (0, import_file_backed4.loadFromFile)(dir, { allowMissing: true });
1349
+ const state = await (0, import_file_backed5.loadFromFile)(dir, { allowMissing: true });
1212
1350
  if (!state) {
1213
- fail(`no workspace found at ${dir} (expected workspace.synced.json)`);
1351
+ fail(`no workspace found at ${dir} (expected workspace.json)`);
1214
1352
  return;
1215
1353
  }
1216
1354
  const ref = (0, import_core4.resolvePlanRef)(state.synced, planRef);
@@ -1280,7 +1418,7 @@ function registerRunCommand(program) {
1280
1418
  process.off("SIGINT", onSigint);
1281
1419
  const aborted = controller.signal.aborted;
1282
1420
  const saved = opts.save !== false;
1283
- if (saved) await (0, import_file_backed4.saveToFile)(dir, result.nextState);
1421
+ if (saved) await (0, import_file_backed5.saveToFile)(dir, result.nextState);
1284
1422
  if (reporter === "json") {
1285
1423
  process.stdout.write(
1286
1424
  JSON.stringify(
@@ -1321,17 +1459,17 @@ function formatHeader(plan, actor, withAssertions, opts) {
1321
1459
  opts.bail ? "bail" : null,
1322
1460
  opts.env ? `env=${opts.env}` : null
1323
1461
  ].filter((f) => f !== null);
1324
- return `${import_kleur5.default.bold("Plan")} ${plan.name} ${import_kleur5.default.dim(
1462
+ return `${import_kleur6.default.bold("Plan")} ${plan.name} ${import_kleur6.default.dim(
1325
1463
  `(${enabled}/${plan.steps.length} steps \xB7 ${flags.join(" \xB7 ")})`
1326
1464
  )}
1327
- ${import_kleur5.default.dim("Run by")} ${actor.name} ${import_kleur5.default.dim(`(${actor.kind})`)}
1465
+ ${import_kleur6.default.dim("Run by")} ${actor.name} ${import_kleur6.default.dim(`(${actor.kind})`)}
1328
1466
 
1329
1467
  `;
1330
1468
  }
1331
1469
  function formatAttachmentPreparation(summary) {
1332
1470
  const status = `${summary.downloaded} downloaded, ${summary.alreadyPresent} already local`;
1333
1471
  const lines = [
1334
- `${import_kleur5.default.bold("Attachments")} ${summary.total} required ${import_kleur5.default.dim(
1472
+ `${import_kleur6.default.bold("Attachments")} ${summary.total} required ${import_kleur6.default.dim(
1335
1473
  `(${status} - ${summary.cacheDir})`
1336
1474
  )}`
1337
1475
  ];
@@ -1339,7 +1477,7 @@ function formatAttachmentPreparation(summary) {
1339
1477
  const source = entry3.source === "linked-workspace" ? `linked:${entry3.linkedWorkspaceId ?? "unknown"}` : "workspace";
1340
1478
  const requiredBy = entry3.requiredBy.map((item) => item.requestName).join(", ");
1341
1479
  lines.push(
1342
- ` ${import_kleur5.default.dim("file")} ${entry3.filename} ${import_kleur5.default.dim(
1480
+ ` ${import_kleur6.default.dim("file")} ${entry3.filename} ${import_kleur6.default.dim(
1343
1481
  `${source} - ${requiredBy} - ${entry3.localPath}`
1344
1482
  )}`
1345
1483
  );
@@ -1352,31 +1490,31 @@ function formatStepLine(step) {
1352
1490
  const n = `${step.stepIndex + 1}.`.padEnd(3);
1353
1491
  const method = (step.requestMethod || "\u2014").padEnd(7);
1354
1492
  if (step.skipped) {
1355
- return ` ${import_kleur5.default.dim("\u2013")} ${import_kleur5.default.dim(n)} ${import_kleur5.default.dim(method)} ${import_kleur5.default.dim(
1493
+ return ` ${import_kleur6.default.dim("\u2013")} ${import_kleur6.default.dim(n)} ${import_kleur6.default.dim(method)} ${import_kleur6.default.dim(
1356
1494
  `${step.requestName} skipped`
1357
1495
  )}
1358
1496
  `;
1359
1497
  }
1360
- const mark = step.passed ? import_kleur5.default.green("\u2713") : import_kleur5.default.red("\u2717");
1498
+ const mark = step.passed ? import_kleur6.default.green("\u2713") : import_kleur6.default.red("\u2717");
1361
1499
  const status = step.result?.status != null ? String(step.result.status) : "\u2014";
1362
1500
  const duration = step.result ? `${step.result.durationMs}ms` : "";
1363
1501
  const name = step.requestName.padEnd(28);
1364
- let line = ` ${mark} ${n} ${method} ${name} ${status.padEnd(4)} ${import_kleur5.default.dim(duration)}`;
1502
+ let line = ` ${mark} ${n} ${method} ${name} ${status.padEnd(4)} ${import_kleur6.default.dim(duration)}`;
1365
1503
  if (step.assertionResults.length > 0) {
1366
1504
  const passed = step.assertionResults.filter((a) => a.passed).length;
1367
- line += ` ${import_kleur5.default.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
1505
+ line += ` ${import_kleur6.default.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
1368
1506
  }
1369
1507
  line += "\n";
1370
1508
  if (step.error) {
1371
- line += ` ${import_kleur5.default.red(step.error)}
1509
+ line += ` ${import_kleur6.default.red(step.error)}
1372
1510
  `;
1373
1511
  }
1374
1512
  for (const a of step.assertionResults) {
1375
- if (!a.passed) line += ` ${import_kleur5.default.red("\u2717")} ${a.detail ?? `${a.kind} ${a.op}`}
1513
+ if (!a.passed) line += ` ${import_kleur6.default.red("\u2717")} ${a.detail ?? `${a.kind} ${a.op}`}
1376
1514
  `;
1377
1515
  }
1378
1516
  if (step.missingVariables.length > 0) {
1379
- line += ` ${import_kleur5.default.yellow("\u26A0")} unresolved: ${step.missingVariables.map((v) => `{{${v}}}`).join(", ")}
1517
+ line += ` ${import_kleur6.default.yellow("\u26A0")} unresolved: ${step.missingVariables.map((v) => `{{${v}}}`).join(", ")}
1380
1518
  `;
1381
1519
  }
1382
1520
  return line;
@@ -1395,24 +1533,24 @@ function tally(result) {
1395
1533
  function formatSummary(result, saved, aborted) {
1396
1534
  if (result.steps.length === 0) {
1397
1535
  return `
1398
- ${import_kleur5.default.yellow("Plan has no steps.")}
1536
+ ${import_kleur6.default.yellow("Plan has no steps.")}
1399
1537
  `;
1400
1538
  }
1401
1539
  const { passed, failed, skipped } = tally(result);
1402
1540
  const parts = [
1403
- import_kleur5.default.green(`${passed} passed`),
1404
- failed > 0 ? import_kleur5.default.red(`${failed} failed`) : import_kleur5.default.dim(`${failed} failed`),
1405
- import_kleur5.default.dim(`${skipped} skipped`)
1541
+ import_kleur6.default.green(`${passed} passed`),
1542
+ failed > 0 ? import_kleur6.default.red(`${failed} failed`) : import_kleur6.default.dim(`${failed} failed`),
1543
+ import_kleur6.default.dim(`${skipped} skipped`)
1406
1544
  ];
1407
- const verdict = result.passed && !aborted ? import_kleur5.default.green("PASS") : import_kleur5.default.red("FAIL");
1545
+ const verdict = result.passed && !aborted ? import_kleur6.default.green("PASS") : import_kleur6.default.red("FAIL");
1408
1546
  let out = `
1409
- ${verdict} ${parts.join(import_kleur5.default.dim(" \xB7 "))} ${import_kleur5.default.dim(
1547
+ ${verdict} ${parts.join(import_kleur6.default.dim(" \xB7 "))} ${import_kleur6.default.dim(
1410
1548
  `\xB7 ${result.planRun.durationMs}ms`
1411
1549
  )}
1412
1550
  `;
1413
- if (aborted) out += `${import_kleur5.default.yellow("Run aborted before every step finished.")}
1551
+ if (aborted) out += `${import_kleur6.default.yellow("Run aborted before every step finished.")}
1414
1552
  `;
1415
- out += saved ? import_kleur5.default.dim("Plan run saved to workspace history.\n") : import_kleur5.default.dim("Plan run not saved (--no-save).\n");
1553
+ out += saved ? import_kleur6.default.dim("Plan run saved to workspace history.\n") : import_kleur6.default.dim("Plan run not saved (--no-save).\n");
1416
1554
  return out;
1417
1555
  }
1418
1556
  function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted, attachments) {
@@ -1488,18 +1626,18 @@ ${cases.join("\n")}
1488
1626
  `;
1489
1627
  }
1490
1628
  function fail(message, code = 2, kind = "error") {
1491
- process.stderr.write(`${import_kleur5.default.red(kind)}: ${message}
1629
+ process.stderr.write(`${import_kleur6.default.red(kind)}: ${message}
1492
1630
  `);
1493
1631
  process.exitCode = code;
1494
1632
  }
1495
- var os2, import_kleur5, import_core4, import_file_backed4, REPORTERS;
1633
+ var os2, import_kleur6, import_core4, import_file_backed5, REPORTERS;
1496
1634
  var init_run = __esm({
1497
1635
  "src/commands/run.ts"() {
1498
1636
  "use strict";
1499
1637
  os2 = __toESM(require("os"), 1);
1500
- import_kleur5 = __toESM(require("kleur"), 1);
1638
+ import_kleur6 = __toESM(require("kleur"), 1);
1501
1639
  import_core4 = require("@apicircle/core");
1502
- import_file_backed4 = require("@apicircle/core/workspace/file-backed");
1640
+ import_file_backed5 = require("@apicircle/core/workspace/file-backed");
1503
1641
  init_secrets();
1504
1642
  init_resolveWorkspace();
1505
1643
  init_executionAttachments();
@@ -1518,15 +1656,15 @@ function registerWorkspacesCommand(program) {
1518
1656
  }
1519
1657
  if (registry.workspaces.length === 0) {
1520
1658
  process.stdout.write(
1521
- `${import_kleur6.default.dim("No workspaces registered yet at")} ${root}
1522
- ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle workspaces create <name>")} ${import_kleur6.default.dim(
1659
+ `${import_kleur7.default.dim("No workspaces registered yet at")} ${root}
1660
+ ${import_kleur7.default.dim("Run")} ${import_kleur7.default.cyan("apicircle workspaces create <name>")} ${import_kleur7.default.dim(
1523
1661
  "or open the desktop app to seed one."
1524
1662
  )}
1525
1663
  `
1526
1664
  );
1527
1665
  return;
1528
1666
  }
1529
- process.stdout.write(`${import_kleur6.default.dim("registry")}: ${root}
1667
+ process.stdout.write(`${import_kleur7.default.dim("registry")}: ${root}
1530
1668
 
1531
1669
  `);
1532
1670
  const rows = [...registry.workspaces].sort(
@@ -1535,22 +1673,22 @@ ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle work
1535
1673
  const nameWidth = Math.max(4, ...rows.map((r) => r.name.length));
1536
1674
  const idWidth = Math.max(2, ...rows.map((r) => r.id.length));
1537
1675
  process.stdout.write(
1538
- import_kleur6.default.bold(
1676
+ import_kleur7.default.bold(
1539
1677
  ` ${"".padEnd(1)} ${"NAME".padEnd(nameWidth)} ${"ID".padEnd(idWidth)} LAST OPENED
1540
1678
  `
1541
1679
  )
1542
1680
  );
1543
1681
  for (const w of rows) {
1544
- const mark = w.id === registry.activeWorkspaceId ? import_kleur6.default.green("\u25CF") : " ";
1682
+ const mark = w.id === registry.activeWorkspaceId ? import_kleur7.default.green("\u25CF") : " ";
1545
1683
  process.stdout.write(
1546
- ` ${mark} ${w.name.padEnd(nameWidth)} ${import_kleur6.default.dim(
1684
+ ` ${mark} ${w.name.padEnd(nameWidth)} ${import_kleur7.default.dim(
1547
1685
  w.id.padEnd(idWidth)
1548
- )} ${import_kleur6.default.dim(w.lastOpenedAt)}
1686
+ )} ${import_kleur7.default.dim(w.lastOpenedAt)}
1549
1687
  `
1550
1688
  );
1551
1689
  }
1552
1690
  process.stdout.write(`
1553
- ${import_kleur6.default.dim("\u25CF = active")}
1691
+ ${import_kleur7.default.dim("\u25CF = active")}
1554
1692
  `);
1555
1693
  });
1556
1694
  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) => {
@@ -1560,17 +1698,17 @@ ${import_kleur6.default.dim("\u25CF = active")}
1560
1698
  sampleRequest: opts.sample ?? false
1561
1699
  });
1562
1700
  process.stdout.write(
1563
- `${import_kleur6.default.green("created")} workspace ${import_kleur6.default.cyan(entry3.name)} ${import_kleur6.default.dim(`(${entry3.id})`)}
1701
+ `${import_kleur7.default.green("created")} workspace ${import_kleur7.default.cyan(entry3.name)} ${import_kleur7.default.dim(`(${entry3.id})`)}
1564
1702
  at ${dir}
1565
1703
  `
1566
1704
  );
1567
1705
  if (registry.activeWorkspaceId === entry3.id) {
1568
- process.stdout.write(`${import_kleur6.default.dim("marked as active")}
1706
+ process.stdout.write(`${import_kleur7.default.dim("marked as active")}
1569
1707
  `);
1570
1708
  }
1571
1709
  } catch (err) {
1572
1710
  process.stderr.write(
1573
- `${import_kleur6.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
1711
+ `${import_kleur7.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
1574
1712
  `
1575
1713
  );
1576
1714
  process.exit(2);
@@ -1581,8 +1719,8 @@ ${import_kleur6.default.dim("\u25CF = active")}
1581
1719
  const entry3 = (0, import_registry2.findWorkspaceEntry)(registry, selector);
1582
1720
  if (!entry3) {
1583
1721
  process.stderr.write(
1584
- `${import_kleur6.default.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1585
- ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle workspaces list")} ${import_kleur6.default.dim("to see what is available.")}
1722
+ `${import_kleur7.default.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1723
+ ${import_kleur7.default.dim("Run")} ${import_kleur7.default.cyan("apicircle workspaces list")} ${import_kleur7.default.dim("to see what is available.")}
1586
1724
  `
1587
1725
  );
1588
1726
  process.exit(2);
@@ -1591,7 +1729,7 @@ ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle work
1591
1729
  const next = await (0, import_registry2.setActiveWorkspace)(root, entry3.id);
1592
1730
  void next;
1593
1731
  process.stdout.write(
1594
- `${import_kleur6.default.green("active")} workspace is now ${import_kleur6.default.cyan(entry3.name)} ${import_kleur6.default.dim(`(${entry3.id})`)}
1732
+ `${import_kleur7.default.green("active")} workspace is now ${import_kleur7.default.cyan(entry3.name)} ${import_kleur7.default.dim(`(${entry3.id})`)}
1595
1733
  `
1596
1734
  );
1597
1735
  });
@@ -1604,7 +1742,7 @@ ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle work
1604
1742
  const entry3 = (0, import_registry2.findWorkspaceEntry)(registry, selector);
1605
1743
  if (!entry3) {
1606
1744
  process.stderr.write(
1607
- `${import_kleur6.default.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1745
+ `${import_kleur7.default.red("error")}: no workspace named "${selector}" in the registry at ${root}.
1608
1746
  `
1609
1747
  );
1610
1748
  process.exit(2);
@@ -1615,16 +1753,1433 @@ ${import_kleur6.default.dim("Run")} ${import_kleur6.default.cyan("apicircle work
1615
1753
  process.stdout.write(workspaceDirFor2(root, entry3.id) + "\n");
1616
1754
  });
1617
1755
  }
1618
- var import_kleur6, import_registry2;
1756
+ var import_kleur7, import_registry2;
1619
1757
  var init_workspaces = __esm({
1620
1758
  "src/commands/workspaces.ts"() {
1621
1759
  "use strict";
1622
- import_kleur6 = __toESM(require("kleur"), 1);
1760
+ import_kleur7 = __toESM(require("kleur"), 1);
1623
1761
  init_resolveWorkspace();
1624
1762
  import_registry2 = require("@apicircle/core/workspace/registry");
1625
1763
  }
1626
1764
  });
1627
1765
 
1766
+ // ../git/src/github/errors.ts
1767
+ var GitHubError, MissingScopeError, RateLimitedError, UnauthorizedError, TimeoutError;
1768
+ var init_errors = __esm({
1769
+ "../git/src/github/errors.ts"() {
1770
+ "use strict";
1771
+ GitHubError = class extends Error {
1772
+ constructor(message, status, body) {
1773
+ super(message);
1774
+ this.status = status;
1775
+ this.body = body;
1776
+ this.name = "GitHubError";
1777
+ }
1778
+ status;
1779
+ body;
1780
+ };
1781
+ MissingScopeError = class extends GitHubError {
1782
+ /** Scope strings the API said are missing, e.g. ['pull_request']. */
1783
+ missingScopes;
1784
+ /** Scope strings the token currently grants, parsed from x-oauth-scopes. */
1785
+ grantedScopes;
1786
+ constructor(message, status, missingScopes, grantedScopes) {
1787
+ super(message, status);
1788
+ this.name = "MissingScopeError";
1789
+ this.missingScopes = missingScopes;
1790
+ this.grantedScopes = grantedScopes;
1791
+ }
1792
+ };
1793
+ RateLimitedError = class extends GitHubError {
1794
+ /** Unix timestamp (ms) when the rate-limit window resets. */
1795
+ resetAtMs;
1796
+ constructor(message, status, resetAtMs) {
1797
+ super(message, status);
1798
+ this.name = "RateLimitedError";
1799
+ this.resetAtMs = resetAtMs;
1800
+ }
1801
+ };
1802
+ UnauthorizedError = class extends GitHubError {
1803
+ constructor(message, status) {
1804
+ super(message, status);
1805
+ this.name = "UnauthorizedError";
1806
+ }
1807
+ };
1808
+ TimeoutError = class extends GitHubError {
1809
+ /** Timeout that fired, in ms. Useful for the UI message. */
1810
+ timeoutMs;
1811
+ constructor(message, timeoutMs) {
1812
+ super(message, 0);
1813
+ this.name = "TimeoutError";
1814
+ this.timeoutMs = timeoutMs;
1815
+ }
1816
+ };
1817
+ }
1818
+ });
1819
+
1820
+ // ../git/src/github/api.ts
1821
+ function normalizeMarketplaceRepo(raw) {
1822
+ return {
1823
+ fullName: raw.full_name,
1824
+ owner: raw.owner.login,
1825
+ name: raw.name,
1826
+ description: raw.description ?? "",
1827
+ topics: raw.topics ?? [],
1828
+ stargazers: raw.stargazers_count ?? 0,
1829
+ defaultBranch: raw.default_branch ?? "main"
1830
+ };
1831
+ }
1832
+ function decodeBase64Utf8(b64) {
1833
+ return new TextDecoder("utf-8").decode(decodeBase64Bytes(b64));
1834
+ }
1835
+ function decodeBase64Bytes(b64) {
1836
+ const binary = atob(b64);
1837
+ const bytes = new Uint8Array(binary.length);
1838
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
1839
+ return bytes;
1840
+ }
1841
+ function normalizeRepo(raw) {
1842
+ const visibility = raw.visibility ?? (raw.private === true ? "private" : "public");
1843
+ const isPrivate = raw.private ?? visibility !== "public";
1844
+ const pushable = raw.permissions?.push === true || raw.permissions?.admin === true;
1845
+ return {
1846
+ fullName: raw.full_name,
1847
+ owner: raw.owner.login,
1848
+ name: raw.name,
1849
+ defaultBranch: raw.default_branch,
1850
+ visibility,
1851
+ isPrivate,
1852
+ pushable
1853
+ };
1854
+ }
1855
+ function parseScopes(headers) {
1856
+ const raw = headers.get("x-oauth-scopes") ?? "";
1857
+ const granted = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1858
+ const acceptedHeader = headers.get("x-accepted-oauth-scopes") ?? "";
1859
+ const acceptedRequired = acceptedHeader.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1860
+ return acceptedRequired.length > 0 ? { granted, acceptedRequired } : { granted };
1861
+ }
1862
+ function classifyError(response, body, callerRequiredScopes) {
1863
+ const message = extractMessage(body) ?? response.statusText;
1864
+ const status = response.status;
1865
+ if (status === 401) {
1866
+ return new UnauthorizedError(message || "Unauthorized \u2014 token rejected", status);
1867
+ }
1868
+ if (status === 403) {
1869
+ const remaining = response.headers.get("x-ratelimit-remaining");
1870
+ const reset = response.headers.get("x-ratelimit-reset");
1871
+ if (remaining === "0" && reset) {
1872
+ const resetAtMs = Number(reset) * 1e3;
1873
+ const deltaMs = Math.max(0, resetAtMs - Date.now());
1874
+ const totalSeconds = Math.ceil(deltaMs / 1e3);
1875
+ const human = totalSeconds < 60 ? `${totalSeconds}s` : totalSeconds < 3600 ? `${Math.ceil(totalSeconds / 60)} min` : `${Math.ceil(totalSeconds / 3600)} h`;
1876
+ return new RateLimitedError(
1877
+ `GitHub rate limit reached. Resets in ${human} (at ${new Date(resetAtMs).toISOString()}).`,
1878
+ status,
1879
+ resetAtMs
1880
+ );
1881
+ }
1882
+ const accepted = (response.headers.get("x-accepted-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1883
+ const granted = (response.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1884
+ const missing = accepted.length > 0 ? accepted.filter((s) => !granted.includes(s)) : callerRequiredScopes.filter((s) => !granted.includes(s));
1885
+ if (missing.length > 0) {
1886
+ return new MissingScopeError(
1887
+ `GitHub denied this action: missing scopes ${missing.join(", ")}.`,
1888
+ status,
1889
+ missing,
1890
+ granted
1891
+ );
1892
+ }
1893
+ }
1894
+ return new GitHubError(message || "GitHub API call failed", status, body);
1895
+ }
1896
+ function extractMessage(body) {
1897
+ if (typeof body === "object" && body !== null && "message" in body) {
1898
+ const m = body.message;
1899
+ if (typeof m === "string") return m;
1900
+ }
1901
+ return null;
1902
+ }
1903
+ async function safeReadJson(response) {
1904
+ try {
1905
+ return await response.json();
1906
+ } catch {
1907
+ return null;
1908
+ }
1909
+ }
1910
+ var API_BASE, LOGIN_BASE, DEFAULT_TIMEOUT_MS, GitHubClient;
1911
+ var init_api = __esm({
1912
+ "../git/src/github/api.ts"() {
1913
+ "use strict";
1914
+ init_errors();
1915
+ API_BASE = "https://api.github.com";
1916
+ LOGIN_BASE = "https://github.com";
1917
+ DEFAULT_TIMEOUT_MS = 15e3;
1918
+ GitHubClient = class {
1919
+ baseUrl;
1920
+ loginBaseUrl;
1921
+ fetchImpl;
1922
+ timeoutMs;
1923
+ constructor(opts = {}) {
1924
+ this.baseUrl = opts.baseUrl ?? API_BASE;
1925
+ this.loginBaseUrl = (opts.loginBaseUrl ?? LOGIN_BASE).replace(/\/$/, "");
1926
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
1927
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1928
+ }
1929
+ /**
1930
+ * Fetch the authenticated user. Doubles as a "verify token" probe — used
1931
+ * by the Secret Vault Sessions tab to refresh the granted-scopes list.
1932
+ */
1933
+ async getViewer(token, opts = {}) {
1934
+ const { json, response } = await this.call(token, "/user", opts);
1935
+ return {
1936
+ viewer: {
1937
+ login: json.login,
1938
+ id: json.id,
1939
+ name: json.name ?? null,
1940
+ avatarUrl: json.avatar_url ?? null
1941
+ },
1942
+ scopes: parseScopes(response.headers)
1943
+ };
1944
+ }
1945
+ /**
1946
+ * List repositories the authenticated user can access. Used by the repo
1947
+ * picker. Capped at 100 sorted by recent push; users with thousands of
1948
+ * repos can paginate later.
1949
+ */
1950
+ async listAccessibleRepos(token, opts = {}) {
1951
+ const { json } = await this.call(
1952
+ token,
1953
+ "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
1954
+ opts
1955
+ );
1956
+ return json.map(normalizeRepo);
1957
+ }
1958
+ /**
1959
+ * Fetch a specific repo. Validates the user-supplied owner/name pair
1960
+ * exists + is accessible, and exposes the default branch.
1961
+ */
1962
+ async getRepo(token, owner, name, opts = {}) {
1963
+ const { json } = await this.call(
1964
+ token,
1965
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
1966
+ opts
1967
+ );
1968
+ return normalizeRepo(json);
1969
+ }
1970
+ /**
1971
+ * Read the head SHA of a branch. Used to seed a new working branch from
1972
+ * main before any edits land.
1973
+ */
1974
+ async getBranchHead(token, owner, name, branch, opts = {}) {
1975
+ const { json } = await this.call(
1976
+ token,
1977
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches/${encodeURIComponent(branch)}`,
1978
+ opts
1979
+ );
1980
+ return { name: json.name, commitSha: json.commit.sha };
1981
+ }
1982
+ /**
1983
+ * List branches on a repo. Used by the Link Workspace repo-browser to
1984
+ * populate the branch dropdown after the user picks a repo. Capped at
1985
+ * 100 (GitHub's max page size); repos with more branches paginate.
1986
+ */
1987
+ async listBranches(token, owner, name, opts = {}) {
1988
+ const { json } = await this.call(
1989
+ token,
1990
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branches?per_page=100`,
1991
+ opts
1992
+ );
1993
+ return json.map((b) => ({ name: b.name, commitSha: b.commit.sha }));
1994
+ }
1995
+ /**
1996
+ * Create a new branch ref pointing at `sha`. The auto-branch flow calls
1997
+ * this with the head SHA from `getBranchHead(main)`.
1998
+ *
1999
+ * GitHub returns 422 with "Reference already exists" when the branch
2000
+ * already exists; that surfaces as a GitHubError(422) so the UI can
2001
+ * prompt for a different name.
2002
+ */
2003
+ async createBranch(token, owner, name, branchName, sha, opts = {}) {
2004
+ const { json } = await this.call(
2005
+ token,
2006
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
2007
+ {
2008
+ ...opts,
2009
+ method: "POST",
2010
+ body: { ref: `refs/heads/${branchName}`, sha },
2011
+ requiredScopes: ["repo"]
2012
+ }
2013
+ );
2014
+ return { name: branchName, commitSha: json.object.sha };
2015
+ }
2016
+ /**
2017
+ * Read a branch ref's current commit SHA. Used at the start of push-to-
2018
+ * save to find the parent commit before building the new tree.
2019
+ */
2020
+ async getRef(token, owner, name, branch, opts = {}) {
2021
+ const { json } = await this.call(
2022
+ token,
2023
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(branch)}`,
2024
+ opts
2025
+ );
2026
+ return { ref: json.ref, sha: json.object.sha };
2027
+ }
2028
+ /**
2029
+ * Read a commit's tree SHA. Used so the new tree can be built `base_tree`
2030
+ * — every path we don't override is inherited from the parent.
2031
+ */
2032
+ async getCommit(token, owner, name, sha, opts = {}) {
2033
+ const { json } = await this.call(
2034
+ token,
2035
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(sha)}`,
2036
+ opts
2037
+ );
2038
+ return {
2039
+ sha: json.sha,
2040
+ treeSha: json.tree.sha,
2041
+ message: json.message
2042
+ };
2043
+ }
2044
+ /**
2045
+ * Upload a blob to the repo and return its SHA. Used by push-to-save
2046
+ * (P4.3b) for binary attachments — text files go straight into a tree
2047
+ * entry's `content`, but binary bytes have to go through a blob first.
2048
+ *
2049
+ * `content` is base64 when `encoding === 'base64'`. GitHub stores blobs
2050
+ * deduplicated by their git-sha1 (not our sha256), so re-uploading the
2051
+ * same bytes is cheap on their side; we save a roundtrip locally by
2052
+ * tracking lastPushedBlobSha per slot in a future revision.
2053
+ */
2054
+ async createBlob(token, owner, name, args, opts = {}) {
2055
+ const { json } = await this.call(
2056
+ token,
2057
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/blobs`,
2058
+ {
2059
+ ...opts,
2060
+ method: "POST",
2061
+ body: { content: args.content, encoding: args.encoding },
2062
+ requiredScopes: ["repo"]
2063
+ }
2064
+ );
2065
+ return { sha: json.sha, size: json.size ?? 0 };
2066
+ }
2067
+ /**
2068
+ * Build a new tree from `entries`, layered over `baseTreeSha`. Entries
2069
+ * with `content` are inlined (text path); entries with a pre-uploaded
2070
+ * `sha` reference an existing blob (binary path — used by attachments).
2071
+ */
2072
+ async createTree(token, owner, name, args, opts = {}) {
2073
+ const tree = args.entries.map((e) => ({
2074
+ path: e.path,
2075
+ mode: e.mode ?? "100644",
2076
+ type: e.type ?? "blob",
2077
+ ...e.content !== void 0 ? { content: e.content } : {},
2078
+ ...e.sha !== void 0 ? { sha: e.sha } : {}
2079
+ }));
2080
+ const { json } = await this.call(
2081
+ token,
2082
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/trees`,
2083
+ {
2084
+ ...opts,
2085
+ method: "POST",
2086
+ body: { base_tree: args.baseTreeSha, tree },
2087
+ requiredScopes: ["repo"]
2088
+ }
2089
+ );
2090
+ return { sha: json.sha };
2091
+ }
2092
+ /**
2093
+ * Create a new commit object pointing at the given tree, with the given
2094
+ * parents. Returns the new commit's SHA + the tree it points at.
2095
+ */
2096
+ async createCommit(token, owner, name, args, opts = {}) {
2097
+ const { json } = await this.call(
2098
+ token,
2099
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits`,
2100
+ {
2101
+ ...opts,
2102
+ method: "POST",
2103
+ body: {
2104
+ message: args.message,
2105
+ tree: args.treeSha,
2106
+ parents: args.parents
2107
+ },
2108
+ requiredScopes: ["repo"]
2109
+ }
2110
+ );
2111
+ return { sha: json.sha, treeSha: json.tree.sha };
2112
+ }
2113
+ /**
2114
+ * Fast-forward a branch ref to a new commit SHA. Pass `force: true` to
2115
+ * skip the FF check (we don't — push-to-save is always FF over the ref
2116
+ * we just read with getRef()).
2117
+ */
2118
+ async updateRef(token, owner, name, args, opts = {}) {
2119
+ const { json } = await this.call(
2120
+ token,
2121
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/heads/${encodeURIComponent(args.branch)}`,
2122
+ {
2123
+ ...opts,
2124
+ method: "PATCH",
2125
+ body: { sha: args.sha, force: args.force ?? false },
2126
+ requiredScopes: ["repo"]
2127
+ }
2128
+ );
2129
+ return { ref: json.ref, sha: json.object.sha };
2130
+ }
2131
+ /**
2132
+ * Search GitHub for public API Circle workspaces. Appends
2133
+ * `topic:apicircle` to the user-supplied query so only repos carrying
2134
+ * the `apicircle` topic — the topic the Releases & Topics dialog
2135
+ * locks onto every workspace repo — surface in results. GitHub
2136
+ * matches the bare query against repository name, description, and
2137
+ * topics, so category words like `payments` narrow the marketplace by
2138
+ * topic. An empty query lists every public API Circle workspace. Top
2139
+ * 30 results. Token is optional — anonymous browsing is supported
2140
+ * (lower GitHub rate limits apply); pass a PAT when one is available
2141
+ * to lift them. `sort` controls ordering: omit for GitHub's
2142
+ * best-match relevance, or pass `'stars'` / `'updated'`.
2143
+ */
2144
+ async searchMarketplaceRepos(token, query, opts = {}) {
2145
+ const { sort, ...callOpts } = opts;
2146
+ const fullQuery = `${query.trim()} topic:apicircle`.trim();
2147
+ const sortParam = sort ? `&sort=${sort}&order=desc` : "";
2148
+ const path8 = `/search/repositories?q=${encodeURIComponent(fullQuery)}&per_page=30${sortParam}`;
2149
+ const { json } = await this.call(token, path8, callOpts);
2150
+ const items = json.items ?? [];
2151
+ return items.map(normalizeMarketplaceRepo);
2152
+ }
2153
+ /**
2154
+ * Start GitHub's OAuth Device Flow. Returns a user-facing code the
2155
+ * user types into github.com/login/device + a device_code the app
2156
+ * polls with. Pure browser-safe: no client_secret involved (device
2157
+ * flow is the only OAuth path GitHub supports for public clients).
2158
+ *
2159
+ * Requires the OAuth App to have "Enable Device Flow" turned on in
2160
+ * its GitHub settings — surface 400 with `not_supported` to the user
2161
+ * if the App owner hasn't done that yet.
2162
+ */
2163
+ async startDeviceFlow(clientId, scope, opts = {}) {
2164
+ const url = `${this.loginBaseUrl}/login/device/code`;
2165
+ const response = await this.fetchImpl(url, {
2166
+ method: "POST",
2167
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
2168
+ body: JSON.stringify({ client_id: clientId, scope }),
2169
+ signal: opts.signal
2170
+ });
2171
+ if (!response.ok) {
2172
+ throw new GitHubError(
2173
+ `Device-flow start failed: HTTP ${response.status}`,
2174
+ response.status,
2175
+ {}
2176
+ );
2177
+ }
2178
+ const json = await response.json();
2179
+ if (json.error) {
2180
+ throw new GitHubError(json.error_description ?? json.error, 400, json);
2181
+ }
2182
+ return {
2183
+ deviceCode: json.device_code,
2184
+ userCode: json.user_code,
2185
+ verificationUri: json.verification_uri,
2186
+ expiresIn: json.expires_in,
2187
+ interval: json.interval
2188
+ };
2189
+ }
2190
+ /**
2191
+ * Poll for the access token after the user has authorized the device
2192
+ * code. GitHub returns `authorization_pending` until the user
2193
+ * completes the flow, `slow_down` if we polled too fast, then a real
2194
+ * token. Caller wraps this in a polling loop bounded by `expiresIn`.
2195
+ */
2196
+ async pollDeviceToken(clientId, deviceCode, opts = {}) {
2197
+ const url = `${this.loginBaseUrl}/login/oauth/access_token`;
2198
+ const response = await this.fetchImpl(url, {
2199
+ method: "POST",
2200
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
2201
+ body: JSON.stringify({
2202
+ client_id: clientId,
2203
+ device_code: deviceCode,
2204
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
2205
+ }),
2206
+ signal: opts.signal
2207
+ });
2208
+ const json = await response.json();
2209
+ if (json.access_token) {
2210
+ return {
2211
+ kind: "granted",
2212
+ accessToken: json.access_token,
2213
+ tokenType: json.token_type ?? "bearer",
2214
+ scope: json.scope ?? ""
2215
+ };
2216
+ }
2217
+ if (json.error === "authorization_pending") return { kind: "pending", slowDown: false };
2218
+ if (json.error === "slow_down") return { kind: "pending", slowDown: true };
2219
+ if (json.error === "expired_token") return { kind: "expired" };
2220
+ if (json.error === "access_denied")
2221
+ return { kind: "denied", reason: json.error_description ?? "User denied authorization" };
2222
+ throw new GitHubError(
2223
+ json.error_description ?? json.error ?? "Device-token poll failed",
2224
+ response.status,
2225
+ json
2226
+ );
2227
+ }
2228
+ /**
2229
+ * Create a lightweight Git tag (a ref under `refs/tags/<name>`) on the
2230
+ * given commit SHA. Used by the publish-release flow when the user
2231
+ * opts in to "Create Git tag v<x.y.z>". Returns the resolved ref.
2232
+ *
2233
+ * GitHub returns 422 with "Reference already exists" when the tag is
2234
+ * a duplicate; that surfaces as a GitHubError(422) so the UI can warn
2235
+ * the user without ever overwriting an existing tag.
2236
+ */
2237
+ async createTag(token, owner, name, args, opts = {}) {
2238
+ const { json } = await this.call(
2239
+ token,
2240
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs`,
2241
+ {
2242
+ ...opts,
2243
+ method: "POST",
2244
+ body: { ref: `refs/tags/${args.tagName}`, sha: args.sha },
2245
+ requiredScopes: ["repo"]
2246
+ }
2247
+ );
2248
+ return { ref: json.ref, sha: json.object.sha };
2249
+ }
2250
+ /**
2251
+ * Compare two commits. Returns the relationship classification GitHub
2252
+ * gives us: `ahead` (head is descendant of base), `behind` (base is
2253
+ * descendant of head), `identical`, or `diverged` (the two histories
2254
+ * share a base but neither contains the other — typical of a force-push
2255
+ * that rewrote history under us).
2256
+ *
2257
+ * Used by the refresh path so we never silently 3-way-merge across a
2258
+ * history rewrite — divergence steers the user through an explicit
2259
+ * "history rewritten" modal instead of corrupting local state.
2260
+ */
2261
+ async compareCommits(token, owner, name, base, head, opts = {}) {
2262
+ const { json } = await this.call(
2263
+ token,
2264
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/compare/${encodeURIComponent(
2265
+ base
2266
+ )}...${encodeURIComponent(head)}`,
2267
+ { ...opts, requiredScopes: ["repo"] }
2268
+ );
2269
+ return {
2270
+ status: json.status,
2271
+ aheadBy: json.ahead_by,
2272
+ behindBy: json.behind_by
2273
+ };
2274
+ }
2275
+ /**
2276
+ * Is `ancestor` reachable from `descendant`? Thin wrapper around
2277
+ * `compareCommits` — "ahead" or "identical" means yes; "behind" or
2278
+ * "diverged" means the histories don't fit, so the answer is no.
2279
+ */
2280
+ async isAncestor(token, owner, name, ancestor, descendant, opts = {}) {
2281
+ if (ancestor === descendant) return true;
2282
+ const cmp = await this.compareCommits(token, owner, name, ancestor, descendant, opts);
2283
+ return cmp.status === "ahead" || cmp.status === "identical";
2284
+ }
2285
+ /**
2286
+ * Create a GitHub Release pointing at an existing tag. Used by the
2287
+ * publish-release flow when the user opts in to "Create GitHub
2288
+ * Release". Returns the release's HTML URL so the UI can show a
2289
+ * "Released — view on GitHub" link.
2290
+ *
2291
+ * Pass `prerelease: true` for semver pre-release identifiers (e.g.
2292
+ * `1.0.0-rc.1`); GitHub's Releases UI flags those distinctly.
2293
+ */
2294
+ async createRelease(token, owner, name, args, opts = {}) {
2295
+ const { json } = await this.call(token, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`, {
2296
+ ...opts,
2297
+ method: "POST",
2298
+ body: {
2299
+ tag_name: args.tagName,
2300
+ name: args.releaseName ?? args.tagName,
2301
+ body: args.body ?? "",
2302
+ draft: args.draft ?? false,
2303
+ prerelease: args.prerelease ?? false
2304
+ },
2305
+ requiredScopes: ["repo"]
2306
+ });
2307
+ return { id: json.id, htmlUrl: json.html_url, tagName: json.tag_name };
2308
+ }
2309
+ /**
2310
+ * Read a tag ref's current commit SHA. Used by the Release & topics
2311
+ * modal to detect whether a tag with the chosen name already exists
2312
+ * (so the UI can surface an "Override existing tag" toggle instead of
2313
+ * silently 422'ing through createTag).
2314
+ *
2315
+ * Returns `null` when the tag doesn't exist (404). Other failures
2316
+ * surface as typed errors.
2317
+ */
2318
+ async getTagSha(token, owner, name, tagName, opts = {}) {
2319
+ try {
2320
+ const { json } = await this.call(
2321
+ token,
2322
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/tags/${encodeURIComponent(tagName)}`,
2323
+ opts
2324
+ );
2325
+ return json.object.sha;
2326
+ } catch (err) {
2327
+ if (err instanceof GitHubError && err.status === 404) return null;
2328
+ throw err;
2329
+ }
2330
+ }
2331
+ /**
2332
+ * Delete a ref. Used to support the "Override existing tag" path on
2333
+ * the Release & topics modal — we delete the existing tag ref, then
2334
+ * createTag against the new SHA. (GitHub doesn't have a single
2335
+ * "force-update tag" endpoint via the simple refs API.)
2336
+ *
2337
+ * `ref` is the bare suffix, e.g. `tags/v1.0.0` or `heads/feature-x`.
2338
+ */
2339
+ async deleteRef(token, owner, name, ref, opts = {}) {
2340
+ await this.call(
2341
+ token,
2342
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/refs/${ref.split("/").map(encodeURIComponent).join("/")}`,
2343
+ {
2344
+ ...opts,
2345
+ method: "DELETE",
2346
+ requiredScopes: ["repo"]
2347
+ }
2348
+ );
2349
+ }
2350
+ /**
2351
+ * Read the repo's current topic list. Topics drive marketplace
2352
+ * discoverability — public API Circle workspaces include `apicircle`
2353
+ * plus user-chosen category topics.
2354
+ *
2355
+ * Note: GitHub's topics API uses a custom Accept header, but we treat
2356
+ * that as transport detail; the `application/vnd.github.mercy-preview+json`
2357
+ * preview is now stable so the default Accept works.
2358
+ */
2359
+ async listRepoTopics(token, owner, name, opts = {}) {
2360
+ const { json } = await this.call(
2361
+ token,
2362
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
2363
+ opts
2364
+ );
2365
+ return Array.isArray(json.names) ? json.names : [];
2366
+ }
2367
+ /**
2368
+ * Replace the repo's full topic list. GitHub's `PUT /topics` endpoint
2369
+ * is a full replace (not a merge), so the caller must pass the
2370
+ * complete desired list. Caps at 20 topics; each must match
2371
+ * `^[a-z0-9][a-z0-9-]*$` and be ≤ 50 chars (GitHub enforces this with
2372
+ * a 422). Returns the persisted list.
2373
+ */
2374
+ async setRepoTopics(token, owner, name, topics, opts = {}) {
2375
+ const { json } = await this.call(
2376
+ token,
2377
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/topics`,
2378
+ {
2379
+ ...opts,
2380
+ method: "PUT",
2381
+ body: { names: topics },
2382
+ requiredScopes: ["repo"]
2383
+ }
2384
+ );
2385
+ return Array.isArray(json.names) ? json.names : [];
2386
+ }
2387
+ /**
2388
+ * Fetch a single file's contents from a branch / commit. Returns
2389
+ * `null` when GitHub answers 404 (file simply doesn't exist on that
2390
+ * ref — the common case for the very first pull). Other failures
2391
+ * surface as the usual typed errors.
2392
+ *
2393
+ * Used by the refresh flow to read remote `workspace.json` so the
2394
+ * 3-way diff can compare it against the local doc.
2395
+ */
2396
+ async getContents(token, owner, name, path8, ref, opts = {}) {
2397
+ const query = `?ref=${encodeURIComponent(ref)}`;
2398
+ try {
2399
+ const { json } = await this.call(
2400
+ token,
2401
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}${query}`,
2402
+ opts
2403
+ );
2404
+ if (Array.isArray(json) || json.type !== "file") {
2405
+ throw new GitHubError(`Path ${path8} is not a file`, 422, json);
2406
+ }
2407
+ const cleaned = json.content.replace(/\n/g, "");
2408
+ const decoded = decodeBase64Utf8(cleaned);
2409
+ return { content: decoded, sha: json.sha, path: json.path, size: json.size };
2410
+ } catch (err) {
2411
+ if (err instanceof GitHubError && err.status === 404) return null;
2412
+ throw err;
2413
+ }
2414
+ }
2415
+ /**
2416
+ * Create or update a file via the Contents API. The killer feature here
2417
+ * vs. the git-data flow (createBlob → createTree → createCommit →
2418
+ * updateRef) is that this works on **truly empty repos**: GitHub's git
2419
+ * database isn't initialized until the first commit lands, so all the
2420
+ * `/git/*` endpoints reject with 409 "Git Repository is empty" — but
2421
+ * `PUT /contents/{path}` atomically initializes the database with a
2422
+ * single-file commit on the supplied branch (defaulting to the repo's
2423
+ * default branch).
2424
+ *
2425
+ * Used by the seed-initial-commit flow to bootstrap a freshly-created
2426
+ * empty repo with a scaffold `workspace.json`.
2427
+ *
2428
+ * `contentBase64` must already be base64-encoded — caller chooses the
2429
+ * encoder (TextEncoder for UTF-8 strings, raw bytes for binaries).
2430
+ */
2431
+ async putContents(token, owner, name, path8, args, opts = {}) {
2432
+ const body = {
2433
+ message: args.message,
2434
+ content: args.contentBase64
2435
+ };
2436
+ if (args.branch) body.branch = args.branch;
2437
+ if (args.sha) body.sha = args.sha;
2438
+ const { json } = await this.call(
2439
+ token,
2440
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}`,
2441
+ {
2442
+ ...opts,
2443
+ method: "PUT",
2444
+ body,
2445
+ requiredScopes: ["repo"]
2446
+ }
2447
+ );
2448
+ return { commitSha: json.commit.sha, contentSha: json.content.sha };
2449
+ }
2450
+ /**
2451
+ * Same as `getContents` but returns the raw bytes instead of UTF-8
2452
+ * decoding the file. Used by the refresh flow to pull
2453
+ * `.apicircle/workspace-<id>/attachments/<slotId>` blobs into local IDB without
2454
+ * mangling binary data through TextDecoder.
2455
+ */
2456
+ async getBinaryContents(token, owner, name, path8, ref, opts = {}) {
2457
+ const query = `?ref=${encodeURIComponent(ref)}`;
2458
+ try {
2459
+ const { json } = await this.call(
2460
+ token,
2461
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/contents/${path8.split("/").map(encodeURIComponent).join("/")}${query}`,
2462
+ opts
2463
+ );
2464
+ if (Array.isArray(json) || json.type !== "file") {
2465
+ throw new GitHubError(`Path ${path8} is not a file`, 422, json);
2466
+ }
2467
+ const cleaned = json.content.replace(/\n/g, "");
2468
+ const bytes = decodeBase64Bytes(cleaned);
2469
+ return { bytes, sha: json.sha, path: json.path, size: json.size };
2470
+ } catch (err) {
2471
+ if (err instanceof GitHubError && err.status === 404) return null;
2472
+ throw err;
2473
+ }
2474
+ }
2475
+ /**
2476
+ * Open a pull request from `head` (the working branch) into `base` (the
2477
+ * repo's default branch). PR creation needs the `pull_request` scope on
2478
+ * top of `repo`; missing-scope errors flow through MissingScopeError so
2479
+ * the UI can prompt the user to update the token without losing branch
2480
+ * state (Plan §3.7).
2481
+ *
2482
+ * GitHub returns 422 when:
2483
+ * - head/base are equal (nothing to merge)
2484
+ * - a PR already exists between this head and base
2485
+ * - the head branch doesn't exist
2486
+ * All three surface as a plain GitHubError(422); the UI message is
2487
+ * picked up from response.body.message.
2488
+ */
2489
+ /**
2490
+ * Fetch a single pull request by number. Used by the refresh flow to
2491
+ * detect whether a previously-opened PR has been merged on GitHub —
2492
+ * `merged: true` is what triggers the working-branch retirement path.
2493
+ *
2494
+ * Returns `null` on 404 (PR was deleted or never existed at this number);
2495
+ * other failures surface as the usual typed errors.
2496
+ */
2497
+ async getPullRequest(token, owner, name, number, opts = {}) {
2498
+ try {
2499
+ const { json } = await this.call(
2500
+ token,
2501
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}`,
2502
+ opts
2503
+ );
2504
+ return {
2505
+ number: json.number,
2506
+ htmlUrl: json.html_url,
2507
+ state: json.state,
2508
+ merged: json.merged === true
2509
+ };
2510
+ } catch (err) {
2511
+ if (err instanceof GitHubError && err.status === 404) return null;
2512
+ throw err;
2513
+ }
2514
+ }
2515
+ /**
2516
+ * List pull requests on a repo. The capability-probe path uses this with
2517
+ * `perPage: 1` to determine whether the token can read PRs (and, by
2518
+ * extension on classic PATs, whether it can also create them).
2519
+ *
2520
+ * Caller declares `requiredScopes` to surface a `MissingScopeError` on
2521
+ * 403, so the capability probe can recognise the missing-scope case
2522
+ * cleanly vs. transient 5xx/network failures.
2523
+ */
2524
+ async listPullRequests(token, owner, name, args = {}, opts = {}) {
2525
+ const params = new URLSearchParams();
2526
+ params.set("per_page", String(args.perPage ?? 30));
2527
+ if (args.state) params.set("state", args.state);
2528
+ const { json } = await this.call(
2529
+ token,
2530
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?${params.toString()}`,
2531
+ {
2532
+ ...opts,
2533
+ requiredScopes: ["repo", "pull_request"]
2534
+ }
2535
+ );
2536
+ return json.map((pr) => ({
2537
+ number: pr.number,
2538
+ htmlUrl: pr.html_url,
2539
+ state: pr.state,
2540
+ title: pr.title
2541
+ }));
2542
+ }
2543
+ async createPullRequest(token, owner, name, args, opts = {}) {
2544
+ const { json } = await this.call(
2545
+ token,
2546
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`,
2547
+ {
2548
+ ...opts,
2549
+ method: "POST",
2550
+ body: {
2551
+ title: args.title,
2552
+ body: args.body,
2553
+ head: args.head,
2554
+ base: args.base,
2555
+ draft: args.draft ?? false
2556
+ },
2557
+ requiredScopes: ["repo", "pull_request"]
2558
+ }
2559
+ );
2560
+ return {
2561
+ number: json.number,
2562
+ htmlUrl: json.html_url,
2563
+ state: json.state,
2564
+ title: json.title
2565
+ };
2566
+ }
2567
+ // --- low-level call ----------------------------------------------------
2568
+ async call(token, path8, opts = {}) {
2569
+ const url = path8.startsWith("http") ? path8 : `${this.baseUrl}${path8}`;
2570
+ const controller = new AbortController();
2571
+ const onExternalAbort = () => controller.abort(opts.signal.reason);
2572
+ if (opts.signal) {
2573
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
2574
+ else opts.signal.addEventListener("abort", onExternalAbort, { once: true });
2575
+ }
2576
+ const timeoutHandle = setTimeout(
2577
+ () => controller.abort(new Error(`GitHub request timed out after ${this.timeoutMs}ms`)),
2578
+ this.timeoutMs
2579
+ );
2580
+ let response;
2581
+ let timedOut = false;
2582
+ try {
2583
+ response = await this.fetchImpl(url, {
2584
+ method: opts.method ?? "GET",
2585
+ headers: {
2586
+ Accept: "application/vnd.github+json",
2587
+ "X-GitHub-Api-Version": "2022-11-28",
2588
+ ...token ? { Authorization: `Bearer ${token}` } : {},
2589
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {}
2590
+ },
2591
+ cache: "no-store",
2592
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
2593
+ signal: controller.signal
2594
+ });
2595
+ } catch (err) {
2596
+ const isAbort = err instanceof DOMException && err.name === "AbortError";
2597
+ const callerAborted = opts.signal?.aborted ?? false;
2598
+ if (isAbort && !callerAborted) {
2599
+ timedOut = true;
2600
+ throw new TimeoutError(
2601
+ `GitHub request timed out after ${this.timeoutMs}ms. The write may have partially landed \u2014 refresh before retrying.`,
2602
+ this.timeoutMs
2603
+ );
2604
+ }
2605
+ throw err;
2606
+ } finally {
2607
+ clearTimeout(timeoutHandle);
2608
+ if (opts.signal) opts.signal.removeEventListener("abort", onExternalAbort);
2609
+ void timedOut;
2610
+ }
2611
+ if (response.ok) {
2612
+ if (response.status === 204 || response.status === 205) {
2613
+ return { json: {}, response };
2614
+ }
2615
+ const json = await response.json();
2616
+ return { json, response };
2617
+ }
2618
+ const errBody = await safeReadJson(response);
2619
+ throw classifyError(response, errBody, opts.requiredScopes ?? []);
2620
+ }
2621
+ };
2622
+ }
2623
+ });
2624
+
2625
+ // ../git/src/index.ts
2626
+ var init_src = __esm({
2627
+ "../git/src/index.ts"() {
2628
+ "use strict";
2629
+ init_api();
2630
+ init_errors();
2631
+ }
2632
+ });
2633
+
2634
+ // src/commands/linked.ts
2635
+ function resolveToken(opts) {
2636
+ return (opts.token ?? process.env.GITHUB_TOKEN ?? "").trim();
2637
+ }
2638
+ async function resolveDir2(opts) {
2639
+ try {
2640
+ const resolved = await resolveWorkspace({
2641
+ name: opts.workspaceName,
2642
+ path: opts.workspacePath,
2643
+ expectExists: false
2644
+ });
2645
+ if (resolved.fromRegistry) {
2646
+ process.stderr.write(
2647
+ `${import_kleur8.default.dim("workspace")}: ${import_kleur8.default.cyan(resolved.name ?? resolved.id ?? "")} ${import_kleur8.default.dim(`(${resolved.dir})`)}
2648
+ `
2649
+ );
2650
+ }
2651
+ return resolved.dir;
2652
+ } catch (err) {
2653
+ if (err instanceof WorkspaceResolutionError) {
2654
+ process.stderr.write(`${import_kleur8.default.red("error")}: ${err.message}
2655
+ `);
2656
+ process.exit(2);
2657
+ }
2658
+ throw err;
2659
+ }
2660
+ }
2661
+ function registerLinkedCommand(program) {
2662
+ const linked = program.command("linked").description("Manage linked workspaces (the workspaces this one consumes).");
2663
+ 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) => {
2664
+ const dir = await resolveDir2(opts);
2665
+ const state = await ensureWorkspace(dir);
2666
+ const links = Object.values(state.synced.linkedWorkspaces);
2667
+ if (links.length === 0) {
2668
+ process.stdout.write(`${import_kleur8.default.dim("No linked workspaces.")}
2669
+ `);
2670
+ return;
2671
+ }
2672
+ for (const l of links) {
2673
+ const pin = l.pinnedVersion ? `v${l.pinnedVersion}` : "unpinned";
2674
+ const ledger = state.synced.releases.perLink[l.id];
2675
+ const cur = ledger?.currentVersion ? ` \xB7 cached current v${ledger.currentVersion}` : "";
2676
+ process.stdout.write(
2677
+ `${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}`)}
2678
+ `
2679
+ );
2680
+ }
2681
+ });
2682
+ 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(
2683
+ async (repo, opts) => {
2684
+ if (!repo.includes("/")) {
2685
+ process.stderr.write(`${import_kleur8.default.red("error")}: repo must be owner/name
2686
+ `);
2687
+ process.exit(2);
2688
+ }
2689
+ if (opts.kind !== "public" && opts.kind !== "private") {
2690
+ process.stderr.write(`${import_kleur8.default.red("error")}: --kind must be private or public
2691
+ `);
2692
+ process.exit(2);
2693
+ }
2694
+ const token = resolveToken(opts);
2695
+ if (opts.kind === "private" && !token) {
2696
+ process.stderr.write(
2697
+ `${import_kleur8.default.red("error")}: a token is required for private repos (--token or GITHUB_TOKEN)
2698
+ `
2699
+ );
2700
+ process.exit(2);
2701
+ }
2702
+ const dir = await resolveDir2(opts);
2703
+ const state = await ensureWorkspace(dir);
2704
+ const dup = Object.values(state.synced.linkedWorkspaces).find(
2705
+ (l) => l.source.repoFullName === repo && l.source.branch === opts.branch
2706
+ );
2707
+ if (dup) {
2708
+ process.stderr.write(
2709
+ `${import_kleur8.default.red("error")}: already linked to ${repo}@${opts.branch} (${dup.id})
2710
+ `
2711
+ );
2712
+ process.exit(2);
2713
+ }
2714
+ const [owner, name] = repo.split("/", 2);
2715
+ const client = new GitHubClient();
2716
+ const result = await (0, import_core5.fetchRemoteWorkspaceJson)(async (p) => {
2717
+ const f = await client.getContents(token, owner, name, p, opts.branch);
2718
+ return f?.content ?? null;
2719
+ });
2720
+ if ("error" in result) {
2721
+ process.stderr.write(`${import_kleur8.default.red("error")}: ${repo}@${opts.branch}: ${result.error}
2722
+ `);
2723
+ process.exit(2);
2724
+ }
2725
+ const probe = (0, import_core5.parseLinkedWorkspaceJson)(result.content);
2726
+ const ledger = (0, import_core5.ledgerFromProbe)(probe);
2727
+ const link = {
2728
+ id: (0, import_shared5.generateId)(),
2729
+ kind: opts.kind,
2730
+ name: repo,
2731
+ sourceWorkspaceId: result.workspaceId,
2732
+ source: {
2733
+ provider: "github",
2734
+ repoFullName: repo,
2735
+ branch: opts.branch,
2736
+ sessionMode: "workspace"
2737
+ },
2738
+ scope: ["collections", "environments"],
2739
+ pinnedVersion: opts.pinnedVersion ?? ledger.currentVersion,
2740
+ updatePolicy: "manual",
2741
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2742
+ requiredSecretKeyIds: probe.secretKeys ? Object.keys(probe.secretKeys) : []
2743
+ };
2744
+ const snapshot = (0, import_core5.buildLinkedSnapshot)(probe, link) ?? void 0;
2745
+ const out = (0, import_core5.applyMutation)(state, {
2746
+ kind: "linkedWorkspace.upsert",
2747
+ link,
2748
+ ledger,
2749
+ ...snapshot ? { snapshot } : {}
2750
+ });
2751
+ await (0, import_file_backed6.saveToFile)(dir, out.next);
2752
+ process.stdout.write(
2753
+ `${import_kleur8.default.green("linked")} ${import_kleur8.default.bold(repo)} ${import_kleur8.default.dim(`(id ${link.id}, ${link.pinnedVersion ? `v${link.pinnedVersion}` : "unpinned"})`)}
2754
+ `
2755
+ );
2756
+ }
2757
+ );
2758
+ 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) => {
2759
+ const dir = await resolveDir2(opts);
2760
+ const state = await ensureWorkspace(dir);
2761
+ const link = state.synced.linkedWorkspaces[id];
2762
+ if (!link) {
2763
+ process.stderr.write(`${import_kleur8.default.red("error")}: linked workspace ${id} not found
2764
+ `);
2765
+ process.exit(2);
2766
+ }
2767
+ const token = resolveToken(opts);
2768
+ if (link.kind === "private" && !token) {
2769
+ process.stderr.write(
2770
+ `${import_kleur8.default.red("error")}: a token is required for private links (--token or GITHUB_TOKEN)
2771
+ `
2772
+ );
2773
+ process.exit(2);
2774
+ }
2775
+ const [owner, name] = link.source.repoFullName.split("/", 2);
2776
+ const client = new GitHubClient();
2777
+ const result = await (0, import_core5.fetchRemoteWorkspaceJson)(async (p) => {
2778
+ const f = await client.getContents(token, owner, name, p, link.source.branch);
2779
+ return f?.content ?? null;
2780
+ });
2781
+ if ("error" in result) {
2782
+ process.stderr.write(
2783
+ `${import_kleur8.default.red("error")}: ${link.source.repoFullName}@${link.source.branch}: ${result.error}
2784
+ `
2785
+ );
2786
+ process.exit(2);
2787
+ }
2788
+ const probe = (0, import_core5.parseLinkedWorkspaceJson)(result.content);
2789
+ const ledger = (0, import_core5.ledgerFromProbe)(probe);
2790
+ const needsSnapshot = !state.local.linkedCollections[id];
2791
+ const snapshot = needsSnapshot ? (0, import_core5.buildLinkedSnapshot)(probe, link) ?? void 0 : void 0;
2792
+ const out = (0, import_core5.applyMutation)(state, {
2793
+ kind: "linkedWorkspace.upsert",
2794
+ link,
2795
+ ledger,
2796
+ ...snapshot ? { snapshot } : {}
2797
+ });
2798
+ await (0, import_file_backed6.saveToFile)(dir, out.next);
2799
+ process.stdout.write(
2800
+ `${import_kleur8.default.green("refreshed")} ${import_kleur8.default.bold(link.name)} ${import_kleur8.default.dim(`(${ledger.versions.length} version(s), current ${ledger.currentVersion ?? "none"})`)}
2801
+ `
2802
+ );
2803
+ });
2804
+ 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) => {
2805
+ const dir = await resolveDir2(opts);
2806
+ const state = await ensureWorkspace(dir);
2807
+ if (!state.synced.linkedWorkspaces[id]) {
2808
+ process.stderr.write(`${import_kleur8.default.red("error")}: linked workspace ${id} not found
2809
+ `);
2810
+ process.exit(2);
2811
+ }
2812
+ const out = (0, import_core5.applyMutation)(state, { kind: "linkedWorkspace.remove", id });
2813
+ await (0, import_file_backed6.saveToFile)(dir, out.next);
2814
+ process.stdout.write(`${import_kleur8.default.green("unlinked")} ${import_kleur8.default.dim(id)}
2815
+ `);
2816
+ });
2817
+ }
2818
+ var import_kleur8, import_core5, import_file_backed6, import_shared5;
2819
+ var init_linked = __esm({
2820
+ "src/commands/linked.ts"() {
2821
+ "use strict";
2822
+ import_kleur8 = __toESM(require("kleur"), 1);
2823
+ import_core5 = require("@apicircle/core");
2824
+ import_file_backed6 = require("@apicircle/core/workspace/file-backed");
2825
+ import_shared5 = require("@apicircle/shared");
2826
+ init_src();
2827
+ init_loadWorkspace();
2828
+ init_resolveWorkspace();
2829
+ }
2830
+ });
2831
+
2832
+ // src/commands/release.ts
2833
+ function resolveToken2(opts) {
2834
+ return (opts.token ?? process.env.GITHUB_TOKEN ?? "").trim();
2835
+ }
2836
+ function parseRepo(repo) {
2837
+ if (!repo.includes("/")) return null;
2838
+ const [owner, name] = repo.split("/", 2);
2839
+ return { owner, name };
2840
+ }
2841
+ function registerReleaseCommand(program) {
2842
+ const release = program.command("release").description("Tag releases and edit topics on the workspace's GitHub repo.");
2843
+ 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) => {
2844
+ const parsed = parseRepo(repo);
2845
+ if (!parsed) {
2846
+ process.stderr.write(`${import_kleur9.default.red("error")}: repo must be owner/name
2847
+ `);
2848
+ process.exit(2);
2849
+ }
2850
+ const token = resolveToken2(opts);
2851
+ if (!token) {
2852
+ process.stderr.write(
2853
+ `${import_kleur9.default.red("error")}: a token is required (--token or GITHUB_TOKEN)
2854
+ `
2855
+ );
2856
+ process.exit(2);
2857
+ }
2858
+ const tagName = `v${version.replace(/^v/, "")}`;
2859
+ const client = new GitHubClient();
2860
+ try {
2861
+ const meta = await client.getRepo(token, parsed.owner, parsed.name);
2862
+ const ref = await client.getRef(token, parsed.owner, parsed.name, meta.defaultBranch);
2863
+ const existing = await client.getTagSha(token, parsed.owner, parsed.name, tagName);
2864
+ if (existing !== null) {
2865
+ if (!opts.override) {
2866
+ process.stderr.write(
2867
+ `${import_kleur9.default.red("error")}: tag ${tagName} already exists at ${existing.slice(0, 7)} \u2014 pass --override to replace
2868
+ `
2869
+ );
2870
+ process.exit(2);
2871
+ }
2872
+ await client.deleteRef(token, parsed.owner, parsed.name, `tags/${tagName}`);
2873
+ }
2874
+ await client.createTag(token, parsed.owner, parsed.name, { tagName, sha: ref.sha });
2875
+ process.stdout.write(
2876
+ `${import_kleur9.default.green("tagged")} ${import_kleur9.default.bold(tagName)} ${import_kleur9.default.dim(`on ${meta.defaultBranch} (${ref.sha.slice(0, 7)})`)}
2877
+ `
2878
+ );
2879
+ if (opts.release) {
2880
+ const r = await client.createRelease(token, parsed.owner, parsed.name, {
2881
+ tagName,
2882
+ releaseName: tagName,
2883
+ body: opts.notes ?? ""
2884
+ });
2885
+ process.stdout.write(`${import_kleur9.default.green("release")} ${import_kleur9.default.dim(r.htmlUrl)}
2886
+ `);
2887
+ }
2888
+ } catch (err) {
2889
+ process.stderr.write(
2890
+ `${import_kleur9.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
2891
+ `
2892
+ );
2893
+ process.exit(2);
2894
+ }
2895
+ });
2896
+ 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) => {
2897
+ const parsed = parseRepo(repo);
2898
+ if (!parsed) {
2899
+ process.stderr.write(`${import_kleur9.default.red("error")}: repo must be owner/name
2900
+ `);
2901
+ process.exit(2);
2902
+ }
2903
+ const token = resolveToken2(opts);
2904
+ if (!token) {
2905
+ process.stderr.write(
2906
+ `${import_kleur9.default.red("error")}: a token is required (--token or GITHUB_TOKEN)
2907
+ `
2908
+ );
2909
+ process.exit(2);
2910
+ }
2911
+ const client = new GitHubClient();
2912
+ try {
2913
+ if (opts.set === void 0) {
2914
+ const list = await client.listRepoTopics(token, parsed.owner, parsed.name);
2915
+ if (list.length === 0) {
2916
+ process.stdout.write(`${import_kleur9.default.dim("(no topics)")}
2917
+ `);
2918
+ } else {
2919
+ for (const t of list) process.stdout.write(`${t}
2920
+ `);
2921
+ }
2922
+ return;
2923
+ }
2924
+ const normalized = Array.from(
2925
+ /* @__PURE__ */ new Set([
2926
+ "apicircle",
2927
+ ...opts.set.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean)
2928
+ ])
2929
+ );
2930
+ for (const t of normalized) {
2931
+ if (!TOPIC_RE.test(t)) {
2932
+ process.stderr.write(`${import_kleur9.default.red("error")}: invalid topic "${t}"
2933
+ `);
2934
+ process.exit(2);
2935
+ }
2936
+ if (t.length > 50) {
2937
+ process.stderr.write(`${import_kleur9.default.red("error")}: topic "${t}" exceeds 50 characters
2938
+ `);
2939
+ process.exit(2);
2940
+ }
2941
+ }
2942
+ if (normalized.length > 20) {
2943
+ process.stderr.write(`${import_kleur9.default.red("error")}: GitHub allows at most 20 topics
2944
+ `);
2945
+ process.exit(2);
2946
+ }
2947
+ const saved = await client.setRepoTopics(token, parsed.owner, parsed.name, normalized);
2948
+ process.stdout.write(`${import_kleur9.default.green("topics set")} ${import_kleur9.default.dim(`(${saved.length})`)}
2949
+ `);
2950
+ for (const t of saved) process.stdout.write(` ${t}
2951
+ `);
2952
+ } catch (err) {
2953
+ process.stderr.write(
2954
+ `${import_kleur9.default.red("error")}: ${err instanceof Error ? err.message : String(err)}
2955
+ `
2956
+ );
2957
+ process.exit(2);
2958
+ }
2959
+ });
2960
+ }
2961
+ var import_kleur9, TOPIC_RE;
2962
+ var init_release = __esm({
2963
+ "src/commands/release.ts"() {
2964
+ "use strict";
2965
+ import_kleur9 = __toESM(require("kleur"), 1);
2966
+ init_src();
2967
+ TOPIC_RE = /^[a-z0-9][a-z0-9-]*$/;
2968
+ }
2969
+ });
2970
+
2971
+ // src/commands/folder.ts
2972
+ async function openWorkspace(opts) {
2973
+ let dir;
2974
+ try {
2975
+ const resolved = await resolveWorkspace({
2976
+ name: opts.workspaceName,
2977
+ path: opts.workspacePath,
2978
+ expectExists: true
2979
+ });
2980
+ dir = resolved.dir;
2981
+ } catch (err) {
2982
+ if (err instanceof WorkspaceResolutionError) {
2983
+ process.stderr.write(`${import_kleur10.default.red("error")}: ${err.message}
2984
+ `);
2985
+ process.exit(2);
2986
+ }
2987
+ throw err;
2988
+ }
2989
+ await ensureWorkspace(dir);
2990
+ return { provider: new import_mcp_server2.FileBackedWorkspaceProvider(dir), dir };
2991
+ }
2992
+ function registerFolderCommand(program) {
2993
+ const folder = program.command("folder").description("List, create, rename, move, set auth, or delete folders.");
2994
+ COMMON_OPTS(
2995
+ folder.command("list").description("Print the folder tree (with auth markers).").option("--json", "Emit JSON instead of a formatted tree")
2996
+ ).action(async (opts) => {
2997
+ const { provider } = await openWorkspace(opts);
2998
+ const state = await provider.read();
2999
+ const folders = state.synced.collections.folders;
3000
+ if (opts.json) {
3001
+ process.stdout.write(JSON.stringify(Object.values(folders), null, 2) + "\n");
3002
+ return;
3003
+ }
3004
+ if (Object.keys(folders).length === 0) {
3005
+ process.stdout.write(`${import_kleur10.default.dim("No folders in this workspace.")}
3006
+ `);
3007
+ return;
3008
+ }
3009
+ const roots = Object.values(folders).filter((f) => f.parentId === null);
3010
+ roots.sort((a, b) => a.name.localeCompare(b.name));
3011
+ for (const root of roots) printTree(root, folders, 0);
3012
+ });
3013
+ COMMON_OPTS(
3014
+ folder.command("create").description(
3015
+ "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."
3016
+ ).requiredOption("--name <name>", "Folder name (must be unique among siblings)").option("--parent <id>", "Parent folder id (omit for top level)").option(
3017
+ "--type <type>",
3018
+ "Initial auth type: bearer | basic | api-key | custom-header | none | inherit"
3019
+ ).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")
3020
+ ).action(async (opts) => {
3021
+ const initialAuth = opts.type ? buildAuthFromCli({ ...opts, type: opts.type }) : void 0;
3022
+ const { provider } = await openWorkspace(opts);
3023
+ const f = {
3024
+ id: (0, import_shared6.generateId)(),
3025
+ name: opts.name.trim(),
3026
+ parentId: opts.parent ?? null,
3027
+ ...initialAuth ? { auth: initialAuth } : {}
3028
+ };
3029
+ const result = await provider.apply({ kind: "folder.create", folder: f });
3030
+ if ((result.changedIds.length ?? 0) === 0) {
3031
+ process.stderr.write(`${import_kleur10.default.red("error")}: folder.create no-op (duplicate id?)
3032
+ `);
3033
+ process.exit(1);
3034
+ }
3035
+ const authNote = initialAuth ? ` auth=${initialAuth.type}` : "";
3036
+ process.stdout.write(`${import_kleur10.default.green("created")} ${f.id} ${f.name}${authNote}
3037
+ `);
3038
+ });
3039
+ COMMON_OPTS(
3040
+ 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")
3041
+ ).action(async (id, opts) => {
3042
+ const { provider } = await openWorkspace(opts);
3043
+ const result = await provider.apply({
3044
+ kind: "folder.update",
3045
+ id,
3046
+ patch: { name: opts.name.trim() }
3047
+ });
3048
+ if (result.changedIds.length === 0) {
3049
+ process.stderr.write(
3050
+ `${import_kleur10.default.red("error")}: rename rejected \u2014 folder not found, or a sibling already has the name "${opts.name}".
3051
+ `
3052
+ );
3053
+ process.exit(1);
3054
+ }
3055
+ process.stdout.write(`${import_kleur10.default.green("renamed")} ${id} ${opts.name}
3056
+ `);
3057
+ });
3058
+ COMMON_OPTS(
3059
+ folder.command("set-auth").description("Set folder-level auth. Descendants with `auth.type: inherit` will pick it up.").argument("<id>", "Folder id").requiredOption(
3060
+ "--type <type>",
3061
+ "Auth type: bearer | basic | api-key | custom-header | none | inherit"
3062
+ ).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")
3063
+ ).action(async (id, opts) => {
3064
+ const auth = buildAuthFromCli(opts);
3065
+ const { provider } = await openWorkspace(opts);
3066
+ const result = await provider.apply({
3067
+ kind: "folder.update",
3068
+ id,
3069
+ patch: { auth }
3070
+ });
3071
+ if (result.changedIds.length === 0) {
3072
+ process.stderr.write(`${import_kleur10.default.red("error")}: folder ${id} not found.
3073
+ `);
3074
+ process.exit(1);
3075
+ }
3076
+ process.stdout.write(`${import_kleur10.default.green("updated")} ${id} auth.type=${auth.type}
3077
+ `);
3078
+ });
3079
+ COMMON_OPTS(
3080
+ folder.command("clear-auth").description("Clear folder-level auth. Descendants `inherit` walks further up.").argument("<id>", "Folder id")
3081
+ ).action(async (id, opts) => {
3082
+ const { provider } = await openWorkspace(opts);
3083
+ const result = await provider.apply({
3084
+ kind: "folder.update",
3085
+ id,
3086
+ patch: { auth: void 0 }
3087
+ });
3088
+ if (result.changedIds.length === 0) {
3089
+ process.stderr.write(`${import_kleur10.default.red("error")}: folder ${id} not found.
3090
+ `);
3091
+ process.exit(1);
3092
+ }
3093
+ process.stdout.write(`${import_kleur10.default.green("cleared auth")} ${id}
3094
+ `);
3095
+ });
3096
+ COMMON_OPTS(
3097
+ 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)")
3098
+ ).action(async (id, opts) => {
3099
+ const { provider } = await openWorkspace(opts);
3100
+ const result = await provider.apply({
3101
+ kind: "folder.move",
3102
+ id,
3103
+ newParentId: opts.parent ?? null
3104
+ });
3105
+ if (result.changedIds.length === 0) {
3106
+ process.stderr.write(
3107
+ `${import_kleur10.default.red("error")}: move rejected \u2014 folder not found, same parent, self-parent, or cycle.
3108
+ `
3109
+ );
3110
+ process.exit(1);
3111
+ }
3112
+ process.stdout.write(`${import_kleur10.default.green("moved")} ${id} parent=${opts.parent ?? "(root)"}
3113
+ `);
3114
+ });
3115
+ COMMON_OPTS(
3116
+ folder.command("delete").description("Delete a folder. Direct children reparent to its parent.").argument("<id>", "Folder id")
3117
+ ).action(async (id, opts) => {
3118
+ const { provider } = await openWorkspace(opts);
3119
+ const result = await provider.apply({ kind: "folder.delete", id });
3120
+ if (result.changedIds.length === 0) {
3121
+ process.stderr.write(`${import_kleur10.default.red("error")}: folder ${id} not found.
3122
+ `);
3123
+ process.exit(1);
3124
+ }
3125
+ process.stdout.write(`${import_kleur10.default.green("deleted")} ${id}
3126
+ `);
3127
+ });
3128
+ }
3129
+ function buildAuthFromCli(opts) {
3130
+ switch (opts.type) {
3131
+ case "none":
3132
+ return { type: "none" };
3133
+ case "inherit":
3134
+ return { type: "inherit" };
3135
+ case "bearer":
3136
+ return { type: "bearer", token: opts.token ?? "" };
3137
+ case "basic":
3138
+ return { type: "basic", username: opts.username ?? "", password: opts.password ?? "" };
3139
+ case "api-key":
3140
+ return {
3141
+ type: "api-key",
3142
+ key: opts.key ?? "",
3143
+ value: opts.value ?? "",
3144
+ addTo: opts.addTo === "query" || opts.addTo === "cookie" ? opts.addTo : "header"
3145
+ };
3146
+ case "custom-header":
3147
+ return { type: "custom-header", key: opts.key ?? "", value: opts.value ?? "" };
3148
+ default:
3149
+ process.stderr.write(
3150
+ `${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.
3151
+ `
3152
+ );
3153
+ process.exit(2);
3154
+ }
3155
+ }
3156
+ function printTree(folder, all, depth) {
3157
+ const indent = " ".repeat(depth);
3158
+ const authTag = folder.auth && folder.auth.type !== "none" && folder.auth.type !== "inherit" ? ` ${import_kleur10.default.cyan(`[auth: ${folder.auth.type}]`)}` : "";
3159
+ process.stdout.write(`${indent}${import_kleur10.default.bold(folder.name)} ${import_kleur10.default.dim(folder.id)}${authTag}
3160
+ `);
3161
+ const children = Object.values(all).filter((f) => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name));
3162
+ for (const c of children) printTree(c, all, depth + 1);
3163
+ }
3164
+ var import_kleur10, import_shared6, import_mcp_server2, COMMON_OPTS;
3165
+ var init_folder = __esm({
3166
+ "src/commands/folder.ts"() {
3167
+ "use strict";
3168
+ import_kleur10 = __toESM(require("kleur"), 1);
3169
+ import_shared6 = require("@apicircle/shared");
3170
+ import_mcp_server2 = require("@apicircle/mcp-server");
3171
+ init_loadWorkspace();
3172
+ init_resolveWorkspace();
3173
+ COMMON_OPTS = (cmd) => cmd.option(
3174
+ "--workspace-name <name-or-id>",
3175
+ "Registry workspace name (case-insensitive) or id. Defaults to the active workspace."
3176
+ ).option(
3177
+ "-w, --workspace-path <dir>",
3178
+ "Filesystem directory containing the .apicircle/ workspace (skips the registry)."
3179
+ );
3180
+ }
3181
+ });
3182
+
1628
3183
  // src/index.ts
1629
3184
  var src_exports = {};
1630
3185
  __export(src_exports, {
@@ -1635,27 +3190,35 @@ function buildProgram() {
1635
3190
  const program = new import_commander.Command();
1636
3191
  program.name("apicircle").description("Command-line companion to API Circle Studio.").version(CLI_PACKAGE_VERSION);
1637
3192
  registerMockCommand(program);
3193
+ registerMocksCommand(program);
1638
3194
  registerMcpCommand(program);
1639
3195
  registerImportCommand(program);
1640
3196
  registerExportCommand(program);
1641
3197
  registerRunCommand(program);
1642
3198
  registerWorkspacesCommand(program);
3199
+ registerLinkedCommand(program);
3200
+ registerReleaseCommand(program);
3201
+ registerFolderCommand(program);
1643
3202
  return program;
1644
3203
  }
1645
3204
  async function runCli(argv = process.argv) {
1646
3205
  await buildProgram().parseAsync(argv);
1647
3206
  }
1648
3207
  var import_commander, entry;
1649
- var init_src = __esm({
3208
+ var init_src2 = __esm({
1650
3209
  "src/index.ts"() {
1651
3210
  "use strict";
1652
3211
  import_commander = require("commander");
1653
3212
  init_mock();
3213
+ init_mocks();
1654
3214
  init_mcp();
1655
3215
  init_import();
1656
3216
  init_export();
1657
3217
  init_run();
1658
3218
  init_workspaces();
3219
+ init_linked();
3220
+ init_release();
3221
+ init_folder();
1659
3222
  init_packageVersion();
1660
3223
  entry = process.argv[1] ?? "";
1661
3224
  if (entry.endsWith("apicircle") || entry.endsWith("index.cjs") || entry.endsWith("index.ts")) {
@@ -1694,6 +3257,8 @@ Commands:
1694
3257
  import <source> <workspace> Import OpenAPI, Postman, Insomnia, or curl.
1695
3258
  run [options] <plan-id> Run an execution plan.
1696
3259
  workspaces Manage local workspace registries.
3260
+ linked <subcommand> Manage linked workspaces (list/link/refresh/unlink).
3261
+ release <subcommand> Tag releases / set topics on the workspace's GitHub repo.
1697
3262
 
1698
3263
  Run "apicircle <command> --help" for command-specific help.
1699
3264
  `;
@@ -1711,7 +3276,7 @@ async function runBin(argv = process.argv) {
1711
3276
  process.stdout.write(formatRootHelp());
1712
3277
  return;
1713
3278
  }
1714
- const { runCli: runCli2 } = await Promise.resolve().then(() => (init_src(), src_exports));
3279
+ const { runCli: runCli2 } = await Promise.resolve().then(() => (init_src2(), src_exports));
1715
3280
  await runCli2(argv);
1716
3281
  }
1717
3282
  var entry2 = process.argv[1] ?? "";