@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.
- package/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- 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
|
+
}
|