@kirrosh/zond 0.21.0 → 0.22.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +110 -3
  2. package/README.md +26 -15
  3. package/package.json +10 -6
  4. package/src/cli/commands/ci-init.ts +12 -6
  5. package/src/cli/commands/completions.ts +176 -0
  6. package/src/cli/commands/db.ts +2 -1
  7. package/src/cli/commands/generate.ts +0 -1
  8. package/src/cli/commands/init/agents-md.ts +61 -0
  9. package/src/cli/commands/init/bootstrap.ts +79 -0
  10. package/src/cli/commands/init/skills.ts +45 -0
  11. package/src/cli/commands/init/templates/agents.md +73 -0
  12. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  13. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  14. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  15. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  16. package/src/cli/commands/init.ts +124 -31
  17. package/src/cli/commands/probe-methods.ts +108 -0
  18. package/src/cli/commands/probe-validation.ts +124 -0
  19. package/src/cli/commands/run.ts +99 -10
  20. package/src/cli/commands/serve.ts +52 -19
  21. package/src/cli/commands/sync.ts +0 -1
  22. package/src/cli/commands/update.ts +1 -1
  23. package/src/cli/commands/use.ts +57 -0
  24. package/src/cli/index.ts +21 -609
  25. package/src/cli/program.ts +655 -0
  26. package/src/cli/version.ts +3 -0
  27. package/src/core/context/current.ts +35 -0
  28. package/src/core/diagnostics/db-analysis.ts +11 -2
  29. package/src/core/diagnostics/render-md.ts +112 -0
  30. package/src/core/generator/chunker.ts +14 -2
  31. package/src/core/generator/data-factory.ts +50 -19
  32. package/src/core/generator/guide-builder.ts +1 -1
  33. package/src/core/generator/openapi-reader.ts +18 -0
  34. package/src/core/generator/serializer.ts +11 -2
  35. package/src/core/generator/suite-generator.ts +106 -7
  36. package/src/core/meta/types.ts +0 -2
  37. package/src/core/parser/schema.ts +3 -1
  38. package/src/core/parser/types.ts +10 -1
  39. package/src/core/parser/variables.ts +90 -2
  40. package/src/core/parser/yaml-parser.ts +50 -1
  41. package/src/core/probe/method-probe.ts +197 -0
  42. package/src/core/probe/negative-probe.ts +657 -0
  43. package/src/core/reporter/console.ts +29 -3
  44. package/src/core/reporter/index.ts +2 -2
  45. package/src/core/reporter/json.ts +5 -2
  46. package/src/core/runner/assertions.ts +4 -1
  47. package/src/core/runner/executor.ts +132 -37
  48. package/src/core/runner/http-client.ts +40 -5
  49. package/src/core/runner/rate-limiter.ts +131 -0
  50. package/src/core/setup-api.ts +4 -1
  51. package/src/core/workspace/root.ts +94 -0
  52. package/src/db/schema.ts +4 -1
