@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 +79 -0
- package/bin/joystack.mjs +39 -2
- package/lib/assets-export.mjs +210 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +26 -11
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +21 -11
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +22 -0
- package/lib/fingerprint/fingerprint.mjs +235 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +78 -158
- package/lib/lint.mjs +15 -0
- package/lib/maker.mjs +16 -2
- package/lib/mime.mjs +28 -0
- package/lib/relay/cli.mjs +37 -88
- package/lib/relay/connection.mjs +2 -3
- package/lib/relay/conversation.mjs +15 -15
- package/lib/relay/engine.mjs +15 -0
- package/lib/relay/http.mjs +57 -0
- package/lib/relay/relay.mjs +267 -257
- package/lib/reload-client.mjs +58 -0
- package/lib/room-server.mjs +10 -0
- package/lib/shot/README.md +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +184 -110
- package/lib/test-cmd.mjs +41 -12
- package/package.json +3 -2
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, ``; 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
|
|
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 `}"` : ''})`;
|
|
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
|
+
}
|
package/lib/assets-json.mjs
CHANGED
|
@@ -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
|
|
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}`);
|
package/lib/assets-page.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
168
|
-
//
|
|
169
|
-
|
|
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.
|
|
172
|
-
host: '127.0.0.1', port, path: '
|
|
173
|
-
headers: { Host: `127.0.0.1:${port}`,
|
|
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
|
|
189
|
+
let text = '';
|
|
176
190
|
res.setEncoding('utf8');
|
|
177
|
-
res.on('data', (d) => { if (
|
|
191
|
+
res.on('data', (d) => { if (text.length < 65536) text += d; });
|
|
178
192
|
res.on('end', () => {
|
|
179
|
-
try { resolve(res.statusCode === 200 ? JSON.parse(
|
|
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
|
}
|
package/lib/assets-sheet.mjs
CHANGED
|
@@ -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.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
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),
|
|
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)}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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 `
|
|
80
|
-
* here; `joystack test`
|
|
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
|
{
|