@joystack/cli 0.0.0-stage → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
package/lib/history.mjs
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// A game's files as they were at an earlier commit, for the Assets page's
|
|
2
|
+
// model history.
|
|
3
|
+
//
|
|
4
|
+
// Two questions are answered from the game's git repository:
|
|
5
|
+
//
|
|
6
|
+
// which commits touched these files? log(paths)
|
|
7
|
+
// what were the game's files at one? extraction(commit) → a directory
|
|
8
|
+
//
|
|
9
|
+
// An extraction is the game's own folder at that commit, written by
|
|
10
|
+
// `git archive` into a temporary directory and made read-only. Nothing is
|
|
11
|
+
// checked out: the working copy, the index and HEAD are never touched, so
|
|
12
|
+
// looking at an old version cannot disturb work in progress.
|
|
13
|
+
//
|
|
14
|
+
// ── TRUST ───────────────────────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// The commit and the paths arrive from a URL, so neither reaches git as text
|
|
17
|
+
// git could read as an option or a pattern:
|
|
18
|
+
// - a commit is hex only, 7 to 64 digits, and is resolved to a full object
|
|
19
|
+
// id with `rev-parse --verify <hex>^{commit}` behind `--end-of-options`;
|
|
20
|
+
// no branch names, no `HEAD~3`, nothing starting with `-`;
|
|
21
|
+
// - a path is a plain relative path inside the game's folder (no `..`, no
|
|
22
|
+
// segment starting with `.`, no backslash), passed after `--` with
|
|
23
|
+
// `--literal-pathspecs`, so `*` and `:(top)` mean nothing;
|
|
24
|
+
// - git runs through execFile/spawn with an argument list, never a shell,
|
|
25
|
+
// in the game's folder, with the variables that could point it at another
|
|
26
|
+
// repository (GIT_DIR and friends) removed.
|
|
27
|
+
//
|
|
28
|
+
// The archive is limited to the game's folder (`<commit>:<prefix>`), so a
|
|
29
|
+
// game inside a larger repository never exposes the rest of it. The tar
|
|
30
|
+
// stream is read here rather than by a `tar` program: only regular files and
|
|
31
|
+
// folders are written, every name is checked to land inside the extraction,
|
|
32
|
+
// and links of every kind are skipped, so nothing served from an extraction
|
|
33
|
+
// can point outside it.
|
|
34
|
+
|
|
35
|
+
import { execFile, spawn } from 'node:child_process';
|
|
36
|
+
import {
|
|
37
|
+
chmodSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, rmSync, writeSync,
|
|
38
|
+
} from 'node:fs';
|
|
39
|
+
import { tmpdir } from 'node:os';
|
|
40
|
+
import { dirname, join, normalize, sep } from 'node:path';
|
|
41
|
+
|
|
42
|
+
export const HISTORY_PATH = '/__joystack/history';
|
|
43
|
+
|
|
44
|
+
// A commit as a URL may name it: lowercase hex, a short id or a full one
|
|
45
|
+
// (SHA-1 is 40 digits, SHA-256 64).
|
|
46
|
+
export const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
|
47
|
+
// The most paths one history request may ask about, the longest path, and
|
|
48
|
+
// the most commits listed (newest first). A model's modules often import a
|
|
49
|
+
// game-wide file (a config, a palette) that changes far more often than the
|
|
50
|
+
// model, so the list is long enough to reach back past a busy week of those.
|
|
51
|
+
export const MAX_PATHS = 200;
|
|
52
|
+
const MAX_PATH_LENGTH = 512;
|
|
53
|
+
export const MAX_COMMITS = 200;
|
|
54
|
+
// Extractions kept at once, and how long one unused is kept. A game's folder
|
|
55
|
+
// at one commit is tens of megabytes for a game with sounds, so a handful is
|
|
56
|
+
// plenty for comparing a few versions and stays well clear of filling a disk.
|
|
57
|
+
export const MAX_EXTRACTIONS = 4;
|
|
58
|
+
export const MAX_EXTRACTION_AGE_MS = 30 * 60 * 1000;
|
|
59
|
+
// Bounds on one extraction: total bytes written, and files.
|
|
60
|
+
const MAX_ARCHIVE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
61
|
+
const MAX_ARCHIVE_ENTRIES = 200_000;
|
|
62
|
+
const GIT_TIMEOUT_MS = 20_000;
|
|
63
|
+
const ARCHIVE_TIMEOUT_MS = 180_000;
|
|
64
|
+
// The prefix of every extraction root this module creates in the temp folder.
|
|
65
|
+
// The process id is part of the name so a root left behind by a server that
|
|
66
|
+
// died can be told from one a running server is using (sweepAbandoned).
|
|
67
|
+
const ROOT_PREFIX = 'joystack-history-';
|
|
68
|
+
|
|
69
|
+
// Variables that would make git read a repository other than the one the
|
|
70
|
+
// game's folder is in.
|
|
71
|
+
const REPO_ENV = [
|
|
72
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
73
|
+
'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function gitEnv() {
|
|
77
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', GIT_PAGER: 'cat' };
|
|
78
|
+
for (const k of REPO_ENV) delete env[k];
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Options placed before every git subcommand: no filesystem monitor (a
|
|
83
|
+
// repository's config can name a program for it), paths printed as they
|
|
84
|
+
// are, no signature check lines mixed into the log being parsed, and
|
|
85
|
+
// pathspecs read literally.
|
|
86
|
+
const GIT_PRE = [
|
|
87
|
+
'-c', 'core.fsmonitor=false', '-c', 'core.quotepath=false', '-c', 'log.showSignature=false', '--literal-pathspecs',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function git(cwd, args, { maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS } = {}) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
execFile('git', [...GIT_PRE, ...args], { cwd, env: gitEnv(), maxBuffer, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
93
|
+
if (err) {
|
|
94
|
+
err.stderr = String(stderr ?? '').trim();
|
|
95
|
+
return reject(err);
|
|
96
|
+
}
|
|
97
|
+
resolve(stdout);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// An error that carries the HTTP status the dev server answers with.
|
|
103
|
+
export class HistoryError extends Error {
|
|
104
|
+
constructor(status, message) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.status = status;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The game's folder has no history to read: it is not in a git repository,
|
|
111
|
+
// or git is not installed. A normal state, answered as data rather than as an
|
|
112
|
+
// error.
|
|
113
|
+
export class NoRepository extends HistoryError {
|
|
114
|
+
constructor(message) {
|
|
115
|
+
super(404, message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A path the page asked about, checked: relative to the game's folder,
|
|
120
|
+
// forward slashes, no empty, `.` or `..` segment, nothing hidden (a segment
|
|
121
|
+
// starting with `.`, which covers `.git`, `.env` and `.joystack`), no control
|
|
122
|
+
// characters. Returns the path or throws a 400.
|
|
123
|
+
export function checkPath(p) {
|
|
124
|
+
if (typeof p !== 'string' || !p || p.length > MAX_PATH_LENGTH) throw new HistoryError(400, 'a path must be a non-empty string');
|
|
125
|
+
if (/[\u0000-\u001f\u007f\\]/.test(p)) throw new HistoryError(400, `path ${JSON.stringify(p)} has a control character or a backslash`);
|
|
126
|
+
if (p.startsWith('/')) throw new HistoryError(400, `path ${JSON.stringify(p)} must be relative to the game's folder`);
|
|
127
|
+
for (const seg of p.split('/')) {
|
|
128
|
+
if (!seg || seg === '.' || seg === '..' || seg.startsWith('.')) {
|
|
129
|
+
throw new HistoryError(400, `path ${JSON.stringify(p)} must be a plain path inside the game's folder`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return p;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A commit as written in a URL, checked for shape (not yet for existence).
|
|
136
|
+
export function checkCommit(text) {
|
|
137
|
+
const t = String(text ?? '').toLowerCase();
|
|
138
|
+
if (!COMMIT_RE.test(t)) throw new HistoryError(400, 'a commit is named by its hex id, 7 to 64 digits');
|
|
139
|
+
return t;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createHistory({
|
|
143
|
+
projectDir, tmpRoot = tmpdir(), maxExtractions = MAX_EXTRACTIONS, maxAgeMs = MAX_EXTRACTION_AGE_MS,
|
|
144
|
+
sweepEveryMs = 60 * 1000, log = () => {},
|
|
145
|
+
}) {
|
|
146
|
+
let repoInfo = null; // { top, prefix } once known
|
|
147
|
+
const resolved = new Map(); // commit text → full id
|
|
148
|
+
const extractions = new Map(); // full id → { dir, ready, lastUsed, busy }
|
|
149
|
+
let root = null;
|
|
150
|
+
let closed = false;
|
|
151
|
+
let queue = Promise.resolve(); // the extraction before the next one
|
|
152
|
+
let running = 0;
|
|
153
|
+
// Extractions past their age are removed on a timer too, not only when
|
|
154
|
+
// another commit is asked for. Unref'd: it never keeps the process alive.
|
|
155
|
+
const sweeper = setInterval(() => expire(), sweepEveryMs);
|
|
156
|
+
sweeper.unref?.();
|
|
157
|
+
|
|
158
|
+
// Where the game's folder sits in its repository. Asked again until it
|
|
159
|
+
// succeeds, so a folder made a repository mid-session is picked up.
|
|
160
|
+
async function repo() {
|
|
161
|
+
if (repoInfo) return repoInfo;
|
|
162
|
+
let out;
|
|
163
|
+
try {
|
|
164
|
+
out = await git(projectDir, ['rev-parse', '--show-toplevel', '--show-prefix']);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
throw new NoRepository(err.code === 'ENOENT'
|
|
167
|
+
? 'git is not installed, so this game has no history to show'
|
|
168
|
+
: 'this game\'s folder is not in a git repository, so it has no history to show. Commit it and every commit becomes a version.');
|
|
169
|
+
}
|
|
170
|
+
const [top, prefix = ''] = out.split('\n');
|
|
171
|
+
repoInfo = { top, prefix: prefix.replace(/\/+$/, '') };
|
|
172
|
+
return repoInfo;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The commits that touched any of `paths`, newest first, each with which
|
|
176
|
+
// of them it touched, and which of them have changes not committed yet.
|
|
177
|
+
//
|
|
178
|
+
// A folder with no repository answers { git: false, reason } instead.
|
|
179
|
+
async function history(paths) {
|
|
180
|
+
if (!Array.isArray(paths) || !paths.length) throw new HistoryError(400, 'ask about at least one path');
|
|
181
|
+
if (paths.length > MAX_PATHS) throw new HistoryError(400, `ask about at most ${MAX_PATHS} paths`);
|
|
182
|
+
const list = [...new Set(paths.map(checkPath))];
|
|
183
|
+
let prefix;
|
|
184
|
+
try {
|
|
185
|
+
({ prefix } = await repo());
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err instanceof NoRepository) return { git: false, reason: err.message };
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
let head = null;
|
|
191
|
+
try {
|
|
192
|
+
head = (await git(projectDir, ['rev-parse', '--verify', '--quiet', '--end-of-options', 'HEAD^{commit}'])).trim() || null;
|
|
193
|
+
} catch { /* a repository with no commits yet */ }
|
|
194
|
+
const commits = [];
|
|
195
|
+
if (head) {
|
|
196
|
+
const out = await git(projectDir, [
|
|
197
|
+
// Merges are left out: the commits a merge brought in are listed
|
|
198
|
+
// themselves, each with the files it changed, while the merge itself
|
|
199
|
+
// names no file and would read as a version that changed nothing.
|
|
200
|
+
'log', `-n${MAX_COMMITS + 1}`, '--no-merges', '--no-color', '--no-ext-diff', '--no-textconv',
|
|
201
|
+
'--format=%x1e%H%x1f%h%x1f%cI%x1f%an%x1f%s', '--name-only', '--relative',
|
|
202
|
+
'--end-of-options', 'HEAD', '--', ...list,
|
|
203
|
+
]);
|
|
204
|
+
for (const rec of out.split('\x1e').slice(1)) {
|
|
205
|
+
const [first, ...rest] = rec.split('\n');
|
|
206
|
+
const [sha, short, date, author, subject] = first.split('\x1f');
|
|
207
|
+
commits.push({ sha, short, date, author, subject, files: rest.filter(Boolean) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const truncated = commits.length > MAX_COMMITS;
|
|
211
|
+
if (truncated) commits.length = MAX_COMMITS;
|
|
212
|
+
// Status paths are relative to the top of the repository; the page's are
|
|
213
|
+
// relative to the game's folder.
|
|
214
|
+
const status = await git(projectDir, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--', ...list]);
|
|
215
|
+
const at = prefix ? `${prefix}/` : '';
|
|
216
|
+
const changed = status.split('\0').filter(Boolean).map((e) => e.slice(3))
|
|
217
|
+
.map((p) => (at && p.startsWith(at) ? p.slice(at.length) : p));
|
|
218
|
+
return { git: true, head, commits, truncated, working: { changed } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A commit named by hex, as the full id of a commit in this repository.
|
|
222
|
+
async function resolve(text) {
|
|
223
|
+
const t = checkCommit(text);
|
|
224
|
+
let full = resolved.get(t) ?? '';
|
|
225
|
+
if (!full) {
|
|
226
|
+
await repo();
|
|
227
|
+
try {
|
|
228
|
+
full = (await git(projectDir, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${t}^{commit}`])).trim();
|
|
229
|
+
} catch { /* not found, or ambiguous */ }
|
|
230
|
+
if (!/^[0-9a-f]{40,64}$/.test(full)) throw new HistoryError(404, `there is no commit ${t} in this game's repository`);
|
|
231
|
+
if (resolved.size > 256) resolved.clear();
|
|
232
|
+
resolved.set(t, full);
|
|
233
|
+
}
|
|
234
|
+
// Only the history that led to what is checked out is a version. A commit
|
|
235
|
+
// on a branch never merged, or one fetched from someone else and never
|
|
236
|
+
// reviewed, is in the object store too, and its code would run in the
|
|
237
|
+
// Assets page if it could be loaded. Asked on every call, never cached:
|
|
238
|
+
// HEAD moves (a checkout, a reset) while the dev server runs, and a
|
|
239
|
+
// commit that was a version a moment ago may no longer be one.
|
|
240
|
+
try {
|
|
241
|
+
await git(projectDir, ['merge-base', '--is-ancestor', '--end-of-options', full, 'HEAD']);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new HistoryError(404, `commit ${t} is not in the history of what is checked out, so it is not a version of this game`);
|
|
244
|
+
}
|
|
245
|
+
return full;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ensureRoot() {
|
|
249
|
+
if (root) return root;
|
|
250
|
+
sweepAbandoned(tmpRoot, log);
|
|
251
|
+
root = mkdtempSync(join(tmpRoot, `${ROOT_PREFIX}${process.pid}-`));
|
|
252
|
+
return root;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Forget extractions unused for longer than maxAgeMs, then the least
|
|
256
|
+
// recently used until there is room for one more.
|
|
257
|
+
function expire() {
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
for (const [id, e] of [...extractions.entries()]) {
|
|
260
|
+
if (e.busy || now - e.lastUsed <= maxAgeMs) continue;
|
|
261
|
+
extractions.delete(id);
|
|
262
|
+
removeTree(e.dir);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function evict() {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
const idle = [...extractions.entries()].filter(([, e]) => !e.busy).sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
|
269
|
+
for (const [id, e] of idle) {
|
|
270
|
+
if (now - e.lastUsed > maxAgeMs || extractions.size >= maxExtractions) {
|
|
271
|
+
extractions.delete(id);
|
|
272
|
+
removeTree(e.dir);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The directory holding the game's folder at `full` (a resolved commit),
|
|
278
|
+
// extracted the first time it is asked for. Concurrent requests for the
|
|
279
|
+
// same commit share one extraction.
|
|
280
|
+
async function extraction(full) {
|
|
281
|
+
if (closed) throw new HistoryError(503, 'the dev server is stopping');
|
|
282
|
+
const have = extractions.get(full);
|
|
283
|
+
if (have) {
|
|
284
|
+
have.lastUsed = Date.now();
|
|
285
|
+
return have.ready;
|
|
286
|
+
}
|
|
287
|
+
evict();
|
|
288
|
+
const dir = join(ensureRoot(), full);
|
|
289
|
+
const e = { dir, lastUsed: Date.now(), busy: true, ready: null };
|
|
290
|
+
// One extraction at a time: each is a git process writing up to the whole
|
|
291
|
+
// game's folder, and several commits asked for at once would run them all
|
|
292
|
+
// side by side. Later ones wait their turn.
|
|
293
|
+
const turn = queue;
|
|
294
|
+
e.ready = turn.then(async () => {
|
|
295
|
+
if (closed) throw new HistoryError(503, 'the dev server is stopping');
|
|
296
|
+
running += 1;
|
|
297
|
+
try {
|
|
298
|
+
return await extract();
|
|
299
|
+
} finally {
|
|
300
|
+
running -= 1;
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
queue = e.ready.catch(() => {});
|
|
304
|
+
const extract = async () => {
|
|
305
|
+
const { top, prefix } = await repo();
|
|
306
|
+
const tree = prefix ? `${full}:${prefix}` : `${full}^{tree}`;
|
|
307
|
+
let kind = '';
|
|
308
|
+
try {
|
|
309
|
+
kind = (await git(top, ['cat-file', '-t', '--end-of-options', tree])).trim();
|
|
310
|
+
} catch { /* the folder did not exist then */ }
|
|
311
|
+
if (kind !== 'tree') throw new HistoryError(404, `the game's folder did not exist at commit ${full.slice(0, 12)}`);
|
|
312
|
+
mkdirSync(dir, { recursive: true });
|
|
313
|
+
await archiveInto(top, tree, dir);
|
|
314
|
+
// Closing removes the extractions root, but files still arriving from
|
|
315
|
+
// the archive recreate it; an extraction that outlived close() is
|
|
316
|
+
// removed here, root included.
|
|
317
|
+
if (closed) throw new HistoryError(503, 'the dev server is stopping');
|
|
318
|
+
makeReadOnly(dir);
|
|
319
|
+
return dir;
|
|
320
|
+
};
|
|
321
|
+
extractions.set(full, e);
|
|
322
|
+
try {
|
|
323
|
+
return await e.ready;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (extractions.get(full) === e) extractions.delete(full);
|
|
326
|
+
removeTree(dir);
|
|
327
|
+
if (closed) removeTree(dirname(dir));
|
|
328
|
+
throw err;
|
|
329
|
+
} finally {
|
|
330
|
+
e.busy = false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function close() {
|
|
335
|
+
closed = true;
|
|
336
|
+
clearInterval(sweeper);
|
|
337
|
+
extractions.clear();
|
|
338
|
+
if (root) removeTree(root);
|
|
339
|
+
root = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
repo, history, resolve, extraction, close,
|
|
344
|
+
// For tests: the extractions held now, by full id.
|
|
345
|
+
held: () => [...extractions.keys()],
|
|
346
|
+
// For tests: how many extractions are running right now.
|
|
347
|
+
running: () => running,
|
|
348
|
+
get root() { return root; },
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// `git archive --format=tar <tree>` from the repository top, read as it
|
|
353
|
+
// streams and written into `dir`.
|
|
354
|
+
function archiveInto(top, tree, dir) {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const child = spawn('git', [...GIT_PRE, 'archive', '--format=tar', '--end-of-options', tree], {
|
|
357
|
+
cwd: top, env: gitEnv(), windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
358
|
+
});
|
|
359
|
+
const untar = createUntar(dir);
|
|
360
|
+
let stderr = '';
|
|
361
|
+
let failed = null;
|
|
362
|
+
const fail = (err) => {
|
|
363
|
+
if (failed) return;
|
|
364
|
+
failed = err;
|
|
365
|
+
child.kill();
|
|
366
|
+
};
|
|
367
|
+
const timer = setTimeout(() => fail(new HistoryError(504, 'extracting that commit took too long')), ARCHIVE_TIMEOUT_MS);
|
|
368
|
+
timer.unref?.();
|
|
369
|
+
child.stdout.on('data', (chunk) => {
|
|
370
|
+
if (failed) return;
|
|
371
|
+
try { untar.push(chunk); } catch (err) { fail(err); }
|
|
372
|
+
});
|
|
373
|
+
child.stderr.on('data', (c) => { if (stderr.length < 4000) stderr += c; });
|
|
374
|
+
child.on('error', (err) => fail(err.code === 'ENOENT' ? new HistoryError(503, 'git is not installed') : err));
|
|
375
|
+
child.on('close', (code) => {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
try { untar.end(); } catch (err) { failed ??= err; }
|
|
378
|
+
if (failed) return reject(failed);
|
|
379
|
+
if (code !== 0) return reject(new HistoryError(500, `git archive failed: ${stderr.trim() || `exit ${code}`}`));
|
|
380
|
+
resolve();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// A streaming reader for the tar format git archive writes (POSIX ustar with
|
|
386
|
+
// pax extended headers for long names). Regular files and folders are
|
|
387
|
+
// written; everything else (links, devices, the global header carrying the
|
|
388
|
+
// commit id) is skipped. A name that would land outside `dir` is an error.
|
|
389
|
+
export function createUntar(dir) {
|
|
390
|
+
const rootDir = normalize(dir);
|
|
391
|
+
let buf = Buffer.alloc(0);
|
|
392
|
+
let state = 'header';
|
|
393
|
+
let remaining = 0;
|
|
394
|
+
let pad = 0;
|
|
395
|
+
let fd = null;
|
|
396
|
+
let paxData = null; // collected body of an 'x' or 'L' header
|
|
397
|
+
let paxKind = null;
|
|
398
|
+
let nextName = null; // a long name from a pax or GNU header, for the next entry
|
|
399
|
+
let written = 0;
|
|
400
|
+
let entries = 0;
|
|
401
|
+
|
|
402
|
+
const hidden = (name) => name.replace(/^\.\/+/, '').split('/').some((seg) => seg.startsWith('.'));
|
|
403
|
+
const target = (name) => {
|
|
404
|
+
const clean = name.replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
405
|
+
if (!clean) return null;
|
|
406
|
+
if (clean.includes('\0') || clean.includes('\\') || clean.startsWith('/')) throw new HistoryError(500, `the archive holds an unsafe name ${JSON.stringify(clean)}`);
|
|
407
|
+
if (clean.split('/').some((s) => s === '..' || s === '')) throw new HistoryError(500, `the archive holds an unsafe name ${JSON.stringify(clean)}`);
|
|
408
|
+
const out = normalize(join(rootDir, clean));
|
|
409
|
+
if (!out.startsWith(rootDir + sep)) throw new HistoryError(500, `the archive holds a name outside its folder ${JSON.stringify(clean)}`);
|
|
410
|
+
return out;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const octal = (b, start, len) => {
|
|
414
|
+
if (b[start] & 0x80) throw new HistoryError(500, 'the archive holds a file too large to extract');
|
|
415
|
+
const s = b.toString('latin1', start, start + len).replace(/\0.*$/s, '').trim();
|
|
416
|
+
if (s && !/^[0-7]+$/.test(s)) throw new HistoryError(500, 'the archive holds a header with an unreadable size');
|
|
417
|
+
return s ? parseInt(s, 8) : 0;
|
|
418
|
+
};
|
|
419
|
+
// The header checksum: the sum of its bytes with the checksum field read as
|
|
420
|
+
// spaces. A block that fails it is not a tar header, and reading on would
|
|
421
|
+
// treat file contents as headers.
|
|
422
|
+
const checksumOk = (h) => {
|
|
423
|
+
let sum = 0;
|
|
424
|
+
for (let i = 0; i < 512; i += 1) sum += i >= 148 && i < 156 ? 0x20 : h[i];
|
|
425
|
+
const stated = h.toString('latin1', 148, 156).replace(/[\0 ]+$/g, '').trim();
|
|
426
|
+
return /^[0-7]+$/.test(stated) && parseInt(stated, 8) === sum;
|
|
427
|
+
};
|
|
428
|
+
const str = (b, start, len) => {
|
|
429
|
+
const end = b.indexOf(0, start);
|
|
430
|
+
return b.toString('utf8', start, end >= 0 && end < start + len ? end : start + len);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
function header(h) {
|
|
434
|
+
if (h.every((x) => x === 0)) return; // an end-of-archive block
|
|
435
|
+
if (!checksumOk(h)) throw new HistoryError(500, 'the archive holds a header whose checksum does not match');
|
|
436
|
+
entries += 1;
|
|
437
|
+
if (entries > MAX_ARCHIVE_ENTRIES) throw new HistoryError(500, 'the archive holds too many files');
|
|
438
|
+
const size = octal(h, 124, 12);
|
|
439
|
+
const type = String.fromCharCode(h[156] || 0x30);
|
|
440
|
+
let name = str(h, 0, 100);
|
|
441
|
+
const prefix = h.toString('latin1', 257, 262) === 'ustar' ? str(h, 345, 155) : '';
|
|
442
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
443
|
+
if (nextName != null) {
|
|
444
|
+
name = nextName;
|
|
445
|
+
nextName = null;
|
|
446
|
+
}
|
|
447
|
+
remaining = size;
|
|
448
|
+
pad = (512 - (size % 512)) % 512;
|
|
449
|
+
if (type === 'x' || type === 'L') {
|
|
450
|
+
paxKind = type;
|
|
451
|
+
paxData = [];
|
|
452
|
+
state = 'meta';
|
|
453
|
+
} else if ((type === '0' || type === '7') && !hidden(name)) {
|
|
454
|
+
const out = target(name);
|
|
455
|
+
if (!out) throw new HistoryError(500, 'the archive holds a file with no name');
|
|
456
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
457
|
+
fd = openSync(out, 'wx', 0o644);
|
|
458
|
+
state = 'file';
|
|
459
|
+
} else {
|
|
460
|
+
// Links of every kind, and anything hidden (`.env`, `.joystack`, …), which
|
|
461
|
+
// the dev server never serves from history, are skipped unwritten.
|
|
462
|
+
if (type === '5' && !hidden(name)) {
|
|
463
|
+
const out = target(name);
|
|
464
|
+
if (out) mkdirSync(out, { recursive: true });
|
|
465
|
+
}
|
|
466
|
+
state = 'skip';
|
|
467
|
+
}
|
|
468
|
+
if (remaining === 0) finishBody();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function finishBody() {
|
|
472
|
+
if (state === 'file') {
|
|
473
|
+
closeSync(fd);
|
|
474
|
+
fd = null;
|
|
475
|
+
} else if (state === 'meta') {
|
|
476
|
+
const body = Buffer.concat(paxData);
|
|
477
|
+
if (paxKind === 'L') nextName = body.toString('utf8').replace(/\0.*$/s, '');
|
|
478
|
+
else {
|
|
479
|
+
// Records are "<length> <key>=<value>\n", where the length counts
|
|
480
|
+
// bytes, so the walk is over the buffer: a name with multibyte
|
|
481
|
+
// characters is shorter in characters than its stated length.
|
|
482
|
+
let i = 0;
|
|
483
|
+
while (i < body.length) {
|
|
484
|
+
const space = body.indexOf(0x20, i);
|
|
485
|
+
if (space < 0) break;
|
|
486
|
+
const len = parseInt(body.toString('latin1', i, space), 10);
|
|
487
|
+
if (!(len > 0) || i + len > body.length) break;
|
|
488
|
+
const rec = body.toString('utf8', space + 1, i + len - 1);
|
|
489
|
+
const eq = rec.indexOf('=');
|
|
490
|
+
const key = rec.slice(0, eq);
|
|
491
|
+
if (key === 'path') nextName = rec.slice(eq + 1);
|
|
492
|
+
// A size in a pax record replaces the header's, for a file over
|
|
493
|
+
// 8 GiB; no game file is that large, and reading on with the
|
|
494
|
+
// header's size would treat the file's contents as headers.
|
|
495
|
+
if (key === 'size') throw new HistoryError(500, 'the archive holds a file too large to extract');
|
|
496
|
+
i += len;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
paxData = null;
|
|
500
|
+
}
|
|
501
|
+
state = pad ? 'pad' : 'header';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
push(chunk) {
|
|
506
|
+
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
|
|
507
|
+
let at = 0;
|
|
508
|
+
for (;;) {
|
|
509
|
+
if (state === 'header') {
|
|
510
|
+
if (buf.length - at < 512) break;
|
|
511
|
+
const h = buf.subarray(at, at + 512);
|
|
512
|
+
at += 512;
|
|
513
|
+
header(h);
|
|
514
|
+
} else if (state === 'pad') {
|
|
515
|
+
const n = Math.min(pad, buf.length - at);
|
|
516
|
+
at += n;
|
|
517
|
+
pad -= n;
|
|
518
|
+
if (pad === 0) state = 'header';
|
|
519
|
+
else break;
|
|
520
|
+
} else {
|
|
521
|
+
const n = Math.min(remaining, buf.length - at);
|
|
522
|
+
if (n === 0 && remaining > 0) break;
|
|
523
|
+
const piece = buf.subarray(at, at + n);
|
|
524
|
+
if (state === 'file') {
|
|
525
|
+
written += n;
|
|
526
|
+
if (written > MAX_ARCHIVE_BYTES) throw new HistoryError(500, 'the game\'s folder at that commit is too large to extract');
|
|
527
|
+
writeSync(fd, piece);
|
|
528
|
+
} else if (state === 'meta') {
|
|
529
|
+
if (paxData.reduce((s, b) => s + b.length, 0) + n > 1024 * 1024) throw new HistoryError(500, 'the archive holds an oversized header');
|
|
530
|
+
paxData.push(Buffer.from(piece));
|
|
531
|
+
}
|
|
532
|
+
at += n;
|
|
533
|
+
remaining -= n;
|
|
534
|
+
if (remaining === 0) finishBody();
|
|
535
|
+
else break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
buf = at >= buf.length ? Buffer.alloc(0) : Buffer.from(buf.subarray(at));
|
|
539
|
+
},
|
|
540
|
+
end() {
|
|
541
|
+
if (fd != null) {
|
|
542
|
+
closeSync(fd);
|
|
543
|
+
fd = null;
|
|
544
|
+
}
|
|
545
|
+
if (state === 'file' || state === 'meta' || (state === 'header' && buf.length > 0)) {
|
|
546
|
+
throw new HistoryError(500, 'the archive ended in the middle of a file');
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Files read-only, then folders, deepest first.
|
|
553
|
+
function makeReadOnly(dir) {
|
|
554
|
+
for (const name of readdirSync(dir)) {
|
|
555
|
+
const p = join(dir, name);
|
|
556
|
+
const st = lstatSync(p);
|
|
557
|
+
if (st.isDirectory()) makeReadOnly(p);
|
|
558
|
+
else if (st.isFile()) chmodSync(p, 0o444);
|
|
559
|
+
}
|
|
560
|
+
chmodSync(dir, 0o555);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Folders made writable again so their contents can be removed, then removed.
|
|
564
|
+
function removeTree(dir) {
|
|
565
|
+
const writable = (d) => {
|
|
566
|
+
try {
|
|
567
|
+
const st = lstatSync(d);
|
|
568
|
+
if (!st.isDirectory()) return;
|
|
569
|
+
chmodSync(d, 0o755);
|
|
570
|
+
for (const name of readdirSync(d)) writable(join(d, name));
|
|
571
|
+
} catch { /* gone already */ }
|
|
572
|
+
};
|
|
573
|
+
writable(dir);
|
|
574
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* left for the next sweep */ }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Remove extraction roots left in the temp folder by dev servers that are no
|
|
578
|
+
// longer running (a crash, a killed terminal). Only folders this module names,
|
|
579
|
+
// owned by this user, whose process id names no running process.
|
|
580
|
+
export function sweepAbandoned(tmpRoot, log = () => {}) {
|
|
581
|
+
let names;
|
|
582
|
+
try {
|
|
583
|
+
names = readdirSync(tmpRoot);
|
|
584
|
+
} catch {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const uid = process.getuid?.();
|
|
588
|
+
for (const name of names) {
|
|
589
|
+
const m = new RegExp(`^${ROOT_PREFIX}(\\d+)-`).exec(name);
|
|
590
|
+
if (!m) continue;
|
|
591
|
+
const pid = Number(m[1]);
|
|
592
|
+
if (pid === process.pid || alive(pid)) continue;
|
|
593
|
+
const p = join(tmpRoot, name);
|
|
594
|
+
try {
|
|
595
|
+
const st = lstatSync(p);
|
|
596
|
+
if (!st.isDirectory() || (uid != null && st.uid !== uid)) continue;
|
|
597
|
+
} catch {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
removeTree(p);
|
|
601
|
+
if (!existsSync(p)) log(`history: removed ${p}, left by a dev server that is no longer running`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function alive(pid) {
|
|
606
|
+
try {
|
|
607
|
+
process.kill(pid, 0);
|
|
608
|
+
return true;
|
|
609
|
+
} catch (err) {
|
|
610
|
+
return err.code === 'EPERM';
|
|
611
|
+
}
|
|
612
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// `joystack inbox reply <id> "<text>"`: the agent's answer to a report a dev
|
|
2
|
+
// tool sent it, shown back in that tool. Each running dev server writes its
|
|
3
|
+
// port and a bearer token to .joystack/agent-inbox/.connection-<port>
|
|
4
|
+
// (owner-only), so only a process that can read the game folder can reply.
|
|
5
|
+
// With several servers for one game, the reply goes to whichever took the
|
|
6
|
+
// report; the others answer 404 and are skipped.
|
|
7
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { stateDir } from './legacy-names.mjs';
|
|
10
|
+
|
|
11
|
+
export const INBOX_HELP = `joystack inbox reply [--dir <game>] <id> "<text>"
|
|
12
|
+
Answer a report a dev tool sent you. The id and the exact command are in
|
|
13
|
+
the message the report arrived with; the text appears in the tool.`;
|
|
14
|
+
|
|
15
|
+
export function connectionFile(projectDir, port) {
|
|
16
|
+
return join(stateDir(projectDir), 'agent-inbox', `.connection-${port}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function connections(dir) {
|
|
20
|
+
let names;
|
|
21
|
+
try { names = readdirSync(join(stateDir(dir), 'agent-inbox')); } catch { return []; }
|
|
22
|
+
return names.filter((name) => /^\.connection-\d+$/.test(name)).flatMap((name) => {
|
|
23
|
+
try {
|
|
24
|
+
const { port, token } = JSON.parse(readFileSync(join(stateDir(dir), 'agent-inbox', name), 'utf8'));
|
|
25
|
+
return Number.isInteger(port) && port > 0 && port < 65536 && typeof token === 'string' ? [{ port, token }] : [];
|
|
26
|
+
} catch { return []; }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function inboxReply({ dir = process.cwd(), id, text }) {
|
|
31
|
+
if (!id || !text?.trim()) throw new Error(`Usage: ${INBOX_HELP}`);
|
|
32
|
+
let reached = false;
|
|
33
|
+
for (const { port, token } of connections(dir)) {
|
|
34
|
+
let res;
|
|
35
|
+
try {
|
|
36
|
+
// Always loopback: the credential names a port, never an address.
|
|
37
|
+
res = await fetch(`http://127.0.0.1:${port}/__joystack/agent-inbox/reply`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ id, text }),
|
|
41
|
+
signal: AbortSignal.timeout(5000),
|
|
42
|
+
});
|
|
43
|
+
} catch { continue; } // that server has stopped
|
|
44
|
+
reached = true;
|
|
45
|
+
if (res.ok) {
|
|
46
|
+
console.log('Reply shown in the tool.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (res.status === 404) continue; // another server's report
|
|
50
|
+
// Whatever answers on that port wrote the body; it is reported by status
|
|
51
|
+
// only, never echoed.
|
|
52
|
+
throw new Error(`The dev server on port ${port} refused the reply (HTTP ${res.status}).`);
|
|
53
|
+
}
|
|
54
|
+
throw new Error(reached
|
|
55
|
+
? `No running 'joystack dev' for ${dir} knows report ${id}; it was taken by a server that has since restarted.`
|
|
56
|
+
: `No running 'joystack dev' has received a report for ${dir}.`);
|
|
57
|
+
}
|