@kirrosh/zond 0.20.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 (59) 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/catalog.ts +62 -0
  5. package/src/cli/commands/ci-init.ts +12 -6
  6. package/src/cli/commands/completions.ts +176 -0
  7. package/src/cli/commands/db.ts +2 -1
  8. package/src/cli/commands/generate.ts +18 -2
  9. package/src/cli/commands/init/agents-md.ts +61 -0
  10. package/src/cli/commands/init/bootstrap.ts +79 -0
  11. package/src/cli/commands/init/skills.ts +45 -0
  12. package/src/cli/commands/init/templates/agents.md +73 -0
  13. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  14. package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
  15. package/src/cli/commands/init/templates/skills/zond.md +184 -0
  16. package/src/cli/commands/init/templates/zond-config.yml +15 -0
  17. package/src/cli/commands/init.ts +124 -31
  18. package/src/cli/commands/probe-methods.ts +108 -0
  19. package/src/cli/commands/probe-validation.ts +124 -0
  20. package/src/cli/commands/run.ts +99 -10
  21. package/src/cli/commands/serve.ts +52 -19
  22. package/src/cli/commands/sync.ts +28 -1
  23. package/src/cli/commands/update.ts +1 -1
  24. package/src/cli/commands/use.ts +57 -0
  25. package/src/cli/index.ts +21 -591
  26. package/src/cli/program.ts +655 -0
  27. package/src/cli/version.ts +3 -0
  28. package/src/core/context/current.ts +35 -0
  29. package/src/core/diagnostics/db-analysis.ts +11 -2
  30. package/src/core/diagnostics/render-md.ts +112 -0
  31. package/src/core/generator/catalog-builder.ts +179 -0
  32. package/src/core/generator/chunker.ts +14 -2
  33. package/src/core/generator/data-factory.ts +50 -19
  34. package/src/core/generator/guide-builder.ts +1 -1
  35. package/src/core/generator/index.ts +2 -0
  36. package/src/core/generator/openapi-reader.ts +18 -0
  37. package/src/core/generator/serializer.ts +11 -2
  38. package/src/core/generator/suite-generator.ts +106 -7
  39. package/src/core/meta/types.ts +0 -2
  40. package/src/core/parser/schema.ts +3 -1
  41. package/src/core/parser/types.ts +10 -1
  42. package/src/core/parser/variables.ts +90 -2
  43. package/src/core/parser/yaml-parser.ts +50 -1
  44. package/src/core/probe/method-probe.ts +197 -0
  45. package/src/core/probe/negative-probe.ts +657 -0
  46. package/src/core/reporter/console.ts +29 -3
  47. package/src/core/reporter/index.ts +2 -2
  48. package/src/core/reporter/json.ts +5 -2
  49. package/src/core/runner/assertions.ts +4 -1
  50. package/src/core/runner/executor.ts +132 -37
  51. package/src/core/runner/http-client.ts +40 -5
  52. package/src/core/runner/rate-limiter.ts +131 -0
  53. package/src/core/setup-api.ts +4 -1
  54. package/src/core/workspace/root.ts +94 -0
  55. package/src/db/schema.ts +4 -1
  56. package/src/web/routes/api.ts +80 -0
  57. package/src/web/routes/dashboard.ts +15 -0
  58. package/src/web/static/style.css +290 -0
  59. package/src/web/views/explorer-tab.ts +402 -0
