@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.
- package/README.md +16 -0
- package/bin/z2h-cli.js +2 -0
- package/dist/commands/build.d.ts +5 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +37 -0
- package/dist/commands/clean.d.ts +5 -0
- package/dist/commands/clean.d.ts.map +1 -0
- package/dist/commands/clean.js +24 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +92 -0
- package/dist/commands/dev.d.ts +5 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +58 -0
- package/dist/constants.d.ts +25 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +67 -0
- package/dist/esm/commands/build.d.ts +5 -0
- package/dist/esm/commands/build.d.ts.map +1 -0
- package/dist/esm/commands/build.mjs +35 -0
- package/dist/esm/commands/clean.d.ts +5 -0
- package/dist/esm/commands/clean.d.ts.map +1 -0
- package/dist/esm/commands/clean.mjs +22 -0
- package/dist/esm/commands/deploy.d.ts +6 -0
- package/dist/esm/commands/deploy.d.ts.map +1 -0
- package/dist/esm/commands/deploy.mjs +86 -0
- package/dist/esm/commands/dev.d.ts +5 -0
- package/dist/esm/commands/dev.d.ts.map +1 -0
- package/dist/esm/commands/dev.mjs +56 -0
- package/dist/esm/constants.d.ts +25 -0
- package/dist/esm/constants.d.ts.map +1 -0
- package/dist/esm/constants.mjs +57 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.mjs +86 -0
- package/dist/esm/shadow/consumer-pkg.d.ts +24 -0
- package/dist/esm/shadow/consumer-pkg.d.ts.map +1 -0
- package/dist/esm/shadow/consumer-pkg.mjs +81 -0
- package/dist/esm/shadow/gitignore.d.ts +7 -0
- package/dist/esm/shadow/gitignore.d.ts.map +1 -0
- package/dist/esm/shadow/gitignore.mjs +29 -0
- package/dist/esm/shadow/merge-package-json.d.ts +16 -0
- package/dist/esm/shadow/merge-package-json.d.ts.map +1 -0
- package/dist/esm/shadow/merge-package-json.mjs +58 -0
- package/dist/esm/shadow/metadata.d.ts +4 -0
- package/dist/esm/shadow/metadata.d.ts.map +1 -0
- package/dist/esm/shadow/metadata.mjs +18 -0
- package/dist/esm/shadow/paths.d.ts +3 -0
- package/dist/esm/shadow/paths.d.ts.map +1 -0
- package/dist/esm/shadow/paths.mjs +25 -0
- package/dist/esm/shadow/port.d.ts +7 -0
- package/dist/esm/shadow/port.d.ts.map +1 -0
- package/dist/esm/shadow/port.mjs +34 -0
- package/dist/esm/shadow/prepare.d.ts +3 -0
- package/dist/esm/shadow/prepare.d.ts.map +1 -0
- package/dist/esm/shadow/prepare.mjs +60 -0
- package/dist/esm/shadow/scaffold.d.ts +7 -0
- package/dist/esm/shadow/scaffold.d.ts.map +1 -0
- package/dist/esm/shadow/scaffold.mjs +112 -0
- package/dist/esm/shadow/sync.d.ts +9 -0
- package/dist/esm/shadow/sync.d.ts.map +1 -0
- package/dist/esm/shadow/sync.mjs +29 -0
- package/dist/esm/shadow/yarnrc.d.ts +10 -0
- package/dist/esm/shadow/yarnrc.d.ts.map +1 -0
- package/dist/esm/shadow/yarnrc.mjs +27 -0
- package/dist/esm/types.d.ts +53 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.mjs +1 -0
- package/dist/esm/util/app-name.d.ts +12 -0
- package/dist/esm/util/app-name.d.ts.map +1 -0
- package/dist/esm/util/app-name.mjs +17 -0
- package/dist/esm/util/logger.d.ts +22 -0
- package/dist/esm/util/logger.d.ts.map +1 -0
- package/dist/esm/util/logger.mjs +59 -0
- package/dist/esm/util/manifest.d.ts +31 -0
- package/dist/esm/util/manifest.d.ts.map +1 -0
- package/dist/esm/util/manifest.mjs +31 -0
- package/dist/esm/util/s3/client.d.ts +11 -0
- package/dist/esm/util/s3/client.d.ts.map +1 -0
- package/dist/esm/util/s3/client.mjs +38 -0
- package/dist/esm/util/s3/upload.d.ts +2 -0
- package/dist/esm/util/s3/upload.d.ts.map +1 -0
- package/dist/esm/util/s3/upload.mjs +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/shadow/consumer-pkg.d.ts +24 -0
- package/dist/shadow/consumer-pkg.d.ts.map +1 -0
- package/dist/shadow/consumer-pkg.js +84 -0
- package/dist/shadow/gitignore.d.ts +7 -0
- package/dist/shadow/gitignore.d.ts.map +1 -0
- package/dist/shadow/gitignore.js +35 -0
- package/dist/shadow/merge-package-json.d.ts +16 -0
- package/dist/shadow/merge-package-json.d.ts.map +1 -0
- package/dist/shadow/merge-package-json.js +66 -0
- package/dist/shadow/metadata.d.ts +4 -0
- package/dist/shadow/metadata.d.ts.map +1 -0
- package/dist/shadow/metadata.js +21 -0
- package/dist/shadow/paths.d.ts +3 -0
- package/dist/shadow/paths.d.ts.map +1 -0
- package/dist/shadow/paths.js +31 -0
- package/dist/shadow/port.d.ts +7 -0
- package/dist/shadow/port.d.ts.map +1 -0
- package/dist/shadow/port.js +41 -0
- package/dist/shadow/prepare.d.ts +3 -0
- package/dist/shadow/prepare.d.ts.map +1 -0
- package/dist/shadow/prepare.js +62 -0
- package/dist/shadow/scaffold.d.ts +7 -0
- package/dist/shadow/scaffold.d.ts.map +1 -0
- package/dist/shadow/scaffold.js +118 -0
- package/dist/shadow/sync.d.ts +9 -0
- package/dist/shadow/sync.d.ts.map +1 -0
- package/dist/shadow/sync.js +31 -0
- package/dist/shadow/yarnrc.d.ts +10 -0
- package/dist/shadow/yarnrc.d.ts.map +1 -0
- package/dist/shadow/yarnrc.js +33 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/util/app-name.d.ts +12 -0
- package/dist/util/app-name.d.ts.map +1 -0
- package/dist/util/app-name.js +20 -0
- package/dist/util/logger.d.ts +22 -0
- package/dist/util/logger.d.ts.map +1 -0
- package/dist/util/logger.js +71 -0
- package/dist/util/manifest.d.ts +31 -0
- package/dist/util/manifest.d.ts.map +1 -0
- package/dist/util/manifest.js +36 -0
- package/dist/util/s3/client.d.ts +11 -0
- package/dist/util/s3/client.d.ts.map +1 -0
- package/dist/util/s3/client.js +48 -0
- package/dist/util/s3/upload.d.ts +2 -0
- package/dist/util/s3/upload.d.ts.map +1 -0
- package/dist/util/s3/upload.js +39 -0
- package/package.json +56 -0
- package/src/commands/build.ts +42 -0
- package/src/commands/clean.ts +26 -0
- package/src/commands/deploy.ts +114 -0
- package/src/commands/dev.ts +64 -0
- package/src/constants.ts +63 -0
- package/src/index.ts +90 -0
- package/src/shadow/consumer-pkg.ts +100 -0
- package/src/shadow/gitignore.ts +30 -0
- package/src/shadow/merge-package-json.ts +62 -0
- package/src/shadow/metadata.ts +17 -0
- package/src/shadow/paths.ts +24 -0
- package/src/shadow/port.ts +34 -0
- package/src/shadow/prepare.ts +68 -0
- package/src/shadow/scaffold.ts +120 -0
- package/src/shadow/sync.ts +30 -0
- package/src/shadow/yarnrc.ts +29 -0
- package/src/types.ts +60 -0
- package/src/util/app-name.ts +16 -0
- package/src/util/logger.ts +64 -0
- package/src/util/manifest.ts +65 -0
- package/src/util/s3/client.ts +40 -0
- 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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|