@joystack/cli 0.1.52 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,27 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.54] - 2026-10-21
18
+
19
+ ### Changed
20
+
21
+ - Every page of yours `joystack dev` serves gets the dev tools, replays
22
+ (`?replay=`) and staged entities, whatever its address, including the
23
+ maker's live link; a tester's share link gets none of them.
24
+ - `joystack publish --dev-tools` says the review build has the dev tools on,
25
+ and an old recording's sweep message points at **Keep** in Sessions, in
26
+ the dev tools.
27
+
28
+ ## [0.1.53] - 2026-10-20
29
+
30
+ ### Added
31
+
32
+ - `joystack dev` finds your game's own dev tools, the files
33
+ `tools/<name>.tool.js`, runs them in the page, and serves the
34
+ `joystack/tools` door they import.
35
+ - `joystack publish` refuses game code that imports `joystack/tools`: only a
36
+ tool file may, and a published page has no such door.
37
+
17
38
  ## [0.1.52] - 2026-10-14
18
39
 
19
40
  ### Added
package/bin/joystack.mjs CHANGED
@@ -53,7 +53,7 @@ Usage:
53
53
  Publish this game to <slug>.joystack.games, open to anyone with a
54
54
  Joystack account (--slug for an A/B copy, open only to invited
55
55
  accounts unless --open; --dev-tools makes that copy a review build
56
- with Tweaks on)
56
+ with the dev tools on)
57
57
  joystack build-id The id of this game's code as it is now, with its git commit
58
58
  joystack replay list Every recorded session: when, how long, on what device, which build
59
59
  joystack replay export [<id>] Turn a recorded session into tables you can query (Parquet)
