@joystack/cli 0.0.0-stage → 0.1.43

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 (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. package/package.json +27 -4
package/lib/inject.mjs ADDED
@@ -0,0 +1,250 @@
1
+ // HTML/JS dev-serving transforms:
2
+ //
3
+ // * Head injection mirrors the production serve path's entryHeadBlock
4
+ // (backend/api/src/lib/inject.js): window.GAME_SLUG + window.JOYSTACK_IDENTITY
5
+ // (classic scripts, so globals exist before any module runs) + the ONE
6
+ // platform-layer module tag. A global the production path also sets under
7
+ // its old name (LEGACY_PAGE_GLOBALS) is set under both names here too. New
8
+ // platform features are added to shared/platform/platform.js FEATURES —
9
+ // never here.
10
+ // * ?v=<stamp> rewriting on <script src> and JS imports busts Chrome's
11
+ // sticky module cache on edit.
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { LEGACY_PAGE_GLOBALS, withLegacyDoors } from './legacy-names.mjs';
15
+
16
+ // The identity every `joystack dev` page is signed in as.
17
+ export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
18
+
19
+ export const PLATFORM_URL = '/shared/platform/platform.js';
20
+ // The dev toolbox — injected ONLY here, never by the production serve path
21
+ // (backend/api/src/lib/inject.js). That separation is the gate
22
+ // that keeps a dev surface off published pages: the production injector never
23
+ // names this module, so there is nothing to strip and nothing to trust.
24
+ export const TOOLBOX_URL = '/shared/ui/toolbox/toolbox.js';
25
+
26
+ // JSON safe for an inline <script>: `<` → < so a value containing
27
+ // "</script>" can't terminate the script early.
28
+ function jsJson(value) {
29
+ return JSON.stringify(value).replaceAll('<', '\\u003c');
30
+ }
31
+
32
+ // A framework-v2 game imports the bare `joystack[/subpath]` specifiers — the only
33
+ // bare specifiers any browser game uses. The browser needs an import map to
34
+ // resolve them to the framework runtime under the (already-mounted) /shared/
35
+ // tree. This set is the import surface (shared/docs/api.md); keep it in sync
36
+ // with V2_BARE_SPECIFIERS in backend/api/src/lib/inject.js.
37
+ const V2_IMPORTS = {
38
+ joystack: '/shared/framework/runtime/joystack.js',
39
+ 'joystack/traits': '/shared/framework/runtime/traits.js',
40
+ 'joystack/client': '/shared/framework/runtime/client.js',
41
+ 'joystack/renderers/canvas': '/shared/framework/runtime/renderers/canvas.js',
42
+ 'joystack/renderers/three': '/shared/framework/runtime/renderers/three.js',
43
+ // The real harness (runtime/test.js) is Node-only. The browser gets a shim that
44
+ // throws a clear "Node-only" error, so a stray `import 'joystack/test'` in game code
45
+ // never white-screens on a node:fs load. Node resolves joystack/test via the build
46
+ // register hook (expand.js), which points at the real test.js regardless.
47
+ 'joystack/test': '/shared/framework/runtime/test-browser.js',
48
+ // three, mapped to the engine's own vendored same-origin copy so a three-backed
49
+ // game needs no import map of its own (kept in sync with V2_BARE_SPECIFIERS).
50
+ three: '/shared/ui/room/vendor/three.js',
51
+ };
52
+
53
+ // One classic script setting a page global, and its old name when the
54
+ // production serve path sets one too.
55
+ function setGlobal(scope, name, value) {
56
+ const old = LEGACY_PAGE_GLOBALS[name];
57
+ return `<script>${scope}.${name} = ${old ? `${scope}.${old} = ` : ''}${jsJson(value)};</script>`;
58
+ }
59
+
60
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, skeleton = null, shareHost = null } = {}) {
61
+ const parts = [];
62
+ // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
63
+ // reads its lowered graph from globalThis.__JOYSTACK_SKELETON__. BOTH must precede
64
+ // any module script (the platform tag AND game.js): a browser ignores an import
65
+ // map once a module has begun executing, and startGame() reads the skeleton at
66
+ // module-eval time. So they go FIRST.
67
+ if (skeleton) {
68
+ parts.push(`<script type="importmap">${jsJson({ imports: withLegacyDoors(V2_IMPORTS) })}</script>`);
69
+ parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
70
+ }
71
+ // A page served through `joystack dev --share`'s tunnel: the hostname it was
72
+ // shared on, which the engine's isSharedDevPage compares with the page's own
73
+ // before treating a public name as a dev page. Ahead of every module script,
74
+ // like the skeleton, because the room transport reads it as the game boots.
75
+ if (shareHost) parts.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
76
+ if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
77
+ parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
78
+ parts.push(
79
+ `<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
80
+ );
81
+ // Dev-only, opt-in per request: the server sets `toolbox` only when the
82
+ // resolved engine bundle carries the module (inject-if-present), so an older
83
+ // pinned engine under a newer CLI never gets a 404ing tag. Behind the
84
+ // platform tag — the boot gate's error handlers come up first.
85
+ if (toolbox) {
86
+ parts.push(
87
+ `<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
88
+ );
89
+ }
90
+ const inject = '\n ' + parts.join('\n ');
91
+ const m = /<head[^>]*>/i.exec(html);
92
+ if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
93
+ return parts.join('\n') + '\n' + html;
94
+ }
95
+
96
+ // Match `import ... from "X"`, `import "X"`, `import("X")`, `export ... from "X"`.
97
+ const JS_IMPORT_RE = /(\b(?:from|import)\s*\(?\s*)(['"])([^'"\n]+?)\2/g;
98
+ // Match <script src="X"> in HTML.
99
+ const HTML_SCRIPT_RE = /(<script\b[^>]*?\bsrc\s*=\s*["'])([^"']+?)(["'])/gi;
100
+
101
+ // A spec that can never name a local file the server would stamp: absolute
102
+ // URLs, data:/blob:, and anything already carrying a query or fragment.
103
+ function isExternalSpec(spec) {
104
+ return (
105
+ /^(https?:)?\/\//.test(spec) ||
106
+ spec.startsWith('data:') ||
107
+ spec.startsWith('blob:') ||
108
+ spec.includes('?') ||
109
+ spec.includes('#')
110
+ );
111
+ }
112
+
113
+ // Resolve an import specifier the way the BROWSER does — against the importing
114
+ // module's URL — returning an absolute URL path, or null for bare/external
115
+ // specs. Stamping must live in URL space because that is where module identity
116
+ // lives: an engine file's relative sibling import can be shadowed by the
117
+ // game's overrides mount, so the file the URL serves is not the file that sits
118
+ // next to the importer on disk.
119
+ function resolveSpecToUrl(spec, baseUrl, { allowBareRelative = false } = {}) {
120
+ if (isExternalSpec(spec)) return null;
121
+ if (spec.startsWith('/')) return spec;
122
+ if (!spec.startsWith('.') && !allowBareRelative) return null; // bare module specifier
123
+ try {
124
+ return new URL(spec, 'http://x' + baseUrl).pathname;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // The version stamp is the max mtime over the module's TRANSITIVE static
131
+ // import closure, not the file's own mtime. Module identity is the full URL,
132
+ // and a browser may reuse a cached body for an unchanged URL across reloads
133
+ // (Chrome's memory cache does not honor no-store for same-URL subresources).
134
+ // A per-target stamp renames only the edited file's URL — its importers' URLs
135
+ // stay put, so a reloading page reassembles the whole pre-edit graph from
136
+ // cache with zero requests and no error anywhere. Stamping the closure renames
137
+ // every URL on the path from the entry (which a navigation always re-fetches),
138
+ // so no URL-keyed cache can serve stale bytes.
139
+ //
140
+ // The parse cache stores RAW specifiers keyed by file path + mtime; resolution
141
+ // happens per walk, because the same file's specs resolve differently through
142
+ // different mount tables (two servers in one process must not contaminate each
143
+ // other). Boundary: only string-literal static/dynamic imports are seen —
144
+ // import(expr) and new URL(...) are runtime-computed and stay unstamped.
145
+ const parseCache = new Map(); // abs fs path -> { mtimeMs, specs: string[] }
146
+
147
+ function fileInfo(path) {
148
+ let st;
149
+ try {
150
+ st = statSync(path);
151
+ } catch {
152
+ return null;
153
+ }
154
+ const hit = parseCache.get(path);
155
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit;
156
+ const specs = [];
157
+ if (/\.(js|mjs)$/.test(path)) {
158
+ let text = null;
159
+ try {
160
+ text = readFileSync(path, 'utf8');
161
+ } catch {
162
+ /* unreadable: stamp from its own mtime alone */
163
+ }
164
+ if (text !== null) {
165
+ for (const m of text.matchAll(JS_IMPORT_RE)) {
166
+ if (!isExternalSpec(m[3])) specs.push(m[3]);
167
+ }
168
+ }
169
+ }
170
+ const info = { mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, specs };
171
+ parseCache.set(path, info);
172
+ return info;
173
+ }
174
+
175
+ // A rewrite shares resolved nodes across all import closures. Each URL is
176
+ // resolved/stat'ed once even when several roots reach it. The cache belongs to
177
+ // this synchronous rewrite only: the next request observes edits, deletions and
178
+ // newly-created overrides immediately, without waiting for the reload watcher.
179
+ function rewriteContext(resolveUrl) {
180
+ const nodes = new Map();
181
+ return {
182
+ stamps: new Map(),
183
+ node(url) {
184
+ if (!nodes.has(url)) {
185
+ const path = resolveUrl(url);
186
+ nodes.set(url, path ? fileInfo(path) : null);
187
+ }
188
+ return nodes.get(url);
189
+ },
190
+ };
191
+ }
192
+
193
+ function closureStampUrl(urlPath, context) {
194
+ if (context.stamps.has(urlPath)) return context.stamps.get(urlPath);
195
+ let max = 0;
196
+ const seen = new Set();
197
+ const stack = [urlPath];
198
+ while (stack.length) {
199
+ const url = stack.pop();
200
+ if (seen.has(url)) continue;
201
+ seen.add(url);
202
+ const info = context.node(url);
203
+ if (!info) continue;
204
+ max = Math.max(max, info.mtimeMs);
205
+ for (const spec of info.specs) {
206
+ const child = resolveSpecToUrl(spec, url);
207
+ if (child) stack.push(child);
208
+ }
209
+ }
210
+ const stamp = max ? Math.floor(max) : null;
211
+ context.stamps.set(urlPath, stamp);
212
+ return stamp;
213
+ }
214
+
215
+ // Resolve `relPath` and return `<relPath>?v=<closure-stamp>` or null. Stamped
216
+ // extensions include .json: entity/config data is imported by games, and its
217
+ // URL must rename on edit exactly like a module's.
218
+ //
219
+ // `baseUrl` + `resolveUrl` are REQUIRED for stamping: there is deliberately no
220
+ // disk-space fallback. Resolving a relative spec beside the importer on disk
221
+ // stamps the wrong file whenever a mount shadows it (the overrides mechanism),
222
+ // which silently re-opens the staleness hole. No serving context → no stamp.
223
+ function versionedPath(relPath, { baseUrl, resolveUrl, allowBareRelative = false, context }) {
224
+ if (!/\.(js|mjs|json)$/.test(relPath)) return null;
225
+ if (baseUrl == null || !resolveUrl) return null;
226
+ const urlTarget = resolveSpecToUrl(relPath, baseUrl, { allowBareRelative });
227
+ if (!urlTarget) return null;
228
+ if (!context.node(urlTarget)) return null;
229
+ const stamp = closureStampUrl(urlTarget, context);
230
+ return stamp === null ? null : `${relPath}?v=${stamp}`;
231
+ }
232
+
233
+ export function rewriteJsImports(text, { baseUrl, resolveUrl }) {
234
+ const context = rewriteContext(resolveUrl);
235
+ return text.replace(JS_IMPORT_RE, (whole, prefix, quote, path) => {
236
+ const v = versionedPath(path, { baseUrl, resolveUrl, context });
237
+ return `${prefix}${quote}${v ?? path}${quote}`;
238
+ });
239
+ }
240
+
241
+ export function rewriteHtmlScripts(html, { baseUrl, resolveUrl }) {
242
+ const context = rewriteContext(resolveUrl);
243
+ return html.replace(HTML_SCRIPT_RE, (whole, prefix, path, suffix) => {
244
+ const v = versionedPath(path, { baseUrl, resolveUrl, allowBareRelative: true, context });
245
+ return `${prefix}${v ?? path}${suffix}`;
246
+ });
247
+ }
248
+
249
+ // For callers that need the base dir of a served file.
250
+ export { dirname };
@@ -0,0 +1,230 @@
1
+ // A `node_modules/joystack` package, written into a game so that running one of
2
+ // its test files with bare `node` says what to run instead.
3
+ //
4
+ // The joystack doors (`joystack`, `joystack/test`, `joystack/traits`, …) are bare
5
+ // specifiers with nothing behind them on disk: the CLI maps them with a Node
6
+ // resolver hook it registers before running a test
7
+ // (shared/framework/build/joystack-test-preload.mjs). Run a test file any other
8
+ // way and Node reports `Cannot find package 'joystack' imported from <file>` —
9
+ // true, and useless: it names no fix, and the nearest thing that looks like one
10
+ // is the engine's own preload, which is a private build file no game should be
11
+ // reaching for.
12
+ //
13
+ // This package is what Node finds instead. It is never consulted under the CLI:
14
+ // the resolver hook short-circuits every door specifier before Node looks in
15
+ // node_modules, so these files are reached only by a process that has no
16
+ // resolver — which is exactly the process that needs telling.
17
+ //
18
+ // THE GENERATED DOORS IMPORT NOTHING. Every name a door exports is emitted as a
19
+ // literal, and the engine module behind it is never named. Two things go wrong
20
+ // the moment a door imports the real one instead:
21
+ //
22
+ // • Node links a whole static graph before it evaluates any of it, so a door
23
+ // that re-exported the engine could not register the resolver first however
24
+ // it was ordered. `joystack/renderers/three` reaches `/shared/…` statically and
25
+ // died on that, reporting an engine source file the reader has never opened
26
+ // — further from the fix than the error being replaced.
27
+ // • esbuild resolves bare specifiers through node_modules. The room-server
28
+ // build (room-server.mjs) deliberately refuses the browser-only doors so
29
+ // that server code importing one fails LOUDLY; a door that pulled in the
30
+ // engine's Node build layer turned that one clear refusal into six
31
+ // unresolved built-in modules, none of them mentioning joystack.
32
+ //
33
+ // Emitting names as literals means knowing them, so they are read from the real
34
+ // doors once per engine version, in a child process that has the resolver (see
35
+ // readDoorExports). A door then both exports every name and throws on
36
+ // evaluation: the export list is what lets `import { openWorld } from
37
+ // 'joystack/test'` link at all — Node checks imported names before a module body
38
+ // runs, so a door with no exports would report a missing export instead of the
39
+ // message.
40
+ //
41
+ // It REFUSES rather than works, deliberately: `joystack test` is the single way a
42
+ // test runs, because the CLI is also what serves the page a smoke drives, what
43
+ // decides which tests a change needs, and what records the verdict.
44
+ import { spawnSync } from 'node:child_process';
45
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
46
+ import { join, resolve, sep } from 'node:path';
47
+ import { pathToFileURL } from 'node:url';
48
+ import { leaveLegacyEnginePath } from './legacy-names.mjs';
49
+
50
+ // The doors, as the resolver maps them (the `doors` map in
51
+ // shared/framework/build/expand.js, exported as JOYSTACK_DOORS and compared
52
+ // against this list by joystack-node-doors.test.mjs). A door missing here still
53
+ // fails with `Cannot find package 'joystack'` outside the CLI.
54
+ export const DOORS = [
55
+ 'joystack',
56
+ 'joystack/traits',
57
+ 'joystack/client',
58
+ 'joystack/test',
59
+ 'joystack/smoke',
60
+ 'joystack/renderers/canvas',
61
+ 'joystack/renderers/three',
62
+ ];
63
+
64
+ // `joystack/renderers/canvas` → `renderers-canvas.js`; the root door → `index.js`.
65
+ const fileFor = (door) => (door === 'joystack' ? 'index.js' : `${door.slice('joystack/'.length).replaceAll('/', '-')}.js`);
66
+ const subpathFor = (door) => (door === 'joystack' ? '.' : `./${door.slice('joystack/'.length)}`);
67
+
68
+ const MESSAGE_LINES = (door) => [
69
+ `joystack: this file imports \`${door}\`, and the joystack doors only resolve under the CLI —`,
70
+ 'it registers a Node resolver for them before it runs a test file.',
71
+ '',
72
+ ' Run this file as: npx joystack test <name>',
73
+ '',
74
+ "<name> matches part of the file's name, so `npx joystack test pickup` runs pickup.test.mjs.",
75
+ 'The CLI is also what serves the page a smoke drives, what decides which tests a change',
76
+ 'needs, and what writes the result to .joystack/test/ — running the file by hand gives up all three.',
77
+ ];
78
+
79
+ /**
80
+ * The names each door exports, read from the real doors.
81
+ *
82
+ * In a child process, because reading them means importing them and importing
83
+ * them means registering the resolver hook — a process-wide change no command
84
+ * should carry for the sake of an error message. The child gets the engine's own
85
+ * preload, which is the same way `joystack test` runs a test file.
86
+ *
87
+ * @returns {Record<string, string[]>|null} null when the engine cannot be read
88
+ * that way at all, in which case nothing is written and the bare-node error
89
+ * stays as it was
90
+ */
91
+ export function readDoorExports(engineDir, { spawn = spawnSync } = {}) {
92
+ const preload = pathToFileURL(join(engineDir, 'shared', 'framework', 'build', 'joystack-test-preload.mjs')).href;
93
+ const script = [
94
+ `const doors = ${JSON.stringify(DOORS)};`,
95
+ 'const out = {};',
96
+ 'for (const door of doors) {',
97
+ // A door that cannot be imported gets an empty list rather than failing the
98
+ // read: six working doors and one bare one beats none at all.
99
+ ' try { out[door] = Object.keys(await import(door)).filter((n) => n !== "default"); }',
100
+ ' catch { out[door] = []; }',
101
+ '}',
102
+ 'process.stdout.write(JSON.stringify(out));',
103
+ ].join('\n');
104
+
105
+ const res = spawn(process.execPath, ['--import', preload, '--input-type=module', '--eval', script], {
106
+ encoding: 'utf8',
107
+ timeout: 60_000,
108
+ });
109
+ if (res.status !== 0 || !res.stdout) return null;
110
+ try {
111
+ const parsed = JSON.parse(res.stdout);
112
+ return DOORS.every((d) => Array.isArray(parsed[d])) ? parsed : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ // Write only what changed: this runs on every command that resolves the engine,
119
+ // and a rewritten file every time would churn the mtimes a watcher reads.
120
+ function put(path, content) {
121
+ try {
122
+ if (readFileSync(path, 'utf8') === content) return;
123
+ } catch { /* absent or unreadable — write it */ }
124
+ writeFileSync(path, content);
125
+ }
126
+
127
+ // The engine version the package was generated from. The export names come out
128
+ // of that engine, so a different one means regenerating — and the same one
129
+ // means the child process above can be skipped entirely, which is what keeps
130
+ // this off the cost of an ordinary command.
131
+ function generatedFor(root) {
132
+ try {
133
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))?.joystack?.generatedFor ?? null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ // A valid JS identifier can be exported by name; anything else (a string key in
140
+ // an `export { x as "odd name" }`) cannot, and is dropped rather than emitted as
141
+ // a syntax error that would break the door for every other name too.
142
+ const isIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && name !== 'default';
143
+
144
+ function doorSource(door, names) {
145
+ return [
146
+ `// \`${door}\` outside the Joystack CLI. Written by the CLI; edits here are overwritten.`,
147
+ '//',
148
+ '// Every name is exported so that an import of this door LINKS — Node checks',
149
+ '// imported names before a module body runs, and a door with no exports would',
150
+ '// report a missing export instead of the message below. Nothing is imported',
151
+ '// here on purpose: see packages/cli/lib/joystack-node-doors.mjs.',
152
+ '',
153
+ `const RUN_IT_WITH_THE_CLI = [`,
154
+ ...MESSAGE_LINES(door).map((l) => ` ${JSON.stringify(l)},`),
155
+ "].join('\\n');",
156
+ '',
157
+ 'const refuse = () => { throw new Error(RUN_IT_WITH_THE_CLI); };',
158
+ '',
159
+ ...names.filter(isIdentifier).map((n) => `export const ${n} = refuse;`),
160
+ 'export default refuse;',
161
+ '',
162
+ 'throw new Error(RUN_IT_WITH_THE_CLI);',
163
+ '',
164
+ ].join('\n');
165
+ }
166
+
167
+ /**
168
+ * Write (or refresh) `<projectDir>/node_modules/joystack`.
169
+ *
170
+ * @param {string} projectDir - the game folder
171
+ * @param {string} engineDir - the resolved engine root, read once for the names
172
+ * each door exports
173
+ * @param {string} engineVersion - what the result is keyed on: the same version
174
+ * is left alone, so the read above happens once per engine rather than once
175
+ * per command
176
+ * @returns {boolean} whether the package is present and current afterwards
177
+ */
178
+ export function writeJoystackNodeDoors(projectDir, engineDir, engineVersion, { readExports = readDoorExports } = {}) {
179
+ const root = join(projectDir, 'node_modules', 'joystack');
180
+ if (engineVersion && generatedFor(root) === engineVersion) return true;
181
+
182
+ const exportsByDoor = readExports(engineDir);
183
+ if (!exportsByDoor) return false;
184
+
185
+ const doorsDir = join(root, 'doors');
186
+ mkdirSync(doorsDir, { recursive: true });
187
+
188
+ for (const door of DOORS) put(join(doorsDir, fileFor(door)), doorSource(door, exportsByDoor[door]));
189
+
190
+ // Written last: it is the key the skip above reads, so a run interrupted
191
+ // part-way through the doors regenerates rather than declaring itself current.
192
+ put(join(root, 'package.json'), `${JSON.stringify({
193
+ name: 'joystack',
194
+ version: '0.0.0',
195
+ private: true,
196
+ type: 'module',
197
+ joystack: { generatedFor: engineVersion ?? null },
198
+ exports: Object.fromEntries(DOORS.map((door) => [subpathFor(door), `./doors/${fileFor(door)}`])),
199
+ }, null, 2)}\n`);
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * `writeJoystackNodeDoors` as a command should call it: only when the engine sits
205
+ * inside this project, and never loudly.
206
+ *
207
+ * An engine served from elsewhere (JOYSTACK_ENGINE_DIR) comes with a promise that
208
+ * the project is exactly as it was when the process exits, and a package written
209
+ * into its node_modules would break that promise for the sake of a better error
210
+ * message. Nor is failing to write one worth failing a command for — it improves
211
+ * an error and does nothing else.
212
+ *
213
+ * The same moment leaves the engine's old folder name linked to it
214
+ * (leaveLegacyEnginePath), for games that reach engine files by that path.
215
+ *
216
+ * @returns {boolean} whether the doors were written
217
+ */
218
+ export function leaveJoystackNodeDoors(projectDir, engine) {
219
+ if (!engine?.dir) return false;
220
+ // Both sides resolved, so a project reached by one path and an engine by
221
+ // another still compare. Logical paths only: a symlinked project with an
222
+ // out-of-tree engine is the JOYSTACK_ENGINE_DIR case, which is refused anyway.
223
+ if (!resolve(engine.dir).startsWith(resolve(projectDir, 'node_modules') + sep)) return false;
224
+ leaveLegacyEnginePath(projectDir, engine.dir);
225
+ try {
226
+ return writeJoystackNodeDoors(projectDir, engine.dir, engine.version);
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
package/lib/lane.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // `joystack lane <name>` — open an isolated lane for an experiment.
2
+ //
3
+ // A lane is this game checked out a second time, in its own folder, on its own
4
+ // branch, with its own dev stack. Work in a lane cannot disturb the main
5
+ // checkout: different files, different branch, different servers, different
6
+ // multiplayer room. Win → merge it. Lose → delete the folder; nothing that
7
+ // mattered was ever touched.
8
+ //
9
+ // This exists because the lane used to be a PROCEDURE — a bash block in the
10
+ // build skill: commit-guard, `git worktree add`, `npm install`, and remember a
11
+ // distinct `--port` or you'd kill the main checkout's servers. Four steps,
12
+ // three of them footguns, one of them slow. Agents read that, priced the
13
+ // ceremony, and skipped isolating altogether — repeatedly. The COST was the
14
+ // bug. A procedure a tool can just perform should never be written down and
15
+ // hoped for.
16
+ //
17
+ // What used to be expensive, and isn't any more:
18
+ //
19
+ // npm install → node_modules is CLONED from the main checkout. On APFS
20
+ // (macOS) and btrfs/xfs (Linux) that's a copy-on-write
21
+ // reflink: effectively instant, near-zero extra disk. The
22
+ // engine is already sitting one directory up; resolving it
23
+ // again over the network was always silly.
24
+ // --port 8010 → gone. `joystack dev` resolves its own ports now and steps
25
+ // around anyone else's stack (ports.mjs). A lane just runs
26
+ // `npx joystack dev`, like everywhere else.
27
+ import { execFileSync } from 'node:child_process';
28
+ import { cpSync, existsSync, statSync } from 'node:fs';
29
+ import { basename, dirname, join, resolve } from 'node:path';
30
+ import { findProject } from './project.mjs';
31
+ import { runNpm } from './npm.mjs';
32
+
33
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
34
+
35
+ // Is this folder a lane (a linked git worktree) rather than the game itself?
36
+ //
37
+ // Git's own marker, no subprocess: in a linked worktree `.git` is a FILE
38
+ // holding `gitdir: …/.git/worktrees/<name>`; in an ordinary repo it's a
39
+ // directory. A folder with no git at all is not a lane.
40
+ //
41
+ // This matters to `publish`, which is global, permanent and irreversible — see
42
+ // the guard there.
43
+ export function isLane(dir) {
44
+ try {
45
+ return statSync(join(dir, '.git')).isFile();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ // The slug of the game a lane was opened from: the project in the main
52
+ // checkout that owns this worktree. `null` when `dir` is not a lane, or the main
53
+ // checkout is not a game folder. A lane folder is named after the lane, so its
54
+ // own slug says nothing about which slug is the live game.
55
+ export function laneParentSlug(dir) {
56
+ if (!isLane(dir)) return null;
57
+ try {
58
+ const common = git(dir, 'rev-parse', '--path-format=absolute', '--git-common-dir');
59
+ return findProject(dirname(common)).slug;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ // Clone a directory tree as cheaply as the filesystem allows.
66
+ //
67
+ // `cp -c` (macOS) and `cp --reflink=auto` (Linux) ask for copy-on-write: the
68
+ // new tree shares its blocks with the old one until something writes. For a
69
+ // node_modules of tens of thousands of files that is the difference between
70
+ // "instant" and "go and make a coffee". Neither is guaranteed — a non-APFS
71
+ // volume, a filesystem without reflinks — so both fall back to a real copy,
72
+ // which is still local, and still beats a network resolve.
73
+ export function cloneTree(src, dest) {
74
+ const args =
75
+ process.platform === 'darwin'
76
+ ? ['-c', '-R', src, dest] // APFS clonefile
77
+ : process.platform === 'linux'
78
+ ? ['-r', '--reflink=auto', src, dest] // btrfs/xfs, degrades to a copy
79
+ : null;
80
+ if (args) {
81
+ try {
82
+ execFileSync('cp', args, { stdio: 'ignore' });
83
+ return 'cloned';
84
+ } catch {
85
+ // Not a CoW filesystem (or no `cp` worth the name — Windows). Fall through.
86
+ }
87
+ }
88
+ cpSync(src, dest, { recursive: true });
89
+ return 'copied';
90
+ }
91
+
92
+ export async function lane(name, { cwd = process.cwd(), log = console.log } = {}) {
93
+ const project = findProject(cwd);
94
+ const slug = project.slug;
95
+ const branch = `lane/${name}`;
96
+ const dir = resolve(project.dir, '..', `${slug}-${name}`);
97
+
98
+ if (existsSync(dir)) {
99
+ throw new Error(
100
+ `A lane called "${name}" already exists at ${dir}.\n` +
101
+ `Work in it (cd ${basename(dir)} && npx joystack dev), or remove it first ` +
102
+ `(git worktree remove ${dir}).`,
103
+ );
104
+ }
105
+
106
+ // `git worktree add` on a repo with NO commits silently produces an empty
107
+ // orphan lane — the folder exists, the game isn't in it. To a creator that
108
+ // reads as "the tool deleted my game". Save first, but ONLY in that case:
109
+ // committing on someone's behalf when they didn't ask is its own betrayal.
110
+ let savedFirst = false;
111
+ try {
112
+ git(project.dir, 'rev-parse', 'HEAD');
113
+ } catch {
114
+ log('→ saving your game first (a lane needs at least one save to branch from)');
115
+ git(project.dir, 'add', '-A');
116
+ git(project.dir, 'commit', '-m', 'save: initial');
117
+ savedFirst = true;
118
+ }
119
+
120
+ // Their uncommitted work is deliberately NOT carried across: the lane starts
121
+ // from the last save. That IS the isolation — the whole point is that what
122
+ // they're in the middle of stays exactly where it is, untouched.
123
+ git(project.dir, 'worktree', 'add', '-b', branch, dir);
124
+ log(`→ lane ${dir} (branch ${branch})`);
125
+
126
+ // node_modules is gitignored, so the lane arrives empty. Without it, `npx
127
+ // joystack` falls through to a WRONG public package on npm and dies with an
128
+ // error that reads like a bug in the creator's game.
129
+ const src = join(project.dir, 'node_modules');
130
+ let installed = false;
131
+ if (existsSync(src)) {
132
+ const how = cloneTree(src, join(dir, 'node_modules'));
133
+ log(`→ engine ${how} from the main checkout (no npm install needed)`);
134
+ } else {
135
+ log('→ engine installing (the main checkout has no node_modules to clone)');
136
+ runNpm(['install'], { cwd: dir, stdio: 'inherit' });
137
+ installed = true;
138
+ }
139
+
140
+ return { dir, branch, savedFirst, installed };
141
+ }