@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +738 -0
  2. package/LICENSE +202 -0
  3. package/README.md +21 -2
  4. package/bin/joystack.mjs +300 -0
  5. package/lib/agent-session.mjs +52 -0
  6. package/lib/agent-surface.mjs +333 -0
  7. package/lib/assets-json.mjs +205 -0
  8. package/lib/assets-page.mjs +184 -0
  9. package/lib/assets-sheet.mjs +365 -0
  10. package/lib/build-id.mjs +130 -0
  11. package/lib/bundle-primitives.mjs +123 -0
  12. package/lib/changelog.mjs +193 -0
  13. package/lib/config.mjs +56 -0
  14. package/lib/create.mjs +241 -0
  15. package/lib/dev.mjs +435 -0
  16. package/lib/engine.mjs +198 -0
  17. package/lib/feedback-sync.mjs +231 -0
  18. package/lib/feedback.mjs +209 -0
  19. package/lib/freeze.mjs +108 -0
  20. package/lib/game-files.mjs +99 -0
  21. package/lib/history.mjs +612 -0
  22. package/lib/inbox-cmd.mjs +57 -0
  23. package/lib/inject.mjs +250 -0
  24. package/lib/joystack-node-doors.mjs +230 -0
  25. package/lib/lane.mjs +141 -0
  26. package/lib/legacy-names.mjs +246 -0
  27. package/lib/lint.mjs +292 -0
  28. package/lib/login.mjs +134 -0
  29. package/lib/model-bake-cli.mjs +17 -0
  30. package/lib/model-bake.mjs +869 -0
  31. package/lib/npm.mjs +38 -0
  32. package/lib/ports.mjs +234 -0
  33. package/lib/primitives.mjs +393 -0
  34. package/lib/printable.mjs +12 -0
  35. package/lib/process-tree.mjs +30 -0
  36. package/lib/project.mjs +84 -0
  37. package/lib/publish.mjs +179 -0
  38. package/lib/replay-cmd.mjs +426 -0
  39. package/lib/replay-export.mjs +1379 -0
  40. package/lib/replay-list.mjs +308 -0
  41. package/lib/replay-profile.mjs +467 -0
  42. package/lib/replay-store.mjs +719 -0
  43. package/lib/room-reload.mjs +168 -0
  44. package/lib/room-server.mjs +539 -0
  45. package/lib/self-update.mjs +162 -0
  46. package/lib/shadow-resolve.mjs +85 -0
  47. package/lib/share.mjs +149 -0
  48. package/lib/smoke-gpu-preload.mjs +91 -0
  49. package/lib/source-path.mjs +33 -0
  50. package/lib/static-server.mjs +1238 -0
  51. package/lib/test-cmd.mjs +697 -0
  52. package/lib/test-policy.mjs +157 -0
  53. package/lib/test-reporter.mjs +177 -0
  54. package/lib/test-selection-cli.mjs +18 -0
  55. package/lib/update.mjs +299 -0
  56. 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
+ }
@@ -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
+ }
@@ -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
+ }