@joystack/cli 0.1.47 → 0.1.49
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 +62 -0
- package/bin/joystack.mjs +29 -4
- package/lib/create.mjs +7 -1
- package/lib/dev-log.mjs +48 -0
- package/lib/dev.mjs +19 -3
- package/lib/freeze.mjs +56 -1
- package/lib/game-files.mjs +17 -0
- package/lib/land.mjs +63 -0
- package/lib/maker.mjs +63 -0
- package/lib/publish.mjs +10 -1
- package/lib/replay-export.mjs +16 -1
- package/lib/static-server.mjs +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,68 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
14
|
|
|
15
15
|
## [Unreleased]
|
|
16
16
|
|
|
17
|
+
## [0.1.49] - 2026-10-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `joystack land <name>` puts a lane's work into your game and removes the
|
|
22
|
+
lane, from the game folder or from inside the lane. It saves the lane's
|
|
23
|
+
unsaved work first. When the lane and the game changed the same lines, it
|
|
24
|
+
stops with the merge in progress and lists the files; once the merge is
|
|
25
|
+
settled and committed, run it again to remove the lane.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- In the Joystack maker, `joystack lane`, `dev`, `publish` and `land` answer
|
|
30
|
+
for the maker: the folder is already the conversation's lane, its game is
|
|
31
|
+
already running on the person's page, and publishing does what the page's
|
|
32
|
+
Publish button does. On your own computer they work as before.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- On Windows, exporting a replay no longer fails now and then with `EPERM`
|
|
37
|
+
while another program still has the file it just wrote open.
|
|
38
|
+
|
|
39
|
+
## [0.1.48] - 2026-10-10
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- `joystack publish --open` makes a copy published with `--slug` open to
|
|
44
|
+
anyone with a Joystack account. Without it, a `--slug` copy (an A/B copy, a
|
|
45
|
+
review build) opens only for accounts invited to Joystack.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- Your game at its own address (`joystack publish` with no `--slug`, or
|
|
50
|
+
`--slug` set to the game's own name) is open to anyone with a Joystack
|
|
51
|
+
account, invited or not. A player who is not signed in is asked to sign in
|
|
52
|
+
first. A game published before this version opens only for invited accounts
|
|
53
|
+
until you publish it again.
|
|
54
|
+
|
|
55
|
+
- `joystack dev` reloads the game only when one of the game's own files
|
|
56
|
+
changes: the files `joystack publish` would upload, plus `package.json`.
|
|
57
|
+
Notes, tests, agent instructions and anything your `.gitignore` leaves out
|
|
58
|
+
no longer reload it. A log you write into the game folder used to reload
|
|
59
|
+
the game over and over until the page stopped loading; with `*.log` in
|
|
60
|
+
`.gitignore` (new games have it) that can no longer happen.
|
|
61
|
+
- `joystack dev` writes everything it prints to `.joystack/dev/last.log` as
|
|
62
|
+
well, and names that file when it starts, so its output can be read later
|
|
63
|
+
without sending it to a file of your own.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- Sessions recorded under `joystack dev` carry the build of the game they ran
|
|
68
|
+
on. They were stamped with an empty build, because the copy `joystack dev`
|
|
69
|
+
serves sits in the ignored `.joystack/` folder and every file in it read as
|
|
70
|
+
ignored.
|
|
71
|
+
- A game folder inside a larger git repository that ignores that folder is
|
|
72
|
+
published whole. Every file in it used to read as ignored.
|
|
73
|
+
- `joystack create` gives a new game an exact Playwright version (1.64.0)
|
|
74
|
+
instead of "1.61.1 or any newer 1.x". Every game made on this CLI gets the
|
|
75
|
+
same Playwright and Chromium, and creating a game no longer downloads a new
|
|
76
|
+
Chromium each time Playwright publishes a release. Games you already have
|
|
77
|
+
keep the Playwright they have.
|
|
78
|
+
|
|
17
79
|
## [0.1.47] - 2026-10-09
|
|
18
80
|
|
|
19
81
|
### Added
|
package/bin/joystack.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { dev } from '../lib/dev.mjs';
|
|
7
7
|
import { agentSessionFromEnv } from '../lib/agent-session.mjs';
|
|
8
8
|
import { lane } from '../lib/lane.mjs';
|
|
9
|
+
import { land } from '../lib/land.mjs';
|
|
10
|
+
import { MAKER_DEV, MAKER_LANE, makerDraft, makerPublish } from '../lib/maker.mjs';
|
|
9
11
|
import { login, logout, whoami } from '../lib/login.mjs';
|
|
10
12
|
import { publish } from '../lib/publish.mjs';
|
|
11
13
|
import { create } from '../lib/create.mjs';
|
|
@@ -38,6 +40,7 @@ Usage:
|
|
|
38
40
|
joystack assets <id> --sheet Draw a view sheet of a 3D asset to .joystack/sheets/ (--help: options)
|
|
39
41
|
joystack assets --json [<id>…] Print each asset's parameters, measured stats and flags as JSON
|
|
40
42
|
joystack lane <name> Open an isolated copy of the game to experiment in, safely
|
|
43
|
+
joystack land <name> Put a lane's work into the game, then remove the lane
|
|
41
44
|
joystack test [aspect] Run targeted checks (no aspect: conservative full run)
|
|
42
45
|
joystack test --since <base> Submission: ordinary + triggered extended checks
|
|
43
46
|
joystack test --all Explicit full audit; add --list to preview selection
|
|
@@ -46,9 +49,11 @@ Usage:
|
|
|
46
49
|
joystack changelog [<v>] What changed in the engine (default: everything newer than your pin)
|
|
47
50
|
joystack update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
|
|
48
51
|
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
|
|
51
|
-
--
|
|
52
|
+
joystack publish [--slug <s>] [--dev-tools] [--open]
|
|
53
|
+
Publish this game to <slug>.joystack.games, open to anyone with a
|
|
54
|
+
Joystack account (--slug for an A/B copy, open only to invited
|
|
55
|
+
accounts unless --open; --dev-tools makes that copy a review build
|
|
56
|
+
with Tweaks on)
|
|
52
57
|
joystack build-id The id of this game's code as it is now, with its git commit
|
|
53
58
|
joystack replay list Every recorded session: when, how long, on what device, which build
|
|
54
59
|
joystack replay export [<id>] Turn a recorded session into tables you can query (Parquet)
|
|
@@ -70,6 +75,10 @@ its own.
|
|
|
70
75
|
try {
|
|
71
76
|
switch (cmd) {
|
|
72
77
|
case 'dev': {
|
|
78
|
+
if (makerDraft()) {
|
|
79
|
+
console.log(MAKER_DEV);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
73
82
|
const handle = await dev({ port: flag('port') ? Number(flag('port')) : undefined, share: rest.includes('--share'), reload: !rest.includes('--no-reload'), agentSession: agentSessionFromEnv() });
|
|
74
83
|
const shutdown = () => {
|
|
75
84
|
handle.stop();
|
|
@@ -186,12 +195,24 @@ try {
|
|
|
186
195
|
break;
|
|
187
196
|
}
|
|
188
197
|
case 'lane': {
|
|
198
|
+
if (makerDraft()) {
|
|
199
|
+
console.log(MAKER_LANE);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
189
202
|
const name = rest.find((a) => !a.startsWith('--'));
|
|
190
203
|
if (!name) throw new Error('Name the lane: joystack lane <name> (e.g. joystack lane judder)');
|
|
191
204
|
const { dir } = await lane(name);
|
|
192
205
|
console.log(`\nWork in it:\n cd ${dir} && npx joystack dev\n`);
|
|
193
206
|
break;
|
|
194
207
|
}
|
|
208
|
+
case 'land': {
|
|
209
|
+
if (makerDraft()) {
|
|
210
|
+
await makerPublish(makerDraft());
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
await land(rest.find((a) => !a.startsWith('--')));
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
195
216
|
case 'setup':
|
|
196
217
|
process.stdout.write(setupGuide());
|
|
197
218
|
break;
|
|
@@ -270,7 +291,11 @@ try {
|
|
|
270
291
|
break;
|
|
271
292
|
}
|
|
272
293
|
case 'publish':
|
|
273
|
-
|
|
294
|
+
if (makerDraft()) {
|
|
295
|
+
await makerPublish(makerDraft(), { slug: flag('slug') });
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
await publish({ slug: flag('slug'), devTools: rest.includes('--dev-tools'), open: rest.includes('--open') });
|
|
274
299
|
break;
|
|
275
300
|
case 'feedback':
|
|
276
301
|
// 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
|
-
|
|
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
|
package/lib/dev-log.mjs
ADDED
|
@@ -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 {
|
|
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,12 @@ 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
|
-
|
|
113
|
+
//
|
|
114
|
+
// `shareIsOwner` is for a host that serves the share link to the creator
|
|
115
|
+
// alone and keeps everyone else off the dev tools' routes (the Joystack
|
|
116
|
+
// maker): a report sent through the link then counts as one from this
|
|
117
|
+
// machine, so it can wake the agent and read its reply.
|
|
118
|
+
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, shareIsOwner = false, devLogPath = null } = {}, onFailure) {
|
|
106
119
|
const project = findProject(cwd);
|
|
107
120
|
// Q4 revision: the engine is not an npm dependency — install/refresh it from
|
|
108
121
|
// the platform's login-gated registry (no-op when the pinned version is in).
|
|
@@ -204,8 +217,9 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
204
217
|
};
|
|
205
218
|
// What a version is made from: the game's files, and the engine's, which the
|
|
206
219
|
// room bundle includes. Engine files are served as they are, not copied.
|
|
220
|
+
const gameFiles = createVersionFiles(project.dir);
|
|
207
221
|
const sourceFiles = () => {
|
|
208
|
-
const files =
|
|
222
|
+
const files = gameFiles();
|
|
209
223
|
for (const [k, v] of snapshotFiles([engine.sharedDir], (name) => name.startsWith('.') || name === 'node_modules')) files.set(k, v);
|
|
210
224
|
return files;
|
|
211
225
|
};
|
|
@@ -313,6 +327,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
313
327
|
log, // agent-inbox report lines ride the dev logger
|
|
314
328
|
engineVersion: engine.version ?? null, // stamped on agent-inbox reports
|
|
315
329
|
agentSession, // woken by agent-inbox reports sent from this machine
|
|
330
|
+
shareIsOwner, // and by those sent through the share link, when it is the creator's alone
|
|
316
331
|
mounts: mountsFor(codeDir),
|
|
317
332
|
...(first ? { fixedBuild: first.build } : {}),
|
|
318
333
|
versions: live ? {
|
|
@@ -562,6 +577,7 @@ async function startDev({ cwd = process.cwd(), port, noMp = process.env.NO_MP ==
|
|
|
562
577
|
if (hasDeclaredAssets(project.dir)) {
|
|
563
578
|
log(` 🔍 ${assetsPageUrl(url)} (your models, sounds and assets, one at a time)`);
|
|
564
579
|
}
|
|
580
|
+
if (devLogPath) log(` 📄 ${relative(project.dir, devLogPath).split(sep).join('/')} (everything this server prints, for reading later)`);
|
|
565
581
|
if (share) {
|
|
566
582
|
log('');
|
|
567
583
|
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 {
|
|
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
|
package/lib/game-files.mjs
CHANGED
|
@@ -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/land.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `joystack land <name>`: put a lane's work into the game, then remove the
|
|
2
|
+
// lane (the /worktree skill, "Land the winner").
|
|
3
|
+
//
|
|
4
|
+
// Run from the game folder or from inside the lane. The lane's unsaved work
|
|
5
|
+
// is saved first, since landing is what the creator asked for. The game
|
|
6
|
+
// folder then merges the lane's branch. On a clean merge the lane's folder
|
|
7
|
+
// and branch go; on a clash the merge is left in progress in the game folder,
|
|
8
|
+
// with the rule for settling it, and running `land` again once it is settled
|
|
9
|
+
// and committed removes the lane.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { isLane } from './lane.mjs';
|
|
14
|
+
import { findProject } from './project.mjs';
|
|
15
|
+
|
|
16
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
17
|
+
|
|
18
|
+
export const CLASH_RULE =
|
|
19
|
+
'Keep both changes wherever both can be kept. Stop and ask the creator only where the two truly ' +
|
|
20
|
+
'contradict, in game terms ("both versions change how jumping feels"), never in git terms.';
|
|
21
|
+
|
|
22
|
+
export async function land(name, { cwd = process.cwd(), log = console.log } = {}) {
|
|
23
|
+
if (!name) throw new Error('Name the lane: joystack land <name> (the name it was opened with: joystack lane <name>)');
|
|
24
|
+
const here = findProject(cwd).dir;
|
|
25
|
+
const main = isLane(here) ? findProject(dirname(git(here, 'rev-parse', '--path-format=absolute', '--git-common-dir'))).dir : here;
|
|
26
|
+
const slug = findProject(main).slug;
|
|
27
|
+
const branch = `lane/${name}`;
|
|
28
|
+
const dir = resolve(main, '..', `${slug}-${name}`);
|
|
29
|
+
try {
|
|
30
|
+
git(main, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`There is no lane called "${name}" (no branch ${branch}).`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (existsSync(dir) && git(dir, 'status', '--porcelain')) {
|
|
36
|
+
git(dir, 'add', '-A');
|
|
37
|
+
git(dir, 'commit', '-q', '-m', `save: lane ${name}`);
|
|
38
|
+
log(`→ saved the lane's unsaved work (${branch})`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (git(main, 'diff', '--name-only', '--diff-filter=U')) {
|
|
42
|
+
throw new Error(`The game folder ${main} is still in the middle of a merge. Settle it and commit, then run joystack land ${name} again.\n${CLASH_RULE}`);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
git(main, 'merge', '--no-edit', branch);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const clashed = git(main, 'diff', '--name-only', '--diff-filter=U');
|
|
48
|
+
if (!clashed) throw new Error(`Landing ${branch} failed: ${String(err.stderr || err.message).trim()}`);
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Landing ${branch} clashed with the game in:\n${clashed.split('\n').map((f) => ` ${f}`).join('\n')}\n` +
|
|
51
|
+
`The merge is in progress in ${main}. ${CLASH_RULE}\n` +
|
|
52
|
+
`Then commit the merge, and run joystack land ${name} again to remove the lane.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
log(`→ landed ${branch} into the game`);
|
|
56
|
+
|
|
57
|
+
if (existsSync(dir)) {
|
|
58
|
+
git(main, 'worktree', 'remove', dir);
|
|
59
|
+
log(`→ removed ${dir}`);
|
|
60
|
+
}
|
|
61
|
+
git(main, 'branch', '-d', branch);
|
|
62
|
+
return { dir, branch };
|
|
63
|
+
}
|
package/lib/maker.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The commands that answer differently inside the Joystack maker.
|
|
2
|
+
//
|
|
3
|
+
// The maker is a web page around the same agent a creator runs in a game
|
|
4
|
+
// folder: it gives each conversation a lane of its own (a "draft"), runs that
|
|
5
|
+
// lane's dev server on the person's page, and has a Publish button that puts
|
|
6
|
+
// the draft in the game and the game online. Its agent reads the same
|
|
7
|
+
// AGENTS.md and skills as anyone, so it runs the same commands. These answer
|
|
8
|
+
// for the maker instead of doing what they do on a laptop, where they would
|
|
9
|
+
// open a lane the person never sees, start a server nobody looks at, or
|
|
10
|
+
// publish behind the page's back.
|
|
11
|
+
//
|
|
12
|
+
// The maker sets JOYSTACK_MAKER on the agent's process: the address of its
|
|
13
|
+
// own control API for this conversation's draft. Only the agent's commands
|
|
14
|
+
// carry it, never the dev servers the maker starts itself.
|
|
15
|
+
|
|
16
|
+
export function makerDraft(env = process.env) {
|
|
17
|
+
const at = String(env.JOYSTACK_MAKER ?? '').trim().replace(/\/+$/, '');
|
|
18
|
+
return at || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const MAKER_LANE =
|
|
22
|
+
"You are in the Joystack maker: this folder is already this conversation's lane " +
|
|
23
|
+
'(the person sees it as a draft on their page). Build here. No new lane was made.';
|
|
24
|
+
|
|
25
|
+
export const MAKER_DEV =
|
|
26
|
+
"You are in the Joystack maker: this folder's dev server is already running, and the " +
|
|
27
|
+
'person plays the game on their page, beside the chat. Every file you save shows up ' +
|
|
28
|
+
'there. Nothing was started.';
|
|
29
|
+
|
|
30
|
+
// `publish` and `land` both do what the page's Publish button does: the draft
|
|
31
|
+
// goes into the game, and the game goes online under the name it has. The
|
|
32
|
+
// maker does it once the agent's turn ends, since it merges the files the
|
|
33
|
+
// agent is still working in. A game that has never been online has no name
|
|
34
|
+
// yet; the person picks one on the page.
|
|
35
|
+
export async function makerPublish(at, { slug = null, log = console.log, fetchImpl = fetch } = {}) {
|
|
36
|
+
if (slug) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'You are in the Joystack maker: a game here has one name, which the person picks on their page, ' +
|
|
39
|
+
'so --slug copies are not available. Run the command without --slug to publish under that name.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
let res;
|
|
43
|
+
try {
|
|
44
|
+
res = await fetchImpl(`${at}/publish`, { method: 'POST', signal: AbortSignal.timeout(15_000) });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`You are in the Joystack maker, and it did not answer (${err?.message ?? err}). Ask the person to press Publish on their page.`);
|
|
47
|
+
}
|
|
48
|
+
const body = await res.json().catch(() => ({}));
|
|
49
|
+
if (body.needsName) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'You are in the Joystack maker, and this game has never been published: its first publish needs ' +
|
|
52
|
+
'a name, which the person picks. Ask them to press Publish on their page.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (!res.ok || !body.ok) {
|
|
56
|
+
throw new Error(`You are in the Joystack maker, and it could not publish: ${body.error ?? `HTTP ${res.status}`}. Ask the person to press Publish on their page.`);
|
|
57
|
+
}
|
|
58
|
+
log(
|
|
59
|
+
'You are in the Joystack maker: publishing this draft. When this turn ends, it goes into the game ' +
|
|
60
|
+
`and the game goes online${body.url ? ` at ${body.url}` : ''}. The person sees it happen on their page.`,
|
|
61
|
+
);
|
|
62
|
+
return body;
|
|
63
|
+
}
|
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/lib/replay-export.mjs
CHANGED
|
@@ -1064,7 +1064,7 @@ function rowSink({ file, columns, rowsPerGroup }) {
|
|
|
1064
1064
|
finish() {
|
|
1065
1065
|
flush();
|
|
1066
1066
|
pq.finish();
|
|
1067
|
-
|
|
1067
|
+
renameInto(partial, file);
|
|
1068
1068
|
return rows;
|
|
1069
1069
|
},
|
|
1070
1070
|
// Called when the run did not get that far. Best-effort: a leftover
|
|
@@ -1075,6 +1075,21 @@ function rowSink({ file, columns, rowsPerGroup }) {
|
|
|
1075
1075
|
};
|
|
1076
1076
|
}
|
|
1077
1077
|
|
|
1078
|
+
// On Windows a file that was just written can be refused a rename for a moment
|
|
1079
|
+
// (EPERM, EBUSY or EACCES) while another process still has it open. The
|
|
1080
|
+
// rename is retried for up to two seconds before it fails.
|
|
1081
|
+
function renameInto(from, to) {
|
|
1082
|
+
for (let i = 0; ; i++) {
|
|
1083
|
+
try {
|
|
1084
|
+
renameSync(from, to);
|
|
1085
|
+
return;
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
if (i >= 40 || !['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) throw err;
|
|
1088
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1078
1093
|
/** A streaming Parquet writer for a table of `columns`, sized as the export sizes its own. */
|
|
1079
1094
|
export function rowSinkFor(file, columns, rowsPerGroup) {
|
|
1080
1095
|
return rowSink({ file, columns, rowsPerGroup: groupFor(columns, rowsPerGroup) });
|
package/lib/static-server.mjs
CHANGED
|
@@ -337,6 +337,9 @@ export function createStaticServer({
|
|
|
337
337
|
// in agent-session.mjs), or null. When set, an agent-inbox report from this
|
|
338
338
|
// machine also wakes that session.
|
|
339
339
|
agentSession = null,
|
|
340
|
+
// Whether a request through the share link is the creator's own (startDev's
|
|
341
|
+
// `shareIsOwner`): its agent-inbox reports then count as from this machine.
|
|
342
|
+
shareIsOwner = false,
|
|
340
343
|
}) {
|
|
341
344
|
// The tunnel hostname `joystack dev --share` opened to this server, once it is
|
|
342
345
|
// up (setShareHost). See trustedHost.
|
|
@@ -858,6 +861,9 @@ export function createStaticServer({
|
|
|
858
861
|
return turn;
|
|
859
862
|
};
|
|
860
863
|
const agentConnected = () => (sessionAlive(agentSession) ? agentSession.agent : null);
|
|
864
|
+
// The creator's own browser: on this machine, or through a share link that
|
|
865
|
+
// only the creator can reach the dev tools by.
|
|
866
|
+
const fromCreator = (req) => fromThisMachine(req) || (shareIsOwner && isShareHost(req.headers.host, shareHost));
|
|
861
867
|
|
|
862
868
|
// The reply credential: this server's port and a bearer token, readable only
|
|
863
869
|
// by the owner of the game folder, one file per server, so with two servers
|
|
@@ -890,7 +896,7 @@ export function createStaticServer({
|
|
|
890
896
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
891
897
|
return res.end('cross-origin post rejected');
|
|
892
898
|
}
|
|
893
|
-
const local =
|
|
899
|
+
const local = fromCreator(req);
|
|
894
900
|
const maxBytes = local ? AGENT_INBOX_LOCAL_MAX_BYTES : AGENT_INBOX_MAX_BYTES;
|
|
895
901
|
const chunks = [];
|
|
896
902
|
let size = 0;
|
|
@@ -986,7 +992,7 @@ export function createStaticServer({
|
|
|
986
992
|
// so only this machine may read its replies. The custom header forces a CORS
|
|
987
993
|
// preflight, which this server never grants, so no foreign page can read it.
|
|
988
994
|
function handleInboxStatus(req, res) {
|
|
989
|
-
if (!
|
|
995
|
+
if (!fromCreator(req) || !sameOrigin(req, { shareHost }) || req.headers['x-joystack-agent-ui'] !== '1') {
|
|
990
996
|
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
991
997
|
return res.end('dev tools only');
|
|
992
998
|
}
|
package/package.json
CHANGED