@apicircle/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +72 -0
- package/dist/index.cjs +640 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +621 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/mock.ts
|
|
7
|
+
import { promises as fs } from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import kleur from "kleur";
|
|
10
|
+
import {
|
|
11
|
+
parseSourceToEndpoints,
|
|
12
|
+
startMockServer
|
|
13
|
+
} from "@apicircle/mock-server-core";
|
|
14
|
+
import { generateId } from "@apicircle/shared";
|
|
15
|
+
function registerMockCommand(program) {
|
|
16
|
+
program.command("mock").description("Run a mock server from an OpenAPI / Postman / Insomnia file").argument("<spec>", "Path to the spec file").option("-p, --port <number>", "TCP port to bind (defaults to a free port)").option("-h, --host <host>", "Hostname to bind", "127.0.0.1").option("-t, --type <type>", "Source type: openapi | postman | insomnia | auto", "auto").option("-f, --format <format>", "OpenAPI format: json | yaml | auto", "auto").option("--cors", "Enable permissive CORS", true).action(async (spec, opts) => {
|
|
17
|
+
const absolute = path.resolve(spec);
|
|
18
|
+
const raw = await fs.readFile(absolute, "utf-8");
|
|
19
|
+
const type = inferType(absolute, opts.type ?? "auto");
|
|
20
|
+
const format = type === "openapi" ? inferFormat(absolute, opts.format ?? "auto") : "json";
|
|
21
|
+
const source = makeSource(type, format, raw);
|
|
22
|
+
const parsed = await parseSourceToEndpoints(source);
|
|
23
|
+
const mock = {
|
|
24
|
+
id: generateId(),
|
|
25
|
+
name: path.basename(absolute),
|
|
26
|
+
source,
|
|
27
|
+
endpoints: parsed.endpoints,
|
|
28
|
+
defaultPort: opts.port ? Number(opts.port) : null,
|
|
29
|
+
cors: { enabled: opts.cors !== false, origins: ["*"] },
|
|
30
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
32
|
+
};
|
|
33
|
+
const handle = await startMockServer(mock, {
|
|
34
|
+
port: opts.port ? Number(opts.port) : void 0,
|
|
35
|
+
host: opts.host
|
|
36
|
+
});
|
|
37
|
+
process.stdout.write(
|
|
38
|
+
`${kleur.green("Mock server")} listening on ${kleur.cyan(`http://${opts.host}:${handle.port}`)} with ${parsed.endpoints.length} endpoints (type=${type}). Press Ctrl-C to stop.
|
|
39
|
+
`
|
|
40
|
+
);
|
|
41
|
+
if (parsed.warnings.length) {
|
|
42
|
+
for (const w of parsed.warnings) {
|
|
43
|
+
process.stderr.write(`${kleur.yellow("warn")}: ${w}
|
|
44
|
+
`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
installShutdown(handle);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function inferType(filePath, hint) {
|
|
51
|
+
if (hint && hint !== "auto") return hint;
|
|
52
|
+
const lower = filePath.toLowerCase();
|
|
53
|
+
if (lower.includes("postman")) return "postman";
|
|
54
|
+
if (lower.includes("insomnia")) return "insomnia";
|
|
55
|
+
return "openapi";
|
|
56
|
+
}
|
|
57
|
+
function inferFormat(filePath, hint) {
|
|
58
|
+
if (hint && hint !== "auto") return hint;
|
|
59
|
+
const lower = filePath.toLowerCase();
|
|
60
|
+
return lower.endsWith(".yaml") || lower.endsWith(".yml") ? "yaml" : "json";
|
|
61
|
+
}
|
|
62
|
+
function makeSource(type, format, raw) {
|
|
63
|
+
switch (type) {
|
|
64
|
+
case "openapi":
|
|
65
|
+
return { kind: "openapi", spec: raw, format };
|
|
66
|
+
case "postman":
|
|
67
|
+
return { kind: "postman", collection: raw };
|
|
68
|
+
case "insomnia":
|
|
69
|
+
return { kind: "insomnia", export: raw };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function installShutdown(handle) {
|
|
73
|
+
let closing = false;
|
|
74
|
+
const shutdown = async () => {
|
|
75
|
+
if (closing) return;
|
|
76
|
+
closing = true;
|
|
77
|
+
await handle.close();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
};
|
|
80
|
+
process.on("SIGINT", () => void shutdown());
|
|
81
|
+
process.on("SIGTERM", () => void shutdown());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/commands/mcp.ts
|
|
85
|
+
import * as path3 from "path";
|
|
86
|
+
import kleur2 from "kleur";
|
|
87
|
+
import {
|
|
88
|
+
createMcpServer,
|
|
89
|
+
FileBackedWorkspaceProvider,
|
|
90
|
+
InProcessMockController
|
|
91
|
+
} from "@apicircle/mcp-server";
|
|
92
|
+
|
|
93
|
+
// src/util/loadWorkspace.ts
|
|
94
|
+
import * as path2 from "path";
|
|
95
|
+
import { promises as fs2 } from "fs";
|
|
96
|
+
import { loadFromFile, saveToFile } from "@apicircle/core/workspace/file-backed";
|
|
97
|
+
import { FONT_SIZE_PERCENT_DEFAULT, generateId as generateId2 } from "@apicircle/shared";
|
|
98
|
+
async function ensureWorkspace(dir) {
|
|
99
|
+
const resolved = path2.resolve(dir);
|
|
100
|
+
await fs2.mkdir(resolved, { recursive: true });
|
|
101
|
+
const existing = await loadFromFile(resolved, { allowMissing: true });
|
|
102
|
+
if (existing) return existing;
|
|
103
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
104
|
+
const workspaceId = generateId2();
|
|
105
|
+
const fresh = {
|
|
106
|
+
synced: {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
workspaceId,
|
|
109
|
+
collections: {
|
|
110
|
+
tree: { id: generateId2(), type: "root", children: [] },
|
|
111
|
+
requests: {},
|
|
112
|
+
folders: {}
|
|
113
|
+
},
|
|
114
|
+
environments: { items: {}, activeName: null, priorityOrder: [] },
|
|
115
|
+
linkedWorkspaces: {},
|
|
116
|
+
linkedOverrides: { requests: {}, environmentVars: {} },
|
|
117
|
+
releases: { self: null, perLink: {} },
|
|
118
|
+
globalAssets: { schemas: {}, graphql: {} },
|
|
119
|
+
mockServers: {},
|
|
120
|
+
meta: { createdAt: now, updatedAt: now, appVersion: "1.0.0" }
|
|
121
|
+
},
|
|
122
|
+
local: {
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
workspaceId,
|
|
125
|
+
executionPlans: {},
|
|
126
|
+
history: { requestRuns: [], planRuns: [] },
|
|
127
|
+
secretIndex: { entries: {} },
|
|
128
|
+
sessions: { github: { workspace: null, links: {} } },
|
|
129
|
+
connectedRepo: null,
|
|
130
|
+
workingBranch: null,
|
|
131
|
+
seededWorkspaceSha: null,
|
|
132
|
+
retiredBranch: null,
|
|
133
|
+
sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] },
|
|
134
|
+
linkedCollections: {},
|
|
135
|
+
globalContext: {},
|
|
136
|
+
mockRuntime: { active: {} },
|
|
137
|
+
ui: {
|
|
138
|
+
activeRequestId: null,
|
|
139
|
+
sidebarExpandedSections: [],
|
|
140
|
+
themeId: "studio-dark",
|
|
141
|
+
fontId: "system-mono",
|
|
142
|
+
fontSizePercent: FONT_SIZE_PERCENT_DEFAULT
|
|
143
|
+
},
|
|
144
|
+
settings: { validateOnSend: true, monacoConsumesWheel: false },
|
|
145
|
+
snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 }
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
await saveToFile(resolved, fresh);
|
|
149
|
+
return fresh;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/commands/mcp.ts
|
|
153
|
+
function registerMcpCommand(program) {
|
|
154
|
+
program.command("mcp").description("Run the APICircle MCP server (stdio transport)").option("-w, --workspace <dir>", "Workspace directory (defaults to current directory)").action(async (opts) => {
|
|
155
|
+
const dir = path3.resolve(opts.workspace ?? process.cwd());
|
|
156
|
+
try {
|
|
157
|
+
await ensureWorkspace(dir);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`${kleur2.red("failed to initialise workspace")} at ${dir}: ${err instanceof Error ? err.message : String(err)}
|
|
161
|
+
`
|
|
162
|
+
);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
const workspace = new FileBackedWorkspaceProvider(dir);
|
|
166
|
+
const mock = new InProcessMockController();
|
|
167
|
+
const host = createMcpServer({ workspace, mock });
|
|
168
|
+
process.stderr.write(`${kleur2.green("apicircle-mcp")} ready \xB7 workspace=${dir}
|
|
169
|
+
`);
|
|
170
|
+
await host.connect();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/commands/import.ts
|
|
175
|
+
import { promises as fs3 } from "fs";
|
|
176
|
+
import * as path4 from "path";
|
|
177
|
+
import kleur3 from "kleur";
|
|
178
|
+
import { applyMutation } from "@apicircle/core";
|
|
179
|
+
import { saveToFile as saveToFile2 } from "@apicircle/core/workspace/file-backed";
|
|
180
|
+
import {
|
|
181
|
+
parseInsomniaToEndpoints,
|
|
182
|
+
parseOpenApiToEndpoints,
|
|
183
|
+
parsePostmanToEndpoints
|
|
184
|
+
} from "@apicircle/mock-server-core";
|
|
185
|
+
import { generateId as generateId3 } from "@apicircle/shared";
|
|
186
|
+
function registerImportCommand(program) {
|
|
187
|
+
program.command("import").description("Import a spec into a workspace folder").argument("<type>", "Source type: openapi | postman | insomnia | curl").argument("<input>", "Path to a spec file, or `-` to read from stdin").option("-w, --workspace <dir>", "Workspace directory (defaults to current directory)").option("-f, --format <format>", "OpenAPI format: json | yaml", "json").action(async (type, input, opts) => {
|
|
188
|
+
const dir = path4.resolve(opts.workspace ?? process.cwd());
|
|
189
|
+
const raw = await readInput(input);
|
|
190
|
+
const state = await ensureWorkspace(dir);
|
|
191
|
+
let nextSynced = state.synced;
|
|
192
|
+
let nextLocal = state.local;
|
|
193
|
+
const created = [];
|
|
194
|
+
const append = (req) => {
|
|
195
|
+
const out = applyMutation(
|
|
196
|
+
{ synced: nextSynced, local: nextLocal },
|
|
197
|
+
{ kind: "request.create", request: req }
|
|
198
|
+
);
|
|
199
|
+
nextSynced = out.next.synced;
|
|
200
|
+
nextLocal = out.next.local;
|
|
201
|
+
created.push(req.id);
|
|
202
|
+
};
|
|
203
|
+
if (type === "curl") {
|
|
204
|
+
const { parseCurl } = await import("@apicircle/core");
|
|
205
|
+
const parsed = parseCurl(raw);
|
|
206
|
+
append(
|
|
207
|
+
blankRequest({
|
|
208
|
+
name: `cURL ${parsed.method} ${parsed.url}`.slice(0, 80),
|
|
209
|
+
method: parsed.method,
|
|
210
|
+
url: parsed.url,
|
|
211
|
+
headers: parsed.headers,
|
|
212
|
+
query: parsed.query,
|
|
213
|
+
body: parsed.body,
|
|
214
|
+
auth: parsed.auth
|
|
215
|
+
})
|
|
216
|
+
);
|
|
217
|
+
} else if (type === "openapi") {
|
|
218
|
+
const parsed = await parseOpenApiToEndpoints(raw, opts.format ?? "json");
|
|
219
|
+
for (const ep of parsed.endpoints) {
|
|
220
|
+
append(
|
|
221
|
+
blankRequest({
|
|
222
|
+
name: ep.example ?? `${ep.method} ${ep.pathPattern}`,
|
|
223
|
+
method: ep.method,
|
|
224
|
+
url: ep.pathPattern
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
} else if (type === "postman") {
|
|
229
|
+
const parsed = parsePostmanToEndpoints(raw);
|
|
230
|
+
for (const ep of parsed.endpoints) {
|
|
231
|
+
append(
|
|
232
|
+
blankRequest({
|
|
233
|
+
name: ep.example ?? `${ep.method} ${ep.pathPattern}`,
|
|
234
|
+
method: ep.method,
|
|
235
|
+
url: ep.pathPattern
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
} else if (type === "insomnia") {
|
|
240
|
+
const parsed = parseInsomniaToEndpoints(raw);
|
|
241
|
+
for (const ep of parsed.endpoints) {
|
|
242
|
+
append(
|
|
243
|
+
blankRequest({
|
|
244
|
+
name: ep.example ?? `${ep.method} ${ep.pathPattern}`,
|
|
245
|
+
method: ep.method,
|
|
246
|
+
url: ep.pathPattern
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
process.stderr.write(`${kleur3.red("error")}: unknown type '${String(type)}'
|
|
252
|
+
`);
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
await saveToFile2(dir, { synced: nextSynced, local: nextLocal });
|
|
256
|
+
process.stdout.write(
|
|
257
|
+
`${kleur3.green("imported")} ${created.length} request${created.length === 1 ? "" : "s"} into ${dir}
|
|
258
|
+
`
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function readInput(p) {
|
|
263
|
+
if (p === "-") {
|
|
264
|
+
return new Promise((resolve7, reject) => {
|
|
265
|
+
let data = "";
|
|
266
|
+
process.stdin.setEncoding("utf-8");
|
|
267
|
+
process.stdin.on("data", (chunk) => {
|
|
268
|
+
data += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
269
|
+
});
|
|
270
|
+
process.stdin.on("end", () => resolve7(data));
|
|
271
|
+
process.stdin.on("error", reject);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return fs3.readFile(path4.resolve(p), "utf-8");
|
|
275
|
+
}
|
|
276
|
+
function blankRequest(partial) {
|
|
277
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
278
|
+
return {
|
|
279
|
+
id: generateId3(),
|
|
280
|
+
folderId: null,
|
|
281
|
+
headers: [],
|
|
282
|
+
query: [],
|
|
283
|
+
body: { type: "none", content: "" },
|
|
284
|
+
auth: { type: "none" },
|
|
285
|
+
contextVars: [],
|
|
286
|
+
extractions: [],
|
|
287
|
+
assertions: [],
|
|
288
|
+
createdAt: now,
|
|
289
|
+
updatedAt: now,
|
|
290
|
+
...partial
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/commands/run.ts
|
|
295
|
+
import * as os from "os";
|
|
296
|
+
import * as path6 from "path";
|
|
297
|
+
import kleur4 from "kleur";
|
|
298
|
+
import {
|
|
299
|
+
ANONYMOUS_ACTOR,
|
|
300
|
+
PlanRunDeniedError,
|
|
301
|
+
resolvePlanRef,
|
|
302
|
+
runPlan
|
|
303
|
+
} from "@apicircle/core";
|
|
304
|
+
import { loadFromFile as loadFromFile2, saveToFile as saveToFile3 } from "@apicircle/core/workspace/file-backed";
|
|
305
|
+
|
|
306
|
+
// src/util/secrets.ts
|
|
307
|
+
import * as path5 from "path";
|
|
308
|
+
import { promises as fs4 } from "fs";
|
|
309
|
+
var DEFAULT_PREFIX = "APICIRCLE_SECRET_";
|
|
310
|
+
async function buildSecretsFromCli(options = {}) {
|
|
311
|
+
const env = options.env ?? process.env;
|
|
312
|
+
const prefix = options.envPrefix ?? DEFAULT_PREFIX;
|
|
313
|
+
const byId = {};
|
|
314
|
+
if (options.secretsFile) {
|
|
315
|
+
const resolved = path5.resolve(options.secretsFile);
|
|
316
|
+
const raw = await fs4.readFile(resolved, "utf8");
|
|
317
|
+
const parsed = JSON.parse(raw);
|
|
318
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`--secrets ${options.secretsFile}: expected an object mapping secretKeyId \u2192 value`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
324
|
+
if (typeof value !== "string") {
|
|
325
|
+
throw new Error(`--secrets ${options.secretsFile}: value for "${id}" must be a string`);
|
|
326
|
+
}
|
|
327
|
+
byId[id] = value;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const [name, value] of Object.entries(env)) {
|
|
331
|
+
if (!name.startsWith(prefix) || typeof value !== "string") continue;
|
|
332
|
+
const id = name.slice(prefix.length);
|
|
333
|
+
if (id) byId[id] = value;
|
|
334
|
+
}
|
|
335
|
+
return { byId };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/commands/run.ts
|
|
339
|
+
var REPORTERS = ["text", "json", "junit"];
|
|
340
|
+
function registerRunCommand(program) {
|
|
341
|
+
program.command("run").description("Run a saved execution plan from a workspace and report the result").argument("<plan>", "Plan name or id to run").option("-w, --workspace <dir>", "Workspace directory (defaults to current directory)").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) => {
|
|
342
|
+
const dir = path6.resolve(opts.workspace ?? process.cwd());
|
|
343
|
+
const reporter = opts.reporter ?? "text";
|
|
344
|
+
if (!isReporter(reporter)) {
|
|
345
|
+
fail(`unknown --reporter "${reporter}" (expected: ${REPORTERS.join(", ")})`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const state = await loadFromFile2(dir, { allowMissing: true });
|
|
349
|
+
if (!state) {
|
|
350
|
+
fail(`no workspace found at ${dir} (expected workspace.synced.json)`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const ref = resolvePlanRef(state.synced, planRef);
|
|
354
|
+
if (!ref.ok) {
|
|
355
|
+
fail(ref.error);
|
|
356
|
+
if (ref.available.length > 0) {
|
|
357
|
+
process.stderr.write(`Available plans: ${ref.available.join(", ")}
|
|
358
|
+
`);
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (opts.env && !state.synced.environments.items[opts.env]) {
|
|
363
|
+
const names = Object.keys(state.synced.environments.items);
|
|
364
|
+
fail(`no environment named "${opts.env}" in this workspace`);
|
|
365
|
+
if (names.length > 0) {
|
|
366
|
+
process.stderr.write(`Available environments: ${names.join(", ")}
|
|
367
|
+
`);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
let secretsById;
|
|
372
|
+
try {
|
|
373
|
+
secretsById = (await buildSecretsFromCli({ secretsFile: opts.secrets })).byId;
|
|
374
|
+
} catch (err) {
|
|
375
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const actor = resolveActor(state.local, opts.as);
|
|
379
|
+
const withAssertions = opts.assertions !== false;
|
|
380
|
+
const text = reporter === "text";
|
|
381
|
+
const controller = new AbortController();
|
|
382
|
+
const onSigint = () => controller.abort(new Error("aborted by SIGINT"));
|
|
383
|
+
process.on("SIGINT", onSigint);
|
|
384
|
+
if (text) process.stdout.write(formatHeader(ref.plan, actor, withAssertions, opts));
|
|
385
|
+
let result;
|
|
386
|
+
try {
|
|
387
|
+
result = await runPlan(state, ref.id, {
|
|
388
|
+
withAssertions,
|
|
389
|
+
bail: opts.bail === true,
|
|
390
|
+
env: opts.env,
|
|
391
|
+
secretsById,
|
|
392
|
+
actor,
|
|
393
|
+
signal: controller.signal,
|
|
394
|
+
authorize: checkRunPermission,
|
|
395
|
+
onStep: text ? (step) => process.stdout.write(formatStepLine(step)) : void 0
|
|
396
|
+
});
|
|
397
|
+
} catch (err) {
|
|
398
|
+
process.off("SIGINT", onSigint);
|
|
399
|
+
if (err instanceof PlanRunDeniedError) {
|
|
400
|
+
fail(err.message, 3, "denied");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
process.off("SIGINT", onSigint);
|
|
406
|
+
const aborted = controller.signal.aborted;
|
|
407
|
+
const saved = opts.save !== false;
|
|
408
|
+
if (saved) await saveToFile3(dir, result.nextState);
|
|
409
|
+
if (reporter === "json") {
|
|
410
|
+
process.stdout.write(
|
|
411
|
+
JSON.stringify(
|
|
412
|
+
buildJsonReport(dir, ref.id, ref.plan, actor, result, saved, aborted),
|
|
413
|
+
null,
|
|
414
|
+
2
|
|
415
|
+
) + "\n"
|
|
416
|
+
);
|
|
417
|
+
} else if (reporter === "junit") {
|
|
418
|
+
process.stdout.write(buildJunitReport(ref.plan, result));
|
|
419
|
+
} else {
|
|
420
|
+
process.stdout.write(formatSummary(result, saved, aborted));
|
|
421
|
+
}
|
|
422
|
+
process.exitCode = result.passed && !aborted ? 0 : 1;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function isReporter(value) {
|
|
426
|
+
return REPORTERS.includes(value);
|
|
427
|
+
}
|
|
428
|
+
function resolveActor(local, override) {
|
|
429
|
+
const explicit = override?.trim();
|
|
430
|
+
if (explicit) return { kind: "unknown", name: explicit };
|
|
431
|
+
const login = local.sessions.github.workspace?.accountLogin;
|
|
432
|
+
if (login) return { kind: "github", name: login };
|
|
433
|
+
try {
|
|
434
|
+
const username = os.userInfo().username;
|
|
435
|
+
if (username) return { kind: "os", name: username };
|
|
436
|
+
} catch {
|
|
437
|
+
}
|
|
438
|
+
return ANONYMOUS_ACTOR;
|
|
439
|
+
}
|
|
440
|
+
function checkRunPermission(_ctx) {
|
|
441
|
+
}
|
|
442
|
+
function formatHeader(plan, actor, withAssertions, opts) {
|
|
443
|
+
const enabled = plan.steps.filter((s) => s.enabled !== false).length;
|
|
444
|
+
const flags = [
|
|
445
|
+
withAssertions ? "assertions on" : "assertions off",
|
|
446
|
+
opts.bail ? "bail" : null,
|
|
447
|
+
opts.env ? `env=${opts.env}` : null
|
|
448
|
+
].filter((f) => f !== null);
|
|
449
|
+
return `${kleur4.bold("Plan")} ${plan.name} ${kleur4.dim(
|
|
450
|
+
`(${enabled}/${plan.steps.length} steps \xB7 ${flags.join(" \xB7 ")})`
|
|
451
|
+
)}
|
|
452
|
+
${kleur4.dim("Run by")} ${actor.name} ${kleur4.dim(`(${actor.kind})`)}
|
|
453
|
+
|
|
454
|
+
`;
|
|
455
|
+
}
|
|
456
|
+
function formatStepLine(step) {
|
|
457
|
+
const n = `${step.stepIndex + 1}.`.padEnd(3);
|
|
458
|
+
const method = (step.requestMethod || "\u2014").padEnd(7);
|
|
459
|
+
if (step.skipped) {
|
|
460
|
+
return ` ${kleur4.dim("\u2013")} ${kleur4.dim(n)} ${kleur4.dim(method)} ${kleur4.dim(
|
|
461
|
+
`${step.requestName} skipped`
|
|
462
|
+
)}
|
|
463
|
+
`;
|
|
464
|
+
}
|
|
465
|
+
const mark = step.passed ? kleur4.green("\u2713") : kleur4.red("\u2717");
|
|
466
|
+
const status = step.result?.status != null ? String(step.result.status) : "\u2014";
|
|
467
|
+
const duration = step.result ? `${step.result.durationMs}ms` : "";
|
|
468
|
+
const name = step.requestName.padEnd(28);
|
|
469
|
+
let line = ` ${mark} ${n} ${method} ${name} ${status.padEnd(4)} ${kleur4.dim(duration)}`;
|
|
470
|
+
if (step.assertionResults.length > 0) {
|
|
471
|
+
const passed = step.assertionResults.filter((a) => a.passed).length;
|
|
472
|
+
line += ` ${kleur4.dim(`${passed}/${step.assertionResults.length} assertions`)}`;
|
|
473
|
+
}
|
|
474
|
+
line += "\n";
|
|
475
|
+
if (step.error) {
|
|
476
|
+
line += ` ${kleur4.red(step.error)}
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
for (const a of step.assertionResults) {
|
|
480
|
+
if (!a.passed) line += ` ${kleur4.red("\u2717")} ${a.detail ?? `${a.kind} ${a.op}`}
|
|
481
|
+
`;
|
|
482
|
+
}
|
|
483
|
+
if (step.missingVariables.length > 0) {
|
|
484
|
+
line += ` ${kleur4.yellow("\u26A0")} unresolved: ${step.missingVariables.map((v) => `{{${v}}}`).join(", ")}
|
|
485
|
+
`;
|
|
486
|
+
}
|
|
487
|
+
return line;
|
|
488
|
+
}
|
|
489
|
+
function tally(result) {
|
|
490
|
+
let passed = 0;
|
|
491
|
+
let failed = 0;
|
|
492
|
+
let skipped = 0;
|
|
493
|
+
for (const s of result.steps) {
|
|
494
|
+
if (s.skipped) skipped++;
|
|
495
|
+
else if (s.passed) passed++;
|
|
496
|
+
else failed++;
|
|
497
|
+
}
|
|
498
|
+
return { passed, failed, skipped };
|
|
499
|
+
}
|
|
500
|
+
function formatSummary(result, saved, aborted) {
|
|
501
|
+
if (result.steps.length === 0) {
|
|
502
|
+
return `
|
|
503
|
+
${kleur4.yellow("Plan has no steps.")}
|
|
504
|
+
`;
|
|
505
|
+
}
|
|
506
|
+
const { passed, failed, skipped } = tally(result);
|
|
507
|
+
const parts = [
|
|
508
|
+
kleur4.green(`${passed} passed`),
|
|
509
|
+
failed > 0 ? kleur4.red(`${failed} failed`) : kleur4.dim(`${failed} failed`),
|
|
510
|
+
kleur4.dim(`${skipped} skipped`)
|
|
511
|
+
];
|
|
512
|
+
const verdict = result.passed && !aborted ? kleur4.green("PASS") : kleur4.red("FAIL");
|
|
513
|
+
let out = `
|
|
514
|
+
${verdict} ${parts.join(kleur4.dim(" \xB7 "))} ${kleur4.dim(
|
|
515
|
+
`\xB7 ${result.planRun.durationMs}ms`
|
|
516
|
+
)}
|
|
517
|
+
`;
|
|
518
|
+
if (aborted) out += `${kleur4.yellow("Run aborted before every step finished.")}
|
|
519
|
+
`;
|
|
520
|
+
out += saved ? kleur4.dim("Plan run saved to workspace history.\n") : kleur4.dim("Plan run not saved (--no-save).\n");
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
function buildJsonReport(workspace, planId, plan, actor, result, saved, aborted) {
|
|
524
|
+
return {
|
|
525
|
+
workspace,
|
|
526
|
+
plan: { id: planId, name: plan.name },
|
|
527
|
+
actor,
|
|
528
|
+
withAssertions: result.planRun.withAssertions,
|
|
529
|
+
passed: result.passed && !aborted,
|
|
530
|
+
aborted,
|
|
531
|
+
durationMs: result.planRun.durationMs,
|
|
532
|
+
saved,
|
|
533
|
+
counts: tally(result),
|
|
534
|
+
steps: result.steps.map((s) => ({
|
|
535
|
+
step: s.stepIndex + 1,
|
|
536
|
+
request: s.requestName,
|
|
537
|
+
method: s.requestMethod,
|
|
538
|
+
skipped: s.skipped,
|
|
539
|
+
status: s.result?.status ?? null,
|
|
540
|
+
ok: s.result?.ok ?? false,
|
|
541
|
+
durationMs: s.result?.durationMs ?? 0,
|
|
542
|
+
passed: s.passed,
|
|
543
|
+
error: s.error ?? null,
|
|
544
|
+
missingVariables: s.missingVariables,
|
|
545
|
+
assertions: s.assertionResults.map((a) => ({
|
|
546
|
+
kind: a.kind,
|
|
547
|
+
op: a.op,
|
|
548
|
+
target: a.target,
|
|
549
|
+
expected: a.expected,
|
|
550
|
+
passed: a.passed,
|
|
551
|
+
detail: a.detail
|
|
552
|
+
}))
|
|
553
|
+
}))
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function xmlEscape(value) {
|
|
557
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
558
|
+
}
|
|
559
|
+
function buildJunitReport(plan, result) {
|
|
560
|
+
const { failed, skipped } = tally(result);
|
|
561
|
+
const total = result.steps.length;
|
|
562
|
+
const suite = xmlEscape(plan.name);
|
|
563
|
+
const suiteTime = (result.planRun.durationMs / 1e3).toFixed(3);
|
|
564
|
+
const cases = result.steps.map((s) => {
|
|
565
|
+
const name = xmlEscape(`${s.stepIndex + 1}. ${s.requestName}`);
|
|
566
|
+
const time = ((s.result?.durationMs ?? 0) / 1e3).toFixed(3);
|
|
567
|
+
const open = ` <testcase name="${name}" classname="${suite}" time="${time}"`;
|
|
568
|
+
if (s.skipped) return `${open}>
|
|
569
|
+
<skipped/>
|
|
570
|
+
</testcase>`;
|
|
571
|
+
if (s.passed) return `${open}/>`;
|
|
572
|
+
const reasons = [];
|
|
573
|
+
if (s.error) reasons.push(s.error);
|
|
574
|
+
for (const a of s.assertionResults) {
|
|
575
|
+
if (!a.passed) reasons.push(a.detail ?? `assertion ${a.kind} ${a.op} failed`);
|
|
576
|
+
}
|
|
577
|
+
if (s.result && !s.result.ok && s.result.status != null) {
|
|
578
|
+
reasons.push(`HTTP ${s.result.status}`);
|
|
579
|
+
}
|
|
580
|
+
const detail = xmlEscape(reasons.join("\n") || "step failed");
|
|
581
|
+
const summary = detail.split("\n")[0];
|
|
582
|
+
return `${open}>
|
|
583
|
+
<failure message="${summary}">${detail}</failure>
|
|
584
|
+
</testcase>`;
|
|
585
|
+
});
|
|
586
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
587
|
+
<testsuites name="${suite}" tests="${total}" failures="${failed}" skipped="${skipped}" time="${suiteTime}">
|
|
588
|
+
<testsuite name="${suite}" tests="${total}" failures="${failed}" skipped="${skipped}" time="${suiteTime}">
|
|
589
|
+
${cases.join("\n")}
|
|
590
|
+
</testsuite>
|
|
591
|
+
</testsuites>
|
|
592
|
+
`;
|
|
593
|
+
}
|
|
594
|
+
function fail(message, code = 2, kind = "error") {
|
|
595
|
+
process.stderr.write(`${kleur4.red(kind)}: ${message}
|
|
596
|
+
`);
|
|
597
|
+
process.exitCode = code;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/index.ts
|
|
601
|
+
function buildProgram() {
|
|
602
|
+
const program = new Command();
|
|
603
|
+
program.name("apicircle").description("Command-line companion to APICircle Studio.").version("1.0.0");
|
|
604
|
+
registerMockCommand(program);
|
|
605
|
+
registerMcpCommand(program);
|
|
606
|
+
registerImportCommand(program);
|
|
607
|
+
registerRunCommand(program);
|
|
608
|
+
return program;
|
|
609
|
+
}
|
|
610
|
+
async function runCli(argv = process.argv) {
|
|
611
|
+
await buildProgram().parseAsync(argv);
|
|
612
|
+
}
|
|
613
|
+
var entry = process.argv[1] ?? "";
|
|
614
|
+
if (entry.endsWith("apicircle") || entry.endsWith("index.cjs") || entry.endsWith("index.ts")) {
|
|
615
|
+
void runCli();
|
|
616
|
+
}
|
|
617
|
+
export {
|
|
618
|
+
buildProgram,
|
|
619
|
+
runCli
|
|
620
|
+
};
|
|
621
|
+
//# sourceMappingURL=index.js.map
|