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