@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,130 @@
1
+ // A build id: one short hash naming exactly the files a game build is made of.
2
+ //
3
+ // It is the game's identity for one state of its code, wherever that code
4
+ // runs: a recording made under `joystack dev` carries the id of the files the dev
5
+ // server was serving, and `joystack publish` computes the same id over the same
6
+ // files (`collectGameFiles`, the set a publish uploads). Two builds with the
7
+ // same id ran the same game code; any edit — a constant inside a tick body, a
8
+ // renamed file, a file added — is a different id. That is finer than the code
9
+ // fingerprint a recording also carries, which hashes the simulation's shape
10
+ // (components, fields, ticks) and cannot see a number changed inside a tick.
11
+ //
12
+ // The id covers the game's own files only. The engine is named separately by
13
+ // its version, and a build of the same files on two engines is the same build.
14
+ //
15
+ // Git is an annotation, never the identity: a game need not be in git, and a
16
+ // build with uncommitted changes is not described by any commit. When the game
17
+ // is in git, the build also carries the commit its files were checked out from
18
+ // and whether they differ from it.
19
+ //
20
+ // Format, version 1: SHA-256 over the version line BUILD_ID_FORMAT followed by, for
21
+ // each file in code-point order of its path, `<path>\0<sha256 of its bytes>\n`,
22
+ // with paths relative to the game folder and `/`-separated. The id is the
23
+ // first 12 hex characters. The per-file hash is what keeps a path and the
24
+ // bytes after it from being shifted into each other. Any change to this format
25
+ // changes every id, so it carries its own version in the hashed text.
26
+ import { createHash } from 'node:crypto';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { collectGameFiles, isGameFilePath } from './game-files.mjs';
29
+ import { findProject } from './project.mjs';
30
+ import { BUILD_ID_FORMAT } from './legacy-names.mjs';
31
+
32
+ export const BUILD_ID_RE = /^[0-9a-f]{12}$/;
33
+ const FORMAT = BUILD_ID_FORMAT;
34
+
35
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
36
+
37
+ /**
38
+ * The build id of a set of game files.
39
+ * @param {Map<string, Buffer|string>} files - path relative to the game folder → contents
40
+ * @returns {string} 12 lowercase hex characters
41
+ */
42
+ export function buildIdOf(files) {
43
+ const h = createHash('sha256').update(FORMAT);
44
+ // Code-point order is UTF-8 byte order; JavaScript's own string comparison
45
+ // is UTF-16 unit order, which differs for characters past U+FFFF.
46
+ const paths = [...files.keys()].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
47
+ for (const path of paths) h.update(`${path}\0${sha256(files.get(path))}\n`);
48
+ return h.digest('hex').slice(0, 12);
49
+ }
50
+
51
+ // The commit a game folder is checked out at, and whether any of its build's
52
+ // files differ from that commit — or null when the folder is not in a git
53
+ // repository (or git is not installed). Only the build's files count: an
54
+ // edited note, or another game in the same repository, is not a change to
55
+ // this build.
56
+ function gitStateOf(dir, buildPaths) {
57
+ const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
58
+ let commit;
59
+ let prefix;
60
+ try {
61
+ commit = git(['rev-parse', 'HEAD']).trim();
62
+ // Only the newline goes: a folder's name can start with a space.
63
+ prefix = git(['rev-parse', '--show-prefix']).replace(/\n$/, '');
64
+ } catch {
65
+ return null;
66
+ }
67
+ let status = '';
68
+ try {
69
+ status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']);
70
+ } catch {
71
+ // Committed but unreadable status: the commit is still known, and whether
72
+ // the files differ from it is not.
73
+ return { commit, dirty: null };
74
+ }
75
+ // Porcelain paths are relative to the repository root. A rename entry is
76
+ // followed by its old path as a second NUL-separated field.
77
+ const entries = status.split('\0');
78
+ let dirty = false;
79
+ for (let i = 0; i < entries.length; i++) {
80
+ const e = entries[i];
81
+ if (e.length < 4) continue;
82
+ const code = e.slice(0, 2);
83
+ // Each path with whether it is gone from the folder: a deletion, or the
84
+ // old side of a rename. A rename's old path follows it as its own field.
85
+ const paths = [[e.slice(3), code.includes('D')]];
86
+ if (code.includes('R')) paths.push([entries[++i] ?? '', true]);
87
+ else if (code.includes('C')) paths.push([entries[++i] ?? '', false]);
88
+ for (const [p, gone] of paths) {
89
+ if (!p.startsWith(prefix)) continue;
90
+ const rel = p.slice(prefix.length);
91
+ // A file that exists is judged by the build's own set, which has had the
92
+ // .gitignore applied; one that is gone no longer exists to be walked, so
93
+ // it is judged by its name.
94
+ if (buildPaths.has(rel) || (gone && isGameFilePath(rel))) dirty = true;
95
+ }
96
+ }
97
+ return { commit, dirty };
98
+ }
99
+
100
+ /**
101
+ * The build a game folder holds right now.
102
+ * @param {string} dir - the game folder
103
+ * @param {object} [opts]
104
+ * @param {Map<string, Buffer>} [opts.files] - the game's files, when already collected
105
+ * @param {boolean} [opts.git] - false to leave out the commit (no git processes)
106
+ * @returns {{ id: string, files: number, git: { commit: string, dirty: boolean|null } | null }}
107
+ */
108
+ export function gameBuild(dir, { files = collectGameFiles(dir), git = true } = {}) {
109
+ return { id: buildIdOf(files), files: files.size, git: git ? gitStateOf(dir, new Set(files.keys())) : null };
110
+ }
111
+
112
+ /**
113
+ * `joystack build-id` — the build of the game this folder is in, as it is now.
114
+ * @param {{ cwd?: string, json?: boolean, log?: (m: string) => void }} [opts]
115
+ */
116
+ export function buildIdCommand({ cwd = process.cwd(), json = false, log = console.log } = {}) {
117
+ const build = gameBuild(findProject(cwd).dir);
118
+ if (json) {
119
+ log(JSON.stringify(build));
120
+ return build;
121
+ }
122
+ log(`build ${build.id}`);
123
+ log(`${build.files} files`);
124
+ if (!build.git) log('not in a git repository');
125
+ else {
126
+ const state = build.git.dirty === true ? ' + uncommitted changes' : build.git.dirty === null ? ' (could not tell whether the files differ from it)' : '';
127
+ log(`commit ${build.git.commit}${state}`);
128
+ }
129
+ return build;
130
+ }
@@ -0,0 +1,123 @@
1
+ // Bundle a server-backed game's primitives on the CREATOR's machine, with the
2
+ // engine marked external.
3
+ //
4
+ // The publish endpoint is a Pages Function — a Worker with no filesystem and no
5
+ // npm — so it cannot run a bundler. But real server primitives import npm
6
+ // packages, WASM, and sibling game files that only a bundler with node_modules
7
+ // on disk can resolve. So the CLI bundles `_local/primitives/index.js` into ONE
8
+ // self-contained ESM module here (npm + WASM + local files inlined), and the
9
+ // endpoint assembles the worker from it + our generated entry + the release's
10
+ // pre-bundled engine runtime.
11
+ //
12
+ // The ENGINE seam (the dev convention): a primitive imports engine server-side
13
+ // helpers via `/shared/ui/room/...` (or the `@joystack/engine` package). We
14
+ // mark those EXTERNAL and rewrite them to `./rooms-runtime.js` — the engine
15
+ // artifact the endpoint uploads alongside. Bundling the engine IN would
16
+ // duplicate the room runtime and, worse, let a primitive smuggle its own room
17
+ // class past the setInterval/park cost boundary. Keeping it external means the
18
+ // worker's room code is always ours.
19
+ import { DOOR_FILTER, ENGINE_PACKAGE_FILTER, canonicalSpecifier } from './legacy-names.mjs';
20
+ import { assertNoOverrideCycle, PRIMITIVES_DIR, SERVER_BARREL, renderBarrel, scanPrimitives } from './primitives.mjs';
21
+ import { scanEntityComponents, renderEntityComponents } from './room-server.mjs';
22
+ import { shadowResolvePlugin } from './shadow-resolve.mjs';
23
+ import { join } from 'node:path';
24
+
25
+ // The bundled barrel's path in the upload — the same path the endpoint detects a
26
+ // server-backed game by (SERVER_PRIMITIVES_PATH in game-server-deploy.ts).
27
+ // The creator never writes it; the CLI generates it from the directory scan.
28
+ export const PRIMITIVES_ENTRY = SERVER_BARREL;
29
+
30
+ // The module name the generated worker entry imports the runtime from; every
31
+ // engine import in the primitives bundle is rewritten to this.
32
+ export const ENGINE_ARTIFACT_MODULE = './rooms-runtime.js';
33
+
34
+ export async function bundlePrimitives(projectDir, { esbuildImpl, engineSharedDir } = {}) {
35
+ // The barrel is GENERATED, not authored. A creator adds a primitive by adding
36
+ // a file to `overrides/shared/ui/room/primitives/` — and an entity component
37
+ // by adding a folder under components/ — there is no list to keep in sync,
38
+ // and therefore no way for the list and the folder to disagree.
39
+ const primitives = scanPrimitives(projectDir); // throws on a reserved index.js
40
+ const engineShared = engineSharedDir ?? join(projectDir, 'node_modules/@joystack/engine/shared');
41
+ const entityComponents = await scanEntityComponents(projectDir, engineShared); // throws on a malformed component
42
+ if (!primitives.length && !entityComponents.length) {
43
+ throw new Error(
44
+ `nothing to bundle — ${PRIMITIVES_DIR}/ and components/ are both empty (this is not a server-backed game)`,
45
+ );
46
+ }
47
+ assertNoOverrideCycle(primitives, projectDir, engineShared);
48
+
49
+ const esbuild = esbuildImpl ?? (await import('esbuild'));
50
+
51
+ // Every engine specifier the primitives import, captured for reporting.
52
+ const externals = [];
53
+ const toArtifact = (args) => {
54
+ externals.push(args.path);
55
+ return { path: ENGINE_ARTIFACT_MODULE, external: true };
56
+ };
57
+
58
+ const enginePlugin = {
59
+ name: 'joystack-engine-external',
60
+ setup(build) {
61
+ // `/shared/ui/room/...` — the dev alias the room server + primitives use.
62
+ build.onResolve({ filter: /^\/shared\// }, toArtifact);
63
+ // The engine package specifier, if a primitive imports it by name.
64
+ build.onResolve({ filter: ENGINE_PACKAGE_FILTER }, (args) => toArtifact({ ...args, path: canonicalSpecifier(args.path) }));
65
+ // Workers-runtime builtins are provided at execution — never bundled.
66
+ build.onResolve({ filter: /^cloudflare:/ }, (args) => ({ path: args.path, external: true }));
67
+ // A bare joystack door has never resolved in this build, and the failure used
68
+ // to be esbuild's. It cannot be any more: a game now carries a real
69
+ // node_modules/joystack (joystack-node-doors.mjs), so the specifier resolves to
70
+ // a file that refuses at runtime, and a bundle that got that far would
71
+ // take the refusal into the worker instead of failing here. Refused by
72
+ // name so the message says which door and where it belongs.
73
+ build.onResolve({ filter: DOOR_FILTER }, (args) => ({
74
+ errors: [{
75
+ text: `\`${args.path}\` cannot be used here — the primitives bundle runs on the server, and `
76
+ + 'the joystack doors are the page\'s. Import what this file needs from the engine directly '
77
+ + '(`@joystack/engine/...` or a `/shared/...` path). See shared/docs/api.md.',
78
+ }],
79
+ }));
80
+ },
81
+ };
82
+
83
+ const result = await esbuild.build({
84
+ // No entry file on disk: the generated barrel is fed straight in, resolved
85
+ // against the game root so `./overrides/...` means what it says. Same shape
86
+ // the dev room server uses (lib/room-server.mjs) — one contract, two builds.
87
+ stdin: {
88
+ contents: `${renderBarrel(primitives)}\n${renderEntityComponents(entityComponents)}\n`,
89
+ resolveDir: projectDir,
90
+ sourcefile: 'joystack-primitives.js',
91
+ loader: 'js',
92
+ },
93
+ absWorkingDir: projectDir,
94
+ bundle: true,
95
+ write: false,
96
+ format: 'esm',
97
+ // workerd is browser-shaped; the engine artifact is built the same way.
98
+ platform: 'browser',
99
+ target: 'esnext',
100
+ legalComments: 'none',
101
+ // Prefer worker builds of packages that ship a `workerd`/`worker` export
102
+ // condition (e.g. the quickjs WASM variant wordsmith uses).
103
+ conditions: ['workerd', 'worker'],
104
+ // Inline WASM as bytes so the bundle stays ONE self-contained module — no
105
+ // extra CompiledWasm part to thread through the 3-module upload.
106
+ loader: { '.wasm': 'binary' },
107
+ plugins: [
108
+ // An override's relative imports fall through to the engine, as they do in
109
+ // the browser — and here the engine is EXTERNAL, so they land on the
110
+ // release's pre-bundled runtime rather than being inlined. That is what
111
+ // makes `import { PlayerBody } from './engine.js'` work inside an override.
112
+ shadowResolvePlugin({
113
+ projectDir,
114
+ sharedDir: engineShared,
115
+ engineExternal: ENGINE_ARTIFACT_MODULE,
116
+ onEngineHit: (spec) => externals.push(spec),
117
+ }),
118
+ enginePlugin,
119
+ ],
120
+ });
121
+
122
+ return { source: result.outputFiles[0].text, externals };
123
+ }
@@ -0,0 +1,193 @@
1
+ // `joystack changelog` — what changed in the engine.
2
+ //
3
+ // Why it exists: an update that reports only a version bump and the SKILL FILES
4
+ // it rewrote says nothing about what changed in the engine itself, so the agent
5
+ // crosses versions of the shared library blind — it cannot know a call it makes
6
+ // is now wrong, or that the thing it was about to hand-roll now exists.
7
+ //
8
+ // Two moments matter, and they are different:
9
+ // BEFORE an update — "what am I about to take?" → the default, vs your pin
10
+ // AFTER an update — "what do I have to change?" → the BREAKING callout
11
+ //
12
+ // This command sends no token: a version number is not a secret, and requiring
13
+ // a login to read what an update would do to you would be backwards. Only the
14
+ // DOWNLOAD carries a token. The backend's listing route
15
+ // (backend/api/src/routes/engine.js, listEngines) does require a creator token,
16
+ // so against that backend this command is refused until one side changes.
17
+ import { findProject } from './project.mjs';
18
+ import { readEnginePin } from './engine.mjs';
19
+ import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
20
+
21
+ const RULE = '─'.repeat(64);
22
+
23
+ // Semver by NUMBER. String order would put 0.1.9 after 0.1.10 and quietly show
24
+ // the wrong set — the kind of bug nobody notices until a release is missing
25
+ // from someone's update.
26
+ //
27
+ // Release candidates carry a pre-release suffix (`0.2.0-rc.1`); a game pinned to
28
+ // one still asks this to compare versions (e.g. `joystack changelog` filters by the
29
+ // pin). Standard semver precedence: a pre-release ranks just BELOW its release
30
+ // (`0.2.0-rc.1` < `0.2.0`), and two pre-releases of the same core order by their
31
+ // suffix. Without this the raw `split('.').map(Number)` produced NaN and silently
32
+ // mis-ranked every suffixed version.
33
+ export function compareVersions(a, b) {
34
+ const parse = (v) => {
35
+ const s = String(v);
36
+ const dash = s.indexOf('-');
37
+ const core = dash < 0 ? s : s.slice(0, dash);
38
+ const pre = dash < 0 ? '' : s.slice(dash + 1);
39
+ return { nums: core.split('.').map(Number), pre };
40
+ };
41
+ const A = parse(a);
42
+ const B = parse(b);
43
+ for (let i = 0; i < 3; i++) {
44
+ if ((A.nums[i] ?? 0) !== (B.nums[i] ?? 0)) return (A.nums[i] ?? 0) > (B.nums[i] ?? 0) ? 1 : -1;
45
+ }
46
+ // Same numeric core: a release outranks its pre-releases; two pre-releases
47
+ // order lexically by suffix (a stable, total order — the label is opaque).
48
+ if (A.pre === B.pre) return 0;
49
+ if (!A.pre) return 1;
50
+ if (!B.pre) return -1;
51
+ return A.pre < B.pre ? -1 : 1;
52
+ }
53
+
54
+ export function selectReleases(releases, { pinned, version, all } = {}) {
55
+ const newestFirst = [...releases].sort((a, b) => compareVersions(b.version, a.version));
56
+
57
+ if (version) {
58
+ const one = newestFirst.find((r) => r.version === version);
59
+ if (!one) {
60
+ throw new Error(
61
+ `no engine release ${version}. Known: ${newestFirst.map((r) => r.version).join(', ')}`,
62
+ );
63
+ }
64
+ return [one];
65
+ }
66
+ if (all) return newestFirst;
67
+ // No pin means the game has never installed an engine (no `dev` yet). There is
68
+ // no "since" to work from, so show what it would get: the newest.
69
+ if (!pinned) return newestFirst.slice(0, 1);
70
+ return newestFirst.filter((r) => compareVersions(r.version, pinned) > 0);
71
+ }
72
+
73
+ // The actionable half. Everything else in a changelog is informative; a BREAKING
74
+ // entry means code in THIS game has to change, so it gets hoisted out of the
75
+ // prose and repeated on its own — with its migration line, which is the part the
76
+ // reader actually needs.
77
+ //
78
+ // The marker is POSITIONAL: a breaking change is a bullet that STARTS with it.
79
+ // Two false positives got here by loosening that, and both were found by reading
80
+ // real output rather than by any test:
81
+ //
82
+ // 1. matching the bare word case-insensitively flagged the ordinary sentence
83
+ // "when your game turns out to be breaking a Joystack rule";
84
+ // 2. matching `**BREAKING**` anywhere on a line flagged a changelog entry that
85
+ // merely DESCRIBED the feature ("...calls out **BREAKING** entries
86
+ // separately...") — prose about breaking changes is not a breaking change.
87
+ //
88
+ // A callout that cries wolf is worse than no callout: it gets ignored, and the
89
+ // next real one is ignored with it.
90
+ // Bold form is case-insensitive (`**BREAKING**`, `**Breaking**` — the markup is
91
+ // the deliberate part). The bare-word form must be ALL CAPS with punctuation, so
92
+ // a bullet that merely opens with the ordinary word ("- breaking the sound
93
+ // barrier: …") can't pass as a marker.
94
+ const BREAKING_BOLD = /^\s*[-*]\s+\*\*breaking\*\*/i;
95
+ const BREAKING_CAPS = /^\s*[-*]\s+BREAKING\b\s*[:—–-]/;
96
+ const BREAKING_BULLET = { test: (l) => BREAKING_BOLD.test(l) || BREAKING_CAPS.test(l) };
97
+
98
+ // True for a line that continues the bullet above it: indented, non-blank, and
99
+ // not itself a new bullet or heading. That's how the migration line rides along.
100
+ const CONTINUATION = /^\s+\S/;
101
+ const NEW_BULLET_OR_HEADING = /^\s*(?:[-*]\s|#)/;
102
+
103
+ export function breakingLines(notes) {
104
+ const lines = (notes ?? '').split('\n');
105
+ const out = [];
106
+ for (let i = 0; i < lines.length; i++) {
107
+ if (!BREAKING_BULLET.test(lines[i])) continue;
108
+ const block = [lines[i].trim()];
109
+ for (let j = i + 1; j < lines.length; j++) {
110
+ const l = lines[j];
111
+ if (!CONTINUATION.test(l) || NEW_BULLET_OR_HEADING.test(l)) break;
112
+ block.push(l.trim());
113
+ i = j;
114
+ }
115
+ out.push(block.join(' '));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ export function renderReleases(selected, { pinned, latest } = {}) {
121
+ const out = [];
122
+ if (!selected.length) {
123
+ out.push(`Engine ${pinned ?? '(unpinned)'} — nothing new. This game is up to date with ${latest}.`);
124
+ return out.join('\n');
125
+ }
126
+
127
+ const head =
128
+ selected.length === 1
129
+ ? `Engine ${selected[0].version}`
130
+ : `${selected.length} engine releases newer than ${pinned}, newest first (latest is ${latest})`;
131
+ out.push(head, '');
132
+
133
+ for (const r of selected) {
134
+ out.push(RULE);
135
+ out.push(`## ${r.version}`);
136
+ out.push('');
137
+ out.push(r.notes?.trim() ? r.notes.trim() : ' (no notes recorded — this release predates the changelog)');
138
+ out.push('');
139
+ }
140
+
141
+ const breaking = selected.flatMap((r) => breakingLines(r.notes).map((l) => ` ${r.version} ${l}`));
142
+ if (breaking.length) {
143
+ out.push(RULE);
144
+ out.push('');
145
+ out.push(
146
+ `⚠ ${breaking.length} BREAKING change${breaking.length === 1 ? '' : 's'} in this range.`,
147
+ ' These are the entries that mean code in THIS game has to change — check each one',
148
+ ' against the game before you call the update done.',
149
+ '',
150
+ ...breaking,
151
+ '',
152
+ );
153
+ }
154
+ return out.join('\n');
155
+ }
156
+
157
+ // The release list is behind the same sign-in as every /api/creator route, so
158
+ // the machine's token is sent when it has one, and a refusal says to sign in.
159
+ export async function fetchReleases({ apiBase, fetchImpl = fetch, token = getToken() }) {
160
+ const res = await fetchImpl(`${apiBase}/api/creator/engine`, token ? { headers: { Authorization: `Bearer ${token}` } } : {});
161
+ if (res.status === 401) throw new Error('reading the engine changelog needs a signed-in machine — run `joystack login` and retry.');
162
+ if (!res.ok) throw new Error(`could not read the engine changelog (HTTP ${res.status})`);
163
+ const body = await res.json();
164
+ // An older platform has no `releases` key. Printing "nothing new" there would
165
+ // be a lie in the most dangerous direction — it reads as "safe to update".
166
+ //
167
+ // Do NOT tell them to upgrade the CLI: the missing half is the PLATFORM's, and
168
+ // a creator cannot deploy it. Advice they can't act on is worse than none.
169
+ if (!Array.isArray(body.releases)) {
170
+ throw new Error(
171
+ 'this Joystack platform is not serving the engine changelog yet. Nothing is wrong with your ' +
172
+ 'game — try again later, and `npx joystack update` still works in the meantime.',
173
+ );
174
+ }
175
+ return body;
176
+ }
177
+
178
+ export async function changelog({
179
+ cwd = process.cwd(),
180
+ apiBase = getApiBase(DEFAULT_API_BASE),
181
+ log = console.log,
182
+ fetchImpl = fetch,
183
+ token = getToken(),
184
+ readPin,
185
+ version,
186
+ all = false,
187
+ } = {}) {
188
+ const pinned = readPin ? readPin() : readEnginePin(findProject(cwd).dir);
189
+ const { releases, latest } = await fetchReleases({ apiBase, fetchImpl, token });
190
+ const selected = selectReleases(releases, { pinned, version, all });
191
+ log(renderReleases(selected, { pinned, latest }));
192
+ return { pinned, latest, selected };
193
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // CLI config at ~/.joystack/config.json (JOYSTACK_HOME overrides the directory —
2
+ // used by tests and by anyone running isolated profiles). Holds the creator
3
+ // token minted by `joystack login`; written 0600 because it's a credential.
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { readEnv } from './legacy-names.mjs';
8
+
9
+ // The platform API the CLI talks to when neither JOYSTACK_API_BASE nor the
10
+ // config file names another.
11
+ export const DEFAULT_API_BASE = 'https://api.joystack.ai';
12
+
13
+ function configDir() {
14
+ return readEnv('JOYSTACK_HOME') || join(homedir(), '.joystack');
15
+ }
16
+
17
+ export function configPath() {
18
+ return join(configDir(), 'config.json');
19
+ }
20
+
21
+ // Machine-wide artifact cache (engine tarballs). Lives beside the config so
22
+ // JOYSTACK_HOME isolates it too.
23
+ export function cacheDir() {
24
+ return join(configDir(), 'cache');
25
+ }
26
+
27
+ export function readConfig() {
28
+ try {
29
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export function writeConfig(config) {
36
+ const dir = configDir();
37
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
39
+ }
40
+
41
+ export function getToken() {
42
+ return readConfig().token ?? null;
43
+ }
44
+
45
+ export function setToken(token, extra = {}) {
46
+ writeConfig({ ...readConfig(), ...extra, token });
47
+ }
48
+
49
+ export function clearToken() {
50
+ const { token, ...rest } = readConfig();
51
+ writeConfig(rest);
52
+ }
53
+
54
+ export function getApiBase(fallback) {
55
+ return readEnv('JOYSTACK_API_BASE') || readConfig().apiBase || fallback;
56
+ }