@businessdash/sdk 0.9.60

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +899 -0
  3. package/dist/adapters-revalidate.d.ts +44 -0
  4. package/dist/adapters-revalidate.d.ts.map +1 -0
  5. package/dist/adapters-revalidate.js +68 -0
  6. package/dist/adapters-revalidate.js.map +1 -0
  7. package/dist/alpha.d.ts +21 -0
  8. package/dist/alpha.d.ts.map +1 -0
  9. package/dist/alpha.js +21 -0
  10. package/dist/alpha.js.map +1 -0
  11. package/dist/analytics-core.d.ts +34 -0
  12. package/dist/analytics-core.d.ts.map +1 -0
  13. package/dist/analytics-core.js +138 -0
  14. package/dist/analytics-core.js.map +1 -0
  15. package/dist/analytics-server.d.ts +54 -0
  16. package/dist/analytics-server.d.ts.map +1 -0
  17. package/dist/analytics-server.js +78 -0
  18. package/dist/analytics-server.js.map +1 -0
  19. package/dist/attribution-logo.d.ts +2 -0
  20. package/dist/attribution-logo.d.ts.map +1 -0
  21. package/dist/attribution-logo.js +5 -0
  22. package/dist/attribution-logo.js.map +1 -0
  23. package/dist/auth-handler.d.ts +92 -0
  24. package/dist/auth-handler.d.ts.map +1 -0
  25. package/dist/auth-handler.js +277 -0
  26. package/dist/auth-handler.js.map +1 -0
  27. package/dist/biab-forms.css +484 -0
  28. package/dist/cli.d.ts +46 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +1824 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/client.d.ts +1681 -0
  33. package/dist/client.d.ts.map +1 -0
  34. package/dist/client.js +2845 -0
  35. package/dist/client.js.map +1 -0
  36. package/dist/collections.d.ts +13 -0
  37. package/dist/collections.d.ts.map +1 -0
  38. package/dist/collections.js +13 -0
  39. package/dist/collections.js.map +1 -0
  40. package/dist/contracts.d.ts +14609 -0
  41. package/dist/contracts.d.ts.map +1 -0
  42. package/dist/contracts.js +2428 -0
  43. package/dist/contracts.js.map +1 -0
  44. package/dist/data-model-schema.d.ts +962 -0
  45. package/dist/data-model-schema.d.ts.map +1 -0
  46. package/dist/data-model-schema.js +2295 -0
  47. package/dist/data-model-schema.js.map +1 -0
  48. package/dist/distribution.d.ts +38 -0
  49. package/dist/distribution.d.ts.map +1 -0
  50. package/dist/distribution.js +61 -0
  51. package/dist/distribution.js.map +1 -0
  52. package/dist/element.d.ts +121 -0
  53. package/dist/element.d.ts.map +1 -0
  54. package/dist/element.js +276 -0
  55. package/dist/element.js.map +1 -0
  56. package/dist/enums.d.ts +25 -0
  57. package/dist/enums.d.ts.map +1 -0
  58. package/dist/enums.js +38 -0
  59. package/dist/enums.js.map +1 -0
  60. package/dist/errors.d.ts +195 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/errors.js +188 -0
  63. package/dist/errors.js.map +1 -0
  64. package/dist/forms-core/autocomplete.d.ts +37 -0
  65. package/dist/forms-core/autocomplete.d.ts.map +1 -0
  66. package/dist/forms-core/autocomplete.js +82 -0
  67. package/dist/forms-core/autocomplete.js.map +1 -0
  68. package/dist/forms-core/business-function.d.ts +61 -0
  69. package/dist/forms-core/business-function.d.ts.map +1 -0
  70. package/dist/forms-core/business-function.js +149 -0
  71. package/dist/forms-core/business-function.js.map +1 -0
  72. package/dist/forms-core/calculation.d.ts +72 -0
  73. package/dist/forms-core/calculation.d.ts.map +1 -0
  74. package/dist/forms-core/calculation.js +103 -0
  75. package/dist/forms-core/calculation.js.map +1 -0
  76. package/dist/forms-core/controller.d.ts +249 -0
  77. package/dist/forms-core/controller.d.ts.map +1 -0
  78. package/dist/forms-core/controller.js +590 -0
  79. package/dist/forms-core/controller.js.map +1 -0
  80. package/dist/forms-core/elements.d.ts +44 -0
  81. package/dist/forms-core/elements.d.ts.map +1 -0
  82. package/dist/forms-core/elements.js +68 -0
  83. package/dist/forms-core/elements.js.map +1 -0
  84. package/dist/forms-core/email-domains.d.ts +10 -0
  85. package/dist/forms-core/email-domains.d.ts.map +1 -0
  86. package/dist/forms-core/email-domains.js +69 -0
  87. package/dist/forms-core/email-domains.js.map +1 -0
  88. package/dist/forms-core/file-rules.d.ts +51 -0
  89. package/dist/forms-core/file-rules.d.ts.map +1 -0
  90. package/dist/forms-core/file-rules.js +105 -0
  91. package/dist/forms-core/file-rules.js.map +1 -0
  92. package/dist/forms-core/formula.d.ts +114 -0
  93. package/dist/forms-core/formula.d.ts.map +1 -0
  94. package/dist/forms-core/formula.js +1170 -0
  95. package/dist/forms-core/formula.js.map +1 -0
  96. package/dist/forms-core/index.d.ts +24 -0
  97. package/dist/forms-core/index.d.ts.map +1 -0
  98. package/dist/forms-core/index.js +28 -0
  99. package/dist/forms-core/index.js.map +1 -0
  100. package/dist/forms-core/units.d.ts +49 -0
  101. package/dist/forms-core/units.d.ts.map +1 -0
  102. package/dist/forms-core/units.js +255 -0
  103. package/dist/forms-core/units.js.map +1 -0
  104. package/dist/forms-core/us-states.d.ts +14 -0
  105. package/dist/forms-core/us-states.d.ts.map +1 -0
  106. package/dist/forms-core/us-states.js +66 -0
  107. package/dist/forms-core/us-states.js.map +1 -0
  108. package/dist/forms-core/validation.d.ts +266 -0
  109. package/dist/forms-core/validation.d.ts.map +1 -0
  110. package/dist/forms-core/validation.js +850 -0
  111. package/dist/forms-core/validation.js.map +1 -0
  112. package/dist/forms.d.ts +19 -0
  113. package/dist/forms.d.ts.map +1 -0
  114. package/dist/forms.js +29 -0
  115. package/dist/forms.js.map +1 -0
  116. package/dist/gallery-types.d.ts +32 -0
  117. package/dist/gallery-types.d.ts.map +1 -0
  118. package/dist/gallery-types.js +2 -0
  119. package/dist/gallery-types.js.map +1 -0
  120. package/dist/graphql.d.ts +97 -0
  121. package/dist/graphql.d.ts.map +1 -0
  122. package/dist/graphql.js +212 -0
  123. package/dist/graphql.js.map +1 -0
  124. package/dist/index.cjs +19448 -0
  125. package/dist/index.d.cts +19 -0
  126. package/dist/index.d.mts +19 -0
  127. package/dist/index.d.ts +19 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +20 -0
  130. package/dist/index.js.map +1 -0
  131. package/dist/marketing-schema.d.ts +216 -0
  132. package/dist/marketing-schema.d.ts.map +1 -0
  133. package/dist/marketing-schema.js +200 -0
  134. package/dist/marketing-schema.js.map +1 -0
  135. package/dist/mcp.d.ts +42 -0
  136. package/dist/mcp.d.ts.map +1 -0
  137. package/dist/mcp.js +87 -0
  138. package/dist/mcp.js.map +1 -0
  139. package/dist/native.d.ts +45 -0
  140. package/dist/native.d.ts.map +1 -0
  141. package/dist/native.js +43 -0
  142. package/dist/native.js.map +1 -0
  143. package/dist/next-revalidate.d.ts +43 -0
  144. package/dist/next-revalidate.d.ts.map +1 -0
  145. package/dist/next-revalidate.js +84 -0
  146. package/dist/next-revalidate.js.map +1 -0
  147. package/dist/parallel-pages.d.ts +102 -0
  148. package/dist/parallel-pages.d.ts.map +1 -0
  149. package/dist/parallel-pages.js +108 -0
  150. package/dist/parallel-pages.js.map +1 -0
  151. package/dist/payment-icons.d.ts +66 -0
  152. package/dist/payment-icons.d.ts.map +1 -0
  153. package/dist/payment-icons.js +160 -0
  154. package/dist/payment-icons.js.map +1 -0
  155. package/dist/proxy.d.ts +67 -0
  156. package/dist/proxy.d.ts.map +1 -0
  157. package/dist/proxy.js +97 -0
  158. package/dist/proxy.js.map +1 -0
  159. package/dist/react-analytics.d.ts +39 -0
  160. package/dist/react-analytics.d.ts.map +1 -0
  161. package/dist/react-analytics.js +59 -0
  162. package/dist/react-analytics.js.map +1 -0
  163. package/dist/react-attribution.d.ts +99 -0
  164. package/dist/react-attribution.d.ts.map +1 -0
  165. package/dist/react-attribution.js +52 -0
  166. package/dist/react-attribution.js.map +1 -0
  167. package/dist/react-marketing.d.ts +181 -0
  168. package/dist/react-marketing.d.ts.map +1 -0
  169. package/dist/react-marketing.js +215 -0
  170. package/dist/react-marketing.js.map +1 -0
  171. package/dist/react.d.ts +727 -0
  172. package/dist/react.d.ts.map +1 -0
  173. package/dist/react.js +2418 -0
  174. package/dist/react.js.map +1 -0
  175. package/dist/reserved-paths.d.ts +24 -0
  176. package/dist/reserved-paths.d.ts.map +1 -0
  177. package/dist/reserved-paths.js +60 -0
  178. package/dist/reserved-paths.js.map +1 -0
  179. package/dist/revalidate-verify.d.ts +30 -0
  180. package/dist/revalidate-verify.d.ts.map +1 -0
  181. package/dist/revalidate-verify.js +74 -0
  182. package/dist/revalidate-verify.js.map +1 -0
  183. package/dist/sdk.d.ts +678 -0
  184. package/dist/sdk.d.ts.map +1 -0
  185. package/dist/sdk.js +801 -0
  186. package/dist/sdk.js.map +1 -0
  187. package/dist/seo.d.ts +218 -0
  188. package/dist/seo.d.ts.map +1 -0
  189. package/dist/seo.js +309 -0
  190. package/dist/seo.js.map +1 -0
  191. package/dist/socials.d.ts +47 -0
  192. package/dist/socials.d.ts.map +1 -0
  193. package/dist/socials.js +172 -0
  194. package/dist/socials.js.map +1 -0
  195. package/dist/static.d.ts +14 -0
  196. package/dist/static.d.ts.map +1 -0
  197. package/dist/static.js +14 -0
  198. package/dist/static.js.map +1 -0
  199. package/dist/url-pattern.d.ts +47 -0
  200. package/dist/url-pattern.d.ts.map +1 -0
  201. package/dist/url-pattern.js +124 -0
  202. package/dist/url-pattern.js.map +1 -0
  203. package/dist/vanilla.d.ts +77 -0
  204. package/dist/vanilla.d.ts.map +1 -0
  205. package/dist/vanilla.js +903 -0
  206. package/dist/vanilla.js.map +1 -0
  207. package/openapi.json +13779 -0
  208. package/package.json +204 -0
  209. package/src/adapters-revalidate.ts +88 -0
  210. package/src/alpha.ts +89 -0
  211. package/src/analytics-core.ts +175 -0
  212. package/src/analytics-server.ts +99 -0
  213. package/src/angular/biab-form.component.ts +1443 -0
  214. package/src/angular/index.ts +26 -0
  215. package/src/attribution-logo.ts +5 -0
  216. package/src/auth-handler.ts +352 -0
  217. package/src/cli.ts +2517 -0
  218. package/src/client.ts +3578 -0
  219. package/src/collections.ts +101 -0
  220. package/src/contracts.ts +3390 -0
  221. package/src/data-model-schema.ts +4042 -0
  222. package/src/distribution.ts +73 -0
  223. package/src/element.ts +321 -0
  224. package/src/enums.ts +65 -0
  225. package/src/errors.ts +338 -0
  226. package/src/forms-core/autocomplete.ts +97 -0
  227. package/src/forms-core/business-function.ts +209 -0
  228. package/src/forms-core/calculation.ts +167 -0
  229. package/src/forms-core/controller.ts +922 -0
  230. package/src/forms-core/elements.ts +86 -0
  231. package/src/forms-core/email-domains.ts +68 -0
  232. package/src/forms-core/file-rules.ts +123 -0
  233. package/src/forms-core/formula.ts +1339 -0
  234. package/src/forms-core/index.ts +122 -0
  235. package/src/forms-core/units.ts +308 -0
  236. package/src/forms-core/us-states.ts +68 -0
  237. package/src/forms-core/validation.ts +1037 -0
  238. package/src/forms.ts +63 -0
  239. package/src/gallery-types.ts +31 -0
  240. package/src/graphql.ts +295 -0
  241. package/src/index.ts +505 -0
  242. package/src/marketing-schema.ts +395 -0
  243. package/src/mcp.ts +108 -0
  244. package/src/native.ts +73 -0
  245. package/src/next-revalidate.ts +124 -0
  246. package/src/parallel-pages.ts +192 -0
  247. package/src/payment-icons.ts +205 -0
  248. package/src/proxy.ts +173 -0
  249. package/src/qwik.tsx +1234 -0
  250. package/src/react-analytics.tsx +76 -0
  251. package/src/react-attribution.tsx +192 -0
  252. package/src/react-marketing.tsx +338 -0
  253. package/src/react.tsx +5495 -0
  254. package/src/reserved-paths.ts +65 -0
  255. package/src/revalidate-verify.ts +98 -0
  256. package/src/sdk.ts +1671 -0
  257. package/src/seo.ts +488 -0
  258. package/src/socials.ts +230 -0
  259. package/src/solid.tsx +1466 -0
  260. package/src/static.ts +21 -0
  261. package/src/styles/biab-forms.css +484 -0
  262. package/src/svelte/BiabForm.svelte +1069 -0
  263. package/src/svelte/context.ts +57 -0
  264. package/src/svelte/index.ts +79 -0
  265. package/src/url-pattern.ts +153 -0
  266. package/src/vanilla.ts +1290 -0
  267. package/src/vue/BiabForm.vue +1538 -0
  268. package/src/vue/index.ts +119 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1824 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `biab-dev` CLI entry point.
