@joystack/cli 0.1.54 → 0.1.56

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 CHANGED
@@ -14,6 +14,85 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.56] - 2026-10-22
18
+
19
+ ### Added
20
+
21
+ - `joystack relay reply <note id> <text>` answers a note the creator sent
22
+ you from the running game; the wake message prints the exact command. The
23
+ answer shows under their note even when your edit reloaded the page
24
+ before you replied, or the dev server restarted.
25
+ - `joystack dev` writes a start that failed in any browser playing your game, a phone on your wifi included, to `.joystack/dev/last.log` as one block naming the file and line.
26
+ - `joystack dev` serves a page opened with `?fresh=<token>` with every file under a new address, so nothing of it comes from the browser's cache; the failed-start card's Reload opens the game so.
27
+
28
+ ### Changed
29
+
30
+ - `joystack dev` needs engine 0.2.49 or later for the note, picks and
31
+ replies: on an older engine they do nothing until `npx joystack update`.
32
+ - `joystack dev` runs the engine's rebuilt relay, on one route. Each pick
33
+ the creator makes in the game or on the Assets page is a line in
34
+ `.joystack/dev/last.log` for you, and a page can ask whether this server
35
+ takes picks. Nothing a phone on the wifi or a `--share` tester does on
36
+ the game's page reaches you: the dev server refuses it.
37
+ - A page `joystack dev` serves may be framed by the same server under its
38
+ other loopback name (`localhost` and `127.0.0.1`, on the same port), and
39
+ still by no other site.
40
+ - `joystack assets` asks the running `joystack dev` who a note would wake
41
+ over the same relay as the page.
42
+ - In the Joystack maker, the line `joystack assets <id> --export` prints for a `.glb` that left something out now carries it as a title, `![Car](….glb "simplified copy: …")`; the chat marks that model a simplified copy and says what was left out when it is tapped. Copy the line as printed.
43
+
44
+ ### Removed
45
+
46
+ - `joystack relay publish` and `joystack relay tail`. Answer a note with
47
+ `joystack relay reply <note id> <text>`, the command its wake message
48
+ prints, and read what the creator picked in `.joystack/dev/last.log`.
49
+
50
+ ### Fixed
51
+
52
+ - A trait file that imports `three` (for its `view()`) now builds: `joystack dev` and `joystack publish` used to fail with `Could not resolve "three"`, and the page showed "The game has an error". The room now gets the same copy of three as the page.
53
+ - `joystack lint` and `joystack test` check your game on the engine its `package.json` names, installing it first as `joystack dev` does. After a merge that changed the engine, they used to check it on the engine still installed and could pass it on code it no longer runs. If that engine cannot be installed (no network, and never downloaded on this machine), they fail and say so rather than checking on another engine.
54
+
55
+ ## [0.1.55] - 2026-10-22
56
+
57
+ ### Added
58
+
59
+ - `joystack assets <id> --export` and `joystack assets --export <file.js>`
60
+ write an asset as files to `.joystack/exports/`: a model as `.glb` and a
61
+ picture, a sound as `.wav`, a 2D asset as `.png` (`--set`, `--tag`,
62
+ `--out`, `--stage`). Needs engine 0.2.48 or later.
63
+ - `joystack shot <script.mjs>` runs a short Playwright script of your
64
+ agent's against the running game and writes the pictures it takes with
65
+ `shot(name)` to `.joystack/shots/`. The script is handed a page already
66
+ open on the game at a phone's size, under a player name of its own, and the
67
+ world handle `openPage` gives a smoke; it is checked before anything starts,
68
+ the page opens only once the dev server has finished updating after an edit,
69
+ each step (a click, a `game.waitFor`) waits at most 5 seconds, the run stops,
70
+ saying so, if the game updates under it, and it fails if the game throws
71
+ while it runs. In a multiplayer game the page joins the game's one room,
72
+ beside anyone playing it.
73
+
74
+ ### Changed
75
+
76
+ - In the Joystack maker, `joystack dev` says the game is ready to play on
77
+ your page and that there is no link to give, and the dev server's log
78
+ lists no addresses, so the agent no longer hands you one you cannot open.
79
+ - `joystack dev` names every file of your game and the engine by its
80
+ content, so a browser keeps what has not changed between loads: opening
81
+ the game again after an edit downloads only the files the edit touched.
82
+
83
+ ### Fixed
84
+
85
+ - Under `joystack dev`, each of your entity files runs once: it used to run
86
+ twice, once for your game's imports and once for the engine's.
87
+ - In Safari, a game page `joystack dev` reloads after an edit downloads only
88
+ the files the edit touched: it used to download every file again.
89
+ - When your game does not build, the page `joystack dev` serves in its place
90
+ now shows the error's own message, not the line of code that threw it, and
91
+ answers with status 500, so a check that fetches the game sees it failed.
92
+ - `joystack test` no longer fails with `ENOENT` in `.joystack/frozen/` when
93
+ `joystack dev` is running: looking for your tests, it never opens
94
+ `.joystack/`, `node_modules/` or `overrides/`.
95
+
17
96
  ## [0.1.54] - 2026-10-21
