@joystack/cli 0.1.53 → 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,17 @@ 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
+
17
28
  ## [0.1.53] - 2026-10-20
18
29
 
19
30
  ### 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,28 +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';
25
26
  // The dev tools (shared/ui/devtools/): the runtime that runs a game's tools
26
27
  // and talks to whatever draws them, the maker around the game or the
27
- // overlay over it. Dev-only, like the toolbox. The overlay is named here and
28
+ // overlay over it. Dev-only. The overlay is named here and
28
29
  // imported only when the runtime finds no page around the game to draw.
29
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';
30
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';
31
37
  // What a game's own tool files (`tools/<name>.tool.js`) import as
32
38
  // `joystack/tools`. Dev pages only: a published page never loads a tool, so
33
39
  // the production import map (V2_BARE_SPECIFIERS) has no such door.
34
40
  export const DEVTOOLS_DOOR = { 'joystack/tools': '/shared/ui/devtools/devtools.js' };
35
41
  // The laptop note (shared/ui/note/): the conversation listener a page carries
36
- // when the dev server was started by a coding-agent session. Dev-only, like
37
- // 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.
38
44
  export const NOTE_URL = '/shared/ui/note/note.js';
39
45
 
40
46
  // The note's tag, last in <head> so it comes up after the platform layer and
41
- // the toolbox; a page without a <head> gets it first.
47
+ // the dev tools; a page without a <head> gets it first.
42
48
  export function injectNoteTag(html, noteUrl = NOTE_URL) {
43
49
  const tag = `<script type="module">import { installNote } from "${noteUrl}"; installNote();</script>`;
44
50
  const end = /<\/head>/i.exec(html);
@@ -96,7 +102,19 @@ function importMap(v2, tools) {
96
102
  return `<script type="importmap">${jsJson({ imports })}</script>`;
97
103
  }
98
104
 
99
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, gameTools = [], skeleton = null, shareHost = null } = {}) {
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 } = {}) {
100
118
  const parts = [];
101
119
  const tools = devtools ? gameTools : [];
102
120
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
@@ -113,6 +131,12 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
113
131
  // like the skeleton, because the room transport reads it as the game boots.
114
132
  if (shareHost) parts.push(`<script>window.JOYSTACK_DEV_SHARE = ${jsJson(shareHost)};</script>`);
115
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));
116
140
  parts.push(setGlobal('window', 'JOYSTACK_IDENTITY', identity));
117
141
  parts.push(
118
142
  `<script type="module">import { installPlatform } from "${platformUrl}"; installPlatform();</script>`,
@@ -126,13 +150,8 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
126
150
  `<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
127
151
  );
128
152
  }
129
- // Inject-if-present like the toolbox: only an engine that carries the
130
- // runtime gets its tag.
131
- if (devtools) {
132
- parts.push(
133
- `<script type="module">import { startDevtools } from "${DEVTOOLS_URL}"; startDevtools({ overlay: () => import("${DEVTOOLS_OVERLAY_URL}")${tools.length ? `, gameTools: ${jsJson(tools)}` : ''} });</script>`,
134
- );
135
- }
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 }));
136
155
  const inject = '\n ' + parts.join('\n ');
137
156
  const m = /<head[^>]*>/i.exec(html);
138
157
  if (m) return html.slice(0, m.index + m[0].length) + inject + html.slice(m.index + m[0].length);
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
  }
@@ -17,7 +17,7 @@ import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'n
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, DEVTOOLS_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 {
@@ -415,6 +415,28 @@ export function createStaticServer({
415
415
  }
416
416
  }
417
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
+
418
440
  const reload = new EventEmitter();
419
441
  reload.setMaxListeners(0);
420
442
  // The build a new session is stamped with: the version's, or, serving the
@@ -464,18 +486,16 @@ export function createStaticServer({
464
486
  const tabIdentity = as
465
487
  ? { userId: `dev-local-${as}`, name: as, color: '#f472b6' }
466
488
  : (shared ? shareGuestIdentity() : identity);
467
- // The toolbox rides only when the resolved engine carries it — an older
468
- // pinned engine gets no 404ing tag (it gains the toolbox at `joystack
469
- // 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.
470
492
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
471
- const devtools = devtoolsRide({ shared, shareIsOwner, carried: !!resolveUrl(DEVTOOLS_URL) });
472
493
  html = injectHeadTags(html, m[1], {
473
494
  ...(tabIdentity ? { identity: tabIdentity } : {}),
474
495
  toolbox,
475
- devtools,
476
- gameTools: devtools ? toolFiles(`/games/${m[1]}/tools`) : [],
496
+ ...devtoolsFor(shared, m[1]),
477
497
  skeleton: currentSkeleton,
478
- ...(shared ? { shareHost } : {}),
498
+ ...(shared ? { shareHost, replay: shareIsOwner } : {}),
479
499
  });
480
500
  }
481
501
  if (wantsNote(req)) html = injectNoteTag(html);
@@ -509,10 +529,10 @@ export function createStaticServer({
509
529
  'Content-Length': buf.length,
510
530
  'Cache-Control': 'no-store, must-revalidate',
511
531
  'Access-Control-Allow-Origin': '*',
512
- // Only a page of this server's own may frame one of its pages. The
513
- // toolbox host (shared/ui/toolbox/host.html) listens and publishes on
514
- // the relay for whatever page frames it, so a page on another site that
515
- // 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
516
536
  // live links from its own origin, which its Worker names in place of
517
537
  // this on every page it passes on.
518
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.53",
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",