@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/dev.mjs ADDED
@@ -0,0 +1,435 @@
1
+ // `joystack dev` — the standalone two-service dev stack:
2
+ //
3
+ // static :8000 game folder at /games/<slug>/, /shared/ → engine bundle,
4
+ // head injection + auto-reload
5
+ // mp :1999 local partykit running the BUNDLE's room-server code
6
+ //
7
+ // Multiplayer runs from minute one — the platform is not optional (the Next
8
+ // analogy deliberately breaks there). NO_MP=1 opts out for purely-SP work.
9
+ import { spawn } from 'node:child_process';
10
+ import { terminateChildTree } from './process-tree.mjs';
11
+ import { withoutAgentSession } from './agent-session.mjs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, dirname, basename } from 'node:path';
15
+ import { findProject } from './project.mjs';
16
+ import { ensureEngine } from './engine.mjs';
17
+ import { createStaticServer } from './static-server.mjs';
18
+ import { pruneOnStart } from './replay-store.mjs';
19
+ import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
20
+ import { assertNoInertOverride, isFrameworkV2, isSinglePlayerV2, scanPrimitives } from './primitives.mjs';
21
+ import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
22
+ import { createFileWatcher, createRoomReloader } from './room-reload.mjs';
23
+ import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
24
+ import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
25
+ import { freezeGame } from './freeze.mjs';
26
+ import { readEnv, stateDir } from './legacy-names.mjs';
27
+
28
+ // Resolve the partykit CLI entry from OUR dependencies (the game never
29
+ // declares partykit; it rides @joystack/cli). partykit's `exports` map hides
30
+ // package.json from require.resolve, so walk node_modules dirs by hand.
31
+ export function partykitBin() {
32
+ let dir = import.meta.dirname;
33
+ for (;;) {
34
+ const candidate = join(dir, 'node_modules', 'partykit', 'package.json');
35
+ if (existsSync(candidate)) {
36
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
37
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.partykit;
38
+ return join(dirname(candidate), bin);
39
+ }
40
+ const parent = dirname(dir);
41
+ if (parent === dir) throw new Error('partykit is not installed alongside @joystack/cli — reinstall the CLI.');
42
+ dir = parent;
43
+ }
44
+ }
45
+
46
+ // Resolves 'up' once the local room server answers an HTTP request, 'exited'
47
+ // as soon as `alive()` says its process ended, and 'timeout' if it has not
48
+ // answered within `timeoutMs`.
49
+ // Any HTTP status counts: partykit answers `GET /` with a 404 as soon as it is
50
+ // serving. An open port is not enough, because the port can accept a
51
+ // connection and then drop it while the server finishes starting.
52
+ export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs = 50, alive = () => true, fetchFn = fetch } = {}) {
53
+ const deadline = Date.now() + timeoutMs;
54
+ while (Date.now() < deadline) {
55
+ if (!alive()) return 'exited';
56
+ try {
57
+ const res = await fetchFn(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) });
58
+ // Releases the connection now instead of when the response is collected.
59
+ await res?.body?.cancel();
60
+ return 'up';
61
+ } catch {
62
+ /* not serving yet */
63
+ }
64
+ await new Promise((r) => setTimeout(r, intervalMs));
65
+ }
66
+ return alive() ? 'timeout' : 'exited';
67
+ }
68
+
69
+ // Per-game server parity: a game with primitives of its own
70
+ // (overrides/shared/ui/room/primitives/) runs its OWN room server in dev, built
71
+ // from those classes. Without them, the engine's stock room server runs.
72
+ //
73
+ // The predicate is `isServerBacked` — the SAME one publish uses. If dev asked a
74
+ // different question (e.g. "does a partykit.json exist", which nothing in this
75
+ // CLI ever creates), dev and publish would disagree and a creator's primitive
76
+ // would run in production but not on their own machine. One question, one
77
+ // answer, both commands.
78
+
79
+ // `share` opens a public link to the stack for testers (share.mjs); `openShare`
80
+ // replaces the tunnel for tests.
81
+ export async function dev(opts = {}) {
82
+ // Whatever startup has made so far, undone if a later step throws: a room
83
+ // build that fails on the creator's code, a port already taken, a tunnel
84
+ // that never opens. A frozen copy of the game is among it.
85
+ const undo = [];
86
+ try {
87
+ return await startDev(opts, (fn) => undo.push(fn));
88
+ } catch (err) {
89
+ for (const fn of undo.reverse()) {
90
+ try { fn(); } catch { /* the original error is the one to report */ }
91
+ }
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ // `agentSession` is the coding-agent session to wake with agent-inbox reports
97
+ // (agent-session.mjs). Only the interactive `joystack dev` and `joystack assets`
98
+ // commands pass one; a stack started by `joystack test` or any other caller has
99
+ // none, so no test run can wake the agent that started it.
100
+ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null } = {}, onFailure) {
101
+ const project = findProject(cwd);
102
+ // Q4 revision: the engine is not an npm dependency — install/refresh it from
103
+ // the platform's login-gated registry (no-op when the pinned version is in).
104
+ const engine = await ensureEngine(project.dir, { log });
105
+ if (share && !engineCanShare(engine.sharedDir)) {
106
+ throw new Error(
107
+ `joystack dev --share needs a newer engine than ${engine.version ?? 'the one this game is pinned to'}: ` +
108
+ 'its pages cannot tell they were opened through the link, so they would record nothing. Run `npx joystack update`, then try again.',
109
+ );
110
+ }
111
+
112
+ // Any 3D model the entity definitions reference re-bakes when its GLB (or
113
+ // bake config) changed — the server must hit the surface the player sees. A
114
+ // bake failure stops the stack with the model named rather than serving a
115
+ // room that shoots at yesterday's shape.
116
+ const { ensureBaked } = await import('./model-bake.mjs');
117
+ await ensureBaked({ dir: project.dir, log });
118
+
119
+ // Recordings accumulate on the creator's own disk — every session played
120
+ // under `joystack dev` writes one — so the oldest go here, before anything can
121
+ // be watching one. The caps and the keep marker live in replay-store.mjs.
122
+ pruneOnStart({ projectDir: project.dir, log });
123
+
124
+ // `reload: false` (`joystack dev --no-reload`) freezes the game as it is now:
125
+ // pages are served, and the room is built, from a copy taken here, after the
126
+ // bake so baked models come with it, and nothing is watched. Recordings still
127
+ // land in the real folder. See freeze.mjs for why a copy is needed.
128
+ const frozen = reload ? null : freezeGame(project.dir);
129
+ if (frozen) onFailure(frozen.remove);
130
+ if (frozen) watch = false;
131
+ const codeDir = frozen?.dir ?? project.dir;
132
+
133
+ // Which ports, and may we take them? An explicit --port is obeyed as given
134
+ // (takeover included). With no flag we step around anyone else's stack —
135
+ // another lane, another game, an unrelated app — and only ever reclaim our
136
+ // OWN stale server. See ports.mjs; this is what makes a lane collision-free
137
+ // without the creator having to remember a flag.
138
+ const explicit = port ?? (readEnv('JOYSTACK_DEV_PORT') ? Number(readEnv('JOYSTACK_DEV_PORT')) : null);
139
+ const { ports, tookOver, steppedAside } = await resolvePorts({
140
+ projectDir: project.dir,
141
+ requested: explicit,
142
+ });
143
+ for (const s of steppedAside) {
144
+ const who = s.reason === 'browser-restricted'
145
+ ? 'browser-restricted (its multiplayer port would be blocked) — stepping past'
146
+ : `held by ${s.projectDir ? `another Joystack game (${basename(s.projectDir)})` : 'another app'} — leaving it alone`;
147
+ log(`→ :${s.port} (${who})`);
148
+ }
149
+ // An EXPLICIT --port is obeyed as given, but if it derives a browser-blocked
150
+ // port (e.g. --port 8050 → mp 2049/NFS) the game would look broken with no
151
+ // clue, so say so loudly rather than silently binding it.
152
+ for (const [name, p] of [['multiplayer', ports.mp], ['static', ports.static]]) {
153
+ if (isRestricted(p)) {
154
+ log(`⚠ ${name} port :${p} is browser-restricted (ERR_UNSAFE_PORT) — browsers will refuse it. Re-run with a --port whose ${name} port is reachable (e.g. --port ${nextBrowserSafe(ports.static)}).`);
155
+ }
156
+ }
157
+ const children = [];
158
+ const servers = [];
159
+
160
+ // The game's own server code, if any. Built BEFORE any server binds a port —
161
+ // both because a malformed primitives folder should fail before we start
162
+ // taking over ports, and because this generates the merged browser registry
163
+ // the static server is about to mount. Building it after would leave a window
164
+ // where the page loads and predicts with the engine's classes instead.
165
+ //
166
+ // The inert-override check runs FIRST, before the scan. An override of, say,
167
+ // `primitives/effects.js` is a room-runtime file that merely happens to live in
168
+ // the primitives folder — scanned first, it would be reported as "a primitive
169
+ // with no default export", which is true and useless. Checked first, the
170
+ // creator is told the actual problem: the server cannot take this file.
171
+ assertNoInertOverride({ projectDir: codeDir, engine });
172
+ const serverPrimitives = scanPrimitives(codeDir);
173
+ const { cwd: roomServerCwd, inputs: roomInputs, skeleton: roomSkeleton = null } = await buildDevRoomServer({ projectDir: codeDir, engine });
174
+ const ownServer = roomServerCwd !== engine.roomServerDir;
175
+ // A single-player framework-v2 game (no config({ multiplayer: true })) runs its
176
+ // authoritative sim in-process via LocalRoom, so no room server is started.
177
+ const singlePlayer = isSinglePlayerV2({ projectDir: codeDir, skeleton: roomSkeleton });
178
+ const runsRoom = !(noMp || singlePlayer);
179
+ let tunnel = null;
180
+
181
+ let stopped = false;
182
+ const stop = () => {
183
+ stopped = true;
184
+ tunnel?.close();
185
+ for (const s of servers) s.close();
186
+ for (const c of children) terminateChildTree(c);
187
+ frozen?.remove();
188
+ };
189
+ onFailure(stop);
190
+
191
+ // ─── static ───
192
+ // `tookOver` is the ONLY licence to kill: an explicit --port, or our own
193
+ // stale server on this block. resolvePorts already walked away from
194
+ // everyone else's, so anything still here is fair game.
195
+ if (tookOver && (await portInUse(ports.static))) {
196
+ log(`→ static :${ports.static} (our stale server — reclaiming)`);
197
+ await killPort(ports.static);
198
+ }
199
+ // overrides/shared/… : the game's engine modifications mount ahead of the
200
+ // bundle and shadow it per-file — the same shadowing the publish resolve step
201
+ // applies in production.
202
+ //
203
+ // Two override layers, in precedence order:
204
+ // 1. GENERATED (.joystack/overrides/shared) — the merged primitive registry the
205
+ // CLI writes for a game with server primitives, so the client predicts
206
+ // with the game's classes. It goes first because it shadows a file
207
+ // (primitives/index.js) the creator is not allowed to write themselves.
208
+ // 2. THE CREATOR'S (overrides/shared) — everything they wrote.
209
+ // Then the engine. First mount that HAS the file wins (static-server.mjs).
210
+ const generatedOverridesDir = join(stateDir(codeDir), 'overrides', 'shared');
211
+ const overridesDir = join(codeDir, 'overrides', 'shared');
212
+ const staticServer = createStaticServer({
213
+ slug: project.slug,
214
+ projectDir: project.dir, // answers /__joystack/whoami — see ports.mjs
215
+ log, // agent-inbox report lines ride the dev logger
216
+ engineVersion: engine.version ?? null, // stamped on agent-inbox reports
217
+ agentSession, // woken by agent-inbox reports sent from this machine
218
+ mounts: [
219
+ { url: `/games/${project.slug}/`, dir: codeDir },
220
+ { url: '/shared/', dir: generatedOverridesDir },
221
+ { url: '/shared/', dir: overridesDir },
222
+ { url: '/shared/', dir: engine.sharedDir },
223
+ ],
224
+ watchDirs: watch ? [codeDir, engine.sharedDir] : [],
225
+ injectReload: watch,
226
+ ...(frozen ? { fixedBuild: frozen.build } : {}),
227
+ // Present only for a framework-v2 game — injects the joystack import map + the
228
+ // lowered skeleton into the entry HTML so startGame() boots in the browser.
229
+ skeleton: roomSkeleton,
230
+ // Where the room's websocket is forwarded (the upgrade handler), so a page
231
+ // reached through the share link joins it on the static port alone.
232
+ roomPort: runsRoom ? ports.mp : null,
233
+ });
234
+ await staticServer.listen(ports.static);
235
+ servers.push(staticServer);
236
+ log(`→ static :${ports.static} (serving ${frozen ? `a copy of ${project.dir} as it is now` : project.dir}, engine ${engine.version}, auto-reload ${watch ? 'on' : 'off'})`);
237
+
238
+ // ─── multiplayer (partykit on the bundle's room code) ───
239
+ // A single-player game's client never opens a socket, so standing up partykit
240
+ // here just burns a port and a workerd process. Skip it, mirroring publish,
241
+ // which deploys no per-game room worker for the same game.
242
+ if (!runsRoom) {
243
+ log(
244
+ singlePlayer && !noMp
245
+ ? `→ multiplayer :${ports.mp} (skipped — single-player; add config({ multiplayer: true }) to enable)`
246
+ : `→ multiplayer :${ports.mp} (skipped — NO_MP=1)`,
247
+ );
248
+ // No room worker, but the client still assembles its graph against the injected
249
+ // skeleton — so a STRUCTURAL edit (new entity/trait) must rebuild the skeleton
250
+ // and push it to the static server, or the page reloads onto a stale skeleton
251
+ // and assembleGraph throws ("re-ran N ticks but the skeleton has M"). The full
252
+ // partykit reloader below does this as a side effect; single-player needs a
253
+ // stripped-down version with no child to respawn. (NO_MP on a real multiplayer
254
+ // game has no client to serve a skeleton to, so it needs none.)
255
+ if (singlePlayer && watch) {
256
+ const skelWatcher = createFileWatcher({
257
+ files: roomInputs,
258
+ dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
259
+ onChange: async () => {
260
+ try {
261
+ const built = await buildDevRoomServer({ projectDir: codeDir, engine });
262
+ staticServer.setSkeleton(built.skeleton ?? null);
263
+ skelWatcher.setFiles(built.inputs);
264
+ } catch (err) {
265
+ log(`[skeleton] rebuild failed — fix the error and save again: ${err.message}`);
266
+ }
267
+ },
268
+ });
269
+ servers.push({ close: () => skelWatcher.close() });
270
+ }
271
+ } else {
272
+ if (tookOver && (await portInUse(ports.mp))) {
273
+ log(`→ multiplayer :${ports.mp} (our stale server — reclaiming)`);
274
+ await killPort(ports.mp);
275
+ }
276
+ const spawnMp = (cwd) => {
277
+ const pk = spawn(
278
+ process.execPath,
279
+ [partykitBin(), 'dev', '--port', String(ports.mp), '--persist', join(tmpdir(), `joystack-partykit-${ports.mp}`)],
280
+ { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: withoutAgentSession(process.env) },
281
+ );
282
+ pk.stdout.on('data', () => {});
283
+ pk.stderr.on('data', (d) => {
284
+ const s = String(d);
285
+ if (/error/i.test(s)) log(`[partykit] ${s.trim()}`);
286
+ });
287
+ // A spawn that fails (ENOENT, EMFILE) emits 'error' and never 'exit';
288
+ // without a listener the event throws and takes the stack down.
289
+ pk.on('error', (err) => log(`[partykit] failed to start: ${err.message}`));
290
+ children.push(pk);
291
+ return pk;
292
+ };
293
+ const killMp = async (pk) => {
294
+ if (pk.pid == null) return;
295
+ // Keep ownership when termination fails so stop() can retry the tree.
296
+ // Child exit callbacks cannot run between these synchronous statements.
297
+ terminateChildTree(pk);
298
+ const idx = children.indexOf(pk);
299
+ if (idx !== -1) children.splice(idx, 1);
300
+ const exited = new Promise((resolveExit) => {
301
+ if (pk.exitCode !== null || pk.signalCode !== null) return resolveExit();
302
+ const onExit = () => {
303
+ pk.removeListener('exit', onExit);
304
+ pk.removeListener('error', onExit);
305
+ resolveExit();
306
+ };
307
+ pk.once('exit', onExit);
308
+ pk.once('error', onExit);
309
+ });
310
+ // partykit is a node wrapper forking workerd; if the tree ignores TERM,
311
+ // escalate — the port must be free before the replacement binds it.
312
+ const hardKill = setTimeout(() => {
313
+ try {
314
+ terminateChildTree(pk, 'SIGKILL');
315
+ } catch {
316
+ /* gone */
317
+ }
318
+ }, 2000);
319
+ hardKill.unref();
320
+ await exited;
321
+ clearTimeout(hardKill);
322
+ // The parent's exit is not the port's release: workerd is a separate
323
+ // process in the group and can hold the listening socket a beat longer.
324
+ // A replacement that binds too early dies on EADDRINUSE — killing a
325
+ // working room and leaving nothing. Wait until the port is actually free.
326
+ const deadline = Date.now() + 5000;
327
+ while ((await portInUse(ports.mp)) && Date.now() < deadline) {
328
+ await new Promise((r) => setTimeout(r, 100));
329
+ }
330
+ };
331
+ const pk = spawnMp(roomServerCwd);
332
+ // A game's own room carries one of three kinds of server code — creator
333
+ // primitives (named), a framework-v2 lowered bundle, or v1 entity
334
+ // components — and only the first has a list to print. Naming the kind
335
+ // keeps a v2 or component-backed game from logging a dangling "— ".
336
+ const ownServerDesc = serverPrimitives.length
337
+ ? `this game's OWN server — ${serverPrimitives.map((p) => p.type).join(', ')}`
338
+ : `this game's OWN server — ${isFrameworkV2(codeDir) ? 'framework v2' : 'entity components'}`;
339
+ log(
340
+ `→ multiplayer :${ports.mp} (partykit on ${ownServer ? ownServerDesc : "the engine's room server"}, pid ${pk.pid})`,
341
+ );
342
+ // The stack is reported ready, and `joystack test` starts its smokes, only
343
+ // once the room server answers. On a game's first run partykit can take
344
+ // several seconds to start; a page opened before then logs a refused
345
+ // WebSocket connection, which fails any smoke that treats console errors
346
+ // as failures, even though the client reconnects moments later.
347
+ // A room server that exits during startup fails the stack here, naming the
348
+ // cause, rather than leaving every page to fail on a refused connection.
349
+ const room = await waitForRoomServer(ports.mp, { alive: () => pk.pid != null && pk.exitCode === null && pk.signalCode === null });
350
+ if (room === 'exited') throw new Error(`the multiplayer server (partykit on :${ports.mp}) stopped while starting — its errors are printed above with a [partykit] prefix`);
351
+ if (room === 'timeout') log(`[partykit] not answering on :${ports.mp} after 30s — the game will keep trying to connect`);
352
+
353
+ // The room bundle is frozen at build time, so the static watcher's page
354
+ // reload is only half the contract: an edit to anything the room runs on
355
+ // (entity data, a room override, a server sim) must also rebuild the room
356
+ // and restart its child, or the creator playtests against pre-edit
357
+ // adjudication with everything looking live — a failure `joystack test` is
358
+ // structurally blind to, because it boots fresh stacks.
359
+ //
360
+ // The watch set is the bundle's own input list (covers every file the room
361
+ // actually imports, wherever it lives) plus the room-shaped game trees for
362
+ // files that don't exist yet — a first primitive, a new entity, a new room
363
+ // override. overrides/ is deliberately scoped to its room subtree: editing
364
+ // a purely client-side override must not restart the room and reset its
365
+ // state mid-playtest.
366
+ let roomWatcher = null;
367
+ let roomCwd = roomServerCwd;
368
+ const reloader = createRoomReloader({
369
+ build: () => buildDevRoomServer({ projectDir: codeDir, engine }),
370
+ spawnChild: spawnMp,
371
+ killChild: killMp,
372
+ log,
373
+ onRebuilt: (built) => {
374
+ roomWatcher?.setFiles(built.inputs);
375
+ // Push the rebuilt skeleton to the static server so the client injects
376
+ // the SAME lowered graph the room now embeds — otherwise a v2 hot-reload
377
+ // leaves the page on the startup skeleton and assembleGraph throws on the
378
+ // next load (client "re-ran N ticks but the skeleton has M").
379
+ staticServer.setSkeleton(built.skeleton ?? null);
380
+ staticServer.roomRestarted();
381
+ // A rebuild can flip which server the game runs on (first primitive
382
+ // added, last one removed) — say so, or the startup line misleads for
383
+ // the rest of the session.
384
+ if (built.cwd !== roomCwd) {
385
+ roomCwd = built.cwd;
386
+ log(
387
+ `→ multiplayer :${ports.mp} (now on ${roomCwd === engine.roomServerDir ? "the engine's room server" : "this game's OWN server"})`,
388
+ );
389
+ }
390
+ },
391
+ });
392
+ reloader.attach(roomServerCwd, pk);
393
+ roomWatcher = watch ? createFileWatcher({
394
+ files: roomInputs,
395
+ dirs: ['entities', 'components', 'overrides/shared/ui/room'].map((d) => join(codeDir, d)),
396
+ onChange: reloader.onChange,
397
+ }) : null;
398
+ // stop() (e.g. `joystack test` tearing down its stack) must also stop the
399
+ // reloader: a rebuild in flight would otherwise spawn a replacement room
400
+ // child AFTER the children list was killed — a leaked detached process.
401
+ servers.push({
402
+ close: () => {
403
+ reloader.stop();
404
+ roomWatcher?.close();
405
+ },
406
+ });
407
+ }
408
+
409
+ const url = `http://localhost:${ports.static}/games/${project.slug}/index.html`;
410
+ const ip = lanIp();
411
+ log('');
412
+ log('────────────────────────────────────────────────────────────');
413
+ log(`✅ ${project.slug} ready. Open:`);
414
+ log(` ${url}`);
415
+ if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
416
+ // Only when this game declares assets (inspect.mjs). A line printed on every
417
+ // single boot is a line nobody reads by the third one, and the tool it points
418
+ // at is then invisible exactly when it would have helped.
419
+ if (hasDeclaredAssets(project.dir)) {
420
+ log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
421
+ }
422
+ if (share) {
423
+ log('');
424
+ log('→ share opening a public link (cloudflared)…');
425
+ tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
426
+ staticServer.setShareHost(tunnel.hostname);
427
+ for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: !!frozen })) log(line);
428
+ tunnel.exited.then(() => {
429
+ if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
430
+ });
431
+ }
432
+ log('────────────────────────────────────────────────────────────');
433
+
434
+ return { project, engine, ports, url, shareUrl: tunnel ? `${tunnel.url}/games/${project.slug}/index.html` : null, frozenDir: frozen?.dir ?? null, stop };
435
+ }
package/lib/engine.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // ensureEngine — make the engine present before dev/publish (Q4 revision:
2
+ // the engine is NOT an npm package; npm ships only this CLI).
3
+ //
4
+ // The engine installs from the platform's release registry via the
5
+ // LOGIN-GATED /api/creator/engine lane — every download maps to a creator
6
+ // account (attribution, revocation, and the /activate approval as the terms
7
+ // gate). The flow, Playwright-style (npm tool + vendor-served artifact):
8
+ //
9
+ // 1. honor the game's `joystack.engine` pin in package.json (written on first
10
+ // install, so repeat runs are reproducible and offline-friendly)
11
+ // 2. if the installed node_modules/@joystack/engine already matches → done
12
+ // 3. otherwise: auto-run the device-flow login when the machine has no
13
+ // token, download the release tarball (cached in ~/.joystack/cache — a
14
+ // later `npm install` may prune the engine from node_modules; the cache
15
+ // makes the self-heal local), `npm install --no-save` it, write the pin.
16
+ //
17
+ // JOYSTACK_ENGINE_TARBALL=<path> short-circuits the network — the offline/smoke
18
+ // lane (packed-install smoke, air-gapped work against a local build).
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import { runNpm } from './npm.mjs';
22
+ import { resolveEngine } from './project.mjs';
23
+ import { cacheDir, DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
24
+ import { login } from './login.mjs';
25
+ import { leaveJoystackNodeDoors } from './joystack-node-doors.mjs';
26
+ import { LEGACY_ENGINE_PACKAGE, gameConfig, gameConfigKey, readEnv } from './legacy-names.mjs';
27
+
28
+ // An engine package that installed under the name releases before 0.2.36 use.
29
+ function olderEngineError(projectDir, version) {
30
+ if (!existsSync(join(projectDir, 'node_modules', ...LEGACY_ENGINE_PACKAGE.split('/'), 'package.json'))) return null;
31
+ return new Error(
32
+ `engine ${version} predates this CLI, which runs engine 0.2.36 and later: pin a newer engine in package.json and retry.`,
33
+ );
34
+ }
35
+
36
+ export function readEnginePin(projectDir) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
39
+ const pin = gameConfig(pkg).engine;
40
+ return typeof pin === 'string' ? pin : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function writeEnginePin(projectDir, version) {
47
+ const pkgPath = join(projectDir, 'package.json');
48
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
49
+ const key = gameConfigKey(pkg);
50
+ pkg[key] = { ...pkg[key], engine: version };
51
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
52
+ }
53
+
54
+ function installedEngine(projectDir) {
55
+ try {
56
+ return resolveEngine(projectDir);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ // `npm install --no-save <tgz>` — npm extracts the package into
63
+ // node_modules/@joystack/engine AND installs its runtime dependencies
64
+ // (the room server's bare imports), without touching package.json.
65
+ function npmInstallTarball(projectDir, tgzPath) {
66
+ runNpm(['install', '--no-save', '--no-audit', '--no-fund', resolve(tgzPath)], {
67
+ cwd: projectDir,
68
+ stdio: 'pipe',
69
+ });
70
+ }
71
+
72
+ /**
73
+ * The engine, present — plus the `node_modules/joystack` package that tells anyone
74
+ * running a test file with bare `node` to use `joystack test` instead
75
+ * (joystack-node-doors.mjs).
76
+ *
77
+ * The moment those doors are wanted is the moment somebody runs a test file by
78
+ * hand, which is not a moment any CLI command is watching — so they are left
79
+ * behind in advance, by whatever ran last. `create` and `dev` come through
80
+ * here; `joystack test` does not, and writes them itself (test-cmd.mjs).
81
+ */
82
+ export async function ensureEngine(projectDir, opts = {}) {
83
+ const engine = await installEngine(projectDir, opts);
84
+ leaveJoystackNodeDoors(projectDir, engine);
85
+ return engine;
86
+ }
87
+
88
+ async function installEngine(
89
+ projectDir,
90
+ {
91
+ apiBase = getApiBase(DEFAULT_API_BASE),
92
+ log = console.log,
93
+ fetchImpl = fetch,
94
+ loginFn = login,
95
+ installTarball = npmInstallTarball,
96
+ tarballOverride = readEnv('JOYSTACK_ENGINE_TARBALL'),
97
+ dirOverride = readEnv('JOYSTACK_ENGINE_DIR'),
98
+ } = {},
99
+ ) {
100
+ // Before the pin, before the installed copy, before the network: an explicit
101
+ // checkout is the most specific thing anyone can ask for.
102
+ if (dirOverride) {
103
+ // resolveEngine owns the override for EVERY tool (project.mjs) — this only
104
+ // announces it, so the banner and the lint can never disagree about which
105
+ // engine is in play.
106
+ const e = resolveEngine(projectDir);
107
+ log(`→ engine ${e.version} from JOYSTACK_ENGINE_DIR (${e.dir}) — nothing installed`);
108
+ return e;
109
+ }
110
+
111
+ let pin = readEnginePin(projectDir);
112
+
113
+ const installed = installedEngine(projectDir);
114
+ if (installed && (!pin || installed.version === pin)) {
115
+ // Adopt a pre-existing install (e.g. the npm-distribution era) as the pin.
116
+ if (!pin) writeEnginePin(projectDir, installed.version);
117
+ return installed;
118
+ }
119
+
120
+ // Offline/smoke lane: a local tarball wins over everything network.
121
+ if (tarballOverride) {
122
+ log(`Installing the engine from ${tarballOverride}…`);
123
+ installTarball(projectDir, tarballOverride);
124
+ const engine = installedEngine(projectDir);
125
+ if (!engine) throw olderEngineError(projectDir, tarballOverride) ?? new Error(`installing ${tarballOverride} did not produce node_modules/@joystack/engine`);
126
+ if (pin && engine.version !== pin) {
127
+ log(` (note: this tarball is ${engine.version}; the game pinned ${pin} — pin updated)`);
128
+ }
129
+ writeEnginePin(projectDir, engine.version);
130
+ return engine;
131
+ }
132
+
133
+ // The download lane is login-gated: no token → run the device flow now.
134
+ if (!getToken()) {
135
+ log('The Joystack engine is downloaded from the platform and needs your account.');
136
+ await loginFn({ apiBase, log });
137
+ if (!getToken()) throw new Error('login did not produce a token — run `joystack login` and retry.');
138
+ }
139
+ const auth = { Authorization: `Bearer ${getToken()}` };
140
+
141
+ if (!pin) {
142
+ const res = await fetchImpl(`${apiBase}/api/creator/engine`, { headers: auth });
143
+ if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
144
+ if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
145
+ const { latest } = await res.json();
146
+ if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
147
+ pin = latest;
148
+ }
149
+
150
+ const cache = cacheDir();
151
+ const cached = join(cache, `engine-${pin}.tgz`);
152
+ // A release candidate (a `-suffix` pin) is MUTABLE — the same label can be
153
+ // re-cut with new bytes — so its cache entry can be stale. Always re-download
154
+ // a candidate; the content-addressed stable releases are safe to cache forever.
155
+ const isCandidate = pin.includes('-');
156
+ if (!existsSync(cached) || isCandidate) {
157
+ log(`Downloading engine ${pin} from ${apiBase}…`);
158
+ const dlUrl = `${apiBase}/api/creator/engine/${pin}`;
159
+ let res = await fetchImpl(dlUrl, { headers: auth });
160
+ // 403 = the token is fine but the platform refuses with a reason the
161
+ // human must act on — today, an updated Engine License that needs
162
+ // re-accepting. The acceptance IS the browser approval step of the device
163
+ // flow, so run the flow right here instead of asking the human to type
164
+ // `joystack login` themselves (the click in the browser is still theirs to
165
+ // make), then retry once with the fresh token. If the platform still says
166
+ // no, surface its words — never loop.
167
+ if (res.status === 403) {
168
+ const reason = (await res.json().catch(() => null))?.error;
169
+ log(reason || 'The platform refused the engine download — your sign-in needs a refresh.');
170
+ log('Opening the sign-in flow to sort that out…');
171
+ await loginFn({ apiBase, log });
172
+ res = await fetchImpl(dlUrl, { headers: { Authorization: `Bearer ${getToken()}` } });
173
+ if (res.status === 403) {
174
+ const still = (await res.json().catch(() => null))?.error;
175
+ throw new Error(still || reason || 'the platform refused the engine download (HTTP 403).');
176
+ }
177
+ }
178
+ if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
179
+ if (res.status === 404) throw new Error(`engine ${pin} is not downloadable — check the \`joystack.engine\` pin in package.json.`);
180
+ if (!res.ok) throw new Error(`engine download failed (HTTP ${res.status})`);
181
+ mkdirSync(cache, { recursive: true });
182
+ // Write-then-rename so a killed download never leaves a truncated .tgz
183
+ // behind as a valid-looking cache entry.
184
+ const partial = `${cached}.partial`;
185
+ writeFileSync(partial, Buffer.from(await res.arrayBuffer()));
186
+ renameSync(partial, cached);
187
+ }
188
+
189
+ log(`Installing engine ${pin}…`);
190
+ installTarball(projectDir, cached);
191
+ const engine = installedEngine(projectDir);
192
+ if (!engine) throw olderEngineError(projectDir, pin) ?? new Error('engine install did not produce node_modules/@joystack/engine');
193
+ if (engine.version !== pin) {
194
+ throw new Error(`installed engine reports ${engine.version} but ${pin} was requested — corrupt cache? Delete ${cached} and retry.`);
195
+ }
196
+ writeEnginePin(projectDir, pin);
197
+ return engine;
198
+ }