@@ -266,7 +266,7 @@ try {
266
266
  case 'replay': {
267
267
  // `replay` is the accessor for a recorded session; `export` is its first
268
268
  // verb. Watching one back needs no command at all — it is `?replay=<id>`
269
- // on the game's URL, or the toolbox's Sessions panel.
269
+ // on the game's URL, or Sessions in the dev tools.
270
270
  const { REPLAY_HELP, replayExport, replayProfile, replayList, parseReplayArgs } = await import('../lib/replay-cmd.mjs');
271
271
  // Parsed in the lib so the argv split is under test — a flag's value must
272
272
  // never slide into the session slot, and --help must be answered before
package/lib/inject.mjs CHANGED
@@ -17,18 +17,34 @@ import { LEGACY_PAGE_GLOBALS, withLegacyDoors } from './legacy-names.mjs';
17
17
  export const DEV_IDENTITY = { userId: 'dev-local-user', name: 'Dev', color: '#38bdf8' };
18
18
 
19
19
  export const PLATFORM_URL = '/shared/platform/platform.js';
20
- // The dev toolbox — injected ONLY here, never by the production serve path
21
- // (backend/api/src/lib/inject.js). That separation is the gate
20
+ // The old dev toolbox, which an engine from before the dev tools runtime
21
+ // carries (0.2.46 and earlier) — injected ONLY here, never by the production
22
+ // serve path (backend/api/src/lib/inject.js). That separation is the gate
22
23
  // that keeps a dev surface off published pages: the production injector never
23
24
  // names this module, so there is nothing to strip and nothing to trust.
24
25
  export const TOOLBOX_URL = '/shared/ui/toolbox/toolbox.js';
26
+ // The dev tools (shared/ui/devtools/): the runtime that runs a game's tools
27
+ // and talks to whatever draws them, the maker around the game or the
28
+ // overlay over it. Dev-only. The overlay is named here and
29
+ // imported only when the runtime finds no page around the game to draw.
30
+ export const DEVTOOLS_URL = '/shared/ui/devtools/runtime.js';
31
+ // The engine's list of its own dev tools: { tools: [{ url, devServer }] }.
32
+ export const ENGINE_TOOLS_URL = '/shared/ui/devtools/engine-tools.json';
33
+ export const DEVTOOLS_OVERLAY_URL = '/shared/ui/devtools/overlay/overlay.js';
34
+ // Point (shared/ui/pointing/), which the runtime loads when a host turns it
35
+ // on; named only when the engine has it.
36
+ export const POINT_URL = '/shared/ui/pointing/pointing.js';
37
+ // What a game's own tool files (`tools/<name>.tool.js`) import as
38
+ // `joystack/tools`. Dev pages only: a published page never loads a tool, so
39
+ // the production import map (V2_BARE_SPECIFIERS) has no such door.
40
+ export const DEVTOOLS_DOOR = { 'joystack/tools': '/shared/ui/devtools/devtools.js' };
25
41
  // The laptop note (shared/ui/note/): the conversation listener a page carries
26
- // when the dev server was started by a coding-agent session. Dev-only, like
27
- // the toolbox, and on every page the creator opens, not only the game.
42
+ // when the dev server was started by a coding-agent session. Dev-only, and on
43
+ // every page the creator opens, not only the game.
28
44
  export const NOTE_URL = '/shared/ui/note/note.js';
29
45
 
30
46
  // The note's tag, last in <head> so it comes up after the platform layer and
31
- // the toolbox; a page without a <head> gets it first.
47
+ // the dev tools; a page without a <head> gets it first.
32
48
  export function injectNoteTag(html, noteUrl = NOTE_URL) {
33
49
  const tag = `<script type="module">import { installNote } from "${noteUrl}"; installNote();</script>`;
34
50
  const end = /<\/head>/i.exec(html);
@@ -77,23 +93,50 @@ function setGlobal(scope, name, value) {
77
93
  return `<script>${scope}.${name} = ${old ? `${scope}.${old} = ` : ''}${jsJson(value)};</script>`;
78
94
  }
79
95
 
80
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, skeleton = null, shareHost = null } = {}) {
96
+ // The page's import map: the framework's doors for a v2 game, and
97
+ // `joystack/tools` when the game has tool files, which import it. A v1 page
98
+ // with tools gets a map of that door alone; a page with neither, none.
99
+ function importMap(v2, tools) {
100
+ if (!v2 && !tools) return '';
101
+ const imports = { ...(v2 ? withLegacyDoors(V2_IMPORTS) : {}), ...(tools ? DEVTOOLS_DOOR : {}) };
102
+ return `<script type="importmap">${jsJson({ imports })}</script>`;
103
+ }
104
+
105
+ // The runtime's tag: the overlay's and Point's doors as lazy imports, and
106
+ // the tool files. `point`: the engine has Point. `shared`: the page is
107
+ // reached through a share link (the maker's live link), where Point is the
108
+ // maker's only: opened alone, nobody hears a pick.
109
+ function devtoolsTag(engineTools, gameTools, { point = false, shared = false } = {}) {
110
+ const pointing = point ? `, point: () => import("${POINT_URL}")${shared ? ', pointAlone: false' : ''}` : '';
111
+ const files = `${engineTools.length ? `, engineTools: ${jsJson(engineTools)}` : ''}${gameTools.length ? `, gameTools: ${jsJson(gameTools)}` : ''}`;
112
+ return `<script type="module">import { startDevtools } from "${DEVTOOLS_URL}"; startDevtools({ overlay: () => import("${DEVTOOLS_OVERLAY_URL}")${pointing}${files} });</script>`;
113
+ }
114
+
115
+ const devFlag = (replay) => `<script>window.JOYSTACK_DEV = ${jsJson({ dev: true, tools: true, replay })};</script>`;
116
+
117
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, engineTools = [], gameTools = [], point = false, skeleton = null, shareHost = null, replay } = {}) {
81
118
  const parts = [];
119
+ const tools = devtools ? gameTools : [];
82
120
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
83
121
  // reads its lowered graph from globalThis.__JOYSTACK_SKELETON__. BOTH must precede
84
122
  // any module script (the platform tag AND game.js): a browser ignores an import
85
123
  // map once a module has begun executing, and startGame() reads the skeleton at
86
124
  // module-eval time. So they go FIRST.
87
- if (skeleton) {
88
- parts.push(`<script type="importmap">${jsJson({ imports: withLegacyDoors(V2_IMPORTS) })}</script>`);
89
- parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
90
- }
125
+ const map = importMap(!!skeleton, tools.length > 0);
126
+ if (map) parts.push(map);
127
+ if (skeleton) parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
91
128
  // A page served through `joystack dev --share`'s tunnel: the hostname it was
92
129
  // shared on, which the engine's isSharedDevPage compares with the page's own
93
130
  // before treating a public name as a dev page. Ahead of every module script,
94
131
  // like the skeleton, because the room transport reads it as the game boots.
95
132
  if (shareHost) parts.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
96
133
  if (slug) parts.push(`<script>window.GAME_SLUG = ${jsJson(slug)};</script>`);
134
+ // Every page this server serves is a dev page carrying the dev tools: the
135
+ // engine's one page-side dev answer (`devPage`, shared/ui/room/lib/dev-host.js)
136
+ // reads it, ahead of every module. `replay`: the page is the creator's own,
137
+ // and may play a recording back or stage entities; a tester's share page
138
+ // may not.
139
+ parts.push(devFlag(replay !== false));
97
140
  parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
98
141
  parts.push(
99
142
  `<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
@@ -107,6 +150,8 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
107
150
  `<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
108
151
  );
109
152
  }