@@ -0,0 +1,108 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ serializeSuite,
8
+ } from "../../core/generator/index.ts";
9
+ import { filterByTag } from "../../core/generator/chunker.ts";
10
+ import { generateMethodProbes } from "../../core/probe/method-probe.ts";
11
+ import { printError, printSuccess } from "../output.ts";
12
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
13
+
14
+ export interface ProbeMethodsOptions {
15
+ specPath: string;
16
+ output: string;
17
+ tag?: string;
18
+ json?: boolean;
19
+ }
20
+
21
+ export async function probeMethodsCommand(
22
+ options: ProbeMethodsOptions,
23
+ ): Promise<number> {
24
+ try {
25
+ const doc = await readOpenApiSpec(options.specPath);
26
+ let endpoints = extractEndpoints(doc);
27
+ const securitySchemes = extractSecuritySchemes(doc);
28
+
29
+ if (options.tag) endpoints = filterByTag(endpoints, options.tag);
30
+
31
+ if (endpoints.length === 0) {
32
+ const message = "No endpoints to probe.";
33
+ if (options.json) {
34
+ printJson(jsonOk("probe-methods", { files: [], message }));
35
+ } else {
36
+ console.log(message);
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ const result = generateMethodProbes({
42
+ endpoints,
43
+ securitySchemes,
44
+ });
45
+
46
+ if (result.suites.length === 0) {
47
+ const message =
48
+ "Every path declares all of GET/POST/PUT/PATCH/DELETE — nothing to probe.";
49
+ if (options.json) {
50
+ printJson(
51
+ jsonOk("probe-methods", {
52
+ files: [],
53
+ probedPaths: 0,
54
+ skippedPaths: result.skippedPaths,
55
+ totalProbes: 0,
56
+ message,
57
+ }),
58
+ );
59
+ } else {
60
+ console.log(message);
61
+ }
62
+ return 0;
63
+ }
64
+
65
+ await mkdir(options.output, { recursive: true });
66
+
67
+ const created: Array<{ file: string; suite: string; tests: number }> = [];
68
+ for (const suite of result.suites) {
69
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
70
+ const filePath = join(options.output, fileName);
71
+ await Bun.write(filePath, serializeSuite(suite));
72
+ created.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
73
+ }
74
+
75
+ if (options.json) {
76
+ printJson(
77
+ jsonOk("probe-methods", {
78
+ files: created,
79
+ probedPaths: result.probedPaths,
80
+ skippedPaths: result.skippedPaths,
81
+ totalProbes: result.totalProbes,
82
+ outputDir: options.output,
83
+ }),
84
+ );
85
+ } else {
86
+ printSuccess(
87
+ `Generated ${result.suites.length} method-probe suite(s) with ${result.totalProbes} probe(s) in ${options.output}`,
88
+ );
89
+ console.log(
90
+ ` ${result.probedPaths} path(s) probed, ${result.skippedPaths} skipped (full method coverage)`,
91
+ );
92
+ console.log("");
93
+ console.log("Next steps:");
94
+ console.log(` zond run ${options.output} --report json # any 5xx or 2xx → bug candidate`);
95
+ console.log(` zond db diagnose <run-id> # inspect failures`);
96
+ }
97
+
98
+ return 0;
99
+ } catch (err) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ if (options.json) {
102
+ printJson(jsonError("probe-methods", [message]));
103
+ } else {
104
+ printError(message);
105
+ }
106
+ return 2;
107
+ }
108
+ }
@@ -0,0 +1,124 @@
1
+ import { join } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ serializeSuite,
8
+ } from "../../core/generator/index.ts";
9
+ import { filterByTag, collectTags } from "../../core/generator/chunker.ts";
10
+ import { generateNegativeProbes } from "../../core/probe/negative-probe.ts";
11
+ import { printError, printSuccess, printWarning } from "../output.ts";
12
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
13
+
14
+ export interface ProbeValidationOptions {
15
+ specPath: string;
16
+ output: string;
17
+ tag?: string;
18
+ maxPerEndpoint?: number;
19
+ noCleanup?: boolean;
20
+ json?: boolean;
21
+ listTags?: boolean;
22
+ }
23
+
24
+ export async function probeValidationCommand(
25
+ options: ProbeValidationOptions,
26
+ ): Promise<number> {
27
+ try {
28
+ const doc = await readOpenApiSpec(options.specPath);
29
+ const allEndpoints = extractEndpoints(doc);
30
+ const securitySchemes = extractSecuritySchemes(doc);
31
+
32
+ if (options.listTags) {
33
+ const tags = collectTags(allEndpoints);
34
+ if (options.json) {
35
+ printJson(jsonOk("probe-validation", { tags }));
36
+ } else {
37
+ if (tags.length === 0) {
38
+ console.log("No tags found in spec.");
39
+ } else {
40
+ console.log("Available tags:");
41
+ for (const t of tags) console.log(` - ${t}`);
42
+ }
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ let endpoints = allEndpoints;
48
+ if (options.tag) {
49
+ endpoints = filterByTag(allEndpoints, options.tag);
50
+ if (endpoints.length === 0) {
51
+ const available = collectTags(allEndpoints);
52
+ const msg = `No endpoints tagged "${options.tag}". Available tags: ${available.length ? available.join(", ") : "(none)"}`;
53
+ if (options.json) {
54
+ printJson(jsonError("probe-validation", [msg]));
55
+ } else {
56
+ printWarning(msg);
57
+ }
58
+ return 2;
59
+ }
60
+ }
61
+
62
+ if (endpoints.length === 0) {
63
+ const message = "No endpoints to probe.";
64
+ if (options.json) {
65
+ printJson(jsonOk("probe-validation", { files: [], message }));
66
+ } else {
67
+ console.log(message);
68
+ }
69
+ return 0;
70
+ }
71
+
72
+ const result = generateNegativeProbes({
73
+ endpoints,
74
+ securitySchemes,
75
+ maxProbesPerEndpoint: options.maxPerEndpoint,
76
+ noCleanup: options.noCleanup,
77
+ });
78
+
79
+ await mkdir(options.output, { recursive: true });
80
+
81
+ const created: Array<{ file: string; suite: string; tests: number }> = [];
82
+ for (const suite of result.suites) {
83
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
84
+ const filePath = join(options.output, fileName);
85
+ await Bun.write(filePath, serializeSuite(suite));
86
+ created.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
87
+ }
88
+
89
+ if (options.json) {
90
+ printJson(
91
+ jsonOk("probe-validation", {
92
+ files: created,
93
+ probedEndpoints: result.probedEndpoints,
94
+ skippedEndpoints: result.skippedEndpoints,
95
+ totalProbes: result.totalProbes,
96
+ outputDir: options.output,
97
+ warnings: result.warnings,
98
+ }),
99
+ );
100
+ } else {
101
+ printSuccess(
102
+ `Generated ${result.suites.length} probe suite(s) with ${result.totalProbes} probe(s) in ${options.output}`,
103
+ );
104
+ console.log(
105
+ ` ${result.probedEndpoints} endpoint(s) probed, ${result.skippedEndpoints} skipped (no probable surface)`,
106
+ );
107
+ for (const w of result.warnings) printWarning(w);
108
+ console.log("");
109
+ console.log("Next steps:");
110
+ console.log(` zond run ${options.output} --report json # any 5xx → bug candidate`);
111
+ console.log(` zond db diagnose <run-id> # inspect failures`);
112
+ }
113
+
114
+ return 0;
115
+ } catch (err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ if (options.json) {
118
+ printJson(jsonError("probe-validation", [message]));
119
+ } else {
120
+ printError(message);
121
+ }
122
+ return 2;
123
+ }
124
+ }
@@ -1,11 +1,14 @@
1
1
  import { dirname } from "path";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { parse } from "../../core/parser/yaml-parser.ts";
4
- import { loadEnvironment } from "../../core/parser/variables.ts";
4
+ import { loadEnvironment, loadEnvMeta, loadEnvFile } from "../../core/parser/variables.ts";
5
5
  import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
6
6
  import { runSuite } from "../../core/runner/executor.ts";
7
- import { getReporter } from "../../core/reporter/index.ts";
7
+ import { createRateLimiter, createAdaptiveRateLimiter } from "../../core/runner/rate-limiter.ts";
8
+ import { getReporter, generateJsonReport, generateJunitXml } from "../../core/reporter/index.ts";
8
9
  import type { ReporterName } from "../../core/reporter/types.ts";
10
+ import { writeFile, mkdir } from "node:fs/promises";
11
+ import { dirname as pathDirname, isAbsolute, resolve as pathResolve } from "node:path";
9
12
  import type { TestSuite } from "../../core/parser/types.ts";
10
13
  import type { TestRunResult } from "../../core/runner/types.ts";
11
14
  import { printError, printWarning } from "../output.ts";
@@ -19,7 +22,10 @@ export interface RunOptions {
19
22
  env?: string;
20
23
  report: ReporterName;
21
24
  timeout?: number;
25
+ rateLimit?: number | "auto";
22
26
  bail: boolean;
27
+ /** Run regular suites sequentially (one after another) instead of in parallel. */
28
+ sequential?: boolean;
23
29
  noDb?: boolean;
24
30
  dbPath?: string;
25
31
  authToken?: string;
@@ -30,6 +36,8 @@ export interface RunOptions {
30
36
  envVars?: string[];
31
37
  dryRun?: boolean;
32
38
  json?: boolean;
39
+ /** Write the report to a file instead of stdout. */
40
+ reportOut?: string;
33
41
  }
34
42
 
35
43
  export async function runCommand(options: RunOptions): Promise<number> {
@@ -110,6 +118,30 @@ export async function runCommand(options: RunOptions): Promise<number> {
110
118
  return 2;
111
119
  }
112
120
 
121
+ // Auto-load ./.env.yaml from cwd when --env not given and the searchDir env
122
+ // file produced nothing. Useful when running with absolute test paths from
123
+ // a collection cwd (e.g. APPLY agents in the auto-loop).
124
+ if (!options.env && Object.keys(env).length === 0) {
125
+ const cwd = process.cwd();
126
+ const cwdEnvPath = `${cwd}/.env.yaml`;
127
+ // Avoid double-load if cwd is already covered by searchDir or its parent
128
+ const alreadyCovered = cwd === searchDir || cwd === dirname(searchDir);
129
+ if (!alreadyCovered) {
130
+ try {
131
+ const cwdVars = await loadEnvFile(cwdEnvPath);
132
+ if (cwdVars && Object.keys(cwdVars).length > 0) {
133
+ env = { ...cwdVars };
134
+ if (!options.json) {
135
+ process.stderr.write(`zond: using ./.env.yaml (cwd fallback)\n`);
136
+ }
137
+ }
138
+ } catch (err) {
139
+ printError(`Failed to load environment: ${(err as Error).message}`);
140
+ return 2;
141
+ }
142
+ }
143
+ }
144
+
113
145
  // Inject CLI auth token — overrides env file value
114
146
  if (options.authToken) {
115
147
  env.auth_token = options.authToken;
@@ -137,6 +169,19 @@ export async function runCommand(options: RunOptions): Promise<number> {
137
169
  }
138
170
  }
139
171
 
172
+ // 3b. Resolve rate limit: CLI flag > .env.yaml `rateLimit:` field
173
+ let rateLimit: number | "auto" | undefined = options.rateLimit;
174
+ if (rateLimit === undefined) {
175
+ try {
176
+ const envMeta = await loadEnvMeta(options.env, searchDir);
177
+ rateLimit = envMeta.rateLimit;
178
+ } catch { /* meta load failure is non-fatal */ }
179
+ }
180
+ const rateLimiter = rateLimit === "auto"
181
+ ? createAdaptiveRateLimiter()
182
+ : createRateLimiter(rateLimit);
183
+ const runOpts = { rateLimiter };
184
+
140
185
  // 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
141
186
  const results: TestRunResult[] = [];
142
187
  const dryRun = options.dryRun === true;
@@ -146,7 +191,7 @@ export async function runCommand(options: RunOptions): Promise<number> {
146
191
  const setupCaptures: Record<string, string> = {};
147
192
 
148
193
  for (const suite of setupSuites) {
149
- const result = await runSuite(suite, env, dryRun);
194
+ const result = await runSuite(suite, env, dryRun, runOpts);
150
195
  results.push(result);
151
196
  for (const step of result.steps) {
152
197
  for (const [k, v] of Object.entries(step.captures)) {
@@ -160,15 +205,21 @@ export async function runCommand(options: RunOptions): Promise<number> {
160
205
  if (options.bail) {
161
206
  // Sequential with bail at suite level
162
207
  for (const suite of regularSuites) {
163
- const result = await runSuite(suite, enrichedEnv, dryRun);
208
+ const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
164
209
  results.push(result);
165
210
  if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
166
211
  break;
167
212
  }
168
213
  }
214
+ } else if (options.sequential) {
215
+ // Sequential without bail — run suites one by one
216
+ for (const suite of regularSuites) {
217
+ const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
218
+ results.push(result);
219
+ }
169
220
  } else {
170
221
  // Parallel
171
- const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
222
+ const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun, runOpts)));
172
223
  results.push(...all);
173
224
  }
174
225
 
@@ -182,10 +233,45 @@ export async function runCommand(options: RunOptions): Promise<number> {
182
233
 
183
234
  // 5b. Report
184
235
  if (!options.json) {
185
- const reporter = getReporter(options.report);
186
- reporter.report(results);
187
- for (const w of warnings) {
188
- printWarning(w);
236
+ if (options.reportOut) {
237
+ // Write report to a file via fs (bypass stdout). Console reporter falls
238
+ // back to a single-line summary on stdout; json/junit produce no stdout.
239
+ const outPath = isAbsolute(options.reportOut)
240
+ ? options.reportOut
241
+ : pathResolve(process.cwd(), options.reportOut);
242
+ let content: string;
243
+ let label: string;
244
+ switch (options.report) {
245
+ case "json":
246
+ content = generateJsonReport(results);
247
+ label = "JSON";
248
+ break;
249
+ case "junit":
250
+ content = generateJunitXml(results);
251
+ label = "JUnit XML";
252
+ break;
253
+ default: // "console" — fall back to JSON in the file (most useful)
254
+ content = generateJsonReport(results);
255
+ label = "JSON";
256
+ break;
257
+ }
258
+ try {
259
+ await mkdir(pathDirname(outPath), { recursive: true });
260
+ await writeFile(outPath, content, "utf-8");
261
+ process.stderr.write(`zond: ${label} report written to ${outPath}\n`);
262
+ } catch (err) {
263
+ printError(`Failed to write --report-out file ${outPath}: ${(err as Error).message}`);
264
+ return 2;
265
+ }
266
+ for (const w of warnings) {
267
+ printWarning(w);
268
+ }
269
+ } else {
270
+ const reporter = getReporter(options.report);
271
+ reporter.report(results);
272
+ for (const w of warnings) {
273
+ printWarning(w);
274
+ }
189
275
  }
190
276
  }
191
277
 
@@ -226,10 +312,13 @@ export async function runCommand(options: RunOptions): Promise<number> {
226
312
  test: s.name,
227
313
  ...(r.suite_file ? { file: r.suite_file } : {}),
228
314
  status: s.status,
315
+ ...(typeof s.response?.status === "number" ? { http_status: s.response.status } : {}),
316
+ ...(typeof s.response?.status === "number" && s.response.status >= 500 && s.response.status < 600 ? { is_5xx: true } : {}),
229
317
  error: s.error,
230
318
  }))
231
319
  );
232
- printJson(jsonOk("run", { summary: { total, passed, failed }, failures, warnings, runId: savedRunId }));
320
+ const fiveXx = failures.filter(f => f.is_5xx).length;
321
+ printJson(jsonOk("run", { summary: { total, passed, failed, fiveXx }, failures, warnings, runId: savedRunId }));
233
322
  }
234
323
 
235
324
  return hasFailures ? 1 : 0;
@@ -1,4 +1,5 @@
1
1
  import { startServer } from "../../web/server.ts";
2
+ import { printError } from "../output.ts";
2
3
 
3
4
  export interface ServeOptions {
4
5
  port?: number;
@@ -6,14 +7,18 @@ export interface ServeOptions {
6
7
  dbPath?: string;
7
8
  watch?: boolean;
8
9
  open?: boolean;
10
+ /** When true, kill any existing process holding `port` before binding (DANGEROUS). */
11
+ killExisting?: boolean;
9
12
  }
10
13
 
11
- /** Kill any existing process listening on the given port (Windows + Unix) */
14
+ /** Range scanned when auto-picking a free port (only when --port is not set). */
15
+ const PORT_SCAN_LENGTH = 11; // 8080..8090 inclusive
16
+
17
+ /** Kill any existing process listening on the given port (Windows + Unix). */
12
18
  async function killPortHolder(port: number): Promise<void> {
13
19
  const isWin = process.platform === "win32";
14
20
  try {
15
21
  if (isWin) {
16
- // PowerShell: find PID on port, then kill it
17
22
  const find = Bun.spawn(["powershell", "-NoProfile", "-Command",
18
23
  `(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue).OwningProcess`], {
19
24
  stdout: "pipe", stderr: "ignore",
@@ -26,12 +31,8 @@ async function killPortHolder(port: number): Promise<void> {
26
31
  stdout: "ignore", stderr: "ignore",
27
32
  });
28
33
  }
29
- if (pids.length > 0) {
30
- // Give OS time to release the port
31
- await Bun.sleep(500);
32
- }
34
+ if (pids.length > 0) await Bun.sleep(500);
33
35
  } else {
34
- // Unix: lsof + kill
35
36
  const find = Bun.spawn(["lsof", "-ti", `:${port}`], {
36
37
  stdout: "pipe", stderr: "ignore",
37
38
  });
@@ -40,20 +41,54 @@ async function killPortHolder(port: number): Promise<void> {
40
41
  for (const pid of pids) {
41
42
  Bun.spawn(["kill", "-9", pid], { stdout: "ignore", stderr: "ignore" });
42
43
  }
43
- if (pids.length > 0) {
44
- await Bun.sleep(300);
45
- }
44
+ if (pids.length > 0) await Bun.sleep(300);
46
45
  }
47
46
  } catch {
48
- // Best effort — if we can't kill, startServer will fail with port-in-use
47
+ // Best effort — if we can't kill, the bind below will fail with port-in-use
48
+ }
49
+ }
50
+
51
+ /** Returns true if `port` is free on `host` (best-effort: tries to bind & immediately stops). */
52
+ async function isPortFree(port: number, host: string): Promise<boolean> {
53
+ try {
54
+ const srv = Bun.serve({ port, hostname: host, fetch: () => new Response() });
55
+ srv.stop(true);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Scans `[start, start+count)` and returns the first free port, or null. */
63
+ async function pickAvailablePort(start: number, count: number, host: string): Promise<number | null> {
64
+ for (let p = start; p < start + count; p++) {
65
+ if (await isPortFree(p, host)) return p;
49
66
  }
67
+ return null;
50
68
  }
51
69
 
52
70
  export async function serveCommand(options: ServeOptions): Promise<number> {
53
- const port = options.port ?? 8080;
71
+ const requested = options.port ?? 8080;
72
+ const host = options.host ?? "0.0.0.0";
54
73
 
55
- // Kill previous instance on the same port
56
- await killPortHolder(port);
74
+ let port: number;
75
+ if (options.killExisting) {
76
+ await killPortHolder(requested);
77
+ port = requested;
78
+ } else {
79
+ const picked = await pickAvailablePort(requested, PORT_SCAN_LENGTH, host);
80
+ if (picked === null) {
81
+ printError(
82
+ `All ports ${requested}..${requested + PORT_SCAN_LENGTH - 1} are in use. ` +
83
+ `Use --port <n> to pick another, or --kill-existing to free :${requested}.`,
84
+ );
85
+ return 1;
86
+ }
87
+ if (picked !== requested) {
88
+ process.stderr.write(`[zond] port ${requested} busy, using ${picked}\n`);
89
+ }
90
+ port = picked;
91
+ }
57
92
 
58
93
  await startServer({
59
94
  port,
@@ -62,20 +97,18 @@ export async function serveCommand(options: ServeOptions): Promise<number> {
62
97
  dev: options.watch,
63
98
  });
64
99
 
65
- // Open browser if requested
66
100
  if (options.open) {
67
- const host = options.host === "0.0.0.0" || !options.host ? "localhost" : options.host;
68
- const url = `http://${host}:${port}`;
101
+ const openHost = host === "0.0.0.0" ? "localhost" : host;
102
+ const url = `http://${openHost}:${port}`;
69
103
  try {
70
104
  const cmd = process.platform === "win32" ? ["cmd", "/c", "start", url]
71
105
  : process.platform === "darwin" ? ["open", url]
72
106
  : ["xdg-open", url];
73
107
  Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
74
108
  } catch {
75
- // Best effort — if browser can't open, server still runs
109
+ // Best effort
76
110
  }
77
111
  }
78
112
 
79
- // Keep running — Bun.serve keeps the process alive
80
113
  return 0;
81
114
  }
@@ -5,6 +5,8 @@ import {
5
5
  extractEndpoints,
6
6
  extractSecuritySchemes,
7
7
  serializeSuite,
8
+ buildCatalog,
9
+ serializeCatalog,
8
10
  } from "../../core/generator/index.ts";
9
11
  import { generateSuites } from "../../core/generator/suite-generator.ts";
10
12
  import { filterByTag } from "../../core/generator/chunker.ts";
@@ -78,6 +80,19 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
78
80
  }
79
81
 
80
82
  if (newEndpoints.length === 0) {
83
+ // Update catalog even when no new endpoints — spec schema may have changed
84
+ const allEndpoints = extractEndpoints(doc);
85
+ const catalog = buildCatalog({
86
+ endpoints: allEndpoints,
87
+ securitySchemes,
88
+ specSource: options.specPath,
89
+ specHash: currentHash,
90
+ apiName: (doc as any).info?.title,
91
+ apiVersion: (doc as any).info?.version,
92
+ baseUrl: (doc as any).servers?.[0]?.url,
93
+ });
94
+ await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
95
+
81
96
  const msg = "Spec changed (hash differs) but no new endpoints detected. Existing tests may need manual review.";
82
97
  warnings.push(msg);
83
98
  if (options.json) {
@@ -163,11 +178,23 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
163
178
  await writeMeta(options.testsDir, {
164
179
  zondVersion: ZOND_VERSION,
165
180
  lastSyncedAt: new Date().toISOString(),
166
- specUrl: options.specPath,
167
181
  specHash: currentHash,
168
182
  files: { ...meta.files, ...updatedMetaFiles },
169
183
  });
170
184
 
185
+ // Update .api-catalog.yaml with current spec state
186
+ const allEndpoints = extractEndpoints(doc);
187
+ const catalog = buildCatalog({
188
+ endpoints: allEndpoints,
189
+ securitySchemes,
190
+ specSource: options.specPath,
191
+ specHash: currentHash,
192
+ apiName: (doc as any).info?.title,
193
+ apiVersion: (doc as any).info?.version,
194
+ baseUrl: (doc as any).servers?.[0]?.url,
195
+ });
196
+ await Bun.write(join(options.testsDir, ".api-catalog.yaml"), serializeCatalog(catalog));
197
+
171
198
  // Sync DB collection if one is registered for this tests directory
172
199
  try {
173
200
  getDb();
@@ -1,4 +1,4 @@
1
- import { VERSION } from "../index.ts";
1
+ import { VERSION } from "../version.ts";
2
2
  import { isCompiledBinary } from "../runtime.ts";
3
3
  import { printError, printSuccess, printWarning } from "../output.ts";
4
4
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
@@ -0,0 +1,57 @@
1
+ import {
2
+ clearCurrentApi,
3
+ currentApiPath,
4
+ readCurrentApi,
5
+ writeCurrentApi,
6
+ } from "../../core/context/current.ts";
7
+ import { jsonError, jsonOk, printJson } from "../json-envelope.ts";
8
+ import { printError, printSuccess } from "../output.ts";
9
+
10
+ export interface UseOptions {
11
+ api?: string;
12
+ clear?: boolean;
13
+ json?: boolean;
14
+ }
15
+
16
+ export async function useCommand(opts: UseOptions): Promise<number> {
17
+ const path = currentApiPath();
18
+
19
+ if (opts.clear) {
20
+ const removed = clearCurrentApi();
21
+ if (opts.json) {
22
+ printJson(jsonOk("use", { action: "cleared", path, removed }));
23
+ } else if (removed) {
24
+ printSuccess(`Cleared ${path}`);
25
+ } else {
26
+ process.stdout.write(`No .zond-current file in ${process.cwd()}\n`);
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ if (opts.api) {
32
+ try {
33
+ writeCurrentApi(opts.api);
34
+ } catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ if (opts.json) printJson(jsonError("use", [message]));
37
+ else printError(message);
38
+ return 1;
39
+ }
40
+ if (opts.json) {
41
+ printJson(jsonOk("use", { action: "set", api: opts.api, path }));
42
+ } else {
43
+ printSuccess(`Set current API to '${opts.api}' (${path})`);
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ const current = readCurrentApi();
49
+ if (opts.json) {
50
+ printJson(jsonOk("use", { action: "show", api: current, path }));
51
+ } else if (current) {
52
+ process.stdout.write(`${current}\n`);
53
+ } else {
54
+ process.stdout.write(`No current API set. Run 'zond use <api>'.\n`);
55
+ }
56
+ return 0;
57
+ }