@mosetta/cli 0.0.0-stage → 0.0.10

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,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
+ }