153
+ // Inject-if-present: only an engine that carries the runtime gets its tag.
154
+ if (devtools) parts.push(devtoolsTag(engineTools, tools, { point, shared: !!shareHost }));
110
155
  const inject = '\n ' + parts.join('\n ');
111
156
  const m = /<head[^>]*>/i.exec(html);
112
157
  if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
@@ -59,6 +59,7 @@ export const DOORS = [
59
59
  'joystack/smoke',
60
60
  'joystack/renderers/canvas',
61
61
  'joystack/renderers/three',
62
+ 'joystack/tools',
62
63
  ];
63
64
 
64
65
  // `joystack/renderers/canvas` → `renderers-canvas.js`; the root door → `index.js`.
@@ -17,6 +17,9 @@ import { LEGACY_ROOT_DOOR_FILE, withLegacyDoors } from './legacy-names.mjs';
17
17
  const SHARED_REF_RE = /["'(=]\s*\/(shared\/[A-Za-z0-9_@./-]+)/g;
18
18
  const CODE_FILE_RE = /\.(js|mjs|html|css)$/i;
19
19
  const ROOT_DOOR = 'joystack';
20
+ const TOOLS_DOOR = 'joystack/tools';
21
+ // What `joystack dev` runs as a tool: a `.tool.js` file directly in `tools/`.
22
+ const TOOL_FILE_RE = /^tools\/[^/]+\.tool\.js$/;
20
23
 
21
24
  function decodeText(bytes) {
22
25
  try {
@@ -72,4 +75,17 @@ export function checkGameCode(files, engine) {
72
75
  for (const door of used) {
73
76
  if (!doors[door]) throw new Error(`engine ${engine.version} does not ship the \`${door}\` import`);
74
77
  }
78
+ checkToolsDoor(texts);
79
+ }
80
+
81
+ // A game's dev tools are the one place `joystack/tools` resolves, and only on
82
+ // the pages `joystack dev` serves: game code importing it would load there
83
+ // and fail in the published page.
84
+ function checkToolsDoor(texts) {
85
+ for (const [path, text] of texts) {
86
+ if (!text || TOOL_FILE_RE.test(path) || !CODE_FILE_RE.test(path)) continue;
87
+ if (text.includes(`'${TOOLS_DOOR}'`) || text.includes(`"${TOOLS_DOOR}"`)) {
88
+ throw new Error(`${path} imports ${TOOLS_DOOR}, which only a dev tool file (tools/<name>.tool.js) may import: a published page has no such door`);
89
+ }
90
+ }
75
91
  }
package/lib/publish.mjs CHANGED
@@ -47,7 +47,7 @@ export async function publish({
47
47
  const project = findProject(cwd);
48
48
 
49
49
  // `--dev-tools` publishes a REVIEW BUILD: the published page carries the dev
50
- // toolbox, so whoever opens the link can use the game's Tweaks. That belongs on
50
+ // tools, so whoever opens the link can use the game's tools. That belongs on
51
51
  // a copy made for reviewing (a pull request's link, a lane's), never on the
52
52
  // game players open — so it needs its own --slug, and the game's own slug is
53
53
  // refused outright, and so is the slug of the game a lane was opened from.
@@ -55,7 +55,7 @@ export async function publish({
55
55
  if (devTools && (!slug || slug === project.slug || slug === laneParentSlug(project.dir))) {
56
56
  throw new Error(
57
57
  `--dev-tools publishes a review build, and a review build never replaces the live game.\n\n` +
58
- ` npx joystack publish --dev-tools --slug ${project.slug}-review # a review copy with Tweaks\n` +
58
+ ` npx joystack publish --dev-tools --slug ${project.slug}-review # a review copy with dev tools\n` +
59
59
  ` npx joystack publish # the live game, without dev tools`,
60
60
  );
61
61
  }
@@ -183,7 +183,7 @@ export async function publish({
183
183
  out = await publishInOneRequest({ apiBase, token, files, meta });
184
184
  }
185
185
  const url = playUrlFor(out.slug);
186
- log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
186
+ log(devTools ? `✅ Live (review build, dev tools on): ${url}` : `✅ Live: ${url}`);
187
187
  return { slug: out.slug, url };
188
188
  }
189
189
 
@@ -30,7 +30,7 @@ export const DEV_SEGMENT_PATH = '/__joystack/segments';
30
30
 
31
31
  // Where the dev page asks what recordings exist, deletes one, or pins one
32
32
  // against the prune — the read half of the same lane. Spelled out in the
33
- // engine's Sessions tool too (shared/ui/sessions/sessions.js), for the same
33
+ // engine's Sessions tool too (shared/ui/sessions/sessions.tool.js), for the same
34
34
  // reason as above: two ends of one wire, neither able to drift silently.
35
35
  export const DEV_REPLAYS_PATH = '/__joystack/replays';
36
36
 
@@ -713,7 +713,7 @@ export function pruneOnStart({ projectDir, log = () => {}, caps } = {}) {
713
713
  // went for a reason that was not the reason.
714
714
  log(`→ replays swept ${result.removed.length} old recording(s) — keeping the newest `
715
715
  + `${result.caps.maxCount} and up to ${sizeLabel(result.caps.maxBytes)} `
716
- + "(pin one in the toolbox's Sessions tool to keep it)");
716
+ + '(Keep one in Sessions, in the dev tools, to hold on to it)');
717
717
  }
718
718
  return result;
719
719
  }
@@ -13,11 +13,11 @@ import http from 'node:http';
13
13
  import { connect as connectTcp } from 'node:net';
14
14
  import { randomBytes } from 'node:crypto';
15
15
  import { EventEmitter } from 'node:events';
16
- import { readFileSync, statSync, existsSync, realpathSync } from 'node:fs';
16
+ import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
17
17
  import { extname, join, normalize, relative, sep } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { gunzipSync } from 'node:zlib';
20
- import { injectHeadTags, injectNoteTag, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL } from './inject.mjs';
20
+ import { injectHeadTags, injectNoteTag, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL, DEVTOOLS_URL, ENGINE_TOOLS_URL, POINT_URL } from './inject.mjs';
21
21
  import { createRelay, RELAY_PATH } from './relay/relay.mjs';
22
22
  import { WHOAMI_PATH } from './ports.mjs';
23
23
  import {
@@ -232,6 +232,16 @@ function sameOrigin(req, { requireOrigin = false, shareHost = null } = {}) {
232
232
  return !!shareHost && isShareHost(req.headers.host, shareHost) && origin === `https://${req.headers.host}`;
233
233
  }
234
234
 
235
+ // Whether a game page gets the dev tools' runtime: when the engine carries
236
+ // it, and the page is served on this machine, or through a share link that
237
+ // belongs to the creator (the maker's live link, `shareIsOwner`), where the
238
+ // page around the game draws the tools. On the maker that is everyone the
239
+ // maker lets onto the live link, not only the game's owner. Never for a
240
+ // tester on `joystack dev --share`.
241
+ function devtoolsRide({ shared, shareIsOwner, carried }) {
242
+ return carried && (!shared || shareIsOwner);
243
+ }
244
+
235
245
  // Whether a Host header names the shared tunnel hostname (with or without a port).
236
246
  function isShareHost(header, shareHost) {
237
247
  return !!shareHost && String(header ?? '').replace(/:\d+$/, '').toLowerCase() === shareHost;
@@ -389,6 +399,44 @@ export function createStaticServer({
389
399
  return relative(realDir, real).split(sep).some((seg) => STATE_DIR_NAMES.includes(seg.toLowerCase()));
390
400
  }
391
401
 
402
+ // The game's own dev tools: each `<name>.tool.js` directly in its `tools/`
403
+ // folder, as the page reaches them, in name order. Read on every page load,
404
+ // so a tool written since shows on the next reload.
405
+ function toolFiles(urlDir) {
406
+ const dir = resolveUrl(urlDir);
407
+ if (!dir) return [];
408
+ try {
409
+ return readdirSync(dir, { withFileTypes: true })
410
+ .filter((e) => e.isFile() && e.name.endsWith('.tool.js'))
411
+ .map((e) => e.name).sort()
412
+ .map((name) => `${urlDir}/${encodeURIComponent(name)}`);
413
+ } catch {
414
+ return [];
415
+ }
416
+ }
417
+
418
+ // The runtime's tag and the tool files it is handed, for a game's page.
419
+ function devtoolsFor(shared, slug) {
420
+ const devtools = devtoolsRide({ shared, shareIsOwner, carried: !!resolveUrl(DEVTOOLS_URL) });
421
+ if (!devtools) return { devtools };
422
+ return { devtools, engineTools: engineToolFiles(), gameTools: toolFiles(`/games/${slug}/tools`), point: !!resolveUrl(POINT_URL) };
423
+ }
424
+
425
+ // The engine's own dev tools, as its list names them, each only when the
426
+ // engine serving the page has its file.
427
+ function engineToolFiles() {
428
+ const list = resolveUrl(ENGINE_TOOLS_URL);
429
+ if (!list) return [];
430
+ try {
431
+ const tools = JSON.parse(readFileSync(list, 'utf8')).tools;
432
+ return (Array.isArray(tools) ? tools : [])
433
+ .map((t) => t?.url)
434
+ .filter((url) => typeof url === 'string' && url.startsWith('/shared/') && !!resolveUrl(url));
435
+ } catch {
436
+ return [];
437
+ }
438
+ }
439
+
392
440
  const reload = new EventEmitter();
393
441
  reload.setMaxListeners(0);
394
442
  // The build a new session is stamped with: the version's, or, serving the
@@ -438,15 +486,16 @@ export function createStaticServer({
438
486
  const tabIdentity = as
439
487
  ? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
440
488
  : (shared ? shareGuestIdentity() : identity);
441
- // The toolbox rides only when the resolved engine carries it — an older
442
- // pinned engine gets no 404ing tag (it gains the toolbox at `joystack
443
- // update`). Per-request, so a bundle swap mid-session is honoured.
489
+ // The old toolbox rides only when the resolved engine carries it (0.2.46
490
+ // and earlier); a newer engine has the dev tools runtime instead.
491
+ // Per-request, so a bundle swap mid-session is honoured.
444
492
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
445
493
  html = injectHeadTags(html, m[1], {
446
494
  ...(tabIdentity ? { identity: tabIdentity } : {}),
447
495
  toolbox,
496
+ ...devtoolsFor(shared, m[1]),
448
497
  skeleton: currentSkeleton,
449
- ...(shared ? { shareHost } : {}),
498
+ ...(shared ? { shareHost, replay: shareIsOwner } : {}),
450
499
  });
451
500
  }
452
501
  if (wantsNote(req)) html = injectNoteTag(html);
@@ -480,10 +529,10 @@ export function createStaticServer({
480
529
  'Content-Length': buf.length,
481
530
  'Cache-Control': 'no-store, must-revalidate',
482
531
  'Access-Control-Allow-Origin': '*',
483
- // Only a page of this server's own may frame one of its pages. The
484
- // toolbox host (shared/ui/toolbox/host.html) listens and publishes on
485
- // the relay for whatever page frames it, so a page on another site that
486
- // framed it could wake the agent and read every event. The maker frames
532
+ // Only a page of this server's own may frame one of its pages. The dev
533
+ // tools runtime (shared/ui/devtools/runtime.js) takes the page that
534
+ // frames it as its host: a page on another site that framed it could
535
+ // write into the game's world and receive every pick. The maker frames
487
536
  // live links from its own origin, which its Worker names in place of
488
537
  // this on every page it passes on.
489
538
  ...(type === MIME['.html'] ? { 'Content-Security-Policy': "frame-ancestors 'self'" } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "Joystack game development CLI \u2014 dev server, login, and publishing for standalone Joystack games.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",