@mondaydotcomorg/z2h-cli 0.1.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 (157) hide show
  1. package/README.md +16 -0
  2. package/bin/z2h-cli.js +2 -0
  3. package/dist/commands/build.d.ts +5 -0
  4. package/dist/commands/build.d.ts.map +1 -0
  5. package/dist/commands/build.js +37 -0
  6. package/dist/commands/clean.d.ts +5 -0
  7. package/dist/commands/clean.d.ts.map +1 -0
  8. package/dist/commands/clean.js +24 -0
  9. package/dist/commands/deploy.d.ts +6 -0
  10. package/dist/commands/deploy.d.ts.map +1 -0
  11. package/dist/commands/deploy.js +92 -0
  12. package/dist/commands/dev.d.ts +5 -0
  13. package/dist/commands/dev.d.ts.map +1 -0
  14. package/dist/commands/dev.js +58 -0
  15. package/dist/constants.d.ts +25 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +67 -0
  18. package/dist/esm/commands/build.d.ts +5 -0
  19. package/dist/esm/commands/build.d.ts.map +1 -0
  20. package/dist/esm/commands/build.mjs +35 -0
  21. package/dist/esm/commands/clean.d.ts +5 -0
  22. package/dist/esm/commands/clean.d.ts.map +1 -0
  23. package/dist/esm/commands/clean.mjs +22 -0
  24. package/dist/esm/commands/deploy.d.ts +6 -0
  25. package/dist/esm/commands/deploy.d.ts.map +1 -0
  26. package/dist/esm/commands/deploy.mjs +86 -0
  27. package/dist/esm/commands/dev.d.ts +5 -0
  28. package/dist/esm/commands/dev.d.ts.map +1 -0
  29. package/dist/esm/commands/dev.mjs +56 -0
  30. package/dist/esm/constants.d.ts +25 -0
  31. package/dist/esm/constants.d.ts.map +1 -0
  32. package/dist/esm/constants.mjs +57 -0
  33. package/dist/esm/index.d.ts +2 -0
  34. package/dist/esm/index.d.ts.map +1 -0
  35. package/dist/esm/index.mjs +86 -0
  36. package/dist/esm/shadow/consumer-pkg.d.ts +24 -0
  37. package/dist/esm/shadow/consumer-pkg.d.ts.map +1 -0
  38. package/dist/esm/shadow/consumer-pkg.mjs +81 -0
  39. package/dist/esm/shadow/gitignore.d.ts +7 -0
  40. package/dist/esm/shadow/gitignore.d.ts.map +1 -0
  41. package/dist/esm/shadow/gitignore.mjs +29 -0
  42. package/dist/esm/shadow/merge-package-json.d.ts +16 -0
  43. package/dist/esm/shadow/merge-package-json.d.ts.map +1 -0
  44. package/dist/esm/shadow/merge-package-json.mjs +58 -0
  45. package/dist/esm/shadow/metadata.d.ts +4 -0
  46. package/dist/esm/shadow/metadata.d.ts.map +1 -0
  47. package/dist/esm/shadow/metadata.mjs +18 -0
  48. package/dist/esm/shadow/paths.d.ts +3 -0
  49. package/dist/esm/shadow/paths.d.ts.map +1 -0
  50. package/dist/esm/shadow/paths.mjs +25 -0
  51. package/dist/esm/shadow/port.d.ts +7 -0
  52. package/dist/esm/shadow/port.d.ts.map +1 -0
  53. package/dist/esm/shadow/port.mjs +34 -0
  54. package/dist/esm/shadow/prepare.d.ts +3 -0
  55. package/dist/esm/shadow/prepare.d.ts.map +1 -0
  56. package/dist/esm/shadow/prepare.mjs +60 -0
  57. package/dist/esm/shadow/scaffold.d.ts +7 -0
  58. package/dist/esm/shadow/scaffold.d.ts.map +1 -0
  59. package/dist/esm/shadow/scaffold.mjs +112 -0
  60. package/dist/esm/shadow/sync.d.ts +9 -0
  61. package/dist/esm/shadow/sync.d.ts.map +1 -0
  62. package/dist/esm/shadow/sync.mjs +29 -0
  63. package/dist/esm/shadow/yarnrc.d.ts +10 -0
  64. package/dist/esm/shadow/yarnrc.d.ts.map +1 -0
  65. package/dist/esm/shadow/yarnrc.mjs +27 -0
  66. package/dist/esm/types.d.ts +53 -0
  67. package/dist/esm/types.d.ts.map +1 -0
  68. package/dist/esm/types.mjs +1 -0
  69. package/dist/esm/util/app-name.d.ts +12 -0
  70. package/dist/esm/util/app-name.d.ts.map +1 -0
  71. package/dist/esm/util/app-name.mjs +17 -0
  72. package/dist/esm/util/logger.d.ts +22 -0
  73. package/dist/esm/util/logger.d.ts.map +1 -0
  74. package/dist/esm/util/logger.mjs +59 -0
  75. package/dist/esm/util/manifest.d.ts +31 -0
  76. package/dist/esm/util/manifest.d.ts.map +1 -0
  77. package/dist/esm/util/manifest.mjs +31 -0
  78. package/dist/esm/util/s3/client.d.ts +11 -0
  79. package/dist/esm/util/s3/client.d.ts.map +1 -0
  80. package/dist/esm/util/s3/client.mjs +38 -0
  81. package/dist/esm/util/s3/upload.d.ts +2 -0
  82. package/dist/esm/util/s3/upload.d.ts.map +1 -0
  83. package/dist/esm/util/s3/upload.mjs +32 -0
  84. package/dist/index.d.ts +2 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +86 -0
  87. package/dist/shadow/consumer-pkg.d.ts +24 -0
  88. package/dist/shadow/consumer-pkg.d.ts.map +1 -0
  89. package/dist/shadow/consumer-pkg.js +84 -0
  90. package/dist/shadow/gitignore.d.ts +7 -0
  91. package/dist/shadow/gitignore.d.ts.map +1 -0
  92. package/dist/shadow/gitignore.js +35 -0
  93. package/dist/shadow/merge-package-json.d.ts +16 -0
  94. package/dist/shadow/merge-package-json.d.ts.map +1 -0
  95. package/dist/shadow/merge-package-json.js +66 -0
  96. package/dist/shadow/metadata.d.ts +4 -0
  97. package/dist/shadow/metadata.d.ts.map +1 -0
  98. package/dist/shadow/metadata.js +21 -0
  99. package/dist/shadow/paths.d.ts +3 -0
  100. package/dist/shadow/paths.d.ts.map +1 -0
  101. package/dist/shadow/paths.js +31 -0
  102. package/dist/shadow/port.d.ts +7 -0
  103. package/dist/shadow/port.d.ts.map +1 -0
  104. package/dist/shadow/port.js +41 -0
  105. package/dist/shadow/prepare.d.ts +3 -0
  106. package/dist/shadow/prepare.d.ts.map +1 -0
  107. package/dist/shadow/prepare.js +62 -0
  108. package/dist/shadow/scaffold.d.ts +7 -0
  109. package/dist/shadow/scaffold.d.ts.map +1 -0
  110. package/dist/shadow/scaffold.js +118 -0
  111. package/dist/shadow/sync.d.ts +9 -0
  112. package/dist/shadow/sync.d.ts.map +1 -0
  113. package/dist/shadow/sync.js +31 -0
  114. package/dist/shadow/yarnrc.d.ts +10 -0
  115. package/dist/shadow/yarnrc.d.ts.map +1 -0
  116. package/dist/shadow/yarnrc.js +33 -0
  117. package/dist/types.d.ts +53 -0
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +1 -0
  120. package/dist/util/app-name.d.ts +12 -0
  121. package/dist/util/app-name.d.ts.map +1 -0
  122. package/dist/util/app-name.js +20 -0
  123. package/dist/util/logger.d.ts +22 -0
  124. package/dist/util/logger.d.ts.map +1 -0
  125. package/dist/util/logger.js +71 -0
  126. package/dist/util/manifest.d.ts +31 -0
  127. package/dist/util/manifest.d.ts.map +1 -0
  128. package/dist/util/manifest.js +36 -0
  129. package/dist/util/s3/client.d.ts +11 -0
  130. package/dist/util/s3/client.d.ts.map +1 -0
  131. package/dist/util/s3/client.js +48 -0
  132. package/dist/util/s3/upload.d.ts +2 -0
  133. package/dist/util/s3/upload.d.ts.map +1 -0
  134. package/dist/util/s3/upload.js +39 -0
  135. package/package.json +56 -0
  136. package/src/commands/build.ts +42 -0
  137. package/src/commands/clean.ts +26 -0
  138. package/src/commands/deploy.ts +114 -0
  139. package/src/commands/dev.ts +64 -0
  140. package/src/constants.ts +63 -0
  141. package/src/index.ts +90 -0
  142. package/src/shadow/consumer-pkg.ts +100 -0
  143. package/src/shadow/gitignore.ts +30 -0
  144. package/src/shadow/merge-package-json.ts +62 -0
  145. package/src/shadow/metadata.ts +17 -0
  146. package/src/shadow/paths.ts +24 -0
  147. package/src/shadow/port.ts +34 -0
  148. package/src/shadow/prepare.ts +68 -0
  149. package/src/shadow/scaffold.ts +120 -0
  150. package/src/shadow/sync.ts +30 -0
  151. package/src/shadow/yarnrc.ts +29 -0
  152. package/src/types.ts +60 -0
  153. package/src/util/app-name.ts +16 -0
  154. package/src/util/logger.ts +64 -0
  155. package/src/util/manifest.ts +65 -0
  156. package/src/util/s3/client.ts +40 -0
  157. package/src/util/s3/upload.ts +41 -0
