@kirrosh/zond 0.17.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kirrosh/zond",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
5
5
  "license": "MIT",
6
6
  "module": "index.ts",
@@ -37,6 +37,9 @@
37
37
  "devDependencies": {
38
38
  "@types/bun": "latest"
39
39
  },
40
+ "engines": {
41
+ "bun": ">=1.1.0"
42
+ },
40
43
  "peerDependencies": {
41
44
  "typescript": "^5"
42
45
  },
@@ -50,6 +50,10 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
50
50
  const color = useColor();
51
51
 
52
52
  // Enriched mode with run results
53
+ let passing = 0;
54
+ let apiError = 0;
55
+ let testFailed = 0;
56
+
53
57
  if (options.runId != null) {
54
58
  getDb();
55
59
  const run = getRunById(options.runId);
@@ -88,62 +92,63 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
88
92
  }
89
93
  }
90
94
 
91
- let passing = 0;
92
- let apiError = 0;
93
- let testFailed = 0;
94
95
  for (const status of endpointStatus.values()) {
95
96
  if (status === "passing") passing++;
96
97
  else if (status === "api_error") apiError++;
97
98
  else if (status === "test_failed") testFailed++;
98
99
  }
100
+ }
99
101
 
100
- console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
101
- console.log("");
102
+ if (!options.json) {
103
+ if (options.runId != null) {
104
+ console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
105
+ console.log("");
102
106
 
103
- if (passing > 0) {
104
- console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
105
- }
106
- if (apiError > 0) {
107
- console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
108
- }
109
- if (testFailed > 0) {
110
- console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
111
- }
112
- if (uncovered.length > 0) {
113
- console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
114
- }
115
- } else {
116
- // Standard mode
117
- console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
118
- console.log("");
107
+ if (passing > 0) {
108
+ console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
109
+ }
110
+ if (apiError > 0) {
111
+ console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
112
+ }
113
+ if (testFailed > 0) {
114
+ console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
115
+ }
116
+ if (uncovered.length > 0) {
117
+ console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
118
+ }
119
+ } else {
120
+ // Standard mode
121
+ console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
122
+ console.log("");
119
123
 
120
- // Covered endpoints
121
- if (coveredCount > 0) {
122
- console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
123
- for (const ep of allEndpoints) {
124
- if (!uncovered.includes(ep)) {
125
- console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
124
+ // Covered endpoints
125
+ if (coveredCount > 0) {
126
+ console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
127
+ for (const ep of allEndpoints) {
128
+ if (!uncovered.includes(ep)) {
129
+ console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
130
+ }
126
131
  }
132
+ console.log("");
127
133
  }
128
- console.log("");
129
- }
130
134
 
131
- // Uncovered endpoints
132
- if (uncovered.length > 0) {
133
- console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
134
- for (const ep of uncovered) {
135
- console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
135
+ // Uncovered endpoints
136
+ if (uncovered.length > 0) {
137
+ console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
138
+ for (const ep of uncovered) {
139
+ console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
140
+ }
136
141
  }
137
142
  }
138
- }
139
143
 
140
- // Static warnings (always shown)
141
- const warnings = analyzeEndpoints(allEndpoints);
142
- if (warnings.length > 0) {
143
- console.log("");
144
- console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
145
- for (const w of warnings) {
146
- console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
144
+ // Static warnings (always shown in human-readable mode)
145
+ const warnings = analyzeEndpoints(allEndpoints);
146
+ if (warnings.length > 0) {
147
+ console.log("");
148
+ console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
149
+ for (const w of warnings) {
150
+ console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
151
+ }
147
152
  }
148
153
  }
149
154
 
@@ -1,4 +1,6 @@
1
1
  import { getCollections, getRuns, getRunDetail, diagnoseRun, compareRuns } from "../../core/diagnostics/db-analysis.ts";
2
+ import { getFilteredResults } from "../../db/queries.ts";
3
+ import { getDb } from "../../db/schema.ts";
2
4
  import { printError } from "../output.ts";
3
5
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
4
6
 
@@ -9,6 +11,8 @@ export interface DbOptions {
9
11
  verbose?: boolean;
10
12
  dbPath?: string;
11
13
  json?: boolean;
14
+ method?: string;
15
+ status?: number;
12
16
  }
13
17
 
14
18
  export async function dbCommand(options: DbOptions): Promise<number> {
@@ -58,11 +62,22 @@ export async function dbCommand(options: DbOptions): Promise<number> {
58
62
  else printError(msg);
59
63
  return 2;
60
64
  }
61
- const detail = getRunDetail(id, options.verbose, options.dbPath);
62
- if (json) {
63
- printJson(jsonOk("db run", detail));
65
+ // If filtering by method/status, show filtered results instead of full detail
66
+ if (options.method || options.status !== undefined) {
67
+ getDb(options.dbPath);
68
+ const results = getFilteredResults(id, { method: options.method, status: options.status });
69
+ if (json) {
70
+ printJson(jsonOk("db run", { run_id: id, count: results.length, results }));
71
+ } else {
72
+ console.log(JSON.stringify({ run_id: id, count: results.length, results }, null, 2));
73
+ }
64
74
  } else {
65
- console.log(JSON.stringify(detail, null, 2));
75
+ const detail = getRunDetail(id, options.verbose, options.dbPath);
76
+ if (json) {
77
+ printJson(jsonOk("db run", detail));
78
+ } else {
79
+ console.log(JSON.stringify(detail, null, 2));
80
+ }
66
81
  }
67
82
  return 0;
68
83
  }
@@ -75,7 +90,7 @@ export async function dbCommand(options: DbOptions): Promise<number> {
75
90
  else printError(msg);
76
91
  return 2;
77
92
  }
78
- const result = diagnoseRun(id, options.verbose, options.dbPath);
93
+ const result = diagnoseRun(id, options.verbose, options.dbPath, options.limit);
79
94
  if (json) {
80
95
  printJson(jsonOk("db diagnose", result));
81
96
  } else {
@@ -1,4 +1,4 @@
1
- import { describeEndpoint, describeCompact } from "../../core/generator/describe.ts";
1
+ import { describeEndpoint, describeCompact, describeAllParams } from "../../core/generator/describe.ts";
2
2
  import { printError } from "../output.ts";
3
3
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
4
4
 
@@ -7,11 +7,36 @@ export interface DescribeOptions {
7
7
  method?: string;
8
8
  path?: string;
9
9
  compact?: boolean;
10
+ listParams?: boolean;
10
11
  json?: boolean;
11
12
  }
12
13
 
13
14
  export async function describeCommand(options: DescribeOptions): Promise<number> {
14
15
  try {
16
+ if (options.listParams) {
17
+ const params = await describeAllParams(options.specPath);
18
+ if (options.json) {
19
+ printJson(jsonOk("describe", { params }));
20
+ } else {
21
+ const grouped = new Map<string, typeof params>();
22
+ for (const p of params) {
23
+ const arr = grouped.get(p.in) ?? [];
24
+ arr.push(p);
25
+ grouped.set(p.in, arr);
26
+ }
27
+ for (const [location, locationParams] of grouped) {
28
+ console.log(`\n${location.toUpperCase()} parameters:`);
29
+ for (const p of locationParams) {
30
+ const req = p.required ? " (required)" : "";
31
+ const type = p.type ? ` [${p.type}]` : "";
32
+ console.log(` ${p.name}${type}${req} — used in ${p.usedBy.length} endpoint(s)`);
33
+ }
34
+ }
35
+ console.log(`\n${params.length} unique parameter(s)`);
36
+ }
37
+ return 0;
38
+ }
39
+
15
40
  if (options.compact) {
16
41
  const endpoints = await describeCompact(options.specPath);
17
42
 
@@ -8,9 +8,10 @@ import {
8
8
  filterUncoveredEndpoints,
9
9
  serializeSuite,
10
10
  } from "../../core/generator/index.ts";
11
- import { generateSuites } from "../../core/generator/suite-generator.ts";
11
+ import { generateSuites, findUnresolvedVars } from "../../core/generator/suite-generator.ts";
12
12
  import { filterByTag } from "../../core/generator/chunker.ts";
13
13
  import { parse } from "../../core/parser/yaml-parser.ts";
14
+ import { decycleSchema } from "../../core/generator/schema-utils.ts";
14
15
  import { printError, printSuccess } from "../output.ts";
15
16
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
16
17
  import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
@@ -82,7 +83,7 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
82
83
 
83
84
  // Write .zond-meta.json (merge with existing meta to preserve info about prior files)
84
85
  const existingMeta = await readMeta(options.output);
85
- const specContent = typeof doc === "object" ? JSON.stringify(doc) : String(doc);
86
+ const specContent = typeof doc === "object" ? JSON.stringify(decycleSchema(doc)) : String(doc);
86
87
  await writeMeta(options.output, {
87
88
  zondVersion: ZOND_VERSION,
88
89
  lastSyncedAt: new Date().toISOString(),
@@ -103,12 +104,23 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
103
104
  // DB unavailable — not fatal
104
105
  }
105
106
 
106
- // Create .env.yaml with base_url if it doesn't exist
107
+ // Create .env.yaml with base_url and unresolved variables as placeholders
107
108
  const envPath = join(options.output, ".env.yaml");
108
109
  const envFile = Bun.file(envPath);
109
- if (!(await envFile.exists()) && baseUrl) {
110
- await Bun.write(envPath, `base_url: ${baseUrl}\n`);
111
- warnings.push(`Created ${envPath} with base_url from spec`);
110
+ if (!(await envFile.exists())) {
111
+ const unresolvedVars = new Set<string>();
112
+ for (const suite of suites) {
113
+ for (const v of findUnresolvedVars(suite)) unresolvedVars.add(v);
114
+ }
115
+ const lines: string[] = [];
116
+ if (baseUrl) lines.push(`base_url: ${baseUrl}`);
117
+ for (const v of [...unresolvedVars].sort()) {
118
+ lines.push(`${v}: "" # TODO: fill in`);
119
+ }
120
+ if (lines.length > 0) {
121
+ await Bun.write(envPath, lines.join("\n") + "\n");
122
+ warnings.push(`Created ${envPath} with ${unresolvedVars.size} placeholder variable(s)`);
123
+ }
112
124
  }
113
125
 
114
126
  // Validate generated files
@@ -2,7 +2,7 @@ import { dirname } from "path";
2
2
  import { stat } from "node:fs/promises";
3
3
  import { parse } from "../../core/parser/yaml-parser.ts";
4
4
  import { loadEnvironment } from "../../core/parser/variables.ts";
5
- import { filterSuitesByTags } from "../../core/parser/filter.ts";
5
+ import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
6
6
  import { runSuite } from "../../core/runner/executor.ts";
7
7
  import { getReporter } from "../../core/reporter/index.ts";
8
8
  import type { ReporterName } from "../../core/reporter/types.ts";
@@ -25,6 +25,8 @@ export interface RunOptions {
25
25
  authToken?: string;
26
26
  safe?: boolean;
27
27
  tag?: string[];
28
+ excludeTag?: string[];
29
+ method?: string;
28
30
  envVars?: string[];
29
31
  dryRun?: boolean;
30
32
  json?: boolean;
@@ -54,6 +56,24 @@ export async function runCommand(options: RunOptions): Promise<number> {
54
56
  }
55
57
  }
56
58
 
59
+ // 1b2. Exclude-tag filter
60
+ if (options.excludeTag && options.excludeTag.length > 0) {
61
+ suites = excludeSuitesByTags(suites, options.excludeTag);
62
+ if (suites.length === 0) {
63
+ printWarning("All suites excluded by --exclude-tag");
64
+ return 0;
65
+ }
66
+ }
67
+
68
+ // 1b3. Method filter
69
+ if (options.method) {
70
+ suites = filterSuitesByMethod(suites, options.method);
71
+ if (suites.length === 0) {
72
+ printWarning(`No tests found with method ${options.method.toUpperCase()}`);
73
+ return 0;
74
+ }
75
+ }
76
+
57
77
  // 1c. Safe mode: keep GET, set-only steps, and auth-related requests
58
78
  if (options.safe) {
59
79
  for (const suite of suites) {
@@ -10,6 +10,7 @@ import { generateSuites } from "../../core/generator/suite-generator.ts";
10
10
  import { filterByTag } from "../../core/generator/chunker.ts";
11
11
  import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
12
12
  import { diffEndpoints } from "../../core/sync/spec-differ.ts";
13
+ import { decycleSchema } from "../../core/generator/schema-utils.ts";
13
14
  import { printError, printSuccess, printWarning } from "../output.ts";
14
15
  import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
15
16
  import { version as ZOND_VERSION } from "../../../package.json";
@@ -41,7 +42,7 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
41
42
 
42
43
  // Load current spec
43
44
  const doc = await readOpenApiSpec(options.specPath);
44
- const specContent = JSON.stringify(doc);
45
+ const specContent = JSON.stringify(decycleSchema(doc));
45
46
  const currentHash = hashSpec(specContent);
46
47
 
47
48
  if (currentHash === meta.specHash) {
@@ -0,0 +1,189 @@
1
+ import { VERSION } from "../index.ts";
2
+ import { isCompiledBinary } from "../runtime.ts";
3
+ import { printError, printSuccess, printWarning } from "../output.ts";
4
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
5
+
6
+ export interface UpdateOptions {
7
+ json?: boolean;
8
+ check?: boolean;
9
+ }
10
+
11
+ const REPO = "kirrosh/zond";
12
+ const GITHUB_API = `https://api.github.com/repos/${REPO}/releases/latest`;
13
+
14
+ interface GitHubRelease {
15
+ tag_name: string;
16
+ assets: { name: string; browser_download_url: string }[];
17
+ }
18
+
19
+ function getTarget(): { target: string; ext: string } | null {
20
+ const platform = process.platform;
21
+ const arch = process.arch;
22
+
23
+ if (platform === "linux" && arch === "x64") return { target: "linux-x64", ext: "tar.gz" };
24
+ if (platform === "darwin" && arch === "arm64") return { target: "darwin-arm64", ext: "tar.gz" };
25
+ if (platform === "win32" && arch === "x64") return { target: "win-x64", ext: "zip" };
26
+ return null;
27
+ }
28
+
29
+ async function fetchLatestRelease(): Promise<GitHubRelease> {
30
+ const resp = await fetch(GITHUB_API, {
31
+ headers: { "User-Agent": `zond/${VERSION}` },
32
+ });
33
+ if (!resp.ok) {
34
+ throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`);
35
+ }
36
+ return resp.json() as Promise<GitHubRelease>;
37
+ }
38
+
39
+ export async function updateCommand(options: UpdateOptions): Promise<number> {
40
+ try {
41
+ if (!isCompiledBinary()) {
42
+ const msg = "Self-update is only available for standalone binaries. Install binary: curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh";
43
+ if (options.json) {
44
+ printJson(jsonOk("update", { action: "skip", reason: "not-standalone", installHint: "curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh" }, [msg]));
45
+ } else {
46
+ printWarning(msg);
47
+ }
48
+ return 3;
49
+ }
50
+
51
+ const target = getTarget();
52
+ if (!target) {
53
+ const msg = `Unsupported platform: ${process.platform}-${process.arch}`;
54
+ if (options.json) {
55
+ printJson(jsonError("update", [msg]));
56
+ } else {
57
+ printError(msg);
58
+ }
59
+ return 2;
60
+ }
61
+
62
+ const release = await fetchLatestRelease();
63
+ const latest = release.tag_name.replace(/^v/, "");
64
+
65
+ if (latest === VERSION) {
66
+ const msg = `Already up to date (${VERSION})`;
67
+ if (options.json) {
68
+ printJson(jsonOk("update", { action: "none", currentVersion: VERSION, latestVersion: latest }));
69
+ } else {
70
+ console.log(msg);
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ if (options.check) {
76
+ const msg = `Update available: ${VERSION} → ${latest}`;
77
+ if (options.json) {
78
+ printJson(jsonOk("update", { action: "available", currentVersion: VERSION, latestVersion: latest }));
79
+ } else {
80
+ console.log(msg);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ // Find the right asset
86
+ const assetName = `zond-${target.target}.${target.ext}`;
87
+ const asset = release.assets.find(a => a.name === assetName);
88
+ if (!asset) {
89
+ const msg = `Binary not found for ${target.target} in release ${release.tag_name}`;
90
+ if (options.json) {
91
+ printJson(jsonError("update", [msg]));
92
+ } else {
93
+ printError(msg);
94
+ }
95
+ return 2;
96
+ }
97
+
98
+ console.log(`Updating zond ${VERSION} → ${latest}...`);
99
+ console.log(`Downloading ${assetName}...`);
100
+
101
+ // Download the archive
102
+ const resp = await fetch(asset.browser_download_url, {
103
+ headers: { "User-Agent": `zond/${VERSION}` },
104
+ });
105
+ if (!resp.ok) {
106
+ throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
107
+ }
108
+ const archiveData = new Uint8Array(await resp.arrayBuffer());
109
+
110
+ const currentBinary = process.execPath;
111
+ const { join, dirname } = await import("path");
112
+ const tmpDir = join(dirname(currentBinary), `.zond-update-${Date.now()}`);
113
+ const { mkdir, rm, rename, chmod } = await import("fs/promises");
114
+ await mkdir(tmpDir, { recursive: true });
115
+
116
+ try {
117
+ const archivePath = join(tmpDir, assetName);
118
+ await Bun.write(archivePath, archiveData);
119
+
120
+ // Extract
121
+ if (target.ext === "tar.gz") {
122
+ const proc = Bun.spawn(["tar", "-xzf", archivePath, "-C", tmpDir]);
123
+ const exitCode = await proc.exited;
124
+ if (exitCode !== 0) throw new Error(`tar extraction failed (exit ${exitCode})`);
125
+ } else {
126
+ // Windows zip
127
+ const proc = Bun.spawn([
128
+ "powershell", "-NoProfile", "-Command",
129
+ `Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
130
+ ]);
131
+ const exitCode = await proc.exited;
132
+ if (exitCode !== 0) throw new Error(`Zip extraction failed (exit ${exitCode})`);
133
+ }
134
+
135
+ // Find the extracted binary
136
+ const binaryName = process.platform === "win32" ? "zond.exe" : "zond";
137
+ const newBinary = join(tmpDir, binaryName);
138
+ const file = Bun.file(newBinary);
139
+ if (!await file.exists()) {
140
+ throw new Error(`Binary '${binaryName}' not found in archive`);
141
+ }
142
+
143
+ // Replace current binary
144
+ try {
145
+ if (process.platform === "win32") {
146
+ // Windows: rename current to .old, move new, clean up
147
+ const oldBinary = currentBinary + ".old";
148
+ try { await rm(oldBinary, { force: true }); } catch {}
149
+ await rename(currentBinary, oldBinary);
150
+ await rename(newBinary, currentBinary);
151
+ try { await rm(oldBinary, { force: true }); } catch {}
152
+ } else {
153
+ await rename(newBinary, currentBinary);
154
+ await chmod(currentBinary, 0o755);
155
+ }
156
+ } catch (replaceErr: any) {
157
+ if (replaceErr?.code === "EACCES" || replaceErr?.code === "EPERM") {
158
+ const hint = process.platform === "win32"
159
+ ? `Permission denied. Run the terminal as Administrator.`
160
+ : `Permission denied writing to ${currentBinary}. Run: sudo zond update`;
161
+ if (options.json) {
162
+ printJson(jsonError("update", [hint]));
163
+ } else {
164
+ printError(hint);
165
+ }
166
+ return 2;
167
+ }
168
+ throw replaceErr;
169
+ }
170
+
171
+ if (options.json) {
172
+ printJson(jsonOk("update", { action: "updated", previousVersion: VERSION, newVersion: latest }));
173
+ } else {
174
+ printSuccess(`Updated zond ${VERSION} → ${latest}`);
175
+ }
176
+ return 0;
177
+ } finally {
178
+ try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
179
+ }
180
+ } catch (err) {
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ if (options.json) {
183
+ printJson(jsonError("update", [message]));
184
+ } else {
185
+ printError(message);
186
+ }
187
+ return 2;
188
+ }
189
+ }
package/src/cli/index.ts CHANGED
@@ -13,6 +13,7 @@ import { guideCommand } from "./commands/guide.ts";
13
13
  import { generateCommand } from "./commands/generate.ts";
14
14
  import { exportCommand } from "./commands/export.ts";
15
15
  import { syncCommand } from "./commands/sync.ts";
16
+ import { updateCommand } from "./commands/update.ts";
16
17
  import { printError } from "./output.ts";
17
18
  import { getRuntimeInfo } from "./runtime.ts";
18
19
  import { getDb } from "../db/schema.ts";
@@ -107,6 +108,7 @@ Usage:
107
108
  zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
108
109
  zond export postman <path> Export YAML tests as Postman Collection v2.1
109
110
  zond sync <spec> Detect new/removed endpoints and generate tests for new ones
111
+ zond update Check for updates and self-update the binary
110
112
 
111
113
  Options for 'run':
112
114
  --dry-run Show requests without sending them (exit code always 0)
@@ -121,6 +123,8 @@ Options for 'run':
121
123
  --auth-token <token> Auth token injected as {{auth_token}} variable
122
124
  --safe Run only GET tests (read-only, safe mode)
123
125
  --tag <tag> Filter suites by tag (repeatable, comma-separated, OR logic)
126
+ --exclude-tag <tag> Exclude suites by tag (repeatable, comma-separated)
127
+ --method <method> Filter tests by HTTP method (e.g. GET, POST)
124
128
 
125
129
  Options for 'init':
126
130
  --name <name> API name (auto-detected from spec title if omitted)
@@ -130,14 +134,15 @@ Options for 'init':
130
134
 
131
135
  Options for 'describe':
132
136
  --compact List all endpoints briefly
137
+ --list-params List all unique parameters across all endpoints
133
138
  --method <method> HTTP method for single endpoint detail
134
139
  --path <path> Endpoint path for single endpoint detail
135
140
 
136
141
  Options for 'db':
137
142
  zond db collections List all API collections
138
143
  zond db runs [--limit N] List recent test runs
139
- zond db run <id> [--verbose] Show run details
140
- zond db diagnose <id> Diagnose run failures
144
+ zond db run <id> [--verbose] Show run details (--method GET, --status 403 to filter)
145
+ zond db diagnose <id> Diagnose run failures (--limit N examples per group, --verbose for all)
141
146
  zond db compare <idA> <idB> Compare two runs
142
147
 
143
148
  Options for 'request':
@@ -186,6 +191,9 @@ Options for 'sync':
186
191
  --dry-run Show what would be generated without writing files
187
192
  --tag <tag> Limit sync to endpoints with this tag
188
193
 
194
+ Options for 'update':
195
+ --check Only check for updates, do not download
196
+
189
197
  General:
190
198
  --json Output in JSON envelope format (available for all commands)
191
199
  --help, -h Show this help
@@ -251,8 +259,9 @@ async function main(): Promise<number> {
251
259
  }
252
260
  }
253
261
 
254
- // Collect all --tag and --env-var flags (parseArgs only stores last one, so re-parse)
262
+ // Collect all --tag, --exclude-tag, and --env-var flags (parseArgs only stores last one, so re-parse)
255
263
  const tagValues: string[] = [];
264
+ const excludeTagValues: string[] = [];
256
265
  const envVarValues: string[] = [];
257
266
  const rawRunArgs = process.argv.slice(2);
258
267
  for (let i = 0; i < rawRunArgs.length; i++) {
@@ -262,6 +271,11 @@ async function main(): Promise<number> {
262
271
  i++;
263
272
  } else if (arg.startsWith("--tag=")) {
264
273
  tagValues.push(arg.slice("--tag=".length));
274
+ } else if (arg === "--exclude-tag" && rawRunArgs[i + 1]) {
275
+ excludeTagValues.push(rawRunArgs[i + 1]!);
276
+ i++;
277
+ } else if (arg.startsWith("--exclude-tag=")) {
278
+ excludeTagValues.push(arg.slice("--exclude-tag=".length));
265
279
  } else if (arg === "--env-var" && rawRunArgs[i + 1]) {
266
280
  envVarValues.push(rawRunArgs[i + 1]!);
267
281
  i++;
@@ -271,6 +285,7 @@ async function main(): Promise<number> {
271
285
  }
272
286
  // Support comma-separated: --tag smoke,crud → ["smoke", "crud"]
273
287
  const tags = tagValues.flatMap(v => v.split(",")).filter(Boolean);
288
+ const excludeTags = excludeTagValues.flatMap(v => v.split(",")).filter(Boolean);
274
289
 
275
290
  return runCommand({
276
291
  path,
@@ -283,6 +298,8 @@ async function main(): Promise<number> {
283
298
  authToken: typeof flags["auth-token"] === "string" ? flags["auth-token"] : undefined,
284
299
  safe: flags["safe"] === true,
285
300
  tag: tags.length > 0 ? tags : undefined,
301
+ excludeTag: excludeTags.length > 0 ? excludeTags : undefined,
302
+ method: typeof flags["method"] === "string" ? flags["method"] : undefined,
286
303
  envVars: envVarValues.length > 0 ? envVarValues : undefined,
287
304
  dryRun: flags["dry-run"] === true,
288
305
  json: jsonFlag,
@@ -405,6 +422,7 @@ async function main(): Promise<number> {
405
422
  return describeCommand({
406
423
  specPath,
407
424
  compact: flags["compact"] === true,
425
+ listParams: flags["list-params"] === true,
408
426
  method: typeof flags["method"] === "string" ? flags["method"] : undefined,
409
427
  path: typeof flags["path"] === "string" ? flags["path"] : undefined,
410
428
  json: jsonFlag,
@@ -423,6 +441,13 @@ async function main(): Promise<number> {
423
441
  limit = parseInt(limitRaw, 10);
424
442
  if (isNaN(limit) || limit <= 0) limit = undefined;
425
443
  }
444
+ const statusRaw = flags["status"];
445
+ let statusFilter: number | undefined;
446
+ if (typeof statusRaw === "string") {
447
+ statusFilter = parseInt(statusRaw, 10);
448
+ if (isNaN(statusFilter)) statusFilter = undefined;
449
+ }
450
+
426
451
  return dbCommand({
427
452
  subcommand: dbSub,
428
453
  positional: positional.slice(1),
@@ -430,6 +455,8 @@ async function main(): Promise<number> {
430
455
  verbose: flags["verbose"] === true,
431
456
  dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
432
457
  json: jsonFlag,
458
+ method: typeof flags["method"] === "string" ? flags["method"] : undefined,
459
+ status: statusFilter,
433
460
  });
434
461
  }
435
462
 
@@ -528,6 +555,14 @@ async function main(): Promise<number> {
528
555
  });
529
556
  }
530
557
 
558
+ case "update":
559
+ case "self-update": {
560
+ return updateCommand({
561
+ check: flags["check"] === true,
562
+ json: jsonFlag,
563
+ });
564
+ }
565
+
531
566
  case "sync": {
532
567
  const specPath = positional[0];
533
568
  if (!specPath) {
@@ -169,7 +169,7 @@ export interface DiagnoseResult {
169
169
  grouped_failures?: FailureGroup[];
170
170
  }
171
171
 
172
- export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string): DiagnoseResult {
172
+ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
173
173
  getDb(dbPath);
174
174
  const diagRun = getRunById(runId);
175
175
  if (!diagRun) throw new Error(`Run ${runId} not found`);
@@ -272,7 +272,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string):
272
272
 
273
273
  const { grouped_failures, compactFailures } = verbose
274
274
  ? { grouped_failures: undefined, compactFailures: failures }
275
- : groupFailures(failures);
275
+ : groupFailures(failures, maxExamples);
276
276
 
277
277
  return {
278
278
  run: {
@@ -301,7 +301,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string):
301
301
  type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
302
302
 
303
303
  /** Group similar failures for compact output. Exported for testing. */
304
- export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
304
+ export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
305
305
  if (failures.length <= 5) {
306
306
  return { compactFailures: failures };
307
307
  }
@@ -341,7 +341,7 @@ export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_f
341
341
  failure_type: group.failure_type,
342
342
  recommended_action: group.items[0]!.recommended_action,
343
343
  hint: group.hint,
344
- examples: group.items.slice(0, 2).map(f => `${f.suite_name}/${f.test_name}`),
344
+ examples: (maxExamples === 0 ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
345
345
  response_status: group.response_status,
346
346
  });
347
347
  compactFailures.push(group.items[0]!);
@@ -7,7 +7,10 @@ import type { OpenAPIV3 } from "openapi-types";
7
7
  export function generateFromSchema(
8
8
  schema: OpenAPIV3.SchemaObject,
9
9
  propertyName?: string,
10
+ _depth = 0,
10
11
  ): unknown {
12
+ if (_depth > 5) return {};
13
+
11
14
  // allOf: merge all schemas
12
15
  if (schema.allOf) {
13
16
  const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
@@ -17,15 +20,15 @@ export function generateFromSchema(
17
20
  merged.properties = { ...merged.properties, ...s.properties };
18
21
  }
19
22
  }
20
- return generateFromSchema(merged, propertyName);
23
+ return generateFromSchema(merged, propertyName, _depth + 1);
21
24
  }
22
25
 
23
26
  // oneOf / anyOf: use first variant
24
27
  if (schema.oneOf) {
25
- return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName);
28
+ return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
26
29
  }
27
30
  if (schema.anyOf) {
28
- return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName);
31
+ return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
29
32
  }
30
33
 
31
34
  // enum: first value
@@ -51,7 +54,7 @@ export function generateFromSchema(
51
54
 
52
55
  case "array": {
53
56
  if (schema.items) {
54
- const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject);
57
+ const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
55
58
  return [item];
56
59
  }
57
60
  return [];
@@ -63,14 +66,14 @@ export function generateFromSchema(
63
66
  if (schema.properties) {
64
67
  const obj: Record<string, unknown> = {};
65
68
  for (const [key, propSchema] of Object.entries(schema.properties)) {
66
- obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key);
69
+ obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key, _depth + 1);
67
70
  }
68
71
  return obj;
69
72
  }
70
73
  // Record type: additionalProperties defines value schema
71
74
  if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
72
75
  const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
73
- return { key1: generateFromSchema(valSchema, "key1"), key2: generateFromSchema(valSchema, "key2") };
76
+ return { key1: generateFromSchema(valSchema, "key1", _depth + 1), key2: generateFromSchema(valSchema, "key2", _depth + 1) };
74
77
  }
75
78
  if (schema.additionalProperties === true) {
76
79
  return { key1: "value1", key2: "value2" };
@@ -248,3 +248,55 @@ export async function describeCompact(
248
248
 
249
249
  return result;
250
250
  }
251
+
252
+ export interface ParamInfo {
253
+ name: string;
254
+ in: string;
255
+ type?: string;
256
+ required: boolean;
257
+ usedBy: string[];
258
+ }
259
+
260
+ export async function describeAllParams(
261
+ specPath: string,
262
+ options?: { insecure?: boolean },
263
+ ): Promise<ParamInfo[]> {
264
+ const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
265
+ const paths = doc.paths ?? {};
266
+ const paramMap = new Map<string, ParamInfo>();
267
+
268
+ for (const [path, pathItem] of Object.entries(paths)) {
269
+ const pathParams = ((pathItem as any)?.parameters ?? []) as OpenAPIV3.ParameterObject[];
270
+
271
+ for (const method of ["get", "post", "put", "patch", "delete"]) {
272
+ const op = (pathItem as any)?.[method] as OpenAPIV3.OperationObject | undefined;
273
+ if (!op) continue;
274
+
275
+ const allParams = [...pathParams, ...((op.parameters ?? []) as OpenAPIV3.ParameterObject[])];
276
+ const endpoint = `${method.toUpperCase()} ${path}`;
277
+
278
+ for (const p of allParams) {
279
+ const key = `${p.in}:${p.name}`;
280
+ const existing = paramMap.get(key);
281
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
282
+ if (existing) {
283
+ existing.usedBy.push(endpoint);
284
+ if (p.required) existing.required = true;
285
+ } else {
286
+ paramMap.set(key, {
287
+ name: p.name,
288
+ in: p.in,
289
+ type: schema?.type,
290
+ required: p.required ?? false,
291
+ usedBy: [endpoint],
292
+ });
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ return [...paramMap.values()].sort((a, b) => {
299
+ if (a.in !== b.in) return a.in.localeCompare(b.in);
300
+ return a.name.localeCompare(b.name);
301
+ });
302
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EndpointInfo } from "./types.ts";
2
2
 
3
- export type WarningCode = "deprecated" | "no_response_schema" | "no_responses_defined" | "required_params_no_examples";
3
+ export type WarningCode = "deprecated" | "no_response_schema" | "no_responses_defined" | "required_params_no_examples" | "post_body_as_query";
4
4
 
5
5
  export interface EndpointWarning {
6
6
  method: string;
@@ -34,6 +34,15 @@ export function analyzeEndpoints(endpoints: EndpointInfo[]): EndpointWarning[] {
34
34
  warnings.push(`required_params_no_examples: ${missingExamples.join(", ")}`);
35
35
  }
36
36
 
37
+ // SpringDoc quirk: POST/PUT/PATCH with query param named "body" or single complex object query param
38
+ if (["POST", "PUT", "PATCH"].includes(ep.method)) {
39
+ const queryParams = ep.parameters.filter(p => p.in === "query");
40
+ const hasBodyQuery = queryParams.some(p => p.name.toLowerCase() === "body");
41
+ if (hasBodyQuery) {
42
+ warnings.push("post_body_as_query: query param 'body' on POST/PUT/PATCH likely means request body (SpringDoc quirk)");
43
+ }
44
+ }
45
+
37
46
  if (warnings.length > 0) {
38
47
  result.push({ method: ep.method, path: ep.path, warnings });
39
48
  }
@@ -15,8 +15,8 @@ function convertPath(path: string): string {
15
15
 
16
16
  /**
17
17
  * Convert path params to seed values for smoke suites (no capture context).
18
- * Uses the parameter's example/default from the spec, or falls back to "1" for
19
- * id-like params. Non-id string params keep the {{placeholder}} form.
18
+ * Uses the parameter's example/default from the spec, or falls back to
19
+ * {{placeholder}} form so the user fills them in via .env.yaml.
20
20
  */
21
21
  function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
22
22
  return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
@@ -24,7 +24,6 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
24
24
  const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
25
25
  const example = (param as any)?.example ?? schema?.example ?? schema?.default;
26
26
  if (example !== undefined) return String(example);
27
- if (schema?.type === "integer" || /^id$|_id$|Id$/i.test(name)) return "1";
28
27
  return `{{${name}}}`;
29
28
  });
30
29
  }
@@ -81,6 +80,20 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
81
80
  return undefined;
82
81
  }
83
82
 
83
+ /** Derive a variable name for a security scheme's token */
84
+ function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
85
+ // Count how many bearer-like schemes exist
86
+ const bearerSchemes = allSchemes.filter(s =>
87
+ (s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
88
+ (s.type === "apiKey" && s.in === "header" && s.apiKeyName === "Authorization")
89
+ );
90
+ // If only one bearer scheme → keep generic auth_token for backward compat
91
+ if (bearerSchemes.length <= 1) return "auth_token";
92
+ // Multiple → derive from scheme name (e.g. "platformAuth" → "platform_auth_token")
93
+ const slug = scheme.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_token$|_auth$/, "");
94
+ return `${slug}_token`;
95
+ }
96
+
84
97
  function getAuthHeaders(
85
98
  ep: EndpointInfo,
86
99
  schemes: SecuritySchemeInfo[],
@@ -93,16 +106,15 @@ function getAuthHeaders(
93
106
 
94
107
  if (scheme.type === "http") {
95
108
  if (scheme.scheme === "bearer" || !scheme.scheme) {
96
- return { Authorization: "Bearer {{auth_token}}" };
109
+ return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
97
110
  }
98
111
  if (scheme.scheme === "basic") {
99
- return { Authorization: "Basic {{auth_token}}" };
112
+ return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
100
113
  }
101
114
  }
102
115
  if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
103
- // When apiKey scheme uses Authorization header, it's typically a Bearer token
104
116
  if (scheme.apiKeyName === "Authorization") {
105
- return { Authorization: "Bearer {{auth_token}}" };
117
+ return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
106
118
  }
107
119
  return { [scheme.apiKeyName]: "{{api_key}}" };
108
120
  }
@@ -111,14 +123,15 @@ function getAuthHeaders(
111
123
  return undefined;
112
124
  }
113
125
 
114
- function getRequiredQueryParams(ep: EndpointInfo): Record<string, unknown> | undefined {
126
+ function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
115
127
  const queryParams = ep.parameters.filter(p => p.in === "query" && p.required);
116
128
  if (queryParams.length === 0) return undefined;
117
129
 
118
- const query: Record<string, unknown> = {};
130
+ const query: Record<string, string> = {};
119
131
  for (const p of queryParams) {
120
132
  if (p.schema) {
121
- query[p.name] = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
133
+ const val = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
134
+ query[p.name] = typeof val === "object" ? JSON.stringify(val) : String(val);
122
135
  } else {
123
136
  query[p.name] = "{{$randomString}}";
124
137
  }
@@ -387,9 +400,10 @@ export function generateCrudSuite(
387
400
  }
388
401
 
389
402
  /** Find unresolved template variables in a suite (excluding known globals, captured vars, and env keys) */
390
- export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>): string[] {
403
+ export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>, extraKnown?: Set<string>): string[] {
391
404
  const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
392
405
  if (envKeys) for (const k of envKeys) KNOWN.add(k);
406
+ if (extraKnown) for (const k of extraKnown) KNOWN.add(k);
393
407
  const captured = new Set<string>();
394
408
  for (const step of suite.tests) {
395
409
  if (step.expect?.body) {
@@ -518,8 +532,13 @@ function generateConsistentAuthSuite(
518
532
  /token|access_token|accessToken|jwt/i.test(k)
519
533
  );
520
534
  if (tokenField) {
535
+ // Determine the capture variable name based on the login endpoint's security scheme
536
+ const loginScheme = loginEp.security.length > 0
537
+ ? securitySchemes.find(s => s.name === loginEp.security[0])
538
+ : undefined;
539
+ const captureVar = loginScheme ? schemeVarName(loginScheme, securitySchemes) : "auth_token";
521
540
  if (!loginStep.expect.body) loginStep.expect.body = {};
522
- loginStep.expect.body[tokenField] = { capture: "auth_token" };
541
+ loginStep.expect.body[tokenField] = { capture: captureVar };
523
542
  }
524
543
  }
525
544
  tests.push(loginStep);
@@ -12,3 +12,29 @@ export function filterSuitesByTags(suites: TestSuite[], tags: string[]): TestSui
12
12
  return suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
13
13
  });
14
14
  }
15
+
16
+ /**
17
+ * Exclude suites whose tags intersect with the exclusion set (OR logic, case-insensitive).
18
+ * Suites without tags are kept.
19
+ */
20
+ export function excludeSuitesByTags(suites: TestSuite[], excludeTags: string[]): TestSuite[] {
21
+ if (excludeTags.length === 0) return suites;
22
+ const normalizedTags = excludeTags.map(t => t.toLowerCase());
23
+ return suites.filter(suite => {
24
+ if (!suite.tags || suite.tags.length === 0) return true;
25
+ return !suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Filter test steps within suites by HTTP method (case-insensitive).
31
+ * Suites with no remaining tests are removed.
32
+ */
33
+ export function filterSuitesByMethod(suites: TestSuite[], method: string): TestSuite[] {
34
+ const upperMethod = method.toUpperCase();
35
+ const filtered = suites.map(suite => ({
36
+ ...suite,
37
+ tests: suite.tests.filter(t => (t.method ?? "GET").toUpperCase() === upperMethod),
38
+ }));
39
+ return filtered.filter(s => s.tests.length > 0);
40
+ }
package/src/db/queries.ts CHANGED
@@ -292,6 +292,30 @@ export function getResultsByRunId(runId: number): StoredStepResult[] {
292
292
  }));
293
293
  }
294
294
 
295
+ export function getFilteredResults(runId: number, filters: { method?: string; status?: number }): StoredStepResult[] {
296
+ const db = getDb();
297
+ const conditions = ["run_id = ?"];
298
+ const params: (string | number)[] = [runId];
299
+
300
+ if (filters.method) {
301
+ conditions.push("request_method = ?");
302
+ params.push(filters.method.toUpperCase());
303
+ }
304
+ if (filters.status !== undefined) {
305
+ conditions.push("response_status = ?");
306
+ params.push(filters.status);
307
+ }
308
+
309
+ const rows = db.query(`SELECT * FROM results WHERE ${conditions.join(" AND ")} ORDER BY id`).all(...params) as Array<
310
+ Omit<StoredStepResult, "assertions" | "captures"> & { assertions: string | null; captures: string | null }
311
+ >;
312
+ return rows.map((row) => ({
313
+ ...row,
314
+ assertions: row.assertions ? JSON.parse(row.assertions) : [],
315
+ captures: row.captures ? JSON.parse(row.captures) : {},
316
+ }));
317
+ }
318
+
295
319
  // ──────────────────────────────────────────────
296
320
  // Dashboard metrics
297
321
  // ──────────────────────────────────────────────
package/src/web/server.ts CHANGED
@@ -4,7 +4,7 @@ import dashboard from "./routes/dashboard.ts";
4
4
  import runs from "./routes/runs.ts";
5
5
  import api from "./routes/api.ts";
6
6
  import styleCssPath from "./static/style.css" with { type: "file" };
7
- import htmxJsPath from "./static/htmx.min.js" with { type: "file" };
7
+ import htmxJsPath from "./static/htmx.min.cjs" with { type: "file" };
8
8
 
9
9
  export interface ServerOptions {
10
10
  port?: number;
File without changes