@mondaydotcomorg/z2h-cli 0.25.9 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/commands/backend.js +2 -2
  2. package/dist/commands/deploy.d.ts +1 -0
  3. package/dist/commands/deploy.d.ts.map +1 -1
  4. package/dist/commands/deploy.js +19 -3
  5. package/dist/commands/generate.d.ts +12 -0
  6. package/dist/commands/generate.d.ts.map +1 -0
  7. package/dist/commands/generate.js +190 -0
  8. package/dist/commands/templates.d.ts +7 -0
  9. package/dist/commands/templates.d.ts.map +1 -0
  10. package/dist/commands/templates.js +23 -0
  11. package/dist/esm/commands/deploy.d.ts +1 -0
  12. package/dist/esm/commands/deploy.d.ts.map +1 -1
  13. package/dist/esm/commands/deploy.mjs +19 -4
  14. package/dist/esm/commands/generate.d.ts +12 -0
  15. package/dist/esm/commands/generate.d.ts.map +1 -0
  16. package/dist/esm/commands/generate.mjs +182 -0
  17. package/dist/esm/commands/templates.d.ts +7 -0
  18. package/dist/esm/commands/templates.d.ts.map +1 -0
  19. package/dist/esm/commands/templates.mjs +21 -0
  20. package/dist/esm/index.mjs +16 -1
  21. package/dist/esm/util/dashboard-templates/load-catalog.d.ts +22 -0
  22. package/dist/esm/util/dashboard-templates/load-catalog.d.ts.map +1 -0
  23. package/dist/esm/util/dashboard-templates/load-catalog.mjs +55 -0
  24. package/dist/esm/util/dashboard-templates/patch-bundle.d.ts +12 -0
  25. package/dist/esm/util/dashboard-templates/patch-bundle.d.ts.map +1 -0
  26. package/dist/esm/util/dashboard-templates/patch-bundle.mjs +108 -0
  27. package/dist/index.js +16 -1
  28. package/dist/util/app-name.js +2 -2
  29. package/dist/util/dashboard-templates/load-catalog.d.ts +22 -0
  30. package/dist/util/dashboard-templates/load-catalog.d.ts.map +1 -0
  31. package/dist/util/dashboard-templates/load-catalog.js +64 -0
  32. package/dist/util/dashboard-templates/patch-bundle.d.ts +12 -0
  33. package/dist/util/dashboard-templates/patch-bundle.d.ts.map +1 -0
  34. package/dist/util/dashboard-templates/patch-bundle.js +116 -0
  35. package/package.json +4 -2
  36. package/src/commands/deploy.ts +24 -5
  37. package/src/commands/generate.ts +225 -0
  38. package/src/commands/templates.ts +20 -0
  39. package/src/index.ts +36 -1
  40. package/src/util/dashboard-templates/load-catalog.ts +80 -0
  41. package/src/util/dashboard-templates/patch-bundle.ts +127 -0
