@mosetta/cli 0.0.0-stage → 0.0.9

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.
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import { sectionLog } from './log.mjs';
6
+
7
+ // App-level typecheck: the build pipeline itself never checks types (esbuild strips them
8
+ // blindly), so the CLI runs the CONSUMER'S OWN tsc against the app root — @mosetta/core ships
9
+ // `typescript` as a dependency, so any mosetta closure has a binary to resolve. Both entry
10
+ // points are safe-to-lose: no tsconfig / no typescript / checker crash → one notice line and
11
+ // the dev/build flow continues exactly as before the feature existed.
12
+
13
+ function typecheckTargets(root) {
14
+ const tsconfig = path.join(root, 'tsconfig.json');
15
+ if (!fs.existsSync(tsconfig)) return { tsconfig: null, tscBin: null };
16
+ try {
17
+ const req = createRequire(path.join(root, 'package.json'));
18
+ const pkgPath = req.resolve('typescript/package.json');
19
+ return { tsconfig, tscBin: path.join(path.dirname(pkgPath), 'lib', 'tsc.js') };
20
+ } catch {
21
+ return { tsconfig, tscBin: null };
22
+ }
23
+ }
24
+
25
+ // tsc output line-by-line into the section log (headers/coloring live in the logger)
26
+ function forwardTo(log) {
27
+ return (chunk) => {
28
+ const text = String(chunk).replace(/\n+$/, '');
29
+ if (text.trim()) log(text);
30
+ };
31
+ }
32
+
33
+ /** blocking gate for `mosetta build` — resolves false ONLY on real type errors */
34
+ export function runTypecheck({ root, log = sectionLog('typescript') } = {}) {
35
+ const { tsconfig, tscBin } = typecheckTargets(root);
36
+ if (!tsconfig) {
37
+ log('no tsconfig.json at the app root — typecheck skipped');
38
+ return Promise.resolve(true);
39
+ }
40
+ if (!tscBin) {
41
+ log('typescript is not resolvable from the app root — typecheck skipped');
42
+ return Promise.resolve(true);
43
+ }
44
+ return new Promise((resolve) => {
45
+ const child = spawn(
46
+ process.execPath,
47
+ [tscBin, '--noEmit', '-p', root, '--pretty', 'false'],
48
+ { stdio: ['ignore', 'pipe', 'pipe'] },
49
+ );
50
+ const forward = forwardTo(log);
51
+ child.stdout.on('data', forward);
52
+ child.stderr.on('data', forward);
53
+ child.on('error', () => resolve(true)); // checker failed to run ≠ type errors
54
+ child.on('exit', (code) => resolve(code === 0));
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Dev companion (the fork-ts-checker idea without the plugin bridge): tsc has its own watcher
60
+ * and incremental checker, so a plain `tsc --watch` child next to the dev server is enough —
61
+ * it never blocks or slows rebuilds, its findings go to the dev log under the section header.
62
+ */
63
+ export function startTypecheckWatch({ root, log = sectionLog('typescript'), onResult } = {}) {
64
+ const { tsconfig, tscBin } = typecheckTargets(root);
65
+ if (!tsconfig || !tscBin) return null;
66
+ const child = spawn(
67
+ process.execPath,
68
+ [tscBin, '--noEmit', '-p', root, '--watch', '--preserveWatchOutput', '--pretty', 'false'],
69
+ { stdio: ['ignore', 'pipe', 'pipe'] },
70
+ );
71
+ let stopped = false;
72
+ const forward = forwardTo(log);
73
+ // `tsc --watch` has no "run started" marker, it just writes to the stream. But its END is
74
+ // explicit — the "Found N errors" line. So we buffer the stream up to that line and report
75
+ // on it: no errors → the status section clears, errors → the accumulated text goes there.
76
+ const DONE_RE = /Found (\d+) errors?/;
77
+ let buffer = '';
78
+ const collect = (chunk) => {
79
+ forward(chunk);
80
+ if (!onResult) return;
81
+ buffer += String(chunk);
82
+ const done = DONE_RE.exec(buffer);
83
+ if (!done) {
84
+ if (buffer.length > 200_000) buffer = buffer.slice(-100_000); // in case the stream never emits the marker
85
+ return;
86
+ }
87
+ const errors = Number(done[1]) > 0
88
+ // messages are lines like `file(1,2): error TS…`; watcher noise stays out of status
89
+ ? buffer.split('\n').filter((l) => l.includes('error TS')).join('\n').trim()
90
+ : null;
91
+ onResult(errors || null);
92
+ buffer = '';
93
+ };
94
+ child.stdout.on('data', collect);
95
+ child.stderr.on('data', collect);
96
+ child.on('error', () => { /* safe-to-lose: dev runs without live typecheck */ });
97
+ child.on('exit', (code) => {
98
+ if (!stopped) log(`checker exited (code=${code}) — dev continues without live typecheck`);
99
+ });
100
+ return {
101
+ child,
102
+ stop() {
103
+ stopped = true;
104
+ try { child.kill(); } catch { /* ignore */ }
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { sectionLog } from './log.mjs';
5
+
6
+ // `mosetta version <patch|minor|major> [--package <namespace | package name>]`: a deployed
7
+ // version never changes (deploy.mjs refuses new bytes under it), so a change ships under a new
8
+ // one. This raises it where it lives — the `version` of the distribution's package.json — and
9
+ // nothing else: the build names every module after it.
10
+
11
+ const LEVELS = ['patch', 'minor', 'major'];
12
+
13
+ /** the next X.Y.Z at `level` */
14
+ export function nextVersion(version, level) {
15
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? '');
16
+ if (!match) throw new Error(`version "${version}" is not X.Y.Z`);
17
+ if (!LEVELS.includes(level)) throw new Error(`"${level}" is not one of ${LEVELS.join(', ')}`);
18
+ const [major, minor, patch] = match.slice(1).map(Number);
19
+ if (level === 'major') return `${major + 1}.0.0`;
20
+ if (level === 'minor') return `${major}.${minor + 1}.0`;
21
+ return `${major}.${minor}.${patch + 1}`;
22
+ }
23
+
24
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
25
+
26
+ /** the directory of a package resolved from the app's root — walked up from its entry */
27
+ function packageDirOfName(root, name) {
28
+ const requireFromRoot = createRequire(path.join(root, 'package.json'));
29
+ let dir = path.dirname(fs.realpathSync(requireFromRoot.resolve(name)));
30
+ for (;;) {
31
+ const file = path.join(dir, 'package.json');
32
+ if (fs.existsSync(file) && readJson(file).name === name) return dir;
33
+ const parent = path.dirname(dir);
34
+ if (parent === dir) throw new Error(`package ${name}: its package.json was not found`);
35
+ dir = parent;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The directory of what `target` names: a package name (`@acme/ui`), a tile palette's brand
41
+ * (`acme-ui` — the app itself or one of its libraries), or — nothing named — the app itself.
42
+ */
43
+ export function distributionDir(root, target) {
44
+ if (!target) return root;
45
+ if (target.startsWith('@') || target.includes('/')) return packageDirOfName(root, target);
46
+ const config = readJson(path.join(root, 'mosetta.json'));
47
+ if (config.brand === target) return root;
48
+ for (const library of [...(config.libraries ?? []), ...(config.devLibraries ?? [])]) {
49
+ const dir = packageDirOfName(root, library);
50
+ const own = path.join(dir, 'mosetta.json');
51
+ if (fs.existsSync(own) && readJson(own).brand === target) return dir;
52
+ }
53
+ throw new Error(`no tile palette of ${root} has the brand "${target}"`);
54
+ }
55
+
56
+ /** raise the version of `target` at `level`; the package.json keeps its own formatting */
57
+ export function raiseVersion(root, { target, level, log = sectionLog('version') } = {}) {
58
+ const dir = distributionDir(root, target);
59
+ const file = path.join(dir, 'package.json');
60
+ const text = fs.readFileSync(file, 'utf8');
61
+ const { name, version } = JSON.parse(text);
62
+ const next = nextVersion(version, level);
63
+ fs.writeFileSync(file, text.replace(/("version"\s*:\s*")[^"]*(")/, `$1${next}$2`));
64
+ log(`${name}: ${version} → ${next}`);
65
+ return { name, from: version, to: next, dir };
66
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * The ONLY build engine of the framework: `@rollup/browser` for the module graph +
5
+ * `esbuild-wasm` for transpilation and private monoliths. The same set ships into the isolate,
6
+ * so dev, CI and the platform build the graph with the same code.
7
+ *
8
+ * We deliberately have no native binaries: per-OS platform builds (and their differences),
9
+ * optional dependencies in CI and differential "two engines produce the same bytes" checks are
10
+ * a class of problems that simply never arises with a single wasm engine. Extra speed — the
11
+ * `mosetta.engine.mjs` hook (engineLoader.mjs); the rules of the game then shift to the developer.
12
+ *
13
+ * IMPORTANT about the FS: a wasm build has no FS of ITS OWN, it takes the host's syscall shim —
14
+ * in node that is the real `fs` (the engine reads the disk!), in workerd — nothing. So "wasm
15
+ * engine" does not guarantee portability by itself; the gate is the virtual world (memory FS).
16
+ */
17
+
18
+ // `@rollup/browser` targets the browser and pulls its wasm via `fetch` from `import.meta.url`.
19
+ // In node the file:// scheme has no fetch. We install a NARROW shim: only file://*.wasm is read
20
+ // from disk, everything else delegates to the real fetch. The isolate does not need this shim —
21
+ // wasm arrives there as a declared module (workerd forbids runtime wasm compilation).
22
+ let fetchPatched = false;
23
+ function installFileWasmFetch() {
24
+ if (fetchPatched) return;
25
+ fetchPatched = true;
26
+ const original = globalThis.fetch;
27
+ globalThis.fetch = async (input, init) => {
28
+ const url = String(input?.url ?? input);
29
+ if (url.startsWith('file://') && url.endsWith('.wasm')) {
30
+ return new Response(fs.readFileSync(new URL(url)), { headers: { 'content-type': 'application/wasm' } });
31
+ }
32
+ return original(input, init);
33
+ };
34
+ }
35
+
36
+ // esbuild-wasm initializes EXACTLY ONCE per process — a second call throws
37
+ let engine = null;
38
+ export function createWasmEngine() {
39
+ if (!engine) engine = build();
40
+ return engine;
41
+ }
42
+
43
+ async function build() {
44
+ installFileWasmFetch();
45
+ const [{ rollup }, esbuild] = await Promise.all([
46
+ import('@rollup/browser'),
47
+ import('esbuild-wasm'),
48
+ ]);
49
+ await esbuild.initialize({ worker: false });
50
+ return {
51
+ rollup,
52
+ transform: (code, options) => esbuild.transform(code, options),
53
+ bundle: (options) => esbuild.build(options),
54
+ };
55
+ }
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import net from 'node:net';
5
+ import { spawn } from 'node:child_process';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const require = createRequire(import.meta.url);
9
+
10
+ // Locate the workerd binary. The `workerd` npm package's default export is the path to the platform
11
+ // binary (like esbuild); fall back to its bin shim, then PATH.
12
+ export function resolveWorkerdBin() {
13
+ try {
14
+ const p = require('workerd');
15
+ if (typeof p === 'string') return p;
16
+ if (p && typeof p.default === 'string') return p.default;
17
+ } catch { /* not installed yet */ }
18
+ try {
19
+ return require.resolve('workerd/bin/workerd');
20
+ } catch { /* ignore */ }
21
+ return 'workerd'; // assume on PATH
22
+ }
23
+
24
+ // Write the bundled worker + a .capnp config into a fresh temp dir. The worker fetches artifacts/pages
25
+ // from the static provider over HTTP, so it needs outbound to localhost (private/local) — granted via a
26
+ // permissive `internet` network service set as globalOutbound. PROVIDER_ORIGIN comes in as a text binding.
27
+ // ctx.exports (loopback RPC bindings) became a default in workerd on this date; before it, the
28
+ // flag is required — and naming it on a later date is an error
29
+ const CTX_EXPORTS_DEFAULT_SINCE = '2025-11-17';
30
+
31
+ export function writeWorkerdConfig({
32
+ workerCode, port, providerOrigin, compatibilityDate = '2024-11-01',
33
+ staticDir = null, appRoot = null, contentRoot = null, providersJson = null,
34
+ }) {
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mosetta-workerd-'));
36
+ fs.writeFileSync(path.join(dir, 'worker.js'), workerCode);
37
+
38
+ // ONE PORT in dev: workerd serves static itself, so a single address is exposed outward
39
+ // (cloudflared/ngrok tunnel). The byte source is a DIRECTORY ON DISK written by node: the
40
+ // `disk` service reads it per request — no separate store or upload protocol is needed.
41
+ // Generations and the lock file live there too.
42
+ // Write boundaries are held by the CONFIG: the worker sees exactly the app's `pages/` and
43
+ // `prefabs/` (writable) and the root (read-only — the runtime `config.json` lives there).
44
+ // Writing into source code is physically impossible for it.
45
+ const devServices = staticDir
46
+ ? `
47
+ (name = "files", disk = (path = ${JSON.stringify(staticDir)}, writable = false)),`
48
+ + (appRoot ? `
49
+ (name = "pages", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/pages`)}, writable = true)),
50
+ (name = "prefabs", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/prefabs`)}, writable = true)),
51
+ (name = "media", disk = (path = ${JSON.stringify(`${contentRoot ?? appRoot}/media`)}, writable = true)),
52
+ (name = "app", disk = (path = ${JSON.stringify(appRoot)}, writable = false)),
53
+ (name = "secrets", disk = (path = ${JSON.stringify(path.join(appRoot, '.mosetta'))}, writable = false)),` : '')
54
+ : '';
55
+ const devBindings = staticDir
56
+ ? `
57
+ (name = "FILES", service = "files"),`
58
+ + (appRoot ? `
59
+ (name = "PAGES", service = "pages"),
60
+ (name = "PREFABS", service = "prefabs"),
61
+ (name = "MEDIA", service = "media"),
62
+ (name = "APP", service = "app"),
63
+ # the project's dev secrets: .mosetta/secrets.json, read by the DevSecrets entrypoint only
64
+ (name = "DEV_SECRETS", service = "secrets"),` : '')
65
+ : '';
66
+
67
+ const capnp = `using Workerd = import "/workerd/workerd.capnp";
68
+
69
+ const config :Workerd.Config = (
70
+ services = [
71
+ (name = "main", worker = .mainWorker),
72
+ (name = "internet", network = (allow = ["public", "private", "local"])),${devServices}
73
+ ],
74
+ sockets = [
75
+ (name = "http", address = "127.0.0.1:${port}", http = (), service = "main"),
76
+ ],
77
+ );
78
+
79
+ const mainWorker :Workerd.Worker = (
80
+ modules = [
81
+ (name = "worker.js", esModule = embed "worker.js"),
82
+ ],
83
+ compatibilityDate = "${compatibilityDate}",${compatibilityDate < CTX_EXPORTS_DEFAULT_SINCE ? `
84
+ # ctx.exports: the store capability stubs isolates get instead of keys (default since ${CTX_EXPORTS_DEFAULT_SINCE})
85
+ compatibilityFlags = ["enable_ctx_exports"],` : ''}
86
+ globalOutbound = "internet",
87
+ bindings = [
88
+ # the app's provider when no trusted-layer headers arrive (dev default)
89
+ (name = "DEFAULT_PROVIDER_ORIGIN", text = ${JSON.stringify(providerOrigin)}),${providersJson ? `
90
+ # EXPLICIT provider registry with credentials: the runner registry + the dev-S3 key table
91
+ (name = "PROVIDERS", text = ${JSON.stringify(providersJson)}),` : ''}
92
+ # Worker Loader: the thin runner composes EVERY isolate with it. Requires --experimental.
93
+ (name = "LOADER", workerLoader = ()),${devBindings}
94
+ ],
95
+ );
96
+ `;
97
+ const configPath = path.join(dir, 'config.capnp');
98
+ fs.writeFileSync(configPath, capnp);
99
+ return { dir, configPath };
100
+ }
101
+
102
+ /**
103
+ * Is the port free. Before startup this matters more than it seems: a workerd copy that survived
104
+ * a previous run holds the address, the new one silently fails to come up — and a "readiness
105
+ * timed out" message points the wrong way.
106
+ */
107
+ export function probePort(port, host = '127.0.0.1') {
108
+ return new Promise((resolve) => {
109
+ const socket = net.connect({ port, host });
110
+ const done = (busy) => {
111
+ socket.destroy();
112
+ resolve(busy);
113
+ };
114
+ socket.once('connect', () => done(true));
115
+ socket.once('error', () => done(false));
116
+ socket.setTimeout(500, () => done(false));
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Kill the workerd child for sure: politely first (SIGTERM), then SIGKILL, and wait for the
122
+ * actual exit. Otherwise a surviving copy occupies the port and the next `mosetta dev` cannot
123
+ * start (workerd does not always die instantly on SIGTERM, and its inherited stdio pipes also
124
+ * keep the parent alive).
125
+ */
126
+ export async function killWorkerd(child, { graceMs = 1500, forceMs = 1500 } = {}) {
127
+ if (!child || child.exitCode !== null || child.signalCode !== null) return true;
128
+ const exited = (ms) => new Promise((resolve) => {
129
+ if (child.exitCode !== null || child.signalCode !== null) return resolve(true);
130
+ const timer = setTimeout(() => resolve(false), ms);
131
+ child.once('exit', () => {
132
+ clearTimeout(timer);
133
+ resolve(true);
134
+ });
135
+ });
136
+ try { child.kill('SIGTERM'); } catch { /* already dead */ }
137
+ if (await exited(graceMs)) return true;
138
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
139
+ return exited(forceMs);
140
+ }
141
+
142
+ // Spawn `workerd serve config.capnp` and wait until it answers HTTP.
143
+ // `log` (optional) wraps workerd output into the section log; without it — inherit as before.
144
+ export async function spawnWorkerd({ binPath, configPath, port, timeoutMs = 15000, log = null }) {
145
+ // --experimental enables the Worker Loader binding (server-action isolates).
146
+ const stdio = log ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'];
147
+ const child = spawn(binPath, ['serve', configPath, '--experimental'], { stdio });
148
+ if (log) {
149
+ const forward = (chunk) => {
150
+ const text = String(chunk).replace(/\n+$/, '');
151
+ if (text.trim()) log(text);
152
+ };
153
+ child.stdout.on('data', forward);
154
+ child.stderr.on('data', forward);
155
+ }
156
+ let exited = null;
157
+ child.on('exit', (code, signal) => { exited = { code, signal }; });
158
+
159
+ const deadline = Date.now() + timeoutMs;
160
+ const url = `http://127.0.0.1:${port}/__mosetta_ping__`;
161
+ while (Date.now() < deadline) {
162
+ if (exited) throw new Error(`workerd exited early (code=${exited.code} signal=${exited.signal})`);
163
+ try {
164
+ await fetch(url); // any HTTP response (incl. 404) means the socket is up
165
+ return child;
166
+ } catch {
167
+ await new Promise((r) => setTimeout(r, 100));
168
+ }
169
+ }
170
+ try { child.kill(); } catch { /* ignore */ }
171
+ throw new Error(`workerd did not become ready on :${port} within ${timeoutMs}ms`);
172
+ }