@joystack/cli 0.0.0-stage → 0.1.43
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 +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Text that may have come from somewhere other than the person at the
|
|
2
|
+
// terminal (a page the game's code runs in, a file in the game folder),
|
|
3
|
+
// made safe to print: no escape sequences a terminal would act on (C0 and C1
|
|
4
|
+
// controls, DEL), and no characters that make text display in an order or on
|
|
5
|
+
// lines other than the ones it is written in (direction marks, overrides and
|
|
6
|
+
// isolates, the line and paragraph separators). Each is replaced by a space.
|
|
7
|
+
export const printable = (text) => String(text ?? '')
|
|
8
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, ' ');
|
|
9
|
+
|
|
10
|
+
// The same for a message of several lines: each line made printable, the
|
|
11
|
+
// line breaks kept.
|
|
12
|
+
export const printableLines = (text) => String(text ?? '').split('\n').map(printable).join('\n');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { win32 } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// PartyKit owns worker descendants. Windows has no Unix process groups, so
|
|
5
|
+
// killing its wrapper alone can leave a worker holding ports and directories.
|
|
6
|
+
export function terminateChildTree(child, signal = 'SIGTERM', {
|
|
7
|
+
platform = process.platform, exec = execFileSync, kill = process.kill,
|
|
8
|
+
} = {}) {
|
|
9
|
+
if (child.pid == null) return;
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
if (child.exitCode != null || child.signalCode != null) return;
|
|
12
|
+
const taskkill = win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
|
|
13
|
+
try {
|
|
14
|
+
exec(taskkill, ['/PID', String(child.pid), '/T', '/F'], {
|
|
15
|
+
stdio: 'pipe', timeout: 5000, windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// An already-exited child is harmless; a live tree that could not be
|
|
19
|
+
// terminated must fail shutdown rather than silently leak workers.
|
|
20
|
+
try { kill(child.pid, 0); } catch (probe) {
|
|
21
|
+
if (probe.code === 'ESRCH') return;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try { kill(-child.pid, signal); } catch {
|
|
28
|
+
child.kill(signal);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Locate the standalone game project and its installed engine bundle.
|
|
2
|
+
//
|
|
3
|
+
// A project is the folder holding the game's package.json (created by
|
|
4
|
+
// `joystack create`, or by hand). The slug — the published URL is
|
|
5
|
+
// <slug>.joystack.games — defaults to the folder name; package.json's
|
|
6
|
+
// `joystack.slug` overrides it for folks whose repo name differs.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { gameConfig, readEnv } from './legacy-names.mjs';
|
|
10
|
+
|
|
11
|
+
export function findProject(startDir = process.cwd()) {
|
|
12
|
+
let dir = resolve(startDir);
|
|
13
|
+
for (;;) {
|
|
14
|
+
const pkgPath = join(dir, 'package.json');
|
|
15
|
+
if (existsSync(pkgPath) && existsSync(join(dir, 'index.html'))) {
|
|
16
|
+
let pkg = {};
|
|
17
|
+
try {
|
|
18
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
// Unparseable package.json: still treat as the project root; slug
|
|
21
|
+
// falls back to the folder name.
|
|
22
|
+
}
|
|
23
|
+
return { dir, slug: gameConfig(pkg).slug || basename(dir), pkg };
|
|
24
|
+
}
|
|
25
|
+
const parent = dirname(dir);
|
|
26
|
+
if (parent === dir) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Not inside a Joystack game project (no folder with both package.json and index.html above ${startDir}). ` +
|
|
29
|
+
'Run this from your game folder, or bootstrap one with `joystack create <name>`.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// An engine CHECKOUT, used where it lies — the lane for working on the engine
|
|
37
|
+
// itself against a real game. Dev-only, and env-gated rather than a flag so it
|
|
38
|
+
// cannot be left switched on in a repo.
|
|
39
|
+
//
|
|
40
|
+
// Every tool resolves the engine through here, which is the point: the dev
|
|
41
|
+
// server serving one engine while `joystack lint` and the auto-bake sweep check a
|
|
42
|
+
// different one is a false green of the worst kind — the browser runs the new
|
|
43
|
+
// code, the gate passes on the old, and nothing on screen says so.
|
|
44
|
+
//
|
|
45
|
+
// It does NOT fall back to the installed copy when the path is wrong. A silent
|
|
46
|
+
// fallback is how the override became invisible in the first place.
|
|
47
|
+
function engineCheckout(dir) {
|
|
48
|
+
if (!existsSync(join(dir, 'package.json')) || !existsSync(join(dir, 'shared'))) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`JOYSTACK_ENGINE_DIR=${dir} is not an engine build.\n` +
|
|
51
|
+
'Expected a package.json and a shared/ directory in it — point at an engine ' +
|
|
52
|
+
"bundle (packages/engine/dist after `node packages/engine/build.mjs`), not at the repo root.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
56
|
+
return {
|
|
57
|
+
dir,
|
|
58
|
+
// A checkout carries no cut version; only a release does.
|
|
59
|
+
version: pkg.version ?? 'working tree',
|
|
60
|
+
sharedDir: join(dir, 'shared'),
|
|
61
|
+
roomServerDir: join(dir, 'room-server'),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveEngine(projectDir) {
|
|
66
|
+
const override = readEnv('JOYSTACK_ENGINE_DIR');
|
|
67
|
+
if (override) return engineCheckout(resolve(override));
|
|
68
|
+
const dir = join(projectDir, 'node_modules', '@joystack', 'engine');
|
|
69
|
+
const pkgPath = join(dir, 'package.json');
|
|
70
|
+
if (!existsSync(pkgPath)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'the Joystack engine is not installed in this project.\n' +
|
|
73
|
+
`Expected it at ${dir}.\n` +
|
|
74
|
+
'Run `joystack dev` (or `joystack publish`) — it downloads the engine from the platform (sign-in required).',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
78
|
+
return {
|
|
79
|
+
dir,
|
|
80
|
+
version: pkg.version,
|
|
81
|
+
sharedDir: join(dir, 'shared'),
|
|
82
|
+
roomServerDir: join(dir, 'room-server'),
|
|
83
|
+
};
|
|
84
|
+
}
|
package/lib/publish.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `joystack publish` — ship the game to its own host, https://<slug>.joystack.games/.
|
|
2
|
+
//
|
|
3
|
+
// Server-resolved: we send ONLY the game's own files plus the engine version
|
|
4
|
+
// the installed bundle declares; the platform resolves /shared/... from its
|
|
5
|
+
// release registry. The client never stages the shared library itself — that
|
|
6
|
+
// trust hole is what this design replaces.
|
|
7
|
+
//
|
|
8
|
+
// Target: /api/creator/publish — the token-authenticated creator lane, the
|
|
9
|
+
// one publish route reachable on the public play host (the legacy
|
|
10
|
+
// /api/publish stays behind the builder's Access wall). The endpoint
|
|
11
|
+
// requires the creator token; without one it 401s with a login hint.
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { findProject } from './project.mjs';
|
|
14
|
+
import { isLane, laneParentSlug } from './lane.mjs';
|
|
15
|
+
import { ensureEngine } from './engine.mjs';
|
|
16
|
+
import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
|
|
17
|
+
import { bundlePrimitives } from './bundle-primitives.mjs';
|
|
18
|
+
import { scanEntityComponents, buildV2PublishModule } from './room-server.mjs';
|
|
19
|
+
import {
|
|
20
|
+
BROWSER_REGISTRY,
|
|
21
|
+
SERVER_BARREL,
|
|
22
|
+
SERVER_V2_ROOM,
|
|
23
|
+
assertNoInertOverride,
|
|
24
|
+
isFrameworkV2,
|
|
25
|
+
renderBrowserRegistry,
|
|
26
|
+
scanPrimitives,
|
|
27
|
+
} from './primitives.mjs';
|
|
28
|
+
|
|
29
|
+
// A published game is served from its own host.
|
|
30
|
+
export const playUrlFor = (slug) => `https://${slug}.joystack.games/`;
|
|
31
|
+
|
|
32
|
+
export { collectGameFiles, gitIgnored } from './game-files.mjs';
|
|
33
|
+
import { collectGameFiles } from './game-files.mjs';
|
|
34
|
+
import { gameBuild } from './build-id.mjs';
|
|
35
|
+
|
|
36
|
+
export async function publish({
|
|
37
|
+
cwd = process.cwd(),
|
|
38
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
39
|
+
slug,
|
|
40
|
+
devTools = false,
|
|
41
|
+
log = console.log,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const project = findProject(cwd);
|
|
44
|
+
|
|
45
|
+
// `--dev-tools` publishes a REVIEW BUILD: the published page carries the dev
|
|
46
|
+
// toolbox, so whoever opens the link can use the game's Tweaks. That belongs on
|
|
47
|
+
// a copy made for reviewing (a pull request's link, a lane's), never on the
|
|
48
|
+
// game players open — so it needs its own --slug, and the game's own slug is
|
|
49
|
+
// refused outright, and so is the slug of the game a lane was opened from.
|
|
50
|
+
// Checked before anything is built or sent.
|
|
51
|
+
if (devTools && (!slug || slug === project.slug || slug === laneParentSlug(project.dir))) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`--dev-tools publishes a review build, and a review build never replaces the live game.\n\n` +
|
|
54
|
+
` npx joystack publish --dev-tools --slug ${project.slug}-review # a review copy with Tweaks\n` +
|
|
55
|
+
` npx joystack publish # the live game, without dev tools`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Published rooms must hit the surface players see: re-bake any referenced
|
|
60
|
+
// model whose GLB or bake config changed before the game ships. Fails the
|
|
61
|
+
// publish, loudly, rather than deploying a room that shoots at an old shape.
|
|
62
|
+
{
|
|
63
|
+
const { ensureBaked } = await import('./model-bake.mjs');
|
|
64
|
+
await ensureBaked({ dir: project.dir, log });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A publish is GLOBAL, PERMANENT and IRREVERSIBLE — the first one claims the
|
|
68
|
+
// name for this account forever. A lane is a throwaway experiment in a folder
|
|
69
|
+
// named after it, and the slug defaults to the folder name, so a bare publish
|
|
70
|
+
// from one would claim "riven-judder" for all time (or 403 against whoever
|
|
71
|
+
// owns it). And when a game overrides `joystack.slug`, the lane inherits it and
|
|
72
|
+
// would silently overwrite the LIVE game with the experiment.
|
|
73
|
+
//
|
|
74
|
+
// A name this permanent is never guessed inside a lane. The guard is on
|
|
75
|
+
// GUESSING, not on publishing: name it with --slug and it goes through.
|
|
76
|
+
if (!slug && isLane(project.dir)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`This is a lane, not your game. Publishing from here would claim the name ` +
|
|
79
|
+
`"${project.slug}" permanently.\n\n` +
|
|
80
|
+
` npx joystack publish --slug ${project.slug}-test # publish an A/B copy to compare\n` +
|
|
81
|
+
` npx joystack publish --slug <your-game> # ship this lane AS the real game\n\n` +
|
|
82
|
+
`Or merge the lane into your game and publish from there.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The declared engineVersion must be a version the platform can resolve —
|
|
87
|
+
// ensureEngine keeps the local install synced to the game's pin.
|
|
88
|
+
const engine = await ensureEngine(project.dir, { apiBase, log });
|
|
89
|
+
const targetSlug = slug ?? project.slug;
|
|
90
|
+
|
|
91
|
+
const files = collectGameFiles(project.dir);
|
|
92
|
+
// The build id of the creator's own files, before the generated ones below
|
|
93
|
+
// join the upload: the same files, and so the same id, a recording of this
|
|
94
|
+
// code under `joystack dev` carries (build-id.mjs). It is computed from the
|
|
95
|
+
// files just collected, so it names exactly what is uploaded.
|
|
96
|
+
const build = gameBuild(project.dir, { files });
|
|
97
|
+
if (!files.has('index.html')) {
|
|
98
|
+
throw new Error('this game has no index.html at its root — publish needs an entry page.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Server-backed game — it has primitives of its own. The SAME predicate `joystack
|
|
102
|
+
// dev` uses (isServerBacked), so a game can never be server-backed in one and
|
|
103
|
+
// not the other. Two different checks would let them disagree, and a
|
|
104
|
+
// creator's primitive would run in production and silently not on their
|
|
105
|
+
// machine.
|
|
106
|
+
//
|
|
107
|
+
// Two files are GENERATED into the upload; neither is in the creator's git:
|
|
108
|
+
//
|
|
109
|
+
// the SERVER barrel — bundled here, not at the endpoint (a Pages Function has
|
|
110
|
+
// no bundler, and real primitives import npm packages and WASM). The engine
|
|
111
|
+
// is marked external and rewritten to the release's pre-bundled runtime, so
|
|
112
|
+
// the worker's room code is always ours.
|
|
113
|
+
//
|
|
114
|
+
// the BROWSER registry — shadows the engine's primitive registry, merged, so
|
|
115
|
+
// the CLIENT predicts with the game's classes too. Without it the server
|
|
116
|
+
// runs the creator's primitive while the client predicts with the engine's
|
|
117
|
+
// class of the same name, and the game rubber-bands. The creator never
|
|
118
|
+
// wires this up (`createRoom({ extraPrimitives })`).
|
|
119
|
+
// Refuse to SHIP an override the server can never honour. dev catches this
|
|
120
|
+
// first, but publish must not trust that dev ran: the whole failure mode we
|
|
121
|
+
// are killing is an override that uploads, serves, and silently never runs.
|
|
122
|
+
// A framework-v2 game authors entities + traits + a world; it has no
|
|
123
|
+
// overrides/ or components/ folder. It ships a single generated module — the
|
|
124
|
+
// lowered room module (built HERE by the framework compiler) — plus its
|
|
125
|
+
// skeleton in meta, and takes none of the primitive/entity-component scan
|
|
126
|
+
// path below.
|
|
127
|
+
let frameworkMeta = {};
|
|
128
|
+
if (isFrameworkV2(project.dir)) {
|
|
129
|
+
const { source, skeleton } = await buildV2PublishModule({ projectDir: project.dir, engine });
|
|
130
|
+
files.set(SERVER_V2_ROOM, Buffer.from(source, 'utf8'));
|
|
131
|
+
frameworkMeta = { framework: 'v2', skeleton };
|
|
132
|
+
log(`Built the framework-v2 room module (${(source.length / 1024).toFixed(0)} KB) — lowered to v1 on your machine.`);
|
|
133
|
+
} else {
|
|
134
|
+
assertNoInertOverride({ projectDir: project.dir, engine });
|
|
135
|
+
const serverPrimitives = scanPrimitives(project.dir); // throws on a reserved index.js
|
|
136
|
+
const entityComponents = await scanEntityComponents(project.dir, engine.sharedDir); // throws on a malformed component
|
|
137
|
+
if (serverPrimitives.length || entityComponents.length) {
|
|
138
|
+
const { source, externals } = await bundlePrimitives(project.dir, { engineSharedDir: engine.sharedDir });
|
|
139
|
+
files.set(SERVER_BARREL, Buffer.from(source, 'utf8'));
|
|
140
|
+
// The browser prediction registry exists for PRIMITIVES only — entity
|
|
141
|
+
// components' client/shared files ship as ordinary game files, and the
|
|
142
|
+
// client imports its own manifests directly.
|
|
143
|
+
if (serverPrimitives.length) {
|
|
144
|
+
files.set(BROWSER_REGISTRY, Buffer.from(renderBrowserRegistry(serverPrimitives), 'utf8'));
|
|
145
|
+
}
|
|
146
|
+
const names = [
|
|
147
|
+
...serverPrimitives.map((p) => p.type),
|
|
148
|
+
...entityComponents.map((c) => `${c.name} (component)`),
|
|
149
|
+
].join(', ');
|
|
150
|
+
log(
|
|
151
|
+
`Bundled server code — ${names} (${(source.length / 1024).toFixed(0)} KB, engine external${externals.length ? `: ${[...new Set(externals)].length} module(s)` : ''}).`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const meta = { slug: targetSlug, entry: 'index.html', engineVersion: engine.version, build, files: {}, ...frameworkMeta, ...(devTools ? { devTools: true } : {}) };
|
|
157
|
+
const form = new FormData();
|
|
158
|
+
for (const [path, bytes] of files) {
|
|
159
|
+
meta.files[path] = createHash('sha256').update(bytes).digest('hex');
|
|
160
|
+
form.append(path, new Blob([bytes]), path);
|
|
161
|
+
}
|
|
162
|
+
form.append('meta', JSON.stringify(meta));
|
|
163
|
+
|
|
164
|
+
log(`Publishing ${targetSlug} (${files.size} files, engine ${engine.version}, build ${build.id}) → ${apiBase}…`);
|
|
165
|
+
const token = getToken();
|
|
166
|
+
const res = await fetch(`${apiBase}/api/creator/publish`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
169
|
+
body: form,
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const detail = await res.text().catch(() => '');
|
|
173
|
+
throw new Error(`publish failed (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
174
|
+
}
|
|
175
|
+
const out = await res.json();
|
|
176
|
+
const url = playUrlFor(out.slug);
|
|
177
|
+
log(devTools ? `✅ Live (review build, Tweaks on): ${url}` : `✅ Live: ${url}`);
|
|
178
|
+
return { slug: out.slug, url };
|
|
179
|
+
}
|