@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,231 @@
1
+ // `joystack feedback` — the back-channel half. The send half
2
+ // (feedback.mjs) ships reports OUT; this module brings the conversation BACK:
3
+ //
4
+ // 1. Push replies. A report file can carry a `## Reply (unsent)` section —
5
+ // the agent writes one when something about the outcome still needs
6
+ // doing. It ships to /api/creator/feedback/reply and the heading is
7
+ // stamped with the returned fu_ id, so it never ships twice.
8
+ // 2. Fetch outcomes. /api/creator/feedback/status returns, for every report
9
+ // this account filed: its status (read / done / discarded), the engine
10
+ // version a fix landed in, and the update thread. Everything is stamped
11
+ // back INTO the report file — `status:` / `resolved-in:` in frontmatter,
12
+ // unseen updates appended under `## Updates` — so the file under
13
+ // notes/feedback/ IS the thread, readable offline by any agent.
14
+ //
15
+ // Idempotency is by fu_ id: every update written into the file carries its
16
+ // `fu_<16 hex>` marker (in an HTML comment, or on a sent-reply heading), and
17
+ // anything whose id already appears in the file is never appended again.
18
+ import { readFileSync, writeFileSync } from 'node:fs';
19
+ import { basename } from 'node:path';
20
+ import { findProject } from './project.mjs';
21
+ import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
22
+ import { discoverSent } from './feedback.mjs';
23
+ import { compareVersions } from './changelog.mjs';
24
+
25
+ const UPDATE_ID = /fu_[0-9a-f]{16}/g;
26
+ const UNSENT_REPLY = /^## Reply \(unsent\)\s*$/m;
27
+
28
+ // Every unsent reply section: the text between a `## Reply (unsent)` heading
29
+ // and the next `## ` heading (or EOF). A `## ` line inside a ``` / ~~~ fence
30
+ // is CONTENT, not a boundary — replies quote markdown evidence, and stopping
31
+ // at a fenced heading would ship half the reply and strand the rest under an
32
+ // already-stamped heading where it can never be sent.
33
+ export function extractUnsentReplies(text) {
34
+ const out = [];
35
+ const lines = text.split('\n');
36
+ for (let i = 0; i < lines.length; i++) {
37
+ if (!/^## Reply \(unsent\)\s*$/.test(lines[i])) continue;
38
+ const body = [];
39
+ let fenced = false;
40
+ for (let j = i + 1; j < lines.length; j++) {
41
+ if (/^(```|~~~)/.test(lines[j])) fenced = !fenced;
42
+ else if (!fenced && /^## /.test(lines[j])) break;
43
+ body.push(lines[j]);
44
+ }
45
+ out.push({ body: body.join('\n').trim() });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export function knownUpdateIds(text) {
51
+ return new Set(text.match(UPDATE_ID) ?? []);
52
+ }
53
+
54
+ // Upsert `status:` / `resolved-in:` into the frontmatter block. The report was
55
+ // stamped `sent:` + `id:` when it shipped, so a synced file always has
56
+ // frontmatter; a file without one is left alone. CRLF files (a Windows clone
57
+ // with autocrlf) get their line endings preserved.
58
+ export function applyStatus(text, { status, resolved_in }) {
59
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
60
+ if (!fm) return text;
61
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
62
+ let block = fm[1];
63
+ const upsert = (key, value) => {
64
+ if (value == null) return;
65
+ const line = `${key}: ${value}`;
66
+ if (new RegExp(`^${key}:.*$`, 'm').test(block)) block = block.replace(new RegExp(`^${key}:[^\\r\\n]*`, 'm'), line);
67
+ else block = `${block}${eol}${line}`;
68
+ };
69
+ upsert('status', status);
70
+ upsert('resolved-in', resolved_in);
71
+ return text.slice(0, fm.index) + `---${eol}${block}${eol}---` + text.slice(fm.index + fm[0].length);
72
+ }
73
+
74
+ // Append unseen thread updates under `## Updates` (created at EOF if missing).
75
+ // Each entry carries its fu_ marker; `knownIds` is what makes re-runs no-ops.
76
+ //
77
+ // The body is written as a BLOCKQUOTE, not raw markdown. That is a protocol
78
+ // necessity, not styling: this file is re-parsed by the reply/section regexes
79
+ // on every sync, so a message that merely QUOTES a heading like "## Reply
80
+ // (unsent)" must not become one — raw injection would ship the team's own
81
+ // words back as a creator reply (reopening the report it arrived on).
82
+ export function appendUpdates(text, updates, knownIds) {
83
+ const fresh = updates.filter((u) => !knownIds.has(u.id));
84
+ if (fresh.length === 0) return { text, appended: 0 };
85
+
86
+ let entries = '';
87
+ for (const u of fresh) {
88
+ const who = u.author === 'creator' ? 'You' : 'Joystack';
89
+ const when = new Date(u.created_at * 1000).toISOString().slice(0, 10);
90
+ const quoted = u.body.split('\n').map((l) => (l.trim() ? `> ${l}` : '>')).join('\n');
91
+ entries += `\n### ${who} — ${when} <!-- ${u.id} -->\n\n${quoted}\n`;
92
+ }
93
+
94
+ // New entries go at the END OF THE SECTION, not the end of the file: once a
95
+ // reply section exists below `## Updates`, appending at EOF would interleave
96
+ // platform messages under the creator's reply and the thread stops reading
97
+ // in order.
98
+ const lines = text.split('\n');
99
+ const head = lines.findIndex((l) => /^## Updates\s*$/.test(l));
100
+ if (head === -1) {
101
+ return { text: `${text.trimEnd()}\n\n## Updates\n${entries}`, appended: fresh.length };
102
+ }
103
+ let end = lines.length;
104
+ for (let j = head + 1; j < lines.length; j++) {
105
+ if (/^## /.test(lines[j])) { end = j; break; }
106
+ }
107
+ const section = lines.slice(0, end).join('\n').trimEnd();
108
+ const rest = lines.slice(end).join('\n');
109
+ return {
110
+ text: `${section}\n${entries}${rest ? `\n${rest}` : ''}`,
111
+ appended: fresh.length,
112
+ };
113
+ }
114
+
115
+ // Which reports landed in the versions an engine update just crossed:
116
+ // (from, to], or everything at/below `to` when no previous pin exists.
117
+ // `status` must still be done — a report reopened after its fix shipped keeps
118
+ // the resolved_in stamp as history, but announcing it as "landed" would claim
119
+ // a fix the reply just said didn't hold.
120
+ export function landedIn(reports, { from, to }) {
121
+ return reports.filter(
122
+ (r) =>
123
+ r.status === 'done' &&
124
+ r.resolved_in &&
125
+ compareVersions(r.resolved_in, to) <= 0 &&
126
+ (!from || compareVersions(r.resolved_in, from) > 0),
127
+ );
128
+ }
129
+
130
+ export async function fetchStatus({ apiBase, fetchImpl = fetch }) {
131
+ const token = getToken();
132
+ if (!token) throw new Error('reading feedback status requires login — run `joystack login` first.');
133
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/status`, {
134
+ headers: { Authorization: `Bearer ${token}` },
135
+ });
136
+ if (!res.ok) throw new Error(`feedback status fetch failed (HTTP ${res.status})`);
137
+ const { reports } = await res.json();
138
+ return Array.isArray(reports) ? reports : [];
139
+ }
140
+
141
+ async function pushReplies({ path, id, apiBase, fetchImpl, log }) {
142
+ let text = readFileSync(path, 'utf8');
143
+ let sent = 0;
144
+ // One at a time: each stamp rewrites the first unsent heading, so the next
145
+ // loop iteration sees the next reply (if any) as the new first.
146
+ while (UNSENT_REPLY.test(text)) {
147
+ const [reply] = extractUnsentReplies(text);
148
+ if (!reply?.body) {
149
+ log(`⚠️ ${basename(path)} has an empty "## Reply (unsent)" section — write the reply, then rerun.`);
150
+ break;
151
+ }
152
+ const token = getToken();
153
+ const res = await fetchImpl(`${apiBase}/api/creator/feedback/reply`, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
156
+ body: JSON.stringify({ id, body: reply.body }),
157
+ });
158
+ if (!res.ok) {
159
+ const detail = await res.text().catch(() => '');
160
+ throw new Error(`reply send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
161
+ }
162
+ const { id: updateId } = await res.json();
163
+ text = text.replace(UNSENT_REPLY, `## Reply (sent ${new Date().toISOString()}, ${updateId})`);
164
+ writeFileSync(path, text);
165
+ sent += 1;
166
+ log(`↩️ Sent your reply on ${basename(path)} → ${updateId}`);
167
+ }
168
+ return sent;
169
+ }
170
+
171
+ // The whole sync: replies out, outcomes in. Fails soft on nothing-to-do (no
172
+ // sent reports → silent no-op, no login needed); fails LOUD on a send error,
173
+ // leaving the reply unsent so the next run retries it.
174
+ export async function syncFeedback({
175
+ cwd = process.cwd(),
176
+ apiBase = getApiBase(DEFAULT_API_BASE),
177
+ log = console.log,
178
+ fetchImpl = fetch,
179
+ } = {}) {
180
+ const project = findProject(cwd);
181
+ const sentFiles = discoverSent(project.dir);
182
+ if (sentFiles.length === 0) return { replies: 0, updated: [] };
183
+
184
+ if (!getToken()) throw new Error('syncing feedback requires login — run `joystack login` first.');
185
+
186
+ // A failing reply must not rob the OTHER reports of their outcomes: push
187
+ // everything, stamp everything, and only THEN re-throw the first failure —
188
+ // loud (so the run is visibly not clean) but after the useful work. The
189
+ // failed reply's heading is still `(unsent)`, so the next run retries it.
190
+ let replies = 0;
191
+ let replyError = null;
192
+ for (const f of sentFiles) {
193
+ try {
194
+ replies += await pushReplies({ ...f, apiBase, fetchImpl, log });
195
+ } catch (err) {
196
+ replyError ??= err;
197
+ }
198
+ }
199
+
200
+ // Fetch-and-stamp is best-effort: the send half already succeeded, and a
201
+ // platform without the endpoint (or a transient failure) must not turn a
202
+ // delivered report into an apparent failure. The replies stay loud.
203
+ let reports;
204
+ try {
205
+ reports = await fetchStatus({ apiBase, fetchImpl });
206
+ } catch (err) {
207
+ log(`(could not check your reports' outcomes right now — ${err.message} They'll sync on the next run.)`);
208
+ if (replyError) throw replyError;
209
+ return { replies, updated: [] };
210
+ }
211
+ const byId = new Map(reports.map((r) => [r.id, r]));
212
+
213
+ const updated = [];
214
+ for (const { path, id } of sentFiles) {
215
+ const report = byId.get(id);
216
+ if (!report) continue;
217
+ const before = readFileSync(path, 'utf8');
218
+ let text = applyStatus(before, report);
219
+ const { text: withUpdates, appended } = appendUpdates(text, report.updates ?? [], knownUpdateIds(text));
220
+ text = withUpdates;
221
+ if (text === before) continue;
222
+ writeFileSync(path, text);
223
+ updated.push({ file: path, id, status: report.status, resolved_in: report.resolved_in, newUpdates: appended });
224
+
225
+ const landed = report.resolved_in ? ` — landed in ${report.resolved_in}` : '';
226
+ const news = appended ? ` (+${appended} update${appended === 1 ? '' : 's'})` : '';
227
+ log(`📬 ${basename(path)}: ${report.status}${landed}${news}`);
228
+ }
229
+ if (replyError) throw replyError;
230
+ return { replies, updated };
231
+ }
@@ -0,0 +1,209 @@
1
+ // `joystack feedback` — send the reports under notes/feedback/ to the Joystack team
2
+ // (the transport behind the template's /feedback skill).
3
+ //
4
+ // Target: /api/creator/feedback — the token-authenticated intake beside
5
+ // creator/publish. Each unsent notes/feedback/*.md ships VERBATIM as the
6
+ // report body (frontmatter, transcript excerpt and all — the agent authors
7
+ // it rich on purpose), with the repo's identity attached: slug, the
8
+ // joystack.engine pin, and this CLI's version. A delivered file is stamped
9
+ // `sent:` + `id:` in its frontmatter so it never ships twice; a failed send
10
+ // leaves the file unstamped and is retried on the next run.
11
+ import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, realpathSync } from 'node:fs';
12
+ import { basename, join, resolve, relative, isAbsolute, sep } from 'node:path';
13
+ import { findProject } from './project.mjs';
14
+ import { DEFAULT_API_BASE, getApiBase, getToken } from './config.mjs';
15
+ import { gameConfig } from './legacy-names.mjs';
16
+
17
+ const CLI_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+
19
+ // Attachment caps. The riven report cited game.js:56 and riven-render.js:302 —
20
+ // source files, not screenshots — so attachments are TEXT. Kept well under the
21
+ // intake's own ceiling so an oversized set is caught HERE, with a readable
22
+ // message: Cloudflare replaces a Pages Function's 5xx body with its own HTML
23
+ // page, which would reach the creator as an unreadable wall.
24
+ export const MAX_ATTACHMENTS = 20;
25
+ export const MAX_ATTACHMENT_BYTES = 256 * 1024; // per file
26
+ export const MAX_ATTACHMENTS_BYTES = 1024 * 1024; // all of them together
27
+
28
+ // CRLF-tolerant: a Windows clone with autocrlf rewrites these files, and a
29
+ // frontmatter that stops matching means a sent report reads as unsent and is
30
+ // re-filed on every run.
31
+ const frontmatter = (text) => /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '';
32
+
33
+ // A report is "sent" when its frontmatter carries a `sent:` stamp.
34
+ function isSent(text) {
35
+ return /^sent:\s*\S/m.test(frontmatter(text));
36
+ }
37
+
38
+ // `attachments:` in the report's frontmatter, in either YAML shape agents write:
39
+ //
40
+ // attachments: attachments: [game.js, src/render.js]
41
+ // - game.js
42
+ // - src/render.js
43
+ export function parseAttachments(text) {
44
+ const fm = frontmatter(text);
45
+ const inline = /^attachments:\s*\[(.*)\]\s*$/m.exec(fm);
46
+ if (inline) {
47
+ return inline[1]
48
+ .split(',')
49
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
50
+ .filter(Boolean);
51
+ }
52
+ const block = /^attachments:\s*$/m.exec(fm);
53
+ if (!block) return [];
54
+ const out = [];
55
+ for (const line of fm.slice(block.index).split('\n').slice(1)) {
56
+ const item = /^\s*-\s*(.+?)\s*$/.exec(line);
57
+ if (!item) break; // the list ends at the first non-item line
58
+ out.push(item[1].replace(/^['"]|['"]$/g, ''));
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // The agent chooses this list, so it is arbitrary filesystem read access over a
64
+ // network. Every path must land inside the game folder — resolved, not merely
65
+ // inspected, because a symlink's string looks innocent. An escape is a hard
66
+ // error, never a skipped file.
67
+ function readAttachments(projectDir, paths, reportName) {
68
+ if (paths.length > MAX_ATTACHMENTS) {
69
+ throw new Error(
70
+ `${reportName} lists ${paths.length} attachments (max ${MAX_ATTACHMENTS}) — attach only the files the diagnosis rests on.`,
71
+ );
72
+ }
73
+
74
+ const root = realpathSync(projectDir);
75
+ const inside = (candidate) => candidate === root || candidate.startsWith(root + sep);
76
+ const escaped = (p, resolved) => {
77
+ throw new Error(
78
+ `${reportName} attaches "${p}", which resolves outside the game folder (${resolved}). ` +
79
+ `Feedback may only attach files from this game.`,
80
+ );
81
+ };
82
+
83
+ const files = [];
84
+ let total = 0;
85
+
86
+ for (const p of paths) {
87
+ // Containment is checked BEFORE existence, and on the lexical path first:
88
+ // "../../.ssh/id_rsa" is refused for what it asks for, not for whether it
89
+ // happens to be there. Then again after realpath — a symlink's string looks
90
+ // perfectly innocent.
91
+ const full = resolve(root, p);
92
+ if (isAbsolute(p) || !inside(full)) escaped(p, full);
93
+
94
+ let real;
95
+ try {
96
+ real = realpathSync(full);
97
+ } catch {
98
+ throw new Error(`${reportName} attaches "${p}", which does not exist in the game folder.`);
99
+ }
100
+ if (!inside(real)) escaped(p, real);
101
+ if (!statSync(real).isFile()) throw new Error(`${reportName} attaches "${p}", which is not a file.`);
102
+
103
+ const bytes = statSync(real).size;
104
+ if (bytes > MAX_ATTACHMENT_BYTES) {
105
+ throw new Error(
106
+ `${reportName} attaches "${p}" (${Math.round(bytes / 1024)} KiB) — too large, the per-file cap is ${MAX_ATTACHMENT_BYTES / 1024} KiB.`,
107
+ );
108
+ }
109
+ total += bytes;
110
+ if (total > MAX_ATTACHMENTS_BYTES) {
111
+ throw new Error(
112
+ `${reportName}'s attachments are too large together (over ${MAX_ATTACHMENTS_BYTES / 1024} KiB) — attach fewer files.`,
113
+ );
114
+ }
115
+
116
+ const content = readFileSync(real, 'utf8');
117
+ if (content.includes('\0')) {
118
+ throw new Error(`${reportName} attaches "${p}", which is not a text file. Attach source, logs, or config.`);
119
+ }
120
+ // Report the path the creator wrote (project-relative), not the real one —
121
+ // it is what the report's prose cites.
122
+ files.push({ path: relative(root, real).split(sep).join('/'), content });
123
+ }
124
+ return files;
125
+ }
126
+
127
+ function titleOf(text, path) {
128
+ const h1 = /^#\s+(.+)$/m.exec(text);
129
+ return h1 ? h1[1].trim() : basename(path, '.md');
130
+ }
131
+
132
+ function stamp(text, id) {
133
+ const lines = `sent: ${new Date().toISOString()}\nid: ${id}`;
134
+ if (/^---\r?\n/.test(text)) return text.replace(/^---\r?\n/, (m) => `${m}${lines}\n`);
135
+ return `---\n${lines}\n---\n${text}`;
136
+ }
137
+
138
+ export function discoverUnsent(projectDir) {
139
+ const dir = join(projectDir, 'notes', 'feedback');
140
+ if (!existsSync(dir)) return [];
141
+ return readdirSync(dir)
142
+ .filter((name) => name.endsWith('.md'))
143
+ .map((name) => join(dir, name))
144
+ .filter((path) => !isSent(readFileSync(path, 'utf8')))
145
+ .sort();
146
+ }
147
+
148
+ // Delivered reports, with the id the platform stamped into each — what the
149
+ // back-channel (feedback-sync.mjs) replies on and fetches outcomes for.
150
+ export function discoverSent(projectDir) {
151
+ const dir = join(projectDir, 'notes', 'feedback');
152
+ if (!existsSync(dir)) return [];
153
+ return readdirSync(dir)
154
+ .filter((name) => name.endsWith('.md'))
155
+ .map((name) => join(dir, name))
156
+ .sort()
157
+ .flatMap((path) => {
158
+ const id = /^id:\s*(fb_[0-9a-f]{16})\s*$/m.exec(frontmatter(readFileSync(path, 'utf8')))?.[1];
159
+ return id ? [{ path, id }] : [];
160
+ });
161
+ }
162
+
163
+ export async function sendFeedback({
164
+ cwd = process.cwd(),
165
+ apiBase = getApiBase(DEFAULT_API_BASE),
166
+ log = console.log,
167
+ } = {}) {
168
+ const project = findProject(cwd);
169
+ const unsent = discoverUnsent(project.dir);
170
+ if (unsent.length === 0) {
171
+ log('Nothing to send — no unsent reports under notes/feedback/.');
172
+ return { sent: [] };
173
+ }
174
+
175
+ const token = getToken();
176
+ if (!token) throw new Error('sending feedback requires login — run `joystack login` first.');
177
+
178
+ const sent = [];
179
+ for (const path of unsent) {
180
+ const body = readFileSync(path, 'utf8');
181
+ // Read + guard the attachments BEFORE the request: a bad path must fail the
182
+ // send outright, leaving the report unstamped and retryable.
183
+ const files = readAttachments(project.dir, parseAttachments(body), basename(path));
184
+ const report = {
185
+ title: titleOf(body, path),
186
+ body,
187
+ slug: project.slug,
188
+ engine: gameConfig(project.pkg).engine,
189
+ cli: CLI_VERSION,
190
+ ...(files.length ? { files } : {}),
191
+ };
192
+ const res = await fetch(`${apiBase}/api/creator/feedback`, {
193
+ method: 'POST',
194
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
195
+ body: JSON.stringify(report),
196
+ });
197
+ if (!res.ok) {
198
+ const detail = await res.text().catch(() => '');
199
+ throw new Error(`feedback send failed for ${basename(path)} (HTTP ${res.status}): ${detail.slice(0, 300)}`);
200
+ }
201
+ const { id } = await res.json();
202
+ writeFileSync(path, stamp(body, id));
203
+ const withFiles = files.length ? ` (+${files.length} file${files.length === 1 ? '' : 's'})` : '';
204
+ log(`✅ Sent ${basename(path)}${withFiles} → ${id}`);
205
+ sent.push({ file: path, id, files: files.map((f) => f.path) });
206
+ }
207
+ log(`${sent.length} report${sent.length === 1 ? '' : 's'} delivered to the Joystack team.`);
208
+ return { sent };
209
+ }
package/lib/freeze.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // `joystack dev --no-reload`: the game as it is at startup, for the whole run.
2
+ //
3
+ // The dev server reads a game's files from disk on every request and rebuilds
4
+ // its room when they change, so switching the watchers off alone does not
5
+ // freeze anything: a page opened after an edit gets the edited files, while the
6
+ // room and the injected entity layout stay as they were, and an edit that adds
7
+ // an entity makes that page fail to boot. Freezing therefore serves, and builds
8
+ // the room from, a copy of the game taken once at startup.
9
+ import { constants, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync, unlinkSync } from 'node:fs';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { basename, join } from 'node:path';
12
+ import { gameBuild } from './build-id.mjs';
13
+ import { STATE_DIR_NAMES, stateDir } from './legacy-names.mjs';
14
+
15
+ // Left out of the copy: recordings and generated build output (.joystack), which
16
+ // are written to the real folder or regenerated into the copy; git history;
17
+ // and installed packages, which the copy links to instead, since they do not
18
+ // change while the creator edits the game.
19
+ const LEFT_OUT = new Set([...STATE_DIR_NAMES, '.git', 'node_modules']);
20
+
21
+ // Where copies live: inside the game's own .joystack, so a copy is on the same
22
+ // disk as the game (cloned rather than copied, on a filesystem that clones)
23
+ // and outside everything the build id and publish read. Each run's copy is
24
+ // named `<pid>-<random>`, so a copy whose process is gone can be told apart
25
+ // from one a running `joystack dev` is serving.
26
+ export function frozenRoot(gameDir) {
27
+ return join(stateDir(gameDir), 'frozen');
28
+ }
29
+
30
+ /**
31
+ * Copies the game at `dir` into `.joystack/frozen/<pid>-<random>/<folder name>`.
32
+ * `build` is the game's build at the moment of the copy, the one every session
33
+ * played on the copy is stamped with. `remove()` deletes the copy; it also runs
34
+ * when the process exits normally, and a copy whose process died without
35
+ * running it is swept by the next freeze of the same game.
36
+ *
37
+ * Symlinks inside the game are copied as what they point to, so a linked
38
+ * folder is frozen with the rest.
39
+ * @param {string} dir
40
+ * @returns {{ dir: string, build: object, remove: () => void }}
41
+ */
42
+ export function freezeGame(dir) {
43
+ sweepStale(dir);
44
+ const build = gameBuild(dir);
45
+ const run = join(frozenRoot(dir), `${process.pid}-${randomBytes(3).toString('hex')}`);
46
+ // The folder name is the game's slug unless package.json names one, so the
47
+ // copy keeps it.
48
+ const copy = join(run, basename(dir));
49
+ const modulesLink = join(copy, 'node_modules');
50
+ let removed = false;
51
+ const remove = () => {
52
+ if (removed) return;
53
+ removed = true;
54
+ process.off('exit', remove);
55
+ // The link first, on its own: on Windows it is a junction, and a recursive
56
+ // delete that followed it would empty the game's real node_modules.
57
+ try { unlinkSync(modulesLink); } catch { /* never made */ }
58
+ rmSync(run, { recursive: true, force: true });
59
+ };
60
+ process.on('exit', remove);
61
+ try {
62
+ mkdirSync(copy, { recursive: true });
63
+ // Entry by entry, because the copy lives inside the game (under .joystack),
64
+ // and a copy of a folder into its own subfolder is refused outright.
65
+ for (const entry of readdirSync(dir)) {
66
+ if (LEFT_OUT.has(entry)) continue;
67
+ cpSync(join(dir, entry), join(copy, entry), { recursive: true, dereference: true, mode: constants.COPYFILE_FICLONE });
68
+ }
69
+ const modules = join(dir, 'node_modules');
70
+ if (existsSync(modules)) symlinkSync(modules, modulesLink, process.platform === 'win32' ? 'junction' : 'dir');
71
+ } catch (err) {
72
+ remove();
73
+ throw err;
74
+ }
75
+ return { dir: copy, build, remove };
76
+ }
77
+
78
+ // Deletes the copies of runs whose process no longer exists: a `joystack dev`
79
+ // killed outright (a closed terminal, a crash) never ran its cleanup.
80
+ function sweepStale(dir) {
81
+ const root = frozenRoot(dir);
82
+ let runs;
83
+ try { runs = readdirSync(root); } catch { return; }
84
+ for (const name of runs) {
85
+ const pid = Number(/^(\d+)-/.exec(name)?.[1]);
86
+ // This process's own runs are removed by their own remove().
87
+ if (pid && (pid === process.pid || alive(pid))) continue;
88
+ const run = join(root, name);
89
+ for (const game of safeList(run)) {
90
+ try { unlinkSync(join(run, game, 'node_modules')); } catch { /* not a link, or absent */ }
91
+ }
92
+ rmSync(run, { recursive: true, force: true });
93
+ }
94
+ }
95
+
96
+ function alive(pid) {
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch (err) {
101
+ // EPERM: the process exists but belongs to someone else.
102
+ return err.code === 'EPERM';
103
+ }
104
+ }
105
+
106
+ function safeList(dir) {
107
+ try { return readdirSync(dir); } catch { return []; }
108
+ }
@@ -0,0 +1,99 @@
1
+ // The files that make up a game: what `joystack publish` uploads, and what a
2
+ // build id (build-id.mjs) is the hash of. One definition, so the id a
3
+ // recording carries and the files a publish ships can never describe
4
+ // different sets.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+ import { STATE_DIR_NAMES } from './legacy-names.mjs';
9
+
10
+ // Never shipped: tooling, VCS, agent workspace, notes + handbook + agent
11
+ // instructions (repo knowledge travels with the repo, not the catalog — a
12
+ // published game must not expose it on a public URL), tests/smokes.
13
+ //
14
+ // This list is the floor, not the whole rule: whatever the game's own
15
+ // .gitignore excludes is dropped as well (gitIgnored below). Name-based lists
16
+ // can only ever cover the names somebody thought of, and the folder that
17
+ // prompted this was `screenshots/` — dev captures a test run had left behind,
18
+ // gitignored by the creator and published to their public URL regardless.
19
+ const SKIP_DIRS = new Set(['node_modules', 'notes', 'handbook', '.git', ...STATE_DIR_NAMES, '.claude', '__pycache__']);
20
+ const SKIP_FILES = [
21
+ /^package(-lock)?\.json$/,
22
+ /\.(test|smoke)\.(js|mjs|ts)$/,
23
+ /^\.DS_Store$/,
24
+ /^gamedev\.toml$/,
25
+ /^(AGENTS|CLAUDE|GEMINI)\.md$/,
26
+ ];
27
+
28
+ // What the creator's own .gitignore excludes, out of a list of candidate paths.
29
+ //
30
+ // A gitignored path is the creator saying, in the file they already maintain for
31
+ // exactly this, that it is not part of the game — build output, scratch, and the
32
+ // screenshots a test run left behind. Publishing it puts it on a public URL
33
+ // under their name, which is nobody's intent.
34
+ //
35
+ // One `git check-ignore` call for the whole list rather than one per file, and
36
+ // NUL-delimited so a path with a space or a newline in it survives. A folder
37
+ // that is not a git repo, or a machine with no git, has no .gitignore to apply,
38
+ // and the list goes through untouched. A git that fails any other way — it
39
+ // hangs, or answers more than fits — throws: going on would publish the very
40
+ // files the .gitignore was written to keep out.
41
+ export function gitIgnored(dir, rels) {
42
+ if (!rels.length) return new Set();
43
+ try {
44
+ const out = execFileSync('git', ['check-ignore', '--stdin', '-z'], {
45
+ cwd: dir,
46
+ input: `${rels.join('\0')}\0`,
47
+ encoding: 'utf8',
48
+ stdio: ['pipe', 'pipe', 'ignore'],
49
+ timeout: 30_000,
50
+ maxBuffer: 256 * 1024 * 1024,
51
+ });
52
+ return new Set(out.split('\0').filter(Boolean));
53
+ } catch (err) {
54
+ // Exit 1 means "nothing on the list is ignored"; git still wrote stdout.
55
+ if (err?.status === 1) return new Set((err.stdout ?? '').split('\0').filter(Boolean));
56
+ // No git installed, or not a repository (git exits 128).
57
+ if (err?.code === 'ENOENT' || err?.status === 128) return new Set();
58
+ throw new Error(`could not read the game's .gitignore: git check-ignore failed (${err?.code ?? err?.signal ?? err?.status ?? err?.message})`);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Whether a path, relative to the game folder with `/` separators, is one the
64
+ * walk below would take by its name alone — before the .gitignore check. A
65
+ * reader holding a path the walk never visited (a deleted file in `git
66
+ * status`) asks this.
67
+ */
68
+ export function isGameFilePath(rel) {
69
+ const parts = String(rel).split('/');
70
+ const name = parts.pop();
71
+ if (!name || name.startsWith('.') || SKIP_FILES.some((re) => re.test(name))) return false;
72
+ return parts.every((d) => d && !SKIP_DIRS.has(d) && !d.startsWith('.'));
73
+ }
74
+
75
+ export function collectGameFiles(dir) {
76
+ const found = new Map(); // relative path → absolute path
77
+ const walk = (d) => {
78
+ for (const name of readdirSync(d)) {
79
+ const p = join(d, name);
80
+ const rel = relative(dir, p).split('\\').join('/');
81
+ if (statSync(p).isDirectory()) {
82
+ if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
83
+ walk(p);
84
+ } else {
85
+ if (name.startsWith('.') || SKIP_FILES.some((re) => re.test(name))) continue;
86
+ found.set(rel, p);
87
+ }
88
+ }
89
+ };
90
+ walk(dir);
91
+
92
+ const ignored = gitIgnored(dir, [...found.keys()]);
93
+ const out = new Map(); // relative path → Buffer
94
+ for (const [rel, p] of found) {
95
+ if (ignored.has(rel)) continue;
96
+ out.set(rel, readFileSync(p));
97
+ }
98
+ return out;
99
+ }