@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
@@ -0,0 +1,333 @@
1
+ // The agent surface — skills + the Layer-0 managed block — and how an update
2
+ // reaches a repo that already exists (creator-harness Slice 2, decision Q2).
3
+ //
4
+ // The surface ships INSIDE the versioned engine artifact, and ONLY there — the
5
+ // npm CLI carries no copy of it. That is what makes the ownership record below
6
+ // trustworthy: `create` places the surface by calling straight into this module
7
+ // (against the artifact it just installed), so the bytes on disk and the hashes
8
+ // we record come from the same place, and a fresh repo's first `update` is a
9
+ // genuine no-op. The CLI used to seed the surface from its own bundled
10
+ // `template/`, stamped with the ENGINE's version — a record that was false the
11
+ // moment npm and the engine registry shipped out of step, and that made the
12
+ // first `update` overwrite (or DELETE, via the orphan prune below) skills on a
13
+ // repo minutes old.
14
+ //
15
+ // **This runs under `joystack update` — and under `joystack create`** — never on
16
+ // `dev` or `publish`. It COULD run there safely (it can only ever converge a
17
+ // repo to the engine version already pinned in its package.json, never a newer one),
18
+ // but a creator who ran `dev` and then found files they never wrote sitting in
19
+ // `git status` would be right to feel we'd gone through their pockets. Updates
20
+ // are something you ASK for. So this module writes nothing on its own schedule,
21
+ // and it narrates nothing either: it returns what it did, and `joystack update`
22
+ // tells the creator, who decides whether to commit it.
23
+ //
24
+ // The one contract everything here serves:
25
+ //
26
+ // an update always lands Joystack's surface, and never loses the
27
+ // creator's content — anything it replaces or removes is backed up
28
+ // and reported.
29
+ //
30
+ // Ownership is by PATH (note hx3kd9): a path the artifact ships belongs to the
31
+ // platform and always converges to the artifact's version. On each run a file at
32
+ // a shipped path is one of:
33
+ //
34
+ // content already equal to the incoming → heal the record, silently
35
+ // ours, unmodified (hash == recorded) → replace with the new
36
+ // anything else on disk → back it up, replace, report lossy
37
+ // not on disk → place ours
38
+ // retired upstream / orphaned → remove (backup + lossy when the
39
+ // content didn't match the record)
40
+ //
41
+ // There is deliberately no keep-theirs outcome. The old model kept edited
42
+ // files and "shadowed" the incoming version — which permanently froze a file
43
+ // out of every future update after a single (usually agent-made, usually
44
+ // unnoticed) edit, breaking the harness for that repo silently (fb_3a0bea).
45
+ // The hash record in `.joystack/agent-surface.json` is therefore not an ownership
46
+ // gate anymore: it is a change DETECTOR, kept so the report can say "this had
47
+ // local content" honestly instead of warning on every write. `lossy` is that
48
+ // report; `joystack update` narrates it and the /update-joystack skill asks the
49
+ // creator what to do with each lost edit.
50
+ //
51
+ // A skill we never ship is never even enumerated, so a creator's own skills
52
+ // are invisible to this code by construction. `AGENTS.md` is the sanctioned
53
+ // home for creator additions: the managed block between the markers is ours to
54
+ // rewrite; everything outside them (their title, their instructions below the
55
+ // end marker) is theirs and is spliced back untouched — and when the markers
56
+ // themselves have been deleted, the block is re-appended below their content
57
+ // rather than contesting any of it.
58
+ import { createHash } from 'node:crypto';
59
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
60
+ import { dirname, join, relative } from 'node:path';
61
+ import { LEGACY_MANAGED_MARKERS, stateDir } from './legacy-names.mjs';
62
+
63
+ export const SURFACE_DIR = 'agent-surface';
64
+ export const LOCAL_MANIFEST = '.joystack/agent-surface.json';
65
+ const localManifestPath = (projectDir) => join(stateDir(projectDir), 'agent-surface.json');
66
+
67
+ const START = '<!-- joystack:managed:start -->';
68
+ const END = '<!-- joystack:managed:end -->';
69
+
70
+ const sha = (buf) => createHash('sha256').update(buf).digest('hex');
71
+
72
+ // Where each artifact path lands in the game repo. `skills/**` is the whole
73
+ // surface today; AGENTS.md is spliced rather than written whole.
74
+ // Targets are manifest keys, not host paths: forward slashes on every platform,
75
+ // because they are recorded in .joystack/agent-surface.json, compared with
76
+ // `startsWith(`${dest}/`)`, and reported to the creator. `join(projectDir, dest)`
77
+ // turns one into a host path wherever the filesystem is touched.
78
+ function target(rel) {
79
+ if (rel === 'AGENTS.md') return 'AGENTS.md';
80
+ if (rel.startsWith('skills/')) return `.claude/${rel}`;
81
+ return null; // unknown surface entry from a newer engine — ignore, don't guess
82
+ }
83
+
84
+ function projectName(projectDir) {
85
+ try {
86
+ return JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')).name || '';
87
+ } catch {
88
+ return '';
89
+ }
90
+ }
91
+
92
+ function readLocal(projectDir) {
93
+ try {
94
+ const m = JSON.parse(readFileSync(localManifestPath(projectDir), 'utf8'));
95
+ return { engineVersion: m.engineVersion ?? null, placed: m.placed ?? {} };
96
+ } catch {
97
+ return null; // no manifest = a pre-Slice-2 repo; see `adopt` below
98
+ }
99
+ }
100
+
101
+ function writeLocal(projectDir, engineVersion, placed) {
102
+ const p = localManifestPath(projectDir);
103
+ mkdirSync(dirname(p), { recursive: true });
104
+ writeFileSync(p, JSON.stringify({ engineVersion, placed }, null, 2) + '\n');
105
+ }
106
+
107
+ // Removing the last SKILL.md from a skill folder must remove the folder too —
108
+ // an empty `.claude/skills/handbook/` still reads to an agent as a skill that
109
+ // exists and is broken. Walks up, stopping at .claude/skills (never the repo).
110
+ function pruneEmptyDirs(projectDir, dest) {
111
+ let dir = dirname(join(projectDir, dest));
112
+ const stop = join(projectDir, '.claude', 'skills');
113
+ while (dir.startsWith(stop) && dir !== stop) {
114
+ try {
115
+ if (readdirSync(dir).length) return;
116
+ rmSync(dir, { recursive: true, force: true });
117
+ } catch {
118
+ return;
119
+ }
120
+ dir = dirname(dir);
121
+ }
122
+ }
123
+
124
+ // Whether everything on disk at `dest` (a file, or a whole skill folder) still
125
+ // matches the hashes recorded for it. Distinguishes a clean retire/orphan
126
+ // removal from one that destroys real local content — only the latter earns a
127
+ // backup and a `lossy` report. A file with no record at all counts as
128
+ // non-matching: content we can't vouch for is content to preserve.
129
+ function contentMatchesRecord(abs, dest, placed) {
130
+ if (!statSync(abs).isDirectory()) {
131
+ return placed[dest] !== undefined && sha(readFileSync(abs)) === placed[dest];
132
+ }
133
+ const entries = readdirSync(abs, { recursive: true, withFileTypes: true }).filter((e) => e.isFile());
134
+ if (!entries.length) return true; // an empty folder holds nothing to lose
135
+ return entries.every((e) => {
136
+ const file = join(e.parentPath, e.name);
137
+ const rel = `${dest}/${relative(abs, file).split('\\').join('/')}`;
138
+ return placed[rel] !== undefined && sha(readFileSync(file)) === placed[rel];
139
+ });
140
+ }
141
+
142
+ // Splice the artifact's managed block into the repo's AGENTS.md, preserving
143
+ // everything outside the markers. Returns null when the creator's file has no
144
+ // markers; the caller then re-appends the block below their content.
145
+ // A file written by the old engine carries the block between its old marker
146
+ // names; the splice replaces it with the block under the current ones.
147
+ function spliceManaged(current, incoming) {
148
+ const span = [{ start: START, end: END }, LEGACY_MANAGED_MARKERS]
149
+ .map(({ start, end }) => [current.indexOf(start), current.indexOf(end), end.length])
150
+ .find(([cs, ce]) => cs !== -1 && ce !== -1 && ce >= cs);
151
+ if (!span) return null;
152
+ const [cs, ce, endLength] = span;
153
+
154
+ const is = incoming.indexOf(START);
155
+ const ie = incoming.indexOf(END);
156
+ if (is === -1 || ie === -1) return null;
157
+
158
+ const block = incoming.slice(is, ie + END.length);
159
+ return current.slice(0, cs) + block + current.slice(ce + endLength);
160
+ }
161
+
162
+ /**
163
+ * Reconcile the game repo's agent surface against the installed engine artifact.
164
+ * Never throws: a malformed or absent surface must not block `joystack dev`.
165
+ */
166
+ export function reconcileAgentSurface(projectDir, engineDir, { log = console.log } = {}) {
167
+ const result = {
168
+ placed: [], removed: [], lossy: [], backups: [],
169
+ adopted: false, skipped: false, engineVersion: null, backupDir: null,
170
+ };
171
+ const surface = join(engineDir, SURFACE_DIR);
172
+
173
+ let manifest;
174
+ try {
175
+ if (!existsSync(join(surface, 'manifest.json'))) {
176
+ result.skipped = true; // engine older than Slice 2 — it carries no surface
177
+ return result;
178
+ }
179
+ manifest = JSON.parse(readFileSync(join(surface, 'manifest.json'), 'utf8'));
180
+ } catch (err) {
181
+ log(`⚠️ Could not read the engine's agent surface (${err.message}) — skills left as they are.`);
182
+ result.skipped = true;
183
+ return result;
184
+ }
185
+
186
+ try {
187
+ const name = projectName(projectDir);
188
+ const render = (buf) => Buffer.from(buf.toString('utf8').replaceAll('{{name}}', name), 'utf8');
189
+
190
+ const prior = readLocal(projectDir);
191
+ // Adoption: a repo scaffolded before this mechanism existed has our files on
192
+ // disk but no record of them. We take ownership so it can ever be updated —
193
+ // and back up anything whose bytes diverge, so an edit we can't distinguish
194
+ // from ours is preserved rather than destroyed.
195
+ const adopt = prior === null;
196
+ result.adopted = adopt;
197
+ const placed = adopt ? {} : { ...prior.placed };
198
+
199
+ const backupRoot = join(stateDir(projectDir), `backup-${manifest.engineVersion}`);
200
+ const backups = [];
201
+ const backup = (rel) => {
202
+ const from = join(projectDir, rel);
203
+ if (!existsSync(from)) return;
204
+ const to = join(backupRoot, rel);
205
+ mkdirSync(dirname(to), { recursive: true });
206
+ cpSync(from, to, { recursive: true });
207
+ backups.push(rel);
208
+ };
209
+
210
+ // ── retired entries: remove what upstream dropped. This is the half that
211
+ // makes a RENAME possible on a repo that already exists — without it a
212
+ // replaced skill would linger next to its replacement forever, still
213
+ // loaded by the creator's agent as live guidance. Removal is
214
+ // unconditional (the path is Joystack's); content that doesn't match
215
+ // the record is preserved in the backup and reported lossy. ───────────
216
+ for (const rel of manifest.retired ?? []) {
217
+ const dest = target(rel);
218
+ if (!dest) continue;
219
+ const abs = join(projectDir, dest);
220
+ if (!existsSync(abs)) continue;
221
+
222
+ if (adopt) {
223
+ backup(dest); // no record to compare — preserve, but don't accuse
224
+ } else if (!contentMatchesRecord(abs, dest, placed)) {
225
+ backup(dest);
226
+ result.lossy.push(dest);
227
+ }
228
+ rmSync(abs, { recursive: true, force: true });
229
+ for (const p of Object.keys(placed)) {
230
+ if (p === dest || p.startsWith(`${dest}/`)) delete placed[p];
231
+ }
232
+ result.removed.push(dest);
233
+ }
234
+
235
+ // ── the live surface ────────────────────────────────────────────────────
236
+ for (const rel of Object.keys(manifest.files ?? {})) {
237
+ const dest = target(rel);
238
+ if (!dest) continue;
239
+ const src = join(surface, rel);
240
+ if (!existsSync(src)) continue;
241
+
242
+ const abs = join(projectDir, dest);
243
+ const desired = render(readFileSync(src));
244
+ const exists = existsSync(abs);
245
+ const current = exists ? readFileSync(abs) : null;
246
+ const recorded = placed[dest];
247
+
248
+ if (dest === 'AGENTS.md') {
249
+ // The markers ARE the contract: inside is ours, outside is theirs.
250
+ const desiredStr = desired.toString('utf8');
251
+ let next = exists ? spliceManaged(current.toString('utf8'), desiredStr) : desiredStr;
252
+ if (next === null) {
253
+ // Their file has no markers — they (or their agent) rewrote Layer 0.
254
+ // The block must still land, but everything they wrote is theirs, so
255
+ // it is re-appended below their content, touching none of it.
256
+ const is = desiredStr.indexOf(START);
257
+ const ie = desiredStr.indexOf(END);
258
+ if (is === -1 || ie === -1) continue; // artifact itself has no block — nothing to land
259
+ next = current.toString('utf8').replace(/\s*$/, '\n\n') + desiredStr.slice(is, ie + END.length) + '\n';
260
+ }
261
+ if (exists && current.toString('utf8') === next) continue;
262
+ writeFileSync(abs, next);
263
+ placed[dest] = sha(Buffer.from(next, 'utf8'));
264
+ result.placed.push(dest);
265
+ continue;
266
+ }
267
+
268
+ if (exists) {
269
+ const now = sha(current);
270
+ if (now === sha(desired)) {
271
+ // Content already equal — including the frozen-record case where a
272
+ // local edit was independently upstreamed (fb_3a0bea). Nothing to
273
+ // write, nothing lost; heal the record so future updates apply.
274
+ placed[dest] = now;
275
+ continue;
276
+ }
277
+ if (adopt) {
278
+ backup(dest); // no record to compare — preserve, but don't accuse
279
+ } else if (now !== recorded) {
280
+ // Edited ours, or never ours — either way real local content is
281
+ // about to be replaced: preserve it and report it for the ask flow.
282
+ backup(dest);
283
+ result.lossy.push(dest);
284
+ }
285
+ }
286
+
287
+ mkdirSync(dirname(abs), { recursive: true });
288
+ writeFileSync(abs, desired);
289
+ placed[dest] = sha(desired);
290
+ result.placed.push(dest);
291
+ }
292
+
293
+ // ── orphans: anything WE placed that the artifact no longer ships ────────
294
+ // Deleting a skill upstream is enough to delete it here — no declaration,
295
+ // nothing to remember. The manifest already knows what we put on disk, so a
296
+ // file of ours that vanished from the artifact has been retired, full stop.
297
+ // (`retired` above still earns its keep: on ADOPTION there is no manifest,
298
+ // so an orphan is indistinguishable from a skill the creator wrote.)
299
+ const shipped = new Set(
300
+ Object.keys(manifest.files ?? {})
301
+ .map(target)
302
+ .filter(Boolean),
303
+ );
304
+ for (const dest of Object.keys(placed)) {
305
+ if (shipped.has(dest)) continue;
306
+ const abs = join(projectDir, dest);
307
+ if (existsSync(abs)) {
308
+ if (sha(readFileSync(abs)) !== placed[dest]) {
309
+ // They changed it — the retirement still lands, the content survives
310
+ // in the backup and the caller asks the creator about it.
311
+ backup(dest);
312
+ result.lossy.push(dest);
313
+ }
314
+ rmSync(abs, { force: true });
315
+ pruneEmptyDirs(projectDir, dest);
316
+ result.removed.push(dest);
317
+ }
318
+ delete placed[dest];
319
+ }
320
+
321
+ writeLocal(projectDir, manifest.engineVersion, placed);
322
+ result.engineVersion = manifest.engineVersion;
323
+ result.backups = backups;
324
+ result.backupDir = backups.length
325
+ ? `${LOCAL_MANIFEST.replace('agent-surface.json', '')}backup-${manifest.engineVersion}/`
326
+ : null;
327
+ } catch (err) {
328
+ // A surface bug must never be the reason a creator can't run their game.
329
+ log(`⚠️ Could not sync the agent skills (${err.message}) — continuing; your game is unaffected.`);
330
+ }
331
+
332
+ return result;
333
+ }
@@ -0,0 +1,205 @@
1
+ // `joystack assets --json [<id>…]` — what each asset IS, as JSON, for an agent
2
+ // to read without a screenshot: its id, label, stage and type, its parameters
3
+ // with their `describe`, the stats measured from what it built, its capability
4
+ // flags (rigged, clips, emissive, a sound's length) and its reference images.
5
+ //
6
+ // The numbers are the Assets page's own. The command opens the page headless
7
+ // (shared/ui/assets/headless.mjs), and for each asset calls
8
+ // `window.joystackAssets.asset(id)`, which selects it, waits for it to finish
9
+ // building and measures it on the stage — the same measurement the page's
10
+ // stats line shows. Nothing here counts anything.
11
+ //
12
+ // joystack assets --json every declared asset (≈20–40 s for 70)
13
+ // joystack assets --json saucer medusa just these
14
+ //
15
+ // ── WHAT IS TRUSTED ─────────────────────────────────────────────────────────
16
+ //
17
+ // The ids on the command line are the creator's own; they are only compared
18
+ // against the declared ids and passed to the page as data. Everything that
19
+ // comes back is text the game's code produced (labels, notes, readouts), so
20
+ // the JSON is printed with every control and text-direction character
21
+ // escaped (safeJson): it parses to exactly the same data, and a terminal
22
+ // showing it acts on none of it. Errors that quote the page are stripped the
23
+ // way `--sheet` strips them (printable), and every answer from the page is
24
+ // timed here, since the page's own timers belong to the game's code.
25
+ //
26
+ // This guards against the game's code in the PAGE. The page driver and
27
+ // playwright are loaded from the game's own node_modules, so a game folder
28
+ // that arrives with its node_modules already in it can run anything as the
29
+ // person running the command; install dependencies yourself before running
30
+ // a command on a folder from someone else.
31
+
32
+ import { findProject } from './project.mjs';
33
+ import { findRunningStack, ASSETS_PAGE_PATH } from './assets-page.mjs';
34
+ import { engineTooOld, loadDriver, loadPlaywright, printable } from './assets-sheet.mjs';
35
+ import { gpuArgs } from './smoke-gpu-preload.mjs';
36
+ import { readEnv } from './legacy-names.mjs';
37
+
38
+ export const JSON_HELP = `joystack assets --json [<id>…] [--port <n>]
39
+
40
+ Prints what each asset is, as JSON: id, label, note, stage, type, the file
41
+ it was declared from, its parameters (with each one's describe), the stats
42
+ measured from what it built (triangles, meshes, materials, draw calls,
43
+ bones, size in game units, palette), a sound's length, its flags (rigged,
44
+ clips, emissive, seconds), its readout and its reference image paths.
45
+
46
+ With no ids, every declared asset, one after another (about half a second
47
+ each). An id that is not declared is an error that lists the ones that are.
48
+ An asset that will not build is listed with "error" set, and the command
49
+ then exits 1.
50
+
51
+ Uses the dev stack already running for this game, or starts one for the
52
+ length of the command. Needs playwright in the game (npm i -D playwright).`;
53
+
54
+ // Flags of this mode that take no value. Any other flag is a typo and is
55
+ // refused, as `--sheet` refuses one, rather than read as an asset id.
56
+ const JSON_SWITCHES = new Set(['--json', '--no-open', '--help']);
57
+
58
+ // The arguments after `joystack assets`, read for the JSON mode: { ids, port }.
59
+ // Ids keep their order; one asked for twice is listed once.
60
+ export function parseJsonArgs(rest = []) {
61
+ const ids = [];
62
+ let port = null;
63
+ for (let i = 0; i < rest.length; i += 1) {
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}`);
66
+ if (a === '--port') {
67
+ const v = rest[i + 1];
68
+ if (v == null || v.startsWith('--')) throw new Error(`--port needs a value.\n\n${JSON_HELP}`);
69
+ i += 1;
70
+ const p = Number(v);
71
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) throw new Error('--port needs a port number');
72
+ port = p;
73
+ } else if (a.startsWith('--')) {
74
+ if (!JSON_SWITCHES.has(a)) throw new Error(`joystack assets --json has no option ${a}.\n\n${JSON_HELP}`);
75
+ } else if (!ids.includes(a)) ids.push(a);
76
+ }
77
+ return { ids, port };
78
+ }
79
+
80
+ // JSON with every character a terminal could act on written as an escape.
81
+ // JSON.stringify already escapes U+0000–U+001F; this adds DEL and the C1
82
+ // controls (U+007F–U+009F, which include a one-byte CSI), the line and
83
+ // paragraph separators, and the bidirectional overrides and isolates that
84
+ // can make text display in an order other than the one it is in. Escaped,
85
+ // not removed, so the data parses back unchanged.
86
+ const UNSAFE = /[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
87
+ //
88
+ // Never throws. The data came from a page the game's code runs in, and that
89
+ // code can hand back a structure JSON cannot hold (a cycle, a BigInt), whose
90
+ // error message would quote the game's own key names. A cycle is written as
91
+ // "[circular]" and a BigInt as its digits.
92
+ function plain(v, ancestors = []) {
93
+ if (typeof v === 'bigint') return String(v);
94
+ if (v === null || typeof v !== 'object') return v;
95
+ if (ancestors.includes(v)) return '[circular]';
96
+ const inner = [...ancestors, v];
97
+ if (Array.isArray(v)) return v.map((x) => plain(x, inner));
98
+ const out = {};
99
+ for (const [k, x] of Object.entries(v)) out[k] = plain(x, inner);
100
+ return out;
101
+ }
102
+
103
+ export function safeJson(value) {
104
+ return JSON.stringify(plain(value), null, 2).replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
105
+ }
106
+
107
+ // How long one answer from the page may take, measured here rather than in
108
+ // the page: the page's own timers belong to the game's code, which can stop
109
+ // them. The page gives a mount 20 s and a late model 3 s more, so this only
110
+ // fires when the page itself has stopped answering.
111
+ const ASSET_TIMEOUT_MS = 45_000;
112
+ // Errors the page reported are kept for the output, up to this many, each cut
113
+ // to this length: a game that throws on every frame would otherwise fill it.
114
+ const PAGE_ERRORS_MAX = 50;
115
+ const PAGE_ERROR_CHARS = 1000;
116
+
117
+ function answer(promise, ms) {
118
+ let timer;
119
+ const late = new Promise((_, reject) => {
120
+ timer = setTimeout(() => reject(new Error(`the Assets page did not answer within ${ms / 1000} s`)), ms);
121
+ });
122
+ return Promise.race([promise, late]).finally(() => clearTimeout(timer));
123
+ }
124
+
125
+ // Measure the assets. `startDev` starts a dev stack (dev() in the CLI) when
126
+ // none is running for the game. `origin`, `browser` and `headless` are for a
127
+ // caller that already has them (a test): an origin skips looking for a stack,
128
+ // a browser is used and left open, `headless` is the page driver's module.
129
+ // `progress(i, n, id)` is told before each asset is measured; `onStarted`
130
+ // is handed a dev stack this call started, so a caller interrupted (Ctrl-C)
131
+ // can stop it.
132
+ //
133
+ // Resolves { kind, version, assets: [record…], pageErrors }; the record shape
134
+ // is shared/ui/assets/facts.js `assetRecord`.
135
+ export async function assetsJson({
136
+ cwd = process.cwd(), ids = [], port = null, startDev = null,
137
+ origin = null, browser = null, headless = null, progress = () => {}, timeoutMs = ASSET_TIMEOUT_MS,
138
+ onStarted = () => {}, env = process.env,
139
+ } = {}) {
140
+ // An explicit --port wins; otherwise JOYSTACK_DEV_PORT, as plain `joystack assets`
141
+ // reads it (assets-page.mjs, openAssets).
142
+ const fromEnv = Number(readEnv('JOYSTACK_DEV_PORT', env));
143
+ if (port == null && Number.isInteger(fromEnv) && fromEnv > 0) port = fromEnv;
144
+ let project = null;
145
+ let started = null;
146
+ if (!origin) {
147
+ project = findProject(cwd);
148
+ const stack = await findRunningStack({ projectDir: project.dir, port });
149
+ if (stack) origin = stack.origin;
150
+ else {
151
+ if (!startDev) throw new Error('no dev stack is running for this game');
152
+ started = await startDev({ cwd, port: port ?? undefined, watch: false, reload: false, noMp: true, log: () => {} });
153
+ onStarted(started);
154
+ origin = new URL(started.url).origin;
155
+ }
156
+ }
157
+ if (!project && !(headless && browser)) project = findProject(cwd);
158
+
159
+ let ownBrowser = null;
160
+ try {
161
+ const driver = headless ?? await loadDriver(project.dir, 'joystack assets --json');
162
+ if (!browser) {
163
+ const { chromium } = await loadPlaywright(project.dir, 'an asset is measured');
164
+ ownBrowser = await chromium.launch({ args: gpuArgs() });
165
+ browser = ownBrowser;
166
+ }
167
+ // No list pictures: each asset is mounted once, on the stage, by asset().
168
+ const first = ids[0] ? `&_item=${encodeURIComponent(ids[0])}` : '';
169
+ const insp = await driver.openAssetsPage({ browser, url: `${origin}${ASSETS_PAGE_PATH}?_thumbs=0${first}`, viewport: { width: 1280, height: 800 } });
170
+ try {
171
+ if (typeof insp.ids !== 'function' || typeof insp.asset !== 'function') {
172
+ throw engineTooOld('joystack assets --json', project?.dir ?? cwd);
173
+ }
174
+ let declared;
175
+ try {
176
+ declared = await answer(insp.ids(), timeoutMs);
177
+ } catch (err) {
178
+ throw new Error(printable(`the Assets page could not list this game's assets: ${err?.message ?? err}`));
179
+ }
180
+ const unknown = ids.filter((id) => !declared.includes(id));
181
+ if (unknown.length) {
182
+ throw new Error(printable(`there is no asset ${unknown.map((id) => `"${id}"`).join(', ')} in this game's assets.js. Declared: ${declared.join(', ') || '(none)'}`));
183
+ }
184
+ const wanted = ids.length ? ids : declared;
185
+ const assets = [];
186
+ for (let i = 0; i < wanted.length; i += 1) {
187
+ const id = wanted[i];
188
+ progress(i, wanted.length, id);
189
+ try {
190
+ assets.push(await answer(insp.asset(id), timeoutMs));
191
+ } catch (err) {
192
+ assets.push({ id, measured: false, error: String(err?.message ?? err) });
193
+ }
194
+ }
195
+ const pageErrors = insp.errors.slice(0, PAGE_ERRORS_MAX).map((e) => printable(e).slice(0, PAGE_ERROR_CHARS));
196
+ if (insp.errors.length > PAGE_ERRORS_MAX) pageErrors.push(`… and ${insp.errors.length - PAGE_ERRORS_MAX} more`);
197
+ return { kind: 'joystack-assets', version: 1, assets, pageErrors };
198
+ } finally {
199
+ await insp.close();
200
+ }
201
+ } finally {
202
+ if (ownBrowser) await ownBrowser.close();
203
+ await started?.stop();
204
+ }
205
+ }