@mondaydotcomorg/z2h-cli 0.25.8 → 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.
- package/dist/commands/backend.js +2 -2
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +19 -3
- package/dist/commands/generate.d.ts +12 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +190 -0
- package/dist/commands/templates.d.ts +7 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +23 -0
- package/dist/esm/commands/deploy.d.ts +1 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -1
- package/dist/esm/commands/deploy.mjs +19 -4
- package/dist/esm/commands/generate.d.ts +12 -0
- package/dist/esm/commands/generate.d.ts.map +1 -0
- package/dist/esm/commands/generate.mjs +182 -0
- package/dist/esm/commands/templates.d.ts +7 -0
- package/dist/esm/commands/templates.d.ts.map +1 -0
- package/dist/esm/commands/templates.mjs +21 -0
- package/dist/esm/index.mjs +16 -1
- package/dist/esm/util/dashboard-templates/load-catalog.d.ts +22 -0
- package/dist/esm/util/dashboard-templates/load-catalog.d.ts.map +1 -0
- package/dist/esm/util/dashboard-templates/load-catalog.mjs +55 -0
- package/dist/esm/util/dashboard-templates/patch-bundle.d.ts +12 -0
- package/dist/esm/util/dashboard-templates/patch-bundle.d.ts.map +1 -0
- package/dist/esm/util/dashboard-templates/patch-bundle.mjs +108 -0
- package/dist/index.js +16 -1
- package/dist/util/app-name.js +2 -2
- package/dist/util/dashboard-templates/load-catalog.d.ts +22 -0
- package/dist/util/dashboard-templates/load-catalog.d.ts.map +1 -0
- package/dist/util/dashboard-templates/load-catalog.js +64 -0
- package/dist/util/dashboard-templates/patch-bundle.d.ts +12 -0
- package/dist/util/dashboard-templates/patch-bundle.d.ts.map +1 -0
- package/dist/util/dashboard-templates/patch-bundle.js +116 -0
- package/handler-api/backend-runner/channels/snowflake-channel/run-query.ts +3 -1
- package/package.json +5 -3
- package/src/commands/deploy.ts +24 -5
- package/src/commands/generate.ts +225 -0
- package/src/commands/templates.ts +20 -0
- package/src/index.ts +36 -1
- package/src/util/dashboard-templates/load-catalog.ts +80 -0
- package/src/util/dashboard-templates/patch-bundle.ts +127 -0
package/src/commands/deploy.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { copy, ensureDir, readJson } from 'fs-extra';
|
|
3
4
|
import { info, warn, output, getOutputMode } from '../util/logger';
|
|
4
5
|
import { resolveConsumerPaths } from '../shadow/paths';
|
|
5
6
|
import { fetchAppTags, fetchTagCatalog, setAppTags } from '../util/tags';
|
|
@@ -27,7 +28,7 @@ import { deployBackend } from './backend';
|
|
|
27
28
|
import { buildCommand } from './build';
|
|
28
29
|
import { promptForTags } from './tag';
|
|
29
30
|
import type { ConsumerPaths, DeployFailureReason } from '../types';
|
|
30
|
-
import type { ManifestEntry } from '../util/manifest';
|
|
31
|
+
import type { ManifestEntry, AssetManifestFile } from '../util/manifest';
|
|
31
32
|
|
|
32
33
|
/** Who is publishing this version — the machine identity, else the OS user. */
|
|
33
34
|
function whoDeployed(): string {
|
|
@@ -42,6 +43,10 @@ export interface DeployOptions {
|
|
|
42
43
|
preview?: boolean;
|
|
43
44
|
open?: boolean;
|
|
44
45
|
description?: string;
|
|
46
|
+
// Used by `generate-app-template`: skip `buildCommand()` entirely and reuse this
|
|
47
|
+
// already-built (string-patched) directory as the shadow build output instead. Only ever
|
|
48
|
+
// populated by the generate pipeline — never set on a normal human/CI deploy.
|
|
49
|
+
prebuiltDir?: string;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
interface DeployContext {
|
|
@@ -91,8 +96,16 @@ async function buildAndStamp(opts: {
|
|
|
91
96
|
publicUrl: string;
|
|
92
97
|
nextVersion: number;
|
|
93
98
|
gitRemote: string;
|
|
99
|
+
prebuiltDir?: string;
|
|
94
100
|
}): Promise<ManifestEntry> {
|
|
95
|
-
|
|
101
|
+
let assetManifest: AssetManifestFile;
|
|
102
|
+
if (opts.prebuiltDir) {
|
|
103
|
+
info(`skipping build — reusing prebuilt bundle from ${opts.prebuiltDir}`);
|
|
104
|
+
await copy(opts.prebuiltDir, opts.paths.shadowBuild, { overwrite: true });
|
|
105
|
+
assetManifest = await readJson(path.join(opts.paths.shadowBuild, 'asset-manifest.json'));
|
|
106
|
+
} else {
|
|
107
|
+
({ assetManifest } = await buildCommand({ publicUrl: opts.publicUrl }));
|
|
108
|
+
}
|
|
96
109
|
const deployedBy = whoDeployed();
|
|
97
110
|
|
|
98
111
|
return {
|
|
@@ -156,7 +169,13 @@ async function deployPreview({ paths, consumerName, appName, token, opts }: Depl
|
|
|
156
169
|
const nextVersion = (await fetchDeployVersion(previewAppName)) + 1;
|
|
157
170
|
|
|
158
171
|
info('building');
|
|
159
|
-
const entry = await buildAndStamp({
|
|
172
|
+
const entry = await buildAndStamp({
|
|
173
|
+
paths,
|
|
174
|
+
publicUrl: url,
|
|
175
|
+
nextVersion,
|
|
176
|
+
gitRemote: '',
|
|
177
|
+
prebuiltDir: opts.prebuiltDir,
|
|
178
|
+
});
|
|
160
179
|
|
|
161
180
|
info('getting sts credentials');
|
|
162
181
|
const creds = await brokerCredentialProvider({ appName: previewAppName, previewId })();
|
|
@@ -245,7 +264,7 @@ async function deployLive({ paths, consumerName, appName, token, opts }: DeployC
|
|
|
245
264
|
const url = `${BUCKET_READ_DNS_URL}/${keyPrefix}`;
|
|
246
265
|
|
|
247
266
|
info('building');
|
|
248
|
-
const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote });
|
|
267
|
+
const entry = await buildAndStamp({ paths, publicUrl: url, nextVersion, gitRemote, prebuiltDir: opts.prebuiltDir });
|
|
249
268
|
|
|
250
269
|
if (opts.dryRun) {
|
|
251
270
|
output(`[z2h-cli] dry-run: would upload to s3://${BUCKET_WRITE_NAME}/${keyPrefix}/ as v${version}`, {
|
|
@@ -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(
|
|
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
|
+
}
|