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