@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 +11 -0
- package/bin/joystack.mjs +2 -2
- package/lib/inject.mjs +33 -14
- package/lib/publish.mjs +3 -3
- package/lib/replay-store.mjs +2 -2
- package/lib/static-server.mjs +32 -12
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
21
|
-
// (
|
|
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
|
|
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,
|
|
37
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
130
|
-
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
186
|
+
log(devTools ? `✅ Live (review build, dev tools on): ${url}` : `✅ Live: ${url}`);
|
|
187
187
|
return { slug: out.slug, url };
|
|
188
188
|
}
|
|
189
189
|
|
package/lib/replay-store.mjs
CHANGED
|
@@ -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
|
-
+
|
|
716
|
+
+ '(Keep one in Sessions, in the dev tools, to hold on to it)');
|
|
717
717
|
}
|
|
718
718
|
return result;
|
|
719
719
|
}
|
package/lib/static-server.mjs
CHANGED
|
@@ -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
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
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