@kirrosh/zond 0.21.0 → 0.23.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 (256) hide show
  1. package/CHANGELOG.md +758 -3
  2. package/README.md +78 -15
  3. package/package.json +17 -10
  4. package/src/cli/argv.ts +122 -0
  5. package/src/cli/commands/add-api.ts +134 -0
  6. package/src/cli/commands/api/annotate/idempotency.ts +59 -0
  7. package/src/cli/commands/api/annotate/index.ts +525 -0
  8. package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
  9. package/src/cli/commands/api/annotate/overlay.ts +206 -0
  10. package/src/cli/commands/api/annotate/pagination.ts +60 -0
  11. package/src/cli/commands/api/annotate/prompts.ts +183 -0
  12. package/src/cli/commands/api/annotate/readback.ts +58 -0
  13. package/src/cli/commands/api/annotate/resources.ts +91 -0
  14. package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
  15. package/src/cli/commands/audit.ts +480 -0
  16. package/src/cli/commands/bootstrap.ts +710 -0
  17. package/src/cli/commands/catalog.ts +35 -0
  18. package/src/cli/commands/check.ts +348 -0
  19. package/src/cli/commands/checks.ts +756 -0
  20. package/src/cli/commands/ci-init.ts +55 -6
  21. package/src/cli/commands/clean.ts +212 -0
  22. package/src/cli/commands/cleanup.ts +262 -0
  23. package/src/cli/commands/completions.ts +192 -0
  24. package/src/cli/commands/coverage.ts +605 -132
  25. package/src/cli/commands/db.ts +180 -8
  26. package/src/cli/commands/describe.ts +37 -2
  27. package/src/cli/commands/discover.ts +1236 -0
  28. package/src/cli/commands/doctor.ts +607 -0
  29. package/src/cli/commands/fixtures.ts +402 -0
  30. package/src/cli/commands/generate.ts +420 -47
  31. package/src/cli/commands/init/agents-md.ts +61 -0
  32. package/src/cli/commands/init/bootstrap.ts +108 -0
  33. package/src/cli/commands/init/index.ts +244 -0
  34. package/src/cli/commands/init/skills.ts +98 -0
  35. package/src/cli/commands/init/templates/agents.md +77 -0
  36. package/src/cli/commands/init/templates/markdown.d.ts +4 -0
  37. package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
  38. package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
  39. package/src/cli/commands/init/templates/skills/zond.md +651 -0
  40. package/src/cli/commands/init/templates/zond-config.yml +14 -0
  41. package/src/cli/commands/prepare-fixtures.ts +135 -0
  42. package/src/cli/commands/probe/mass-assignment.ts +503 -0
  43. package/src/cli/commands/probe/security.ts +454 -0
  44. package/src/cli/commands/probe/static.ts +255 -0
  45. package/src/cli/commands/probe/webhooks.ts +161 -0
  46. package/src/cli/commands/probe.ts +459 -0
  47. package/src/cli/commands/reference.ts +87 -0
  48. package/src/cli/commands/refresh-api.ts +169 -0
  49. package/src/cli/commands/remove-api.ts +150 -0
  50. package/src/cli/commands/report-bundle.ts +318 -0
  51. package/src/cli/commands/report.ts +241 -0
  52. package/src/cli/commands/request.ts +379 -4
  53. package/src/cli/commands/run.ts +911 -33
  54. package/src/cli/commands/session.ts +244 -0
  55. package/src/cli/commands/use.ts +74 -0
  56. package/src/cli/index.ts +36 -607
  57. package/src/cli/json-envelope.ts +112 -3
  58. package/src/cli/json-schemas.ts +263 -0
  59. package/src/cli/program.ts +218 -0
  60. package/src/cli/resolve.ts +105 -0
  61. package/src/cli/status-filter.ts +124 -0
  62. package/src/cli/util/api-context.ts +85 -0
  63. package/src/cli/version.ts +8 -0
  64. package/src/core/anti-fp/bootstrap.ts +34 -0
  65. package/src/core/anti-fp/index.ts +33 -0
  66. package/src/core/anti-fp/registry.ts +44 -0
  67. package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
  68. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
  69. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
  70. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
  71. package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
  72. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
  73. package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
  74. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
  75. package/src/core/anti-fp/types.ts +68 -0
  76. package/src/core/checks/checks/_crud-helpers.ts +133 -0
  77. package/src/core/checks/checks/_negative_mutator.ts +133 -0
  78. package/src/core/checks/checks/_readback-helpers.ts +133 -0
  79. package/src/core/checks/checks/content_type_conformance.ts +39 -0
  80. package/src/core/checks/checks/cross_call_references.ts +134 -0
  81. package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
  82. package/src/core/checks/checks/idempotency_replay.ts +246 -0
  83. package/src/core/checks/checks/ignored_auth.ts +211 -0
  84. package/src/core/checks/checks/index.ts +65 -0
  85. package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
  86. package/src/core/checks/checks/missing_required_header.ts +40 -0
  87. package/src/core/checks/checks/negative_data_rejection.ts +45 -0
  88. package/src/core/checks/checks/not_a_server_error.ts +27 -0
  89. package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
  90. package/src/core/checks/checks/pagination_invariants.ts +238 -0
  91. package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
  92. package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
  93. package/src/core/checks/checks/response_headers_conformance.ts +74 -0
  94. package/src/core/checks/checks/response_schema_conformance.ts +30 -0
  95. package/src/core/checks/checks/status_code_conformance.ts +61 -0
  96. package/src/core/checks/checks/unsupported_method.ts +63 -0
  97. package/src/core/checks/checks/use_after_free.ts +78 -0
  98. package/src/core/checks/index.ts +30 -0
  99. package/src/core/checks/mode.ts +79 -0
  100. package/src/core/checks/recommended-action.ts +64 -0
  101. package/src/core/checks/registry.ts +78 -0
  102. package/src/core/checks/runner.ts +874 -0
  103. package/src/core/checks/sarif.ts +230 -0
  104. package/src/core/checks/stateful.ts +121 -0
  105. package/src/core/checks/types.ts +189 -0
  106. package/src/core/classifier/recommended-action.ts +222 -0
  107. package/src/core/context/current.ts +51 -0
  108. package/src/core/context/session.ts +78 -0
  109. package/src/core/coverage/loader.ts +185 -0
  110. package/src/core/coverage/reasons.ts +300 -0
  111. package/src/core/diagnostics/db-analysis.ts +161 -12
  112. package/src/core/diagnostics/failure-class.ts +120 -0
  113. package/src/core/diagnostics/failure-hints.ts +212 -9
  114. package/src/core/diagnostics/spec-pointer.ts +99 -0
  115. package/src/core/diagnostics/suggested-fixes.ts +156 -0
  116. package/src/core/exporter/case-study/index.ts +270 -0
  117. package/src/core/exporter/curl.ts +40 -0
  118. package/src/core/exporter/exporter.ts +48 -0
  119. package/src/core/exporter/html-report/escape.ts +24 -0
  120. package/src/core/exporter/html-report/index.ts +479 -0
  121. package/src/core/exporter/html-report/script.ts +100 -0
  122. package/src/core/exporter/html-report/styles.ts +408 -0
  123. package/src/core/generator/chunker.ts +53 -15
  124. package/src/core/generator/coverage-phase.ts +0 -0
  125. package/src/core/generator/create-body.ts +89 -0
  126. package/src/core/generator/data-factory.ts +490 -33
  127. package/src/core/generator/describe.ts +1 -1
  128. package/src/core/generator/fixtures-builder.ts +325 -0
  129. package/src/core/generator/index.ts +7 -5
  130. package/src/core/generator/openapi-reader.ts +55 -3
  131. package/src/core/generator/path-param-disambig.ts +114 -0
  132. package/src/core/generator/resources-builder.ts +648 -0
  133. package/src/core/generator/schema-utils.ts +11 -3
  134. package/src/core/generator/serializer.ts +114 -15
  135. package/src/core/generator/suite-generator.ts +484 -77
  136. package/src/core/generator/types.ts +8 -0
  137. package/src/core/identity/identity-file.ts +129 -0
  138. package/src/core/lint/affects.ts +28 -0
  139. package/src/core/lint/config.ts +96 -0
  140. package/src/core/lint/format.ts +42 -0
  141. package/src/core/lint/index.ts +94 -0
  142. package/src/core/lint/reporter.ts +128 -0
  143. package/src/core/lint/rules/consistency.ts +158 -0
  144. package/src/core/lint/rules/heuristics.ts +97 -0
  145. package/src/core/lint/rules/strictness.ts +109 -0
  146. package/src/core/lint/types.ts +96 -0
  147. package/src/core/lint/walker.ts +248 -0
  148. package/src/core/meta/meta-store.ts +6 -73
  149. package/src/core/output/README.md +91 -0
  150. package/src/core/output/index.ts +13 -0
  151. package/src/core/output/run.ts +126 -0
  152. package/src/core/output/types.ts +129 -0
  153. package/src/core/parser/env-interpolation.ts +104 -0
  154. package/src/core/parser/filter.ts +57 -0
  155. package/src/core/parser/schema.ts +132 -5
  156. package/src/core/parser/types.ts +29 -2
  157. package/src/core/parser/variables.ts +0 -0
  158. package/src/core/parser/yaml-parser.ts +108 -13
  159. package/src/core/probe/bootstrap.ts +34 -0
  160. package/src/core/probe/dry-run-envelope.ts +57 -0
  161. package/src/core/probe/mass-assignment-probe-class.ts +198 -0
  162. package/src/core/probe/mass-assignment-probe.ts +1122 -0
  163. package/src/core/probe/mass-assignment-template.ts +212 -0
  164. package/src/core/probe/method-probe.ts +164 -0
  165. package/src/core/probe/method-shared.ts +69 -0
  166. package/src/core/probe/negative-probe.ts +691 -0
  167. package/src/core/probe/orphan-tracker.ts +188 -0
  168. package/src/core/probe/path-discovery.ts +440 -0
  169. package/src/core/probe/probe-harness.ts +120 -0
  170. package/src/core/probe/registry.ts +89 -0
  171. package/src/core/probe/runner.ts +136 -0
  172. package/src/core/probe/security-probe-class.ts +201 -0
  173. package/src/core/probe/security-probe.ts +1453 -0
  174. package/src/core/probe/shared.ts +505 -0
  175. package/src/core/probe/static-probe-class.ts +125 -0
  176. package/src/core/probe/types.ts +165 -0
  177. package/src/core/probe/verdict-aggregator.ts +33 -0
  178. package/src/core/probe/webhooks-probe.ts +284 -0
  179. package/src/core/reporter/console.ts +69 -4
  180. package/src/core/reporter/index.ts +2 -3
  181. package/src/core/reporter/json.ts +15 -2
  182. package/src/core/reporter/junit.ts +27 -12
  183. package/src/core/reporter/ndjson.ts +37 -0
  184. package/src/core/reporter/types.ts +3 -0
  185. package/src/core/runner/assertions.ts +62 -2
  186. package/src/core/runner/async-pool.ts +108 -0
  187. package/src/core/runner/auth-path.ts +8 -0
  188. package/src/core/runner/ci-context.ts +72 -0
  189. package/src/core/runner/executor.ts +391 -52
  190. package/src/core/runner/form-encode.ts +51 -0
  191. package/src/core/runner/http-client.ts +115 -7
  192. package/src/core/runner/learn-drift.ts +293 -0
  193. package/src/core/runner/preflight-vars.ts +149 -0
  194. package/src/core/runner/progress-tracker.ts +73 -0
  195. package/src/core/runner/rate-limiter.ts +203 -0
  196. package/src/core/runner/run-kind.ts +39 -0
  197. package/src/core/runner/schema-validator.ts +312 -0
  198. package/src/core/runner/send-request.ts +153 -20
  199. package/src/core/runner/types.ts +38 -0
  200. package/src/core/secrets/registry.ts +164 -0
  201. package/src/core/secrets/secrets-file.ts +115 -0
  202. package/src/core/selectors/operation-filter.ts +144 -0
  203. package/src/core/setup-api.ts +419 -17
  204. package/src/core/severity/category.ts +94 -0
  205. package/src/core/severity/index.ts +121 -0
  206. package/src/core/spec/layers.ts +154 -0
  207. package/src/core/util/format-eta.ts +21 -0
  208. package/src/core/utils.ts +5 -1
  209. package/src/core/workspace/config.ts +129 -0
  210. package/src/core/workspace/manifest.ts +283 -0
  211. package/src/core/workspace/output-rotation.ts +62 -0
  212. package/src/core/workspace/root.ts +94 -0
  213. package/src/core/workspace/triage-path.ts +87 -0
  214. package/src/db/lint-runs.ts +47 -0
  215. package/src/db/migrate.ts +126 -0
  216. package/src/db/migrations/0001_run_kind.sql +25 -0
  217. package/src/db/migrations/sql.d.ts +4 -0
  218. package/src/db/queries/collections.ts +133 -0
  219. package/src/db/queries/coverage.ts +9 -0
  220. package/src/db/queries/dashboard.ts +59 -0
  221. package/src/db/queries/results.ts +128 -0
  222. package/src/db/queries/runs.ts +235 -0
  223. package/src/db/queries/sessions.ts +42 -0
  224. package/src/db/queries/settings.ts +28 -0
  225. package/src/db/queries/types.ts +172 -0
  226. package/src/db/queries.ts +72 -802
  227. package/src/db/schema.ts +179 -48
  228. package/src/cli/commands/export.ts +0 -144
  229. package/src/cli/commands/guide.ts +0 -127
  230. package/src/cli/commands/init.ts +0 -57
  231. package/src/cli/commands/serve.ts +0 -81
  232. package/src/cli/commands/sync.ts +0 -269
  233. package/src/cli/commands/update.ts +0 -189
  234. package/src/cli/commands/validate.ts +0 -34
  235. package/src/core/exporter/postman.ts +0 -963
  236. package/src/core/generator/guide-builder.ts +0 -253
  237. package/src/core/meta/types.ts +0 -21
  238. package/src/core/parser/index.ts +0 -21
  239. package/src/core/runner/execute-run.ts +0 -132
  240. package/src/core/runner/index.ts +0 -12
  241. package/src/core/sync/spec-differ.ts +0 -38
  242. package/src/web/data/collection-state.ts +0 -362
  243. package/src/web/routes/api.ts +0 -314
  244. package/src/web/routes/dashboard.ts +0 -350
  245. package/src/web/routes/runs.ts +0 -64
  246. package/src/web/schemas.ts +0 -121
  247. package/src/web/server.ts +0 -134
  248. package/src/web/static/htmx.min.cjs +0 -1
  249. package/src/web/static/style.css +0 -1148
  250. package/src/web/views/endpoints-tab.ts +0 -174
  251. package/src/web/views/explorer-tab.ts +0 -402
  252. package/src/web/views/health-strip.ts +0 -92
  253. package/src/web/views/layout.ts +0 -48
  254. package/src/web/views/results.ts +0 -210
  255. package/src/web/views/runs-tab.ts +0 -126
  256. package/src/web/views/suites-tab.ts +0 -181