18
97
 
19
98
  ### Changed
package/bin/joystack.mjs CHANGED
@@ -38,7 +38,9 @@ Usage:
38
38
  joystack dev --no-reload ...frozen as it is now: saves reach nobody until you restart it
39
39
  joystack assets [<id>] Browse and drive your models, sounds and assets on their own
40
40
  joystack assets <id> --sheet Draw a view sheet of a 3D asset to .joystack/sheets/ (--help: options)
41
+ joystack assets <id> --export Export an asset as files: a model as .glb and a picture, a sound as .wav (--help: options)
41
42
  joystack assets --json [<id>…] Print each asset's parameters, measured stats and flags as JSON
43
+ joystack shot <script.mjs> Run a short Playwright script on the running game; its shot(name) calls write pictures to .joystack/shots/ (--help)
42
44
  joystack lane <name> Open an isolated copy of the game to experiment in, safely
43
45
  joystack land <name> Put a lane's work into the game, then remove the lane
44
46
  joystack test [aspect] Run targeted checks (no aspect: conservative full run)
@@ -60,8 +62,7 @@ Usage:
60
62
  joystack replay profile [<id>] --ticks <a-b>
61
63
  Which functions ran in those ticks, as a table
62
64
  joystack feedback Send reports + replies under notes/feedback/; pull outcomes back in
63
- joystack relay publish <name> [<json>] Put an event on the running game's relay, as the agent (agent.replied …)
64
- joystack relay tail [<pattern>…] Print the running game's relay events as they happen, one JSON line each
65
+ joystack relay reply <note id> <text> Answer a note the creator sent you from the running game
65
66
  joystack login Authenticate this machine as your Joystack player account
66
67
  joystack logout [--all] Sign out: revoke the token (--all: on every machine)
67
68
  joystack whoami Show who this machine publishes as
@@ -134,6 +135,23 @@ try {
134
135
  process.stdout.write(`${safeJson(out)}\n`);
135
136
  break;
136
137
  }
138
+ // `--export`: the asset as files, made headless (assets-export.mjs).
139
+ if (rest.includes('--export')) {
140
+ const { assetsExport, parseExportArgs, EXPORT_HELP } = await import('../lib/assets-export.mjs');
141
+ if (rest.includes('--help')) {
142
+ console.log(EXPORT_HELP);
143
+ break;
144
+ }
145
+ try {
146
+ await assetsExport({ ...parseExportArgs(rest), startDev: dev });
147
+ } catch (err) {
148
+ // An error here may quote the game's own text, so every line of it
149
+ // is made printable before it reaches the terminal.
150
+ console.error(`joystack assets: ${printableLines(err?.message ?? err)}`);
151
+ process.exit(1);
152
+ }
153
+ process.exit(0);
154
+ }
137
155
  // `--sheet`: draw a view sheet headless and write it to disk, instead
138
156
  // of opening the page (assets-sheet.mjs).
