@1agh/maude 0.53.1 → 0.54.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 (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,385 @@
1
+ // The sandbox HOST — Cloud Phase 25 A1 + A1b, moved into the studio by
2
+ // Cloud Phase 27 / DDR-209 A′2.
3
+ //
4
+ // Owns everything ABOUT a canvas build that must not live inside it: the
5
+ // ceilings, the empty environment, the content-hash cache, and the counters the
6
+ // cost surface reads. The build itself is `canvas-build-worker.ts`, running
7
+ // under Bun in its own process.
8
+ //
9
+ // WHY THIS EXISTS AT ALL, given `buildCanvasModule` is one import away. On a
10
+ // desktop, "the process that parses your canvas" and "the process you own" are
11
+ // the same process, so an in-process build costs nothing. In a cell they are
12
+ // not: the server process holds HUB_SECRET, MAUDE_PROJECT_TOKEN_KEY and the
13
+ // tenant's storage credentials, and the source being parsed is written by
14
+ // somebody who is not us. Same engine either way — a different host.
15
+ //
16
+ // THE CACHE IS AN ECONOMIC CONTROL, NOT AN OPTIMISATION (A1b). Rebuilding per
17
+ // page view makes cost scale with VIEWS; keying the built module by the hash
18
+ // of its inputs makes it scale with EDITS. That is the difference between a
19
+ // €19 plan that works and one that does not, which is why the counters below
20
+ // exist from day one rather than being retrofitted after the first invoice.
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { existsSync, readFileSync, statSync } from 'node:fs';
24
+ import { dirname, join, resolve, sep } from 'node:path';
25
+
26
+ import { DEV_SERVER_ROOT } from './paths.ts';
27
+
28
+ /** Wall clock a single canvas build may take. */
29
+ export const BUILD_TIMEOUT_MS = Number(process.env.MAUDE_CANVAS_BUILD_TIMEOUT_MS ?? 20_000);
30
+ /** Resident memory a build process may reach before it is killed. */
31
+ export const BUILD_RSS_LIMIT_MB = Number(process.env.MAUDE_CANVAS_BUILD_RSS_MB ?? 768);
32
+ /** How many built modules to keep. Each is a string; canvases are ~100–400 kB. */
33
+ export const CACHE_MAX_ENTRIES = Number(process.env.MAUDE_CANVAS_CACHE_ENTRIES ?? 64);
34
+
35
+ export interface SandboxBuildOk {
36
+ ok: true;
37
+ js: string;
38
+ locator: unknown;
39
+ etag: string;
40
+ cached: boolean;
41
+ }
42
+ export interface SandboxBuildFail {
43
+ ok: false;
44
+ error: string;
45
+ kind: 'build' | 'timeout' | 'memory' | 'runtime';
46
+ }
47
+ export type SandboxBuildResult = SandboxBuildOk | SandboxBuildFail;
48
+
49
+ const counters = {
50
+ builds: 0,
51
+ cacheHits: 0,
52
+ cacheMisses: 0,
53
+ failures: 0,
54
+ timeouts: 0,
55
+ memoryKills: 0,
56
+ /** Every completed build's wall-clock, newest last, capped. */
57
+ durationsMs: [] as number[],
58
+ };
59
+
60
+ /** A snapshot for the operator surface + the cost lane. */
61
+ export function buildStats() {
62
+ const d = [...counters.durationsMs].sort((a, b) => a - b);
63
+ const p = (q: number) =>
64
+ d.length === 0 ? null : d[Math.min(d.length - 1, Math.floor(d.length * q))];
65
+ const total = counters.cacheHits + counters.cacheMisses;
66
+ return {
67
+ builds: counters.builds,
68
+ cacheHits: counters.cacheHits,
69
+ cacheMisses: counters.cacheMisses,
70
+ cacheHitRatio: total === 0 ? null : Number((counters.cacheHits / total).toFixed(3)),
71
+ failures: counters.failures,
72
+ timeouts: counters.timeouts,
73
+ memoryKills: counters.memoryKills,
74
+ p50Ms: p(0.5),
75
+ p95Ms: p(0.95),
76
+ };
77
+ }
78
+
79
+ /** Test seam. */
80
+ export function _resetBuildStats(): void {
81
+ counters.builds = 0;
82
+ counters.cacheHits = 0;
83
+ counters.cacheMisses = 0;
84
+ counters.failures = 0;
85
+ counters.timeouts = 0;
86
+ counters.memoryKills = 0;
87
+ counters.durationsMs = [];
88
+ cache.clear();
89
+ }
90
+
91
+ /** key → { js, locator, etag } */
92
+ const cache = new Map<string, { js: string; locator: unknown; etag: string }>();
93
+
94
+ /**
95
+ * The cache key: the canvas source plus every sibling source it could pull in.
96
+ *
97
+ * Hashing only the entry file would serve a stale bundle after an edit to an
98
+ * imported component — the exact bug the desktop's mtime-keyed cache had to
99
+ * grow a watcher for. Here the inputs are cheap to enumerate (the design root
100
+ * is small and local), so the key is honest by construction.
101
+ */
102
+ export function cacheKeyFor(designRoot: string, canvasAbs: string): string {
103
+ const h = createHash('sha256');
104
+ h.update(canvasAbs);
105
+ for (const file of relevantSources(designRoot, canvasAbs)) {
106
+ try {
107
+ const st = statSync(file);
108
+ h.update(`\0${file}\0${st.size}\0${st.mtimeMs}`);
109
+ } catch {
110
+ h.update(`\0${file}\0missing`);
111
+ }
112
+ }
113
+ return h.digest('hex');
114
+ }
115
+
116
+ /**
117
+ * Files whose content can change a canvas's bundle: the canvas itself, and
118
+ * every `.tsx`/`.ts`/`.css` file it can reach by relative import inside the
119
+ * design root (transitively). The allowlist guarantees nothing outside can be
120
+ * reached, so nothing outside can invalidate.
121
+ */
122
+ function relevantSources(designRoot: string, canvasAbs: string): string[] {
123
+ const seen = new Set<string>();
124
+ const root = resolve(designRoot);
125
+ const stack = [resolve(canvasAbs)];
126
+ while (stack.length > 0) {
127
+ const file = stack.pop();
128
+ if (!file || seen.has(file) || !existsSync(file)) continue;
129
+ if (file !== root && !file.startsWith(root + sep)) continue;
130
+ seen.add(file);
131
+ let src = '';
132
+ try {
133
+ src = readFileSync(file, 'utf8');
134
+ } catch {
135
+ continue;
136
+ }
137
+ for (const m of src.matchAll(/(?:from|import)\s*["'](\.[^"']+)["']/g)) {
138
+ for (const candidate of resolveCandidates(dirname(file), m[1])) stack.push(candidate);
139
+ }
140
+ }
141
+ return [...seen].sort();
142
+ }
143
+
144
+ function resolveCandidates(fromDir: string, spec: string): string[] {
145
+ const base = resolve(fromDir, spec);
146
+ return [
147
+ base,
148
+ `${base}.tsx`,
149
+ `${base}.ts`,
150
+ `${base}.jsx`,
151
+ `${base}.js`,
152
+ join(base, 'index.tsx'),
153
+ join(base, 'index.ts'),
154
+ ];
155
+ }
156
+
157
+ function remember(key: string, value: { js: string; locator: unknown; etag: string }): void {
158
+ cache.set(key, value);
159
+ while (cache.size > CACHE_MAX_ENTRIES) {
160
+ const oldest = cache.keys().next().value as string;
161
+ cache.delete(oldest);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Resolve the runtime that runs the worker.
167
+ *
168
+ * In the cell image this is the Bun binary staged next to the bundle; in a dev
169
+ * checkout it is whatever `bun` is on PATH — and inside a compiled sidecar it is
170
+ * this very executable re-entered with `BUN_BE_BUN=1` (DDR-177: the packaged app
171
+ * must not need a user-installed runtime). A missing runtime is a configuration
172
+ * error the caller must surface — never a silent fallback to "no rendering",
173
+ * which would look to a member exactly like an empty project.
174
+ */
175
+ export function resolveBunPath(env: NodeJS.ProcessEnv = process.env): string {
176
+ return env.MAUDE_BUN_PATH || 'bun';
177
+ }
178
+
179
+ /** Absolute path to the worker script, resolved per DDR-045 (never a bunfs path). */
180
+ export function workerScript(env: NodeJS.ProcessEnv = process.env): string {
181
+ const staged = env.MAUDE_CANVAS_WORKERS
182
+ ? join(env.MAUDE_CANVAS_WORKERS, 'canvas-build-worker.ts')
183
+ : null;
184
+ if (staged && existsSync(staged)) return staged;
185
+ return join(DEV_SERVER_ROOT, 'canvas-build-worker.ts');
186
+ }
187
+
188
+ /**
189
+ * The child's environment: a PATH to exec with, a HOME for Bun's cache, and
190
+ * the two PATHS that tell it where our own code lives. Nothing else — every
191
+ * secret in a cell is an env var, and a build that cannot read them cannot
192
+ * leak them, whatever a tenant's source manages to import.
193
+ */
194
+ export function workerEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
195
+ return {
196
+ PATH: env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
197
+ HOME: env.HOME ?? '/tmp',
198
+ // Where the engine lives. Defaulted to this install's own root so a cell
199
+ // that stages the studio somewhere unusual still resolves it, and a dev
200
+ // checkout needs no configuration at all.
201
+ MAUDE_STUDIO_SRC: env.MAUDE_STUDIO_SRC ?? DEV_SERVER_ROOT,
202
+ // NAPI_RS_NATIVE_LIBRARY_PATH is forwarded ONLY when it names a real file.
203
+ //
204
+ // Inside a `bun --compile` binary this variable is set at runtime by the
205
+ // DDR-042 compile entry, to a path in that binary's OWN virtual filesystem
206
+ // (`/$bunfs/root/…`). Handing that to a child is handing it an address that
207
+ // exists in a different process's imagination: the child resolves
208
+ // oxc-parser, finds the override, cannot open it, and reports "Cannot find
209
+ // native binding" — while the very same command run by hand with a clean
210
+ // environment works, because it then finds the real binding on disk.
211
+ // (Which is exactly how this was diagnosed.)
212
+ ...(env.NAPI_RS_NATIVE_LIBRARY_PATH && !isVirtualPath(env.NAPI_RS_NATIVE_LIBRARY_PATH)
213
+ ? { NAPI_RS_NATIVE_LIBRARY_PATH: env.NAPI_RS_NATIVE_LIBRARY_PATH }
214
+ : {}),
215
+ // DDR-177 — when the "bun" we spawn is the compiled sidecar itself, this is
216
+ // what makes it behave as a JS runtime instead of re-launching the server.
217
+ ...(env.MAUDE_BUN_PATH && env.MAUDE_BUN_PATH === process.execPath ? { BUN_BE_BUN: '1' } : {}),
218
+ };
219
+ }
220
+
221
+ /** A path inside a compiled binary's embedded filesystem — real to that process
222
+ * and to nothing else. Mirrors `paths.ts`'s own bunfs test. */
223
+ function isVirtualPath(p: string): boolean {
224
+ return p.startsWith('/$bunfs') || p.startsWith('B:/~BUN');
225
+ }
226
+
227
+ /**
228
+ * Whether the sandbox is actually wired in this install.
229
+ *
230
+ * This is the `sandboxArmed` input to the containment boot-assert (DDR-209 A′1):
231
+ * a cell may serve `/_canvas-shell` and `/_canvas-runtime` only while a real,
232
+ * runnable, out-of-process build exists. Both halves are checked because either
233
+ * one missing means the same thing in practice — the server would fall back to
234
+ * building tenant source in the process that holds the secrets.
235
+ *
236
+ * Reported rather than thrown so the boot-assert owns the refusal and its
237
+ * wording; this function has no opinion about what a caller does with `false`.
238
+ */
239
+ export function isSandboxArmed(env: NodeJS.ProcessEnv = process.env): boolean {
240
+ if (!existsSync(workerScript(env))) return false;
241
+ const bun = resolveBunPath(env);
242
+ // A bare `bun` means "whatever is on PATH" and we cannot cheaply prove it is
243
+ // there; an absolute path we can. Both are accepted — the spawn's own failure
244
+ // is the backstop, and refusing to boot because PATH lookup is unverifiable
245
+ // would make a dev checkout unable to test workspace mode at all.
246
+ return bun === 'bun' || existsSync(bun);
247
+ }
248
+
249
+ /**
250
+ * Build one canvas, sandboxed.
251
+ */
252
+ export async function buildCanvasSandboxed({
253
+ designRoot,
254
+ canvasAbs,
255
+ env = process.env,
256
+ }: {
257
+ designRoot: string;
258
+ canvasAbs: string;
259
+ env?: NodeJS.ProcessEnv;
260
+ }): Promise<SandboxBuildResult> {
261
+ const key = cacheKeyFor(designRoot, canvasAbs);
262
+ const hit = cache.get(key);
263
+ if (hit) {
264
+ counters.cacheHits++;
265
+ return { ...hit, ok: true, cached: true };
266
+ }
267
+ counters.cacheMisses++;
268
+
269
+ const started = Date.now();
270
+ const result = await runWorker({ designRoot, canvasAbs, env });
271
+ const elapsed = Date.now() - started;
272
+ counters.durationsMs.push(elapsed);
273
+ if (counters.durationsMs.length > 200) counters.durationsMs.shift();
274
+
275
+ if (result.ok) {
276
+ counters.builds++;
277
+ const value = { js: result.js, locator: result.locator, etag: result.etag };
278
+ remember(key, value);
279
+ return { ...value, ok: true, cached: false };
280
+ }
281
+ counters.failures++;
282
+ if (result.kind === 'timeout') counters.timeouts++;
283
+ if (result.kind === 'memory') counters.memoryKills++;
284
+ return result;
285
+ }
286
+
287
+ async function runWorker({
288
+ designRoot,
289
+ canvasAbs,
290
+ env,
291
+ }: {
292
+ designRoot: string;
293
+ canvasAbs: string;
294
+ env: NodeJS.ProcessEnv;
295
+ }): Promise<SandboxBuildResult> {
296
+ const bun = resolveBunPath(env);
297
+ let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
298
+ try {
299
+ child = Bun.spawn([bun, workerScript(env), designRoot, canvasAbs], {
300
+ // THE EMPTY ENVIRONMENT IS THE POINT — see workerEnv().
301
+ env: workerEnv(env),
302
+ cwd: designRoot,
303
+ stdin: 'ignore',
304
+ stdout: 'pipe',
305
+ stderr: 'pipe',
306
+ });
307
+ } catch (err) {
308
+ return {
309
+ ok: false,
310
+ kind: 'runtime',
311
+ error: `could not start the build: ${(err as Error).message}`,
312
+ };
313
+ }
314
+
315
+ let outcome: SandboxBuildFail | null = null;
316
+
317
+ const deadline = setTimeout(() => {
318
+ outcome = {
319
+ ok: false,
320
+ kind: 'timeout',
321
+ error: `this canvas took longer than ${Math.round(BUILD_TIMEOUT_MS / 1000)}s to build and was stopped.`,
322
+ };
323
+ child.kill('SIGKILL');
324
+ }, BUILD_TIMEOUT_MS);
325
+
326
+ // RSS ceiling. A hard `ulimit -v` is the obvious alternative and does not
327
+ // work: JS runtimes reserve enormous virtual address space and would die
328
+ // at boot. Polling resident size is honest about what is actually being
329
+ // consumed on our bill.
330
+ const rssPoll = setInterval(() => {
331
+ const mb = rssMb(child.pid);
332
+ if (mb !== null && mb > BUILD_RSS_LIMIT_MB) {
333
+ outcome = {
334
+ ok: false,
335
+ kind: 'memory',
336
+ error: `this canvas needed more than ${BUILD_RSS_LIMIT_MB} MB to build and was stopped.`,
337
+ };
338
+ child.kill('SIGKILL');
339
+ }
340
+ }, 500);
341
+ rssPoll.unref?.();
342
+
343
+ let out = '';
344
+ let errOut = '';
345
+ try {
346
+ [out, errOut] = await Promise.all([
347
+ new Response(child.stdout).text(),
348
+ new Response(child.stderr).text(),
349
+ ]);
350
+ await child.exited;
351
+ } catch (err) {
352
+ return { ok: false, kind: 'runtime', error: `the build failed: ${(err as Error).message}` };
353
+ } finally {
354
+ clearTimeout(deadline);
355
+ clearInterval(rssPoll);
356
+ }
357
+
358
+ if (outcome) return outcome;
359
+
360
+ try {
361
+ const parsed = JSON.parse(out);
362
+ if (parsed.ok) {
363
+ return { ok: true, js: parsed.js, locator: parsed.locator, etag: parsed.etag, cached: false };
364
+ }
365
+ return { ok: false, kind: 'build', error: String(parsed.error) };
366
+ } catch {
367
+ return {
368
+ ok: false,
369
+ kind: 'runtime',
370
+ error: `the build produced no result${errOut ? `: ${errOut.trim().slice(0, 500)}` : ''}`,
371
+ };
372
+ }
373
+ }
374
+
375
+ /** Resident set size of a pid in MB, or null when it cannot be read. */
376
+ function rssMb(pid: number | undefined): number | null {
377
+ if (!pid) return null;
378
+ try {
379
+ // Linux (the cell). /proc/<pid>/statm reports pages; page size is 4 KiB.
380
+ const statm = readFileSync(`/proc/${pid}/statm`, 'utf8').split(' ');
381
+ return (Number(statm[1]) * 4096) / (1024 * 1024);
382
+ } catch {
383
+ return null; // macOS dev boxes: the wall-clock ceiling still applies
384
+ }
385
+ }
@@ -0,0 +1,85 @@
1
+ // The cell's canvas build, in its own process — Cloud Phase 25 A1, moved to the
2
+ // studio by Cloud Phase 27 / DDR-209 A′2.
3
+ //
4
+ // WHY A SEPARATE PROCESS, AND WHY BUN.
5
+ //
6
+ // Phase 25 A0 decided the cell BUILDS a tenant's canvas and the member's browser
7
+ // EVALUATES it. "Build is not evaluation" is only true if the build cannot be
8
+ // made to do anything else, so the build runs where it can be bounded:
9
+ //
10
+ // - its own process, spawned with an EMPTY environment — the derived cell
11
+ // secret, the project token key and the tenant's storage credentials are
12
+ // all env vars in the server, and none of them exist here;
13
+ // - a wall-clock deadline and an RSS ceiling enforced by the parent, so a
14
+ // pathological import graph costs one killed process, not the cell;
15
+ // - an import allowlist (canvas-build.ts `restrictImportsTo`), so a tenant's
16
+ // source can reach the runtime packages, `@maude/canvas-lib`, and its own
17
+ // files — and nothing else on this disk.
18
+ //
19
+ // WHY IT MOVED. It used to live at `apps/hub/src/canvas/build-worker.ts` and
20
+ // import this engine across the repo. DDR-209 A′2 deletes the hub's canvas
21
+ // implementation and runs the REAL studio in the cell, so the host has to live
22
+ // where the route it protects lives. Nothing about the contract changed — the
23
+ // empty env, the allowlist and the ceilings are the same ones, and
24
+ // `scripts/check-containment.sh` still asserts every one of them.
25
+ //
26
+ // It runs under BUN rather than the hub's Node so the output is the SAME
27
+ // artifact the desktop produces: same `Bun.hash`-derived `data-cd-id`s (so a
28
+ // comment anchored in the browser resolves in the desktop and back), and the
29
+ // same `Bun.build` bundle. The alternative — esbuild — was measured at 0.6%
30
+ // median drift in the Phase 21 spike; identical beats measured-close, and it
31
+ // removes a whole class of "renders differently in the browser" bug reports.
32
+ //
33
+ // Protocol: argv gives the design root and the canvas path; stdout carries one
34
+ // JSON object. Nothing else is printed on the happy path, so the parent parses
35
+ // stdout wholesale.
36
+
37
+ import { join } from 'node:path';
38
+
39
+ const [, , designRoot, canvasAbs] = process.argv;
40
+
41
+ /**
42
+ * Where the build engine lives.
43
+ *
44
+ * A DYNAMIC import, not a static one, because this file runs from two very
45
+ * different layouts: a dev checkout (a sibling of `canvas-build.ts`) and the
46
+ * cell image, where the Dockerfile stages the studio source wherever it likes.
47
+ * `MAUDE_STUDIO_SRC` is passed through the sandbox's otherwise-empty environment
48
+ * on purpose — it is a path, not a secret.
49
+ *
50
+ * `import.meta.dir`, NOT `paths.ts`: this file is deliberately dependency-free
51
+ * so the sandboxed child imports the engine and nothing else. DDR-045's rule is
52
+ * about the SERVER's disk paths under `bun --compile`; the worker is never
53
+ * compiled — it is always run as source by an explicit `bun <path>` the parent
54
+ * resolved through `paths.ts` already.
55
+ */
56
+ function studioDir(): string {
57
+ return process.env.MAUDE_STUDIO_SRC || import.meta.dir;
58
+ }
59
+
60
+ async function main() {
61
+ if (!designRoot || !canvasAbs) {
62
+ process.stdout.write(JSON.stringify({ ok: false, error: 'usage: <designRoot> <canvasAbs>' }));
63
+ process.exit(2);
64
+ }
65
+ const { buildCanvasModule } = await import(join(studioDir(), 'canvas-build.ts'));
66
+ const source = await Bun.file(canvasAbs).text();
67
+ const built = await buildCanvasModule(canvasAbs, source, {
68
+ designRoot,
69
+ restrictImportsTo: designRoot,
70
+ });
71
+ process.stdout.write(
72
+ JSON.stringify({ ok: true, js: built.js, locator: built.locator, etag: built.etag })
73
+ );
74
+ }
75
+
76
+ main().catch((err) => {
77
+ // The message is the product surface: a rejected import, a syntax error or a
78
+ // missing sibling all arrive here, and the person who wrote the canvas is
79
+ // the one who has to act on it. Bun collapses plugin throws into the build
80
+ // log, so the allowlist's own wording survives in `err.message`.
81
+ process.stdout.write(
82
+ JSON.stringify({ ok: false, error: String(err?.message ?? err).slice(0, 4000) })
83
+ );
84
+ process.exit(1);
85
+ });