@@ -0,0 +1,402 @@
1
+ /**
2
+ * `zond fixtures` umbrella — manual fixture-bootstrap UX (ARV-195).
3
+ *
4
+ * Two subcommands today, both targeting the case `prepare-fixtures
5
+ * --seed` cannot solve:
6
+ *
7
+ * • `zond fixtures add <var>=<id> [--validate]`
8
+ * Set a fixture by hand. With `--validate` the command GETs the
9
+ * resource's read-by-id endpoint and classifies the value as
10
+ * `live` (200/2xx), `stale` (404), or `unknown` (no read endpoint
11
+ * wired or non-2xx/non-404 status).
12
+ *
13
+ * • `zond fixtures import --from-curl`
14
+ * Paste a curl command (from a vendor dashboard / Chrome
15
+ * devtools) on stdin or via `--curl <text>`. The URL is matched
16
+ * against `apis/<name>/spec.json` paths; every `{var}` segment
17
+ * whose corresponding part of the URL is a literal id contributes
18
+ * a fixture. Reports the inferred map; with `--apply` writes it
19
+ * to `.env.yaml` (with .bak backup).
20
+ *
21
+ * Both commands target `apis/<name>/.env.yaml` resolved via the standard
22
+ * --api / ZOND_API / .zond/current-api chain. They never touch the
23
+ * manifest (.api-fixtures.yaml) — vars not in the manifest are still
24
+ * written but flagged as `not in manifest, ignored` by the next
25
+ * `prepare-fixtures` run, mirroring existing semantics.
26
+ */
27
+ import type { Command } from "commander";
28
+ import { join } from "node:path";
29
+ import { copyFile } from "node:fs/promises";
30
+
31
+ import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
32
+ import { resolveApiCollection } from "../resolve.ts";
33
+ import { resolveCollectionSpec } from "../../core/setup-api.ts";
34
+ import { findCollectionByNameOrId } from "../../db/queries.ts";
35
+ import { readOpenApiSpec } from "../../core/generator/index.ts";
36
+ import { upsertEnvLine } from "./discover.ts";
37
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
38
+ import { printError, printSuccess } from "../output.ts";
39
+ import { globalJson } from "../resolve.ts";
40
+ import { executeRequest } from "../../core/runner/http-client.ts";
41
+ import { loadEnvFile } from "../../core/parser/variables.ts";
42
+
43
+ interface AddOptions {
44
+ api?: string;
45
+ validate?: boolean;
46
+ apply?: boolean;
47
+ json?: boolean;
48
+ }
49
+
50
+ interface ImportOptions {
51
+ api?: string;
52
+ fromCurl?: boolean;
53
+ curl?: string;
54
+ apply?: boolean;
55
+ json?: boolean;
56
+ }
57
+
58
+ function resolveApiContext(
59
+ cmd: Command,
60
+ optsApi: string | undefined,
61
+ json: boolean,
62
+ ): { apiName: string; baseDir: string; specPath: string; envPath: string } | { error: string } {
63
+ const apiName = getApi(cmd, { api: optsApi } as Record<string, unknown>);
64
+ if (!apiName) return { error: MISSING_API_MESSAGE };
65
+ const col = resolveApiCollection(apiName, undefined);
66
+ if ("error" in col) return { error: col.error };
67
+ if (!col.baseDir) return { error: `API '${apiName}' has no base_dir registered.` };
68
+ if (!col.spec) return { error: `API '${apiName}' has no spec registered (run 'zond add api ... --spec ...').` };
69
+ return {
70
+ apiName,
71
+ baseDir: col.baseDir,
72
+ specPath: col.spec,
73
+ envPath: join(col.baseDir, ".env.yaml"),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Walk the spec for read-by-id endpoints (GET on a path containing `{var}`
79
+ * exactly). Returns the first one whose template matches `{<varName>}`
80
+ * verbatim — used by `fixtures add --validate` to GET the resource and
81
+ * decide live/stale/unknown.
82
+ */
83
+ async function findReadEndpointForVar(
84
+ specPath: string,
85
+ varName: string,
86
+ ): Promise<{ method: string; path: string } | null> {
87
+ const doc = await readOpenApiSpec(specPath);
88
+ const placeholder = `{${varName}}`;
89
+ for (const [p, item] of Object.entries(doc.paths ?? {})) {
90
+ if (!item) continue;
91
+ if (!p.includes(placeholder)) continue;
92
+ if ((item as Record<string, unknown>).get) {
93
+ return { method: "GET", path: p };
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ async function readEnv(envPath: string): Promise<Record<string, string>> {
100
+ return (await loadEnvFile(envPath)) ?? {};
101
+ }
102
+
103
+ async function applyEnvWrites(
104
+ envPath: string,
105
+ writes: Record<string, string>,
106
+ ): Promise<{ backup: string | null }> {
107
+ const file = Bun.file(envPath);
108
+ let text = (await file.exists()) ? await file.text() : "";
109
+ let backup: string | null = `${envPath}.bak`;
110
+ if (await file.exists()) {
111
+ try { await copyFile(envPath, backup); } catch { backup = null; }
112
+ } else {
113
+ backup = null;
114
+ }
115
+ for (const [k, v] of Object.entries(writes)) {
116
+ text = upsertEnvLine(text, k, v);
117
+ }
118
+ if (!text.endsWith("\n")) text += "\n";
119
+ await Bun.write(envPath, text);
120
+ return { backup };
121
+ }
122
+
123
+ function fillPath(template: string, vars: Record<string, string>): string {
124
+ return template.replace(/\{([^}]+)\}/g, (_, n) => {
125
+ const v = vars[n];
126
+ return typeof v === "string" && v.length > 0 ? encodeURIComponent(v) : `{${n}}`;
127
+ });
128
+ }
129
+
130
+ async function addAction(
131
+ pairs: string[],
132
+ cmd: Command,
133
+ ): Promise<void> {
134
+ const opts = cmd.opts<AddOptions>();
135
+ const json = opts.json === true || globalJson(cmd);
136
+ const ctx = resolveApiContext(cmd, opts.api, json);
137
+ if ("error" in ctx) {
138
+ if (json) printJson(jsonError("fixtures add", [ctx.error]));
139
+ else printError(ctx.error);
140
+ process.exit(2);
141
+ return;
142
+ }
143
+
144
+ // Parse "var=value" pairs from positionals.
145
+ const writes: Record<string, string> = {};
146
+ for (const raw of pairs) {
147
+ const idx = raw.indexOf("=");
148
+ if (idx <= 0) {
149
+ const m = `Invalid fixture '${raw}' — expected 'var=value'`;
150
+ if (json) printJson(jsonError("fixtures add", [m])); else printError(m);
151
+ process.exit(2);
152
+ return;
153
+ }
154
+ writes[raw.slice(0, idx).trim()] = raw.slice(idx + 1);
155
+ }
156
+ if (Object.keys(writes).length === 0) {
157
+ const m = "No fixtures supplied. Usage: zond fixtures add <var>=<value> [<var>=<value> ...]";
158
+ if (json) printJson(jsonError("fixtures add", [m])); else printError(m);
159
+ process.exit(2);
160
+ return;
161
+ }
162
+
163
+ // ARV-32: optional read-by-id validate per fixture.
164
+ type Validation = { var: string; status: "live" | "stale" | "unknown"; httpStatus?: number; reason?: string };
165
+ const validations: Validation[] = [];
166
+ if (opts.validate) {
167
+ const env = await readEnv(ctx.envPath);
168
+ const baseUrl = env.base_url;
169
+ if (!baseUrl) {
170
+ const m = "Cannot --validate: base_url not set in .env.yaml.";
171
+ if (json) printJson(jsonError("fixtures add", [m])); else printError(m);
172
+ process.exit(2);
173
+ return;
174
+ }
175
+ for (const [k, v] of Object.entries(writes)) {
176
+ const ep = await findReadEndpointForVar(ctx.specPath, k);
177
+ if (!ep) {
178
+ validations.push({ var: k, status: "unknown", reason: "no GET endpoint with {" + k + "} in path" });
179
+ continue;
180
+ }
181
+ const url = `${baseUrl.replace(/\/+$/, "")}${fillPath(ep.path, { ...env, [k]: v })}`;
182
+ try {
183
+ const resp = await executeRequest(
184
+ { method: "GET", url, headers: { accept: "application/json" } },
185
+ { timeout: 10_000, retries: 0, network_retries: 1 },
186
+ );
187
+ if (resp.status >= 200 && resp.status < 300) {
188
+ validations.push({ var: k, status: "live", httpStatus: resp.status });
189
+ } else if (resp.status === 404) {
190
+ validations.push({ var: k, status: "stale", httpStatus: 404 });
191
+ } else {
192
+ validations.push({ var: k, status: "unknown", httpStatus: resp.status, reason: `non-2xx/non-404 status` });
193
+ }
194
+ } catch (err) {
195
+ validations.push({ var: k, status: "unknown", reason: (err as Error).message });
196
+ }
197
+ }
198
+ }
199
+
200
+ let backup: string | null = null;
201
+ if (opts.apply) {
202
+ const result = await applyEnvWrites(ctx.envPath, writes);
203
+ backup = result.backup;
204
+ }
205
+
206
+ if (json) {
207
+ printJson(jsonOk("fixtures add", {
208
+ api: ctx.apiName,
209
+ env: ctx.envPath,
210
+ writes,
211
+ applied: opts.apply === true,
212
+ backup,
213
+ validations,
214
+ }));
215
+ } else {
216
+ if (opts.apply) {
217
+ printSuccess(`Wrote ${Object.keys(writes).length} fixture(s) to ${ctx.envPath}` + (backup ? ` (backup: ${backup})` : ""));
218
+ } else {
219
+ printSuccess(`Dry-run — pass --apply to write to ${ctx.envPath}`);
220
+ }
221
+ for (const [k, v] of Object.entries(writes)) {
222
+ const val = validations.find((x) => x.var === k);
223
+ const tag = val ? ` [${val.status}${val.httpStatus ? " " + val.httpStatus : ""}]${val.reason ? " — " + val.reason : ""}` : "";
224
+ console.log(` ${k} = ${v}${tag}`);
225
+ }
226
+ }
227
+ process.exit(0);
228
+ }
229
+
230
+ /** Strip the `curl` invocation down to the URL. Handles `-X METHOD`,
231
+ * `-H 'Header: v'`, `--data ...`, etc. — we only need the URL here. */
232
+ export function extractUrlFromCurl(curl: string): string | null {
233
+ const cleaned = curl.replace(/\\\n/g, " ").trim();
234
+ // Tokens are space-delimited, but URL values may be quoted. Walk the
235
+ // string with a small state machine so we honour single/double quotes.
236
+ const tokens: string[] = [];
237
+ let buf = "";
238
+ let quote: '"' | "'" | null = null;
239
+ for (let i = 0; i < cleaned.length; i++) {
240
+ const ch = cleaned[i]!;
241
+ if (quote) {
242
+ if (ch === quote) { quote = null; continue; }
243
+ buf += ch;
244
+ continue;
245
+ }
246
+ if (ch === '"' || ch === "'") { quote = ch as '"' | "'"; continue; }
247
+ if (/\s/.test(ch)) {
248
+ if (buf) tokens.push(buf);
249
+ buf = "";
250
+ continue;
251
+ }
252
+ buf += ch;
253
+ }
254
+ if (buf) tokens.push(buf);
255
+
256
+ // First token starting with http(s):// is the URL. Curl also accepts
257
+ // `--url <url>` and `-:` syntax; stay conservative and just look for
258
+ // a URL-shaped token.
259
+ for (const t of tokens) {
260
+ if (/^https?:\/\//i.test(t)) return t;
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /** Match a concrete URL path against spec path templates and extract
266
+ * `{var}` → value bindings. Returns the bindings of the FIRST template
267
+ * that matches the whole path, or empty when nothing matches. */
268
+ export function extractFixturesFromPath(
269
+ url: string,
270
+ specPaths: string[],
271
+ ): { matchedTemplate: string; bindings: Record<string, string> } | null {
272
+ let pathname: string;
273
+ try { pathname = new URL(url).pathname; } catch { return null; }
274
+ // Sort longest-first so a 3-segment template wins over a 1-segment one.
275
+ const sorted = [...specPaths].sort((a, b) => b.split("/").length - a.split("/").length);
276
+ for (const tpl of sorted) {
277
+ const tplSegs = tpl.split("/").filter(Boolean);
278
+ const urlSegs = pathname.split("/").filter(Boolean);
279
+ if (tplSegs.length !== urlSegs.length) continue;
280
+ const bindings: Record<string, string> = {};
281
+ let ok = true;
282
+ for (let i = 0; i < tplSegs.length; i++) {
283
+ const ts = tplSegs[i]!;
284
+ const us = urlSegs[i]!;
285
+ const m = ts.match(/^\{([^}]+)\}$/);
286
+ if (m) {
287
+ try { bindings[m[1]!] = decodeURIComponent(us); } catch { bindings[m[1]!] = us; }
288
+ } else if (ts !== us) {
289
+ ok = false;
290
+ break;
291
+ }
292
+ }
293
+ if (ok) return { matchedTemplate: tpl, bindings };
294
+ }
295
+ return null;
296
+ }
297
+
298
+ async function importAction(cmd: Command): Promise<void> {
299
+ const opts = cmd.opts<ImportOptions>();
300
+ const json = opts.json === true || globalJson(cmd);
301
+ const ctx = resolveApiContext(cmd, opts.api, json);
302
+ if ("error" in ctx) {
303
+ if (json) printJson(jsonError("fixtures import", [ctx.error]));
304
+ else printError(ctx.error);
305
+ process.exit(2);
306
+ return;
307
+ }
308
+ if (opts.fromCurl !== true) {
309
+ const m = "Required: --from-curl. (Other importers can be added later.)";
310
+ if (json) printJson(jsonError("fixtures import", [m])); else printError(m);
311
+ process.exit(2);
312
+ return;
313
+ }
314
+
315
+ let curl = opts.curl;
316
+ if (!curl) {
317
+ // Read from stdin so the user can `pbpaste | zond fixtures import --from-curl`.
318
+ curl = (await Bun.stdin.text()).trim();
319
+ }
320
+ if (!curl || curl.length === 0) {
321
+ const m = "No curl input — pipe a 'curl ...' command on stdin or pass --curl '<text>'.";
322
+ if (json) printJson(jsonError("fixtures import", [m])); else printError(m);
323
+ process.exit(2);
324
+ return;
325
+ }
326
+
327
+ const url = extractUrlFromCurl(curl);
328
+ if (!url) {
329
+ const m = "Could not extract a URL from the curl input.";
330
+ if (json) printJson(jsonError("fixtures import", [m])); else printError(m);
331
+ process.exit(2);
332
+ return;
333
+ }
334
+
335
+ const doc = await readOpenApiSpec(ctx.specPath);
336
+ const specPaths = Object.keys(doc.paths ?? {});
337
+ const match = extractFixturesFromPath(url, specPaths);
338
+ if (!match || Object.keys(match.bindings).length === 0) {
339
+ const m = `URL '${url}' did not match any path template in the spec, or had no {var} bindings.`;
340
+ if (json) printJson(jsonError("fixtures import", [m])); else printError(m);
341
+ process.exit(2);
342
+ return;
343
+ }
344
+
345
+ let backup: string | null = null;
346
+ if (opts.apply) {
347
+ const result = await applyEnvWrites(ctx.envPath, match.bindings);
348
+ backup = result.backup;
349
+ }
350
+
351
+ if (json) {
352
+ printJson(jsonOk("fixtures import", {
353
+ api: ctx.apiName,
354
+ env: ctx.envPath,
355
+ source: { kind: "curl", url, matchedTemplate: match.matchedTemplate },
356
+ writes: match.bindings,
357
+ applied: opts.apply === true,
358
+ backup,
359
+ }));
360
+ } else {
361
+ if (opts.apply) {
362
+ printSuccess(`Imported ${Object.keys(match.bindings).length} fixture(s) from curl URL`);
363
+ console.log(` source: ${url}`);
364
+ console.log(` matched: ${match.matchedTemplate}`);
365
+ console.log(` wrote to: ${ctx.envPath}` + (backup ? ` (backup: ${backup})` : ""));
366
+ } else {
367
+ printSuccess(`Dry-run — pass --apply to write to ${ctx.envPath}`);
368
+ console.log(` matched: ${match.matchedTemplate}`);
369
+ }
370
+ for (const [k, v] of Object.entries(match.bindings)) {
371
+ console.log(` ${k} = ${v}`);
372
+ }
373
+ }
374
+ process.exit(0);
375
+ }
376
+
377
+ export function registerFixtures(program: Command): void {
378
+ const fixtures = program
379
+ .command("fixtures")
380
+ .description("Manual fixture-bootstrap helpers (ARV-195) — `add` and `import`. Complements `zond prepare-fixtures` for the cases auto-discover/--seed cannot solve (path-FK ids hidden in vendor dashboards, manual sandbox setup).");
381
+
382
+ fixtures
383
+ .command("add <pairs...>")
384
+ .description("Set one or more fixtures: 'var=value'. Optionally validate by GETing the spec's read-by-id endpoint for the var.")
385
+ .option("--api <name>", "Registered API (apis/<name>/.env.yaml). Falls back to ZOND_API / .zond/current-api.")
386
+ .option("--validate", "GET the resource's read-by-id endpoint and classify each value as live/stale/unknown.")
387
+ .option("--apply", "Write the fixtures to .env.yaml (with .env.yaml.bak backup). Default: dry-run.")
388
+ .action(async (pairs: string[], _opts, cmd: Command) => {
389
+ await addAction(pairs, cmd);
390
+ });
391
+
392
+ fixtures
393
+ .command("import")
394
+ .description("Import fixtures from an external source. Today: --from-curl (paste a curl command from a vendor dashboard / Chrome devtools).")
395
+ .option("--api <name>", "Registered API (apis/<name>/.env.yaml). Falls back to ZOND_API / .zond/current-api.")
396
+ .option("--from-curl", "Treat input as a curl command. Reads from stdin or --curl <text>.")
397
+ .option("--curl <text>", "Inline curl command (alternative to stdin).")
398
+ .option("--apply", "Write the inferred fixtures to .env.yaml (with .env.yaml.bak backup). Default: dry-run.")
399
+ .action(async (_opts, cmd: Command) => {
400
+ await importAction(cmd);
401
+ });
402
+ }