@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
package/lib/build-id.mjs
ADDED
|
@@ -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
|
+
}
|