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