@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,710 @@
1
+ /**
2
+ * `zond bootstrap` — one-shot setup of an empty workspace until `.env.yaml`
3
+ * has enough FK fixtures to run generated tests (TASK-261).
4
+ *
5
+ * The shape of the problem: `zond discover` already fills FK ids from
6
+ * list-endpoints, but only when their parent fixtures are present. On a
7
+ * fresh workspace nearly every FK is nested (`/orgs/{org}/projects/`,
8
+ * `/projects/{org}/{proj}/keys/` ...) so a single discover pass quits with
9
+ * `miss-nested-list` for ~80% of vars. Bootstrap closes that loop:
10
+ *
11
+ * 1. cascade discover — repeat until no new fixtures land in a pass;
12
+ * 2. (optional) seed — for vars discover couldn't satisfy (empty list,
13
+ * list-only owner with no element to grab), POST a generated body to
14
+ * the resource's `create` endpoint, capture the id, write it back;
15
+ * 3. final discover sweep — children of seeded parents.
16
+ *
17
+ * Idempotent by construction: `discover`'s "skip-already-set" logic means
18
+ * a re-run with the same env is a no-op for filled vars unless `--force`
19
+ * is passed. Seeds aren't re-attempted when their owner var is already
20
+ * filled — natural deduplication without a state file.
21
+ */
22
+
23
+ import { join } from "path";
24
+ import { copyFile } from "fs/promises";
25
+ import {
26
+ readOpenApiSpec,
27
+ extractEndpoints,
28
+ extractSecuritySchemes,
29
+ } from "../../core/generator/index.ts";
30
+ import { buildCreateRequestBody } from "../../core/generator/create-body.ts";
31
+ import { loadEnvFile, substituteDeep } from "../../core/parser/variables.ts";
32
+ import { encodeFormBody } from "../../core/runner/form-encode.ts";
33
+ import { liveAuthHeaders } from "../../core/probe/shared.ts";
34
+ import { executeRequest } from "../../core/runner/http-client.ts";
35
+ import {
36
+ collectTargets,
37
+ isPlaceholder,
38
+ probeOne,
39
+ readFixtureManifest,
40
+ readResourceMap,
41
+ upsertEnvLine,
42
+ type DiscoveryItem,
43
+ type FkTarget,
44
+ type ResourceYaml,
45
+ } from "./discover.ts";
46
+ import { printError, printSuccess, printWarning } from "../output.ts";
47
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
48
+ import type { EndpointInfo, SecuritySchemeInfo } from "../../core/generator/types.ts";
49
+
50
+ export interface BootstrapOptions {
51
+ specPath: string;
52
+ apiDir: string;
53
+ envPath?: string;
54
+ apply?: boolean;
55
+ /** Re-create seed resources / re-fetch ids even when the var is filled. */
56
+ force?: boolean;
57
+ /** POST to create endpoints when discover can't find an existing record. */
58
+ seed?: boolean;
59
+ timeoutMs?: number;
60
+ /** Hard cap on cascade passes — defends against pathological loops. */
61
+ maxPasses?: number;
62
+ json?: boolean;
63
+ /** ARV-205/F19 (R10/R13): command name surfaced in the JSON envelope.
64
+ * prepare-fixtures delegates here for `--cascade`, but a generic
65
+ * "bootstrap" label in the envelope misleads anyone filtering by
66
+ * command. Caller passes the human-facing name; defaults to "bootstrap"
67
+ * for back-compat with direct invocations. */
68
+ commandName?: string;
69
+ }
70
+
71
+ interface SeedAttempt {
72
+ varName: string;
73
+ resource: string;
74
+ createPath: string;
75
+ status:
76
+ | "seeded"
77
+ | "skip-already-set"
78
+ | "skip-no-create"
79
+ | "skip-no-schema"
80
+ | "miss-network"
81
+ | "miss-status"
82
+ | "miss-seed-422"
83
+ | "miss-no-id";
84
+ capturedId?: string;
85
+ reason?: string;
86
+ /** ARV-47: short curl-style repro printed for 4xx seed failures so the
87
+ * user (or downstream agent) can reproduce the exact request without
88
+ * poking through structured envelopes. */
89
+ repro?: string;
90
+ }
91
+
92
+ interface Pass {
93
+ index: number;
94
+ items: DiscoveryItem[];
95
+ newWrites: string[];
96
+ }
97
+
98
+ function parseEndpointLabel(label: string): { method: string; path: string } | null {
99
+ const parts = label.trim().split(/\s+/);
100
+ if (parts.length < 2) return null;
101
+ return { method: parts[0]!.toUpperCase(), path: parts[1]! };
102
+ }
103
+
104
+ /** Vars whose value is currently a real fixture (non-empty, non-TODO). */
105
+ function nonEmptyVars(env: Record<string, string>): Record<string, string> {
106
+ const out: Record<string, string> = {};
107
+ for (const [k, v] of Object.entries(env)) {
108
+ if (typeof v === "string" && !isPlaceholder(v)) out[k] = v;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Pick the response field that matches `captureField`/common id fallbacks. */
114
+ function captureFromResponse(body: unknown, preferred: string): string | undefined {
115
+ if (!body || typeof body !== "object") return undefined;
116
+ // Some APIs wrap created entities in { data: { ... } }.
117
+ let payload: Record<string, unknown> = body as Record<string, unknown>;
118
+ for (const wrapper of ["data", "result", "item"]) {
119
+ if (
120
+ payload[wrapper] &&
121
+ typeof payload[wrapper] === "object" &&
122
+ !Array.isArray(payload[wrapper])
123
+ ) {
124
+ payload = payload[wrapper] as Record<string, unknown>;
125
+ break;
126
+ }
127
+ }
128
+ const tryKey = (k: string): string | undefined => {
129
+ const v = payload[k];
130
+ if (typeof v === "string" || typeof v === "number") return String(v);
131
+ return undefined;
132
+ };
133
+ return (
134
+ tryKey(preferred) ?? tryKey("id") ?? tryKey("slug") ?? tryKey("uuid") ?? tryKey("key") ?? tryKey("name")
135
+ );
136
+ }
137
+
138
+ /** Resolve the resource record that *creates* `varName` — i.e. the resource
139
+ * whose `idParam` matches the var, with a `create` endpoint. */
140
+ function findOwnerResourceForSeed(
141
+ varName: string,
142
+ resources: ResourceYaml[],
143
+ ): ResourceYaml | undefined {
144
+ // Strategy 1: idParam matches exactly.
145
+ let owner = resources.find(r => r.idParam === varName && r.endpoints?.create);
146
+ if (owner) return owner;
147
+ // Strategy 2: another resource references `varName` via fkDependencies and
148
+ // names its ownerResource. Look that up.
149
+ for (const r of resources) {
150
+ for (const dep of r.fkDependencies ?? []) {
151
+ if (dep.var === varName && dep.ownerResource) {
152
+ owner = resources.find(x => x.resource === dep.ownerResource && x.endpoints?.create);
153
+ if (owner) return owner;
154
+ }
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+
160
+ /** ARV-47: build a one-line `curl` invocation that reproduces the failed
161
+ * seed request. Headers carrying secrets (Authorization, X-API-Key) are
162
+ * redacted to a `<REDACTED>` placeholder so the repro can ship in stderr
163
+ * / JSON envelopes without leaking tokens. */
164
+ function buildCurlRepro(
165
+ method: string,
166
+ url: string,
167
+ headers: Record<string, string>,
168
+ body: unknown,
169
+ serializedBody?: string,
170
+ ): string {
171
+ const parts: string[] = [`curl -X ${method} ${JSON.stringify(url)}`];
172
+ for (const [k, v] of Object.entries(headers)) {
173
+ const lk = k.toLowerCase();
174
+ const redacted = lk === "authorization" || lk === "x-api-key" || lk === "api-key";
175
+ parts.push(`-H ${JSON.stringify(`${k}: ${redacted ? "<REDACTED>" : v}`)}`);
176
+ }
177
+ // ARV-196: when the on-the-wire body is form-urlencoded (Stripe-style),
178
+ // the curl repro must mirror that exactly — JSON.stringify(body) would
179
+ // confuse the user (and not actually reproduce the request).
180
+ const wireBody = serializedBody ?? JSON.stringify(body);
181
+ parts.push(`-d ${JSON.stringify(wireBody)}`);
182
+ return parts.join(" ");
183
+ }
184
+
185
+ /** Pull the first useful validation message out of a 422 response body. */
186
+ function extractFirst422Detail(body: unknown): string | undefined {
187
+ if (typeof body === "string") return body.length < 200 ? body : undefined;
188
+ if (!body || typeof body !== "object") return undefined;
189
+ const obj = body as Record<string, unknown>;
190
+ if (typeof obj.detail === "string") return obj.detail;
191
+ if (typeof obj.message === "string") return obj.message;
192
+ if (typeof obj.error === "string") return obj.error;
193
+ // FastAPI / many SaaS APIs nest details: { detail: [{ loc, msg }] }
194
+ if (Array.isArray(obj.detail) && obj.detail.length > 0) {
195
+ const first = obj.detail[0] as { msg?: string; loc?: unknown[] };
196
+ if (first?.msg) return `${first.msg}${Array.isArray(first.loc) ? ` at ${first.loc.join(".")}` : ""}`;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ /** Substitute path-params in a path with values from vars. Returns
202
+ * `{ resolved, missing }`; missing is non-empty when a parent fixture is
203
+ * still absent — caller defers the seed for a later cascade pass. */
204
+ function resolvePath(path: string, vars: Record<string, string>): { resolved: string; missing: string[] } {
205
+ const missing: string[] = [];
206
+ const resolved = path.replace(/\{([^}]+)\}/g, (_, name: string) => {
207
+ const v = vars[name];
208
+ if (typeof v === "string" && v) return v;
209
+ missing.push(name);
210
+ return `{${name}}`;
211
+ });
212
+ return { resolved, missing };
213
+ }
214
+
215
+ async function trySeed(
216
+ varName: string,
217
+ resources: ResourceYaml[],
218
+ endpoints: EndpointInfo[],
219
+ schemes: SecuritySchemeInfo[],
220
+ vars: Record<string, string>,
221
+ baseUrl: string,
222
+ timeoutMs: number,
223
+ ): Promise<SeedAttempt> {
224
+ const owner = findOwnerResourceForSeed(varName, resources);
225
+ if (!owner || !owner.endpoints?.create) {
226
+ return {
227
+ varName,
228
+ resource: owner?.resource ?? "?",
229
+ createPath: "",
230
+ status: "skip-no-create",
231
+ reason: `no resource with create endpoint produces ${varName}`,
232
+ };
233
+ }
234
+ const parsed = parseEndpointLabel(owner.endpoints.create);
235
+ if (!parsed) {
236
+ return { varName, resource: owner.resource, createPath: "", status: "skip-no-create", reason: `unparsable label ${owner.endpoints.create}` };
237
+ }
238
+ const ep = endpoints.find(
239
+ e => e.method.toUpperCase() === parsed.method && e.path === parsed.path && !e.deprecated,
240
+ );
241
+ if (!ep) {
242
+ return { varName, resource: owner.resource, createPath: parsed.path, status: "skip-no-create", reason: `${parsed.method} ${parsed.path} not in spec` };
243
+ }
244
+ if (!ep.requestBodySchema) {
245
+ return { varName, resource: owner.resource, createPath: parsed.path, status: "skip-no-schema", reason: `no requestBodySchema on ${parsed.method} ${parsed.path}` };
246
+ }
247
+ const { resolved: pathResolved, missing } = resolvePath(parsed.path, vars);
248
+ if (missing.length > 0) {
249
+ return {
250
+ varName,
251
+ resource: owner.resource,
252
+ createPath: parsed.path,
253
+ status: "skip-no-create",
254
+ reason: `parent fixtures missing for create path: ${missing.join(", ")}`,
255
+ };
256
+ }
257
+ // ARV-47: spec-aware body builder substitutes parent-FK fields with
258
+ // values already in env (audience_id ↔ env["audience_id"]) before the
259
+ // schema-derived random placeholders win. Without this, every nested
260
+ // POST 422s on a real API because randomly-generated parent ids don't
261
+ // exist in the target tenant.
262
+ const known = nonEmptyVars(vars);
263
+ const generated = buildCreateRequestBody(ep.requestBodySchema, { knownFixtures: known });
264
+ // Resolve `{{$randomSlug}}` etc. into concrete values for the live POST.
265
+ const concreteBody = substituteDeep(generated, vars);
266
+
267
+ const url = `${baseUrl.replace(/\/+$/, "")}${pathResolved}`;
268
+ const contentType = ep.requestBodyContentType ?? "application/json";
269
+ const headers: Record<string, string> = {
270
+ accept: "application/json",
271
+ "content-type": contentType,
272
+ ...liveAuthHeaders(ep, schemes, vars),
273
+ };
274
+ // ARV-196: Stripe-style endpoints declare application/x-www-form-urlencoded
275
+ // and reject JSON bodies with 400 / 415. Use the shared bracket-notation
276
+ // serializer (`card[number]=...`, `items[0][price]=...`) so seed POSTs
277
+ // actually create resources instead of being stuck on broken-baseline.
278
+ const wireBody = contentType.startsWith("application/x-www-form-urlencoded")
279
+ ? encodeFormBody(concreteBody as Record<string, unknown>)
280
+ : JSON.stringify(concreteBody);
281
+
282
+ let resp;
283
+ try {
284
+ // ARV-48: 1 network-class retry covers transient DNS/connection-reset
285
+ // hiccups during seed POSTs. Application-level errors (4xx/5xx) keep
286
+ // their existing branches and surface to the user.
287
+ resp = await executeRequest(
288
+ { method: parsed.method, url, headers, body: wireBody },
289
+ { timeout: timeoutMs, retries: 0, network_retries: 1 },
290
+ );
291
+ } catch (err) {
292
+ return {
293
+ varName,
294
+ resource: owner.resource,
295
+ createPath: parsed.path,
296
+ status: "miss-network",
297
+ reason: err instanceof Error ? err.message : String(err),
298
+ };
299
+ }
300
+ if (resp.status < 200 || resp.status >= 300) {
301
+ // ARV-47 AC#4: 422 is the most common failure mode for seed POSTs
302
+ // (validator complaint, not transport error). Branch it out so agents
303
+ // can route on the manifest-grade status, and emit a curl-style repro
304
+ // so the user can reproduce the request manually.
305
+ const isSeed422 = resp.status === 422;
306
+ const repro = buildCurlRepro(parsed.method, url, headers, concreteBody, wireBody);
307
+ const respBody = resp.body_parsed ?? resp.body;
308
+ const detailHint = extractFirst422Detail(respBody);
309
+ return {
310
+ varName,
311
+ resource: owner.resource,
312
+ createPath: parsed.path,
313
+ status: isSeed422 ? "miss-seed-422" : "miss-status",
314
+ reason: `${parsed.method} ${pathResolved} → ${resp.status}${detailHint ? ` (${detailHint})` : ""}`,
315
+ repro,
316
+ };
317
+ }
318
+ const captured = captureFromResponse(
319
+ resp.body_parsed ?? resp.body,
320
+ owner.captureField || "id",
321
+ );
322
+ if (captured === undefined) {
323
+ return {
324
+ varName,
325
+ resource: owner.resource,
326
+ createPath: parsed.path,
327
+ status: "miss-no-id",
328
+ reason: `response had no extractable ${owner.captureField || "id"}`,
329
+ };
330
+ }
331
+ return {
332
+ varName,
333
+ resource: owner.resource,
334
+ createPath: parsed.path,
335
+ status: "seeded",
336
+ capturedId: captured,
337
+ };
338
+ }
339
+
340
+ interface BootstrapResult {
341
+ passes: Pass[];
342
+ seeds: SeedAttempt[];
343
+ finalEnv: Record<string, string>;
344
+ writes: Map<string, string>;
345
+ }
346
+
347
+ /** TASK-271: explicit reason the cascade stopped iterating, surfaced in the
348
+ * summary so users can tell `nothing-to-do` apart from `we ran out of
349
+ * budget`. */
350
+ export type CascadeStopReason = "stable" | "max-passes" | "no-targets";
351
+
352
+ async function runCascade(
353
+ targets: FkTarget[],
354
+ endpoints: EndpointInfo[],
355
+ schemes: SecuritySchemeInfo[],
356
+ env: Record<string, string>,
357
+ baseUrl: string,
358
+ timeoutMs: number,
359
+ maxPasses: number,
360
+ writes: Map<string, string>,
361
+ passesOut: Pass[],
362
+ startIndex: number,
363
+ ): Promise<CascadeStopReason> {
364
+ for (let pass = 0; pass < maxPasses; pass++) {
365
+ const items: DiscoveryItem[] = [];
366
+ const newWrites: string[] = [];
367
+ for (const target of targets) {
368
+ const current = env[target.varName];
369
+ if (!isPlaceholder(current)) continue;
370
+ const item = await probeOne(target, current, endpoints, schemes, env, baseUrl, timeoutMs);
371
+ items.push(item);
372
+ if (item.status === "write" && item.discovered) {
373
+ env[target.varName] = item.discovered;
374
+ writes.set(target.varName, item.discovered);
375
+ newWrites.push(target.varName);
376
+ }
377
+ }
378
+ passesOut.push({ index: startIndex + pass, items, newWrites });
379
+ if (newWrites.length === 0) return "stable";
380
+ }
381
+ return "max-passes";
382
+ }
383
+
384
+ export async function bootstrapCommand(options: BootstrapOptions): Promise<number> {
385
+ const commandName = options.commandName ?? "bootstrap";
386
+ try {
387
+ const doc = await readOpenApiSpec(options.specPath);
388
+ const endpoints = extractEndpoints(doc);
389
+ const schemes = extractSecuritySchemes(doc);
390
+
391
+ const resourceMap = await readResourceMap(options.apiDir);
392
+ if (!resourceMap || resourceMap.resources.length === 0) {
393
+ const msg = `No .api-resources.yaml in ${options.apiDir}. Run 'zond refresh-api ${options.apiDir.split("/").pop()}' first.`;
394
+ if (options.json) printJson(jsonError(commandName, [msg]));
395
+ else printError(msg);
396
+ return 2;
397
+ }
398
+
399
+ const envPath = options.envPath ?? join(options.apiDir, ".env.yaml");
400
+ const env = (await loadEnvFile(envPath)) ?? {};
401
+ const baseUrl = env["base_url"];
402
+ if (!baseUrl) {
403
+ const msg = `base_url is required in ${envPath} (live API calls need it).`;
404
+ if (options.json) printJson(jsonError(commandName, [msg]));
405
+ else printError(msg);
406
+ return 2;
407
+ }
408
+
409
+ const timeout = options.timeoutMs ?? 30000;
410
+ const maxPasses = options.maxPasses ?? 8;
411
+ // ARV-133: manifest-aware cascade — pull every required path/body-fk var
412
+ // into the target list, not only ones declared as parent-FK edges. Without
413
+ // the manifest, root-level vars (`domain_id`, `webhook_id`) that have no
414
+ // fkDep edge to another resource silently dropped out of cascade.
415
+ const manifest = (await readFixtureManifest(options.apiDir)) ?? undefined;
416
+ const targets = collectTargets(resourceMap, manifest);
417
+
418
+ if (targets.length === 0 && !options.seed) {
419
+ const msg = "No path-FK dependencies — nothing to bootstrap.";
420
+ if (options.json) {
421
+ printJson(jsonOk(commandName, { envPath, applied: false, passes: [], seeds: [], summary: { writes: 0, seeds: 0 } }));
422
+ } else {
423
+ console.log(msg);
424
+ }
425
+ return 0;
426
+ }
427
+
428
+ // --force erases existing values so the cascade revisits them.
429
+ if (options.force) {
430
+ for (const t of targets) env[t.varName] = "";
431
+ }
432
+
433
+ // Snapshot which vars were already filled before any discover/seed. We
434
+ // need this for per-target classification (`already` vs `discovered`).
435
+ const preFilled = new Set(targets.filter(t => !isPlaceholder(env[t.varName])).map(t => t.varName));
436
+
437
+ const writes = new Map<string, string>();
438
+ const passes: Pass[] = [];
439
+ let lastCascadeStop: CascadeStopReason = "no-targets";
440
+ if (targets.length > 0) {
441
+ lastCascadeStop = await runCascade(targets, endpoints, schemes, env, baseUrl, timeout, maxPasses, writes, passes, 1);
442
+ }
443
+
444
+ const seeds: SeedAttempt[] = [];
445
+ let seedStop: "stable" | "max-passes" | "no-progress" | "not-run" = "not-run";
446
+ if (options.seed) {
447
+ // Two-phase seed: a seed unlocks parents, which can let cascade fill
448
+ // children in the next pass. We loop seed→cascade until either
449
+ // (a) no remaining empty FK has a viable owner, or (b) no progress
450
+ // is made — whichever comes first.
451
+ let outer = 0;
452
+ for (; outer < maxPasses; outer++) {
453
+ const stillEmpty = targets.filter(t => isPlaceholder(env[t.varName]));
454
+ if (stillEmpty.length === 0) { seedStop = "stable"; break; }
455
+
456
+ let progressed = false;
457
+ for (const t of stillEmpty) {
458
+ const owner = findOwnerResourceForSeed(t.varName, resourceMap.resources);
459
+ if (!owner) continue;
460
+ const attempt = await trySeed(
461
+ t.varName,
462
+ resourceMap.resources,
463
+ endpoints,
464
+ schemes,
465
+ nonEmptyVars(env),
466
+ baseUrl,
467
+ timeout,
468
+ );
469
+ seeds.push(attempt);
470
+ if (attempt.status === "seeded" && attempt.capturedId) {
471
+ env[t.varName] = attempt.capturedId;
472
+ writes.set(t.varName, attempt.capturedId);
473
+ progressed = true;
474
+ }
475
+ }
476
+ if (!progressed) { seedStop = "no-progress"; break; }
477
+ // After seeding parents, give cascade another go for nested children.
478
+ lastCascadeStop = await runCascade(targets, endpoints, schemes, env, baseUrl, timeout, maxPasses, writes, passes, passes.length + 1);
479
+ }
480
+ if (outer >= maxPasses) seedStop = "max-passes";
481
+ }
482
+
483
+ let applied = false;
484
+ let backupPath: string | null = null;
485
+ if (options.apply && writes.size > 0) {
486
+ backupPath = `${envPath}.bak`;
487
+ try {
488
+ await copyFile(envPath, backupPath);
489
+ } catch {
490
+ backupPath = null;
491
+ }
492
+ const file = Bun.file(envPath);
493
+ let text = (await file.exists()) ? await file.text() : "";
494
+ for (const [k, v] of writes) {
495
+ text = upsertEnvLine(text, k, v);
496
+ }
497
+ if (!text.endsWith("\n")) text += "\n";
498
+ await Bun.write(envPath, text);
499
+ applied = true;
500
+ }
501
+
502
+ const totalFkVars = targets.length;
503
+ const filledFkVars = targets.filter(t => !isPlaceholder(env[t.varName])).length;
504
+ const fillRate = totalFkVars === 0 ? 1 : filledFkVars / totalFkVars;
505
+
506
+ // TASK-271: per-target classification — `already` (was filled at start),
507
+ // `discovered` (cascade list-endpoint pulled an id), `seeded` (POST-create
508
+ // seeded a parent and we captured its id), `failed:<reason>` (still empty
509
+ // — surface why so the operator knows where to step in by hand).
510
+ type TargetStatus = "already" | "discovered" | "seeded" | `failed:${string}`;
511
+ // ARV-112: `sourceEndpoint` records WHERE a filled value came from so
512
+ // the operator can re-derive it / spot a wrong harvest source without
513
+ // re-running with --verify. `discovered` → the list GET that surfaced
514
+ // the id; `seeded` → the POST that created it; `already` → "(pre-set)".
515
+ const perTarget: Array<{ var: string; resource: string; status: TargetStatus; value?: string; reason?: string; sourceEndpoint?: string }> = [];
516
+ for (const t of targets) {
517
+ const value = env[t.varName];
518
+ let status: TargetStatus;
519
+ let reason: string | undefined;
520
+ let sourceEndpoint: string | undefined;
521
+ if (preFilled.has(t.varName) && !options.force) {
522
+ status = "already";
523
+ sourceEndpoint = "(pre-set)";
524
+ } else if (!isPlaceholder(value)) {
525
+ const seeded = seeds.find(s => s.varName === t.varName && s.status === "seeded");
526
+ if (seeded) {
527
+ status = "seeded";
528
+ sourceEndpoint = `POST ${seeded.createPath}`;
529
+ } else {
530
+ status = "discovered";
531
+ // Find the cascade item that actually wrote this var — last pass
532
+ // where it appeared with status="write" wins. Falls back to any
533
+ // pass-item for the var if no explicit write status was recorded
534
+ // (defensive — current code path always sets "write" on success).
535
+ const writingItem = [...passes].reverse()
536
+ .flatMap(p => p.items)
537
+ .find(i => i.varName === t.varName && i.status === "write");
538
+ const lookupItem = writingItem
539
+ ?? [...passes].reverse().flatMap(p => p.items).find(i => i.varName === t.varName);
540
+ if (lookupItem?.listPath) sourceEndpoint = `GET ${lookupItem.listPath}`;
541
+ }
542
+ } else {
543
+ // Still empty — pick the most recent miss-* item from the cascade as
544
+ // the reason; fallback to the seed attempt's reason.
545
+ const lastItem = [...passes].reverse()
546
+ .flatMap(p => p.items)
547
+ .find(i => i.varName === t.varName);
548
+ const lastSeed = [...seeds].reverse().find(s => s.varName === t.varName);
549
+ if (lastItem) {
550
+ reason = lastItem.reason ?? lastItem.status;
551
+ status = `failed:${lastItem.status}`;
552
+ // ARV-98 (F3): when --seed was already passed, the cascade reason
553
+ // for `miss-empty` still shouts "re-run with --seed --apply"
554
+ // because discover.ts is context-blind. Replace it with what the
555
+ // seed-fallback actually did so agents stop re-running the same
556
+ // flag. Two sub-cases:
557
+ // (a) seed was tried for this var → splice in its outcome.
558
+ // (b) seed was on but no attempt landed → owner lookup couldn't
559
+ // find a POST/create endpoint (common for SDK-only writes
560
+ // like ingest-only resources). Spell that out.
561
+ if (options.seed && lastItem.status === "miss-empty") {
562
+ if (lastSeed) {
563
+ reason = `${lastItem.reason ?? lastItem.status}; seed attempt: ${lastSeed.reason ?? lastSeed.status}`;
564
+ status = `failed:${lastSeed.status}`;
565
+ } else {
566
+ const owner = findOwnerResourceForSeed(t.varName, resourceMap.resources);
567
+ if (!owner) {
568
+ reason = `no .api-resources.yaml owner produces ${t.varName} — --seed cannot help. Either extend .api-resources.local.yaml (ARV-111: user-maintained sibling that survives refresh-api) or harvest the id by hand into .env.yaml.`;
569
+ status = "failed:miss-empty-no-seed-owner";
570
+ } else if (!owner.endpoints?.create) {
571
+ reason = `owner resource '${owner.resource}' has no create endpoint in spec — --seed cannot help (resource likely write-only / SDK-only). Either extend .api-resources.local.yaml with a custom create endpoint (ARV-111) or harvest the id by hand into .env.yaml.`;
572
+ status = "failed:miss-empty-no-seed-endpoint";
573
+ }
574
+ // Else: owner has a create endpoint but no attempt landed in
575
+ // this loop iteration — leave the cascade reason untouched.
576
+ }
577
+ }
578
+ } else if (lastSeed) {
579
+ reason = lastSeed.reason ?? lastSeed.status;
580
+ status = `failed:${lastSeed.status}`;
581
+ } else {
582
+ reason = "no list endpoint and no create endpoint to seed from";
583
+ status = "failed:no-route";
584
+ }
585
+ }
586
+ perTarget.push({
587
+ var: t.varName,
588
+ resource: t.ownerResource,
589
+ status,
590
+ ...(isPlaceholder(value) ? {} : { value }),
591
+ ...(reason ? { reason } : {}),
592
+ ...(sourceEndpoint ? { sourceEndpoint } : {}),
593
+ });
594
+ }
595
+
596
+ const noOp = totalFkVars > 0 && filledFkVars === totalFkVars && writes.size === 0 && seeds.length === 0;
597
+ const mode: "exec" | "plan" = options.apply ? "exec" : "plan";
598
+
599
+ if (options.json) {
600
+ printJson(jsonOk(commandName, {
601
+ envPath,
602
+ applied,
603
+ mode,
604
+ backup: backupPath,
605
+ passes: passes.map(p => ({ pass: p.index, writes: p.newWrites, items: p.items })),
606
+ seeds,
607
+ perTarget,
608
+ summary: {
609
+ targets: totalFkVars,
610
+ filled: filledFkVars,
611
+ fillRate: Number(fillRate.toFixed(2)),
612
+ writes: writes.size,
613
+ seedsAttempted: seeds.length,
614
+ seedsSucceeded: seeds.filter(s => s.status === "seeded").length,
615
+ passes: passes.length,
616
+ cascadeStop: lastCascadeStop,
617
+ ...(options.seed ? { seedStop } : {}),
618
+ noOp,
619
+ },
620
+ }));
621
+ } else {
622
+ const tag = mode === "exec" ? "[exec]" : "[plan]";
623
+ console.log(`${tag} Bootstrap against ${baseUrl} (${envPath})`);
624
+ console.log("");
625
+
626
+ if (noOp) {
627
+ console.log(`bootstrap: nothing to do — ${filledFkVars}/${totalFkVars} fixtures already present.`);
628
+ } else {
629
+ // Per-target table. ARV-112: include `from` column with the endpoint
630
+ // that produced the value (GET <list> for discovered, POST <create>
631
+ // for seeded, "(pre-set)" for already-filled) so a wrong harvest is
632
+ // diagnosable without re-running --verify.
633
+ if (perTarget.length > 0) {
634
+ console.log("Fixture status:");
635
+ for (const r of perTarget) {
636
+ const head = r.status.padEnd(20);
637
+ const value = r.value !== undefined ? `→ ${r.value}` : "";
638
+ const reason = r.reason ? ` (${r.reason})` : "";
639
+ const from = r.sourceEndpoint ? ` from ${r.sourceEndpoint}` : "";
640
+ console.log(` ${head} ${r.var.padEnd(28)} ${r.resource.padEnd(20)} ${value}${reason}${from}`);
641
+ }
642
+ console.log("");
643
+ }
644
+ for (const p of passes) {
645
+ console.log(`Pass ${p.index}: ${p.newWrites.length} new fixture(s)${p.newWrites.length ? ` — ${p.newWrites.join(", ")}` : ""}`);
646
+ }
647
+ if (passes.length > 0) {
648
+ console.log(`Cascade stopped after ${passes.length} pass(es): ${lastCascadeStop}.`);
649
+ }
650
+ if (seeds.length > 0) {
651
+ console.log("");
652
+ console.log("Seed attempts:");
653
+ for (const s of seeds) {
654
+ const tail = s.status === "seeded" ? `→ ${s.capturedId}` : `(${s.reason ?? ""})`;
655
+ console.log(` ${s.status.padEnd(18)} ${s.varName.padEnd(28)} ${s.resource.padEnd(20)} ${tail}`);
656
+ }
657
+ // ARV-47 AC#4: print curl-style repro for every 422 to stderr so the
658
+ // user can copy-paste-debug without inspecting the JSON envelope.
659
+ for (const s of seeds) {
660
+ if (s.status === "miss-seed-422" && s.repro) {
661
+ console.error(` Repro: ${s.repro}`);
662
+ }
663
+ }
664
+ console.log(`Seed loop stopped: ${seedStop}.`);
665
+ // ARV-242 (R-02/SD5): when seeds produce zero new vars and a
666
+ // majority of attempts failed with auth/scope status codes (401 /
667
+ // 403), the token clearly cannot create resources — keep retrying
668
+ // burns rate-limit budget without ever succeeding. Hint at
669
+ // `--no-seed` so the next prepare-fixtures pass skips the futile
670
+ // POSTs and focuses on cascade discovery.
671
+ const seedsCreated = seeds.filter(s => s.status === "seeded").length;
672
+ const seedsAuthFail = seeds.filter(s => {
673
+ if (s.status !== "miss-status") return false;
674
+ const reason = s.reason ?? "";
675
+ return /(\s|→)40[13]\b/.test(reason);
676
+ }).length;
677
+ if (seedsCreated === 0 && seedsAuthFail > 0 && seedsAuthFail >= Math.ceil(seeds.length / 2)) {
678
+ console.log(
679
+ `Hint: ${seedsAuthFail}/${seeds.length} seed attempts hit 401/403 (likely token scope). ` +
680
+ `Re-run without \`--seed\` (cascade-only) to skip futile POSTs and save rate-limit budget.`,
681
+ );
682
+ }
683
+ }
684
+ console.log("");
685
+ console.log(`Filled ${filledFkVars}/${totalFkVars} path-FK vars (${Math.round(fillRate * 100)}%).`);
686
+ }
687
+
688
+ if (applied) {
689
+ printSuccess(`Wrote ${writes.size} value(s) to ${envPath}` + (backupPath ? ` (backup: ${backupPath})` : ""));
690
+ } else if (writes.size === 0) {
691
+ if (!noOp) console.log("Nothing to write (cascade/seed produced no new values).");
692
+ } else {
693
+ printWarning(`[plan] ${writes.size} value(s) ready. Re-run with --apply to write ${envPath}.`);
694
+ }
695
+ }
696
+ return 0;
697
+ } catch (err) {
698
+ const message = err instanceof Error ? err.message : String(err);
699
+ if (options.json) printJson(jsonError(commandName, [message]));
700
+ else printError(message);
701
+ return 2;
702
+ }
703
+ }
704
+
705
+ // ARV-130 (m-19): file kept on purpose. CLI registration is owned by
706
+ // ./prepare-fixtures.ts (TASK-299, m-13 D); `bootstrapCommand` above is
707
+ // consumed both by that wrapper and by direct unit tests
708
+ // (`tests/cli/bootstrap.test.ts`). Not to be confused with
709
+ // `./init/bootstrap.ts` — a different module that scaffolds a fresh
710
+ // workspace. See the m-19 audit note in backlog/tasks/arv-130.