@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,157 @@
1
+ // Extended tests are opt-in declarations; every undeclared test stays ordinary.
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join, relative, resolve } from 'node:path';
5
+ import { STATE_DIR_NAMES, TEST_POLICY_FILE, testPolicyFile } from './legacy-names.mjs';
6
+
7
+ // The policy file's name, as the game being read spells it (testPolicyFile);
8
+ // set by readTestPolicy so every error names the file the creator has.
9
+ let POLICY = TEST_POLICY_FILE;
10
+ const posix = (s) => s.replaceAll('\\', '/');
11
+
12
+ function matcher(pattern) {
13
+ if (typeof pattern !== 'string' || !pattern || pattern.startsWith('/') || pattern.includes('\\') || pattern.split('/').includes('..') || /[\[\]{}]/.test(pattern)) {
14
+ throw new Error(`${POLICY}: patterns must be relative paths using /, *, ** or ?`);
15
+ }
16
+ let source = '^';
17
+ for (let i = 0; i < pattern.length; i++) {
18
+ const c = pattern[i];
19
+ if (c === '*' && pattern[i + 1] === '*') {
20
+ i++;
21
+ if (pattern[i + 1] === '/') { i++; source += '(?:.*/)?'; }
22
+ else source += '.*';
23
+ } else if (c === '*') source += '[^/]*';
24
+ else if (c === '?') source += '[^/]';
25
+ else source += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
+ }
27
+ return new RegExp(source + '$');
28
+ }
29
+
30
+ function patterns(value, label, { nonempty = false } = {}) {
31
+ if (!Array.isArray(value) || (nonempty && !value.length)) throw new Error(`${POLICY}: ${label} must be ${nonempty ? 'a nonempty' : 'an'} array`);
32
+ return value.map(matcher);
33
+ }
34
+ const matches = (path, rules) => rules.some((rule) => rule.test(path));
35
+
36
+ export function readTestPolicy(dir, files) {
37
+ POLICY = testPolicyFile(dir);
38
+ const path = join(dir, POLICY);
39
+ if (!existsSync(path)) return null;
40
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
41
+ if (raw.version !== 1 || !Array.isArray(raw.extended)) throw new Error(`${POLICY}: expected version 1 and an extended array`);
42
+ const allowed = ['version', 'extended', 'ordinaryChanges', 'always', 'budgetSeconds'];
43
+ if (Object.keys(raw).some((k) => !allowed.includes(k))) throw new Error(`${POLICY}: unknown top-level field`);
44
+ if ('budgetSeconds' in raw && !(typeof raw.budgetSeconds === 'number' && raw.budgetSeconds > 0)) throw new Error(`${POLICY}: budgetSeconds must be a positive number of seconds`);
45
+ const known = new Set(files.map((f) => posix(relative(dir, f))));
46
+ const seen = new Set();
47
+ const extended = raw.extended.map((entry) => {
48
+ if (!entry || typeof entry.test !== 'string' || !known.has(entry.test) || seen.has(entry.test)) throw new Error(`${POLICY}: extended test is missing or duplicated: ${entry?.test}`);
49
+ if (Object.keys(entry).some((k) => !['test', 'when', 'reason'].includes(k))) throw new Error(`${POLICY}: unknown field for ${entry.test}`);
50
+ if (typeof entry.reason !== 'string' || !entry.reason.trim()) throw new Error(`${POLICY}: ${entry.test} needs a reason`);
51
+ seen.add(entry.test);
52
+ return { ...entry, rules: patterns(entry.when, `${entry.test}.when`, { nonempty: true }) };
53
+ });
54
+ return {
55
+ extended,
56
+ ordinary: patterns(raw.ordinaryChanges ?? [], 'ordinaryChanges'),
57
+ always: patterns(raw.always ?? [], 'always'),
58
+ budgetSeconds: raw.budgetSeconds,
59
+ };
60
+ }
61
+
62
+ // Name-only without rename detection returns both sides of moves. NUL delimiters
63
+ // preserve spaces/newlines, and -- separates paths from command-line options.
64
+ export function changedTestPaths(dir, since) {
65
+ const git = (args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
66
+ if (!since || since.startsWith('-')) throw new Error('test --since needs an integration base, not an option');
67
+ const root = realpathSync.native(git(['rev-parse', '--show-toplevel']).trim());
68
+ const projectDir = realpathSync.native(dir);
69
+ const base = git(['merge-base', 'HEAD', since]).trim();
70
+ const changed = git(['-C', root, 'diff', '--no-relative', '--name-only', '--no-renames', '-z', base, '--']);
71
+ const untracked = git(['-C', root, 'ls-files', '--others', '--exclude-standard', '-z']);
72
+ // The runner's own output under `.joystack/` is never a change to the game. A
73
+ // game whose .gitignore does not list it would otherwise see this run's log
74
+ // as an unmapped change and widen every run to the whole suite.
75
+ return [...new Set((changed + untracked).split('\0').filter(Boolean).map((p) => posix(relative(projectDir, resolve(root, p)))))]
76
+ .filter((p) => !STATE_DIR_NAMES.some((d) => p.startsWith(`${d}/`)));
77
+ }
78
+
79
+ export function selectTests({ dir, files, policy, changed, all = false }) {
80
+ if (!policy || all) return { files, skipped: [], reason: all ? 'explicit --all audit' : 'all tests are ordinary (no policy)' };
81
+ if (!changed) return { files, skipped: [], reason: 'no integration base; including all extended tests (use --since <base> at submission)' };
82
+ const extended = new Map(policy.extended.map((entry) => [entry.test, entry]));
83
+ const testPaths = new Set(files.map((f) => posix(relative(dir, f))));
84
+ // A changed test file is never an unmapped change, whether or not it still
85
+ // exists: a deleted or renamed test is absent from `files`, so it is
86
+ // recognised by name. Nothing imports a test file, so removing one cannot
87
+ // affect any other test.
88
+ const isTest = (p) => /\.(test|smoke)\.mjs$/.test(p);
89
+ const policyFile = testPolicyFile(dir);
90
+ const broad = changed.find((p) => p === policyFile || matches(p, policy.always) || (!isTest(p) && !matches(p, policy.ordinary) && !policy.extended.some((e) => matches(p, e.rules))));
91
+ if (broad) return { files, skipped: [], reason: `broad coverage: ${broad} is a foundation, policy, or unmapped change` };
92
+ const selected = files.filter((file) => {
93
+ const path = posix(relative(dir, file));
94
+ const entry = extended.get(path);
95
+ return !entry || changed.some((p) => p === path || matches(p, entry.rules));
96
+ });
97
+ const selectedSet = new Set(selected);
98
+ return { files: selected, skipped: files.filter((f) => !selectedSet.has(f)), reason: 'ordinary regressions plus extended tests triggered by changes' };
99
+ }
100
+
101
+ export const TEST_HELP = `joystack test — run this game's checks
102
+
103
+ Usage:
104
+ joystack test <aspect> [<aspect>…] Run the test files whose path contains <aspect> (e.g. \`joystack test boot\`
105
+ runs boot.test.mjs and boot.smoke.mjs). The way to check one thing while
106
+ you work; skips lint.
107
+ joystack test --since <base> Submission: ordinary tests plus the extended ones your changes since
108
+ <base> trigger (joystack.tests.json), and lint.
109
+ joystack test Everything not deferred by joystack.tests.json, and lint.
110
+ joystack test --all Every test file, whatever joystack.tests.json says.
111
+ joystack test … --list Print what would run, and stop.
112
+
113
+ Test files:
114
+ *.test.mjs node tests (node:test). Run together, reported per file.
115
+ *.smoke.mjs browser smokes. Run one at a time against a dev stack this command starts and stops.
116
+ Both can import the joystack doors (\`import { openWorld } from 'joystack/test'\`). That import only resolves
117
+ when the file is run by this command — \`node --test x.test.mjs\` fails with "Cannot find package 'joystack'".
118
+
119
+ Reading the result:
120
+ The exit code is 0 only when everything passed. The console shows failures in full and passing tests
121
+ as counts. Every run also writes .joystack/test/last.json (the verdict: what failed, what was skipped,
122
+ seconds per file) and .joystack/test/last.log (the complete output). Read those instead of piping this
123
+ command through \`tail\` or \`grep\`: a pipe replaces the exit code with its own and cuts the diagnosis.
124
+
125
+ A whole-suite run ends with its total against a budget: 300 seconds, or "budgetSeconds" in joystack.tests.json.
126
+ Going over never fails the run; it recommends /compress-tests, the pass over the whole suite.
127
+
128
+ A test that cannot run on some platform says so at the top of its file, and is reported as skipped there:
129
+ // skip-on: win32 — WebKit on Windows has no Web Audio
130
+
131
+ Environment:
132
+ JOYSTACK_TEST_SMOKE_TIMEOUT=<seconds> Kill a smoke still running after this long (default 600). A hang
133
+ detector, not a speed limit.
134
+ JOYSTACK_TEST_UNIT_TIMEOUT=<seconds> The same for the node tests as a whole (default 1800).
135
+ JOYSTACK_TEST_NO_GPU=1 Run smoke browsers on software rendering.
136
+ JOYSTACK_TEST_ANGLE=<backend> Graphics backend for smoke browsers (Windows defaults to d3d11;
137
+ \`default\` lets the browser choose).
138
+ `;
139
+
140
+ export function parseTestArgs(args) {
141
+ const options = { patterns: [], all: false, list: false };
142
+ // Asking for help is never an error, whatever else is on the line.
143
+ if (args.includes('--help') || args.includes('-h')) return { ...options, help: true };
144
+ for (let i = 0; i < args.length; i++) {
145
+ const arg = args[i];
146
+ if (arg === '--all') options.all = true;
147
+ else if (arg === '--list') options.list = true;
148
+ else if (arg === '--since') {
149
+ const value = args[++i];
150
+ if (!value || value.startsWith('-')) throw new Error('test --since requires a git integration base');
151
+ options.since = value;
152
+ } else if (arg.startsWith('-')) throw new Error(`Unknown test option: ${arg}`);
153
+ else options.patterns.push(arg);
154
+ }
155
+ if ((options.all && options.since) || (options.patterns.length && (options.all || options.since))) throw new Error('Choose test <pattern>, test --since <base>, or test --all');
156
+ return options;
157
+ }
@@ -0,0 +1,177 @@
1
+ // The node:test reporter `joystack test` runs its unit half with.
2
+ //
3
+ // It prints failures in full and everything else as counts, so the output of a
4
+ // two-thousand-test suite fits on a screen and nobody needs to pipe it through
5
+ // `tail` to read it. The complete per-test listing still exists: the runner
6
+ // attaches node's own `spec` reporter to a file alongside this one.
7
+ //
8
+ // It also records what the default reporters do not: which FILE each result
9
+ // belongs to and how long each file took. The record is written as JSON to the
10
+ // path in JOYSTACK_TEST_UNIT_REPORT when the run ends; the runner folds it into
11
+ // the verdict file.
12
+ import { writeFileSync, realpathSync } from 'node:fs';
13
+ import { relative, isAbsolute } from 'node:path';
14
+ import { readEnv } from './legacy-names.mjs';
15
+
16
+ const MAX_ERROR_LINES = 24;
17
+ const SLOWEST_SHOWN = 5;
18
+
19
+ // Events for one file can name it through different spellings of the same
20
+ // path (macOS reports /var/… in one event and /private/var/… in another), so
21
+ // both sides are resolved before the relative path is taken.
22
+ const real = (path) => { try { return realpathSync.native(path); } catch { return path; } };
23
+
24
+ // node names a whole-file result after the path the file was given by on the
25
+ // command line, which may be relative or absolute.
26
+ const isWholeFile = (data) => Boolean(data.file) && data.nesting === 0
27
+ && (data.name === data.file || real(data.name) === real(data.file) || data.file.endsWith(`/${data.name}`) || data.file.endsWith(`\\${data.name}`));
28
+
29
+ function rel(file, root) {
30
+ if (!file) return '(unknown file)';
31
+ const path = isAbsolute(file) ? relative(real(root), real(file)) : file;
32
+ return path.split('\\').join('/');
33
+ }
34
+
35
+ // node wraps an assertion thrown inside a test in an ERR_TEST_FAILURE error
36
+ // whose `cause` is the original; the original carries the message a reader wants.
37
+ function describeError(error) {
38
+ const cause = error?.cause ?? error;
39
+ const message = String(cause?.message ?? cause ?? 'test failed');
40
+ const stack = typeof cause?.stack === 'string' ? cause.stack : '';
41
+ // Frames inside node itself say nothing about the game and push the frames
42
+ // that do off the screen; the full log keeps them.
43
+ const lines = (stack.includes(message) ? stack : `${message}\n${stack}`).split('\n')
44
+ .filter((line) => !/^\s*at .*\(?node:/.test(line));
45
+ const shown = lines.slice(0, MAX_ERROR_LINES);
46
+ if (lines.length > shown.length) shown.push(`… ${lines.length - shown.length} more line(s) in the full log`);
47
+ return { message, text: shown.join('\n') };
48
+ }
49
+
50
+ export function createCollector(root = process.cwd()) {
51
+ const files = new Map();
52
+ const failed = [];
53
+ const skipped = [];
54
+ const todos = [];
55
+ // Counts are singular (`fail`, `skip`); the lists of what failed and what was
56
+ // skipped take the past participle, so the two never share a key.
57
+ const totals = { tests: 0, pass: 0, fail: 0, skip: 0, todo: 0 };
58
+
59
+ const fileEntry = (file) => {
60
+ const key = rel(file, root);
61
+ if (!files.has(key)) files.set(key, { file: key, ms: 0, tests: 0, fail: 0, measured: false });
62
+ return files.get(key);
63
+ };
64
+
65
+ function record(type, data) {
66
+ // A `describe` block reports as an event of its own; counting it would
67
+ // count its tests twice.
68
+ if (data.details?.type === 'suite') return null;
69
+ const entry = fileEntry(data.file);
70
+ const duration = Number(data.details?.duration_ms) || 0;
71
+ // Top-level durations already include their subtests. A file's time is the
72
+ // sum of its top-level tests unless node reports a per-file figure (below).
73
+ if (data.nesting === 0 && !entry.measured) entry.ms += duration;
74
+ totals.tests += 1;
75
+ entry.tests += 1;
76
+ if (data.skip !== undefined && data.skip !== false) {
77
+ totals.skip += 1;
78
+ const item = { file: entry.file, name: data.name, reason: typeof data.skip === 'string' ? data.skip : '' };
79
+ skipped.push(item);
80
+ return { kind: 'skip', item };
81
+ }
82
+ if (data.todo !== undefined && data.todo !== false) {
83
+ totals.todo += 1;
84
+ const item = { file: entry.file, name: data.name, reason: typeof data.todo === 'string' ? data.todo : '' };
85
+ todos.push(item);
86
+ return { kind: 'todo', item };
87
+ }
88
+ // node reports a FILE as one passing test named after its own path only
89
+ // when the process ended cleanly without reporting any test of its own —
90
+ // which is what `process.exit(0)` at module level, or a helper that exits
91
+ // on import, produces. The tests in that file never ran, so it is a failure.
92
+ if (type === 'test:pass' && isWholeFile(data)) {
93
+ totals.fail += 1;
94
+ entry.fail += 1;
95
+ const message = 'this file exited before reporting any test — a `process.exit()` at module level (its own, or in something it imports) ended it, so none of its tests ran';
96
+ const item = { file: entry.file, name: '(the whole file)', message };
97
+ failed.push(item);
98
+ return { kind: 'fail', item, text: message };
99
+ }
100
+ if (type === 'test:pass') { totals.pass += 1; return null; }
101
+ totals.fail += 1;
102
+ entry.fail += 1;
103
+ let { message, text } = describeError(data.details?.error);
104
+ // A file that could not be loaded fails as a whole, and node's error for it
105
+ // says only "test failed"; what went wrong was written to the file's stderr.
106
+ const output = (stderrByFile.get(data.file) ?? '').trim();
107
+ if (isWholeFile(data) && output) {
108
+ const lines = output.split('\n').filter((line) => !/^\s*at .*\(?node:/.test(line));
109
+ message = lines.find((line) => /\w*Error\b/.test(line)) ?? lines[0];
110
+ text = lines.slice(0, MAX_ERROR_LINES).join('\n');
111
+ }
112
+ const item = { file: entry.file, name: isWholeFile(data) ? '(the whole file)' : data.name, message };
113
+ failed.push(item);
114
+ return { kind: 'fail', item, text };
115
+ }
116
+
117
+ // node ≥ 22.12 emits a summary per file with its wall time, which includes
118
+ // module load and top-level awaits that per-test durations leave out.
119
+ function summary(data) {
120
+ if (!data?.file || typeof data.duration_ms !== 'number') return;
121
+ const entry = fileEntry(data.file);
122
+ entry.ms = data.duration_ms;
123
+ entry.measured = true;
124
+ }
125
+
126
+ // What each test file wrote to stderr, kept only to explain a file-level failure.
127
+ const stderrByFile = new Map();
128
+ function stderr(data) {
129
+ if (!data?.file || typeof data.message !== 'string') return;
130
+ const so_far = stderrByFile.get(data.file) ?? '';
131
+ if (so_far.length < 16384) stderrByFile.set(data.file, so_far + data.message);
132
+ }
133
+
134
+ function result() {
135
+ const list = [...files.values()].map(({ measured, ...f }) => ({ ...f, ms: Math.round(f.ms) }));
136
+ list.sort((a, b) => b.ms - a.ms);
137
+ return { ...totals, failed, skipped, todos, files: list };
138
+ }
139
+
140
+ return { record, summary, stderr, result };
141
+ }
142
+
143
+ export function summaryLines(result, { seconds } = {}) {
144
+ const lines = [];
145
+ const took = seconds === undefined ? '' : ` in ${seconds.toFixed(1)}s`;
146
+ lines.push(` node tests: ${result.tests} tests — ${result.pass} passed, ${result.fail} failed, ${result.skip} skipped${result.todo ? `, ${result.todo} todo` : ''}${took}`);
147
+ if (result.files.length > SLOWEST_SHOWN) {
148
+ lines.push(` slowest node files: ${result.files.slice(0, SLOWEST_SHOWN).map((f) => `${f.file} ${(f.ms / 1000).toFixed(1)}s`).join(', ')}`);
149
+ } else if (result.files.length > 1) {
150
+ lines.push(` node files: ${result.files.map((f) => `${f.file} ${(f.ms / 1000).toFixed(1)}s`).join(', ')}`);
151
+ }
152
+ return lines;
153
+ }
154
+
155
+ export default async function* joystackReporter(source) {
156
+ const collector = createCollector(process.cwd());
157
+ const started = Date.now();
158
+ for await (const event of source) {
159
+ if (event.type === 'test:pass' || event.type === 'test:fail') {
160
+ const outcome = collector.record(event.type, event.data);
161
+ if (outcome?.kind === 'fail') yield ` ✖ ${outcome.item.file} › ${outcome.item.name}\n${outcome.text.split('\n').map((l) => ` ${l}`).join('\n')}\n`;
162
+ if (outcome?.kind === 'skip') yield ` ⏭ ${outcome.item.file} › ${outcome.item.name} — skipped: ${outcome.item.reason || '(no reason given)'}\n`;
163
+ if (outcome?.kind === 'todo') yield ` ☐ ${outcome.item.file} › ${outcome.item.name} — todo, does not run${outcome.item.reason ? `: ${outcome.item.reason}` : ''}\n`;
164
+ } else if (event.type === 'test:summary') {
165
+ collector.summary(event.data);
166
+ } else if (event.type === 'test:stderr') {
167
+ collector.stderr(event.data);
168
+ }
169
+ }
170
+ const result = collector.result();
171
+ const seconds = (Date.now() - started) / 1000;
172
+ if (readEnv('JOYSTACK_TEST_UNIT_REPORT')) {
173
+ writeFileSync(readEnv('JOYSTACK_TEST_UNIT_REPORT'), JSON.stringify({ ...result, seconds }));
174
+ }
175
+ // No summary from here: the runner prints it once at the end of the whole
176
+ // run, with a wall time that includes node's start-up and module loading.
177
+ }
@@ -0,0 +1,18 @@
1
+ // Adapter for runners with their own discovery rules. Stdout is NUL-delimited
2
+ // selected paths; explanations go to stderr so filenames remain lossless.
3
+ import { readTestPolicy, changedTestPaths, selectTests } from './test-policy.mjs';
4
+ const [dir, since, ...files] = process.argv.slice(2);
5
+ try {
6
+ const policy = readTestPolicy(dir, files);
7
+ let changed;
8
+ if (since !== '--all') {
9
+ try { changed = changedTestPaths(dir, since); }
10
+ catch (error) { console.error(`Cannot compare with ${since}: ${error.message}. Including all tests.`); }
11
+ }
12
+ const selection = selectTests({ dir, files, policy, changed, all: since === '--all' });
13
+ console.error(`Test selection: ${selection.reason}; ${selection.files.length} selected, ${selection.skipped.length} deferred.`);
14
+ process.stdout.write(selection.files.map((file) => file + '\0').join(''));
15
+ } catch (error) {
16
+ console.error(error.message);
17
+ process.exitCode = 1;
18
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,299 @@
1
+ // `joystack update` — move this game to the latest Joystack: the engine bundle AND
2
+ // the agent surface (skills + the Layer-0 managed block) that rides it.
3
+ //
4
+ // This is the ONLY command that writes to `.claude/skills/` or `AGENTS.md`.
5
+ // `dev` and `publish` never touch them: an update is something a creator asks
6
+ // for, not something that happens to them while they were doing something else.
7
+ //
8
+ // Because it writes into files the creator has in git, it OWES them a full
9
+ // account of what it changed — every path, and whose it was. The `/update-joystack`
10
+ // skill wraps this to walk them through the diff and offer the commit.
11
+ import { findProject, resolveEngine } from './project.mjs';
12
+ import { ensureEngine, readEnginePin, writeEnginePin } from './engine.mjs';
13
+ import { reconcileAgentSurface } from './agent-surface.mjs';
14
+ import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
15
+ import { login } from './login.mjs';
16
+ import { compareVersions, renderReleases } from './changelog.mjs';
17
+ import { syncCli, reportCli } from './self-update.mjs';
18
+ import { fetchStatus, landedIn } from './feedback-sync.mjs';
19
+
20
+ // The report is the point. A creator reading this must be able to answer, with
21
+ // no further digging: what changed, was any of it mine, and what do I do now.
22
+ function report(log, engineVersion, surface) {
23
+ const { placed = [], removed = [], lossy = [], backups = [], backupDir, adopted } = surface;
24
+ if (!placed.length && !removed.length && !lossy.length) {
25
+ log(' Your skills and instructions were already current.');
26
+ return;
27
+ }
28
+
29
+ log('');
30
+ log(`Joystack also updated this game's skills and instructions to match engine ${engineVersion}:`);
31
+ for (const f of placed) {
32
+ if (lossy.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
33
+ else log(` updated ${f}`);
34
+ }
35
+ for (const f of removed) {
36
+ if (lossy.includes(f)) log(` removed ${f} (retired by Joystack — it had local changes, see below)`);
37
+ else log(` removed ${f} (retired by Joystack)`);
38
+ }
39
+ for (const f of lossy) {
40
+ if (!placed.includes(f) && !removed.includes(f)) log(` OVERWROTE ${f} (it had local changes — see below)`);
41
+ }
42
+
43
+ if (lossy.length) {
44
+ // The overwrite is the contract (Joystack's files always land — keeping a
45
+ // local edit would freeze the file out of every future update), but the content is
46
+ // never gone: name both recovery routes and the one place local guidance
47
+ // is safe from updates.
48
+ log('');
49
+ log(' Files marked OVERWROTE had local changes that Joystack replaced. Nothing is');
50
+ log(` lost: recover them with \`git diff\`${backupDir ? `, or from ${backupDir}` : ''}.`);
51
+ log(' Guidance you want to keep belongs in AGENTS.md, below the managed block —');
52
+ log(' updates never touch anything there.');
53
+ }
54
+
55
+ if (adopted && backups.length) {
56
+ // On adoption we cannot tell an untouched old platform file from one the
57
+ // creator edited — there is no ownership record to compare against, which
58
+ // is precisely why we're adopting. Most of these are just the previous
59
+ // version's files. Claiming "you edited these" would be a lie that alarms;
60
+ // saying nothing would risk a silent overwrite. State only what is true.
61
+ log('');
62
+ log(` This game predates Joystack's skill updates, so those files had no`);
63
+ log(` ownership record. Their previous contents are saved in ${backupDir}`);
64
+ log(` — if you had edited any of them, your version is in there.`);
65
+ }
66
+
67
+ log('');
68
+ log(" These are Joystack's files inside your repo, so they show up in git as");
69
+ log(" changes you didn't make. Review them and save them with your next commit.");
70
+ }
71
+
72
+ export async function update({
73
+ cwd = process.cwd(),
74
+ apiBase = getApiBase(DEFAULT_API_BASE),
75
+ log = console.log,
76
+ fetchImpl = fetch,
77
+ loginFn = login,
78
+ ensure = ensureEngine,
79
+ reconcile = reconcileAgentSurface,
80
+ syncCliFn = syncCli,
81
+ // `joystack update --rc <version>` — take an EXACT release candidate by reference.
82
+ target = null,
83
+ } = {}) {
84
+ const project = findProject(cwd);
85
+ const from = readEnginePin(project.dir);
86
+
87
+ // Same login gate as every /api/creator endpoint.
88
+ if (!getToken()) {
89
+ log('Checking for engine updates needs your Joystack account.');
90
+ await loginFn({ apiBase, log });
91
+ if (!getToken()) throw new Error('login did not produce a token — run `joystack login` and retry.');
92
+ }
93
+
94
+ // ── Release-candidate lane ────────────────────────────────────────────────
95
+ // A candidate (`--rc 0.2.0-rc.1`) is UNLISTED: it never appears in `latest` or
96
+ // `joystack changelog`, so this lane bypasses the newest-stable comparison and
97
+ // installs the named version directly. Its changelog and any migration notes
98
+ // ship INSIDE the engine tarball (the docs the agent reads), not through the
99
+ // version-crossing callout the stable lane prints. syncCli runs FIRST for the
100
+ // same reason it does below — its npm install would prune the engine if it ran
101
+ // after the engine landed.
102
+ if (target) {
103
+ // Validate the version shape BEFORE anything mutates the repo. syncCli's npm
104
+ // install below prunes the engine, so a typo caught only after that point
105
+ // would leave the game engine-less on a bad pin. This mirrors the platform's
106
+ // ENGINE_VERSION_WITH_PRERELEASE_OK; a well-formed but unknown/removed
107
+ // candidate is still caught below by restoring the pin on a failed download.
108
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(target)) {
109
+ throw new Error(`\`--rc\` needs a valid engine version like 0.2.0-rc.1 — got "${target}".`);
110
+ }
111
+ let cli;
112
+ try {
113
+ cli = await syncCliFn({ projectDir: project.dir, log });
114
+ } catch (err) {
115
+ cli = { updated: false, error: err };
116
+ }
117
+ writeEnginePin(project.dir, target);
118
+ let engine;
119
+ try {
120
+ engine = await ensure(project.dir, { apiBase, log, fetchImpl });
121
+ } catch (err) {
122
+ // The candidate didn't download (a typo that still parsed, or one that was
123
+ // since removed). Restore the previous pin so the game isn't left pointing
124
+ // at a version that doesn't exist — the engine may have been pruned by the
125
+ // CLI install above, but is recoverable by a normal `joystack dev`/`update` on
126
+ // the restored pin. A game with no prior pin keeps none.
127
+ if (from) writeEnginePin(project.dir, from);
128
+ throw err;
129
+ }
130
+ const engineDir = engine.dir ?? null;
131
+ log('');
132
+ log(`✅ Engine candidate installed: ${from ?? '(none)'} → ${engine.version}`);
133
+
134
+ const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
135
+ if (!surface.skipped) report(log, surface.engineVersion ?? target, surface);
136
+
137
+ if (cli?.error) {
138
+ log('');
139
+ log(` The joystack command could not be updated (${cli.error.message}).`);
140
+ log(' Retry with: npm update @joystack/cli');
141
+ } else {
142
+ reportCli(log, cli);
143
+ }
144
+
145
+ log('');
146
+ log(' This is a pre-release candidate — its changelog and any migration');
147
+ log(' notes ship inside the engine (read the handbook / engine docs), not');
148
+ log(' via `joystack changelog`. Republish (`npx joystack publish`) when ready.');
149
+ return { from, to: engine.version, updated: from !== engine.version, surface, crossed: null, cli, candidate: true };
150
+ }
151
+
152
+ const res = await fetchImpl(`${apiBase}/api/creator/engine`, {
153
+ headers: { Authorization: `Bearer ${getToken()}` },
154
+ });
155
+ if (res.status === 401) throw new Error('the platform rejected this machine’s token — run `joystack login` again.');
156
+ if (!res.ok) throw new Error(`could not list engine releases (HTTP ${res.status})`);
157
+ const { latest, releases } = await res.json();
158
+ if (!latest) throw new Error('the platform has no downloadable engine releases yet.');
159
+
160
+ // What the update is about to change UNDER the game.
161
+ // Reporting the skill files we rewrote and nothing about the engine was the
162
+ // whole gap: an agent crossed two versions of the shared library blind.
163
+ //
164
+ // A platform too old to serve `releases` must NOT block the update — it just
165
+ // has to be honest that it cannot say what changed. (`joystack changelog`, whose
166
+ // only job is to answer that question, refuses outright instead.)
167
+ // Newest first, to match `joystack changelog` and — more importantly — to match
168
+ // what renderReleases' own header SAYS. The endpoint returns them ascending;
169
+ // passing that straight through would print 0.1.13 above 0.1.14 under a
170
+ // heading that reads "newest first".
171
+ const crossed = Array.isArray(releases)
172
+ ? releases
173
+ .filter(
174
+ (r) => compareVersions(r.version, latest) <= 0 && (!from || compareVersions(r.version, from) > 0),
175
+ )
176
+ .sort((a, b) => compareVersions(b.version, a.version))
177
+ : null;
178
+
179
+ // ── The `joystack` command FIRST, before the engine lands ─────────────────────
180
+ //
181
+ // Order is load-bearing, and getting it wrong deleted the engine. The engine is
182
+ // not an npm package — it installs with `npm install --no-save`, so npm has no
183
+ // record of it and treats it as extraneous. ANY later `npm install` in the game
184
+ // prunes it. syncCli runs exactly such an install (`--save-dev` the new CLI), so
185
+ // running it AFTER the engine install wiped the engine every single time: the
186
+ // update reported success and left node_modules/@joystack/engine gone. The
187
+ // next `joystack lint` then found no engine at all (caught on a real game).
188
+ //
189
+ // So: do every npm install that this command is going to do BEFORE the engine
190
+ // is put on disk, and let the engine be the last thing to land.
191
+ //
192
+ // Wrapped: syncCli is already fail-soft, and a bug in it must not stop the
193
+ // engine update that is the point of this command.
194
+ let cli;
195
+ try {
196
+ cli = await syncCliFn({ projectDir: project.dir, log });
197
+ } catch (err) {
198
+ cli = { updated: false, error: err };
199
+ }
200
+
201
+ let engineDir = null;
202
+ let updated = false;
203
+
204
+ // Forward-only: `joystack update` moves a game to `latest` only when `latest` is
205
+ // actually NEWER than the pin. It never moves a game that is already on latest
206
+ // OR ahead of it — the latter is real now that release candidates exist: a
207
+ // testbed pinned to `0.2.0-rc.3` while the newest stable is `0.1.10` must not
208
+ // be silently DOWNGRADED to 0.1.10 (which would break every 0.2.0 API it uses)
209
+ // just because the plain `update` was run out of habit. `--rc` is the lane that
210
+ // moves onto a candidate; the stable lane only ever moves forward.
211
+ const cmp = from ? compareVersions(from, latest) : -1;
212
+ if (cmp >= 0) {
213
+ // On latest, or ahead of it on a pre-release. Reconcile the surface anyway —
214
+ // a repo can sit on the right engine with a STILL out-of-date surface (one
215
+ // scaffolded before this mechanism existed never had its skills adopted) —
216
+ // but never rewrite the pin.
217
+ const label = from ?? latest;
218
+ const ahead = cmp > 0;
219
+ try {
220
+ engineDir = resolveEngine(project.dir).dir;
221
+ log(
222
+ ahead
223
+ ? `✅ Engine ${label} — a pre-release ahead of the latest release (${latest}); not downgrading.`
224
+ : `✅ Engine ${label} — already up to date.`,
225
+ );
226
+ } catch {
227
+ // Pinned to this version, but NOT on disk. The pin is a claim about what
228
+ // this game runs; node_modules is the truth, and they disagree — because a
229
+ // plain `npm install` (the creator's own, or ours above) prunes the engine,
230
+ // which npm never recorded. "Already up to date" while the engine is missing
231
+ // is a lie that leaves every engine-reading command broken, and re-running
232
+ // update could never fix it. Put it back — from the local cache, so this is
233
+ // fast and works offline.
234
+ log(`Engine ${label} is pinned but missing from node_modules — reinstalling it.`);
235
+ const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
236
+ engineDir = engine.dir ?? null;
237
+ log('');
238
+ log(`✅ Engine ${label} — restored.`);
239
+ }
240
+ } else {
241
+ // Rewrite the pin first; ensureEngine honors it (download → install → pin).
242
+ writeEnginePin(project.dir, latest);
243
+ const engine = await ensure(project.dir, { apiBase, log, fetchImpl });
244
+ engineDir = engine.dir ?? null;
245
+ updated = true;
246
+ log('');
247
+ log(`✅ Engine updated: ${from ?? '(none)'} → ${engine.version}`);
248
+ }
249
+
250
+ const surface = engineDir ? reconcile(project.dir, engineDir, { log }) : { skipped: true };
251
+ if (!surface.skipped) report(log, surface.engineVersion ?? latest, surface);
252
+
253
+ // The third lane: the `joystack` command itself (see self-update.mjs). The skills
254
+ // we reconciled ship WITH the engine and can name any command they like — a real
255
+ // game ended up on an engine whose skills say `joystack changelog` while its CLI was
256
+ // four versions too old to have it. So update moves this too.
257
+ //
258
+ // It already RAN, at the top: its npm install has to happen before the engine
259
+ // lands or it prunes it. This is only the report.
260
+ if (cli?.error) {
261
+ log('');
262
+ log(` The joystack command could not be updated (${cli.error.message}).`);
263
+ log(' Your engine and skills are up to date. Retry with: npm update @joystack/cli');
264
+ } else {
265
+ reportCli(log, cli);
266
+ }
267
+
268
+ // Feedback this repo filed that a crossed release resolved. Best-effort on
269
+ // purpose: the back-channel must never block or fail an engine update, so
270
+ // any error here (an older platform without the endpoint, offline after the
271
+ // download, a bad response) reduces to "no landings to report".
272
+ let landed = [];
273
+ if (updated) {
274
+ try {
275
+ landed = landedIn(await fetchStatus({ apiBase, fetchImpl }), { from, to: latest });
276
+ } catch {
277
+ landed = [];
278
+ }
279
+ }
280
+
281
+ if (updated) {
282
+ log('');
283
+ if (crossed === null) {
284
+ log(' This Joystack platform could not tell us what changed in the engine — no changelog');
285
+ log(' is available for the versions you just crossed.');
286
+ } else if (crossed.length) {
287
+ log(renderReleases(crossed, { pinned: from, latest }));
288
+ }
289
+ if (landed.length) {
290
+ log('');
291
+ log(' Feedback you filed landed in this update:');
292
+ for (const r of landed) log(` • "${r.title}" — resolved in ${r.resolved_in}`);
293
+ log(' Run `npx joystack feedback` to pull the details into notes/feedback/.');
294
+ }
295
+ log('');
296
+ log(' Republish (`npx joystack publish`) when you want the live game on it.');
297
+ }
298
+ return { from, to: latest, updated, surface, crossed, cli, landed };
299
+ }