4
+ *
5
+ * Two independent flows, deliberately sharing nothing but transport
6
+ * conventions — a website's content shape and an org's database shape are
7
+ * different artifacts, with different review cadence and blast radius.
8
+ *
9
+ * WEBSITE CONTENT (`biab.config.{ts,mjs,js}`):
10
+ * - `sync-schema` — resolve the config, compute the canonical SHA-256,
11
+ * POST to the draft-schema endpoint. Idempotent.
12
+ * - `sync-content` — walk `<contentSync.rootDir>/<locale>/<page>/
13
+ * <section>.json`, batch, POST to bulk-sections.
14
+ * Per-row failures are reported but don't abort.
15
+ * - `print-schema` — resolved schema + checksum, no upload.
16
+ *
17
+ * CUSTOM DATABASE (`biab.data-model.config.{ts,mjs,js}`):
18
+ * - `view-data-model` — what's LIVE (not what was last pushed — the
19
+ * dashboard writes this model too). `--diff` for the
20
+ * migration plan, `--out` to scaffold a config.
21
+ * - `print-data-model` — resolved local config + checksum, no network.
22
+ * - `sync-data-model` — push to the draft slot. Prints the plan first.
23
+ * - `sync-records` — upsert rows from JSON, keyed on row identity so
24
+ * re-running converges instead of duplicating.
25
+ * - `wipe-collections` — hard-delete records from explicitly named custom
26
+ * collections, only after a verified Media backup and
27
+ * two interactive confirmations.
28
+ *
29
+ * The CLI never promotes, restores, or inspects schema history. Those live in the
30
+ * dashboard, gated on the operator's permission (`website.manage` /
31
+ * `metadata.manage`), NOT on the API key. Wiping records is the deliberately
32
+ * destructive exception: it requires a user-bound secret key, a state-bound
33
+ * preview, a verified backup in Media, and typed confirmation. The schema
34
+ * invariant still holds: **a key proposes, a human promotes.** A schema key
35
+ * cannot promote a draft or change what visitors see. The wipe endpoint does
36
+ * change record contents, so its backup and confirmation guards are
37
+ * load-bearing.
38
+ *
39
+ * Auth: `BIAB_API_KEY`, `BIAB_SITE_ID`, and `BIAB_PACKAGE_API_BASE_URL` from
40
+ * the environment (or a `.env` / `.env.local` in the cwd). Scopes:
41
+ * `marketing:write_schema` / `marketing:write_content` for the website flow;
42
+ * `metadata:read_schema` (view/plan), `metadata:write_schema` (push), and
43
+ * `metadata:write_records` (seed/wipe) for the data model.
44
+ */
45
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
46
+ import { createRequire } from "node:module";
47
+ import { dirname, isAbsolute, join, resolve } from "node:path";
48
+ import { createInterface } from "node:readline/promises";
49
+ import { pathToFileURL } from "node:url";
50
+ import { computeDataModelChecksum, } from "./data-model-schema.js";
51
+ import { dataModelToGraphqlSdl } from "./graphql.js";
52
+ import { isReservedPagePath, reservedPagePathErrorMessage, } from "./reserved-paths.js";
53
+ const COMMANDS = [
54
+ "sync-schema",
55
+ "sync-content",
56
+ "print-schema",
57
+ // Data model (custom database). Separate config, separate endpoint,
58
+ // separate scopes — see data-model-schema.ts.
59
+ "view-data-model",
60
+ "print-data-model",
61
+ "export-data-model",
62
+ "export-graphql-schema",
63
+ "sync-data-model",
64
+ "sync-records",
65
+ "wipe-collections",
66
+ "wipe-database",
67
+ "help",
68
+ ];
69
+ /** Config candidates per flow. The marketing and data-model configs are
70
+ * deliberately different files: different artifacts, different blast radius.
71
+ * `businessdash.*` names are preferred going forward; the `biab.*` names
72
+ * remain a permanent filesystem contract with every existing consumer. */
73
+ const MARKETING_CONFIG_CANDIDATES = [
74
+ "businessdash.config.ts",
75
+ "businessdash.config.mjs",
76
+ "businessdash.config.js",
77
+ "biab.config.ts",
78
+ "biab.config.mjs",
79
+ "biab.config.js",
80
+ ];
81
+ const DATA_MODEL_CONFIG_CANDIDATES = [
82
+ "businessdash.data-model.config.ts",
83
+ "businessdash.data-model.config.mjs",
84
+ "businessdash.data-model.config.js",
85
+ "biab.data-model.config.ts",
86
+ "biab.data-model.config.mjs",
87
+ "biab.data-model.config.js",
88
+ ];
89
+ // V7-styled output: matches the dashboard's status-pill colors so the
90
+ // CLI feels like the same product (✓ = "done" green-accent, ⚠ =
91
+ // "partial" orange-accent, ✕ = "missing" red-accent). We only emit
92
+ // ANSI when stdout is a TTY so CI logs stay plain.
93
+ const USE_COLOR = process.stdout.isTTY && !process.env["NO_COLOR"];
94
+ const fg = {
95
+ green: (s) => (USE_COLOR ? `\x1b[32m${s}\x1b[0m` : s),
96
+ red: (s) => (USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s),
97
+ amber: (s) => (USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s),
98
+ dim: (s) => (USE_COLOR ? `\x1b[2m${s}\x1b[0m` : s),
99
+ bold: (s) => (USE_COLOR ? `\x1b[1m${s}\x1b[0m` : s),
100
+ };
101
+ function logStep(msg) {
102
+ console.log(`[biab-dev] ${msg}`);
103
+ }
104
+ /**
105
+ * A single progress line for long, batched work.
106
+ *
107
+ * On a TTY it rewrites one line in place (`\r` + erase), so a run with many
108
+ * network round-trips shows live movement instead of a silent wait. Without a
109
+ * TTY (CI) it prints a plain line per tick — no carriage returns to mangle the
110
+ * log, just a bounded trail of progress.
111
+ *
112
+ * `tick(0)` up front makes the line appear before the first round-trip returns,
113
+ * so the user sees it engage rather than staring at nothing.
114
+ */
115
+ function makeProgress(total) {
116
+ const tty = Boolean(process.stdout.isTTY);
117
+ return {
118
+ tick(done, note = "") {
119
+ const pct = total === 0 ? 100 : Math.floor((done / total) * 100);
120
+ // No nested color: the whole line is dimmed, so the note stays plain
121
+ // text (a reset inside would cancel the dim for everything after it).
122
+ const line = ` ↻ ${done}/${total} (${pct}%)${note ? ` · ${note}` : ""}`;
123
+ if (tty)
124
+ process.stdout.write(`\r\x1b[2K${fg.dim(line)}`);
125
+ else
126
+ console.log(line);
127
+ },
128
+ clear() {
129
+ if (tty)
130
+ process.stdout.write("\r\x1b[2K");
131
+ },
132
+ };
133
+ }
134
+ function logError(msg) {
135
+ console.error(`[biab-dev] ${msg}`);
136
+ }
137
+ function parseEnvFile(path) {
138
+ if (!existsSync(path))
139
+ return {};
140
+ const out = {};
141
+ for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
142
+ const line = raw.trim();
143
+ if (!line || line.startsWith("#"))
144
+ continue;
145
+ const eq = line.indexOf("=");
146
+ if (eq === -1)
147
+ continue;
148
+ const key = line.slice(0, eq).trim();
149
+ let value = line.slice(eq + 1).trim();
150
+ if ((value.startsWith('"') && value.endsWith('"')) ||
151
+ (value.startsWith("'") && value.endsWith("'"))) {
152
+ value = value.slice(1, -1);
153
+ }
154
+ if (key && value !== undefined)
155
+ out[key] = value;
156
+ }
157
+ return out;
158
+ }
159
+ function loadEnv(cwd) {
160
+ const merged = {
161
+ ...parseEnvFile(resolve(cwd, ".env")),
162
+ ...parseEnvFile(resolve(cwd, ".env.local")),
163
+ ...process.env,
164
+ };
165
+ return merged;
166
+ }
167
+ function findConfigPath(cwd, override, candidates = MARKETING_CONFIG_CANDIDATES, hint = "Create one with `defineSiteMarketingSchema({...})` exported as default.") {
168
+ if (override) {
169
+ const p = isAbsolute(override) ? override : resolve(cwd, override);
170
+ if (!existsSync(p))
171
+ throw new Error(`Config not found at ${p}`);
172
+ return p;
173
+ }
174
+ for (const candidate of candidates) {
175
+ const p = resolve(cwd, candidate);
176
+ if (existsSync(p))
177
+ return p;
178
+ }
179
+ const names = candidates.join(", ");
180
+ throw new Error(`None of [${names}] found in ${cwd}. ${hint}`);
181
+ }
182
+ function findDataModelConfigPath(cwd, override) {
183
+ return findConfigPath(cwd, override, DATA_MODEL_CONFIG_CANDIDATES, "Create one with `defineDataModel({...})` exported as default.");
184
+ }
185
+ /**
186
+ * Load the consumer's `biab.config.*`. Supports .ts (via the
187
+ * consumer's tsx / ts-node setup — we only invoke if `--require tsx`
188
+ * or `tsx`/`ts-node` registered the loader), .mjs (native ESM), and
189
+ * .js (CJS or ESM depending on package.json).
190
+ */
191
+ async function loadConfig(configPath) {
192
+ const url = pathToFileURL(configPath).href;
193
+ let mod;
194
+ try {
195
+ mod = (await import(url));
196
+ }
197
+ catch (err) {
198
+ if (configPath.endsWith(".ts")) {
199
+ throw new Error(`Could not load TypeScript config at ${configPath}. Run the CLI under tsx or ts-node, e.g.: ` +
200
+ `\`pnpm exec tsx node_modules/@businessdash/sdk/dist/cli.js sync-schema\`.\nUnderlying: ${err instanceof Error ? err.message : String(err)}`);
201
+ }
202
+ throw err;
203
+ }
204
+ const fromDefault = mod.default;
205
+ const fromNamed = mod.marketing;
206
+ const chosen = fromDefault ?? fromNamed;
207
+ if (!chosen ||
208
+ typeof chosen.toJSONSchema !== "function") {
209
+ throw new Error(`${configPath} did not export a marketing schema. Default-export the result of \`defineSiteMarketingSchema({...})\` (or named-export it as \`marketing\`).`);
210
+ }
211
+ return chosen;
212
+ }
213
+ function normalizeBaseUrl(input) {
214
+ let next = input.trim();
215
+ if (!/^https?:\/\//i.test(next)) {
216
+ throw new Error(`BIAB_PACKAGE_API_BASE_URL must start with http(s):// — got "${input}".`);
217
+ }
218
+ if (!next.endsWith("/api/package/v1") && !next.endsWith("/api/package/v1/")) {
219
+ // Accept either the bare host or the full prefix; canonicalize to
220
+ // "<origin>/api/package/v1".
221
+ next = next.replace(/\/+$/, "");
222
+ next = `${next}/api/package/v1`;
223
+ }
224
+ return next.replace(/\/+$/, "");
225
+ }
226
+ async function cmdPrintSchema(opts) {
227
+ const schema = await loadConfig(opts.configPath);
228
+ const artifact = schema.toJSONSchema();
229
+ const checksum = await schema.checksum();
230
+ console.log(JSON.stringify({ checksum, artifact }, null, 2));
231
+ }
232
+ async function cmdSyncSchema(opts) {
233
+ const schema = await loadConfig(opts.configPath);
234
+ const artifact = schema.toJSONSchema();
235
+ const checksum = await schema.checksum();
236
+ // Reject any `contentSync.pages[].pageKey` that collides with a BIAB
237
+ // platform-reserved route. Catches the misconfiguration before any
238
+ // upload happens.
239
+ const declaredPages = schema.contentSync?.pages ?? [];
240
+ const reservedDeclared = declaredPages.filter((p) => isReservedPagePath(p.pageKey));
241
+ if (reservedDeclared.length > 0) {
242
+ for (const p of reservedDeclared) {
243
+ logError(reservedPagePathErrorMessage(p.pageKey));
244
+ }
245
+ throw new Error(`Refusing to sync schema: ${reservedDeclared.length} reserved pageKey(s) declared in contentSync.pages.`);
246
+ }
247
+ logStep(`Loaded schema with ${Object.keys(artifact.sections).length} section(s).`);
248
+ logStep(`Checksum: ${checksum}`);
249
+ if (opts.dryRun) {
250
+ logStep("--dry-run was set, skipping upload.");
251
+ return;
252
+ }
253
+ const apiKey = opts.env["BIAB_API_KEY"];
254
+ const siteId = opts.env["BIAB_SITE_ID"];
255
+ const rawBase = opts.env["BIAB_PACKAGE_API_BASE_URL"] ??
256
+ opts.env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
257
+ if (!apiKey)
258
+ throw new Error("Missing BIAB_API_KEY in env or .env.local.");
259
+ if (!siteId)
260
+ throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
261
+ if (!rawBase) {
262
+ throw new Error("Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).");
263
+ }
264
+ const baseUrl = normalizeBaseUrl(rawBase);
265
+ const target = `${baseUrl}/sites/${encodeURIComponent(siteId)}/marketing/draft-schema`;
266
+ logStep(`POST ${target}`);
267
+ const res = await fetch(target, {
268
+ method: "POST",
269
+ headers: {
270
+ "content-type": "application/json",
271
+ authorization: `Bearer ${apiKey}`,
272
+ },
273
+ body: JSON.stringify({
274
+ schema: artifact,
275
+ checksum,
276
+ }),
277
+ });
278
+ const bodyText = await res.text();
279
+ let body = null;
280
+ try {
281
+ body = JSON.parse(bodyText);
282
+ }
283
+ catch {
284
+ body = null;
285
+ }
286
+ if (!res.ok) {
287
+ logError(`Upload failed (${res.status} ${res.statusText}).`);
288
+ if (body?.message)
289
+ logError(`Server: ${body.message}`);
290
+ else
291
+ logError(`Raw response: ${bodyText.slice(0, 500)}`);
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ if (body?.noop) {
296
+ logStep(`No changes — schema already at v${body.version}.`);
297
+ }
298
+ else if (body?.version) {
299
+ logStep(`Uploaded draft v${body.version}.`);
300
+ logStep("Promote in the dashboard at Site Builder → Site Data → Schema to make this live.");
301
+ }
302
+ else {
303
+ logStep("Upload succeeded.");
304
+ }
305
+ }
306
+ /**
307
+ * Load either a plain data model or createFormSchemaOrchestrator() output.
308
+ * The latter intentionally keeps its database artifact and generated form
309
+ * candidates together in one config.
310
+ */
311
+ async function loadDataModelConfig(configPath) {
312
+ const url = pathToFileURL(configPath).href;
313
+ let mod;
314
+ try {
315
+ mod = (await import(url));
316
+ }
317
+ catch (err) {
318
+ if (configPath.endsWith(".ts")) {
319
+ throw new Error(`Could not load TypeScript config at ${configPath}. Run the CLI under tsx or ts-node, e.g.: ` +
320
+ `\`npx tsx node_modules/@businessdash/sdk/dist/cli.js sync-data-model\`.\nUnderlying: ${err instanceof Error ? err.message : String(err)}`);
321
+ }
322
+ throw err;
323
+ }
324
+ const chosen = mod.default ?? mod.dataModel;
325
+ const orchestrated = chosen;
326
+ const model = orchestrated?.databaseSchema ?? chosen;
327
+ if (!model ||
328
+ typeof model.toJSONSchema !== "function") {
329
+ throw new Error(`${configPath} did not export a data model. Default-export createSchema(...) or createFormSchemaOrchestrator(...).`);
330
+ }
331
+ return {
332
+ model: model,
333
+ generatedForms: Array.isArray(orchestrated?.generatedForms)
334
+ ? orchestrated.generatedForms
335
+ : [],
336
+ };
337
+ }
338
+ function resolveApiTarget(env) {
339
+ const apiKey = env["BIAB_API_KEY"];
340
+ const siteId = env["BIAB_SITE_ID"];
341
+ const rawBase = env["BIAB_PACKAGE_API_BASE_URL"] ??
342
+ env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
343
+ if (!apiKey)
344
+ throw new Error("Missing BIAB_API_KEY in env or .env.local.");
345
+ if (!siteId)
346
+ throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
347
+ if (!rawBase) {
348
+ throw new Error("Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).");
349
+ }
350
+ return { baseUrl: normalizeBaseUrl(rawBase), apiKey, siteId };
351
+ }
352
+ async function callDataModelApi(input) {
353
+ const { target, path, method, body } = input;
354
+ const url = `${target.baseUrl}/sites/${encodeURIComponent(target.siteId)}/data-model/${path}`;
355
+ const res = await fetch(url, {
356
+ method,
357
+ headers: {
358
+ "content-type": "application/json",
359
+ authorization: `Bearer ${target.apiKey}`,
360
+ },
361
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
362
+ });
363
+ const text = await res.text();
364
+ let parsed = null;
365
+ try {
366
+ parsed = JSON.parse(text);
367
+ }
368
+ catch {
369
+ parsed = null;
370
+ }
371
+ if (!res.ok) {
372
+ const message = typeof parsed?.["message"] === "string"
373
+ ? parsed["message"]
374
+ : `HTTP ${res.status} ${res.statusText} — ${text.slice(0, 240)}`;
375
+ throw new Error(message);
376
+ }
377
+ if (!parsed) {
378
+ throw new Error(`Server replied ${res.status} but the body wasn't JSON — first 240 chars: ${text.slice(0, 240)}`);
379
+ }
380
+ return parsed;
381
+ }
382
+ /**
383
+ * Render a migration plan. Grouped by class rather than by object, because the
384
+ * question a developer is actually asking is "is this safe?" — not "what
385
+ * changed on permits?".
386
+ */
387
+ function printPlan(plan) {
388
+ if (plan.changes.length === 0) {
389
+ console.log(` ${fg.dim("No changes — the live model already matches.")}`);
390
+ }
391
+ const order = ["exposure", "destructive", "backfill", "safe"];
392
+ const marks = {
393
+ exposure: fg.red("◉"),
394
+ destructive: fg.red("✕"),
395
+ backfill: fg.amber("⚠"),
396
+ safe: fg.green("✓"),
397
+ };
398
+ for (const cls of order) {
399
+ const changes = plan.changes.filter((c) => c.class === cls);
400
+ if (changes.length === 0)
401
+ continue;
402
+ console.log("");
403
+ console.log(` ${fg.bold(cls.toUpperCase())} (${changes.length})`);
404
+ for (const change of changes) {
405
+ console.log(` ${marks[cls]} ${change.summary}`);
406
+ if (change.risk)
407
+ console.log(` ${fg.dim(change.risk)}`);
408
+ }
409
+ }
410
+ if (plan.unmanaged.length > 0) {
411
+ console.log("");
412
+ console.log(` ${fg.dim(`${plan.unmanaged.length} live object/field(s) aren't in this config. They're left alone — pass --prune to remove them.`)}`);
413
+ for (const item of plan.unmanaged) {
414
+ const label = item.field ? `${item.object}.${item.field}` : item.object;
415
+ console.log(` ${fg.dim(`· ${label}`)}`);
416
+ }
417
+ }
418
+ if (plan.hasExposure) {
419
+ console.log("");
420
+ console.log(` ${fg.red("This plan changes who can READ your data.")} ${fg.dim("Opening a table to signed-in customers or the public is confirmed separately in the dashboard, and can't be undone for data that's already been read.")}`);
421
+ }
422
+ if (plan.hasDestructive) {
423
+ console.log("");
424
+ console.log(` ${fg.red("This plan contains destructive changes.")} ${fg.dim("They need an explicit confirmation in the dashboard before they apply.")}`);
425
+ }
426
+ }
427
+ async function cmdPrintDataModel(opts) {
428
+ const loaded = await loadDataModelConfig(opts.configPath);
429
+ const artifact = {
430
+ ...loaded.model.toJSONSchema(),
431
+ ...(loaded.generatedForms.length > 0
432
+ ? { generatedForms: loaded.generatedForms }
433
+ : {}),
434
+ };
435
+ const checksum = await computeDataModelChecksum(artifact);
436
+ console.log(JSON.stringify({ checksum, artifact }, null, 2));
437
+ }
438
+ /**
439
+ * What's actually live — read from the metadata engine, not from whatever was
440
+ * last pushed. The dashboard is a second writer, so those differ.
441
+ */
442
+ async function cmdViewDataModel(opts) {
443
+ const target = resolveApiTarget(opts.env);
444
+ const res = await callDataModelApi({ target, path: "live", method: "GET" });
445
+ const model = res["model"];
446
+ const objects = (model?.objects ?? []);
447
+ if (opts.json) {
448
+ console.log(JSON.stringify(res, null, 2));
449
+ return;
450
+ }
451
+ if (opts.diff) {
452
+ const configPath = findDataModelConfigPath(opts.cwd, opts.configOverride);
453
+ const local = await loadDataModelConfig(configPath);
454
+ const planRes = await callDataModelApi({
455
+ target,
456
+ path: "plan",
457
+ method: "POST",
458
+ body: { schema: local.model.toJSONSchema(), prune: opts.prune },
459
+ });
460
+ logStep("Plan (local config vs live model):");
461
+ printPlan(planRes["plan"]);
462
+ return;
463
+ }
464
+ if (opts.out) {
465
+ const path = isAbsolute(opts.out) ? opts.out : resolve(opts.cwd, opts.out);
466
+ writeFileSync(path, renderConfigModule(objects), "utf8");
467
+ logStep(`${fg.green("✓")} Wrote ${path}`);
468
+ logStep(fg.dim("Review it, then `sync-data-model` to push. Your model is code from here on."));
469
+ return;
470
+ }
471
+ logStep(`Live model — ${fg.bold(String(objects.length))} object(s) ${fg.dim(`(checksum ${String(res["checksum"]).slice(0, 12)}…)`)}`);
472
+ for (const object of objects) {
473
+ const tag = object.kind === "standard" ? fg.dim(" (standard)") : "";
474
+ console.log("");
475
+ console.log(` ${fg.bold(object.namePlural)}${tag}`);
476
+ for (const field of object.fields ?? []) {
477
+ const enumValues = Array.isArray(field.options)
478
+ ? fg.dim(` [${field.options.map((o) => o.value).join(", ")}]`)
479
+ : "";
480
+ console.log(` ${field.name} ${fg.dim(field.type)}${enumValues}`);
481
+ }
482
+ }
483
+ }
484
+ /**
485
+ * Pull the ENTIRE custom database — schema + records + relations — into one
486
+ * portable snapshot file. The read side `view-data-model` never had: it emits
487
+ * the schema, but records could only be pushed IN (`sync-records`), never
488
+ * pulled OUT.
489
+ *
490
+ * Commit the file next to your code and it becomes a Drizzle/Convex-style
491
+ * snapshot: deterministic ordering means successive exports git-diff cleanly,
492
+ * so an AI agent (or a human) can read the collection's state and how it
493
+ * changed over time without querying BIAB live. It also re-seeds a fresh org —
494
+ * the `schema` half is the same shape `sync-data-model` pushes, the `records`
495
+ * half is the same shape `sync-records` ingests.
496
+ */
497
+ async function cmdExportDataModel(opts) {
498
+ const target = resolveApiTarget(opts.env);
499
+ const path = opts.schemaOnly ? "export?records=0" : "export";
500
+ const res = await callDataModelApi({ target, path, method: "GET" });
501
+ const seed = res["seed"];
502
+ if (!seed) {
503
+ throw new Error("Server did not return a seed. Check your key's scopes.");
504
+ }
505
+ const outPath = opts.out
506
+ ? isAbsolute(opts.out)
507
+ ? opts.out
508
+ : resolve(opts.cwd, opts.out)
509
+ : resolve(opts.cwd, "biab.data-model.snapshot.json");
510
+ writeFileSync(outPath, `${JSON.stringify(seed, null, 2)}\n`, "utf8");
511
+ const c = seed.counts;
512
+ logStep(`${fg.green("✓")} Wrote ${outPath}`);
513
+ logStep(fg.dim(`${c.customObjects} custom object(s), ${c.records} record(s)` +
514
+ (opts.schemaOnly ? " (schema only)" : "") +
515
+ ` · checksum ${seed.checksum.slice(0, 12)}…`));
516
+ if (c.syntheticIdentifiers > 0) {
517
+ logStep(fg.amber(`⚠ ${c.syntheticIdentifiers} dashboard-authored row(s) were given a synthetic id. Re-seeding those into the SAME org duplicates them — target a fresh org.`));
518
+ }
519
+ if (c.truncated) {
520
+ logStep(fg.amber("⚠ An object hit the per-object cap; some rows were left out. Pass --out to a full export or page the records endpoint."));
521
+ }
522
+ logStep(fg.dim("Commit this file. Re-seed with `sync-data-model` (schema) + `sync-records` (rows)."));
523
+ }
524
+ /**
525
+ * Emit a GraphQL SDL schema of the org's custom data model.
526
+ *
527
+ * "GraphQL layer compatibility": a real, typed GraphQL schema of the custom
528
+ * collections that any GraphQL codegen / tooling can consume, and that an AI
529
+ * agent can read next to an `export-data-model` snapshot — the schema of the
530
+ * data beside the data. This emits the SCHEMA; it does not stand up a GraphQL
531
+ * server (execution is a separate layer).
532
+ *
533
+ * Reads the live model (same source as `view-data-model`) and renders SDL
534
+ * locally — no server-side GraphQL runtime involved.
535
+ */
536
+ async function cmdExportGraphqlSchema(opts) {
537
+ const target = resolveApiTarget(opts.env);
538
+ const res = await callDataModelApi({ target, path: "live", method: "GET" });
539
+ const model = res["model"];
540
+ if (!model?.objects) {
541
+ throw new Error("Server did not return a model. Check your key's scopes.");
542
+ }
543
+ const sdl = dataModelToGraphqlSdl(model, {
544
+ includeStandard: opts.includeStandard,
545
+ });
546
+ const outPath = opts.out
547
+ ? isAbsolute(opts.out)
548
+ ? opts.out
549
+ : resolve(opts.cwd, opts.out)
550
+ : resolve(opts.cwd, "biab.data-model.graphql");
551
+ writeFileSync(outPath, sdl, "utf8");
552
+ const typeCount = (sdl.match(/^type \w/gm) ?? []).length;
553
+ logStep(`${fg.green("✓")} Wrote ${outPath}`);
554
+ logStep(fg.dim(`${typeCount} GraphQL type(s)${opts.includeStandard ? " (incl. standard)" : ""}. Feed it to codegen or commit it as a contract.`));
555
+ }
556
+ /**
557
+ * Scaffold a config from live state. The adoption path for an org that already
558
+ * built its model in the dashboard — without it, data-model-as-code would only
559
+ * serve greenfield orgs.
560
+ */
561
+ function renderConfigModule(objects) {
562
+ const lines = [
563
+ "// Generated by `biab-dev view-data-model --out`.",
564
+ "// This is your org's live data model, as code. Edit it, push it with",
565
+ "// `biab-dev sync-data-model`, then promote in the dashboard.",
566
+ "",
567
+ 'import { createSchema, defineCollection, defineField } from "@businessdash/sdk";',
568
+ "",
569
+ "export default createSchema({",
570
+ "\tcollections: [",
571
+ ];
572
+ for (const object of objects) {
573
+ lines.push("\t\tdefineField({");
574
+ lines.push(`\t\t\tuniversalIdentifier: ${JSON.stringify(object.universalIdentifier)},`);
575
+ if (object.kind === "standard") {
576
+ lines.push('\t\t\tkind: "standard",');
577
+ lines.push(`\t\t\tstandardEntityName: ${JSON.stringify(object.standardEntityName)},`);
578
+ }
579
+ lines.push(`\t\t\tnameSingular: ${JSON.stringify(object.nameSingular)},`);
580
+ lines.push(`\t\t\tnamePlural: ${JSON.stringify(object.namePlural)},`);
581
+ lines.push(`\t\t\tlabelSingular: ${JSON.stringify(object.labelSingular)},`);
582
+ lines.push(`\t\t\tlabelPlural: ${JSON.stringify(object.labelPlural)},`);
583
+ if (object.labelIdentifierFieldName) {
584
+ lines.push(`\t\t\tlabelIdentifierFieldName: ${JSON.stringify(object.labelIdentifierFieldName)},`);
585
+ }
586
+ lines.push("\t\t\tfields: [");
587
+ for (const field of object.fields ?? []) {
588
+ lines.push(`\t\t\t\tdefineCollection(${JSON.stringify(field)}),`);
589
+ }
590
+ lines.push("\t\t\t],");
591
+ lines.push("\t\t}),");
592
+ }
593
+ lines.push("\t],");
594
+ lines.push("})");
595
+ lines.push("");
596
+ return lines.join("\n");
597
+ }
598
+ async function cmdSyncDataModel(opts) {
599
+ const loaded = await loadDataModelConfig(opts.configPath);
600
+ const selectedForms = await selectGeneratedForms(loaded.generatedForms, opts.forms);
601
+ const artifact = {
602
+ ...loaded.model.toJSONSchema(),
603
+ ...(selectedForms.length > 0 ? { generatedForms: selectedForms } : {}),
604
+ };
605
+ const checksum = await computeDataModelChecksum(artifact);
606
+ const objects = (artifact["objects"] ?? []);
607
+ logStep(`Loaded data model with ${objects.length} object(s).`);
608
+ if (loaded.generatedForms.length > 0) {
609
+ logStep(`Including ${selectedForms.length}/${loaded.generatedForms.length} generated inactive form draft(s).`);
610
+ }
611
+ logStep(`Checksum: ${checksum}`);
612
+ const target = resolveApiTarget(opts.env);
613
+ // Always plan before pushing. A push is harmless on its own — it only
614
+ // writes the draft slot — but showing what promoting it WOULD do is the
615
+ // whole point of the flow, and it costs one request.
616
+ const planRes = await callDataModelApi({
617
+ target,
618
+ path: "plan",
619
+ method: "POST",
620
+ body: { schema: artifact, prune: opts.prune },
621
+ });
622
+ const plan = planRes["plan"];
623
+ console.log("");
624
+ logStep("Plan (what promoting this would do):");
625
+ printPlan(plan);
626
+ console.log("");
627
+ if (opts.dryRun) {
628
+ logStep("--dry-run was set, skipping upload.");
629
+ return;
630
+ }
631
+ const res = await callDataModelApi({
632
+ target,
633
+ path: "draft-schema",
634
+ method: "POST",
635
+ body: { schema: artifact, checksum, note: opts.note },
636
+ });
637
+ if (res["noop"]) {
638
+ logStep(`No changes — the draft is already at v${String(res["version"])}.`);
639
+ return;
640
+ }
641
+ logStep(`${fg.green("✓")} Uploaded draft v${String(res["version"])}.`);
642
+ logStep(fg.dim("Nothing has changed yet. Promote in the dashboard at Site Builder → Site Data → Database to apply the collections and create/update the selected forms as inactive drafts."));
643
+ }
644
+ async function selectGeneratedForms(candidates, mode) {
645
+ if (candidates.length === 0 || mode === "none")
646
+ return [];
647
+ if (mode === "all")
648
+ return candidates;
649
+ if (mode.startsWith("list:")) {
650
+ const requested = new Set(mode
651
+ .slice(5)
652
+ .split(",")
653
+ .map((value) => value.trim())
654
+ .filter(Boolean));
655
+ const selected = candidates.filter((candidate) => requested.has(candidate.slug) || requested.has(candidate.sourceKey));
656
+ const found = new Set(selected.flatMap((candidate) => [candidate.slug, candidate.sourceKey]));
657
+ const unknown = [...requested].filter((value) => !found.has(value));
658
+ if (unknown.length > 0) {
659
+ throw new Error(`Unknown generated form candidate(s): ${unknown.join(", ")}. Available slugs: ${candidates.map((candidate) => candidate.slug).join(", ")}.`);
660
+ }
661
+ return selected;
662
+ }
663
+ // CI/non-interactive runs remain deterministic and useful: all candidates
664
+ // are included, but promotion still creates them inactive.
665
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
666
+ return candidates;
667
+ console.log("");
668
+ logStep("Generated form candidates (all selected by default):");
669
+ candidates.forEach((candidate, index) => {
670
+ console.log(` ${index + 1}. ${candidate.name} ${fg.dim(`(${candidate.slug}, ${candidate.fields.length} fields)`)}`);
671
+ });
672
+ const prompt = createInterface({
673
+ input: process.stdin,
674
+ output: process.stdout,
675
+ });
676
+ const answer = await prompt.question("Select forms by number (comma-separated), press Enter for all, or type none: ");
677
+ prompt.close();
678
+ const value = answer.trim().toLowerCase();
679
+ if (!value)
680
+ return candidates;
681
+ if (value === "none")
682
+ return [];
683
+ const indexes = new Set(value
684
+ .split(",")
685
+ .map((part) => Number(part.trim()) - 1)
686
+ .filter((index) => Number.isInteger(index)));
687
+ if ([...indexes].some((index) => index < 0 || index >= candidates.length)) {
688
+ throw new Error(`Form selection must contain numbers from 1 to ${candidates.length}.`);
689
+ }
690
+ return candidates.filter((_, index) => indexes.has(index));
691
+ }
692
+ /**
693
+ * Seed rows for custom objects from JSON on disk.
694
+ *
695
+ * Layout: `<rootDir>/<anything>.json`, each file an array of
696
+ * `{ object, universalIdentifier, values }`. Row identity is required — it's
697
+ * what makes running this on every deploy converge instead of duplicating.
698
+ */
699
+ function discoverSeedFiles(rootDir) {
700
+ const rows = [];
701
+ const warnings = [];
702
+ const walk = (dir) => {
703
+ for (const entry of readdirSync(dir)) {
704
+ const abs = join(dir, entry);
705
+ if (statSync(abs).isDirectory()) {
706
+ walk(abs);
707
+ continue;
708
+ }
709
+ if (!entry.endsWith(".json"))
710
+ continue;
711
+ let parsed;
712
+ try {
713
+ parsed = JSON.parse(readFileSync(abs, "utf8"));
714
+ }
715
+ catch (err) {
716
+ warnings.push(`Invalid JSON in ${abs}: ${err instanceof Error ? err.message : String(err)}`);
717
+ continue;
718
+ }
719
+ if (!Array.isArray(parsed)) {
720
+ warnings.push(`Skipping ${abs} — expected an array of rows.`);
721
+ continue;
722
+ }
723
+ for (const [i, raw] of parsed.entries()) {
724
+ const row = raw;
725
+ if (!row?.object || !row.universalIdentifier || !row.values) {
726
+ warnings.push(`Skipping ${abs}[${i}] — needs object, universalIdentifier, and values.`);
727
+ continue;
728
+ }
729
+ if (row.relations !== undefined &&
730
+ (typeof row.relations !== "object" ||
731
+ row.relations === null ||
732
+ Array.isArray(row.relations))) {
733
+ warnings.push(`Skipping ${abs}[${i}] — relations must be an object of { fieldName: targetUid | targetUid[] }.`);
734
+ continue;
735
+ }
736
+ rows.push({
737
+ object: row.object,
738
+ universalIdentifier: row.universalIdentifier,
739
+ values: row.values,
740
+ ...(row.relations ? { relations: row.relations } : {}),
741
+ });
742
+ }
743
+ }
744
+ };
745
+ walk(rootDir);
746
+ return { rows, warnings };
747
+ }
748
+ async function cmdSyncRecords(opts) {
749
+ const rootDir = isAbsolute(opts.rootDir)
750
+ ? opts.rootDir
751
+ : resolve(opts.cwd, opts.rootDir);
752
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
753
+ throw new Error(`--records-dir resolves to "${rootDir}", which doesn't exist or isn't a directory.`);
754
+ }
755
+ const { rows, warnings } = discoverSeedFiles(rootDir);
756
+ logStep(`Discovered ${fg.bold(String(rows.length))} row(s) under ${rootDir}.`);
757
+ for (const warning of warnings) {
758
+ console.log(` ${fg.amber("⚠")} ${fg.dim(warning)}`);
759
+ }
760
+ if (rows.length === 0) {
761
+ logStep("Nothing to seed.");
762
+ return;
763
+ }
764
+ if (opts.dryRun) {
765
+ logStep("--dry-run was set, skipping upload.");
766
+ for (const row of rows) {
767
+ const relNote = row.relations
768
+ ? ` ${fg.dim(`(+${Object.keys(row.relations).length} relation field(s))`)}`
769
+ : "";
770
+ console.log(` ${fg.dim("→")} ${row.object} ${fg.dim(row.universalIdentifier)}${relNote}`);
771
+ }
772
+ return;
773
+ }
774
+ const target = resolveApiTarget(opts.env);
775
+ const BATCH = 64; // well under the server's 256 cap; smaller = faster feedback
776
+ let created = 0;
777
+ let updated = 0;
778
+ const failures = [];
779
+ // 893 rows is ~14 sequential round-trips; without this the CLI sits silent
780
+ // between "Discovered" and the final tally the whole time.
781
+ const progress = makeProgress(rows.length);
782
+ progress.tick(0);
783
+ for (let i = 0; i < rows.length; i += BATCH) {
784
+ // Pass 1 sends values only. Relations go in a second pass below, after
785
+ // EVERY record exists — so a link can point at a row from any file,
786
+ // in any order, without a "target not found yet" failure.
787
+ const batch = rows.slice(i, i + BATCH).map((row) => ({
788
+ object: row.object,
789
+ universalIdentifier: row.universalIdentifier,
790
+ values: row.values,
791
+ }));
792
+ const res = await callDataModelApi({
793
+ target,
794
+ path: "records",
795
+ method: "POST",
796
+ body: { items: batch },
797
+ });
798
+ for (const result of (res["results"] ?? [])) {
799
+ if (result.ok) {
800
+ if (result.created)
801
+ created += 1;
802
+ else
803
+ updated += 1;
804
+ }
805
+ else {
806
+ failures.push(result);
807
+ }
808
+ }
809
+ const note = `${created} created, ${updated} updated${failures.length > 0 ? `, ${failures.length} failed` : ""}`;
810
+ progress.tick(Math.min(i + BATCH, rows.length), note);
811
+ }
812
+ progress.clear();
813
+ console.log("");
814
+ logStep(`${fg.green(`✓ ${created} created`)} ${fg.green(`${updated} updated`)}${failures.length > 0 ? ` ${fg.red(`✕ ${failures.length} failed`)}` : ""}`);
815
+ for (const failure of failures) {
816
+ if (failure.ok)
817
+ continue;
818
+ console.log(` ${fg.red("✕")} ${failure.object} ${fg.dim(failure.universalIdentifier)} — ${fg.dim(failure.error)}`);
819
+ }
820
+ // Pass 2: converge declared relations, now that every record exists.
821
+ // Rows whose record failed in pass 1 are skipped — no point linking a row
822
+ // that isn't there.
823
+ const failedIdentity = new Set(failures.map((f) => `${f.object}:${f.universalIdentifier}`));
824
+ const relationRows = rows.filter((row) => row.relations &&
825
+ Object.keys(row.relations).length > 0 &&
826
+ !failedIdentity.has(`${row.object}:${row.universalIdentifier}`));
827
+ if (relationRows.length > 0) {
828
+ logStep(`Syncing relations for ${fg.bold(String(relationRows.length))} row(s)…`);
829
+ let linksApplied = 0;
830
+ const relationFailures = [];
831
+ for (let i = 0; i < relationRows.length; i += BATCH) {
832
+ const batch = relationRows.slice(i, i + BATCH).map((row) => ({
833
+ object: row.object,
834
+ universalIdentifier: row.universalIdentifier,
835
+ relations: row.relations,
836
+ }));
837
+ const res = await callDataModelApi({
838
+ target,
839
+ path: "records",
840
+ method: "POST",
841
+ body: { items: batch },
842
+ });
843
+ for (const result of (res["results"] ?? [])) {
844
+ if (result.ok)
845
+ linksApplied += result.relationsApplied ?? 0;
846
+ else
847
+ relationFailures.push(result);
848
+ }
849
+ }
850
+ logStep(`${fg.green(`✓ ${linksApplied} link change(s) applied`)}${relationFailures.length > 0
851
+ ? ` ${fg.red(`✕ ${relationFailures.length} failed`)}`
852
+ : ""}`);
853
+ for (const failure of relationFailures) {
854
+ if (failure.ok)
855
+ continue;
856
+ console.log(` ${fg.red("✕")} ${failure.object} ${fg.dim(failure.universalIdentifier)} — ${fg.dim(failure.error)}`);
857
+ }
858
+ if (relationFailures.length > 0)
859
+ process.exitCode = 1;
860
+ }
861
+ if (failures.length > 0)
862
+ process.exitCode = 1;
863
+ }
864
+ /**
865
+ * Wipe record contents, never collection definitions. There is intentionally
866
+ * no `--all` and no `--yes`: a human must name the targets, review live counts,
867
+ * answer once, and then type a state-bound phrase exactly.
868
+ */
869
+ async function cmdWipeCollections(opts) {
870
+ const collections = [
871
+ ...new Set(opts.collections
872
+ .map((value) => value.trim())
873
+ .filter((value) => value.length > 0)),
874
+ ];
875
+ if (collections.length === 0) {
876
+ throw new Error("wipe-collections requires --collections name-a,name-b. There is no implicit all-collections mode.");
877
+ }
878
+ const target = resolveApiTarget(opts.env);
879
+ const path = `records/wipe?collections=${encodeURIComponent(collections.join(","))}`;
880
+ const preview = (await callDataModelApi({
881
+ target,
882
+ path,
883
+ method: "GET",
884
+ }));
885
+ console.log("");
886
+ logStep(fg.red(fg.bold("DESTRUCTIVE RECORD WIPE PREVIEW")));
887
+ for (const collection of preview.collections) {
888
+ console.log(` ${fg.red("✕")} ${fg.bold(collection.name)} — ${collection.records} record(s) ` +
889
+ fg.dim(`(${collection.activeRecords} active, ${collection.softDeletedRecords} soft-deleted)`));
890
+ }
891
+ console.log(` ${fg.amber("↳")} ${preview.relationCount} relationship link(s) also removed`);
892
+ console.log(` ${fg.green("↳")} Backup required at Media / ${preview.backupFolder}`);
893
+ console.log(` ${fg.dim("↳")} Plan ${preview.planToken.slice(0, 12)}… — any intervening data change invalidates it`);
894
+ console.log("");
895
+ if (opts.dryRun) {
896
+ logStep("--dry-run was set. Nothing was backed up or wiped.");
897
+ return;
898
+ }
899
+ if (preview.recordCount === 0 && preview.relationCount === 0) {
900
+ logStep("Nothing to wipe.");
901
+ return;
902
+ }
903
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
904
+ throw new Error("wipe-collections only runs in an interactive terminal. It deliberately has no --yes bypass.");
905
+ }
906
+ const prompt = createInterface({
907
+ input: process.stdin,
908
+ output: process.stdout,
909
+ });
910
+ try {
911
+ const first = await prompt.question("Continue to the final typed confirmation? Type yes to continue: ");
912
+ if (first.trim().toLowerCase() !== "yes") {
913
+ logStep("Cancelled. No backup was created and nothing was wiped.");
914
+ return;
915
+ }
916
+ console.log("");
917
+ console.log(fg.amber("The server will save and verify the backup before deleting any record."));
918
+ const confirmation = await prompt.question(`Type exactly "${preview.confirmationPhrase}" to wipe: `);
919
+ if (confirmation !== preview.confirmationPhrase) {
920
+ throw new Error(`Confirmation did not match. Expected exactly: ${preview.confirmationPhrase}`);
921
+ }
922
+ const result = (await callDataModelApi({
923
+ target,
924
+ path: "records/wipe",
925
+ method: "POST",
926
+ body: {
927
+ collections,
928
+ planToken: preview.planToken,
929
+ confirmation,
930
+ },
931
+ }));
932
+ console.log("");
933
+ logStep(`${fg.green(`✓ Verified backup: ${result.backup.name}`)} ${fg.dim(`(${result.backup.sizeBytes} bytes, sha256 ${result.backup.sha256.slice(0, 12)}…)`)}`);
934
+ logStep(`${fg.green(`✓ Wiped ${result.deleted.records} record(s)`)} and ${result.deleted.relations} relationship link(s).`);
935
+ logStep(`Backup location: Media / ${result.backup.folder}`);
936
+ }
937
+ finally {
938
+ prompt.close();
939
+ }
940
+ }
941
+ /**
942
+ * Developer shortcut that wipes records AND removes the named collections
943
+ * from the schema. Combines the data-wipe flow (with backup) with a
944
+ * pruned schema draft push; the schema change still requires dashboard
945
+ * promotion.
946
+ *
947
+ * When --all is used, calls the data-model/reset endpoint which atomically
948
+ * soft-deletes all metadata, clears records, and removes schema slots.
949
+ *
950
+ * Accepts --yes to skip the interactive prompt (single confirmation).
951
+ */
952
+ async function cmdWipeDatabase(opts) {
953
+ const explicitNames = [
954
+ ...new Set(opts.collections.map((v) => v.trim()).filter(Boolean)),
955
+ ];
956
+ if (!opts.all && explicitNames.length === 0) {
957
+ throw new Error("wipe-database requires --collections name-a,name-b or --all.");
958
+ }
959
+ const target = resolveApiTarget(opts.env);
960
+ // ── BRANCH: Full reset via data-model/reset ──
961
+ if (opts.all) {
962
+ const preview = (await callDataModelApi({
963
+ target,
964
+ path: "reset",
965
+ method: "GET",
966
+ }));
967
+ console.log("");
968
+ logStep(fg.red(fg.bold("DATA MODEL RESET PREVIEW")));
969
+ console.log(` ${fg.red("✕")} ${preview.objectCount} object(s) will be soft-deleted`);
970
+ console.log(` ${fg.dim("↳")} ${preview.fieldCount} field(s), ${preview.viewCount} view(s)`);
971
+ console.log(` ${fg.red("✕")} ${preview.recordCount} record(s) and ${preview.relationCount} relation(s) will be hard-deleted`);
972
+ if (preview.generatedFormCount > 0) {
973
+ console.log(` ${fg.amber("⚠")} ${preview.generatedFormCount} generated form(s) will be deactivated`);
974
+ }
975
+ if (preview.hasDraftSchema) {
976
+ console.log(` ${fg.amber("↳")} Draft schema slot will be cleared`);
977
+ }
978
+ if (preview.hasPublishedSchema) {
979
+ console.log(` ${fg.amber("↳")} Published schema slot will be cleared`);
980
+ }
981
+ console.log(` ${fg.green("↳")} Backup at Media / ${preview.backupFolder}`);
982
+ console.log(` ${fg.dim("↳")} Plan ${preview.planToken.slice(0, 12)}…`);
983
+ console.log("");
984
+ if (opts.dryRun) {
985
+ logStep("--dry-run was set. Nothing was backed up or reset.");
986
+ return;
987
+ }
988
+ // ── Confirmation ──
989
+ if (!opts.yes) {
990
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
991
+ throw new Error("wipe-database --all requires an interactive terminal or --yes.");
992
+ }
993
+ const prompt = createInterface({
994
+ input: process.stdin,
995
+ output: process.stdout,
996
+ });
997
+ try {
998
+ const first = await prompt.question("Continue to the final typed confirmation? Type yes to continue: ");
999
+ if (first.trim().toLowerCase() !== "yes") {
1000
+ logStep("Cancelled. No backup was created and nothing was reset.");
1001
+ return;
1002
+ }
1003
+ console.log("");
1004
+ console.log(fg.amber("The server will save and verify the backup before resetting any data."));
1005
+ const confirmation = await prompt.question(`Type exactly "${preview.confirmationPhrase}" to reset: `);
1006
+ if (confirmation !== preview.confirmationPhrase) {
1007
+ throw new Error(`Confirmation did not match. Expected exactly: ${preview.confirmationPhrase}`);
1008
+ }
1009
+ }
1010
+ finally {
1011
+ prompt.close();
1012
+ }
1013
+ }
1014
+ // ── Execute reset ──
1015
+ logStep("Backing up and resetting data model…");
1016
+ try {
1017
+ const result = (await callDataModelApi({
1018
+ target,
1019
+ path: "reset",
1020
+ method: "POST",
1021
+ body: {
1022
+ planToken: preview.planToken,
1023
+ confirmation: preview.confirmationPhrase,
1024
+ },
1025
+ }));
1026
+ console.log("");
1027
+ logStep(`${fg.green("✓")} Backup: ${result.backup.name} (${result.backup.sizeBytes} bytes, sha256 ${result.backup.sha256.slice(0, 12)}…)`);
1028
+ logStep(`${fg.green("✓")} Reset: ${result.objectsSoftDeleted} object(s), ${result.fieldsSoftDeleted} field(s), ${result.viewsSoftDeleted} view(s)`);
1029
+ logStep(`${fg.green("✓")} Wiped: ${result.recordsDeleted} record(s), ${result.relationsDeleted} relation(s)`);
1030
+ if (result.formsSoftDeleted > 0) {
1031
+ logStep(`${fg.green("✓")} Deactivated ${result.formsSoftDeleted} generated form(s)`);
1032
+ }
1033
+ }
1034
+ catch (err) {
1035
+ throw new Error(`Data model reset failed: ${err instanceof Error ? err.message : String(err)}`);
1036
+ }
1037
+ return;
1038
+ }
1039
+ // ── BRANCH: Targeted wipe via records/wipe + pruned schema draft ──
1040
+ // ── 1. Load local config and determine which collections to remove ──
1041
+ const loaded = await loadDataModelConfig(opts.configPath);
1042
+ const artifact = loaded.model.toJSONSchema();
1043
+ const names = explicitNames;
1044
+ const targetNames = new Set(names);
1045
+ const keep = [];
1046
+ const removed = [];
1047
+ for (const obj of artifact.objects) {
1048
+ const singular = String(obj["nameSingular"] ?? "");
1049
+ const plural = String(obj["namePlural"] ?? "");
1050
+ if (targetNames.has(singular) || targetNames.has(plural)) {
1051
+ removed.push(obj);
1052
+ }
1053
+ else {
1054
+ keep.push(obj);
1055
+ }
1056
+ }
1057
+ if (removed.length === 0 && artifact.objects.length > 0) {
1058
+ throw new Error(`wipe-database: none of the named collections match any object in the config. ` +
1059
+ `Config has: ${artifact.objects.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}`);
1060
+ }
1061
+ const prunedArtifact = { ...artifact, objects: keep };
1062
+ // Remove generated forms that reference wiped collections
1063
+ const wipedUis = new Set(removed.map((o) => String(o["universalIdentifier"] ?? "")));
1064
+ const loadedForms = loaded.generatedForms;
1065
+ const keptForms = loadedForms.filter((f) => {
1066
+ const refs = f.settings.generatedFrom.objectIdentifiers ??
1067
+ (f.settings.generatedFrom.objectIdentifier
1068
+ ? [f.settings.generatedFrom.objectIdentifier]
1069
+ : []);
1070
+ return !refs.some((r) => wipedUis.has(r));
1071
+ });
1072
+ const prunedForms = keptForms.length < loadedForms.length ? keptForms : loadedForms;
1073
+ const pushArtifact = {
1074
+ ...prunedArtifact,
1075
+ ...(prunedForms.length > 0 ? { generatedForms: prunedForms } : {}),
1076
+ };
1077
+ logStep(`Loaded config with ${artifact.objects.length} object(s), ` +
1078
+ `removing ${removed.length} (${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}), ` +
1079
+ `keeping ${keep.length}.`);
1080
+ // ── 2. Fetch the wipe preview to show live record counts ──
1081
+ const wipePath = `records/wipe?collections=${encodeURIComponent(names.join(","))}`;
1082
+ let wipePreview = null;
1083
+ let wipeError = null;
1084
+ try {
1085
+ wipePreview = (await callDataModelApi({
1086
+ target,
1087
+ path: wipePath,
1088
+ method: "GET",
1089
+ }));
1090
+ }
1091
+ catch (err) {
1092
+ wipeError = err instanceof Error ? err.message : String(err);
1093
+ }
1094
+ console.log("");
1095
+ logStep(fg.red(fg.bold("WIPE-DATABASE PREVIEW")));
1096
+ // -- data preview --
1097
+ if (wipePreview && wipePreview.collections.length > 0) {
1098
+ for (const c of wipePreview.collections) {
1099
+ console.log(` ${fg.red("✕")} ${fg.bold(c.name)} — ${c.records} record(s) ` +
1100
+ fg.dim(`(${c.activeRecords} active, ${c.softDeletedRecords} soft-deleted)`));
1101
+ }
1102
+ console.log(` ${fg.amber("↳")} ${wipePreview.relationCount} relationship link(s) also removed`);
1103
+ console.log(` ${fg.green("↳")} Backup required at Media / ${wipePreview.backupFolder}`);
1104
+ }
1105
+ else if (wipeError) {
1106
+ console.log(` ${fg.dim("Data wipe preview unavailable:")} ${wipeError}`);
1107
+ }
1108
+ else {
1109
+ console.log(` ${fg.dim("No live records found for the named collections.")}`);
1110
+ }
1111
+ // -- schema preview (skip if no objects remain) --
1112
+ const hasSchemaChanges = keep.length > 0 && keep.length < artifact.objects.length;
1113
+ if (hasSchemaChanges) {
1114
+ const planRes = await callDataModelApi({
1115
+ target,
1116
+ path: "plan",
1117
+ method: "POST",
1118
+ body: { schema: pushArtifact, prune: true },
1119
+ });
1120
+ const plan = planRes["plan"];
1121
+ console.log("");
1122
+ logStep("Schema plan (what promoting this draft would do):");
1123
+ printPlan(plan);
1124
+ }
1125
+ else {
1126
+ console.log("");
1127
+ logStep(keep.length === 0
1128
+ ? "No collections remain in config — schema draft will be left unchanged."
1129
+ : "No schema changes needed.");
1130
+ }
1131
+ if (opts.dryRun) {
1132
+ console.log("");
1133
+ logStep("--dry-run was set. Nothing was wiped or pushed.");
1134
+ return;
1135
+ }
1136
+ // ── 3. Confirmation ──
1137
+ if (!opts.yes) {
1138
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1139
+ throw new Error("wipe-database requires an interactive terminal or --yes.");
1140
+ }
1141
+ const prompt = createInterface({
1142
+ input: process.stdin,
1143
+ output: process.stdout,
1144
+ });
1145
+ try {
1146
+ const answer = await prompt.question(`\nThis will delete records (with backup) and push a pruned schema draft. ${fg.red("This cannot be undone.")}\nType the collection name "` +
1147
+ `${removed[0]?.["nameSingular"] ?? ""}" to confirm: `);
1148
+ if (answer.trim() !== (removed[0]?.["nameSingular"] ?? "")) {
1149
+ logStep("Cancelled.");
1150
+ return;
1151
+ }
1152
+ }
1153
+ finally {
1154
+ prompt.close();
1155
+ }
1156
+ }
1157
+ // ── 4. Execute data wipe ──
1158
+ if (wipePreview && wipePreview.recordCount + wipePreview.relationCount > 0) {
1159
+ const backupPhrase = wipePreview.confirmationPhrase;
1160
+ logStep("Backing up and wiping records…");
1161
+ try {
1162
+ const wipeResult = (await callDataModelApi({
1163
+ target,
1164
+ path: "records/wipe",
1165
+ method: "POST",
1166
+ body: {
1167
+ collections: names,
1168
+ planToken: wipePreview.planToken,
1169
+ confirmation: backupPhrase,
1170
+ },
1171
+ }));
1172
+ console.log(` ${fg.green("✓")} Backup: ${wipeResult.backup.name} (${wipeResult.backup.sizeBytes} bytes)`);
1173
+ console.log(` ${fg.green("✓")} Wiped ${wipeResult.deleted.records} record(s) and ${wipeResult.deleted.relations} relation(s).`);
1174
+ }
1175
+ catch (err) {
1176
+ throw new Error(`Data wipe failed: ${err instanceof Error ? err.message : String(err)}`);
1177
+ }
1178
+ }
1179
+ else {
1180
+ logStep("No records to wipe.");
1181
+ }
1182
+ // ── 5. Push schema draft (skip if no objects remain) ──
1183
+ if (hasSchemaChanges) {
1184
+ const checksum = await computeDataModelChecksum(pushArtifact);
1185
+ logStep("Pushing pruned schema draft…");
1186
+ try {
1187
+ const pushRes = await callDataModelApi({
1188
+ target,
1189
+ path: "draft-schema",
1190
+ method: "POST",
1191
+ body: {
1192
+ schema: pushArtifact,
1193
+ checksum,
1194
+ note: `wipe-database: removed ${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}`,
1195
+ },
1196
+ });
1197
+ if (pushRes["noop"]) {
1198
+ logStep("Schema already matches — no draft update needed.");
1199
+ }
1200
+ else {
1201
+ logStep(`${fg.green("✓")} Uploaded draft v${String(pushRes["version"])}.`);
1202
+ }
1203
+ }
1204
+ catch (err) {
1205
+ throw new Error(`Schema push failed: ${err instanceof Error ? err.message : String(err)}`);
1206
+ }
1207
+ }
1208
+ else if (keep.length === 0) {
1209
+ logStep(fg.dim("Schema draft unchanged — all collections were removed from the config. Remove or edit biab.data-model.config.ts to rebuild."));
1210
+ }
1211
+ console.log("");
1212
+ logStep(`${fg.green("✓")} Data wiped for ${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}.`);
1213
+ }
1214
+ // ── sync-content ─────────────────────────────────────────────────────
1215
+ /**
1216
+ * Convert a basename like `value-props` or `value_props` to camelCase
1217
+ * (`valueProps`). Used as the default mapping when the consumer hasn't
1218
+ * supplied a `sectionAliases` entry. Pure identity for already-camelCased
1219
+ * names like `hero`. Matches the convention enforced by the package-API
1220
+ * sectionKey regex.
1221
+ */
1222
+ function toCamelCase(input) {
1223
+ return input.replace(/[-_]+([a-zA-Z0-9])/g, (_m, c) => c.toUpperCase());
1224
+ }
1225
+ /**
1226
+ * Resolve the list of `(pageKey, dir-on-disk)` entries to walk for a
1227
+ * given locale. If `pages` is supplied we substitute `{locale}` into
1228
+ * each entry's `dir`. Otherwise we fall back to auto-discovery:
1229
+ * iterate `<rootDir>/<locale>/*` and use the leaf name as pageKey.
1230
+ */
1231
+ function resolvePageDirs(input) {
1232
+ const { rootDir, locale, pages } = input;
1233
+ if (pages && pages.length > 0) {
1234
+ return pages.map((p) => ({
1235
+ pageKey: p.pageKey,
1236
+ absDir: join(rootDir, p.dir.replaceAll("{locale}", locale)),
1237
+ }));
1238
+ }
1239
+ const localeDir = join(rootDir, locale);
1240
+ if (!existsSync(localeDir) || !statSync(localeDir).isDirectory()) {
1241
+ return [];
1242
+ }
1243
+ const out = [];
1244
+ for (const dirent of readdirSync(localeDir)) {
1245
+ const abs = join(localeDir, dirent);
1246
+ if (statSync(abs).isDirectory()) {
1247
+ out.push({ pageKey: dirent, absDir: abs });
1248
+ }
1249
+ }
1250
+ return out;
1251
+ }
1252
+ function discoverContentFiles(input) {
1253
+ const { rootDir, cfg, knownSectionKeys } = input;
1254
+ const items = [];
1255
+ const warnings = [];
1256
+ const locales = cfg.locales && cfg.locales.length > 0 ? cfg.locales : ["en"];
1257
+ const sectionAliases = cfg.sectionAliases ?? {};
1258
+ const skip = new Set(cfg.skipSections ?? []);
1259
+ for (const locale of locales) {
1260
+ const pageDirs = resolvePageDirs({ rootDir, locale, pages: cfg.pages });
1261
+ if (pageDirs.length === 0) {
1262
+ warnings.push({
1263
+ kind: "skipped",
1264
+ message: `No page directories resolved for locale "${locale}". Check contentSync.pages / rootDir.`,
1265
+ });
1266
+ continue;
1267
+ }
1268
+ for (const { pageKey, absDir } of pageDirs) {
1269
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) {
1270
+ warnings.push({
1271
+ kind: "skipped",
1272
+ message: `Page dir not found: ${absDir} (pageKey="${pageKey}")`,
1273
+ });
1274
+ continue;
1275
+ }
1276
+ for (const fileDirent of readdirSync(absDir)) {
1277
+ if (!fileDirent.endsWith(".json"))
1278
+ continue;
1279
+ const basename = fileDirent.slice(0, -".json".length);
1280
+ // Scoped alias wins over global alias wins over camelCase
1281
+ // fallback. Lets `about/page` map to `aboutPage` while
1282
+ // `services/page` maps to `servicesPage` from the same
1283
+ // file basename.
1284
+ const aliased = sectionAliases[`${pageKey}/${basename}`] ?? sectionAliases[basename];
1285
+ const sectionKey = aliased ?? toCamelCase(basename);
1286
+ if (skip.has(sectionKey)) {
1287
+ warnings.push({
1288
+ kind: "skipped",
1289
+ message: `Skipping ${pageKey}/${basename}.json (sectionKey "${sectionKey}" is in skipSections).`,
1290
+ });
1291
+ continue;
1292
+ }
1293
+ if (!knownSectionKeys.has(sectionKey)) {
1294
+ warnings.push({
1295
+ kind: "unknown",
1296
+ message: `Skipping ${pageKey}/${basename}.json — no matching schema section "${sectionKey}". Add to contentSync.sectionAliases?`,
1297
+ });
1298
+ continue;
1299
+ }
1300
+ const filePath = join(absDir, fileDirent);
1301
+ const raw = readFileSync(filePath, "utf8");
1302
+ let payload;
1303
+ try {
1304
+ payload = JSON.parse(raw);
1305
+ }
1306
+ catch (err) {
1307
+ warnings.push({
1308
+ kind: "unknown",
1309
+ message: `Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
1310
+ });
1311
+ continue;
1312
+ }
1313
+ items.push({
1314
+ pageKey,
1315
+ locale,
1316
+ sectionKey,
1317
+ filename: filePath,
1318
+ payload,
1319
+ });
1320
+ }
1321
+ }
1322
+ }
1323
+ return { items, warnings };
1324
+ }
1325
+ async function postSectionBatch(input) {
1326
+ const target = `${input.baseUrl}/sites/${encodeURIComponent(input.siteId)}/marketing/sections`;
1327
+ const res = await fetch(target, {
1328
+ method: "POST",
1329
+ headers: {
1330
+ "content-type": "application/json",
1331
+ authorization: `Bearer ${input.apiKey}`,
1332
+ },
1333
+ body: JSON.stringify({
1334
+ items: input.items,
1335
+ validate: input.validate,
1336
+ note: input.note,
1337
+ }),
1338
+ });
1339
+ const bodyText = await res.text();
1340
+ if (!res.ok) {
1341
+ let message = `HTTP ${res.status} ${res.statusText}`.trim();
1342
+ try {
1343
+ const parsed = JSON.parse(bodyText);
1344
+ if (parsed.message)
1345
+ message = parsed.message;
1346
+ }
1347
+ catch {
1348
+ const looksHtml = /^\s*<(?:!doctype|html|head|body)/i.test(bodyText);
1349
+ if (looksHtml) {
1350
+ message = `${message} (server returned an HTML page, not JSON — the bulk-upsert route may not exist on this host yet. Make sure BIAB is deployed with the marketing/sections endpoint, and that BIAB_PACKAGE_API_BASE_URL points at the right host.)`;
1351
+ }
1352
+ else if (bodyText.trim()) {
1353
+ message = `${message} — ${bodyText.slice(0, 240).trim()}`;
1354
+ }
1355
+ }
1356
+ return { ok: false, status: res.status, message };
1357
+ }
1358
+ // Successful 2xx response. Defend against an HTML body here too —
1359
+ // some edge proxies (Vercel preview deployments, CDNs) can wrap
1360
+ // the JSON in an HTML shell if the route lookup actually missed.
1361
+ try {
1362
+ const parsed = JSON.parse(bodyText);
1363
+ return { ok: true, results: parsed.results ?? [] };
1364
+ }
1365
+ catch {
1366
+ return {
1367
+ ok: false,
1368
+ status: res.status,
1369
+ message: `Server replied 200 OK but the body wasn't JSON — first 240 chars: ${bodyText.slice(0, 240).trim()}`,
1370
+ };
1371
+ }
1372
+ }
1373
+ async function cmdSyncContent(opts) {
1374
+ const schema = await loadConfig(opts.configPath);
1375
+ const artifact = schema.toJSONSchema();
1376
+ const cfg = schema.contentSync;
1377
+ if (!cfg) {
1378
+ throw new Error(`${opts.configPath} doesn't declare a \`contentSync\` config. Add one to your defineSiteMarketingSchema({...}) call, for example: contentSync: { rootDir: "src/content", sectionAliases: { ... } }`);
1379
+ }
1380
+ const configDir = dirname(opts.configPath);
1381
+ const rootDir = isAbsolute(cfg.rootDir)
1382
+ ? cfg.rootDir
1383
+ : resolve(configDir, cfg.rootDir);
1384
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
1385
+ throw new Error(`contentSync.rootDir resolves to "${rootDir}", which does not exist or is not a directory.`);
1386
+ }
1387
+ const knownSectionKeys = new Set(Object.keys(artifact.sections));
1388
+ const { items, warnings } = discoverContentFiles({
1389
+ rootDir,
1390
+ cfg,
1391
+ knownSectionKeys,
1392
+ });
1393
+ logStep(`Discovered ${fg.bold(String(items.length))} section file(s) under ${rootDir}.`);
1394
+ for (const w of warnings) {
1395
+ console.log(` ${fg.amber("⚠")} ${fg.dim(w.message)}`);
1396
+ }
1397
+ // Reject content syncs that target a BIAB-reserved pageKey. We dedupe
1398
+ // so we report each offending key once rather than per-file.
1399
+ const reservedKeys = Array.from(new Set(items
1400
+ .filter((it) => isReservedPagePath(it.pageKey))
1401
+ .map((it) => it.pageKey)));
1402
+ if (reservedKeys.length > 0) {
1403
+ for (const key of reservedKeys) {
1404
+ logError(reservedPagePathErrorMessage(key));
1405
+ }
1406
+ throw new Error(`Refusing to sync content: ${reservedKeys.length} reserved pageKey(s) found in discovered files.`);
1407
+ }
1408
+ if (items.length === 0) {
1409
+ logStep("Nothing to sync.");
1410
+ return;
1411
+ }
1412
+ if (opts.dryRun) {
1413
+ logStep("--dry-run was set, skipping upload.");
1414
+ for (const it of items) {
1415
+ console.log(` ${fg.dim("→")} ${it.locale}/${it.pageKey}/${it.sectionKey} ${fg.dim(`(from ${it.filename})`)}`);
1416
+ }
1417
+ return;
1418
+ }
1419
+ const apiKey = opts.env["BIAB_API_KEY"];
1420
+ const siteId = opts.env["BIAB_SITE_ID"];
1421
+ const rawBase = opts.env["BIAB_PACKAGE_API_BASE_URL"] ??
1422
+ opts.env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
1423
+ if (!apiKey)
1424
+ throw new Error("Missing BIAB_API_KEY in env or .env.local.");
1425
+ if (!siteId)
1426
+ throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
1427
+ if (!rawBase) {
1428
+ throw new Error("Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).");
1429
+ }
1430
+ const baseUrl = normalizeBaseUrl(rawBase);
1431
+ const BATCH_SIZE = 64; // well under the server's 256 cap; smaller = faster failure feedback
1432
+ const batches = [];
1433
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
1434
+ batches.push(items.slice(i, i + BATCH_SIZE));
1435
+ }
1436
+ let okCount = 0;
1437
+ let failCount = 0;
1438
+ const failures = [];
1439
+ logStep(`POST ${fg.dim(`${baseUrl}/sites/${siteId}/marketing/sections`)} in ${batches.length} batch(es)…`);
1440
+ for (let i = 0; i < batches.length; i++) {
1441
+ const batch = batches[i];
1442
+ const res = await postSectionBatch({
1443
+ baseUrl,
1444
+ apiKey,
1445
+ siteId,
1446
+ items: batch.map((b) => ({
1447
+ pageKey: b.pageKey,
1448
+ locale: b.locale,
1449
+ sectionKey: b.sectionKey,
1450
+ payload: b.payload,
1451
+ })),
1452
+ validate: !opts.lax,
1453
+ note: opts.note,
1454
+ });
1455
+ if (!res.ok) {
1456
+ logError(`Batch ${i + 1}/${batches.length} failed (${res.status}): ${res.message}`);
1457
+ process.exitCode = 1;
1458
+ return;
1459
+ }
1460
+ for (const r of res.results) {
1461
+ if (r.ok) {
1462
+ okCount += 1;
1463
+ }
1464
+ else {
1465
+ failCount += 1;
1466
+ failures.push(r);
1467
+ }
1468
+ }
1469
+ logStep(`Batch ${i + 1}/${batches.length}: ${fg.green(`✓ ${res.results.filter((r) => r.ok).length}`)} ${res.results.some((r) => !r.ok)
1470
+ ? fg.red(`✕ ${res.results.filter((r) => !r.ok).length}`)
1471
+ : ""}`.trimEnd());
1472
+ }
1473
+ console.log("");
1474
+ if (failCount === 0) {
1475
+ logStep(`${fg.green(`✓ Synced ${okCount} section(s).`)} ${fg.dim("Check the dashboard at Site Builder → Site Data → Pages.")}`);
1476
+ }
1477
+ else {
1478
+ logStep(`${fg.green(`✓ ${okCount} succeeded`)} ${fg.red(`✕ ${failCount} failed`)}`);
1479
+ for (const f of failures) {
1480
+ if (f.ok)
1481
+ continue;
1482
+ console.log(` ${fg.red("✕")} ${f.locale}/${f.pageKey}/${f.sectionKey} — ${fg.dim(f.error)}`);
1483
+ }
1484
+ console.log("");
1485
+ console.log(fg.dim(` Tip: pass --lax to skip validation for this run (writes raw JSON without enforcing the published schema).`));
1486
+ process.exitCode = 1;
1487
+ }
1488
+ }
1489
+ function printHelp() {
1490
+ console.log(`biab-dev — Business In A Box developer CLI
1491
+
1492
+ Run via your package runner — biab-dev is a package bin, not a global:
1493
+ npx biab-dev <command> (or: pnpm exec biab-dev <command>)
1494
+
1495
+ WEBSITE CONTENT (biab.config.ts)
1496
+ npx biab-dev sync-schema [--config path] [--dry-run]
1497
+ npx biab-dev sync-content [--config path] [--dry-run] [--lax] [--note "first import"]
1498
+ npx biab-dev print-schema [--config path]
1499
+
1500
+ CUSTOM DATABASE (biab.data-model.config.ts)
1501
+ npx biab-dev view-data-model [--json] [--diff] [--out path] [--prune]
1502
+ npx biab-dev print-data-model [--config path]
1503
+ npx biab-dev export-data-model [--out path] [--schema-only]
1504
+ npx biab-dev export-graphql-schema [--out path] [--include-standard]
1505
+ npx biab-dev sync-data-model [--config path] [--dry-run] [--prune] [--forms [slugs]] [--no-forms] [--note "..."]
1506
+ npx biab-dev sync-records [--records-dir path] [--dry-run]
1507
+ npx biab-dev wipe-collections --collections name-a,name-b [--dry-run]
1508
+ npx biab-dev wipe-database --collections name-a,name-b [--config path] [--dry-run] [--yes]
1509
+ npx biab-dev wipe-database --all [--config path] [--dry-run] [--yes]
1510
+
1511
+ view what's LIVE in BIAB right now (the dashboard can change it too, so
1512
+ this is not just "what I last pushed")
1513
+ print what your local config resolves to — no network
1514
+ export pull the WHOLE custom database — schema + records + relations — into
1515
+ one portable snapshot (default: ./biab.data-model.snapshot.json).
1516
+ Deterministic order, so committing it gives a git-diffable history an
1517
+ AI agent can read without querying BIAB. Re-seeds a fresh org via
1518
+ sync-data-model (schema) + sync-records (rows). --schema-only skips
1519
+ the row scan. Needs metadata:read_schema + metadata:read_records.
1520
+ export-graphql-schema
1521
+ render a GraphQL SDL schema of your custom collections (default:
1522
+ ./biab.data-model.graphql) — every object a type, relations as lists,
1523
+ SELECTs as enums where valid. Feed it to GraphQL codegen or commit it
1524
+ as a typed contract. Schema only; this does not run a GraphQL server.
1525
+ --include-standard also emits standard CRM types. Needs
1526
+ metadata:read_schema.
1527
+ sync push your config to the draft slot. Prints the migration plan first;
1528
+ nothing changes until you promote in the dashboard.
1529
+
1530
+ seed upsert rows from JSON (default dir: ./biab-records). Each file is an
1531
+ array of { object, universalIdentifier, values, relations? }; identity
1532
+ is what makes re-running converge instead of duplicating. relations
1533
+ links rows by the TARGET row's universalIdentifier, e.g.
1534
+ { "tech": "<uid>" } or { "tags": ["<uid>", "<uid>"] } — synced after
1535
+ all records exist, so files can reference each other in any order.
1536
+
1537
+ wipe hard-delete records (not schemas) from only the explicitly named
1538
+ custom collections. The CLI previews live counts, requires two
1539
+ interactive confirmations, writes + verifies a JSON backup under
1540
+ Media / Database Backups / Collection Wipes, then deletes. There is
1541
+ intentionally no --all or --yes bypass.
1542
+
1543
+ wipe-database
1544
+ developer shortcut: wipes records AND removes the named collections
1545
+ (or all collections with --all) from your schema draft. Combines
1546
+ data deletion (with backup) with a pruned schema push; requires
1547
+ dashboard promotion to finalise the schema change. Uses a single
1548
+ confirmation with --yes to skip the interactive prompt.
1549
+
1550
+ --diff show the migration plan for your local config without pushing
1551
+ --out path write a config file from the live model (adopt an existing
1552
+ dashboard-built model as code)
1553
+ --prune reconcile fully: remove anything live but absent from the
1554
+ config. Everything it removes is a destructive change.
1555
+ --forms include all generated form drafts (the non-interactive default).
1556
+ --forms a,b include only the generated forms with those slugs/source keys.
1557
+ --no-forms push only collections. Generated forms are created inactive on
1558
+ promotion and must be activated separately in the dashboard.
1559
+
1560
+ npx biab-dev help
1561
+
1562
+ Environment (read from process.env, .env, or .env.local in the cwd):
1563
+ BIAB_API_KEY API key. Website flow needs marketing:write_schema
1564
+ / marketing:write_content. Data-model flow needs
1565
+ metadata:read_schema (view/plan) and
1566
+ metadata:write_schema (push) or
1567
+ metadata:write_records (seed/wipe).
1568
+ BIAB_SITE_ID UUID of the site to publish to
1569
+ BIAB_PACKAGE_API_BASE_URL Host URL (any path; the CLI canonicalises
1570
+ to "<origin>/api/package/v1" automatically).
1571
+ Use the APEX host — e.g. https://biab.app, NOT
1572
+ https://www.biab.app. A www→apex redirect makes
1573
+ fetch drop the Authorization header (it's a
1574
+ cross-origin hop), and the server then reports
1575
+ "Missing bearer API key" as if your key were
1576
+ absent.
1577
+
1578
+ Schema pushes cannot change what an org's database or website serves: they
1579
+ write a draft and a human promotes it in the dashboard. wipe-collections is
1580
+ the explicit record-data exception, guarded by preview, verified backup, and
1581
+ two interactive confirmations. wipe-database combines a data wipe with a
1582
+ pruned schema push for developer convenience.
1583
+
1584
+ Config files (override either with --config path):
1585
+ biab.config.ts → defineSiteMarketingSchema (website content)
1586
+ biab.data-model.config.ts → createFormSchemaOrchestrator / createSchema
1587
+
1588
+ sync-content also reads the optional \`contentSync\` field on your
1589
+ defineSiteMarketingSchema({...}) call. Minimal form (default layout
1590
+ \`<rootDir>/<locale>/<page>/<section>.json\`):
1591
+
1592
+ contentSync: {
1593
+ rootDir: "src/content",
1594
+ sectionAliases: { marquee: "trustMarquee" },
1595
+ locales: ["en"],
1596
+ skipSections: ["gallery", "blogFeed"], // managed by other surfaces
1597
+ }
1598
+
1599
+ Explicit form (when pages aren't direct children of the locale dir, or
1600
+ when shared/cross-page sections live in a sibling dir):
1601
+
1602
+ contentSync: {
1603
+ rootDir: "src/content",
1604
+ locales: ["en"],
1605
+ pages: [
1606
+ { dir: "{locale}/shared", pageKey: "shared" },
1607
+ { dir: "{locale}/pages/home", pageKey: "home" },
1608
+ { dir: "{locale}/pages/about", pageKey: "about" },
1609
+ ],
1610
+ sectionAliases: {
1611
+ "home/marquee": "trustMarquee", // scoped: only inside home/
1612
+ "about/page": "aboutPage", // scoped
1613
+ "service-areas": "serviceAreas", // global
1614
+ },
1615
+ }
1616
+ `);
1617
+ }
1618
+ function parseArgs(argv) {
1619
+ const args = argv.slice(2);
1620
+ const out = {
1621
+ command: "help",
1622
+ dryRun: false,
1623
+ lax: false,
1624
+ note: null,
1625
+ json: false,
1626
+ diff: false,
1627
+ out: null,
1628
+ prune: false,
1629
+ recordsDir: "biab-records",
1630
+ forms: "prompt",
1631
+ collections: [],
1632
+ yes: false,
1633
+ all: false,
1634
+ schemaOnly: false,
1635
+ includeStandard: false,
1636
+ };
1637
+ for (let i = 0; i < args.length; i++) {
1638
+ const arg = args[i];
1639
+ if (!arg)
1640
+ continue;
1641
+ if (i === 0 && COMMANDS.includes(arg)) {
1642
+ out.command = arg;
1643
+ continue;
1644
+ }
1645
+ if (arg === "--config" && i + 1 < args.length) {
1646
+ const next = args[++i];
1647
+ if (next)
1648
+ out.configOverride = next;
1649
+ continue;
1650
+ }
1651
+ if (arg === "--dry-run") {
1652
+ out.dryRun = true;
1653
+ continue;
1654
+ }
1655
+ if (arg === "--lax") {
1656
+ out.lax = true;
1657
+ continue;
1658
+ }
1659
+ if (arg === "--json") {
1660
+ out.json = true;
1661
+ continue;
1662
+ }
1663
+ if (arg === "--diff") {
1664
+ out.diff = true;
1665
+ continue;
1666
+ }
1667
+ if (arg === "--prune") {
1668
+ out.prune = true;
1669
+ continue;
1670
+ }
1671
+ if (arg === "--schema-only") {
1672
+ out.schemaOnly = true;
1673
+ continue;
1674
+ }
1675
+ if (arg === "--include-standard") {
1676
+ out.includeStandard = true;
1677
+ continue;
1678
+ }
1679
+ if (arg === "--no-forms") {
1680
+ out.forms = "none";
1681
+ continue;
1682
+ }
1683
+ if (arg === "--forms") {
1684
+ const next = args[i + 1];
1685
+ if (next && !next.startsWith("--")) {
1686
+ i++;
1687
+ out.forms = `list:${next}`;
1688
+ }
1689
+ else {
1690
+ out.forms = "all";
1691
+ }
1692
+ continue;
1693
+ }
1694
+ if (arg === "--records-dir" && i + 1 < args.length) {
1695
+ const next = args[++i];
1696
+ if (next)
1697
+ out.recordsDir = next;
1698
+ continue;
1699
+ }
1700
+ if (arg === "--collections" && i + 1 < args.length) {
1701
+ const next = args[++i];
1702
+ if (next) {
1703
+ out.collections.push(...next.split(",").map((value) => value.trim()));
1704
+ }
1705
+ continue;
1706
+ }
1707
+ if (arg === "--out" && i + 1 < args.length) {
1708
+ const next = args[++i];
1709
+ if (next)
1710
+ out.out = next;
1711
+ continue;
1712
+ }
1713
+ if (arg === "--note" && i + 1 < args.length) {
1714
+ const next = args[++i];
1715
+ if (next)
1716
+ out.note = next;
1717
+ continue;
1718
+ }
1719
+ if (arg === "--yes") {
1720
+ out.yes = true;
1721
+ continue;
1722
+ }
1723
+ if (arg === "--all") {
1724
+ out.all = true;
1725
+ continue;
1726
+ }
1727
+ if (arg === "--help" || arg === "-h") {
1728
+ out.command = "help";
1729
+ break;
1730
+ }
1731
+ }
1732
+ return out;
1733
+ }
1734
+ async function main() {
1735
+ const cwd = process.cwd();
1736
+ const { command, configOverride, dryRun, lax, note, json, diff, out, prune, recordsDir, forms, collections, yes, all, schemaOnly, includeStandard, } = parseArgs(process.argv);
1737
+ if (command === "help") {
1738
+ printHelp();
1739
+ return;
1740
+ }
1741
+ const env = loadEnv(cwd);
1742
+ // Data-model commands read their own config file (D9) — and `view` needs
1743
+ // no local config at all unless --diff/--out is in play, so resolve lazily.
1744
+ if (command === "view-data-model") {
1745
+ await cmdViewDataModel({
1746
+ env,
1747
+ json,
1748
+ diff,
1749
+ out,
1750
+ configOverride,
1751
+ cwd,
1752
+ prune,
1753
+ });
1754
+ return;
1755
+ }
1756
+ if (command === "print-data-model") {
1757
+ await cmdPrintDataModel({
1758
+ configPath: findDataModelConfigPath(cwd, configOverride),
1759
+ });
1760
+ return;
1761
+ }
1762
+ if (command === "export-data-model") {
1763
+ await cmdExportDataModel({ env, out, schemaOnly, cwd });
1764
+ return;
1765
+ }
1766
+ if (command === "export-graphql-schema") {
1767
+ await cmdExportGraphqlSchema({ env, out, includeStandard, cwd });
1768
+ return;
1769
+ }
1770
+ if (command === "sync-records") {
1771
+ await cmdSyncRecords({ env, cwd, rootDir: recordsDir, dryRun });
1772
+ return;
1773
+ }
1774
+ if (command === "wipe-collections") {
1775
+ await cmdWipeCollections({ env, collections, dryRun });
1776
+ return;
1777
+ }
1778
+ if (command === "wipe-database") {
1779
+ await cmdWipeDatabase({
1780
+ configPath: findDataModelConfigPath(cwd, configOverride),
1781
+ env,
1782
+ collections,
1783
+ dryRun,
1784
+ yes,
1785
+ all,
1786
+ });
1787
+ return;
1788
+ }
1789
+ if (command === "sync-data-model") {
1790
+ await cmdSyncDataModel({
1791
+ configPath: findDataModelConfigPath(cwd, configOverride),
1792
+ env,
1793
+ dryRun,
1794
+ prune,
1795
+ note,
1796
+ forms,
1797
+ });
1798
+ return;
1799
+ }
1800
+ const configPath = findConfigPath(cwd, configOverride);
1801
+ if (command === "print-schema") {
1802
+ await cmdPrintSchema({ configPath });
1803
+ return;
1804
+ }
1805
+ if (command === "sync-schema") {
1806
+ await cmdSyncSchema({ configPath, env, dryRun });
1807
+ return;
1808
+ }
1809
+ if (command === "sync-content") {
1810
+ await cmdSyncContent({ configPath, env, dryRun, lax, note });
1811
+ return;
1812
+ }
1813
+ printHelp();
1814
+ }
1815
+ main().catch((err) => {
1816
+ logError(err instanceof Error ? err.message : String(err));
1817
+ process.exitCode = 1;
1818
+ });
1819
+ // Silence "createRequire is unused" since some bundlers strip the static
1820
+ // import above; keep it as a runtime hook in case downstream needs to
1821
+ // resolve relative paths the same way the loader would.
1822
+ void createRequire(import.meta.url);
1823
+ void dirname;
1824
+ //# sourceMappingURL=cli.js.map