@joystack/cli 0.0.0-stage → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -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
|
+
}
|