@@ -0,0 +1,225 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { execa } from 'execa';
5
+ import { pathExists, readdir } from 'fs-extra';
6
+ import { assertWorkspace } from '../util/workspace';
7
+ import { validateAppName, cleanAppName, mfAppName } from '../util/app-name';
8
+ import { fetchApp } from '../util/broker/app';
9
+ import { patchBuiltBundle, patchOneFile, type PatchedConfig } from '../util/dashboard-templates/patch-bundle';
10
+ import { findTemplate, type DashboardTemplateEntry } from '../util/dashboard-templates/load-catalog';
11
+ import { initRepo, defaultS3Remote } from '../util/git/repo';
12
+ import { info, output } from '../util/logger';
13
+ import type {
14
+ DashboardTemplateSetupAPI,
15
+ ToFrontendConfigHook,
16
+ SetupBackendHook,
17
+ } from '@mondaydotcomorg/z2h-shared-utils/dashboard-template-hooks';
18
+
19
+ export interface GenerateOptions {
20
+ template: string;
21
+ input: string; // JSON string, shape defined entirely by the template's own configSchema
22
+ }
23
+
24
+ // Must match zth-backend-runner's route pattern — fail at generate time, not at first invocation.
25
+ const HANDLER_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
26
+
27
+ function createSetupAPI(handlersDir: string): DashboardTemplateSetupAPI {
28
+ return {
29
+ createBackend(handlerName, content) {
30
+ if (!HANDLER_NAME_RE.test(handlerName)) {
31
+ throw new Error(
32
+ `setupBackend: invalid handler name "${handlerName}" — must be lowercase snake_case, matching ${HANDLER_NAME_RE}`
33
+ );
34
+ }
35
+ fs.writeFileSync(path.join(handlersDir, `${handlerName}.js`), content);
36
+ },
37
+ };
38
+ }
39
+
40
+ // Shape is entirely template-defined — validated wholesale against the template's own zod schema,
41
+ // not against any fields z2h-cli itself knows about.
42
+ function parseInput(raw: string, template: DashboardTemplateEntry): PatchedConfig {
43
+ let parsed: unknown;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ throw new Error('--input must be valid JSON');
48
+ }
49
+
50
+ const result = template.validateConfig(parsed);
51
+ if (!result.success) {
52
+ throw new Error(`--input failed validation against template "${template.id}": ${result.error}`);
53
+ }
54
+
55
+ return result.data as PatchedConfig;
56
+ }
57
+
58
+ async function isDirEmpty(dir: string): Promise<boolean> {
59
+ if (!(await pathExists(dir))) {
60
+ return true;
61
+ }
62
+ return (await readdir(dir)).length === 0;
63
+ }
64
+
65
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
66
+
67
+ // A real npm publish already resolves a template's workspace:* deps to real versions before
68
+ // packaging (see trident-monorepo's replaceWorkspaceProtocolInPackage) — this is a no-op there.
69
+ // In this repo's own dev setup (reached via yarn link, unpublished), that never ran, so we resolve
70
+ // it ourselves via require.resolve, same as node would, anchored to this file not to cwd.
71
+ function resolveWorkspaceProtocolDeps(appDir: string): void {
72
+ const pkgPath = path.join(appDir, 'package.json');
73
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
74
+ const nodeRequire = createRequire(import.meta.url);
75
+ let changed = false;
76
+
77
+ for (const field of DEPENDENCY_FIELDS) {
78
+ const deps = pkg[field];
79
+ if (!deps) {
80
+ continue;
81
+ }
82
+ for (const [depName, range] of Object.entries(deps) as [string, string][]) {
83
+ if (!range.startsWith('workspace:')) {
84
+ continue;
85
+ }
86
+ const protocol = range.slice('workspace:'.length);
87
+ const depPkgPath = nodeRequire.resolve(`${depName}/package.json`);
88
+ const actualVersion = (JSON.parse(fs.readFileSync(depPkgPath, 'utf8')) as { version: string }).version;
89
+ deps[depName] = protocol === '*' ? actualVersion : `${protocol}${actualVersion}`;
90
+ changed = true;
91
+ }
92
+ }
93
+
94
+ if (changed) {
95
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
96
+ }
97
+ }
98
+
99
+ // Only the top-level package.json needs renaming — deployCommand() reads its "name" to derive
100
+ // the registered app name/URL slug. `.zth/package.json` is NOT patched here: `generate` never
101
+ // rebuilds (deploy runs with --prebuilt-dir), so it's never read; a real rebuild later would
102
+ // regenerate it from the top-level name anyway (see shadow/sync.ts's writeShadowPkg).
103
+ function patchPackageJson(appDir: string, appName: string): void {
104
+ const pkgPath = path.join(appDir, 'package.json');
105
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
106
+ pkg.name = appName;
107
+ delete pkg.devDependencies?.['@mondaydotcomorg/z2h-shared-utils'];
108
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
109
+ resolveWorkspaceProtocolDeps(appDir);
110
+ }
111
+
112
+ // The copied template already ships src/templateConfig.ts as a placeholder — real, typed source
113
+ // kept buildable on its own so the template can be verified independently of the generator.
114
+ function patchConfigSource(appDir: string, frontendConfig: PatchedConfig): void {
115
+ const configPath = path.join(appDir, 'src', 'templateConfig.ts');
116
+ const placeholder = fs.readFileSync(configPath, 'utf8');
117
+ const patched = patchOneFile(placeholder, frontendConfig);
118
+ if (patched === null) {
119
+ throw new Error(`generate: ${configPath} has no Z2H_CONFIG_V1 anchor — template's placeholder is stale`);
120
+ }
121
+ fs.writeFileSync(configPath, patched);
122
+ }
123
+
124
+ // Optional per-template extension point, mirroring setup-backend.ts's contract: decides which
125
+ // fields of the raw, validated --input are safe to ship to the browser. Templates with nothing
126
+ // sensitive to strip (no sql, no secrets) can skip shipping this file entirely — the raw config
127
+ // is used as-is. Compiled from setup-frontend.ts into dist/setup-frontend.js by dashboard-templates'
128
+ // build script (scripts/build-templates.mjs) — a standalone tsc pass, separate from the browser
129
+ // MF build, since this hook is Node-side and never ships to the browser.
130
+ function toFrontendConfig(templateDir: string, rawConfig: PatchedConfig): PatchedConfig {
131
+ const setupFrontendPath = path.join(templateDir, 'dist', 'setup-frontend.js');
132
+ if (!fs.existsSync(setupFrontendPath)) {
133
+ return rawConfig;
134
+ }
135
+ const templateRequire = createRequire(import.meta.url);
136
+ const { toFrontendConfig: transform } = templateRequire(setupFrontendPath) as {
137
+ toFrontendConfig: ToFrontendConfigHook<PatchedConfig>;
138
+ };
139
+ return transform(rawConfig);
140
+ }
141
+
142
+ /**
143
+ * Copies a warm @mondaydotcomorg/z2h-dashboard-templates entry, patches its already-built bundle with real
144
+ * config data, and previews it — no `z2h-cli build`, no `yarn install`, no live deploy. See
145
+ * systems/owned/z2h/specs/agent-dashboard-generation.md ("Separating Z2H from its consumers")
146
+ * in bb-foundations for why this lives here instead of as agent-side MCP tools.
147
+ */
148
+ export async function generateCommand(appName: string, opts: GenerateOptions): Promise<void> {
149
+ const workspace = await assertWorkspace();
150
+ validateAppName(appName);
151
+
152
+ const template = findTemplate(opts.template);
153
+ const rawConfig = parseInput(opts.input, template);
154
+
155
+ const appDir = path.join(workspace, appName);
156
+ if (!(await isDirEmpty(appDir))) {
157
+ throw new Error(`${appDir} is non-empty. Pick a different name or wipe it first.`);
158
+ }
159
+
160
+ const existing = await fetchApp(mfAppName(appName));
161
+ if (existing) {
162
+ throw new Error(`An app named "${appName}" already exists in the Z2H registry. Pick a different name.`);
163
+ }
164
+
165
+ info(`copying template "${template.id}" → ${appDir}`);
166
+ // Excludes node_modules (installed into templateDir itself by build-templates.mjs, never
167
+ // needed by the generated app), .build-shadow (a scratch dir that build-templates.mjs
168
+ // normally cleans up itself — only present here if a prior build crashed mid-way), and
169
+ // setup-backend/setup-frontend/template (source .ts at templateDir root, compiled .js in dist/ —
170
+ // generation-time-only files, required() straight from template.templateDir/dist above, never
171
+ // read again once the app exists, so neither form should end up in the generated app).
172
+ fs.cpSync(template.templateDir, appDir, {
173
+ recursive: true,
174
+ filter: src =>
175
+ !/[/\\](node_modules|\.build-shadow|setup-backend\.(ts|js)|setup-frontend\.(ts|js)|template\.(ts|js))([/\\]|$)/.test(
176
+ '/' + path.relative(template.templateDir, src)
177
+ ),
178
+ });
179
+
180
+ patchPackageJson(appDir, appName);
181
+ fs.appendFileSync(path.join(appDir, '.gitignore'), 'dist/\n');
182
+
183
+ const frontendConfig = toFrontendConfig(template.templateDir, rawConfig);
184
+ patchConfigSource(appDir, frontendConfig);
185
+
186
+ const patchedBuildDir = path.join(appDir, '.zth', 'build-patched');
187
+ info('patching prebuilt bundle with real config data');
188
+ // Source is templateDir/dist/ — built by @mondaydotcomorg/z2h-dashboard-templates' own `build` script (see
189
+ // packages/dashboard-templates/scripts/build-templates.mjs), never committed to git.
190
+ patchBuiltBundle(path.join(appDir, 'dist'), patchedBuildDir, frontendConfig);
191
+
192
+ const setupBackendPath = path.join(template.templateDir, 'dist', 'setup-backend.js');
193
+ if (fs.existsSync(setupBackendPath)) {
194
+ info('generating backend handlers via template.setupBackend');
195
+ const templateRequire = createRequire(import.meta.url);
196
+ const { setupBackend } = templateRequire(setupBackendPath) as {
197
+ setupBackend: SetupBackendHook<PatchedConfig>;
198
+ };
199
+ const handlersDir = path.join(appDir, 'backend', 'handlers');
200
+ fs.mkdirSync(handlersDir, { recursive: true });
201
+ setupBackend(rawConfig, createSetupAPI(handlersDir));
202
+ }
203
+
204
+ info(`initializing git repo for ${appName}...`);
205
+ await initRepo(appDir, defaultS3Remote(appName));
206
+
207
+ // generate only ever produces a preview — a disposable, shareable synthetic slot. It never
208
+ // registers a live version; that's a separate, explicit `z2h-cli deploy` the user runs
209
+ // themselves once they're happy with the preview.
210
+ info('deploying preview');
211
+ const previewArgs = ['deploy', '--preview', '--prebuilt-dir', patchedBuildDir, '--json', '--open'];
212
+ const { stdout } = await execa('z2h-cli', previewArgs, { cwd: appDir });
213
+ const previewResult = JSON.parse(stdout.trim().split('\n').filter(Boolean).at(-1) ?? '{}') as {
214
+ previewUrl?: string;
215
+ version?: number;
216
+ };
217
+
218
+ output(`[z2h-cli] generated "${appName}" from template "${template.id}" ✅\n preview: ${previewResult.previewUrl}`, {
219
+ ok: true,
220
+ appName: cleanAppName(appName),
221
+ templateId: template.id,
222
+ previewUrl: previewResult.previewUrl,
223
+ version: previewResult.version,
224
+ });
225
+ }
@@ -0,0 +1,20 @@
1
+ import { output } from '../util/logger';
2
+ import { loadDashboardTemplatesCatalog } from '../util/dashboard-templates/load-catalog';
3
+
4
+ /**
5
+ * Prints the @mondaydotcomorg/z2h-dashboard-templates catalog — id, title, description, and the
6
+ * config schema as JSON Schema (each template defines its own whole-config shape;
7
+ * `generate-app-template` validates `--input` against whichever one is named).
8
+ */
9
+ export async function templatesCommand(): Promise<void> {
10
+ const templates = loadDashboardTemplatesCatalog().map(({ id, title, description, configSchema }) => ({
11
+ id,
12
+ title,
13
+ description,
14
+ configSchema,
15
+ }));
16
+
17
+ const templatesInfo = templates.map(t => `${t.id} — ${t.title}\n ${t.description}`);
18
+ const hasInfo = templatesInfo.length > 0;
19
+ output(hasInfo ? templatesInfo.join('\n') : '(no templates in catalog)', { ok: true, templates });
20
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import { deleteCommand } from './commands/delete';
11
11
  import { grantCommand, revokeCommand, transferOwnerCommand } from './commands/grant';
12
12
  import { createWorkspaceCommand } from './commands/create-workspace';
13
13
  import { createCommand, CreateOptions } from './commands/create';
14
+ import { generateCommand, GenerateOptions } from './commands/generate';
15
+ import { templatesCommand } from './commands/templates';
14
16
  import { generateZ2HTokenCommand } from './commands/generate-z2h-token';
15
17
  import { migrateCommand } from './commands/migrate';
16
18
  import { tagCommand } from './commands/tag';
@@ -66,6 +68,7 @@ interface DeployCliOptions {
66
68
  remote?: string;
67
69
  repoUrl?: string;
68
70
  description?: string;
71
+ prebuiltDir?: string;
69
72
  }
70
73
 
71
74
  async function main(): Promise<void> {
@@ -127,6 +130,10 @@ async function main(): Promise<void> {
127
130
  'First deploy only: GitHub repo URL — required when --remote=github. Error if the app already has a git remote.'
128
131
  )
129
132
  .option('--description <text>', 'short (<=200 char) description of the app')
133
+ .option(
134
+ '--prebuilt-dir <path>',
135
+ 'internal — used by `generate-app-template`: skip the build step and deploy this already-built directory as-is'
136
+ )
130
137
  .action(
131
138
  runCommand(
132
139
  {
@@ -170,7 +177,9 @@ async function main(): Promise<void> {
170
177
 
171
178
  program
172
179
  .command('create <app-name>')
173
- .description(`Scaffold a new Z2H consumer app inside ${getWorkspaceHome()}/`)
180
+ .description(
181
+ `Scaffold a blank Z2H consumer app inside ${getWorkspaceHome()}/ using a built-in template (default | html-embed). If a catalog template already fits what you're building, use \`generate-app-template\` instead.`
182
+ )
174
183
  .option('--template <name>', 'Template name (default | html-embed)')
175
184
  .option('--html-file <path>', 'Path to an HTML file to embed (implies --template html-embed)')
176
185
  .option(
@@ -191,6 +200,32 @@ async function main(): Promise<void> {
191
200
  )
192
201
  );
193
202
 
203
+ program
204
+ .command('templates')
205
+ .description(
206
+ "List the BigBrain Studio (Z2H) template catalog — id, title, description, and each template's widget schema"
207
+ )
208
+ .action(runCommand({}, () => templatesCommand()));
209
+
210
+ program
211
+ .command('generate-app-template <app-name>')
212
+ .description(
213
+ `Scaffold + preview a Z2H app/dashboard from a templates catalog entry inside ${getWorkspaceHome()}/ — copies a prebuilt template, patches in real widget data, and deploys a preview. No build, no yarn install, no live deploy — run \`z2h-cli deploy\` yourself from the app dir once you're happy with it.`
214
+ )
215
+ .requiredOption('--template <id>', 'catalog template id — see `z2h-cli templates --json`')
216
+ .requiredOption(
217
+ '--input <json>',
218
+ "JSON string — shape is template-defined, validated wholesale against the template's configSchema (see `z2h-cli templates --json`)"
219
+ )
220
+ .action(
221
+ runCommand(
222
+ {
223
+ data: ([appName, opts]: [string, GenerateOptions]) => ({ app_name: appName, template: opts.template }),
224
+ },
225
+ (appName: string, opts: GenerateOptions) => generateCommand(appName, opts)
226
+ )
227
+ );
228
+
194
229
  program
195
230
  .command('edit <app-name>')
196
231
  .description(`Pull the latest version of an app into ${getWorkspaceHome()}/<app-name>, cloning it first if needed`)
@@ -0,0 +1,80 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { z } from 'zod';
5
+
6
+ export type ValidateConfigResult = { success: true; data: unknown } | { success: false; error: string };
7
+
8
+ export interface DashboardTemplateEntry {
9
+ id: string;
10
+ title: string;
11
+ description: string;
12
+ /** Absolute path to this template's dir — source + its already-built .zth/build. */
13
+ templateDir: string;
14
+ /** JSON Schema for this template's whole `--input` config object — shape is template-defined. */
15
+ configSchema: Record<string, unknown>;
16
+ /** Validates --input against this template's actual zod schema — the JSON Schema above is
17
+ * display-only (catalog listing), this is what `generate` really validates against. */
18
+ validateConfig: (input: unknown) => ValidateConfigResult;
19
+ }
20
+
21
+ type RawTemplate = Omit<DashboardTemplateEntry, 'templateDir' | 'configSchema' | 'validateConfig'>;
22
+
23
+ // Each template defines its catalog metadata and its config shape once, in template.ts (compiled
24
+ // to dist/template.js by dashboard-templates' build script) — the JSON Schema used for catalog
25
+ // listing is derived from its zod configSchema here rather than hand-duplicated as a separate JSON
26
+ // literal, and --input validation goes through the same zod schema directly (see validateConfig),
27
+ // so the schema and the TS types templates' own hooks import can never drift apart.
28
+ function loadTemplateModule(
29
+ templateDir: string
30
+ ): { template: RawTemplate; configSchema: Record<string, unknown> } & Pick<DashboardTemplateEntry, 'validateConfig'> {
31
+ const templateModulePath = path.join(templateDir, 'dist', 'template.js');
32
+ const { template, configSchema } = createRequire(import.meta.url)(templateModulePath) as {
33
+ template: RawTemplate;
34
+ configSchema: z.ZodType;
35
+ };
36
+ return {
37
+ template,
38
+ // draft-7 is arbitrary here (nothing re-validates against it) — just a reasonably standard,
39
+ // widely-understood dialect for whatever reads `templates --json`'s output.
40
+ configSchema: z.toJSONSchema(configSchema, { target: 'draft-7' }) as Record<string, unknown>,
41
+ validateConfig: input => {
42
+ const result = configSchema.safeParse(input);
43
+ return result.success
44
+ ? { success: true, data: result.data }
45
+ : { success: false, error: z.prettifyError(result.error) };
46
+ },
47
+ };
48
+ }
49
+
50
+ export function findTemplate(id: string): DashboardTemplateEntry {
51
+ const templates = loadDashboardTemplatesCatalog();
52
+ const template = templates.find(t => t.id === id);
53
+ if (!template) {
54
+ const known = templates.map(t => t.id).join(', ') || '(none)';
55
+ throw new Error(`Unknown template "${id}". Available: ${known}.`);
56
+ }
57
+ return template;
58
+ }
59
+
60
+ export function loadDashboardTemplatesCatalog(): DashboardTemplateEntry[] {
61
+ const catalogDir = path.dirname(
62
+ createRequire(import.meta.url).resolve('@mondaydotcomorg/z2h-dashboard-templates/package.json')
63
+ );
64
+ const templatesRoot = path.join(catalogDir, 'templates');
65
+ if (!fs.existsSync(templatesRoot)) {
66
+ return [];
67
+ }
68
+
69
+ return fs
70
+ .readdirSync(templatesRoot, { withFileTypes: true })
71
+ .filter(entry => entry.isDirectory())
72
+ .flatMap(entry => {
73
+ const templateDir = path.join(templatesRoot, entry.name);
74
+ if (!fs.existsSync(path.join(templateDir, 'template.ts'))) {
75
+ return [];
76
+ }
77
+ const { template, configSchema, validateConfig } = loadTemplateModule(templateDir);
78
+ return [{ ...template, templateDir, configSchema, validateConfig }];
79
+ });
80
+ }
@@ -0,0 +1,127 @@
1
+ // Swaps a template's placeholder config object directly into an ALREADY-BUILT MF bundle by
2
+ // string-patching the minified JS, instead of rerunning `z2h-cli build`. The config object is
3
+ // marked by a `/*! Z2H_CONFIG_V1 */` comment immediately before it — comments are inert to a
4
+ // minifier's dead-code elimination (unlike an object property, which gets stripped the moment
5
+ // nothing in the app statically reads it — see this package's README for how that bit us), and
6
+ // Terser's default `output.comments` setting (`some`) specifically preserves "bang" comments
7
+ // (`/*!` ...) while dropping ordinary ones, which is why the marker must use that exact form.
8
+ //
9
+ // Anchor is shape-agnostic by design: every template in @mondaydotcomorg/z2h-dashboard-templates'
10
+ // catalog must place this comment directly before its OWN placeholder config object, regardless
11
+ // of that template's real configSchema (which varies per template — title+widgets for one
12
+ // template, something else entirely for another) — see that package's README for the contract.
13
+ // This is the one constant every template shares, so this file needs no per-template logic and
14
+ // never reads any of the config's fields itself.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ // Comment content collapses whitespace during minification (`/*!Z2H_CONFIG_V1*/` is also valid),
19
+ // hence \s* rather than a literal single space.
20
+ const ANCHOR_RE = /\/\*!\s*Z2H_CONFIG_V1\s*\*\//;
21
+
22
+ // Generic on purpose: this file never reads the config's fields (only JSON.stringify's the whole
23
+ // object), and the catalog's templates each define their own shape — there is no one fixed shape
24
+ // to name here.
25
+ export type PatchedConfig = Record<string, unknown>;
26
+
27
+ // The anchor comment sits immediately before the config object (`= /*! Z2H_CONFIG_V1 */ {...}`),
28
+ // so unlike a property-based anchor nested inside the object, this never needs to scan backward
29
+ // to find where the object starts — just forward from the end of the comment match to the next
30
+ // "{", then a standard string-aware brace-depth scan to its matching "}".
31
+ function findObjectAfterAnchor(src: string, anchorEnd: number): { start: number; end: number } {
32
+ let start = anchorEnd;
33
+ while (start < src.length && src[start] !== '{') {
34
+ start++;
35
+ }
36
+ if (start >= src.length) {
37
+ throw new Error('patch-bundle: no "{" found after the anchor comment — anchor may be stale');
38
+ }
39
+
40
+ let braceDepth = 0;
41
+ let inString = false;
42
+ let quoteChar = '';
43
+ let end = -1;
44
+ for (let i = start; i < src.length; i++) {
45
+ const ch = src[i];
46
+ const prev = src[i - 1];
47
+ if (inString) {
48
+ if (ch === quoteChar && prev !== '\\') {
49
+ inString = false;
50
+ }
51
+ continue;
52
+ }
53
+ if (ch === '"' || ch === "'") {
54
+ inString = true;
55
+ quoteChar = ch;
56
+ continue;
57
+ }
58
+ if (ch === '{') {
59
+ braceDepth++;
60
+ } else if (ch === '}') {
61
+ braceDepth--;
62
+ if (braceDepth === 0) {
63
+ end = i;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+ if (end === -1) {
69
+ throw new Error('patch-bundle: could not find matching "}" for the config object — malformed scan');
70
+ }
71
+ return { start, end: end + 1 };
72
+ }
73
+
74
+ // Finds the anchor comment and swaps its immediately-following object literal wholesale. Returns
75
+ // null if this particular source doesn't contain the anchor (not every MF entrypoint does; the
76
+ // caller decides whether that's fatal).
77
+ export function patchOneFile(src: string, config: PatchedConfig): string | null {
78
+ const match = ANCHOR_RE.exec(src);
79
+ if (match === null) {
80
+ return null;
81
+ }
82
+ const { start, end } = findObjectAfterAnchor(src, match.index + match[0].length);
83
+ return src.slice(0, start) + JSON.stringify(config) + src.slice(end);
84
+ }
85
+
86
+ /**
87
+ * Reads a built `.zth/build` directory, patches every MF entrypoint's config-object literal
88
+ * in place with real data, and writes the result to `outDir` (does NOT mutate `sourceBuildDir`
89
+ * — safe to call against a shared/reused prebuilt bundle). Throws if the placeholder shape
90
+ * isn't found in ANY entrypoint, since that means the anchor is stale and nothing was patched.
91
+ */
92
+ export function patchBuiltBundle(
93
+ sourceBuildDir: string,
94
+ outDir: string,
95
+ config: PatchedConfig
96
+ ): { patchedFiles: string[] } {
97
+ const manifestPath = path.join(sourceBuildDir, 'asset-manifest.json');
98
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
99
+ entrypoints: string[];
100
+ nonExternalsEntrypoints: string[];
101
+ };
102
+ const jsEntries = [...manifest.entrypoints, ...manifest.nonExternalsEntrypoints].filter(f => f.endsWith('.js'));
103
+
104
+ fs.rmSync(outDir, { recursive: true, force: true });
105
+ fs.cpSync(sourceBuildDir, outDir, { recursive: true });
106
+
107
+ const patchedFiles: string[] = [];
108
+ for (const rel of jsEntries) {
109
+ const outPath = path.join(outDir, rel);
110
+ const src = fs.readFileSync(outPath, 'utf8');
111
+ const patched = patchOneFile(src, config);
112
+ if (patched === null) {
113
+ continue;
114
+ }
115
+
116
+ new Function(patched); // throws SyntaxError if the patch produced malformed JS — fail loud, not silently
117
+ fs.writeFileSync(outPath, patched);
118
+ patchedFiles.push(rel);
119
+ }
120
+
121
+ if (patchedFiles.length === 0) {
122
+ throw new Error(
123
+ `patch-bundle: patched 0 of ${jsEntries.length} entrypoint(s) — anchor ${ANCHOR_RE} not found in any of them`
124
+ );
125
+ }
126
+ return { patchedFiles };
127
+ }