@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.
- package/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- 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;
|