@joystack/cli 0.1.51 → 0.1.53

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,25 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.53] - 2026-10-20
18
+
19
+ ### Added
20
+
21
+ - `joystack dev` finds your game's own dev tools, the files
22
+ `tools/<name>.tool.js`, runs them in the page, and serves the
23
+ `joystack/tools` door they import.
24
+ - `joystack publish` refuses game code that imports `joystack/tools`: only a
25
+ tool file may, and a published page has no such door.
26
+
27
+ ## [0.1.52] - 2026-10-14
28
+
29
+ ### Added
30
+
31
+ - `joystack dev` serves `three/addons/` (the glTF loader and the skeleton
32
+ utilities the engine's `.glb` support imports), so a game that declares a
33
+ model file in `assets.js` loads it on the dev server as it will when
34
+ published.
35
+
17
36
  ## [0.1.51] - 2026-10-13
18
37
 
19
38
  ### Added
package/lib/inject.mjs CHANGED
@@ -22,6 +22,16 @@ export const PLATFORM_URL = '/shared/platform/platform.js';
22
22
  // that keeps a dev surface off published pages: the production injector never
23
23
  // names this module, so there is nothing to strip and nothing to trust.
24
24
  export const TOOLBOX_URL = '/shared/ui/toolbox/toolbox.js';
25
+ // The dev tools (shared/ui/devtools/): the runtime that runs a game's tools
26
+ // 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
+ // imported only when the runtime finds no page around the game to draw.
29
+ export const DEVTOOLS_URL = '/shared/ui/devtools/runtime.js';
30
+ export const DEVTOOLS_OVERLAY_URL = '/shared/ui/devtools/overlay/overlay.js';
31
+ // What a game's own tool files (`tools/<name>.tool.js`) import as
32
+ // `joystack/tools`. Dev pages only: a published page never loads a tool, so
33
+ // the production import map (V2_BARE_SPECIFIERS) has no such door.
34
+ export const DEVTOOLS_DOOR = { 'joystack/tools': '/shared/ui/devtools/devtools.js' };
25
35
  // The laptop note (shared/ui/note/): the conversation listener a page carries
26
36
  // when the dev server was started by a coding-agent session. Dev-only, like
27
37
  // the toolbox, and on every page the creator opens, not only the game.
@@ -63,6 +73,11 @@ export const V2_IMPORTS = {
63
73
  // three, mapped to the engine's own vendored same-origin copy so a three-backed
64
74
  // game needs no import map of its own (kept in sync with V2_BARE_SPECIFIERS).
65
75
  three: '/shared/ui/room/vendor/three.js',
76
+ // The three addons the engine vendors (the glTF loader and the skeleton
77
+ // utilities), under the specifier the engine's own model loader imports them
78
+ // by, so a declared `.glb` opens in a game's page. The published map names
79
+ // each file (V2_BARE_SPECIFIERS), since it hashes per file.
80
+ 'three/addons/': '/shared/ui/room/vendor/three-addons/',
66
81
  };
67
82
 
68
83
  // One classic script setting a page global, and its old name when the
@@ -72,17 +87,26 @@ function setGlobal(scope, name, value) {
72
87
  return `<script>${scope}.${name} = ${old ? `${scope}.${old} = ` : ''}${jsJson(value)};</script>`;
73
88
  }
74
89
 
75
- export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, skeleton = null, shareHost = null } = {}) {
90
+ // The page's import map: the framework's doors for a v2 game, and
91
+ // `joystack/tools` when the game has tool files, which import it. A v1 page
92
+ // with tools gets a map of that door alone; a page with neither, none.
93
+ function importMap(v2, tools) {
94
+ if (!v2 && !tools) return '';
95
+ const imports = { ...(v2 ? withLegacyDoors(V2_IMPORTS) : {}), ...(tools ? DEVTOOLS_DOOR : {}) };
96
+ return `<script type="importmap">${jsJson({ imports })}</script>`;
97
+ }
98
+
99
+ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUrl = PLATFORM_URL, toolbox = false, toolboxUrl = TOOLBOX_URL, devtools = false, gameTools = [], skeleton = null, shareHost = null } = {}) {
76
100
  const parts = [];
101
+ const tools = devtools ? gameTools : [];
77
102
  // A framework-v2 game resolves `joystack`/`joystack/client` through an import map and
78
103
  // reads its lowered graph from globalThis.__JOYSTACK_SKELETON__. BOTH must precede
79
104
  // any module script (the platform tag AND game.js): a browser ignores an import
80
105
  // map once a module has begun executing, and startGame() reads the skeleton at
81
106
  // module-eval time. So they go FIRST.
82
- if (skeleton) {
83
- parts.push(`<script type="importmap">${jsJson({ imports: withLegacyDoors(V2_IMPORTS) })}</script>`);
84
- parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
85
- }
107
+ const map = importMap(!!skeleton, tools.length > 0);
108
+ if (map) parts.push(map);
109
+ if (skeleton) parts.push(setGlobal('globalThis', '__JOYSTACK_SKELETON__', skeleton));
86
110
  // A page served through `joystack dev --share`'s tunnel: the hostname it was
87
111
  // shared on, which the engine's isSharedDevPage compares with the page's own
88
112
  // before treating a public name as a dev page. Ahead of every module script,
@@ -102,6 +126,13 @@ export function injectHeadTags(html, slug, { identity = DEV_IDENTITY, platformUr
102
126
  `<script type="module">import { ensureToolbox } from "${toolboxUrl}"; ensureToolbox();</script>`,
103
127
  );
104
128
  }
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
+ }
105
136
  const inject = '\n ' + parts.join('\n ');
106
137
  const m = /<head[^>]*>/i.exec(html);
107
138
  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
  }
@@ -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 } 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,22 @@ 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
+
392
418
  const reload = new EventEmitter();
393
419
  reload.setMaxListeners(0);
394
420
  // The build a new session is stamped with: the version's, or, serving the
@@ -442,9 +468,12 @@ export function createStaticServer({
442
468
  // pinned engine gets no 404ing tag (it gains the toolbox at `joystack
443
469
  // update`). Per-request, so a bundle swap mid-session is honoured.
444
470
  const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
471
+ const devtools = devtoolsRide({ shared, shareIsOwner, carried: !!resolveUrl(DEVTOOLS_URL) });
445
472
  html = injectHeadTags(html, m[1], {
446
473
  ...(tabIdentity ? { identity: tabIdentity } : {}),
447
474
  toolbox,
475
+ devtools,
476
+ gameTools: devtools ? toolFiles(`/games/${m[1]}/tools`) : [],
448
477
  skeleton: currentSkeleton,
449
478
  ...(shared ? { shareHost } : {}),
450
479
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
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",