@@ -0,0 +1,655 @@
1
+ import { Command, InvalidArgumentError, Option } from "commander";
2
+
3
+ import { runCommand } from "./commands/run.ts";
4
+ import { validateCommand } from "./commands/validate.ts";
5
+ import { serveCommand } from "./commands/serve.ts";
6
+ import { coverageCommand } from "./commands/coverage.ts";
7
+ import { ciInitCommand } from "./commands/ci-init.ts";
8
+ import { initCommand } from "./commands/init.ts";
9
+ import { describeCommand } from "./commands/describe.ts";
10
+ import { dbCommand } from "./commands/db.ts";
11
+ import { requestCommand } from "./commands/request.ts";
12
+ import { guideCommand } from "./commands/guide.ts";
13
+ import { generateCommand } from "./commands/generate.ts";
14
+ import { probeValidationCommand } from "./commands/probe-validation.ts";
15
+ import { probeMethodsCommand } from "./commands/probe-methods.ts";
16
+ import { exportCommand } from "./commands/export.ts";
17
+ import { syncCommand } from "./commands/sync.ts";
18
+ import { updateCommand } from "./commands/update.ts";
19
+ import { catalogCommand } from "./commands/catalog.ts";
20
+ import { completionsCommand, COMPLETION_SHELLS, type CompletionShell } from "./commands/completions.ts";
21
+ import { useCommand } from "./commands/use.ts";
22
+
23
+ import { readCurrentApi } from "../core/context/current.ts";
24
+ import { printError } from "./output.ts";
25
+ import { getRuntimeInfo } from "./runtime.ts";
26
+ import { VERSION } from "./version.ts";
27
+ import { getDb } from "../db/schema.ts";
28
+ import { findCollectionByNameOrId } from "../db/queries.ts";
29
+ import type { ReporterName } from "../core/reporter/types.ts";
30
+
31
+ // ── MSYS path preprocessing ──
32
+ //
33
+ // Git Bash on Windows converts API paths like "/users" → "C:/Program Files/Git/users".
34
+ // We reverse that for flags whose values are API paths, not filesystem paths.
35
+
36
+ const MSYS_PREFIX_RE = /^[A-Z]:[\\/](?:Program Files[\\/]Git|msys64|usr)[\\/]/i;
37
+
38
+ const API_PATH_FLAGS = new Set(["--path", "--json-path"]);
39
+
40
+ function stripMsysPath(value: string): string {
41
+ if (!MSYS_PREFIX_RE.test(value)) return value;
42
+ return value.replace(MSYS_PREFIX_RE, "/");
43
+ }
44
+
45
+ /**
46
+ * Pre-process argv before commander sees it: undo Git Bash's MSYS path conversion
47
+ * for `--path` and `--json-path` values (both `--path X` and `--path=X` forms).
48
+ */
49
+ export function preprocessArgv(argv: string[]): string[] {
50
+ const out = [...argv];
51
+ for (let i = 0; i < out.length; i++) {
52
+ const arg = out[i]!;
53
+
54
+ // --flag=value form
55
+ const eqIdx = arg.indexOf("=");
56
+ if (arg.startsWith("--") && eqIdx !== -1) {
57
+ const flag = arg.slice(0, eqIdx);
58
+ if (API_PATH_FLAGS.has(flag)) {
59
+ out[i] = `${flag}=${stripMsysPath(arg.slice(eqIdx + 1))}`;
60
+ }
61
+ continue;
62
+ }
63
+
64
+ // --flag value form
65
+ if (API_PATH_FLAGS.has(arg)) {
66
+ const next = out[i + 1];
67
+ if (next !== undefined && !next.startsWith("-")) {
68
+ out[i + 1] = stripMsysPath(next);
69
+ }
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // ── Argument parsers ──
76
+
77
+ function parsePositiveInt(name: string): (raw: string) => number {
78
+ return (raw: string) => {
79
+ const n = Number.parseInt(raw, 10);
80
+ if (Number.isNaN(n) || n <= 0) {
81
+ throw new InvalidArgumentError(`Invalid ${name} value: ${raw}`);
82
+ }
83
+ return n;
84
+ };
85
+ }
86
+
87
+ /** `--rate-limit` accepts a positive integer (req/sec cap) or the literal
88
+ * string `auto` (no static cap; throttle adaptively from ratelimit-* headers). */
89
+ function parseRateLimit(raw: string): number | "auto" {
90
+ if (raw.toLowerCase() === "auto") return "auto";
91
+ const n = Number.parseInt(raw, 10);
92
+ if (Number.isNaN(n) || n <= 0) {
93
+ throw new InvalidArgumentError(`Invalid --rate-limit value: ${raw} (expected a positive integer or "auto")`);
94
+ }
95
+ return n;
96
+ }
97
+
98
+ function parseInteger(name: string): (raw: string) => number {
99
+ return (raw: string) => {
100
+ const n = Number.parseInt(raw, 10);
101
+ if (Number.isNaN(n)) {
102
+ throw new InvalidArgumentError(`Invalid ${name} value: ${raw}`);
103
+ }
104
+ return n;
105
+ };
106
+ }
107
+
108
+ function parsePercentage(raw: string): number {
109
+ const n = Number.parseInt(raw, 10);
110
+ if (Number.isNaN(n) || n < 0 || n > 100) {
111
+ throw new InvalidArgumentError(`Invalid --fail-on-coverage value: ${raw} (must be 0–100)`);
112
+ }
113
+ return n;
114
+ }
115
+
116
+ const collect = (val: string, prev: string[]): string[] => [...prev, val];
117
+
118
+ const VALID_REPORTERS = new Set<string>(["console", "json", "junit"]);
119
+
120
+ function parseReporter(raw: string): ReporterName {
121
+ if (!VALID_REPORTERS.has(raw)) {
122
+ throw new InvalidArgumentError(`Unknown reporter: ${raw}. Available: console, json, junit`);
123
+ }
124
+ return raw as ReporterName;
125
+ }
126
+
127
+ // Helper: split repeatable values like ["a,b", "c"] → ["a", "b", "c"]
128
+ function flatSplit(values: string[] | undefined): string[] | undefined {
129
+ if (!values || values.length === 0) return undefined;
130
+ const out = values.flatMap((v) => v.split(",")).filter(Boolean);
131
+ return out.length > 0 ? out : undefined;
132
+ }
133
+
134
+ // Helper: read a global option from any command in the tree
135
+ function globalJson(cmd: Command): boolean {
136
+ return cmd.optsWithGlobals().json === true;
137
+ }
138
+
139
+ // Resolve API collection → returns { spec?, testPath? } or null when not found
140
+ function resolveApiCollection(apiName: string, dbPath: string | undefined):
141
+ | { spec: string | null; testPath: string | null }
142
+ | { error: string } {
143
+ if (typeof apiName !== "string" || apiName.length === 0) {
144
+ return { error: "Internal: --api received non-string value" };
145
+ }
146
+ try {
147
+ getDb(dbPath);
148
+ const col = findCollectionByNameOrId(apiName);
149
+ if (!col) return { error: `API '${apiName}' not found` };
150
+ return { spec: col.openapi_spec ?? null, testPath: col.test_path ?? null };
151
+ } catch (err) {
152
+ return { error: `Failed to resolve --api: ${(err as Error).message}` };
153
+ }
154
+ }
155
+
156
+ // ── Program builder ──
157
+
158
+ export function buildProgram(): Command {
159
+ const program = new Command("zond")
160
+ .description("API Testing Platform")
161
+ .version(`${VERSION} (${getRuntimeInfo()})`, "-v, --version", "Show version")
162
+ .helpOption("-h, --help", "Show this help")
163
+ .option("--json", "Output in JSON envelope format")
164
+ .showHelpAfterError("(run 'zond --help' for usage)")
165
+ .exitOverride();
166
+
167
+ // ── run ──
168
+ program
169
+ .command("run [path]")
170
+ .description("Run API tests")
171
+ .option("--env <name>", "Use environment file (.env.<name>.yaml)")
172
+ .option("--api <name>", "Use API collection (resolves test path automatically)")
173
+ .addOption(
174
+ new Option("--report <format>", "Output format")
175
+ .choices(["console", "json", "junit"])
176
+ .default("console")
177
+ .argParser(parseReporter),
178
+ )
179
+ .option("--timeout <ms>", "Override request timeout", parsePositiveInt("--timeout"))
180
+ .option("--rate-limit <N|auto>", "Throttle requests to at most N per second, or `auto` to adapt from ratelimit-* response headers", parseRateLimit)
181
+ .option("--bail", "Stop on first suite failure")
182
+ .option("--sequential", "Run regular suites one after another instead of in parallel (opt-out of Promise.all)")
183
+ .option("--no-db", "Do not save results to zond.db")
184
+ .option("--db <path>", "Path to SQLite database file (default: zond.db)")
185
+ .option("--auth-token <token>", "Auth token injected as {{auth_token}} variable")
186
+ .option("--safe", "Run only GET tests (read-only, safe mode)")
187
+ .option("--tag <tag>", "Filter suites by tag (repeatable, comma-separated)", collect, [])
188
+ .option("--exclude-tag <tag>", "Exclude suites by tag (repeatable, comma-separated)", collect, [])
189
+ .option("--method <method>", "Filter tests by HTTP method (e.g. GET, POST)")
190
+ .option("--env-var <KEY=VALUE>", "Inject env variable (repeatable, overrides env file)", collect, [])
191
+ .option("--dry-run", "Show requests without sending them (exit code always 0)")
192
+ .option("--report-out <file>", "Write the report to a file via fs (bypass stdout). Useful when the bun wrapper or other shells contaminate stdout.")
193
+ .action(async (pathArg: string | undefined, opts, cmd: Command) => {
194
+ let path = pathArg;
195
+ const apiFlag = (opts.api as string | undefined) ?? (path ? undefined : readCurrentApi() ?? undefined);
196
+ const dbPath = typeof opts.db === "string" ? opts.db : undefined;
197
+
198
+ if (!path && apiFlag) {
199
+ const resolved = resolveApiCollection(apiFlag, dbPath);
200
+ if ("error" in resolved) {
201
+ printError(resolved.error);
202
+ process.exitCode = resolved.error.startsWith("Failed") ? 2 : 1;
203
+ return;
204
+ }
205
+ if (!resolved.testPath) {
206
+ printError(`API '${apiFlag}' has no test_path`);
207
+ process.exitCode = 1;
208
+ return;
209
+ }
210
+ path = resolved.testPath;
211
+ }
212
+ if (!path) {
213
+ printError("No path given and .zond-current not set; run `zond use <api>` or pass path explicitly (or use --api <name>)");
214
+ process.exitCode = 2;
215
+ return;
216
+ }
217
+
218
+ const tags = flatSplit(opts.tag);
219
+ const excludeTags = flatSplit(opts.excludeTag);
220
+ const envVars = (opts.envVar as string[] | undefined)?.length ? (opts.envVar as string[]) : undefined;
221
+
222
+ process.exitCode = await runCommand({
223
+ path,
224
+ env: opts.env,
225
+ report: opts.report as ReporterName,
226
+ timeout: opts.timeout,
227
+ rateLimit: opts.rateLimit,
228
+ bail: opts.bail === true,
229
+ sequential: opts.sequential === true,
230
+ // Commander's `--no-db` produces { db: false }; keep semantics: when --no-db given → noDb=true
231
+ noDb: opts.db === false,
232
+ dbPath: typeof opts.db === "string" ? opts.db : undefined,
233
+ authToken: opts.authToken,
234
+ safe: opts.safe === true,
235
+ tag: tags,
236
+ excludeTag: excludeTags,
237
+ method: opts.method,
238
+ envVars,
239
+ dryRun: opts.dryRun === true,
240
+ reportOut: typeof opts.reportOut === "string" ? opts.reportOut : undefined,
241
+ json: globalJson(cmd),
242
+ });
243
+ });
244
+
245
+ // ── validate ──
246
+ program
247
+ .command("validate <path>")
248
+ .description("Validate test files without running")
249
+ .action(async (path: string, _opts, cmd: Command) => {
250
+ process.exitCode = await validateCommand({ path, json: globalJson(cmd) });
251
+ });
252
+
253
+ // ── serve ──
254
+ program
255
+ .command("serve")
256
+ .description("Start web dashboard")
257
+ .option("--port <port>", "Server port (default: 8080)", parsePositiveInt("--port"))
258
+ .option("--host <host>", "Server host (default: 0.0.0.0)")
259
+ .option("--db <path>", "Path to SQLite database file (default: zond.db)")
260
+ .option("--open", "Open dashboard in browser after starting")
261
+ .option("--watch", "Enable dev mode with hot reload (auto-refresh on file changes)")
262
+ .option("--kill-existing", "Kill any process holding the requested port (DANGEROUS — can terminate your dev server)")
263
+ .action(async (opts) => {
264
+ process.exitCode = await serveCommand({
265
+ port: opts.port,
266
+ host: opts.host,
267
+ dbPath: opts.db,
268
+ watch: opts.watch === true,
269
+ open: opts.open === true,
270
+ killExisting: opts.killExisting === true,
271
+ });
272
+ });
273
+
274
+ // ── ci ──
275
+ const ci = program.command("ci").description("CI/CD scaffolding");
276
+ ci
277
+ .command("init")
278
+ .description("Generate CI/CD workflow (GitHub Actions, GitLab CI)")
279
+ .option("--github", "Generate GitHub Actions workflow")
280
+ .option("--gitlab", "Generate GitLab CI config")
281
+ .option("--dir <path>", "Project root directory (default: current directory)")
282
+ .option("--force", "Overwrite existing CI config")
283
+ .action(async (opts, cmd: Command) => {
284
+ let platform: "github" | "gitlab" | undefined;
285
+ if (opts.github === true) platform = "github";
286
+ else if (opts.gitlab === true) platform = "gitlab";
287
+ process.exitCode = await ciInitCommand({
288
+ platform,
289
+ force: opts.force === true,
290
+ dir: opts.dir,
291
+ json: globalJson(cmd),
292
+ });
293
+ });
294
+
295
+ // ── use ──
296
+ program
297
+ .command("use [api]")
298
+ .description("Set or show the current API for this workspace (.zond-current)")
299
+ .option("--clear", "Remove .zond-current from the current directory")
300
+ .action(async (api: string | undefined, opts, cmd: Command) => {
301
+ process.exitCode = await useCommand({
302
+ api,
303
+ clear: opts.clear === true,
304
+ json: globalJson(cmd),
305
+ });
306
+ });
307
+
308
+ // ── coverage ──
309
+ program
310
+ .command("coverage")
311
+ .description("Analyze API test coverage")
312
+ .option("--api <name>", "Use API collection (auto-resolves spec and tests dir)")
313
+ .option("--spec <path>", "Path to OpenAPI spec (required unless --api used)")
314
+ .option("--tests <dir>", "Path to test files directory (required unless --api used)")
315
+ .option("--fail-on-coverage <N>", "Exit 1 when coverage percentage is below N (0–100)", parsePercentage)
316
+ .option("--run-id <number>", "Cross-reference with a test run for pass/fail/5xx breakdown", parseInteger("--run-id"))
317
+ .option("--db <path>", "Path to SQLite database file")
318
+ .action(async (opts, cmd: Command) => {
319
+ let spec: string | undefined = opts.spec;
320
+ let tests: string | undefined = opts.tests;
321
+ const apiFlag = (opts.api as string | undefined) ?? (spec || tests ? undefined : readCurrentApi() ?? undefined);
322
+
323
+ if (apiFlag) {
324
+ const resolved = resolveApiCollection(apiFlag, opts.db);
325
+ if ("error" in resolved) {
326
+ printError(resolved.error);
327
+ process.exitCode = resolved.error.startsWith("Failed") ? 2 : 1;
328
+ return;
329
+ }
330
+ if (!spec && resolved.spec) spec = resolved.spec;
331
+ if (!tests && resolved.testPath) tests = resolved.testPath;
332
+ }
333
+ if (!spec) {
334
+ printError("Missing --spec <path>. Usage: zond coverage --spec <path> --tests <dir>");
335
+ process.exitCode = 2;
336
+ return;
337
+ }
338
+ if (!tests) {
339
+ printError("Missing --tests <dir>. Usage: zond coverage --spec <path> --tests <dir>");
340
+ process.exitCode = 2;
341
+ return;
342
+ }
343
+ process.exitCode = await coverageCommand({
344
+ spec,
345
+ tests,
346
+ failOnCoverage: opts.failOnCoverage,
347
+ runId: opts.runId,
348
+ json: globalJson(cmd),
349
+ });
350
+ });
351
+
352
+ // ── init ──
353
+ program
354
+ .command("init [spec]")
355
+ .description("Bootstrap a workspace, or register an API when --spec is given")
356
+ .option("--name <name>", "API name (auto-detected from spec title if omitted)")
357
+ .option("--spec <path>", "Path to OpenAPI spec file (registers a single API)")
358
+ .option("--base-url <url>", "Override base URL")
359
+ .option("--dir <path>", "Target directory")
360
+ .option("--force", "Overwrite existing API collection")
361
+ .option("--insecure", "Skip TLS verification when fetching the spec")
362
+ .option("--db <path>", "Path to SQLite database file")
363
+ .option("--workspace", "Bootstrap a zond workspace (zond.config.yml, apis/, AGENTS.md)")
364
+ .option("--with-spec <path>", "Bootstrap workspace AND register first API from spec")
365
+ .option("--no-agents-md", "Skip writing AGENTS.md when bootstrapping")
366
+ .option("--no-skills", "Skip writing Claude Code skills under .claude/skills/")
367
+ .action(async (specPos: string | undefined, opts, cmd: Command) => {
368
+ process.exitCode = await initCommand({
369
+ name: opts.name,
370
+ spec: opts.spec ?? specPos,
371
+ baseUrl: opts.baseUrl,
372
+ dir: opts.dir,
373
+ force: opts.force === true,
374
+ insecure: opts.insecure === true,
375
+ dbPath: opts.db,
376
+ workspace: opts.workspace === true,
377
+ withSpec: opts.withSpec,
378
+ noAgents: opts.agentsMd === false,
379
+ noSkills: opts.skills === false,
380
+ json: globalJson(cmd),
381
+ });
382
+ });
383
+
384
+ // ── describe ──
385
+ program
386
+ .command("describe <spec>")
387
+ .description("Describe endpoints from OpenAPI spec")
388
+ .option("--compact", "List all endpoints briefly")
389
+ .option("--list-params", "List all unique parameters across all endpoints")
390
+ .option("--method <method>", "HTTP method for single endpoint detail")
391
+ .option("--path <path>", "Endpoint path for single endpoint detail")
392
+ .action(async (specPath: string, opts, cmd: Command) => {
393
+ process.exitCode = await describeCommand({
394
+ specPath,
395
+ compact: opts.compact === true,
396
+ listParams: opts.listParams === true,
397
+ method: opts.method,
398
+ path: opts.path,
399
+ json: globalJson(cmd),
400
+ });
401
+ });
402
+
403
+ // ── db (nested) ──
404
+ const db = program.command("db").description("Query the test database");
405
+
406
+ db
407
+ .command("collections")
408
+ .description("List all API collections")
409
+ .option("--db <path>", "Path to SQLite database file")
410
+ .action(async (opts, cmd: Command) => {
411
+ process.exitCode = await dbCommand({
412
+ subcommand: "collections",
413
+ positional: [],
414
+ dbPath: opts.db,
415
+ json: globalJson(cmd),
416
+ });
417
+ });
418
+
419
+ db
420
+ .command("runs")
421
+ .description("List recent test runs")
422
+ .option("--limit <N>", "Maximum number of runs to display", parsePositiveInt("--limit"))
423
+ .option("--db <path>", "Path to SQLite database file")
424
+ .action(async (opts, cmd: Command) => {
425
+ process.exitCode = await dbCommand({
426
+ subcommand: "runs",
427
+ positional: [],
428
+ limit: opts.limit,
429
+ dbPath: opts.db,
430
+ json: globalJson(cmd),
431
+ });
432
+ });
433
+
434
+ db
435
+ .command("run <id>")
436
+ .description("Show run details")
437
+ .option("--verbose", "Show all results")
438
+ .option("--method <method>", "Filter by HTTP method")
439
+ .option("--status <code>", "Filter by HTTP status code", parseInteger("--status"))
440
+ .option("--db <path>", "Path to SQLite database file")
441
+ .action(async (id: string, opts, cmd: Command) => {
442
+ process.exitCode = await dbCommand({
443
+ subcommand: "run",
444
+ positional: [id],
445
+ verbose: opts.verbose === true,
446
+ method: opts.method,
447
+ status: opts.status,
448
+ dbPath: opts.db,
449
+ json: globalJson(cmd),
450
+ });
451
+ });
452
+
453
+ db
454
+ .command("diagnose <id>")
455
+ .description("Diagnose run failures")
456
+ .option("--limit <N>", "Examples per failure group", parsePositiveInt("--limit"))
457
+ .option("--verbose", "Show all examples (not grouped)")
458
+ .option("--db <path>", "Path to SQLite database file")
459
+ .action(async (id: string, opts, cmd: Command) => {
460
+ process.exitCode = await dbCommand({
461
+ subcommand: "diagnose",
462
+ positional: [id],
463
+ limit: opts.limit,
464
+ verbose: opts.verbose === true,
465
+ dbPath: opts.db,
466
+ json: globalJson(cmd),
467
+ });
468
+ });
469
+
470
+ db
471
+ .command("compare <idA> <idB>")
472
+ .description("Compare two runs")
473
+ .option("--db <path>", "Path to SQLite database file")
474
+ .action(async (idA: string, idB: string, opts, cmd: Command) => {
475
+ process.exitCode = await dbCommand({
476
+ subcommand: "compare",
477
+ positional: [idA, idB],
478
+ dbPath: opts.db,
479
+ json: globalJson(cmd),
480
+ });
481
+ });
482
+
483
+ // ── request ──
484
+ program
485
+ .command("request <method> <url>")
486
+ .description("Send an ad-hoc HTTP request")
487
+ .option("--header <H>", `Request header "Name: Value" (repeatable)`, collect, [])
488
+ .option("--body <json>", "Request body (JSON string)")
489
+ .option("--timeout <ms>", "Request timeout", parsePositiveInt("--timeout"))
490
+ .option("--env <name>", "Environment for variable interpolation")
491
+ .option("--api <name>", "Collection name (loads env from its directory)")
492
+ .option("--json-path <path>", "Extract value from response (dot notation)")
493
+ .option("--db <path>", "Path to SQLite database file")
494
+ .action(async (method: string, url: string, opts, cmd: Command) => {
495
+ const headers = (opts.header as string[] | undefined)?.length ? (opts.header as string[]) : undefined;
496
+ const api = (opts.api as string | undefined) ?? readCurrentApi() ?? undefined;
497
+ process.exitCode = await requestCommand({
498
+ method,
499
+ url,
500
+ headers,
501
+ body: opts.body,
502
+ timeout: opts.timeout,
503
+ env: opts.env,
504
+ api,
505
+ jsonPath: opts.jsonPath,
506
+ dbPath: opts.db,
507
+ json: globalJson(cmd),
508
+ });
509
+ });
510
+
511
+ // ── generate ──
512
+ program
513
+ .command("generate <spec>")
514
+ .description("Generate test suites from OpenAPI spec")
515
+ .requiredOption("--output <dir>", "Output directory for generated test files")
516
+ .option("--tag <tag>", "Generate only for endpoints with this tag")
517
+ .option("--uncovered-only", "Skip endpoints already covered by existing tests")
518
+ .action(async (specPath: string, opts, cmd: Command) => {
519
+ process.exitCode = await generateCommand({
520
+ specPath,
521
+ output: opts.output,
522
+ tag: opts.tag,
523
+ uncoveredOnly: opts.uncoveredOnly === true,
524
+ json: globalJson(cmd),
525
+ });
526
+ });
527
+
528
+ // ── probe-validation ──
529
+ program
530
+ .command("probe-validation <spec>")
531
+ .description("Generate negative-input probe suites (catches 5xx-on-bad-input bugs)")
532
+ .requiredOption("--output <dir>", "Output directory for generated probe files")
533
+ .option("--tag <tag>", "Probe only endpoints with this tag")
534
+ .option("--list-tags", "List available tags from spec and exit")
535
+ .option("--max-per-endpoint <N>", "Cap probes per endpoint (default 50)", parsePositiveInt("--max-per-endpoint"))
536
+ .option("--no-cleanup", "Skip emission of follow-up DELETE cleanup steps for mutating probes (use in namespace-isolated test envs)")
537
+ .action(async (specPath: string, opts, cmd: Command) => {
538
+ process.exitCode = await probeValidationCommand({
539
+ specPath,
540
+ output: opts.output,
541
+ tag: opts.tag,
542
+ maxPerEndpoint: opts.maxPerEndpoint,
543
+ // Commander: --no-cleanup → opts.cleanup === false; default is true.
544
+ noCleanup: opts.cleanup === false,
545
+ json: globalJson(cmd),
546
+ listTags: opts.listTags,
547
+ });
548
+ });
549
+
550
+ // ── probe-methods ──
551
+ program
552
+ .command("probe-methods <spec>")
553
+ .description("Generate negative-method probe suites (catches 5xx/2xx on undeclared HTTP methods)")
554
+ .requiredOption("--output <dir>", "Output directory for generated probe files")
555
+ .option("--tag <tag>", "Probe only endpoints with this tag")
556
+ .action(async (specPath: string, opts, cmd: Command) => {
557
+ process.exitCode = await probeMethodsCommand({
558
+ specPath,
559
+ output: opts.output,
560
+ tag: opts.tag,
561
+ json: globalJson(cmd),
562
+ });
563
+ });
564
+
565
+ // ── catalog ──
566
+ program
567
+ .command("catalog <spec>")
568
+ .description("Generate API catalog (compact endpoint reference)")
569
+ .option("--output <dir>", "Output directory (default: current directory)")
570
+ .action(async (specPath: string, opts, cmd: Command) => {
571
+ process.exitCode = await catalogCommand({
572
+ specPath,
573
+ output: opts.output,
574
+ json: globalJson(cmd),
575
+ });
576
+ });
577
+
578
+ // ── guide ──
579
+ program
580
+ .command("guide <spec>")
581
+ .description("Generate test generation guide from OpenAPI spec")
582
+ .option("--tests-dir <dir>", "Filter to uncovered endpoints only")
583
+ .option("--tag <tag>", "Generate only for endpoints with this tag")
584
+ .action(async (specPath: string, opts, cmd: Command) => {
585
+ process.exitCode = await guideCommand({
586
+ specPath,
587
+ testsDir: opts.testsDir,
588
+ tag: opts.tag,
589
+ json: globalJson(cmd),
590
+ });
591
+ });
592
+
593
+ // ── export (with subcommand: postman) ──
594
+ const exportCmd = program.command("export").description("Export tests to other formats");
595
+ exportCmd
596
+ .command("postman <path>")
597
+ .description("Export YAML tests as Postman Collection v2.1")
598
+ .option("--output <file>", "Output file path", "collection.postman.json")
599
+ .option("--env <file>", "Also export .env.yaml as Postman environment")
600
+ .option("--collection-name <name>", "Collection name (default: derived from path)")
601
+ .action(async (testsPath: string, opts, cmd: Command) => {
602
+ process.exitCode = await exportCommand({
603
+ testsPath,
604
+ output: opts.output,
605
+ env: opts.env,
606
+ collectionName: opts.collectionName,
607
+ json: globalJson(cmd),
608
+ });
609
+ });
610
+
611
+ // ── update / self-update ──
612
+ program
613
+ .command("update")
614
+ .alias("self-update")
615
+ .description("Check for updates and self-update the binary")
616
+ .option("--check", "Only check for updates, do not download")
617
+ .action(async (opts, cmd: Command) => {
618
+ process.exitCode = await updateCommand({
619
+ check: opts.check === true,
620
+ json: globalJson(cmd),
621
+ });
622
+ });
623
+
624
+ // ── sync ──
625
+ program
626
+ .command("sync <spec>")
627
+ .description("Detect new/removed endpoints and generate tests for new ones")
628
+ .requiredOption("--tests <dir>", "Path to test files directory")
629
+ .option("--dry-run", "Show what would be generated without writing files")
630
+ .option("--tag <tag>", "Limit sync to endpoints with this tag")
631
+ .action(async (specPath: string, opts, cmd: Command) => {
632
+ process.exitCode = await syncCommand({
633
+ specPath,
634
+ testsDir: opts.tests,
635
+ dryRun: opts.dryRun === true,
636
+ tag: opts.tag,
637
+ json: globalJson(cmd),
638
+ });
639
+ });
640
+
641
+ // ── completions ──
642
+ program
643
+ .command("completions <shell>")
644
+ .description(`Generate shell completion script (${COMPLETION_SHELLS.join(", ")})`)
645
+ .action((shell: string) => {
646
+ if (!(COMPLETION_SHELLS as readonly string[]).includes(shell)) {
647
+ printError(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(", ")}`);
648
+ process.exitCode = 2;
649
+ return;
650
+ }
651
+ process.exitCode = completionsCommand({ shell: shell as CompletionShell, program });
652
+ });
653
+
654
+ return program;
655
+ }
@@ -0,0 +1,3 @@
1
+ import { version } from "../../package.json";
2
+
3
+ export const VERSION = version;
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { findWorkspaceRoot } from "../workspace/root.ts";
4
+
5
+ const FILENAME = ".zond-current";
6
+
7
+ export function currentApiPath(cwd?: string): string {
8
+ const base = cwd ?? findWorkspaceRoot().root;
9
+ return join(base, FILENAME);
10
+ }
11
+
12
+ /** Returns the API collection name stored in `.zond-current`, or null when the file is absent or empty. */
13
+ export function readCurrentApi(cwd?: string): string | null {
14
+ const path = currentApiPath(cwd);
15
+ if (!existsSync(path)) return null;
16
+ const raw = readFileSync(path, "utf-8").trim();
17
+ return raw.length > 0 ? raw : null;
18
+ }
19
+
20
+ /** Writes the API collection name to `.zond-current`. The file is single-line plain text. */
21
+ export function writeCurrentApi(name: string, cwd?: string): string {
22
+ const trimmed = name.trim();
23
+ if (!trimmed) throw new Error("API name cannot be empty");
24
+ const path = currentApiPath(cwd);
25
+ writeFileSync(path, trimmed + "\n", "utf-8");
26
+ return path;
27
+ }
28
+
29
+ /** Deletes `.zond-current`. Returns true when a file was removed, false when it did not exist. */
30
+ export function clearCurrentApi(cwd?: string): boolean {
31
+ const path = currentApiPath(cwd);
32
+ if (!existsSync(path)) return false;
33
+ unlinkSync(path);
34
+ return true;
35
+ }