@joystack/cli 0.1.47 → 0.1.48

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,46 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.48] - 2026-10-10
18
+
19
+ ### Added
20
+
21
+ - `joystack publish --open` makes a copy published with `--slug` open to
22
+ anyone with a Joystack account. Without it, a `--slug` copy (an A/B copy, a
23
+ review build) opens only for accounts invited to Joystack.
24
+
25
+ ### Changed
26
+
27
+ - Your game at its own address (`joystack publish` with no `--slug`, or
28
+ `--slug` set to the game's own name) is open to anyone with a Joystack
29
+ account, invited or not. A player who is not signed in is asked to sign in
30
+ first. A game published before this version opens only for invited accounts
31
+ until you publish it again.
32
+
33
+ - `joystack dev` reloads the game only when one of the game's own files
34
+ changes: the files `joystack publish` would upload, plus `package.json`.
35
+ Notes, tests, agent instructions and anything your `.gitignore` leaves out
36
+ no longer reload it. A log you write into the game folder used to reload
37
+ the game over and over until the page stopped loading; with `*.log` in
38
+ `.gitignore` (new games have it) that can no longer happen.
39
+ - `joystack dev` writes everything it prints to `.joystack/dev/last.log` as
40
+ well, and names that file when it starts, so its output can be read later
41
+ without sending it to a file of your own.
42
+
43
+ ### Fixed
44
+
45
+ - Sessions recorded under `joystack dev` carry the build of the game they ran
46
+ on. They were stamped with an empty build, because the copy `joystack dev`
47
+ serves sits in the ignored `.joystack/` folder and every file in it read as
48
+ ignored.
49
+ - A game folder inside a larger git repository that ignores that folder is
50
+ published whole. Every file in it used to read as ignored.
51
+ - `joystack create` gives a new game an exact Playwright version (1.64.0)
52
+ instead of "1.61.1 or any newer 1.x". Every game made on this CLI gets the
53
+ same Playwright and Chromium, and creating a game no longer downloads a new
54
+ Chromium each time Playwright publishes a release. Games you already have
55
+ keep the Playwright they have.
56
+
17
57
  ## [0.1.47] - 2026-10-09
18
58
 
19
59
  ### Added
package/bin/joystack.mjs CHANGED
@@ -46,9 +46,11 @@ Usage:
46
46
  joystack changelog [<v>] What changed in the engine (default: everything newer than your pin)
47
47
  joystack update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
48
48
  joystack model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
49
- joystack publish [--slug <s>] [--dev-tools]
50
- Publish this game to <slug>.joystack.games (--slug for an A/B copy;
51
- --dev-tools makes that copy a review build with Tweaks on)
49
+ joystack publish [--slug <s>] [--dev-tools] [--open]
50
+ Publish this game to <slug>.joystack.games, open to anyone with a
51
+ Joystack account (--slug for an A/B copy, open only to invited
52
+ accounts unless --open; --dev-tools makes that copy a review build
53
+ with Tweaks on)
52
54
  joystack build-id The id of this game's code as it is now, with its git commit
53
55
  joystack replay list Every recorded session: when, how long, on what device, which build
54
56
  joystack replay export [<id>] Turn a recorded session into tables you can query (Parquet)
@@ -270,7 +272,7 @@ try {
270
272
  break;
271
273
  }
272
274
  case 'publish':
273
- await publish({ slug: flag('slug'), devTools: rest.includes('--dev-tools') });
275
+ await publish({ slug: flag('slug'), devTools: rest.includes('--dev-tools'), open: rest.includes('--open') });
274
276
  break;
275
277
  case 'feedback':
276
278
  // Send first, then sync: a report filed this run is already stamped with
package/lib/create.mjs CHANGED
@@ -65,7 +65,13 @@ const ALLOW_SCRIPTS = { esbuild: true, workerd: true, fsevents: true };
65
65
  // devDependencies and the Chromium binary is fetched at create time — a
66
66
  // surprise "npm i -D playwright" + browser download mid-build is exactly
67
67
  // what create-time provisioning exists to prevent.
68
- const PLAYWRIGHT_SPEC = '^1.61.1';
68
+ //
69
+ // An exact version, so a game's Playwright, and the Chromium build it needs,
70
+ // changes only when this line does, never when Playwright publishes a release.
71
+ // The maker's machine image carries the Chromium for this version
72
+ // (maker/container/Dockerfile): changing it means rebuilding and redeploying
73
+ // the maker, or every game the maker creates downloads its own Chromium.
74
+ const PLAYWRIGHT_SPEC = '1.64.0';
69
75
 
70
76
  // The Joystack rules ship as an ESLint config (the scaffold's eslint.config.js,
71
77
  // importing the rules from the installed engine), so they squiggle in the
@@ -0,0 +1,48 @@
1
+ // `.joystack/dev/last.log`: everything an interactive `joystack dev` prints,
2
+ // written by `joystack dev` itself.
3
+ //
4
+ // An agent runs `joystack dev` in the background and needs its output later.
5
+ // Sending that output to a file of its own puts the file wherever the agent
6
+ // chose, often inside the game folder, where the file is mistaken for part of
7
+ // the game. This file is inside the game's state folder, which nothing treats
8
+ // as the game: no version, no publish, no commit. The game's AGENTS.md tells
9
+ // agents to read it.
10
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { findProject } from './project.mjs';
13
+ import { stateDir } from './legacy-names.mjs';
14
+
15
+ /**
16
+ * Starts a fresh log for the game around `cwd`. Returns null when `cwd` is not
17
+ * in a game (startDev reports that) or the log cannot be written; a dev
18
+ * server that cannot keep a log still runs.
19
+ * @returns {{ path: string, write: (line: string) => void, tee: (log: (line: string) => void) => (line: string) => void } | null}
20
+ */
21
+ export function openDevLog(cwd) {
22
+ let path;
23
+ try {
24
+ const dir = join(stateDir(findProject(cwd).dir), 'dev');
25
+ mkdirSync(dir, { recursive: true });
26
+ path = join(dir, 'last.log');
27
+ writeFileSync(path, '');
28
+ } catch {
29
+ return null;
30
+ }
31
+ let broken = false;
32
+ const write = (line) => {
33
+ if (broken) return;
34
+ try {
35
+ appendFileSync(path, `${line}\n`);
36
+ } catch {
37
+ broken = true;
38
+ }
39
+ };
40
+ return {
41
+ path,
42
+ write,
43
+ tee: (log) => (line) => {
44
+ log(line);
45
+ write(String(line));
46
+ },
47
+ };
48
+ }
package/lib/dev.mjs CHANGED
@@ -23,7 +23,8 @@ import { buildDevRoomServer, DEV_OVERRIDES_DIR } from './room-server.mjs';
23
23
  import { createLiveVersions, snapshotFiles } from './live-versions.mjs';
24
24
  import { hasDeclaredAssets, assetsPageUrl } from './assets-page.mjs';
25
25
  import { openTunnel, shareBanner, engineCanShare } from './share.mjs';
26
- import { freezeGame, frozenFiles } from './freeze.mjs';
26
+ import { createVersionFiles, freezeGame } from './freeze.mjs';
27
+ import { openDevLog } from './dev-log.mjs';
27
28
  import { STATE_DIR_NAMES, readEnv, stateDir } from './legacy-names.mjs';
28
29
 
29
30
  // The route a room under `joystack dev` answers with the name of the build it
@@ -84,6 +85,12 @@ export async function waitForRoomServer(port, { timeoutMs = 30_000, intervalMs =
84
85
  // `share` opens a public link to the stack for testers (share.mjs); `openShare`
85
86
  // replaces the tunnel for tests.
86
87
  export async function dev(opts = {}) {
88
+ // An interactive `joystack dev` (watch on, the default) also writes
89
+ // everything it prints to .joystack/dev/last.log, so an agent that started
90
+ // it in the background reads its output there instead of sending it to a
91
+ // file of its own (dev-log.mjs).
92
+ const devLog = opts.watch === false ? null : openDevLog(opts.cwd ?? process.cwd());
93
+ if (devLog) opts = { ...opts, log: devLog.tee(opts.log ?? console.log), devLogPath: devLog.path };
87
94
  // Whatever startup has made so far, undone if a later step throws: a room
88
95
  // build that fails on the creator's code, a port already taken, a tunnel
89
96
  // that never opens. A frozen copy of the game is among it.
@@ -94,6 +101,7 @@ export async function dev(opts = {}) {
94
101
  for (const fn of undo.reverse()) {
95
102
  try { fn(); } catch { /* the original error is the one to report */ }
96
103
  }
104
+ devLog?.write(`joystack dev: ${err?.message ?? err}`);
97
105
  throw err;
98
106
  }
99
107
  }
@@ -102,7 +110,7 @@ export async function dev(opts = {}) {
102
110
  // (agent-session.mjs). Only the interactive `joystack dev` and `joystack assets`
103
111
  // commands pass one; a stack started by `joystack test` or any other caller has
104
112
  // none, so no test run can wake the agent that started it.
105
- async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null } = {}, onFailure) {
113
+ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP === '1', watch = true, reload = true, share = false, openShare = openTunnel, log = console.log, agentSession = null, devLogPath = null } = {}, onFailure) {
106
114
  const project = findProject(cwd);
107
115
  // Q4 revision: the engine is not an npm dependency — install/refresh it from
108
116
  // the platform's login-gated registry (no-op when the pinned version is in).
@@ -204,8 +212,9 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
204
212
  };
205
213
  // What a version is made from: the game's files, and the engine's, which the
206
214
  // room bundle includes. Engine files are served as they are, not copied.
215
+ const gameFiles = createVersionFiles(project.dir);
207
216
  const sourceFiles = () => {
208
- const files = frozenFiles(project.dir);
217
+ const files = gameFiles();
209
218
  for (const [k, v] of snapshotFiles([engine.sharedDir], (name) => name.startsWith('.') || name === 'node_modules')) files.set(k, v);
210
219
  return files;
211
220
  };
@@ -562,6 +571,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
562
571
  if (hasDeclaredAssets(project.dir)) {
563
572
  log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
564
573
  }
574
+ if (devLogPath) log(` 📄 ${relative(project.dir, devLogPath).split(sep).join('/')} (everything this server prints, for reading later)`);
565
575
  if (share) {
566
576
  log('');
567
577
  log('→ share opening a public link (cloudflared)…');
package/lib/freeze.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  import { constants, copyFileSync, cpSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
12
- import { collectGameFiles } from './game-files.mjs';
12
+ import { statSync } from 'node:fs';
13
+ import { collectGameFiles, gitIgnored, isGameFilePath } from './game-files.mjs';
13
14
  import { gameBuild } from './build-id.mjs';
14
15
  import { snapshotFiles } from './live-versions.mjs';
15
16
  import { STATE_DIR_NAMES, stateDir } from './legacy-names.mjs';
@@ -39,6 +40,60 @@ export function frozenFiles(dir) {
39
40
  return snapshotFiles([dir], (name, depth) => depth === 0 && LEFT_OUT.has(name), { hashBelow: 256 * 1024 });
40
41
  }
41
42
 
43
+ // Files a version also needs that are not part of the published game:
44
+ // package.json says which framework the game is built on and which engine.
45
+ const BUILD_FILES = new Set(['package.json']);
46
+ const IGNORE_MAX_AGE_MS = 5000;
47
+
48
+ /**
49
+ * The files a version of the game is made from, as path → `mtime:size`: the
50
+ * game's own files by the definition `joystack publish` uses (game-files.mjs),
51
+ * which leaves out notes, tests, agent instructions and everything the game's
52
+ * .gitignore excludes, plus BUILD_FILES. A change to any other file in the
53
+ * folder (a log, a scratch file, a test) is not a change to the game and does
54
+ * not make a version.
55
+ *
56
+ * Asking git which paths the .gitignore excludes is the costly part, so the
57
+ * answer is kept until the list of paths or an ignore file in the game
58
+ * changes, and for at most IGNORE_MAX_AGE_MS, which bounds how long a change
59
+ * git reads from elsewhere (a .gitignore above the game, the global excludes
60
+ * file, `git add -f`) takes to apply. If git fails after the first answer, the
61
+ * last answer stands and git is asked again on the next look, so a passing
62
+ * git failure never stops the dev server.
63
+ */
64
+ export function createVersionFiles(dir) {
65
+ let key = null;
66
+ let askedAt = 0;
67
+ let ignored = null;
68
+ const exclude = join(dir, '.git', 'info', 'exclude');
69
+ return () => {
70
+ const all = frozenFiles(dir);
71
+ const rels = new Map();
72
+ for (const path of all.keys()) rels.set(path, relative(dir, path).split(sep).join('/'));
73
+ const candidates = [...rels.values()].filter((rel) => BUILD_FILES.has(rel) || isGameFilePath(rel));
74
+ let excludeStamp = '';
75
+ try { excludeStamp = String(statSync(exclude).mtimeMs); } catch { /* none */ }
76
+ const ignoreFiles = [...all].filter(([path]) => basename(path) === '.gitignore').map(([, entry]) => entry);
77
+ const nextKey = `${candidates.join('\0')}|${ignoreFiles.join('|')}|${excludeStamp}`;
78
+ if (nextKey !== key || Date.now() - askedAt > IGNORE_MAX_AGE_MS) {
79
+ try {
80
+ ignored = gitIgnored(dir, candidates);
81
+ key = nextKey;
82
+ askedAt = Date.now();
83
+ } catch (err) {
84
+ if (ignored === null) throw err;
85
+ key = null;
86
+ }
87
+ }
88
+ const out = new Map();
89
+ for (const [path, entry] of all) {
90
+ const rel = rels.get(path);
91
+ if ((BUILD_FILES.has(rel) || isGameFilePath(rel)) && !ignored.has(rel)) out.set(path, entry);
92
+ }
93
+ return out;
94
+ };
95
+ }
96
+
42
97
  /**
43
98
  * Copies the game at `dir` into `.joystack/frozen/<pid>-<random>/<folder name>`.
44
99
  * `build()` is the game's build as the copy has it, the one every session
@@ -40,6 +40,12 @@ const SKIP_FILES = [
40
40
  // files the .gitignore was written to keep out.
41
41
  export function gitIgnored(dir, rels) {
42
42
  if (!rels.length) return new Set();
43
+ // A folder that is itself ignored by an enclosing repository is not part of
44
+ // that repository, so no .gitignore is in force inside it: git would report
45
+ // every file under it as ignored. A copy of the game that `joystack dev`
46
+ // serves lives in the game's ignored .joystack/ folder, and a game folder can
47
+ // sit in an ignored folder of a larger repository; both keep all their files.
48
+ if (folderIgnored(dir)) return new Set();
43
49
  try {
44
50
  const out = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
45
51
  cwd: dir,
@@ -59,6 +65,17 @@ export function gitIgnored(dir, rels) {
59
65
  }
60
66
  }
61
67
 
68
+ function folderIgnored(dir) {
69
+ try {
70
+ execFileSync('git', ['check-ignore', '-q', '.'], { cwd: dir, stdio: 'ignore', timeout: 30_000 });
71
+ return true;
72
+ } catch {
73
+ // Exit 1 (not ignored), 128 (no repository) or no git: the .gitignore, if
74
+ // any, is read as usual below.
75
+ return false;
76
+ }
77
+ }
78
+
62
79
  /**
63
80
  * Whether a path, relative to the game folder with `/` separators, is one the
64
81
  * walk below would take by its name alone — before the .gitignore check. A
package/lib/publish.mjs CHANGED
@@ -41,6 +41,7 @@ export async function publish({
41
41
  apiBase = getApiBase(DEFAULT_API_BASE),
42
42
  slug,
43
43
  devTools = false,
44
+ open = false,
44
45
  log = console.log,
45
46
  } = {}) {
46
47
  const project = findProject(cwd);
@@ -90,6 +91,14 @@ export async function publish({
90
91
  // ensureEngine keeps the local install synced to the game's pin.
91
92
  const engine = await ensureEngine(project.dir, { apiBase, log });
92
93
  const targetSlug = slug ?? project.slug;
94
+ // Who may open the published game. The game under its own name is open to
95
+ // every signed-in Joystack account; a copy under another name (an A/B copy, a
96
+ // pull request's or a lane's review link) is open only to invited accounts,
97
+ // unless --open asks otherwise. Inside a lane the game's own name is that of
98
+ // the game the lane was opened from, since the lane folder is named after the
99
+ // lane.
100
+ const ownSlug = laneParentSlug(project.dir) ?? project.slug;
101
+ const openToAll = open || targetSlug === ownSlug;
93
102
 
94
103
  const files = collectGameFiles(project.dir);
95
104
  // The build id of the creator's own files, before the generated ones below
@@ -156,7 +165,7 @@ export async function publish({
156
165
  }
157
166
  }
158
167
 
159
- const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}) };
168
+ const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}), open: openToAll };
160
169
  for (const [path, bytes] of files) meta.files[path] = createHash('sha256').update(bytes).digest('hex');
161
170
 
162
171
  // The platform only publishes framework-v2 games, and checks their code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joystack/cli",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
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",