139
157
  if (rest.includes('--sheet')) {
@@ -186,6 +204,25 @@ try {
186
204
  process.on('SIGTERM', shutdown);
187
205
  break;
188
206
  }
207
+ case 'shot': {
208
+ // A Playwright script of the agent's own, run on the running game
209
+ // (lib/shot/).
210
+ const { shot: runShot, parseShotArgs, SHOT_HELP } = await import('../lib/shot/shot.mjs');
211
+ if (rest.includes('--help') || !rest.length) {
212
+ console.log(SHOT_HELP);
213
+ break;
214
+ }
215
+ try {
216
+ await runShot({ ...parseShotArgs(rest), startDev: dev });
217
+ } catch (err) {
218
+ // The runner has already said what went wrong; anything else here may
219
+ // quote the game's own text (its build error), so every line of it is
220
+ // made printable before it reaches the terminal.
221
+ if (!err?.reported) console.error(`joystack shot: ${printableLines(err?.message ?? err)}`);
222
+ process.exit(err?.exitCode ?? 1);
223
+ }
224
+ process.exit(0);
225
+ }
189
226
  case 'relay': {
190
227
  const { relayCommand, RELAY_HELP } = await import('../lib/relay/cli.mjs');
191
228
  const dirAt = rest.indexOf('--dir');
@@ -0,0 +1,210 @@
1
+ // `joystack assets <id> --export` and `joystack assets --export <file.js>`:
2
+ // one asset as files someone else can open, made by the Assets page headless
3
+ // from the asset as the game builds it (shared/ui/assets/export/).
4
+ //
5
+ // joystack assets car --export .joystack/exports/car.glb + car.png
6
+ // joystack assets car --export --set armor=2 --tag heavy
7
+ // .joystack/exports/car-heavy.glb + .png
8
+ // joystack assets engine-hum --export .joystack/exports/engine-hum.wav
9
+ // joystack assets --export art/car-v2.js .joystack/exports/car-v2.glb + .png
10
+ //
11
+ // The last form is a scratch module: a file in the asset format (the mount
12
+ // and apply a declared asset's module has) that is not declared in assets.js,
13
+ // so a version of a model can be made, exported and thrown away, leaving
14
+ // nothing in the game.
15
+ //
16
+ // In the maker (JOYSTACK_MAKER set), it also prints the line that shows the
17
+ // result in the agent's reply, which is how the person sees it there.
18
+
19
+ import { mkdirSync } from 'node:fs';
20
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
21
+ import { findProject } from './project.mjs';
22
+ import { ASSETS_PAGE_PATH } from './assets-page.mjs';
23
+ import { gpuArgs } from './smoke-gpu-preload.mjs';
24
+ import { engineTooOld, loadDriver, loadPlaywright, parseSet, printable, safeName, stackOrigin, stateFolderIn, writeReplacing } from './assets-sheet.mjs';
25
+ import { makerDraft } from './maker.mjs';
26
+
27
+ export const EXPORT_HELP = `joystack assets <id> --export [--set <k=v,…>] [--tag <name>] [--out <dir>] [--port <n>]
28
+ joystack assets --export <file.js> [--stage <stage>] [--set <k=v,…>] [--tag <name>] [--out <dir>] [--port <n>]
29
+
30
+ Exports one asset as files: a 3D model as <name>.glb and a picture of it
31
+ <name>.png, a sound as <name>.wav, any other asset as <name>.png. Written to
32
+ .joystack/exports/ unless --out names a folder. An outline drawn as a
33
+ back-face hull and toon paint are converted so the .glb looks the same in
34
+ any glTF viewer; what it still cannot carry (a shader of the asset's own,
35
+ particles, reflections, glows, motion made in code, …) is named on a
36
+ "left out:" line, and in the maker the reply line marks the model a
37
+ simplified copy.
38
+
39
+ <file.js> a scratch module: a file in the asset format that is not
40
+ declared in assets.js, exported as it is (its stage is
41
+ three unless --stage names another). For trying versions
42
+ of an asset without adding them to the game.
43
+ --set k=v,… export it at these parameter values. A value outside what
44
+ the asset declares is an error that names the range.
45
+ --tag <name> added to the file names, e.g. --tag v2
46
+ --out <dir> where to write
47
+
48
+ Uses the dev stack already running for this game, or starts one for the
49
+ length of the command. Needs playwright in the game (npm i -D playwright).`;
50
+
51
+ const SWITCHES = new Set(['--export', '--help']);
52
+ const SCRATCH = /\.(m?js)$/i;
53
+ // The id a scratch module has on the Assets page (shared/ui/assets/index.html).
54
+ const SCRATCH_ID = '_scratch';
55
+
56
+ // The arguments after `joystack assets`, read for the export mode.
57
+ export function parseExportArgs(rest = []) {
58
+ const opts = { id: undefined, module: null, stage: null, sets: [], tag: null, out: null, port: null };
59
+ let named;
60
+ for (let i = 0; i < rest.length; i += 1) {
61
+ const a = rest[i];
62
+ const value = () => {
63
+ const v = rest[i + 1];
64
+ if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${EXPORT_HELP}`);
65
+ i += 1;
66
+ return v;
67
+ };
68
+ if (a === '--sheet' || a === '--json') throw new Error(`--export and ${a} are two different outputs; ask for one.\n\n${EXPORT_HELP}`);
69
+ if (a === '--set') opts.sets.push(value());
70
+ else if (a === '--tag') opts.tag = value();
71
+ else if (a === '--out') opts.out = value();
72
+ else if (a === '--stage') opts.stage = value();
73
+ else if (a === '--port') {
74
+ const p = Number(value());
75
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error('--port needs a port number');
76
+ opts.port = p;
77
+ } else if (a.startsWith('--')) {
78
+ if (!SWITCHES.has(a)) throw new Error(`joystack assets --export has no option ${a}.\n\n${EXPORT_HELP}`);
79
+ } else if (named === undefined) named = a;
80
+ else throw new Error(`joystack assets --export exports one asset; got "${named}" and "${a}".\n\n${EXPORT_HELP}`);
81
+ }
82
+ if (!named) throw new Error(`Name the asset or the file: joystack assets <id> --export, or joystack assets --export <file.js>\n\n${EXPORT_HELP}`);
83
+ if (SCRATCH.test(named)) opts.module = named;
84
+ else opts.id = named;
85
+ if (opts.stage && !opts.module) throw new Error('--stage is for a scratch module; a declared asset names its own stage in assets.js');
86
+ return { ...opts, values: parseSet(opts.sets) };
87
+ }
88
+
89
+ // A scratch module's path inside the game, with forward slashes, or an error.
90
+ export function scratchPath(projectDir, file, cwd) {
91
+ const full = resolve(cwd, file);
92
+ const rel = relative(projectDir, full);
93
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
94
+ throw new Error(`${file} is not inside this game; a scratch module has to be, so the dev server can serve it`);
95
+ }
96
+ return rel.split(sep).join('/');
97
+ }
98
+
99
+ // The line that shows an exported file in the agent's reply in the maker: a
100
+ // markdown image of the model's .glb (its picture is found beside it), the
101
+ // sound or the picture. The path is written with forward slashes, since a
102
+ // backslash in markdown escapes the character after it.
103
+ //
104
+ // A .glb that leaves something out (`left`, the export's sentences) carries
105
+ // them as the image's title, `"simplified copy: …"`, and the maker's chat
106
+ // marks the model so, with what was left out on a tap, one item per
107
+ // sentence: the chat splits the title on ";", so a sentence's own becomes a
108
+ // comma. A model with nothing left out has no title.
109
+ export function replyLine(label, path, left = []) {
110
+ const alt = String(label).replace(/[[\]\n]/g, ' ').trim() || 'Asset';
111
+ path = path.split(sep).join('/');
112
+ const where = /[\s()<>]/.test(path) ? `<${path}>` : path;
113
+ const said = /\.glb$/i.test(path) ? left.map((l) => String(l).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/"/g, "'").replace(/;/g, ',').replace(/\s+/g, ' ').trim()).filter(Boolean) : [];
114
+ return `![${alt}](${where}${said.length ? ` "simplified copy: ${said.join('; ')}"` : ''})`;
115
+ }
116
+
117
+ const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
118
+
119
+ // Where the Assets page is opened for the export, and the name its files take.
120
+ function pageFor({ projectDir, id, module, stage, cwd }) {
121
+ const query = new URLSearchParams({ _thumbs: '0' });
122
+ if (!module) {
123
+ query.set('_item', id);
124
+ return { query, name: id };
125
+ }
126
+ const path = scratchPath(projectDir, module, cwd);
127
+ query.set('_item', SCRATCH_ID);
128
+ query.set('_module', path);
129
+ if (stage) query.set('_stage', stage);
130
+ return { query, name: basename(path, extname(path)) };
131
+ }
132
+
133
+ // The page's ids hold the asset asked for, or the error that says why not.
134
+ function mustHold(ids, { id, module, errors }) {
135
+ const want = module ? SCRATCH_ID : id;
136
+ if (Array.isArray(ids) && ids.includes(want)) return;
137
+ if (module) throw new Error(`${printable(module)} did not load as an asset${errors.length ? `: ${printable(errors.join('; '))}` : ''}`);
138
+ const declared = Array.isArray(ids) ? ids.filter((x) => x !== SCRATCH_ID).map(printable).join(', ') : '';
139
+ throw new Error(`there is no asset "${printable(id)}" in this game's assets.js. Declared: ${declared || '(none)'}`);
140
+ }
141
+
142
+ // Each exported file written under `base` in `outDir`: { glb?, png?, wav? }.
143
+ function writeFiles(made, outDir, base) {
144
+ const files = {};
145
+ for (const [key, file] of Object.entries(made.files)) {
146
+ files[key] = join(outDir, `${base}.${key}`);
147
+ writeReplacing(files[key], file.bytes);
148
+ }
149
+ return files;
150
+ }
151
+
152
+ // What was written, what a file left out, and in the maker the reply line.
153
+ function report({ files, made, label, projectDir, errors, env, log }) {
154
+ const shown = (p) => (p.startsWith(`${projectDir}${sep}`) ? p.slice(projectDir.length + 1) : p);
155
+ const main = files.glb ?? files.wav ?? files.png;
156
+ log(`\n 📦 ${printable(label)} → ${shown(main)}`);
157
+ if (files.glb && files.png) log(` its picture → ${shown(files.png)}`);
158
+ for (const l of made.left ?? []) log(` left out: ${printable(l)}`);
159
+ if (made.clips?.length) log(` clips: ${made.clips.map(printable).join(', ')}`);
160
+ if (errors.length) log(` ⚠ the page reported: ${printable(errors.join('; '))}`);
161
+ if (makerDraft(env)) log(` To show it in your reply, write: ${replyLine(label, shown(main), main === files.glb ? made.left ?? [] : [])}`);
162
+ }
163
+
164
+ // The game's Assets page driver, or the one a test gives, refused when the
165
+ // game's engine is too old to export.
166
+ async function driverFor(projectDir, given) {
167
+ const driver = given ?? await loadDriver(projectDir, 'joystack assets --export');
168
+ if (typeof driver.exportAsset !== 'function') throw engineTooOld('joystack assets --export', projectDir);
169
+ return driver;
170
+ }
171
+
172
+ // A headless browser from the game's own Playwright.
173
+ async function launch(projectDir) {
174
+ const { chromium } = await loadPlaywright(projectDir, 'an asset is exported');
175
+ return chromium.launch({ args: gpuArgs() });
176
+ }
177
+
178
+ // Export one asset. `startDev` starts a dev stack when none is running for
179
+ // the game. `origin`, `browser` and `headless` are for a caller that already
180
+ // has them (a test), as in assetsSheet. `env` decides whether the maker's
181
+ // line is printed.
182
+ export async function assetsExport({
183
+ cwd = process.cwd(), id, module, stage, values = {}, tag, out, port, startDev, log = console.log, origin, browser, headless, env = process.env,
184
+ } = {}) {
185
+ const project = findProject(cwd);
186
+ const { query, name } = pageFor({ projectDir: project.dir, id, module, stage, cwd });
187
+ let started = null;
188
+ if (!origin) ({ origin, started } = await stackOrigin({ cwd, projectDir: project.dir, port, startDev }));
189
+ let ownBrowser = null;
190
+ try {
191
+ const driver = await driverFor(project.dir, headless);
192
+ if (!browser) browser = ownBrowser = await launch(project.dir);
193
+ const insp = await driver.openAssetsPage({ browser, url: `${origin}${ASSETS_PAGE_PATH}?${query}`, viewport: { width: 1280, height: 800 } });
194
+ try {
195
+ mustHold(await insp.ids(), { id, module, errors: insp.errors });
196
+ const made = await driver.exportAsset(insp, { values });
197
+ if (made.problems) throw new Error(`these values would not apply as given, so nothing was exported:\n${lines(made.problems)}`);
198
+ const outDir = out ? resolve(cwd, out) : stateFolderIn(project.dir, 'exports');
199
+ mkdirSync(outDir, { recursive: true });
200
+ const files = writeFiles(made, outDir, `${safeName(name)}${tag ? `-${safeName(tag)}` : ''}`);
201
+ report({ files, made, label: module ? name : made.label, projectDir: project.dir, errors: insp.errors, env, log });
202
+ return { files, kind: made.kind, left: made.left ?? [] };
203
+ } finally {
204
+ await insp.close();
205
+ }
206
+ } finally {
207
+ if (ownBrowser) await ownBrowser.close();
208
+ await started?.stop();
209
+ }
210
+ }
@@ -62,7 +62,7 @@ export function parseJsonArgs(rest = []) {
62
62
  let port = null;
63
63
  for (let i = 0; i < rest.length; i += 1) {
64
64
  const a = rest[i];
65
- if (a === '--sheet') throw new Error(`--json and --sheet are two different outputs; ask for one.\n\n${JSON_HELP}`);
65
+ if (a === '--sheet' || a === '--export') throw new Error(`--json and ${a} are two different outputs; ask for one.\n\n${JSON_HELP}`);
66
66
  if (a === '--port') {
67
67
  const v = rest[i + 1];
68
68
  if (v == null || v.startsWith('--')) throw new Error(`--port needs a value.\n\n${JSON_HELP}`);
@@ -51,8 +51,9 @@ export function hasDeclaredAssets(projectDir) {
51
51
  // argument that is not a flag" turns 8210 into the thing to open — on the one
52
52
  // command whose entire job is opening the right asset.
53
53
  // The `--set`, `--tag`, `--out` and `--against` of `joystack assets <id> --sheet`
54
- // are among them (assets-sheet.mjs).
55
- export const VALUED_FLAGS = new Set(['--port', '--set', '--tag', '--out', '--against']);
54
+ // are among them (assets-sheet.mjs), and the `--stage` of `--export`
55
+ // (assets-export.mjs).
56
+ export const VALUED_FLAGS = new Set(['--port', '--set', '--tag', '--out', '--against', '--stage']);
56
57
 
57
58
  export function itemArg(rest = []) {
58
59
  for (let i = 0; i < rest.length; i += 1) {
@@ -164,22 +165,36 @@ export async function openAssets({
164
165
  }
165
166
 
166
167
  // The agent a running stack would wake (`'Claude Code'`), or null when none
167
- // is listening or the stack cannot say. Loopback only, with the header the
168
- // status route requires.
169
- function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
168
+ // is listening or the stack cannot say: the relay's `server.agent`
169
+ // (relay/relay.mjs), asked on loopback with the origin of a page the stack
170
+ // served, which is what it takes from a program on this machine.
171
+ export function stackAgent(port, timeoutMs = HEALTH_TIMEOUT_MS) {
172
+ return loopbackJson({
173
+ port, path: '/__joystack/relay', timeoutMs,
174
+ headers: { Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({ name: 'server.agent' }),
176
+ }).then((out) => (out?.ok ? out.value ?? null : null));
177
+ }
178
+
179
+ // What a route of a dev stack on this machine answers, as JSON, or null when
180
+ // it answers anything but a 200 with JSON, or nothing in `timeoutMs`. Asked of
181
+ // 127.0.0.1 under its own Host, which is what the stack's same-origin checks
182
+ // accept: a GET, or a POST of `body`.
183
+ export function loopbackJson({ port, path, headers = {}, body = null, timeoutMs = HEALTH_TIMEOUT_MS }) {
170
184
  return new Promise((resolve) => {
171
- const req = http.get({
172
- host: '127.0.0.1', port, path: '/__joystack/relay/status', timeout: timeoutMs,
173
- headers: { Host: `127.0.0.1:${port}`, 'X-Joystack-Agent-UI': '1' },
185
+ const req = http.request({
186
+ host: '127.0.0.1', port, path, method: body === null ? 'GET' : 'POST', timeout: timeoutMs,
187
+ headers: { Host: `127.0.0.1:${port}`, ...headers },
174
188
  }, (res) => {
175
- let body = '';
189
+ let text = '';
176
190
  res.setEncoding('utf8');
177
- res.on('data', (d) => { if (body.length < 65536) body += d; });
191
+ res.on('data', (d) => { if (text.length < 65536) text += d; });
178
192
  res.on('end', () => {
179
- try { resolve(res.statusCode === 200 ? JSON.parse(body).agent ?? null : null); } catch { resolve(null); }
193
+ try { resolve(res.statusCode === 200 ? JSON.parse(text) : null); } catch { resolve(null); }
180
194
  });
181
195
  });
182
196
  req.on('timeout', () => { req.destroy(); resolve(null); });
183
197
  req.on('error', () => resolve(null));
198
+ req.end(body ?? undefined);
184
199
  });
185
200
  }
@@ -41,7 +41,7 @@ import { gpuArgs } from './smoke-gpu-preload.mjs';
41
41
  import { printable } from './printable.mjs';
42
42
  import { stateDirName } from './legacy-names.mjs';
43
43
 
44
- export const SHEET_HELP = `joystack assets <id> --sheet [--set <k=v,…>] [--tag <name>] [--out <dir>] [--against <sheet.json>]
44
+ export const SHEET_HELP = `joystack assets <id> --sheet [--set <k=v,…>] [--tag <name>] [--out <dir>] [--against <sheet.json>] [--port <n>]
45
45
 
46
46
  Draws a view sheet of one 3D asset: front, side, back and top at one scale
47
47
  with a ruler, a three-quarter view, the in-game view when assets.js declares
@@ -185,13 +185,16 @@ export function writeReplacing(path, data, { suffix = () => randomBytes(8).toStr
185
185
  }
186
186
  }
187
187
 
188
- // The default sheets folder, created inside the game. `.joystack` and
189
- // `.joystack/sheets` must each be a real folder, never a link (even one that
190
- // points inside the game today), and the folder must resolve inside the game
191
- // once it exists.
192
- export function sheetsDirIn(projectDir) {
188
+ // The default sheets folder, created inside the game.
189
+ export const sheetsDirIn = (projectDir) => stateFolderIn(projectDir, 'sheets');
190
+
191
+ // A folder in the game's state folder (`.joystack/<name>`), created when it
192
+ // is missing. `.joystack` and the folder must each be a real folder, never a
193
+ // link (even one that points inside the game today), and the folder must
194
+ // resolve inside the game once it exists.
195
+ export function stateFolderIn(projectDir, name) {
193
196
  let dir = projectDir;
194
- for (const part of [stateDirName(projectDir), 'sheets']) {
197
+ for (const part of [stateDirName(projectDir), name]) {
195
198
  dir = join(dir, part);
196
199
  const shown = relative(projectDir, dir);
197
200
  let st = null;
@@ -207,7 +210,7 @@ export function sheetsDirIn(projectDir) {
207
210
  const real = realpathSync(dir);
208
211
  const rel = relative(realpathSync(projectDir), real);
209
212
  if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
210
- throw new Error(`${stateDirName(projectDir)}/sheets in this game leads outside it (to ${printable(real)}); remove the link, or choose a folder with --out`);
213
+ throw new Error(`${stateDirName(projectDir)}/${name} in this game leads outside it (to ${printable(real)}); remove the link, or choose a folder with --out`);
211
214
  }
212
215
  return dir;
213
216
  }
@@ -264,6 +267,19 @@ export async function loadPlaywright(projectDir, why = 'a view sheet is drawn')
264
267
 
265
268
  const lines = (problems) => problems.map((p) => printable(` ✗ ${p.key}=${p.requested}: ${p.key} ${p.reason}`)).join('\n');
266
269
 
270
+ // The origin of the dev stack running for the game, or of one started for the
271
+ // length of a command when none is (`started`, to stop afterwards; null when
272
+ // one was already running). The one started has no room server unless `mp`:
273
+ // a sheet needs none, a picture of a multiplayer game does.
274
+ export async function stackOrigin({ cwd, projectDir, port = null, startDev = null, mp = false }) {
275
+ const stack = await findRunningStack({ projectDir, port });
276
+ if (stack) return { origin: stack.origin, started: null };
277
+ if (!startDev) throw new Error('no dev stack is running for this game');
278
+ const quiet = [];
279
+ const started = await startDev({ cwd, port: port ?? undefined, watch: false, reload: false, noMp: !mp, log: (l) => quiet.push(l) });
280
+ return { origin: new URL(started.url).origin, started };
281
+ }
282
+
267
283
  // Draw one sheet. `startDev` starts a dev stack (dev() in the CLI) when none
268
284
  // is running for the game. `origin`, `browser` and `headless` are for a caller
269
285
  // that already has them (a test): an origin skips looking for a stack, a
@@ -274,18 +290,10 @@ export async function assetsSheet({
274
290
  } = {}) {
275
291
  const earlier = against ? readEarlierSheet(resolve(cwd, against)) : null;
276
292
  let project = null;
277
- let stack = null;
278
293
  let started = null;
279
294
  if (!origin) {
280
295
  project = findProject(cwd);
281
- stack = await findRunningStack({ projectDir: project.dir, port });
282
- if (!stack) {
283
- if (!startDev) throw new Error('no dev stack is running for this game');
284
- const quiet = [];
285
- started = await startDev({ cwd, port: port ?? undefined, watch: false, reload: false, noMp: true, log: (l) => quiet.push(l) });
286
- stack = { origin: new URL(started.url).origin };
287
- }
288
- origin = stack.origin;
296
+ ({ origin, started } = await stackOrigin({ cwd, projectDir: project.dir, port, startDev }));
289
297
  }
290
298
  if (!project && !(headless && browser)) project = findProject(cwd);
291
299
  const base = `${safeName(id)}${tag ? `-${safeName(tag)}` : ''}-sheet`;
package/lib/dev.mjs CHANGED
@@ -83,7 +83,8 @@ export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs =
83
83
  // answer, both commands.
84
84
 
85
85
  // `share` opens a public link to the stack for testers (share.mjs); `openShare`
86
- // replaces the tunnel for tests.
86
+ // replaces the tunnel for tests. `announce: false` prints no address: the
87
+ // Joystack maker, which serves the game on the person's own page.
87
88
  export async function dev(opts = {}) {
88
89
  // An interactive `joystack dev` (watch on, the default) also writes
89
90
  // everything it prints to .joystack/dev/last.log, so an agent that started
@@ -114,14 +115,18 @@ export async function dev(opts = {}) {
114
115
  //
115
116
  // `shareIsOwner` is for a host that serves the share link to the creator
116
117
  // alone and keeps everyone else off the dev tools' routes (the Joystack
117
- // maker): a relay event sent through the link then counts as one from this
118
+ // maker): a relay request sent through the link then counts as one from this
118
119
  // machine, so it can wake the agent and read its reply.
119
120
  //
121
+ // `agentFeed: false` turns off the relay's feed for Claude Code (the lines
122
+ // relay/relay.mjs writes to the log, and `server.picks`), for a host whose
123
+ // page takes the picks itself (the Joystack maker).
124
+ //
120
125
  // `pollMs` is how often, in milliseconds, the game's files are compared for
121
126
  // changes the operating system's events miss (live-versions.mjs); left unset
122
127
  // it is that module's default of one second. Tests pass a shorter one so a
123
128
  // save is acted on sooner.
124
- 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, shareIsOwner = false, devLogPath = null, pollMs } = {}, onFailure) {
129
+ 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, shareIsOwner = false, agentFeed = true, devLogPath = null, pollMs, announce = true } = {}, onFailure) {
125
130
  const project = findProject(cwd);
126
131
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
127
132
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -334,6 +339,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
334
339
  engineVersion: engine.version ?? null, // stamped on recorded sessions
335
340
  agentSession, // woken by a note sent on the relay from this machine
336
341
  shareIsOwner, // and by one sent through the share link, when it is the creator's alone
342
+ agentFeed, // the relay's feed for Claude Code, off where the page's host takes the picks itself (the maker)
337
343
  mounts: mountsFor(codeDir),
338
344
  ...(first ? { fixedBuild: first.build } : {}),
339
345
  versions: live ? {
@@ -575,24 +581,28 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
575
581
  const ip = lanIp();
576
582
  log('');
577
583
  log('────────────────────────────────────────────────────────────');
578
- log(`✅ ${project.slug} ready. Open:`);
579
- log(` ${url}`);
580
- if (ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
584
+ // `announce: false`: the Joystack maker shows the game on the person's own
585
+ // page, so nothing here is an address anyone could open, and an address in
586
+ // this output (which an agent reads in .joystack/dev/last.log) is one it
587
+ // would hand over.
588
+ log(announce ? `✅ ${project.slug} ready. Open:` : `✅ ${project.slug} ready.`);
589
+ if (announce) log(` ${url}`);
590
+ if (announce && ip) log(` 📱 http://${ip}:${ports.static}/games/${project.slug}/index.html (phone / LAN)`);
581
591
  // Only when this game declares assets (inspect.mjs). A line printed on every
582
592
  // single boot is a line nobody reads by the third one, and the tool it points
583
593
  // at is then invisible exactly when it would have helped.
584
- if (hasDeclaredAssets(project.dir)) {
594
+ if (announce && hasDeclaredAssets(project.dir)) {
585
595
  log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
586
596
  }
587
597
  if (devLogPath) log(` 📄 ${relative(project.dir, devLogPath).split(sep).join('/')} (everything this server prints, for reading later)`);
588
598
  if (share) {
589
- log('');
590
- log('→ share opening a public link (cloudflared)…');
599
+ if (announce) log('');
600
+ if (announce) log('→ share opening a public link (cloudflared)…');
591
601
  tunnel = await openShare({ port: ports.static }); // a failure stops the stack (see dev)
592
602
  staticServer.setShareHost(tunnel.hostname);
593
- for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
603
+ if (announce) for (const line of shareBanner({ url: tunnel.url, gamePath: `/games/${project.slug}/index.html`, resolved: tunnel.resolved, frozen: live && !reload })) log(line);
594
604
  tunnel.exited.then(() => {
595
- if (!stopped) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
605
+ if (!stopped && announce) log('⚠ the share link stopped working (cloudflared exited). Restart `joystack dev --share` for a new one.');
596
606
  });
597
607
  }
598
608
  log('────────────────────────────────────────────────────────────');
package/lib/engine.mjs CHANGED
@@ -76,8 +76,9 @@ function npmInstallTarball(projectDir, tgzPath) {
76
76
  *
77
77
  * The moment those doors are wanted is the moment somebody runs a test file by
78
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).
79
+ * behind in advance, by whatever ran last. `create`, `dev`, `lint` and `test`
80
+ * come through here (the checks by ensureNamedEngine); `joystack test` also
81
+ * writes them itself (test-cmd.mjs).
81
82
  */
82
83
  export async function ensureEngine(projectDir, opts = {}) {
83
84
  const engine = await installEngine(projectDir, opts);
@@ -85,6 +86,38 @@ export async function ensureEngine(projectDir, opts = {}) {
85
86
  return engine;
86
87
  }
87
88
 
89
+ /**
90
+ * The engine a check runs on (`joystack lint`, `joystack test`): the one the
91
+ * game's package.json names, installed first when node_modules holds another.
92
+ * `git merge` of an engine update rewrites the `joystack.engine` pin and leaves
93
+ * node_modules, which git ignores, on the old engine; a check that resolved the
94
+ * installed copy would pass the game on code it no longer runs.
95
+ *
96
+ * It costs two small file reads when the named engine is already installed.
97
+ * Otherwise it is `ensureEngine`: it installs from the download cache
98
+ * (`~/.joystack/cache`) when this machine downloaded that release before, and
99
+ * needs the network and a sign-in only for a release it never had. A game with
100
+ * no pin is left on what is installed, and null is returned: a check never
101
+ * picks an engine for a game, `create` and `dev` do. Throws, with the reason,
102
+ * when the named engine cannot be installed.
103
+ */
104
+ export async function ensureNamedEngine(projectDir, { ensure = ensureEngine, log = console.log } = {}) {
105
+ if (!readEnginePin(projectDir)) return null;
106
+ // A check runs unattended (an agent's lint or test): it never opens the
107
+ // sign-in flow and waits on a person, as `dev` does.
108
+ const loginFn = async () => { throw new Error('this machine is not signed in to Joystack: run `npx joystack login`, or `npx joystack dev`, which signs in and installs it'); };
109
+ return ensure(projectDir, { log, loginFn });
110
+ }
111
+
112
+ // The line a check prints when the engine its game names could not be
113
+ // installed, so that neither lint nor test passes a game on another engine.
114
+ export function namedEngineFailure(projectDir, error) {
115
+ let installed = null;
116
+ try { installed = resolveEngine(projectDir).version; } catch { /* none installed */ }
117
+ return `your game names engine ${readEnginePin(projectDir)} in package.json, and it could not be installed `
118
+ + `(${installed ? `this game has ${installed}` : 'no engine is installed'}): ${error?.message ?? error}`;
119
+ }
120
+
88
121
  async function installEngine(
89
122
  projectDir,
90
123
  {