@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/src/cli.ts ADDED
@@ -0,0 +1,2517 @@
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
+
46
+ import {
47
+ existsSync,
48
+ readdirSync,
49
+ readFileSync,
50
+ statSync,
51
+ writeFileSync,
52
+ } from "node:fs";
53
+ import { createRequire } from "node:module";
54
+ import { dirname, isAbsolute, join, resolve } from "node:path";
55
+ import { createInterface } from "node:readline/promises";
56
+ import { pathToFileURL } from "node:url";
57
+ import {
58
+ computeDataModelChecksum,
59
+ type GeneratedFormCandidate,
60
+ } from "./data-model-schema.js";
61
+ import { dataModelToGraphqlSdl, type GraphqlSdlModel } from "./graphql.js";
62
+ import type {
63
+ SiteMarketingClientShape,
64
+ SiteMarketingContentSyncConfig,
65
+ } from "./marketing-schema.js";
66
+ import {
67
+ isReservedPagePath,
68
+ reservedPagePathErrorMessage,
69
+ } from "./reserved-paths.js";
70
+
71
+ const COMMANDS = [
72
+ "sync-schema",
73
+ "sync-content",
74
+ "print-schema",
75
+ // Data model (custom database). Separate config, separate endpoint,
76
+ // separate scopes — see data-model-schema.ts.
77
+ "view-data-model",
78
+ "print-data-model",
79
+ "export-data-model",
80
+ "export-graphql-schema",
81
+ "sync-data-model",
82
+ "sync-records",
83
+ "wipe-collections",
84
+ "wipe-database",
85
+ "help",
86
+ ] as const;
87
+ type Command = (typeof COMMANDS)[number];
88
+
89
+ /** Config candidates per flow. The marketing and data-model configs are
90
+ * deliberately different files: different artifacts, different blast radius.
91
+ * `businessdash.*` names are preferred going forward; the `biab.*` names
92
+ * remain a permanent filesystem contract with every existing consumer. */
93
+ const MARKETING_CONFIG_CANDIDATES = [
94
+ "businessdash.config.ts",
95
+ "businessdash.config.mjs",
96
+ "businessdash.config.js",
97
+ "biab.config.ts",
98
+ "biab.config.mjs",
99
+ "biab.config.js",
100
+ ];
101
+ const DATA_MODEL_CONFIG_CANDIDATES = [
102
+ "businessdash.data-model.config.ts",
103
+ "businessdash.data-model.config.mjs",
104
+ "businessdash.data-model.config.js",
105
+ "biab.data-model.config.ts",
106
+ "biab.data-model.config.mjs",
107
+ "biab.data-model.config.js",
108
+ ];
109
+
110
+ // V7-styled output: matches the dashboard's status-pill colors so the
111
+ // CLI feels like the same product (✓ = "done" green-accent, ⚠ =
112
+ // "partial" orange-accent, ✕ = "missing" red-accent). We only emit
113
+ // ANSI when stdout is a TTY so CI logs stay plain.
114
+ const USE_COLOR = process.stdout.isTTY && !process.env["NO_COLOR"];
115
+ const fg = {
116
+ green: (s: string) => (USE_COLOR ? `\x1b[32m${s}\x1b[0m` : s),
117
+ red: (s: string) => (USE_COLOR ? `\x1b[31m${s}\x1b[0m` : s),
118
+ amber: (s: string) => (USE_COLOR ? `\x1b[33m${s}\x1b[0m` : s),
119
+ dim: (s: string) => (USE_COLOR ? `\x1b[2m${s}\x1b[0m` : s),
120
+ bold: (s: string) => (USE_COLOR ? `\x1b[1m${s}\x1b[0m` : s),
121
+ };
122
+
123
+ function logStep(msg: string): void {
124
+ console.log(`[biab-dev] ${msg}`);
125
+ }
126
+
127
+ /**
128
+ * A single progress line for long, batched work.
129
+ *
130
+ * On a TTY it rewrites one line in place (`\r` + erase), so a run with many
131
+ * network round-trips shows live movement instead of a silent wait. Without a
132
+ * TTY (CI) it prints a plain line per tick — no carriage returns to mangle the
133
+ * log, just a bounded trail of progress.
134
+ *
135
+ * `tick(0)` up front makes the line appear before the first round-trip returns,
136
+ * so the user sees it engage rather than staring at nothing.
137
+ */
138
+ function makeProgress(total: number): {
139
+ tick: (done: number, note?: string) => void;
140
+ clear: () => void;
141
+ } {
142
+ const tty = Boolean(process.stdout.isTTY);
143
+ return {
144
+ tick(done, note = "") {
145
+ const pct = total === 0 ? 100 : Math.floor((done / total) * 100);
146
+ // No nested color: the whole line is dimmed, so the note stays plain
147
+ // text (a reset inside would cancel the dim for everything after it).
148
+ const line = ` ↻ ${done}/${total} (${pct}%)${note ? ` · ${note}` : ""}`;
149
+ if (tty) process.stdout.write(`\r\x1b[2K${fg.dim(line)}`);
150
+ else console.log(line);
151
+ },
152
+ clear() {
153
+ if (tty) process.stdout.write("\r\x1b[2K");
154
+ },
155
+ };
156
+ }
157
+
158
+ function logError(msg: string): void {
159
+ console.error(`[biab-dev] ${msg}`);
160
+ }
161
+
162
+ function parseEnvFile(path: string): Record<string, string> {
163
+ if (!existsSync(path)) return {};
164
+ const out: Record<string, string> = {};
165
+ for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
166
+ const line = raw.trim();
167
+ if (!line || line.startsWith("#")) continue;
168
+ const eq = line.indexOf("=");
169
+ if (eq === -1) continue;
170
+ const key = line.slice(0, eq).trim();
171
+ let value = line.slice(eq + 1).trim();
172
+ if (
173
+ (value.startsWith('"') && value.endsWith('"')) ||
174
+ (value.startsWith("'") && value.endsWith("'"))
175
+ ) {
176
+ value = value.slice(1, -1);
177
+ }
178
+ if (key && value !== undefined) out[key] = value;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function loadEnv(cwd: string): Record<string, string> {
184
+ const merged: Record<string, string> = {
185
+ ...parseEnvFile(resolve(cwd, ".env")),
186
+ ...parseEnvFile(resolve(cwd, ".env.local")),
187
+ ...(process.env as Record<string, string>),
188
+ };
189
+ return merged;
190
+ }
191
+
192
+ function findConfigPath(
193
+ cwd: string,
194
+ override?: string,
195
+ candidates: string[] = MARKETING_CONFIG_CANDIDATES,
196
+ hint = "Create one with `defineSiteMarketingSchema({...})` exported as default.",
197
+ ): string {
198
+ if (override) {
199
+ const p = isAbsolute(override) ? override : resolve(cwd, override);
200
+ if (!existsSync(p)) throw new Error(`Config not found at ${p}`);
201
+ return p;
202
+ }
203
+ for (const candidate of candidates) {
204
+ const p = resolve(cwd, candidate);
205
+ if (existsSync(p)) return p;
206
+ }
207
+ const names = candidates.join(", ");
208
+ throw new Error(`None of [${names}] found in ${cwd}. ${hint}`);
209
+ }
210
+
211
+ function findDataModelConfigPath(cwd: string, override?: string): string {
212
+ return findConfigPath(
213
+ cwd,
214
+ override,
215
+ DATA_MODEL_CONFIG_CANDIDATES,
216
+ "Create one with `defineDataModel({...})` exported as default.",
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Load the consumer's `biab.config.*`. Supports .ts (via the
222
+ * consumer's tsx / ts-node setup — we only invoke if `--require tsx`
223
+ * or `tsx`/`ts-node` registered the loader), .mjs (native ESM), and
224
+ * .js (CJS or ESM depending on package.json).
225
+ */
226
+ async function loadConfig(
227
+ configPath: string,
228
+ ): Promise<SiteMarketingClientShape<Record<string, never>>> {
229
+ const url = pathToFileURL(configPath).href;
230
+ let mod: { default?: unknown; marketing?: unknown };
231
+ try {
232
+ mod = (await import(url)) as typeof mod;
233
+ } catch (err) {
234
+ if (configPath.endsWith(".ts")) {
235
+ throw new Error(
236
+ `Could not load TypeScript config at ${configPath}. Run the CLI under tsx or ts-node, e.g.: ` +
237
+ `\`pnpm exec tsx node_modules/@businessdash/sdk/dist/cli.js sync-schema\`.\nUnderlying: ${
238
+ err instanceof Error ? err.message : String(err)
239
+ }`,
240
+ );
241
+ }
242
+ throw err;
243
+ }
244
+ const fromDefault = mod.default;
245
+ const fromNamed = mod.marketing;
246
+ const chosen = fromDefault ?? fromNamed;
247
+ if (
248
+ !chosen ||
249
+ typeof (chosen as { toJSONSchema?: unknown }).toJSONSchema !== "function"
250
+ ) {
251
+ throw new Error(
252
+ `${configPath} did not export a marketing schema. Default-export the result of \`defineSiteMarketingSchema({...})\` (or named-export it as \`marketing\`).`,
253
+ );
254
+ }
255
+ return chosen as SiteMarketingClientShape<Record<string, never>>;
256
+ }
257
+
258
+ function normalizeBaseUrl(input: string): string {
259
+ let next = input.trim();
260
+ if (!/^https?:\/\//i.test(next)) {
261
+ throw new Error(
262
+ `BIAB_PACKAGE_API_BASE_URL must start with http(s):// — got "${input}".`,
263
+ );
264
+ }
265
+ if (!next.endsWith("/api/package/v1") && !next.endsWith("/api/package/v1/")) {
266
+ // Accept either the bare host or the full prefix; canonicalize to
267
+ // "<origin>/api/package/v1".
268
+ next = next.replace(/\/+$/, "");
269
+ next = `${next}/api/package/v1`;
270
+ }
271
+ return next.replace(/\/+$/, "");
272
+ }
273
+
274
+ async function cmdPrintSchema(opts: { configPath: string }) {
275
+ const schema = await loadConfig(opts.configPath);
276
+ const artifact = schema.toJSONSchema();
277
+ const checksum = await schema.checksum();
278
+ console.log(JSON.stringify({ checksum, artifact }, null, 2));
279
+ }
280
+
281
+ async function cmdSyncSchema(opts: {
282
+ configPath: string;
283
+ env: Record<string, string>;
284
+ dryRun: boolean;
285
+ }) {
286
+ const schema = await loadConfig(opts.configPath);
287
+ const artifact = schema.toJSONSchema();
288
+ const checksum = await schema.checksum();
289
+
290
+ // Reject any `contentSync.pages[].pageKey` that collides with a BIAB
291
+ // platform-reserved route. Catches the misconfiguration before any
292
+ // upload happens.
293
+ const declaredPages = schema.contentSync?.pages ?? [];
294
+ const reservedDeclared = declaredPages.filter((p) =>
295
+ isReservedPagePath(p.pageKey),
296
+ );
297
+ if (reservedDeclared.length > 0) {
298
+ for (const p of reservedDeclared) {
299
+ logError(reservedPagePathErrorMessage(p.pageKey));
300
+ }
301
+ throw new Error(
302
+ `Refusing to sync schema: ${reservedDeclared.length} reserved pageKey(s) declared in contentSync.pages.`,
303
+ );
304
+ }
305
+
306
+ logStep(
307
+ `Loaded schema with ${Object.keys(artifact.sections).length} section(s).`,
308
+ );
309
+ logStep(`Checksum: ${checksum}`);
310
+
311
+ if (opts.dryRun) {
312
+ logStep("--dry-run was set, skipping upload.");
313
+ return;
314
+ }
315
+
316
+ const apiKey = opts.env["BIAB_API_KEY"];
317
+ const siteId = opts.env["BIAB_SITE_ID"];
318
+ const rawBase =
319
+ opts.env["BIAB_PACKAGE_API_BASE_URL"] ??
320
+ opts.env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
321
+ if (!apiKey) throw new Error("Missing BIAB_API_KEY in env or .env.local.");
322
+ if (!siteId) throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
323
+ if (!rawBase) {
324
+ throw new Error(
325
+ "Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).",
326
+ );
327
+ }
328
+
329
+ const baseUrl = normalizeBaseUrl(rawBase);
330
+ const target = `${baseUrl}/sites/${encodeURIComponent(siteId)}/marketing/draft-schema`;
331
+ logStep(`POST ${target}`);
332
+
333
+ const res = await fetch(target, {
334
+ method: "POST",
335
+ headers: {
336
+ "content-type": "application/json",
337
+ authorization: `Bearer ${apiKey}`,
338
+ },
339
+ body: JSON.stringify({
340
+ schema: artifact,
341
+ checksum,
342
+ }),
343
+ });
344
+
345
+ const bodyText = await res.text();
346
+ type ResponseBody = {
347
+ ok?: boolean;
348
+ noop?: boolean;
349
+ version?: number;
350
+ message?: string;
351
+ };
352
+ let body: ResponseBody | null = null;
353
+ try {
354
+ body = JSON.parse(bodyText) as ResponseBody;
355
+ } catch {
356
+ body = null;
357
+ }
358
+
359
+ if (!res.ok) {
360
+ logError(`Upload failed (${res.status} ${res.statusText}).`);
361
+ if (body?.message) logError(`Server: ${body.message}`);
362
+ else logError(`Raw response: ${bodyText.slice(0, 500)}`);
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+
367
+ if (body?.noop) {
368
+ logStep(`No changes — schema already at v${body.version}.`);
369
+ } else if (body?.version) {
370
+ logStep(`Uploaded draft v${body.version}.`);
371
+ logStep(
372
+ "Promote in the dashboard at Site Builder → Site Data → Schema to make this live.",
373
+ );
374
+ } else {
375
+ logStep("Upload succeeded.");
376
+ }
377
+ }
378
+
379
+ // ── data model (custom database) ──────────────────────────────────────
380
+
381
+ type DataModelShape = {
382
+ toJSONSchema(): Record<string, unknown>;
383
+ checksum(): Promise<string>;
384
+ };
385
+
386
+ type LoadedDataModelConfig = {
387
+ model: DataModelShape;
388
+ generatedForms: GeneratedFormCandidate[];
389
+ };
390
+
391
+ /**
392
+ * Load either a plain data model or createFormSchemaOrchestrator() output.
393
+ * The latter intentionally keeps its database artifact and generated form
394
+ * candidates together in one config.
395
+ */
396
+ async function loadDataModelConfig(
397
+ configPath: string,
398
+ ): Promise<LoadedDataModelConfig> {
399
+ const url = pathToFileURL(configPath).href;
400
+ let mod: { default?: unknown; dataModel?: unknown };
401
+ try {
402
+ mod = (await import(url)) as typeof mod;
403
+ } catch (err) {
404
+ if (configPath.endsWith(".ts")) {
405
+ throw new Error(
406
+ `Could not load TypeScript config at ${configPath}. Run the CLI under tsx or ts-node, e.g.: ` +
407
+ `\`npx tsx node_modules/@businessdash/sdk/dist/cli.js sync-data-model\`.\nUnderlying: ${
408
+ err instanceof Error ? err.message : String(err)
409
+ }`,
410
+ );
411
+ }
412
+ throw err;
413
+ }
414
+ const chosen = mod.default ?? mod.dataModel;
415
+ const orchestrated = chosen as
416
+ | {
417
+ databaseSchema?: unknown;
418
+ generatedForms?: unknown;
419
+ }
420
+ | undefined;
421
+ const model = orchestrated?.databaseSchema ?? chosen;
422
+ if (
423
+ !model ||
424
+ typeof (model as { toJSONSchema?: unknown }).toJSONSchema !== "function"
425
+ ) {
426
+ throw new Error(
427
+ `${configPath} did not export a data model. Default-export createSchema(...) or createFormSchemaOrchestrator(...).`,
428
+ );
429
+ }
430
+ return {
431
+ model: model as DataModelShape,
432
+ generatedForms: Array.isArray(orchestrated?.generatedForms)
433
+ ? (orchestrated.generatedForms as GeneratedFormCandidate[])
434
+ : [],
435
+ };
436
+ }
437
+
438
+ type ApiTarget = { baseUrl: string; apiKey: string; siteId: string };
439
+
440
+ function resolveApiTarget(env: Record<string, string>): ApiTarget {
441
+ const apiKey = env["BIAB_API_KEY"];
442
+ const siteId = env["BIAB_SITE_ID"];
443
+ const rawBase =
444
+ env["BIAB_PACKAGE_API_BASE_URL"] ??
445
+ env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
446
+ if (!apiKey) throw new Error("Missing BIAB_API_KEY in env or .env.local.");
447
+ if (!siteId) throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
448
+ if (!rawBase) {
449
+ throw new Error(
450
+ "Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).",
451
+ );
452
+ }
453
+ return { baseUrl: normalizeBaseUrl(rawBase), apiKey, siteId };
454
+ }
455
+
456
+ async function callDataModelApi(input: {
457
+ target: ApiTarget;
458
+ path: string;
459
+ method: "GET" | "POST";
460
+ body?: unknown;
461
+ }): Promise<Record<string, unknown>> {
462
+ const { target, path, method, body } = input;
463
+ const url = `${target.baseUrl}/sites/${encodeURIComponent(target.siteId)}/data-model/${path}`;
464
+ const res = await fetch(url, {
465
+ method,
466
+ headers: {
467
+ "content-type": "application/json",
468
+ authorization: `Bearer ${target.apiKey}`,
469
+ },
470
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
471
+ });
472
+ const text = await res.text();
473
+ let parsed: Record<string, unknown> | null = null;
474
+ try {
475
+ parsed = JSON.parse(text) as Record<string, unknown>;
476
+ } catch {
477
+ parsed = null;
478
+ }
479
+ if (!res.ok) {
480
+ const message =
481
+ typeof parsed?.["message"] === "string"
482
+ ? parsed["message"]
483
+ : `HTTP ${res.status} ${res.statusText} — ${text.slice(0, 240)}`;
484
+ throw new Error(message);
485
+ }
486
+ if (!parsed) {
487
+ throw new Error(
488
+ `Server replied ${res.status} but the body wasn't JSON — first 240 chars: ${text.slice(0, 240)}`,
489
+ );
490
+ }
491
+ return parsed;
492
+ }
493
+
494
+ type PlanChange = {
495
+ class: "safe" | "backfill" | "destructive" | "exposure";
496
+ kind: string;
497
+ object: string;
498
+ field?: string;
499
+ option?: string;
500
+ summary: string;
501
+ risk?: string;
502
+ };
503
+ type MigrationPlan = {
504
+ changes: PlanChange[];
505
+ counts: {
506
+ safe: number;
507
+ backfill: number;
508
+ destructive: number;
509
+ exposure: number;
510
+ };
511
+ hasDestructive: boolean;
512
+ hasExposure: boolean;
513
+ unmanaged: Array<{ object: string; field?: string }>;
514
+ };
515
+
516
+ /**
517
+ * Render a migration plan. Grouped by class rather than by object, because the
518
+ * question a developer is actually asking is "is this safe?" — not "what
519
+ * changed on permits?".
520
+ */
521
+ function printPlan(plan: MigrationPlan): void {
522
+ if (plan.changes.length === 0) {
523
+ console.log(` ${fg.dim("No changes — the live model already matches.")}`);
524
+ }
525
+
526
+ const order = ["exposure", "destructive", "backfill", "safe"] as const;
527
+ const marks = {
528
+ exposure: fg.red("◉"),
529
+ destructive: fg.red("✕"),
530
+ backfill: fg.amber("⚠"),
531
+ safe: fg.green("✓"),
532
+ };
533
+ for (const cls of order) {
534
+ const changes = plan.changes.filter((c) => c.class === cls);
535
+ if (changes.length === 0) continue;
536
+ console.log("");
537
+ console.log(` ${fg.bold(cls.toUpperCase())} (${changes.length})`);
538
+ for (const change of changes) {
539
+ console.log(` ${marks[cls]} ${change.summary}`);
540
+ if (change.risk) console.log(` ${fg.dim(change.risk)}`);
541
+ }
542
+ }
543
+
544
+ if (plan.unmanaged.length > 0) {
545
+ console.log("");
546
+ console.log(
547
+ ` ${fg.dim(`${plan.unmanaged.length} live object/field(s) aren't in this config. They're left alone — pass --prune to remove them.`)}`,
548
+ );
549
+ for (const item of plan.unmanaged) {
550
+ const label = item.field ? `${item.object}.${item.field}` : item.object;
551
+ console.log(` ${fg.dim(`· ${label}`)}`);
552
+ }
553
+ }
554
+
555
+ if (plan.hasExposure) {
556
+ console.log("");
557
+ console.log(
558
+ ` ${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.")}`,
559
+ );
560
+ }
561
+
562
+ if (plan.hasDestructive) {
563
+ console.log("");
564
+ console.log(
565
+ ` ${fg.red("This plan contains destructive changes.")} ${fg.dim("They need an explicit confirmation in the dashboard before they apply.")}`,
566
+ );
567
+ }
568
+ }
569
+
570
+ async function cmdPrintDataModel(opts: { configPath: string }) {
571
+ const loaded = await loadDataModelConfig(opts.configPath);
572
+ const artifact: Record<string, unknown> = {
573
+ ...loaded.model.toJSONSchema(),
574
+ ...(loaded.generatedForms.length > 0
575
+ ? { generatedForms: loaded.generatedForms }
576
+ : {}),
577
+ };
578
+ const checksum = await computeDataModelChecksum(artifact);
579
+ console.log(JSON.stringify({ checksum, artifact }, null, 2));
580
+ }
581
+
582
+ /**
583
+ * What's actually live — read from the metadata engine, not from whatever was
584
+ * last pushed. The dashboard is a second writer, so those differ.
585
+ */
586
+ async function cmdViewDataModel(opts: {
587
+ env: Record<string, string>;
588
+ json: boolean;
589
+ diff: boolean;
590
+ out: string | null;
591
+ configOverride: string | undefined;
592
+ cwd: string;
593
+ prune: boolean;
594
+ }) {
595
+ const target = resolveApiTarget(opts.env);
596
+ const res = await callDataModelApi({ target, path: "live", method: "GET" });
597
+ const model = res["model"] as { objects?: unknown[] } | undefined;
598
+ const objects = (model?.objects ?? []) as Array<{
599
+ nameSingular: string;
600
+ namePlural: string;
601
+ kind: string;
602
+ fields: Array<{ name: string; type: string; options?: unknown[] }>;
603
+ }>;
604
+
605
+ if (opts.json) {
606
+ console.log(JSON.stringify(res, null, 2));
607
+ return;
608
+ }
609
+
610
+ if (opts.diff) {
611
+ const configPath = findDataModelConfigPath(opts.cwd, opts.configOverride);
612
+ const local = await loadDataModelConfig(configPath);
613
+ const planRes = await callDataModelApi({
614
+ target,
615
+ path: "plan",
616
+ method: "POST",
617
+ body: { schema: local.model.toJSONSchema(), prune: opts.prune },
618
+ });
619
+ logStep("Plan (local config vs live model):");
620
+ printPlan(planRes["plan"] as MigrationPlan);
621
+ return;
622
+ }
623
+
624
+ if (opts.out) {
625
+ const path = isAbsolute(opts.out) ? opts.out : resolve(opts.cwd, opts.out);
626
+ writeFileSync(path, renderConfigModule(objects), "utf8");
627
+ logStep(`${fg.green("✓")} Wrote ${path}`);
628
+ logStep(
629
+ fg.dim(
630
+ "Review it, then `sync-data-model` to push. Your model is code from here on.",
631
+ ),
632
+ );
633
+ return;
634
+ }
635
+
636
+ logStep(
637
+ `Live model — ${fg.bold(String(objects.length))} object(s) ${fg.dim(`(checksum ${String(res["checksum"]).slice(0, 12)}…)`)}`,
638
+ );
639
+ for (const object of objects) {
640
+ const tag = object.kind === "standard" ? fg.dim(" (standard)") : "";
641
+ console.log("");
642
+ console.log(` ${fg.bold(object.namePlural)}${tag}`);
643
+ for (const field of object.fields ?? []) {
644
+ const enumValues = Array.isArray(field.options)
645
+ ? fg.dim(
646
+ ` [${(field.options as Array<{ value: string }>).map((o) => o.value).join(", ")}]`,
647
+ )
648
+ : "";
649
+ console.log(` ${field.name} ${fg.dim(field.type)}${enumValues}`);
650
+ }
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Pull the ENTIRE custom database — schema + records + relations — into one
656
+ * portable snapshot file. The read side `view-data-model` never had: it emits
657
+ * the schema, but records could only be pushed IN (`sync-records`), never
658
+ * pulled OUT.
659
+ *
660
+ * Commit the file next to your code and it becomes a Drizzle/Convex-style
661
+ * snapshot: deterministic ordering means successive exports git-diff cleanly,
662
+ * so an AI agent (or a human) can read the collection's state and how it
663
+ * changed over time without querying BIAB live. It also re-seeds a fresh org —
664
+ * the `schema` half is the same shape `sync-data-model` pushes, the `records`
665
+ * half is the same shape `sync-records` ingests.
666
+ */
667
+ async function cmdExportDataModel(opts: {
668
+ env: Record<string, string>;
669
+ out: string | null;
670
+ schemaOnly: boolean;
671
+ cwd: string;
672
+ }) {
673
+ const target = resolveApiTarget(opts.env);
674
+ const path = opts.schemaOnly ? "export?records=0" : "export";
675
+ const res = await callDataModelApi({ target, path, method: "GET" });
676
+ const seed = res["seed"] as
677
+ | {
678
+ checksum: string;
679
+ counts: {
680
+ objects: number;
681
+ customObjects: number;
682
+ records: number;
683
+ syntheticIdentifiers: number;
684
+ truncated: boolean;
685
+ };
686
+ }
687
+ | undefined;
688
+ if (!seed) {
689
+ throw new Error("Server did not return a seed. Check your key's scopes.");
690
+ }
691
+
692
+ const outPath = opts.out
693
+ ? isAbsolute(opts.out)
694
+ ? opts.out
695
+ : resolve(opts.cwd, opts.out)
696
+ : resolve(opts.cwd, "biab.data-model.snapshot.json");
697
+ writeFileSync(outPath, `${JSON.stringify(seed, null, 2)}\n`, "utf8");
698
+
699
+ const c = seed.counts;
700
+ logStep(`${fg.green("✓")} Wrote ${outPath}`);
701
+ logStep(
702
+ fg.dim(
703
+ `${c.customObjects} custom object(s), ${c.records} record(s)` +
704
+ (opts.schemaOnly ? " (schema only)" : "") +
705
+ ` · checksum ${seed.checksum.slice(0, 12)}…`,
706
+ ),
707
+ );
708
+ if (c.syntheticIdentifiers > 0) {
709
+ logStep(
710
+ fg.amber(
711
+ `⚠ ${c.syntheticIdentifiers} dashboard-authored row(s) were given a synthetic id. Re-seeding those into the SAME org duplicates them — target a fresh org.`,
712
+ ),
713
+ );
714
+ }
715
+ if (c.truncated) {
716
+ logStep(
717
+ fg.amber(
718
+ "⚠ An object hit the per-object cap; some rows were left out. Pass --out to a full export or page the records endpoint.",
719
+ ),
720
+ );
721
+ }
722
+ logStep(
723
+ fg.dim(
724
+ "Commit this file. Re-seed with `sync-data-model` (schema) + `sync-records` (rows).",
725
+ ),
726
+ );
727
+ }
728
+
729
+ /**
730
+ * Emit a GraphQL SDL schema of the org's custom data model.
731
+ *
732
+ * "GraphQL layer compatibility": a real, typed GraphQL schema of the custom
733
+ * collections that any GraphQL codegen / tooling can consume, and that an AI
734
+ * agent can read next to an `export-data-model` snapshot — the schema of the
735
+ * data beside the data. This emits the SCHEMA; it does not stand up a GraphQL
736
+ * server (execution is a separate layer).
737
+ *
738
+ * Reads the live model (same source as `view-data-model`) and renders SDL
739
+ * locally — no server-side GraphQL runtime involved.
740
+ */
741
+ async function cmdExportGraphqlSchema(opts: {
742
+ env: Record<string, string>;
743
+ out: string | null;
744
+ includeStandard: boolean;
745
+ cwd: string;
746
+ }) {
747
+ const target = resolveApiTarget(opts.env);
748
+ const res = await callDataModelApi({ target, path: "live", method: "GET" });
749
+ const model = res["model"] as GraphqlSdlModel | undefined;
750
+ if (!model?.objects) {
751
+ throw new Error("Server did not return a model. Check your key's scopes.");
752
+ }
753
+ const sdl = dataModelToGraphqlSdl(model, {
754
+ includeStandard: opts.includeStandard,
755
+ });
756
+
757
+ const outPath = opts.out
758
+ ? isAbsolute(opts.out)
759
+ ? opts.out
760
+ : resolve(opts.cwd, opts.out)
761
+ : resolve(opts.cwd, "biab.data-model.graphql");
762
+ writeFileSync(outPath, sdl, "utf8");
763
+
764
+ const typeCount = (sdl.match(/^type \w/gm) ?? []).length;
765
+ logStep(`${fg.green("✓")} Wrote ${outPath}`);
766
+ logStep(
767
+ fg.dim(
768
+ `${typeCount} GraphQL type(s)${opts.includeStandard ? " (incl. standard)" : ""}. Feed it to codegen or commit it as a contract.`,
769
+ ),
770
+ );
771
+ }
772
+
773
+ /**
774
+ * Scaffold a config from live state. The adoption path for an org that already
775
+ * built its model in the dashboard — without it, data-model-as-code would only
776
+ * serve greenfield orgs.
777
+ */
778
+ function renderConfigModule(
779
+ objects: Array<{
780
+ universalIdentifier?: string;
781
+ nameSingular: string;
782
+ namePlural: string;
783
+ labelSingular?: string;
784
+ labelPlural?: string;
785
+ kind: string;
786
+ labelIdentifierFieldName?: string;
787
+ standardEntityName?: string;
788
+ fields: Array<Record<string, unknown>>;
789
+ }>,
790
+ ): string {
791
+ const lines: string[] = [
792
+ "// Generated by `biab-dev view-data-model --out`.",
793
+ "// This is your org's live data model, as code. Edit it, push it with",
794
+ "// `biab-dev sync-data-model`, then promote in the dashboard.",
795
+ "",
796
+ 'import { createSchema, defineCollection, defineField } from "@businessdash/sdk";',
797
+ "",
798
+ "export default createSchema({",
799
+ "\tcollections: [",
800
+ ];
801
+ for (const object of objects) {
802
+ lines.push("\t\tdefineField({");
803
+ lines.push(
804
+ `\t\t\tuniversalIdentifier: ${JSON.stringify(object.universalIdentifier)},`,
805
+ );
806
+ if (object.kind === "standard") {
807
+ lines.push('\t\t\tkind: "standard",');
808
+ lines.push(
809
+ `\t\t\tstandardEntityName: ${JSON.stringify(object.standardEntityName)},`,
810
+ );
811
+ }
812
+ lines.push(`\t\t\tnameSingular: ${JSON.stringify(object.nameSingular)},`);
813
+ lines.push(`\t\t\tnamePlural: ${JSON.stringify(object.namePlural)},`);
814
+ lines.push(`\t\t\tlabelSingular: ${JSON.stringify(object.labelSingular)},`);
815
+ lines.push(`\t\t\tlabelPlural: ${JSON.stringify(object.labelPlural)},`);
816
+ if (object.labelIdentifierFieldName) {
817
+ lines.push(
818
+ `\t\t\tlabelIdentifierFieldName: ${JSON.stringify(object.labelIdentifierFieldName)},`,
819
+ );
820
+ }
821
+ lines.push("\t\t\tfields: [");
822
+ for (const field of object.fields ?? []) {
823
+ lines.push(`\t\t\t\tdefineCollection(${JSON.stringify(field)}),`);
824
+ }
825
+ lines.push("\t\t\t],");
826
+ lines.push("\t\t}),");
827
+ }
828
+ lines.push("\t],");
829
+ lines.push("})");
830
+ lines.push("");
831
+ return lines.join("\n");
832
+ }
833
+
834
+ async function cmdSyncDataModel(opts: {
835
+ configPath: string;
836
+ env: Record<string, string>;
837
+ dryRun: boolean;
838
+ prune: boolean;
839
+ note: string | null;
840
+ forms: string;
841
+ }) {
842
+ const loaded = await loadDataModelConfig(opts.configPath);
843
+ const selectedForms = await selectGeneratedForms(
844
+ loaded.generatedForms,
845
+ opts.forms,
846
+ );
847
+ const artifact: Record<string, unknown> = {
848
+ ...loaded.model.toJSONSchema(),
849
+ ...(selectedForms.length > 0 ? { generatedForms: selectedForms } : {}),
850
+ };
851
+ const checksum = await computeDataModelChecksum(artifact);
852
+ const objects = (artifact["objects"] ?? []) as unknown[];
853
+
854
+ logStep(`Loaded data model with ${objects.length} object(s).`);
855
+ if (loaded.generatedForms.length > 0) {
856
+ logStep(
857
+ `Including ${selectedForms.length}/${loaded.generatedForms.length} generated inactive form draft(s).`,
858
+ );
859
+ }
860
+ logStep(`Checksum: ${checksum}`);
861
+
862
+ const target = resolveApiTarget(opts.env);
863
+
864
+ // Always plan before pushing. A push is harmless on its own — it only
865
+ // writes the draft slot — but showing what promoting it WOULD do is the
866
+ // whole point of the flow, and it costs one request.
867
+ const planRes = await callDataModelApi({
868
+ target,
869
+ path: "plan",
870
+ method: "POST",
871
+ body: { schema: artifact, prune: opts.prune },
872
+ });
873
+ const plan = planRes["plan"] as MigrationPlan;
874
+ console.log("");
875
+ logStep("Plan (what promoting this would do):");
876
+ printPlan(plan);
877
+ console.log("");
878
+
879
+ if (opts.dryRun) {
880
+ logStep("--dry-run was set, skipping upload.");
881
+ return;
882
+ }
883
+
884
+ const res = await callDataModelApi({
885
+ target,
886
+ path: "draft-schema",
887
+ method: "POST",
888
+ body: { schema: artifact, checksum, note: opts.note },
889
+ });
890
+
891
+ if (res["noop"]) {
892
+ logStep(`No changes — the draft is already at v${String(res["version"])}.`);
893
+ return;
894
+ }
895
+ logStep(`${fg.green("✓")} Uploaded draft v${String(res["version"])}.`);
896
+ logStep(
897
+ fg.dim(
898
+ "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.",
899
+ ),
900
+ );
901
+ }
902
+
903
+ async function selectGeneratedForms(
904
+ candidates: GeneratedFormCandidate[],
905
+ mode: string,
906
+ ): Promise<GeneratedFormCandidate[]> {
907
+ if (candidates.length === 0 || mode === "none") return [];
908
+ if (mode === "all") return candidates;
909
+ if (mode.startsWith("list:")) {
910
+ const requested = new Set(
911
+ mode
912
+ .slice(5)
913
+ .split(",")
914
+ .map((value) => value.trim())
915
+ .filter(Boolean),
916
+ );
917
+ const selected = candidates.filter(
918
+ (candidate) =>
919
+ requested.has(candidate.slug) || requested.has(candidate.sourceKey),
920
+ );
921
+ const found = new Set(
922
+ selected.flatMap((candidate) => [candidate.slug, candidate.sourceKey]),
923
+ );
924
+ const unknown = [...requested].filter((value) => !found.has(value));
925
+ if (unknown.length > 0) {
926
+ throw new Error(
927
+ `Unknown generated form candidate(s): ${unknown.join(", ")}. Available slugs: ${candidates.map((candidate) => candidate.slug).join(", ")}.`,
928
+ );
929
+ }
930
+ return selected;
931
+ }
932
+
933
+ // CI/non-interactive runs remain deterministic and useful: all candidates
934
+ // are included, but promotion still creates them inactive.
935
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return candidates;
936
+
937
+ console.log("");
938
+ logStep("Generated form candidates (all selected by default):");
939
+ candidates.forEach((candidate, index) => {
940
+ console.log(
941
+ ` ${index + 1}. ${candidate.name} ${fg.dim(`(${candidate.slug}, ${candidate.fields.length} fields)`)}`,
942
+ );
943
+ });
944
+ const prompt = createInterface({
945
+ input: process.stdin,
946
+ output: process.stdout,
947
+ });
948
+ const answer = await prompt.question(
949
+ "Select forms by number (comma-separated), press Enter for all, or type none: ",
950
+ );
951
+ prompt.close();
952
+ const value = answer.trim().toLowerCase();
953
+ if (!value) return candidates;
954
+ if (value === "none") return [];
955
+ const indexes = new Set(
956
+ value
957
+ .split(",")
958
+ .map((part) => Number(part.trim()) - 1)
959
+ .filter((index) => Number.isInteger(index)),
960
+ );
961
+ if ([...indexes].some((index) => index < 0 || index >= candidates.length)) {
962
+ throw new Error(
963
+ `Form selection must contain numbers from 1 to ${candidates.length}.`,
964
+ );
965
+ }
966
+ return candidates.filter((_, index) => indexes.has(index));
967
+ }
968
+
969
+ // ── sync-records ─────────────────────────────────────────────────────
970
+
971
+ type SeedRow = {
972
+ object: string;
973
+ universalIdentifier: string;
974
+ values: Record<string, unknown>;
975
+ /**
976
+ * RELATION values, keyed by field name → the TARGET row's
977
+ * `universalIdentifier` (or a list of them). Links live in the platform's
978
+ * relation table, not in `values` — a relation key inside `values` is
979
+ * silently stripped server-side, so this is the only channel that works.
980
+ * Synced in a second pass after every record exists, so seed files can
981
+ * reference rows in any file, in any order.
982
+ */
983
+ relations?: Record<string, string | string[]>;
984
+ };
985
+
986
+ type SeedResult =
987
+ | { ok: true; object: string; universalIdentifier: string; created: boolean }
988
+ | { ok: false; object: string; universalIdentifier: string; error: string };
989
+
990
+ /**
991
+ * Seed rows for custom objects from JSON on disk.
992
+ *
993
+ * Layout: `<rootDir>/<anything>.json`, each file an array of
994
+ * `{ object, universalIdentifier, values }`. Row identity is required — it's
995
+ * what makes running this on every deploy converge instead of duplicating.
996
+ */
997
+ function discoverSeedFiles(rootDir: string): {
998
+ rows: SeedRow[];
999
+ warnings: string[];
1000
+ } {
1001
+ const rows: SeedRow[] = [];
1002
+ const warnings: string[] = [];
1003
+
1004
+ const walk = (dir: string): void => {
1005
+ for (const entry of readdirSync(dir)) {
1006
+ const abs = join(dir, entry);
1007
+ if (statSync(abs).isDirectory()) {
1008
+ walk(abs);
1009
+ continue;
1010
+ }
1011
+ if (!entry.endsWith(".json")) continue;
1012
+ let parsed: unknown;
1013
+ try {
1014
+ parsed = JSON.parse(readFileSync(abs, "utf8"));
1015
+ } catch (err) {
1016
+ warnings.push(
1017
+ `Invalid JSON in ${abs}: ${err instanceof Error ? err.message : String(err)}`,
1018
+ );
1019
+ continue;
1020
+ }
1021
+ if (!Array.isArray(parsed)) {
1022
+ warnings.push(`Skipping ${abs} — expected an array of rows.`);
1023
+ continue;
1024
+ }
1025
+ for (const [i, raw] of parsed.entries()) {
1026
+ const row = raw as Partial<SeedRow>;
1027
+ if (!row?.object || !row.universalIdentifier || !row.values) {
1028
+ warnings.push(
1029
+ `Skipping ${abs}[${i}] — needs object, universalIdentifier, and values.`,
1030
+ );
1031
+ continue;
1032
+ }
1033
+ if (
1034
+ row.relations !== undefined &&
1035
+ (typeof row.relations !== "object" ||
1036
+ row.relations === null ||
1037
+ Array.isArray(row.relations))
1038
+ ) {
1039
+ warnings.push(
1040
+ `Skipping ${abs}[${i}] — relations must be an object of { fieldName: targetUid | targetUid[] }.`,
1041
+ );
1042
+ continue;
1043
+ }
1044
+ rows.push({
1045
+ object: row.object,
1046
+ universalIdentifier: row.universalIdentifier,
1047
+ values: row.values,
1048
+ ...(row.relations ? { relations: row.relations } : {}),
1049
+ });
1050
+ }
1051
+ }
1052
+ };
1053
+
1054
+ walk(rootDir);
1055
+ return { rows, warnings };
1056
+ }
1057
+
1058
+ async function cmdSyncRecords(opts: {
1059
+ env: Record<string, string>;
1060
+ cwd: string;
1061
+ rootDir: string;
1062
+ dryRun: boolean;
1063
+ }) {
1064
+ const rootDir = isAbsolute(opts.rootDir)
1065
+ ? opts.rootDir
1066
+ : resolve(opts.cwd, opts.rootDir);
1067
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
1068
+ throw new Error(
1069
+ `--records-dir resolves to "${rootDir}", which doesn't exist or isn't a directory.`,
1070
+ );
1071
+ }
1072
+
1073
+ const { rows, warnings } = discoverSeedFiles(rootDir);
1074
+ logStep(
1075
+ `Discovered ${fg.bold(String(rows.length))} row(s) under ${rootDir}.`,
1076
+ );
1077
+ for (const warning of warnings) {
1078
+ console.log(` ${fg.amber("⚠")} ${fg.dim(warning)}`);
1079
+ }
1080
+ if (rows.length === 0) {
1081
+ logStep("Nothing to seed.");
1082
+ return;
1083
+ }
1084
+
1085
+ if (opts.dryRun) {
1086
+ logStep("--dry-run was set, skipping upload.");
1087
+ for (const row of rows) {
1088
+ const relNote = row.relations
1089
+ ? ` ${fg.dim(`(+${Object.keys(row.relations).length} relation field(s))`)}`
1090
+ : "";
1091
+ console.log(
1092
+ ` ${fg.dim("→")} ${row.object} ${fg.dim(row.universalIdentifier)}${relNote}`,
1093
+ );
1094
+ }
1095
+ return;
1096
+ }
1097
+
1098
+ const target = resolveApiTarget(opts.env);
1099
+ const BATCH = 64; // well under the server's 256 cap; smaller = faster feedback
1100
+ let created = 0;
1101
+ let updated = 0;
1102
+ const failures: SeedResult[] = [];
1103
+
1104
+ // 893 rows is ~14 sequential round-trips; without this the CLI sits silent
1105
+ // between "Discovered" and the final tally the whole time.
1106
+ const progress = makeProgress(rows.length);
1107
+ progress.tick(0);
1108
+
1109
+ for (let i = 0; i < rows.length; i += BATCH) {
1110
+ // Pass 1 sends values only. Relations go in a second pass below, after
1111
+ // EVERY record exists — so a link can point at a row from any file,
1112
+ // in any order, without a "target not found yet" failure.
1113
+ const batch = rows.slice(i, i + BATCH).map((row) => ({
1114
+ object: row.object,
1115
+ universalIdentifier: row.universalIdentifier,
1116
+ values: row.values,
1117
+ }));
1118
+ const res = await callDataModelApi({
1119
+ target,
1120
+ path: "records",
1121
+ method: "POST",
1122
+ body: { items: batch },
1123
+ });
1124
+ for (const result of (res["results"] ?? []) as SeedResult[]) {
1125
+ if (result.ok) {
1126
+ if (result.created) created += 1;
1127
+ else updated += 1;
1128
+ } else {
1129
+ failures.push(result);
1130
+ }
1131
+ }
1132
+ const note = `${created} created, ${updated} updated${
1133
+ failures.length > 0 ? `, ${failures.length} failed` : ""
1134
+ }`;
1135
+ progress.tick(Math.min(i + BATCH, rows.length), note);
1136
+ }
1137
+
1138
+ progress.clear();
1139
+ console.log("");
1140
+ logStep(
1141
+ `${fg.green(`✓ ${created} created`)} ${fg.green(`${updated} updated`)}${
1142
+ failures.length > 0 ? ` ${fg.red(`✕ ${failures.length} failed`)}` : ""
1143
+ }`,
1144
+ );
1145
+ for (const failure of failures) {
1146
+ if (failure.ok) continue;
1147
+ console.log(
1148
+ ` ${fg.red("✕")} ${failure.object} ${fg.dim(failure.universalIdentifier)} — ${fg.dim(failure.error)}`,
1149
+ );
1150
+ }
1151
+
1152
+ // Pass 2: converge declared relations, now that every record exists.
1153
+ // Rows whose record failed in pass 1 are skipped — no point linking a row
1154
+ // that isn't there.
1155
+ const failedIdentity = new Set(
1156
+ failures.map((f) => `${f.object}:${f.universalIdentifier}`),
1157
+ );
1158
+ const relationRows = rows.filter(
1159
+ (row) =>
1160
+ row.relations &&
1161
+ Object.keys(row.relations).length > 0 &&
1162
+ !failedIdentity.has(`${row.object}:${row.universalIdentifier}`),
1163
+ );
1164
+ if (relationRows.length > 0) {
1165
+ logStep(
1166
+ `Syncing relations for ${fg.bold(String(relationRows.length))} row(s)…`,
1167
+ );
1168
+ let linksApplied = 0;
1169
+ const relationFailures: SeedResult[] = [];
1170
+ for (let i = 0; i < relationRows.length; i += BATCH) {
1171
+ const batch = relationRows.slice(i, i + BATCH).map((row) => ({
1172
+ object: row.object,
1173
+ universalIdentifier: row.universalIdentifier,
1174
+ relations: row.relations,
1175
+ }));
1176
+ const res = await callDataModelApi({
1177
+ target,
1178
+ path: "records",
1179
+ method: "POST",
1180
+ body: { items: batch },
1181
+ });
1182
+ for (const result of (res["results"] ?? []) as (SeedResult & {
1183
+ relationsApplied?: number;
1184
+ })[]) {
1185
+ if (result.ok) linksApplied += result.relationsApplied ?? 0;
1186
+ else relationFailures.push(result);
1187
+ }
1188
+ }
1189
+ logStep(
1190
+ `${fg.green(`✓ ${linksApplied} link change(s) applied`)}${
1191
+ relationFailures.length > 0
1192
+ ? ` ${fg.red(`✕ ${relationFailures.length} failed`)}`
1193
+ : ""
1194
+ }`,
1195
+ );
1196
+ for (const failure of relationFailures) {
1197
+ if (failure.ok) continue;
1198
+ console.log(
1199
+ ` ${fg.red("✕")} ${failure.object} ${fg.dim(failure.universalIdentifier)} — ${fg.dim(failure.error)}`,
1200
+ );
1201
+ }
1202
+ if (relationFailures.length > 0) process.exitCode = 1;
1203
+ }
1204
+
1205
+ if (failures.length > 0) process.exitCode = 1;
1206
+ }
1207
+
1208
+ // ── wipe-collections ──────────────────────────────────────────────────
1209
+
1210
+ type WipeCollectionsPreview = {
1211
+ planToken: string;
1212
+ confirmationPhrase: string;
1213
+ collections: Array<{
1214
+ universalIdentifier: string;
1215
+ name: string;
1216
+ label: string;
1217
+ records: number;
1218
+ activeRecords: number;
1219
+ softDeletedRecords: number;
1220
+ }>;
1221
+ recordCount: number;
1222
+ relationCount: number;
1223
+ backupFolder: string;
1224
+ };
1225
+
1226
+ type WipeCollectionsResult = {
1227
+ ok: true;
1228
+ deleted: { records: number; relations: number };
1229
+ backup: {
1230
+ mediaId: string;
1231
+ folderId: string;
1232
+ name: string;
1233
+ sha256: string;
1234
+ sizeBytes: number;
1235
+ folder: string;
1236
+ };
1237
+ };
1238
+
1239
+ /**
1240
+ * Wipe record contents, never collection definitions. There is intentionally
1241
+ * no `--all` and no `--yes`: a human must name the targets, review live counts,
1242
+ * answer once, and then type a state-bound phrase exactly.
1243
+ */
1244
+ async function cmdWipeCollections(opts: {
1245
+ env: Record<string, string>;
1246
+ collections: string[];
1247
+ dryRun: boolean;
1248
+ }) {
1249
+ const collections = [
1250
+ ...new Set(
1251
+ opts.collections
1252
+ .map((value) => value.trim())
1253
+ .filter((value) => value.length > 0),
1254
+ ),
1255
+ ];
1256
+ if (collections.length === 0) {
1257
+ throw new Error(
1258
+ "wipe-collections requires --collections name-a,name-b. There is no implicit all-collections mode.",
1259
+ );
1260
+ }
1261
+
1262
+ const target = resolveApiTarget(opts.env);
1263
+ const path = `records/wipe?collections=${encodeURIComponent(collections.join(","))}`;
1264
+ const preview = (await callDataModelApi({
1265
+ target,
1266
+ path,
1267
+ method: "GET",
1268
+ })) as WipeCollectionsPreview;
1269
+
1270
+ console.log("");
1271
+ logStep(fg.red(fg.bold("DESTRUCTIVE RECORD WIPE PREVIEW")));
1272
+ for (const collection of preview.collections) {
1273
+ console.log(
1274
+ ` ${fg.red("✕")} ${fg.bold(collection.name)} — ${collection.records} record(s) ` +
1275
+ fg.dim(
1276
+ `(${collection.activeRecords} active, ${collection.softDeletedRecords} soft-deleted)`,
1277
+ ),
1278
+ );
1279
+ }
1280
+ console.log(
1281
+ ` ${fg.amber("↳")} ${preview.relationCount} relationship link(s) also removed`,
1282
+ );
1283
+ console.log(
1284
+ ` ${fg.green("↳")} Backup required at Media / ${preview.backupFolder}`,
1285
+ );
1286
+ console.log(
1287
+ ` ${fg.dim("↳")} Plan ${preview.planToken.slice(0, 12)}… — any intervening data change invalidates it`,
1288
+ );
1289
+ console.log("");
1290
+
1291
+ if (opts.dryRun) {
1292
+ logStep("--dry-run was set. Nothing was backed up or wiped.");
1293
+ return;
1294
+ }
1295
+ if (preview.recordCount === 0 && preview.relationCount === 0) {
1296
+ logStep("Nothing to wipe.");
1297
+ return;
1298
+ }
1299
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1300
+ throw new Error(
1301
+ "wipe-collections only runs in an interactive terminal. It deliberately has no --yes bypass.",
1302
+ );
1303
+ }
1304
+
1305
+ const prompt = createInterface({
1306
+ input: process.stdin,
1307
+ output: process.stdout,
1308
+ });
1309
+ try {
1310
+ const first = await prompt.question(
1311
+ "Continue to the final typed confirmation? Type yes to continue: ",
1312
+ );
1313
+ if (first.trim().toLowerCase() !== "yes") {
1314
+ logStep("Cancelled. No backup was created and nothing was wiped.");
1315
+ return;
1316
+ }
1317
+ console.log("");
1318
+ console.log(
1319
+ fg.amber(
1320
+ "The server will save and verify the backup before deleting any record.",
1321
+ ),
1322
+ );
1323
+ const confirmation = await prompt.question(
1324
+ `Type exactly "${preview.confirmationPhrase}" to wipe: `,
1325
+ );
1326
+ if (confirmation !== preview.confirmationPhrase) {
1327
+ throw new Error(
1328
+ `Confirmation did not match. Expected exactly: ${preview.confirmationPhrase}`,
1329
+ );
1330
+ }
1331
+
1332
+ const result = (await callDataModelApi({
1333
+ target,
1334
+ path: "records/wipe",
1335
+ method: "POST",
1336
+ body: {
1337
+ collections,
1338
+ planToken: preview.planToken,
1339
+ confirmation,
1340
+ },
1341
+ })) as WipeCollectionsResult;
1342
+
1343
+ console.log("");
1344
+ logStep(
1345
+ `${fg.green(`✓ Verified backup: ${result.backup.name}`)} ${fg.dim(
1346
+ `(${result.backup.sizeBytes} bytes, sha256 ${result.backup.sha256.slice(0, 12)}…)`,
1347
+ )}`,
1348
+ );
1349
+ logStep(
1350
+ `${fg.green(`✓ Wiped ${result.deleted.records} record(s)`)} and ${result.deleted.relations} relationship link(s).`,
1351
+ );
1352
+ logStep(`Backup location: Media / ${result.backup.folder}`);
1353
+ } finally {
1354
+ prompt.close();
1355
+ }
1356
+ }
1357
+
1358
+ // ── wipe-database ────────────────────────────────────────────────────
1359
+
1360
+ type ResetPreview = {
1361
+ planToken: string;
1362
+ confirmationPhrase: string;
1363
+ objectCount: number;
1364
+ fieldCount: number;
1365
+ viewCount: number;
1366
+ recordCount: number;
1367
+ relationCount: number;
1368
+ generatedFormCount: number;
1369
+ hasDraftSchema: boolean;
1370
+ hasPublishedSchema: boolean;
1371
+ backupFolder: string;
1372
+ };
1373
+
1374
+ type ResetResult = {
1375
+ ok: boolean;
1376
+ objectsSoftDeleted: number;
1377
+ fieldsSoftDeleted: number;
1378
+ viewsSoftDeleted: number;
1379
+ relationsDeleted: number;
1380
+ recordsDeleted: number;
1381
+ formsSoftDeleted: number;
1382
+ backup: {
1383
+ mediaId: string;
1384
+ folderId: string;
1385
+ name: string;
1386
+ sha256: string;
1387
+ sizeBytes: number;
1388
+ folder: string;
1389
+ };
1390
+ };
1391
+
1392
+ /**
1393
+ * Developer shortcut that wipes records AND removes the named collections
1394
+ * from the schema. Combines the data-wipe flow (with backup) with a
1395
+ * pruned schema draft push; the schema change still requires dashboard
1396
+ * promotion.
1397
+ *
1398
+ * When --all is used, calls the data-model/reset endpoint which atomically
1399
+ * soft-deletes all metadata, clears records, and removes schema slots.
1400
+ *
1401
+ * Accepts --yes to skip the interactive prompt (single confirmation).
1402
+ */
1403
+ async function cmdWipeDatabase(opts: {
1404
+ configPath: string;
1405
+ env: Record<string, string>;
1406
+ collections: string[];
1407
+ dryRun: boolean;
1408
+ yes: boolean;
1409
+ all: boolean;
1410
+ }) {
1411
+ const explicitNames = [
1412
+ ...new Set(opts.collections.map((v) => v.trim()).filter(Boolean)),
1413
+ ];
1414
+ if (!opts.all && explicitNames.length === 0) {
1415
+ throw new Error(
1416
+ "wipe-database requires --collections name-a,name-b or --all.",
1417
+ );
1418
+ }
1419
+
1420
+ const target = resolveApiTarget(opts.env);
1421
+
1422
+ // ── BRANCH: Full reset via data-model/reset ──
1423
+ if (opts.all) {
1424
+ const preview = (await callDataModelApi({
1425
+ target,
1426
+ path: "reset",
1427
+ method: "GET",
1428
+ })) as ResetPreview;
1429
+
1430
+ console.log("");
1431
+ logStep(fg.red(fg.bold("DATA MODEL RESET PREVIEW")));
1432
+ console.log(
1433
+ ` ${fg.red("✕")} ${preview.objectCount} object(s) will be soft-deleted`,
1434
+ );
1435
+ console.log(
1436
+ ` ${fg.dim("↳")} ${preview.fieldCount} field(s), ${preview.viewCount} view(s)`,
1437
+ );
1438
+ console.log(
1439
+ ` ${fg.red("✕")} ${preview.recordCount} record(s) and ${preview.relationCount} relation(s) will be hard-deleted`,
1440
+ );
1441
+ if (preview.generatedFormCount > 0) {
1442
+ console.log(
1443
+ ` ${fg.amber("⚠")} ${preview.generatedFormCount} generated form(s) will be deactivated`,
1444
+ );
1445
+ }
1446
+ if (preview.hasDraftSchema) {
1447
+ console.log(` ${fg.amber("↳")} Draft schema slot will be cleared`);
1448
+ }
1449
+ if (preview.hasPublishedSchema) {
1450
+ console.log(` ${fg.amber("↳")} Published schema slot will be cleared`);
1451
+ }
1452
+ console.log(` ${fg.green("↳")} Backup at Media / ${preview.backupFolder}`);
1453
+ console.log(` ${fg.dim("↳")} Plan ${preview.planToken.slice(0, 12)}…`);
1454
+ console.log("");
1455
+
1456
+ if (opts.dryRun) {
1457
+ logStep("--dry-run was set. Nothing was backed up or reset.");
1458
+ return;
1459
+ }
1460
+
1461
+ // ── Confirmation ──
1462
+ if (!opts.yes) {
1463
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1464
+ throw new Error(
1465
+ "wipe-database --all requires an interactive terminal or --yes.",
1466
+ );
1467
+ }
1468
+ const prompt = createInterface({
1469
+ input: process.stdin,
1470
+ output: process.stdout,
1471
+ });
1472
+ try {
1473
+ const first = await prompt.question(
1474
+ "Continue to the final typed confirmation? Type yes to continue: ",
1475
+ );
1476
+ if (first.trim().toLowerCase() !== "yes") {
1477
+ logStep("Cancelled. No backup was created and nothing was reset.");
1478
+ return;
1479
+ }
1480
+ console.log("");
1481
+ console.log(
1482
+ fg.amber(
1483
+ "The server will save and verify the backup before resetting any data.",
1484
+ ),
1485
+ );
1486
+ const confirmation = await prompt.question(
1487
+ `Type exactly "${preview.confirmationPhrase}" to reset: `,
1488
+ );
1489
+ if (confirmation !== preview.confirmationPhrase) {
1490
+ throw new Error(
1491
+ `Confirmation did not match. Expected exactly: ${preview.confirmationPhrase}`,
1492
+ );
1493
+ }
1494
+ } finally {
1495
+ prompt.close();
1496
+ }
1497
+ }
1498
+
1499
+ // ── Execute reset ──
1500
+ logStep("Backing up and resetting data model…");
1501
+ try {
1502
+ const result = (await callDataModelApi({
1503
+ target,
1504
+ path: "reset",
1505
+ method: "POST",
1506
+ body: {
1507
+ planToken: preview.planToken,
1508
+ confirmation: preview.confirmationPhrase,
1509
+ },
1510
+ })) as ResetResult;
1511
+
1512
+ console.log("");
1513
+ logStep(
1514
+ `${fg.green("✓")} Backup: ${result.backup.name} (${result.backup.sizeBytes} bytes, sha256 ${result.backup.sha256.slice(0, 12)}…)`,
1515
+ );
1516
+ logStep(
1517
+ `${fg.green("✓")} Reset: ${result.objectsSoftDeleted} object(s), ${result.fieldsSoftDeleted} field(s), ${result.viewsSoftDeleted} view(s)`,
1518
+ );
1519
+ logStep(
1520
+ `${fg.green("✓")} Wiped: ${result.recordsDeleted} record(s), ${result.relationsDeleted} relation(s)`,
1521
+ );
1522
+ if (result.formsSoftDeleted > 0) {
1523
+ logStep(
1524
+ `${fg.green("✓")} Deactivated ${result.formsSoftDeleted} generated form(s)`,
1525
+ );
1526
+ }
1527
+ } catch (err) {
1528
+ throw new Error(
1529
+ `Data model reset failed: ${err instanceof Error ? err.message : String(err)}`,
1530
+ );
1531
+ }
1532
+ return;
1533
+ }
1534
+
1535
+ // ── BRANCH: Targeted wipe via records/wipe + pruned schema draft ──
1536
+
1537
+ // ── 1. Load local config and determine which collections to remove ──
1538
+ const loaded = await loadDataModelConfig(opts.configPath);
1539
+ const artifact = loaded.model.toJSONSchema() as {
1540
+ $schema: string;
1541
+ version: number;
1542
+ metadata: Record<string, string | number | boolean>;
1543
+ objects: Array<Record<string, unknown>>;
1544
+ };
1545
+
1546
+ const names = explicitNames;
1547
+
1548
+ const targetNames = new Set(names);
1549
+ const keep: Array<Record<string, unknown>> = [];
1550
+ const removed: Array<Record<string, unknown>> = [];
1551
+ for (const obj of artifact.objects) {
1552
+ const singular = String(obj["nameSingular"] ?? "");
1553
+ const plural = String(obj["namePlural"] ?? "");
1554
+ if (targetNames.has(singular) || targetNames.has(plural)) {
1555
+ removed.push(obj);
1556
+ } else {
1557
+ keep.push(obj);
1558
+ }
1559
+ }
1560
+
1561
+ if (removed.length === 0 && artifact.objects.length > 0) {
1562
+ throw new Error(
1563
+ `wipe-database: none of the named collections match any object in the config. ` +
1564
+ `Config has: ${artifact.objects.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}`,
1565
+ );
1566
+ }
1567
+
1568
+ const prunedArtifact = { ...artifact, objects: keep };
1569
+ // Remove generated forms that reference wiped collections
1570
+ const wipedUis = new Set(
1571
+ removed.map((o) => String(o["universalIdentifier"] ?? "")),
1572
+ );
1573
+ const loadedForms = loaded.generatedForms;
1574
+ const keptForms = loadedForms.filter((f) => {
1575
+ const refs =
1576
+ f.settings.generatedFrom.objectIdentifiers ??
1577
+ (f.settings.generatedFrom.objectIdentifier
1578
+ ? [f.settings.generatedFrom.objectIdentifier]
1579
+ : []);
1580
+ return !refs.some((r) => wipedUis.has(r));
1581
+ });
1582
+ const prunedForms =
1583
+ keptForms.length < loadedForms.length ? keptForms : loadedForms;
1584
+ const pushArtifact: Record<string, unknown> = {
1585
+ ...prunedArtifact,
1586
+ ...(prunedForms.length > 0 ? { generatedForms: prunedForms } : {}),
1587
+ };
1588
+
1589
+ logStep(
1590
+ `Loaded config with ${artifact.objects.length} object(s), ` +
1591
+ `removing ${removed.length} (${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}), ` +
1592
+ `keeping ${keep.length}.`,
1593
+ );
1594
+
1595
+ // ── 2. Fetch the wipe preview to show live record counts ──
1596
+ const wipePath = `records/wipe?collections=${encodeURIComponent(names.join(","))}`;
1597
+ let wipePreview: WipeCollectionsPreview | null = null;
1598
+ let wipeError: string | null = null;
1599
+ try {
1600
+ wipePreview = (await callDataModelApi({
1601
+ target,
1602
+ path: wipePath,
1603
+ method: "GET",
1604
+ })) as WipeCollectionsPreview;
1605
+ } catch (err) {
1606
+ wipeError = err instanceof Error ? err.message : String(err);
1607
+ }
1608
+
1609
+ console.log("");
1610
+ logStep(fg.red(fg.bold("WIPE-DATABASE PREVIEW")));
1611
+
1612
+ // -- data preview --
1613
+ if (wipePreview && wipePreview.collections.length > 0) {
1614
+ for (const c of wipePreview.collections) {
1615
+ console.log(
1616
+ ` ${fg.red("✕")} ${fg.bold(c.name)} — ${c.records} record(s) ` +
1617
+ fg.dim(
1618
+ `(${c.activeRecords} active, ${c.softDeletedRecords} soft-deleted)`,
1619
+ ),
1620
+ );
1621
+ }
1622
+ console.log(
1623
+ ` ${fg.amber("↳")} ${wipePreview.relationCount} relationship link(s) also removed`,
1624
+ );
1625
+ console.log(
1626
+ ` ${fg.green("↳")} Backup required at Media / ${wipePreview.backupFolder}`,
1627
+ );
1628
+ } else if (wipeError) {
1629
+ console.log(` ${fg.dim("Data wipe preview unavailable:")} ${wipeError}`);
1630
+ } else {
1631
+ console.log(
1632
+ ` ${fg.dim("No live records found for the named collections.")}`,
1633
+ );
1634
+ }
1635
+
1636
+ // -- schema preview (skip if no objects remain) --
1637
+ const hasSchemaChanges =
1638
+ keep.length > 0 && keep.length < artifact.objects.length;
1639
+ if (hasSchemaChanges) {
1640
+ const planRes = await callDataModelApi({
1641
+ target,
1642
+ path: "plan",
1643
+ method: "POST",
1644
+ body: { schema: pushArtifact, prune: true },
1645
+ });
1646
+ const plan = planRes["plan"] as MigrationPlan;
1647
+ console.log("");
1648
+ logStep("Schema plan (what promoting this draft would do):");
1649
+ printPlan(plan);
1650
+ } else {
1651
+ console.log("");
1652
+ logStep(
1653
+ keep.length === 0
1654
+ ? "No collections remain in config — schema draft will be left unchanged."
1655
+ : "No schema changes needed.",
1656
+ );
1657
+ }
1658
+
1659
+ if (opts.dryRun) {
1660
+ console.log("");
1661
+ logStep("--dry-run was set. Nothing was wiped or pushed.");
1662
+ return;
1663
+ }
1664
+
1665
+ // ── 3. Confirmation ──
1666
+ if (!opts.yes) {
1667
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1668
+ throw new Error(
1669
+ "wipe-database requires an interactive terminal or --yes.",
1670
+ );
1671
+ }
1672
+ const prompt = createInterface({
1673
+ input: process.stdin,
1674
+ output: process.stdout,
1675
+ });
1676
+ try {
1677
+ const answer = await prompt.question(
1678
+ `\nThis will delete records (with backup) and push a pruned schema draft. ${fg.red("This cannot be undone.")}\nType the collection name "` +
1679
+ `${removed[0]?.["nameSingular"] ?? ""}" to confirm: `,
1680
+ );
1681
+ if (answer.trim() !== (removed[0]?.["nameSingular"] ?? "")) {
1682
+ logStep("Cancelled.");
1683
+ return;
1684
+ }
1685
+ } finally {
1686
+ prompt.close();
1687
+ }
1688
+ }
1689
+
1690
+ // ── 4. Execute data wipe ──
1691
+ if (wipePreview && wipePreview.recordCount + wipePreview.relationCount > 0) {
1692
+ const backupPhrase = wipePreview.confirmationPhrase;
1693
+ logStep("Backing up and wiping records…");
1694
+ try {
1695
+ const wipeResult = (await callDataModelApi({
1696
+ target,
1697
+ path: "records/wipe",
1698
+ method: "POST",
1699
+ body: {
1700
+ collections: names,
1701
+ planToken: wipePreview.planToken,
1702
+ confirmation: backupPhrase,
1703
+ },
1704
+ })) as WipeCollectionsResult;
1705
+ console.log(
1706
+ ` ${fg.green("✓")} Backup: ${wipeResult.backup.name} (${wipeResult.backup.sizeBytes} bytes)`,
1707
+ );
1708
+ console.log(
1709
+ ` ${fg.green("✓")} Wiped ${wipeResult.deleted.records} record(s) and ${wipeResult.deleted.relations} relation(s).`,
1710
+ );
1711
+ } catch (err) {
1712
+ throw new Error(
1713
+ `Data wipe failed: ${err instanceof Error ? err.message : String(err)}`,
1714
+ );
1715
+ }
1716
+ } else {
1717
+ logStep("No records to wipe.");
1718
+ }
1719
+
1720
+ // ── 5. Push schema draft (skip if no objects remain) ──
1721
+ if (hasSchemaChanges) {
1722
+ const checksum = await computeDataModelChecksum(pushArtifact);
1723
+ logStep("Pushing pruned schema draft…");
1724
+ try {
1725
+ const pushRes = await callDataModelApi({
1726
+ target,
1727
+ path: "draft-schema",
1728
+ method: "POST",
1729
+ body: {
1730
+ schema: pushArtifact,
1731
+ checksum,
1732
+ note: `wipe-database: removed ${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}`,
1733
+ },
1734
+ });
1735
+ if (pushRes["noop"]) {
1736
+ logStep("Schema already matches — no draft update needed.");
1737
+ } else {
1738
+ logStep(
1739
+ `${fg.green("✓")} Uploaded draft v${String(pushRes["version"])}.`,
1740
+ );
1741
+ }
1742
+ } catch (err) {
1743
+ throw new Error(
1744
+ `Schema push failed: ${err instanceof Error ? err.message : String(err)}`,
1745
+ );
1746
+ }
1747
+ } else if (keep.length === 0) {
1748
+ logStep(
1749
+ fg.dim(
1750
+ "Schema draft unchanged — all collections were removed from the config. Remove or edit biab.data-model.config.ts to rebuild.",
1751
+ ),
1752
+ );
1753
+ }
1754
+
1755
+ console.log("");
1756
+ logStep(
1757
+ `${fg.green("✓")} Data wiped for ${removed.map((o) => String(o["nameSingular"] ?? "?")).join(", ")}.`,
1758
+ );
1759
+ }
1760
+
1761
+ // ── sync-content ─────────────────────────────────────────────────────
1762
+
1763
+ /**
1764
+ * Convert a basename like `value-props` or `value_props` to camelCase
1765
+ * (`valueProps`). Used as the default mapping when the consumer hasn't
1766
+ * supplied a `sectionAliases` entry. Pure identity for already-camelCased
1767
+ * names like `hero`. Matches the convention enforced by the package-API
1768
+ * sectionKey regex.
1769
+ */
1770
+ function toCamelCase(input: string): string {
1771
+ return input.replace(/[-_]+([a-zA-Z0-9])/g, (_m, c: string) =>
1772
+ c.toUpperCase(),
1773
+ );
1774
+ }
1775
+
1776
+ type DiscoveredItem = {
1777
+ pageKey: string;
1778
+ locale: string;
1779
+ sectionKey: string;
1780
+ filename: string;
1781
+ payload: unknown;
1782
+ };
1783
+
1784
+ type DiscoveryWarning = { kind: "skipped" | "unknown"; message: string };
1785
+
1786
+ /**
1787
+ * Resolve the list of `(pageKey, dir-on-disk)` entries to walk for a
1788
+ * given locale. If `pages` is supplied we substitute `{locale}` into
1789
+ * each entry's `dir`. Otherwise we fall back to auto-discovery:
1790
+ * iterate `<rootDir>/<locale>/*` and use the leaf name as pageKey.
1791
+ */
1792
+ function resolvePageDirs(input: {
1793
+ rootDir: string;
1794
+ locale: string;
1795
+ pages: SiteMarketingContentSyncConfig["pages"];
1796
+ }): Array<{ pageKey: string; absDir: string }> {
1797
+ const { rootDir, locale, pages } = input;
1798
+ if (pages && pages.length > 0) {
1799
+ return pages.map((p) => ({
1800
+ pageKey: p.pageKey,
1801
+ absDir: join(rootDir, p.dir.replaceAll("{locale}", locale)),
1802
+ }));
1803
+ }
1804
+ const localeDir = join(rootDir, locale);
1805
+ if (!existsSync(localeDir) || !statSync(localeDir).isDirectory()) {
1806
+ return [];
1807
+ }
1808
+ const out: Array<{ pageKey: string; absDir: string }> = [];
1809
+ for (const dirent of readdirSync(localeDir)) {
1810
+ const abs = join(localeDir, dirent);
1811
+ if (statSync(abs).isDirectory()) {
1812
+ out.push({ pageKey: dirent, absDir: abs });
1813
+ }
1814
+ }
1815
+ return out;
1816
+ }
1817
+
1818
+ function discoverContentFiles(input: {
1819
+ rootDir: string;
1820
+ cfg: SiteMarketingContentSyncConfig;
1821
+ knownSectionKeys: ReadonlySet<string>;
1822
+ }): { items: DiscoveredItem[]; warnings: DiscoveryWarning[] } {
1823
+ const { rootDir, cfg, knownSectionKeys } = input;
1824
+ const items: DiscoveredItem[] = [];
1825
+ const warnings: DiscoveryWarning[] = [];
1826
+
1827
+ const locales = cfg.locales && cfg.locales.length > 0 ? cfg.locales : ["en"];
1828
+ const sectionAliases = cfg.sectionAliases ?? {};
1829
+ const skip = new Set(cfg.skipSections ?? []);
1830
+
1831
+ for (const locale of locales) {
1832
+ const pageDirs = resolvePageDirs({ rootDir, locale, pages: cfg.pages });
1833
+ if (pageDirs.length === 0) {
1834
+ warnings.push({
1835
+ kind: "skipped",
1836
+ message: `No page directories resolved for locale "${locale}". Check contentSync.pages / rootDir.`,
1837
+ });
1838
+ continue;
1839
+ }
1840
+
1841
+ for (const { pageKey, absDir } of pageDirs) {
1842
+ if (!existsSync(absDir) || !statSync(absDir).isDirectory()) {
1843
+ warnings.push({
1844
+ kind: "skipped",
1845
+ message: `Page dir not found: ${absDir} (pageKey="${pageKey}")`,
1846
+ });
1847
+ continue;
1848
+ }
1849
+
1850
+ for (const fileDirent of readdirSync(absDir)) {
1851
+ if (!fileDirent.endsWith(".json")) continue;
1852
+ const basename = fileDirent.slice(0, -".json".length);
1853
+ // Scoped alias wins over global alias wins over camelCase
1854
+ // fallback. Lets `about/page` map to `aboutPage` while
1855
+ // `services/page` maps to `servicesPage` from the same
1856
+ // file basename.
1857
+ const aliased =
1858
+ sectionAliases[`${pageKey}/${basename}`] ?? sectionAliases[basename];
1859
+ const sectionKey = aliased ?? toCamelCase(basename);
1860
+
1861
+ if (skip.has(sectionKey)) {
1862
+ warnings.push({
1863
+ kind: "skipped",
1864
+ message: `Skipping ${pageKey}/${basename}.json (sectionKey "${sectionKey}" is in skipSections).`,
1865
+ });
1866
+ continue;
1867
+ }
1868
+ if (!knownSectionKeys.has(sectionKey)) {
1869
+ warnings.push({
1870
+ kind: "unknown",
1871
+ message: `Skipping ${pageKey}/${basename}.json — no matching schema section "${sectionKey}". Add to contentSync.sectionAliases?`,
1872
+ });
1873
+ continue;
1874
+ }
1875
+
1876
+ const filePath = join(absDir, fileDirent);
1877
+ const raw = readFileSync(filePath, "utf8");
1878
+ let payload: unknown;
1879
+ try {
1880
+ payload = JSON.parse(raw);
1881
+ } catch (err) {
1882
+ warnings.push({
1883
+ kind: "unknown",
1884
+ message: `Invalid JSON in ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
1885
+ });
1886
+ continue;
1887
+ }
1888
+
1889
+ items.push({
1890
+ pageKey,
1891
+ locale,
1892
+ sectionKey,
1893
+ filename: filePath,
1894
+ payload,
1895
+ });
1896
+ }
1897
+ }
1898
+ }
1899
+
1900
+ return { items, warnings };
1901
+ }
1902
+
1903
+ type BulkResultItem =
1904
+ | {
1905
+ ok: true;
1906
+ pageKey: string;
1907
+ locale: string;
1908
+ sectionKey: string;
1909
+ version: number;
1910
+ }
1911
+ | {
1912
+ ok: false;
1913
+ pageKey: string;
1914
+ locale: string;
1915
+ sectionKey: string;
1916
+ error: string;
1917
+ };
1918
+
1919
+ async function postSectionBatch(input: {
1920
+ baseUrl: string;
1921
+ apiKey: string;
1922
+ siteId: string;
1923
+ items: ReadonlyArray<{
1924
+ pageKey: string;
1925
+ locale: string;
1926
+ sectionKey: string;
1927
+ payload: unknown;
1928
+ }>;
1929
+ validate: boolean;
1930
+ note: string | null;
1931
+ }): Promise<
1932
+ | { ok: true; results: BulkResultItem[] }
1933
+ | { ok: false; status: number; message: string }
1934
+ > {
1935
+ const target = `${input.baseUrl}/sites/${encodeURIComponent(input.siteId)}/marketing/sections`;
1936
+ const res = await fetch(target, {
1937
+ method: "POST",
1938
+ headers: {
1939
+ "content-type": "application/json",
1940
+ authorization: `Bearer ${input.apiKey}`,
1941
+ },
1942
+ body: JSON.stringify({
1943
+ items: input.items,
1944
+ validate: input.validate,
1945
+ note: input.note,
1946
+ }),
1947
+ });
1948
+ const bodyText = await res.text();
1949
+ if (!res.ok) {
1950
+ let message = `HTTP ${res.status} ${res.statusText}`.trim();
1951
+ try {
1952
+ const parsed = JSON.parse(bodyText) as { message?: string };
1953
+ if (parsed.message) message = parsed.message;
1954
+ } catch {
1955
+ const looksHtml = /^\s*<(?:!doctype|html|head|body)/i.test(bodyText);
1956
+ if (looksHtml) {
1957
+ 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.)`;
1958
+ } else if (bodyText.trim()) {
1959
+ message = `${message} — ${bodyText.slice(0, 240).trim()}`;
1960
+ }
1961
+ }
1962
+ return { ok: false, status: res.status, message };
1963
+ }
1964
+ // Successful 2xx response. Defend against an HTML body here too —
1965
+ // some edge proxies (Vercel preview deployments, CDNs) can wrap
1966
+ // the JSON in an HTML shell if the route lookup actually missed.
1967
+ try {
1968
+ const parsed = JSON.parse(bodyText) as { results?: BulkResultItem[] };
1969
+ return { ok: true, results: parsed.results ?? [] };
1970
+ } catch {
1971
+ return {
1972
+ ok: false,
1973
+ status: res.status,
1974
+ message: `Server replied 200 OK but the body wasn't JSON — first 240 chars: ${bodyText.slice(0, 240).trim()}`,
1975
+ };
1976
+ }
1977
+ }
1978
+
1979
+ async function cmdSyncContent(opts: {
1980
+ configPath: string;
1981
+ env: Record<string, string>;
1982
+ dryRun: boolean;
1983
+ lax: boolean;
1984
+ note: string | null;
1985
+ }) {
1986
+ const schema = await loadConfig(opts.configPath);
1987
+ const artifact = schema.toJSONSchema();
1988
+ const cfg = schema.contentSync;
1989
+
1990
+ if (!cfg) {
1991
+ throw new Error(
1992
+ `${opts.configPath} doesn't declare a \`contentSync\` config. Add one to your defineSiteMarketingSchema({...}) call, for example: contentSync: { rootDir: "src/content", sectionAliases: { ... } }`,
1993
+ );
1994
+ }
1995
+
1996
+ const configDir = dirname(opts.configPath);
1997
+ const rootDir = isAbsolute(cfg.rootDir)
1998
+ ? cfg.rootDir
1999
+ : resolve(configDir, cfg.rootDir);
2000
+
2001
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
2002
+ throw new Error(
2003
+ `contentSync.rootDir resolves to "${rootDir}", which does not exist or is not a directory.`,
2004
+ );
2005
+ }
2006
+
2007
+ const knownSectionKeys = new Set(Object.keys(artifact.sections));
2008
+
2009
+ const { items, warnings } = discoverContentFiles({
2010
+ rootDir,
2011
+ cfg,
2012
+ knownSectionKeys,
2013
+ });
2014
+
2015
+ logStep(
2016
+ `Discovered ${fg.bold(String(items.length))} section file(s) under ${rootDir}.`,
2017
+ );
2018
+ for (const w of warnings) {
2019
+ console.log(` ${fg.amber("⚠")} ${fg.dim(w.message)}`);
2020
+ }
2021
+
2022
+ // Reject content syncs that target a BIAB-reserved pageKey. We dedupe
2023
+ // so we report each offending key once rather than per-file.
2024
+ const reservedKeys = Array.from(
2025
+ new Set(
2026
+ items
2027
+ .filter((it) => isReservedPagePath(it.pageKey))
2028
+ .map((it) => it.pageKey),
2029
+ ),
2030
+ );
2031
+ if (reservedKeys.length > 0) {
2032
+ for (const key of reservedKeys) {
2033
+ logError(reservedPagePathErrorMessage(key));
2034
+ }
2035
+ throw new Error(
2036
+ `Refusing to sync content: ${reservedKeys.length} reserved pageKey(s) found in discovered files.`,
2037
+ );
2038
+ }
2039
+
2040
+ if (items.length === 0) {
2041
+ logStep("Nothing to sync.");
2042
+ return;
2043
+ }
2044
+
2045
+ if (opts.dryRun) {
2046
+ logStep("--dry-run was set, skipping upload.");
2047
+ for (const it of items) {
2048
+ console.log(
2049
+ ` ${fg.dim("→")} ${it.locale}/${it.pageKey}/${it.sectionKey} ${fg.dim(`(from ${it.filename})`)}`,
2050
+ );
2051
+ }
2052
+ return;
2053
+ }
2054
+
2055
+ const apiKey = opts.env["BIAB_API_KEY"];
2056
+ const siteId = opts.env["BIAB_SITE_ID"];
2057
+ const rawBase =
2058
+ opts.env["BIAB_PACKAGE_API_BASE_URL"] ??
2059
+ opts.env["NEXT_PUBLIC_BIAB_PACKAGE_API_BASE_URL"];
2060
+ if (!apiKey) throw new Error("Missing BIAB_API_KEY in env or .env.local.");
2061
+ if (!siteId) throw new Error("Missing BIAB_SITE_ID in env or .env.local.");
2062
+ if (!rawBase) {
2063
+ throw new Error(
2064
+ "Missing BIAB_PACKAGE_API_BASE_URL in env or .env.local (e.g. https://your-host.tld/api/package/v1).",
2065
+ );
2066
+ }
2067
+
2068
+ const baseUrl = normalizeBaseUrl(rawBase);
2069
+ const BATCH_SIZE = 64; // well under the server's 256 cap; smaller = faster failure feedback
2070
+ const batches: DiscoveredItem[][] = [];
2071
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
2072
+ batches.push(items.slice(i, i + BATCH_SIZE));
2073
+ }
2074
+
2075
+ let okCount = 0;
2076
+ let failCount = 0;
2077
+ const failures: BulkResultItem[] = [];
2078
+
2079
+ logStep(
2080
+ `POST ${fg.dim(`${baseUrl}/sites/${siteId}/marketing/sections`)} in ${batches.length} batch(es)…`,
2081
+ );
2082
+
2083
+ for (let i = 0; i < batches.length; i++) {
2084
+ const batch = batches[i]!;
2085
+ const res = await postSectionBatch({
2086
+ baseUrl,
2087
+ apiKey,
2088
+ siteId,
2089
+ items: batch.map((b) => ({
2090
+ pageKey: b.pageKey,
2091
+ locale: b.locale,
2092
+ sectionKey: b.sectionKey,
2093
+ payload: b.payload,
2094
+ })),
2095
+ validate: !opts.lax,
2096
+ note: opts.note,
2097
+ });
2098
+ if (!res.ok) {
2099
+ logError(
2100
+ `Batch ${i + 1}/${batches.length} failed (${res.status}): ${res.message}`,
2101
+ );
2102
+ process.exitCode = 1;
2103
+ return;
2104
+ }
2105
+ for (const r of res.results) {
2106
+ if (r.ok) {
2107
+ okCount += 1;
2108
+ } else {
2109
+ failCount += 1;
2110
+ failures.push(r);
2111
+ }
2112
+ }
2113
+ logStep(
2114
+ `Batch ${i + 1}/${batches.length}: ${fg.green(`✓ ${res.results.filter((r) => r.ok).length}`)} ${
2115
+ res.results.some((r) => !r.ok)
2116
+ ? fg.red(`✕ ${res.results.filter((r) => !r.ok).length}`)
2117
+ : ""
2118
+ }`.trimEnd(),
2119
+ );
2120
+ }
2121
+
2122
+ console.log("");
2123
+ if (failCount === 0) {
2124
+ logStep(
2125
+ `${fg.green(`✓ Synced ${okCount} section(s).`)} ${fg.dim("Check the dashboard at Site Builder → Site Data → Pages.")}`,
2126
+ );
2127
+ } else {
2128
+ logStep(
2129
+ `${fg.green(`✓ ${okCount} succeeded`)} ${fg.red(`✕ ${failCount} failed`)}`,
2130
+ );
2131
+ for (const f of failures) {
2132
+ if (f.ok) continue;
2133
+ console.log(
2134
+ ` ${fg.red("✕")} ${f.locale}/${f.pageKey}/${f.sectionKey} — ${fg.dim(f.error)}`,
2135
+ );
2136
+ }
2137
+ console.log("");
2138
+ console.log(
2139
+ fg.dim(
2140
+ ` Tip: pass --lax to skip validation for this run (writes raw JSON without enforcing the published schema).`,
2141
+ ),
2142
+ );
2143
+ process.exitCode = 1;
2144
+ }
2145
+ }
2146
+
2147
+ function printHelp(): void {
2148
+ console.log(`biab-dev — Business In A Box developer CLI
2149
+
2150
+ Run via your package runner — biab-dev is a package bin, not a global:
2151
+ npx biab-dev <command> (or: pnpm exec biab-dev <command>)
2152
+
2153
+ WEBSITE CONTENT (biab.config.ts)
2154
+ npx biab-dev sync-schema [--config path] [--dry-run]
2155
+ npx biab-dev sync-content [--config path] [--dry-run] [--lax] [--note "first import"]
2156
+ npx biab-dev print-schema [--config path]
2157
+
2158
+ CUSTOM DATABASE (biab.data-model.config.ts)
2159
+ npx biab-dev view-data-model [--json] [--diff] [--out path] [--prune]
2160
+ npx biab-dev print-data-model [--config path]
2161
+ npx biab-dev export-data-model [--out path] [--schema-only]
2162
+ npx biab-dev export-graphql-schema [--out path] [--include-standard]
2163
+ npx biab-dev sync-data-model [--config path] [--dry-run] [--prune] [--forms [slugs]] [--no-forms] [--note "..."]
2164
+ npx biab-dev sync-records [--records-dir path] [--dry-run]
2165
+ npx biab-dev wipe-collections --collections name-a,name-b [--dry-run]
2166
+ npx biab-dev wipe-database --collections name-a,name-b [--config path] [--dry-run] [--yes]
2167
+ npx biab-dev wipe-database --all [--config path] [--dry-run] [--yes]
2168
+
2169
+ view what's LIVE in BIAB right now (the dashboard can change it too, so
2170
+ this is not just "what I last pushed")
2171
+ print what your local config resolves to — no network
2172
+ export pull the WHOLE custom database — schema + records + relations — into
2173
+ one portable snapshot (default: ./biab.data-model.snapshot.json).
2174
+ Deterministic order, so committing it gives a git-diffable history an
2175
+ AI agent can read without querying BIAB. Re-seeds a fresh org via
2176
+ sync-data-model (schema) + sync-records (rows). --schema-only skips
2177
+ the row scan. Needs metadata:read_schema + metadata:read_records.
2178
+ export-graphql-schema
2179
+ render a GraphQL SDL schema of your custom collections (default:
2180
+ ./biab.data-model.graphql) — every object a type, relations as lists,
2181
+ SELECTs as enums where valid. Feed it to GraphQL codegen or commit it
2182
+ as a typed contract. Schema only; this does not run a GraphQL server.
2183
+ --include-standard also emits standard CRM types. Needs
2184
+ metadata:read_schema.
2185
+ sync push your config to the draft slot. Prints the migration plan first;
2186
+ nothing changes until you promote in the dashboard.
2187
+
2188
+ seed upsert rows from JSON (default dir: ./biab-records). Each file is an
2189
+ array of { object, universalIdentifier, values, relations? }; identity
2190
+ is what makes re-running converge instead of duplicating. relations
2191
+ links rows by the TARGET row's universalIdentifier, e.g.
2192
+ { "tech": "<uid>" } or { "tags": ["<uid>", "<uid>"] } — synced after
2193
+ all records exist, so files can reference each other in any order.
2194
+
2195
+ wipe hard-delete records (not schemas) from only the explicitly named
2196
+ custom collections. The CLI previews live counts, requires two
2197
+ interactive confirmations, writes + verifies a JSON backup under
2198
+ Media / Database Backups / Collection Wipes, then deletes. There is
2199
+ intentionally no --all or --yes bypass.
2200
+
2201
+ wipe-database
2202
+ developer shortcut: wipes records AND removes the named collections
2203
+ (or all collections with --all) from your schema draft. Combines
2204
+ data deletion (with backup) with a pruned schema push; requires
2205
+ dashboard promotion to finalise the schema change. Uses a single
2206
+ confirmation with --yes to skip the interactive prompt.
2207
+
2208
+ --diff show the migration plan for your local config without pushing
2209
+ --out path write a config file from the live model (adopt an existing
2210
+ dashboard-built model as code)
2211
+ --prune reconcile fully: remove anything live but absent from the
2212
+ config. Everything it removes is a destructive change.
2213
+ --forms include all generated form drafts (the non-interactive default).
2214
+ --forms a,b include only the generated forms with those slugs/source keys.
2215
+ --no-forms push only collections. Generated forms are created inactive on
2216
+ promotion and must be activated separately in the dashboard.
2217
+
2218
+ npx biab-dev help
2219
+
2220
+ Environment (read from process.env, .env, or .env.local in the cwd):
2221
+ BIAB_API_KEY API key. Website flow needs marketing:write_schema
2222
+ / marketing:write_content. Data-model flow needs
2223
+ metadata:read_schema (view/plan) and
2224
+ metadata:write_schema (push) or
2225
+ metadata:write_records (seed/wipe).
2226
+ BIAB_SITE_ID UUID of the site to publish to
2227
+ BIAB_PACKAGE_API_BASE_URL Host URL (any path; the CLI canonicalises
2228
+ to "<origin>/api/package/v1" automatically).
2229
+ Use the APEX host — e.g. https://biab.app, NOT
2230
+ https://www.biab.app. A www→apex redirect makes
2231
+ fetch drop the Authorization header (it's a
2232
+ cross-origin hop), and the server then reports
2233
+ "Missing bearer API key" as if your key were
2234
+ absent.
2235
+
2236
+ Schema pushes cannot change what an org's database or website serves: they
2237
+ write a draft and a human promotes it in the dashboard. wipe-collections is
2238
+ the explicit record-data exception, guarded by preview, verified backup, and
2239
+ two interactive confirmations. wipe-database combines a data wipe with a
2240
+ pruned schema push for developer convenience.
2241
+
2242
+ Config files (override either with --config path):
2243
+ biab.config.ts → defineSiteMarketingSchema (website content)
2244
+ biab.data-model.config.ts → createFormSchemaOrchestrator / createSchema
2245
+
2246
+ sync-content also reads the optional \`contentSync\` field on your
2247
+ defineSiteMarketingSchema({...}) call. Minimal form (default layout
2248
+ \`<rootDir>/<locale>/<page>/<section>.json\`):
2249
+
2250
+ contentSync: {
2251
+ rootDir: "src/content",
2252
+ sectionAliases: { marquee: "trustMarquee" },
2253
+ locales: ["en"],
2254
+ skipSections: ["gallery", "blogFeed"], // managed by other surfaces
2255
+ }
2256
+
2257
+ Explicit form (when pages aren't direct children of the locale dir, or
2258
+ when shared/cross-page sections live in a sibling dir):
2259
+
2260
+ contentSync: {
2261
+ rootDir: "src/content",
2262
+ locales: ["en"],
2263
+ pages: [
2264
+ { dir: "{locale}/shared", pageKey: "shared" },
2265
+ { dir: "{locale}/pages/home", pageKey: "home" },
2266
+ { dir: "{locale}/pages/about", pageKey: "about" },
2267
+ ],
2268
+ sectionAliases: {
2269
+ "home/marquee": "trustMarquee", // scoped: only inside home/
2270
+ "about/page": "aboutPage", // scoped
2271
+ "service-areas": "serviceAreas", // global
2272
+ },
2273
+ }
2274
+ `);
2275
+ }
2276
+
2277
+ type ParsedArgs = {
2278
+ command: Command;
2279
+ configOverride?: string;
2280
+ dryRun: boolean;
2281
+ lax: boolean;
2282
+ note: string | null;
2283
+ json: boolean;
2284
+ diff: boolean;
2285
+ out: string | null;
2286
+ prune: boolean;
2287
+ recordsDir: string;
2288
+ forms: string;
2289
+ collections: string[];
2290
+ yes: boolean;
2291
+ all: boolean;
2292
+ schemaOnly: boolean;
2293
+ includeStandard: boolean;
2294
+ };
2295
+
2296
+ function parseArgs(argv: string[]): ParsedArgs {
2297
+ const args = argv.slice(2);
2298
+ const out: ParsedArgs = {
2299
+ command: "help",
2300
+ dryRun: false,
2301
+ lax: false,
2302
+ note: null,
2303
+ json: false,
2304
+ diff: false,
2305
+ out: null,
2306
+ prune: false,
2307
+ recordsDir: "biab-records",
2308
+ forms: "prompt",
2309
+ collections: [],
2310
+ yes: false,
2311
+ all: false,
2312
+ schemaOnly: false,
2313
+ includeStandard: false,
2314
+ };
2315
+ for (let i = 0; i < args.length; i++) {
2316
+ const arg = args[i];
2317
+ if (!arg) continue;
2318
+ if (i === 0 && (COMMANDS as readonly string[]).includes(arg)) {
2319
+ out.command = arg as Command;
2320
+ continue;
2321
+ }
2322
+ if (arg === "--config" && i + 1 < args.length) {
2323
+ const next = args[++i];
2324
+ if (next) out.configOverride = next;
2325
+ continue;
2326
+ }
2327
+ if (arg === "--dry-run") {
2328
+ out.dryRun = true;
2329
+ continue;
2330
+ }
2331
+ if (arg === "--lax") {
2332
+ out.lax = true;
2333
+ continue;
2334
+ }
2335
+ if (arg === "--json") {
2336
+ out.json = true;
2337
+ continue;
2338
+ }
2339
+ if (arg === "--diff") {
2340
+ out.diff = true;
2341
+ continue;
2342
+ }
2343
+ if (arg === "--prune") {
2344
+ out.prune = true;
2345
+ continue;
2346
+ }
2347
+ if (arg === "--schema-only") {
2348
+ out.schemaOnly = true;
2349
+ continue;
2350
+ }
2351
+ if (arg === "--include-standard") {
2352
+ out.includeStandard = true;
2353
+ continue;
2354
+ }
2355
+ if (arg === "--no-forms") {
2356
+ out.forms = "none";
2357
+ continue;
2358
+ }
2359
+ if (arg === "--forms") {
2360
+ const next = args[i + 1];
2361
+ if (next && !next.startsWith("--")) {
2362
+ i++;
2363
+ out.forms = `list:${next}`;
2364
+ } else {
2365
+ out.forms = "all";
2366
+ }
2367
+ continue;
2368
+ }
2369
+ if (arg === "--records-dir" && i + 1 < args.length) {
2370
+ const next = args[++i];
2371
+ if (next) out.recordsDir = next;
2372
+ continue;
2373
+ }
2374
+ if (arg === "--collections" && i + 1 < args.length) {
2375
+ const next = args[++i];
2376
+ if (next) {
2377
+ out.collections.push(...next.split(",").map((value) => value.trim()));
2378
+ }
2379
+ continue;
2380
+ }
2381
+ if (arg === "--out" && i + 1 < args.length) {
2382
+ const next = args[++i];
2383
+ if (next) out.out = next;
2384
+ continue;
2385
+ }
2386
+ if (arg === "--note" && i + 1 < args.length) {
2387
+ const next = args[++i];
2388
+ if (next) out.note = next;
2389
+ continue;
2390
+ }
2391
+ if (arg === "--yes") {
2392
+ out.yes = true;
2393
+ continue;
2394
+ }
2395
+ if (arg === "--all") {
2396
+ out.all = true;
2397
+ continue;
2398
+ }
2399
+ if (arg === "--help" || arg === "-h") {
2400
+ out.command = "help";
2401
+ break;
2402
+ }
2403
+ }
2404
+ return out;
2405
+ }
2406
+
2407
+ async function main(): Promise<void> {
2408
+ const cwd = process.cwd();
2409
+ const {
2410
+ command,
2411
+ configOverride,
2412
+ dryRun,
2413
+ lax,
2414
+ note,
2415
+ json,
2416
+ diff,
2417
+ out,
2418
+ prune,
2419
+ recordsDir,
2420
+ forms,
2421
+ collections,
2422
+ yes,
2423
+ all,
2424
+ schemaOnly,
2425
+ includeStandard,
2426
+ } = parseArgs(process.argv);
2427
+ if (command === "help") {
2428
+ printHelp();
2429
+ return;
2430
+ }
2431
+ const env = loadEnv(cwd);
2432
+
2433
+ // Data-model commands read their own config file (D9) — and `view` needs
2434
+ // no local config at all unless --diff/--out is in play, so resolve lazily.
2435
+ if (command === "view-data-model") {
2436
+ await cmdViewDataModel({
2437
+ env,
2438
+ json,
2439
+ diff,
2440
+ out,
2441
+ configOverride,
2442
+ cwd,
2443
+ prune,
2444
+ });
2445
+ return;
2446
+ }
2447
+ if (command === "print-data-model") {
2448
+ await cmdPrintDataModel({
2449
+ configPath: findDataModelConfigPath(cwd, configOverride),
2450
+ });
2451
+ return;
2452
+ }
2453
+ if (command === "export-data-model") {
2454
+ await cmdExportDataModel({ env, out, schemaOnly, cwd });
2455
+ return;
2456
+ }
2457
+ if (command === "export-graphql-schema") {
2458
+ await cmdExportGraphqlSchema({ env, out, includeStandard, cwd });
2459
+ return;
2460
+ }
2461
+ if (command === "sync-records") {
2462
+ await cmdSyncRecords({ env, cwd, rootDir: recordsDir, dryRun });
2463
+ return;
2464
+ }
2465
+ if (command === "wipe-collections") {
2466
+ await cmdWipeCollections({ env, collections, dryRun });
2467
+ return;
2468
+ }
2469
+ if (command === "wipe-database") {
2470
+ await cmdWipeDatabase({
2471
+ configPath: findDataModelConfigPath(cwd, configOverride),
2472
+ env,
2473
+ collections,
2474
+ dryRun,
2475
+ yes,
2476
+ all,
2477
+ });
2478
+ return;
2479
+ }
2480
+ if (command === "sync-data-model") {
2481
+ await cmdSyncDataModel({
2482
+ configPath: findDataModelConfigPath(cwd, configOverride),
2483
+ env,
2484
+ dryRun,
2485
+ prune,
2486
+ note,
2487
+ forms,
2488
+ });
2489
+ return;
2490
+ }
2491
+
2492
+ const configPath = findConfigPath(cwd, configOverride);
2493
+ if (command === "print-schema") {
2494
+ await cmdPrintSchema({ configPath });
2495
+ return;
2496
+ }
2497
+ if (command === "sync-schema") {
2498
+ await cmdSyncSchema({ configPath, env, dryRun });
2499
+ return;
2500
+ }
2501
+ if (command === "sync-content") {
2502
+ await cmdSyncContent({ configPath, env, dryRun, lax, note });
2503
+ return;
2504
+ }
2505
+ printHelp();
2506
+ }
2507
+
2508
+ main().catch((err) => {
2509
+ logError(err instanceof Error ? err.message : String(err));
2510
+ process.exitCode = 1;
2511
+ });
2512
+
2513
+ // Silence "createRequire is unused" since some bundlers strip the static
2514
+ // import above; keep it as a runtime hook in case downstream needs to
2515
+ // resolve relative paths the same way the loader would.
2516
+ void createRequire(import.meta.url);
2517
+ void dirname;