@joystack/cli 0.1.55 → 0.1.56
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 +38 -0
- package/bin/joystack.mjs +1 -2
- package/lib/assets-export.mjs +17 -5
- package/lib/assets-page.mjs +17 -12
- package/lib/dev.mjs +7 -2
- package/lib/engine.mjs +35 -2
- package/lib/fingerprint/README.md +6 -0
- package/lib/fingerprint/fingerprint.mjs +41 -1
- package/lib/inject.mjs +20 -16
- package/lib/lint.mjs +15 -0
- package/lib/mime.mjs +28 -0
- package/lib/relay/cli.mjs +37 -88
- package/lib/relay/connection.mjs +2 -3
- package/lib/relay/conversation.mjs +15 -15
- package/lib/relay/engine.mjs +15 -0
- package/lib/relay/http.mjs +57 -0
- package/lib/relay/relay.mjs +267 -257
- package/lib/reload-client.mjs +58 -0
- package/lib/room-server.mjs +10 -0
- package/lib/shot/README.md +1 -1
- package/lib/start-failed.mjs +110 -0
- package/lib/static-server.mjs +91 -99
- package/lib/test-cmd.mjs +12 -0
- package/package.json +3 -2
package/lib/relay/connection.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The relay's credential for the agent: each running dev server writes its port
|
|
2
2
|
// and a bearer token to .joystack/relay/.connection-<port>, owner-only, so
|
|
3
|
-
// only a process that can read the game folder can
|
|
4
|
-
// agent. `joystack relay` reads every file there and speaks to each server.
|
|
3
|
+
// only a process that can read the game folder can reply as the agent. `joystack relay` reads every file there and speaks to each server.
|
|
5
4
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { stateDir } from '../legacy-names.mjs';
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// The laptop conversation: a note the person sent (shared/ui/note/, once
|
|
2
|
+
// they pressed Send) becomes files the agent can open under .joystack/relay/
|
|
3
|
+
// and a message in the Claude Code session that started this dev server,
|
|
4
|
+
// through that session's inbox socket (agent-session.mjs). Made only when a
|
|
5
|
+
// session started the server; a server from a plain terminal has none, and
|
|
6
|
+
// the maker's dev server has none either (its conversation is the maker
|
|
7
|
+
// page's).
|
|
8
8
|
//
|
|
9
|
-
// Reaching the agent starts paid work, so only a note the
|
|
10
|
-
//
|
|
11
|
-
// page a phone on the wifi or a --share tester
|
|
9
|
+
// Reaching the agent starts paid work, so only a note the creator's own
|
|
10
|
+
// browser sent reaches here: the relay's route refuses any other page
|
|
11
|
+
// (relay.mjs), and a page a phone on the wifi or a --share tester
|
|
12
|
+
// loaded is never the creator's.
|
|
12
13
|
import { randomBytes } from 'node:crypto';
|
|
13
14
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
import { postToSession, sessionAlive } from '../agent-session.mjs';
|
|
16
17
|
import { relayDir } from './connection.mjs';
|
|
17
18
|
|
|
18
|
-
// A page can
|
|
19
|
+
// A page can send notes in a loop. The agent is woken at most this often: a note
|
|
19
20
|
// sent sooner waits its turn, and once this many are waiting, the rest are
|
|
20
21
|
// filed without waking it (a burst that size is a script, not someone typing).
|
|
21
22
|
export const WAKE_MIN_INTERVAL_MS = 1000;
|
|
@@ -41,12 +42,12 @@ export function shellWord(text) {
|
|
|
41
42
|
// so no line of it can read as one the server wrote (a second header, a
|
|
42
43
|
// different reply command).
|
|
43
44
|
const LINE_BREAK_RE = /\r\n?|[\u0085\u2028\u2029]/g;
|
|
44
|
-
const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n');
|
|
45
|
+
const asLines = (text) => String(text).replace(LINE_BREAK_RE, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u0084\u0086-\u009f]/g, '').split('\n');
|
|
45
46
|
const indented = (text) => asLines(text).map((l) => ` ${l}`).join('\n');
|
|
46
47
|
// A label is one line: JSON.stringify escapes \n and \r but not the Unicode
|
|
47
48
|
// separators, so they are made spaces first.
|
|
48
49
|
const oneLine = (text) => asLines(text).join(' ');
|
|
49
|
-
// The kind of thing attached is
|
|
50
|
+
// The kind of thing attached is a relay name (`<owner>.<what>`) or nothing.
|
|
50
51
|
const KIND_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/;
|
|
51
52
|
|
|
52
53
|
// The message that wakes the agent. Everything the server writes comes
|
|
@@ -58,8 +59,7 @@ export function agentMessage({ kind, label, words, text, file, image, id, projec
|
|
|
58
59
|
if (words) lines.push(indented(words));
|
|
59
60
|
lines.push(`Data: ${file}`);
|
|
60
61
|
if (image) lines.push(`Picture of what they see: ${image}`);
|
|
61
|
-
|
|
62
|
-
lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay publish --dir ${shellWord(projectDir)} agent.replied ${shellWord(reply)}`);
|
|
62
|
+
lines.push(`Answer where they asked, in one or two sentences: node ${shellWord(cliBin)} relay reply --dir ${shellWord(projectDir)} ${id} '<what you did>'`);
|
|
63
63
|
lines.push('', ...asLines(String(text).trim()).map((line) => `> ${line}`));
|
|
64
64
|
return lines.join('\n');
|
|
65
65
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The folder of the engine's relay the dev server loads its pure files from:
|
|
2
|
+
// the game's installed engine, or this CLI's own repository when it runs
|
|
3
|
+
// from inside it. Null when that engine has no relay with `files` in it.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export function relayFiles(sharedDir, files) {
|
|
8
|
+
// The repository's own relay only from a checkout of it: an installed CLI
|
|
9
|
+
// sits in node_modules, where the same path names a package called
|
|
10
|
+
// `shared`.
|
|
11
|
+
const repo = join(import.meta.dirname, '../../../..');
|
|
12
|
+
const fromRepo = existsSync(join(repo, 'packages', 'cli', 'package.json')) ? join(repo, 'shared', 'ui', 'relay') : null;
|
|
13
|
+
const candidates = [sharedDir && join(sharedDir, 'ui', 'relay'), fromRepo];
|
|
14
|
+
return candidates.find((dir) => dir && files.every((f) => existsSync(join(dir, f)))) ?? null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The relay's HTTP pieces on the dev server: answers, a bounded body, and
|
|
2
|
+
// the agent's bearer token.
|
|
3
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// What one post may weigh: a pick carries its picture.
|
|
6
|
+
export const MAX_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
8
|
+
|
|
9
|
+
export const json = (res, status, body, extra = {}) => {
|
|
10
|
+
const buf = Buffer.from(JSON.stringify(body));
|
|
11
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store', ...extra });
|
|
12
|
+
res.end(buf);
|
|
13
|
+
};
|
|
14
|
+
export const text = (res, status, body, extra = {}) => {
|
|
15
|
+
res.writeHead(status, { 'Content-Type': 'text/plain', ...extra });
|
|
16
|
+
res.end(body);
|
|
17
|
+
};
|
|
18
|
+
// The agent's credential: a bearer token from a process on this machine,
|
|
19
|
+
// never a browser (which always sends Origin on a cross-origin request and
|
|
20
|
+
// cannot set this header from a page the server did not serve). 'agent'
|
|
21
|
+
// when it is the server's token, 'refused' when a bearer was offered and is
|
|
22
|
+
// not, null when none was offered.
|
|
23
|
+
export function bearer(req, token) {
|
|
24
|
+
const header = String(req.headers.authorization ?? '');
|
|
25
|
+
if (!header.startsWith('Bearer ')) return null;
|
|
26
|
+
const supplied = Buffer.from(header.slice(7));
|
|
27
|
+
const expected = Buffer.from(token);
|
|
28
|
+
const ok = LOOPBACK.has(req.socket.remoteAddress) && !req.headers.origin
|
|
29
|
+
&& supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
30
|
+
return ok ? 'agent' : 'refused';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readBody(req, res, maxBytes) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
let overflowed = false;
|
|
38
|
+
req.on('data', (chunk) => {
|
|
39
|
+
if (overflowed) return; // draining the rest so the client can read the 413
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > maxBytes) {
|
|
42
|
+
overflowed = true;
|
|
43
|
+
chunks.length = 0;
|
|
44
|
+
text(res, 413, `message too large (max ${maxBytes} bytes)`, { Connection: 'close' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
});
|
|
49
|
+
req.on('end', () => resolve(overflowed ? null : Buffer.concat(chunks).toString('utf8')));
|
|
50
|
+
// A client that goes before the body ends is answered by nobody; the
|
|
51
|
+
// promise still settles, so nothing waits on it for good.
|
|
52
|
+
req.on('close', () => { if (!req.complete) resolve(null); });
|
|
53
|
+
req.on('error', () => resolve(null));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export const parse = (raw) => { try { return JSON.parse(raw); } catch { return undefined; } };
|
|
57
|
+
|