@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/README.md +38 -6
- package/dist/bin/cli.cjs +1666 -101
- package/dist/bin/cli.cjs.map +1 -1
- package/dist/bin/cli.js +3 -1
- package/dist/bin/cli.js.map +1 -1
- package/dist/{chunk-H4VHFKVH.js → chunk-P4CXF2MS.js} +3 -2
- package/dist/{chunk-H4VHFKVH.js.map → chunk-P4CXF2MS.js.map} +1 -1
- package/dist/index.cjs +1601 -94
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1610 -97
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
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
|
|
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
|
|
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,
|
|
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.
|
|
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(`${
|
|
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
|
-
`${
|
|
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(`${
|
|
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
|
|
688
|
+
var import_kleur3, import_mcp_server;
|
|
562
689
|
var init_mcp = __esm({
|
|
563
690
|
"src/commands/mcp.ts"() {
|
|
564
691
|
"use strict";
|
|
565
|
-
|
|
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.
|
|
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
|
-
`${
|
|
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(`${
|
|
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
|
-
`${
|
|
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(`${
|
|
813
|
+
process.stderr.write(`${import_kleur4.default.yellow("warning")}: ${w}
|
|
687
814
|
`);
|
|
688
815
|
}
|
|
689
|
-
await (0,
|
|
816
|
+
await (0, import_file_backed4.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
|
|
690
817
|
process.stdout.write(
|
|
691
|
-
`${
|
|
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
|
-
`${
|
|
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(`${
|
|
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,
|
|
833
|
+
await (0, import_file_backed4.saveToFile)(dir, { synced: nextSynced, local: nextLocal });
|
|
707
834
|
process.stdout.write(
|
|
708
|
-
`${
|
|
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,
|
|
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
|
-
|
|
877
|
+
import_kleur4 = __toESM(require("kleur"), 1);
|
|
751
878
|
import_core = require("@apicircle/core");
|
|
752
|
-
|
|
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.
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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,
|
|
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
|
-
|
|
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 = [
|
|
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 =
|
|
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.
|
|
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
|
-
`${
|
|
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,
|
|
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.
|
|
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,
|
|
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 `${
|
|
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
|
-
${
|
|
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
|
-
`${
|
|
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
|
-
` ${
|
|
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 ` ${
|
|
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 ?
|
|
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)} ${
|
|
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 += ` ${
|
|
1505
|
+
line += ` ${import_kleur6.default.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
|
|
1368
1506
|
}
|
|
1369
1507
|
line += "\n";
|
|
1370
1508
|
if (step.error) {
|
|
1371
|
-
line += ` ${
|
|
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 += ` ${
|
|
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 += ` ${
|
|
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
|
-
${
|
|
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
|
-
|
|
1404
|
-
failed > 0 ?
|
|
1405
|
-
|
|
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 ?
|
|
1545
|
+
const verdict = result.passed && !aborted ? import_kleur6.default.green("PASS") : import_kleur6.default.red("FAIL");
|
|
1408
1546
|
let out = `
|
|
1409
|
-
${verdict} ${parts.join(
|
|
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 += `${
|
|
1551
|
+
if (aborted) out += `${import_kleur6.default.yellow("Run aborted before every step finished.")}
|
|
1414
1552
|
`;
|
|
1415
|
-
out += saved ?
|
|
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(`${
|
|
1629
|
+
process.stderr.write(`${import_kleur6.default.red(kind)}: ${message}
|
|
1492
1630
|
`);
|
|
1493
1631
|
process.exitCode = code;
|
|
1494
1632
|
}
|
|
1495
|
-
var os2,
|
|
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
|
-
|
|
1638
|
+
import_kleur6 = __toESM(require("kleur"), 1);
|
|
1501
1639
|
import_core4 = require("@apicircle/core");
|
|
1502
|
-
|
|
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
|
-
`${
|
|
1522
|
-
${
|
|
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(`${
|
|
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
|
-
|
|
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 ?
|
|
1682
|
+
const mark = w.id === registry.activeWorkspaceId ? import_kleur7.default.green("\u25CF") : " ";
|
|
1545
1683
|
process.stdout.write(
|
|
1546
|
-
` ${mark} ${w.name.padEnd(nameWidth)} ${
|
|
1684
|
+
` ${mark} ${w.name.padEnd(nameWidth)} ${import_kleur7.default.dim(
|
|
1547
1685
|
w.id.padEnd(idWidth)
|
|
1548
|
-
)} ${
|
|
1686
|
+
)} ${import_kleur7.default.dim(w.lastOpenedAt)}
|
|
1549
1687
|
`
|
|
1550
1688
|
);
|
|
1551
1689
|
}
|
|
1552
1690
|
process.stdout.write(`
|
|
1553
|
-
${
|
|
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
|
-
`${
|
|
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(`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
1585
|
-
${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
|
1756
|
+
var import_kleur7, import_registry2;
|
|
1619
1757
|
var init_workspaces = __esm({
|
|
1620
1758
|
"src/commands/workspaces.ts"() {
|
|
1621
1759
|
"use strict";
|
|
1622
|
-
|
|
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
|
|
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(() => (
|
|
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] ?? "";
|