@@ -0,0 +1,64 @@
1
+ import { readJson } from 'fs-extra';
2
+ import { watch } from 'chokidar';
3
+ import { execa } from 'execa';
4
+ import { info, warn, errorLog, output, setOutputMode } from '../util/logger';
5
+ import { prepareShadow } from '../shadow/prepare';
6
+ import { ensureConsumerPkg } from '../shadow/consumer-pkg';
7
+ import { syncShadow } from '../shadow/sync';
8
+ import type { ConsumerPkg } from '../types';
9
+
10
+ export interface DevOptions {
11
+ json?: boolean;
12
+ }
13
+
14
+ const TRIDENT_READY_PATTERN = /Server started at/i;
15
+
16
+ export async function devCommand(opts: DevOptions): Promise<void> {
17
+ if (opts.json) {
18
+ setOutputMode('json');
19
+ }
20
+
21
+ const ctx = await prepareShadow();
22
+ const { port } = ctx;
23
+ const url = `https://bigbrain.me/bigbrain-vibe/${ctx.appName}?zth_local=${port}`;
24
+
25
+ info('starting z2h app');
26
+ // stdout is piped so we can detect trident's ready banner before printing
27
+ // our own ready line. stdin/stderr stay inherited so colored errors and
28
+ // interactive prompts still flow naturally.
29
+ const server = execa('yarn', ['start'], {
30
+ cwd: ctx.paths.shadowDir,
31
+ stdio: ['inherit', 'pipe', 'inherit'],
32
+ });
33
+
34
+ let readyPrinted = false;
35
+ server.stdout?.on('data', (chunk: Buffer) => {
36
+ process.stdout.write(chunk);
37
+ if (!readyPrinted && TRIDENT_READY_PATTERN.test(chunk.toString())) {
38
+ readyPrinted = true;
39
+ output(`\n[z2h-cli] ready — open:\n ${url}\n`, {
40
+ ok: true,
41
+ appName: ctx.appName,
42
+ port,
43
+ url,
44
+ });
45
+ }
46
+ });
47
+
48
+ // Only watch consumer package.json. Source changes flow through Vite's
49
+ // native import graph — the wrapper imports consumer src via relative
50
+ // path, so Vite's HMR picks up edits without any rsync.
51
+ const pkgWatcher = watch(ctx.paths.consumerPkg, { ignoreInitial: true });
52
+ pkgWatcher.on('change', async () => {
53
+ warn('consumer package.json changed — re-checking deps');
54
+ try {
55
+ const raw = (await readJson(ctx.paths.consumerPkg)) as ConsumerPkg;
56
+ const { pkg } = await ensureConsumerPkg(ctx.paths.consumerPkg, raw);
57
+ await syncShadow(ctx.paths, pkg);
58
+ } catch (err) {
59
+ errorLog(`re-prepare failed: ${(err as Error).message}`);
60
+ }
61
+ });
62
+
63
+ await server;
64
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Cross-file constants. Narrowly-used literals (templates, prefixes used in
3
+ * one place) stay local to their file to keep this index small.
4
+ */
5
+
6
+ // ─────────── Shadow dir layout ───────────
7
+ export const SHADOW_DIR_NAME = '.zth';
8
+
9
+ // ─────────── Consumer pkg enforcement ──────
10
+ /**
11
+ * Protected runtime deps that MUST resolve to a single instance across the
12
+ * wrapper (.zth) and the consumer's src. Declared as peerDependencies in
13
+ * the shadow pkg so yarn hoists them from the workspace root — which is
14
+ * why we enforce their presence in the consumer's own package.json.
15
+ */
16
+ export const PROTECTED_DEPS: Record<string, string> = {
17
+ react: '^18.2.0',
18
+ 'react-dom': '^18.2.0',
19
+ 'react-error-boundary': '^3.1.3',
20
+ '@mondaydotcomorg/trident-runtime': '^1.24.1',
21
+ '@vibe/core': '^4.0.1',
22
+ };
23
+
24
+ /**
25
+ * Protected dev deps. Same idempotent treatment as PROTECTED_DEPS but live
26
+ * in the consumer's `devDependencies`. These give the LLM-authored consumer
27
+ * code access to monday.com platform types out of the box.
28
+ */
29
+ export const PROTECTED_DEV_DEPS: Record<string, string> = {
30
+ '@mondaydotcomorg/mf-api-types': '^0.1.1',
31
+ '@mondaydotcomorg/bigbrain-types': '^0.3.3',
32
+ };
33
+
34
+ // ─────────── Shadow pkg template ───────────
35
+ // TODO — Add trident mf-types / bigbrain-types opt-in.
36
+ export const WORKSPACE_DEV_DEPS: Record<string, string> = {
37
+ '@mondaydotcomorg/trident-toolkit': '^2.9.25',
38
+ '@types/react': '^18.0.14',
39
+ '@types/react-dom': '^18.0.5',
40
+ typescript: '^5.7.2',
41
+ };
42
+
43
+ export const SHADOW_SCRIPTS: Record<string, string> = {
44
+ start: 'trident start',
45
+ build: 'trident build',
46
+ };
47
+
48
+ // ─────────── Auto-allocated dev port range ────
49
+ // High enough to dodge common dev defaults (3000 Next, 5173 Vite, 8080).
50
+ // Collisions within the 7k-port pool are rare for POC; if trident can't
51
+ // bind, the user sees a clear bind error from the dev server and re-runs.
52
+ export const MIN_PORT = 13000;
53
+ export const MAX_PORT = 19999;
54
+
55
+ // ─────────── Gitignore entries auto-appended to the consumer's file ────
56
+ export const GITIGNORE_ENTRIES = ['.zth/'];
57
+
58
+ // ─────────── .yarnrc.yml written to new consumer apps ────
59
+ export const YARNRC_LINES = [
60
+ 'nodeLinker: node-modules',
61
+ "npmAuthToken: '${NPM_TOKEN}'",
62
+ "npmRegistryServer: 'https://registry.npmjs.org/'",
63
+ ];
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { Command } from 'commander';
2
+ import { devCommand } from './commands/dev';
3
+ import { buildCommand } from './commands/build';
4
+ import { cleanCommand } from './commands/clean';
5
+ import { deployCommand } from './commands/deploy';
6
+ import { errorLog, fail } from './util/logger';
7
+
8
+ async function main(): Promise<void> {
9
+ const program = new Command();
10
+ program
11
+ .name('z2h-cli')
12
+ .description(
13
+ [
14
+ 'Zero-to-Hero CLI — ship LLM-generated React apps as Trident MFs.',
15
+ '',
16
+ 'Each consumer dir is self-contained: a shadow at <consumer>/.zth/',
17
+ 'wraps the consumer source as a Trident MF. Run from inside any',
18
+ 'consumer dir (the one with your src/index.tsx).',
19
+ '',
20
+ 'Exit codes:',
21
+ ' 0 success',
22
+ ' 1 user-resolvable error (bad cwd, invalid package.json, lock held)',
23
+ ' 2 system error (yarn install or trident build failed)',
24
+ ].join('\n')
25
+ )
26
+ .version('0.0.0');
27
+
28
+ program
29
+ .command('build')
30
+ .description('Build the consumer into its .zth/ shadow and copy artifacts to ./build/')
31
+ .option('--json', 'emit structured JSON on stdout')
32
+ .action(async (opts: { json?: boolean }) => {
33
+ try {
34
+ await buildCommand(opts);
35
+ } catch (err) {
36
+ // fail() emits a structured JSON error on stdout in --json mode and
37
+ // a red line on stderr in human mode, then exits. errorLog alone is
38
+ // silent in JSON mode, which leaves machine consumers with no signal.
39
+ fail(`build failed: ${(err as Error).message}`, { command: 'build' }, 2);
40
+ }
41
+ });
42
+
43
+ program
44
+ .command('dev')
45
+ .description('Start the Trident dev server against .zth/ with native Vite HMR')
46
+ .option('--json', 'emit structured JSON on stdout')
47
+ .action(async (opts: { json?: boolean }) => {
48
+ try {
49
+ await devCommand(opts);
50
+ } catch (err) {
51
+ fail(`dev failed: ${(err as Error).message}`, { command: 'dev' }, 2);
52
+ }
53
+ });
54
+
55
+ program
56
+ .command('clean')
57
+ .description('Delete <consumer>/.zth/ and <consumer>/build/')
58
+ .option('--json', 'emit structured JSON on stdout')
59
+ .action(async (opts: { json?: boolean }) => {
60
+ try {
61
+ await cleanCommand(opts);
62
+ } catch (err) {
63
+ fail(`clean failed: ${(err as Error).message}`, { command: 'clean' }, 2);
64
+ }
65
+ });
66
+
67
+ program
68
+ .command('deploy')
69
+ .description('Build, upload artifacts to S3, and update the Z2H manifest')
70
+ .option('--dry-run', 'print what would happen without uploading anything')
71
+ .option('--json', 'emit structured JSON on stdout')
72
+ .action(async (opts: { dryRun?: boolean; json?: boolean }) => {
73
+ try {
74
+ await deployCommand(opts);
75
+ } catch (err) {
76
+ fail(`deploy failed: ${(err as Error).message}`, { command: 'deploy' }, 2);
77
+ }
78
+ });
79
+
80
+ await program.parseAsync(process.argv);
81
+ }
82
+
83
+ void main()
84
+ .then(() => {
85
+ process.exit(0);
86
+ })
87
+ .catch((err: unknown) => {
88
+ errorLog(`main failed: ${(err as Error).message}`);
89
+ process.exit(1);
90
+ });
@@ -0,0 +1,100 @@
1
+ import { writeJson } from 'fs-extra';
2
+ import { PROTECTED_DEPS, PROTECTED_DEV_DEPS, SHADOW_DIR_NAME } from '../constants';
3
+ import type { ConsumerPkg } from '../types';
4
+
5
+ /**
6
+ * Return the consumer's `workspaces` field extended with `.zth`. Handles
7
+ * all three shapes: missing, array form, or object form (`{ packages }`).
8
+ * Always extends — never replaces the user's existing entries.
9
+ */
10
+ export function ensureConsumerWorkspaces(pkg: ConsumerPkg): {
11
+ changed: boolean;
12
+ workspaces: ConsumerPkg['workspaces'];
13
+ } {
14
+ const { workspaces } = pkg;
15
+
16
+ if (Array.isArray(workspaces)) {
17
+ if (workspaces.includes(SHADOW_DIR_NAME)) {
18
+ return { changed: false, workspaces };
19
+ }
20
+ return { changed: true, workspaces: [...workspaces, SHADOW_DIR_NAME] };
21
+ }
22
+
23
+ if (workspaces && typeof workspaces === 'object') {
24
+ const pkgs = workspaces.packages ?? [];
25
+ if (pkgs.includes(SHADOW_DIR_NAME)) {
26
+ return { changed: false, workspaces };
27
+ }
28
+ return {
29
+ changed: true,
30
+ workspaces: { ...workspaces, packages: [...pkgs, SHADOW_DIR_NAME] },
31
+ };
32
+ }
33
+
34
+ return { changed: true, workspaces: [SHADOW_DIR_NAME] };
35
+ }
36
+
37
+ /**
38
+ * Extend a deps map with the protected entries; only fills in missing ones,
39
+ * never overrides versions the consumer already pinned.
40
+ */
41
+ function applyProtected(
42
+ current: Record<string, string> | undefined,
43
+ protectedMap: Record<string, string>
44
+ ): { changed: boolean; next: Record<string, string> } {
45
+ const next = { ...(current ?? {}) };
46
+ let changed = false;
47
+ for (const [name, version] of Object.entries(protectedMap)) {
48
+ if (!next[name]) {
49
+ next[name] = version;
50
+ changed = true;
51
+ }
52
+ }
53
+ return { changed, next };
54
+ }
55
+
56
+ /**
57
+ * Idempotently reshape the consumer's package.json so that:
58
+ * - it is marked `"private": true`
59
+ * - `.zth` is declared as a yarn workspace member (yarn will hoist)
60
+ * - the protected runtime deps are present at compatible versions
61
+ * - the protected dev deps are present at compatible versions
62
+ *
63
+ * Always extends — never overrides existing fields.
64
+ */
65
+ export async function ensureConsumerPkg(
66
+ pkgPath: string,
67
+ pkg: ConsumerPkg
68
+ ): Promise<{ changed: boolean; pkg: ConsumerPkg }> {
69
+ let changed = false;
70
+ const next: ConsumerPkg = { ...pkg };
71
+
72
+ if (next.private !== true) {
73
+ next.private = true;
74
+ changed = true;
75
+ }
76
+
77
+ const ws = ensureConsumerWorkspaces(next);
78
+ if (ws.changed) {
79
+ next.workspaces = ws.workspaces;
80
+ changed = true;
81
+ }
82
+
83
+ const deps = applyProtected(next.dependencies, PROTECTED_DEPS);
84
+ if (deps.changed) {
85
+ next.dependencies = deps.next;
86
+ changed = true;
87
+ }
88
+
89
+ const devDeps = applyProtected(next.devDependencies, PROTECTED_DEV_DEPS);
90
+ if (devDeps.changed) {
91
+ next.devDependencies = devDeps.next;
92
+ changed = true;
93
+ }
94
+
95
+ if (changed) {
96
+ await writeJson(pkgPath, next, { spaces: 2 });
97
+ }
98
+
99
+ return { changed, pkg: next };
100
+ }
@@ -0,0 +1,30 @@
1
+ import path from 'node:path';
2
+ import { pathExists, readFile, writeFile } from 'fs-extra';
3
+ import { GITIGNORE_ENTRIES } from '../constants';
4
+
5
+ /**
6
+ * Idempotently append our shadow-dir entries to the consumer's .gitignore.
7
+ * Never modifies existing entries; only appends missing ones. Creates the
8
+ * file if absent.
9
+ */
10
+ export async function ensureGitignore(consumerDir: string): Promise<void> {
11
+ const gitignorePath = path.join(consumerDir, '.gitignore');
12
+ let body = '';
13
+ if (await pathExists(gitignorePath)) {
14
+ body = await readFile(gitignorePath, 'utf8');
15
+ }
16
+ const existing = new Set(
17
+ body
18
+ .split('\n')
19
+ .map(l => l.trim())
20
+ .filter(Boolean)
21
+ );
22
+ const missing = GITIGNORE_ENTRIES.filter(e => !existing.has(e));
23
+ if (missing.length === 0) {
24
+ return;
25
+ }
26
+
27
+ const prefix = body.length && !body.endsWith('\n') ? '\n' : '';
28
+ const block = `${prefix}\n# z2h-cli\n${missing.join('\n')}\n`;
29
+ await writeFile(gitignorePath, body + block);
30
+ }
@@ -0,0 +1,62 @@
1
+ import crypto from 'node:crypto';
2
+ import { writeJson } from 'fs-extra';
3
+ import { PROTECTED_DEPS, SHADOW_SCRIPTS, WORKSPACE_DEV_DEPS } from '../constants';
4
+ import { mfAppName } from '../util/app-name';
5
+ import type { ConsumerPkg, ShadowPkg } from '../types';
6
+
7
+ /**
8
+ * Build the shadow package.json. Declares the shared runtime deps as
9
+ * peerDependencies — yarn resolves them from the workspace root (the
10
+ * consumer's package.json), guaranteeing a single React instance across
11
+ * the wrapper and consumer src.
12
+ */
13
+ export function buildShadowPkg(consumerPkg: ConsumerPkg): ShadowPkg {
14
+ // Use the `@trident/` scope so trident-toolkit strips it and the emitted
15
+ // bundle becomes `MF-mf-<appName>`, matching the URL the host MF routes to.
16
+ // The scope also keeps the shadow's name distinct from the consumer's
17
+ // package.json name (same workspace, different identifiers).
18
+ return {
19
+ name: `@trident/${mfAppName(consumerPkg.name)}`,
20
+ version: '0.0.1',
21
+ private: true,
22
+ scripts: SHADOW_SCRIPTS,
23
+ peerDependencies: { ...PROTECTED_DEPS },
24
+ devDependencies: WORKSPACE_DEV_DEPS,
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Stable, key-sorted serialization. Two objects with the same keys/values
30
+ * hash identically regardless of insertion order — so re-orderings from
31
+ * yarn's install or ensureConsumerPkg don't trigger spurious reinstalls.
32
+ */
33
+ function stableStringify(value: unknown): string {
34
+ return JSON.stringify(value, (_key, v) => {
35
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
36
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
37
+ }
38
+ return v;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Hash the consumer's declared deps. This is the sole input that decides
44
+ * whether `yarn install` needs to run — the shadow package.json is
45
+ * regenerated from a CLI-owned template every run and doesn't participate.
46
+ */
47
+ export function hashConsumerDeps(consumer: ConsumerPkg): string {
48
+ return crypto
49
+ .createHash('sha256')
50
+ .update(
51
+ stableStringify({
52
+ deps: consumer.dependencies ?? {},
53
+ devDeps: consumer.devDependencies ?? {},
54
+ peerDeps: consumer.peerDependencies ?? {},
55
+ })
56
+ )
57
+ .digest('hex');
58
+ }
59
+
60
+ export async function writeShadowPkg(shadowPkgPath: string, shadow: ShadowPkg): Promise<void> {
61
+ await writeJson(shadowPkgPath, shadow, { spaces: 2 });
62
+ }
@@ -0,0 +1,17 @@
1
+ import { pathExists, readJson, writeJson } from 'fs-extra';
2
+ import type { ConsumerPaths, ShadowMetadata } from '../types';
3
+
4
+ export async function readMetadata(paths: ConsumerPaths): Promise<ShadowMetadata | null> {
5
+ if (!(await pathExists(paths.shadowMetadata))) {
6
+ return null;
7
+ }
8
+ try {
9
+ return (await readJson(paths.shadowMetadata)) as ShadowMetadata;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export async function writeMetadata(paths: ConsumerPaths, meta: ShadowMetadata): Promise<void> {
16
+ await writeJson(paths.shadowMetadata, meta, { spaces: 2 });
17
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ import { SHADOW_DIR_NAME } from '../constants';
3
+ import type { ConsumerPaths } from '../types';
4
+
5
+ export function resolveConsumerPaths(consumerDir: string): ConsumerPaths {
6
+ const shadowDir = path.join(consumerDir, SHADOW_DIR_NAME);
7
+ return {
8
+ consumerDir,
9
+ consumerPkg: path.join(consumerDir, 'package.json'),
10
+ consumerSrc: path.join(consumerDir, 'src'),
11
+ consumerSrcEntry: path.join(consumerDir, 'src', 'index.tsx'),
12
+ shadowDir,
13
+ shadowPkg: path.join(shadowDir, 'package.json'),
14
+ shadowSrc: path.join(shadowDir, 'src'),
15
+ shadowWrapper: path.join(shadowDir, 'src', 'index.tsx'),
16
+ shadowTridentrc: path.join(shadowDir, '.tridentrc.js'),
17
+ shadowNvmrc: path.join(shadowDir, '.nvmrc'),
18
+ shadowDts: path.join(shadowDir, 'src', 'declaration.d.ts'),
19
+ shadowTsconfig: path.join(shadowDir, 'tsconfig.json'),
20
+ shadowBuild: path.join(shadowDir, 'build'),
21
+ shadowDepsHash: path.join(shadowDir, '.deps-hash'),
22
+ shadowMetadata: path.join(shadowDir, 'metadata.json'),
23
+ };
24
+ }
@@ -0,0 +1,34 @@
1
+ import net from 'node:net';
2
+ import { MAX_PORT, MIN_PORT } from '../constants';
3
+
4
+ function randomPort(): number {
5
+ return MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1));
6
+ }
7
+
8
+ /**
9
+ * Tries to bind to `port` on localhost; resolves true if the bind succeeds
10
+ * (port is free) and the listener was cleanly closed.
11
+ */
12
+ export function isPortFree(port: number): Promise<boolean> {
13
+ return new Promise(resolve => {
14
+ const server = net.createServer();
15
+ server.once('error', () => resolve(false));
16
+ server.once('listening', () => server.close(() => resolve(true)));
17
+ server.listen(port, '127.0.0.1');
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Picks a random port from the configured range and verifies it's actually
23
+ * free before returning. Tries up to `MAX_PORT_TRIES` candidates.
24
+ */
25
+ const MAX_PORT_TRIES = 50;
26
+ export async function pickPort(): Promise<number> {
27
+ for (let i = 0; i < MAX_PORT_TRIES; i++) {
28
+ const p = randomPort();
29
+ if (await isPortFree(p)) {
30
+ return p;
31
+ }
32
+ }
33
+ throw new Error(`Could not find a free port in range ${MIN_PORT}-${MAX_PORT} after ${MAX_PORT_TRIES} tries`);
34
+ }
@@ -0,0 +1,68 @@
1
+ import { ensureDir, pathExists, readJson } from 'fs-extra';
2
+ import { info } from '../util/logger';
3
+ import { cleanAppName } from '../util/app-name';
4
+ import { resolveConsumerPaths } from './paths';
5
+ import { ensureGitignore } from './gitignore';
6
+ import { ensureYarnRc } from './yarnrc';
7
+ import { ensureConsumerPkg } from './consumer-pkg';
8
+ import { scaffoldShadow } from './scaffold';
9
+ import { readMetadata, writeMetadata } from './metadata';
10
+ import { isPortFree, pickPort } from './port';
11
+ import { syncShadow } from './sync';
12
+ import type { ConsumerPaths, ConsumerPkg, PreparedShadow, ShadowMetadata } from '../types';
13
+
14
+ export async function prepareShadow(): Promise<PreparedShadow> {
15
+ const paths = resolveConsumerPaths(process.cwd());
16
+
17
+ if (!(await pathExists(paths.consumerPkg))) {
18
+ throw new Error(`No package.json found in ${paths.consumerDir}`);
19
+ }
20
+ if (!(await pathExists(paths.consumerSrcEntry))) {
21
+ throw new Error(`Consumer must have src/index.tsx (in ${paths.consumerDir})`);
22
+ }
23
+
24
+ const rawConsumerPkg = (await readJson(paths.consumerPkg)) as ConsumerPkg;
25
+ if (!rawConsumerPkg.name) {
26
+ throw new Error('Consumer package.json must have a "name" field');
27
+ }
28
+ const appName = cleanAppName(rawConsumerPkg.name);
29
+
30
+ const { pkg: consumerPkg, changed: consumerChanged } = await ensureConsumerPkg(paths.consumerPkg, rawConsumerPkg);
31
+ if (consumerChanged) {
32
+ info(`ensured consumer pkg shape at ${paths.consumerPkg}`);
33
+ }
34
+
35
+ await ensureGitignore(paths.consumerDir);
36
+ await ensureYarnRc(paths.consumerDir);
37
+ await ensureDir(paths.shadowDir);
38
+
39
+ const metadata = await resolvePortAndMetadata(paths, appName);
40
+ const port = metadata.port;
41
+
42
+ info(`preparing shadow at ${paths.shadowDir} (port ${port})`);
43
+ await scaffoldShadow(paths, appName, port);
44
+ await syncShadow(paths, consumerPkg);
45
+
46
+ return { appName, port, paths };
47
+ }
48
+
49
+ /**
50
+ * Resolve the dev server port. Prefer the persisted port if it's still free
51
+ * (URL stays stable across restarts). Otherwise pick a fresh one. The
52
+ * orphaned process — if there is one — keeps running on its old port,
53
+ * harmless. No process killing, no PID files.
54
+ */
55
+ async function resolvePortAndMetadata(paths: ConsumerPaths, appName: string): Promise<ShadowMetadata> {
56
+ const existing = await readMetadata(paths);
57
+ if (existing?.port && (await isPortFree(existing.port))) {
58
+ return existing;
59
+ }
60
+
61
+ const next: ShadowMetadata = {
62
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
63
+ appName,
64
+ port: await pickPort(),
65
+ };
66
+ await writeMetadata(paths, next);
67
+ return next;
68
+ }
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import { ensureDir, writeFile, writeJson } from 'fs-extra';
3
+ import type { ConsumerPaths } from '../types';
4
+
5
+ const WRAPPER_TEMPLATE = `import '@vibe/core/tokens';
6
+ import ConsumerApp from '../../src/index';
7
+ import React from 'react';
8
+ import * as ReactDOM from 'react-dom/client';
9
+ import { ErrorBoundary } from 'react-error-boundary';
10
+
11
+ const roots = new Map<HTMLElement, ReactDOM.Root>();
12
+
13
+ export function init() {}
14
+
15
+ export function render(element: HTMLElement) {
16
+ const root = ReactDOM.createRoot(element);
17
+ root.render(
18
+ <ErrorBoundary fallback={<div style={{ padding: 16 }}>This app crashed.</div>}>
19
+ <ConsumerApp />
20
+ </ErrorBoundary>,
21
+ );
22
+ roots.set(element, root);
23
+ }
24
+
25
+ export function unmount({ element }: { element: HTMLElement }) {
26
+ const root = roots.get(element);
27
+ if (root) {
28
+ root.unmount();
29
+ roots.delete(element);
30
+ }
31
+ }
32
+ `;
33
+
34
+ const tridentrcTemplate = (appName: string, port: number): string =>
35
+ `/** @type {import("@mondaydotcomorg/trident-user-config").MicroFrontendConfig} */
36
+ const config = {
37
+ appName: '${appName}',
38
+ port: ${port},
39
+ };
40
+
41
+ module.exports = config;
42
+ `;
43
+
44
+ const dtsFileTemplate = (): string =>
45
+ `
46
+ declare module '*.module.css' {
47
+ const classNames: {
48
+ [className: string]: string;
49
+ };
50
+ export = classNames;
51
+ }
52
+
53
+ declare module '*.module.scss' {
54
+ const classNames: {
55
+ [className: string]: string;
56
+ };
57
+ export = classNames;
58
+ }
59
+
60
+ declare module '*.jpg' {
61
+ const e: string;
62
+ export default e;
63
+ }
64
+ declare module '*.jpeg' {
65
+ const e: string;
66
+ export default e;
67
+ }
68
+ declare module '*.png' {
69
+ const e: string;
70
+ export default e;
71
+ }
72
+ declare module '*.riv' {
73
+ const e: string;
74
+ export default e;
75
+ }
76
+
77
+ declare module '*.wasm' {
78
+ const e: string;
79
+ export default e;
80
+ }
81
+ `;
82
+
83
+ const TSCONFIG_TEMPLATE = {
84
+ compilerOptions: {
85
+ target: 'es5',
86
+ lib: ['dom', 'dom.iterable', 'esnext'],
87
+ allowJs: false,
88
+ skipLibCheck: true,
89
+ esModuleInterop: true,
90
+ allowSyntheticDefaultImports: true,
91
+ strict: false,
92
+ module: 'esnext',
93
+ moduleResolution: 'bundler',
94
+ resolveJsonModule: true,
95
+ noImplicitAny: false,
96
+ isolatedModules: true,
97
+ baseUrl: 'src',
98
+ noEmit: true,
99
+ jsx: 'react-jsx',
100
+ },
101
+ include: ['src', '../src'],
102
+ };
103
+
104
+ /**
105
+ * Write every generated file under .zth/. Overwrites on every run so the
106
+ * shadow dir is a deterministic function of the CLI version — no drift.
107
+ */
108
+ export async function scaffoldShadow(paths: ConsumerPaths, appName: string, port: number): Promise<void> {
109
+ await ensureDir(paths.shadowSrc);
110
+ await writeFile(paths.shadowWrapper, WRAPPER_TEMPLATE);
111
+ await writeFile(paths.shadowTridentrc, tridentrcTemplate(appName, port));
112
+ await writeFile(paths.shadowNvmrc, `${process.version}\n`);
113
+ await writeFile(paths.shadowDts, dtsFileTemplate());
114
+ await writeJson(paths.shadowTsconfig, TSCONFIG_TEMPLATE, { spaces: 2 });
115
+ // Drop a stub README so contributors who open .zth/ know what they're looking at.
116
+ await writeFile(
117
+ path.join(paths.shadowDir, 'README.md'),
118
+ '# .zth/\nGenerated by z2h-cli. Do not edit — run `z2h-cli build` or `z2h-cli dev` to refresh.\n'
119
+ );
120
+ }