@joystack/cli 0.1.54 → 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/lib/maker.mjs CHANGED
@@ -24,8 +24,8 @@ export const MAKER_LANE =
24
24
 
25
25
  export const MAKER_DEV =
26
26
  "You are in the Joystack maker: this folder's dev server is already running, and the " +
27
- 'person plays the game on their page, beside the chat. Every file you save shows up ' +
28
- 'there. Nothing was started.';
27
+ 'person plays the game on their page, beside the chat (on a phone, under Play). Every file you save shows up ' +
28
+ 'there. Nothing was started. There is no link to give them: to hand the game over, tell them it is ready to play on their page.';
29
29
 
30
30
  // `publish` and `land` both do what the page's Publish button does: the draft
31
31
  // goes into the game, and the game goes online under the name it has. The
@@ -61,3 +61,17 @@ export async function makerPublish(at, { slug = null, log = console.log, fetchIm
61
61
  );
62
62
  return body;
63
63
  }
64
+
65
+ // The port of this draft's dev server on the maker's machine, starting it if
66
+ // it sleeps (`POST <JOYSTACK_MAKER>/serve`, which answers `{ port }`), or null
67
+ // when the maker does not say. A draft's server is on a port of the maker's
68
+ // choosing, which the ports `joystack dev` takes on a laptop do not include.
69
+ export async function makerDevPort(at, { fetchImpl = fetch } = {}) {
70
+ try {
71
+ const res = await fetchImpl(`${at}/serve`, { method: 'POST', signal: AbortSignal.timeout(60_000) });
72
+ const port = res.ok ? Number((await res.json())?.port) : NaN;
73
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
package/lib/mime.mjs ADDED
@@ -0,0 +1,28 @@
1
+ // The content type the dev server sends for a file, by its extension.
2
+ export const MIME = {
3
+ '.html': 'text/html; charset=utf-8',
4
+ '.js': 'text/javascript; charset=utf-8',
5
+ '.mjs': 'text/javascript; charset=utf-8',
6
+ '.css': 'text/css; charset=utf-8',
7
+ '.json': 'application/json; charset=utf-8',
8
+ '.svg': 'image/svg+xml',
9
+ '.png': 'image/png',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.webp': 'image/webp',
14
+ '.ico': 'image/x-icon',
15
+ '.wasm': 'application/wasm',
16
+ '.mp3': 'audio/mpeg',
17
+ '.ogg': 'audio/ogg',
18
+ '.wav': 'audio/wav',
19
+ '.m4a': 'audio/mp4',
20
+ '.flac': 'audio/flac',
21
+ '.txt': 'text/plain; charset=utf-8',
22
+ '.md': 'text/markdown; charset=utf-8',
23
+ '.woff': 'font/woff',
24
+ '.woff2': 'font/woff2',
25
+ '.glb': 'model/gltf-binary',
26
+ '.gltf': 'model/gltf+json',
27
+ '.bin': 'application/octet-stream',
28
+ };
package/lib/relay/cli.mjs CHANGED
@@ -1,112 +1,61 @@
1
- // `joystack relay publish` and `joystack relay tail`: the agent's end of the
2
- // relay, from the game folder. Each running dev server for the folder wrote
3
- // its port and a bearer token to .joystack/relay/.connection-<port>
4
- // (connection.mjs), so only a process that can read the folder can speak as
5
- // the agent, and with several servers for one game every one of them hears.
1
+ // `joystack relay reply`: the agent's end of the relay, from the game
2
+ // folder. Each running dev server for the folder wrote its port and a bearer token
3
+ // to .joystack/relay/.connection-<port> (connection.mjs), so only a process
4
+ // that can read the folder can speak as the agent, and with several servers
5
+ // for one game every one of them hears.
6
6
  import { connections } from './connection.mjs';
7
7
 
8
- export const RELAY_HELP = `joystack relay publish [--dir <game>] <name> [<json>]
9
- Put an event on the relay of every running 'joystack dev' for this game, as
10
- the agent: <name> is <publisher>.<what> (agent.replied, agent.show), <json>
11
- its data. The events worth publishing, with their exact command lines, are
12
- in your game's AGENTS.md and the engine's docs.
13
- joystack relay tail [--dir <game>] [<pattern>…]
14
- Print every event on the relay as it happens, one JSON line each, until
15
- stopped. A pattern is a name whose segments may be *; none means every event.`;
8
+ export const RELAY_HELP = `joystack relay reply [--dir <game>] <note id> <text>
9
+ Answer a note the creator sent you from the running game: the text shows
10
+ under their note, even if the game reloaded since. The command line with
11
+ the note's id is in the message the note woke you with.`;
16
12
 
17
- const RELAY = '/__joystack/relay';
13
+ const RELAY_REQUEST = '/__joystack/relay';
18
14
 
19
- // Publish `data` under `name` on every server for `dir`. Resolves the number
20
- // of listeners reached, summed over the servers; throws when no server for
21
- // the folder is running.
22
- export async function relayPublish({ dir = process.cwd(), name, data = null, log = console.log }) {
23
- if (!name) throw new Error(`Usage: ${RELAY_HELP}`);
24
- let reached = 0;
25
- let delivered = 0;
26
- let agent = false;
15
+ // Ask every running dev server for `dir` the agent's `name` with `data`, as
16
+ // the agent. Resolves the answers, one per server reached, a refusal as an
17
+ // error answer; throws when no server for the folder is running.
18
+ async function askAsAgent(dir, name, data) {
19
+ const answers = [];
27
20
  for (const { port, token } of connections(dir)) {
28
21
  let res;
29
22
  try {
30
23
  // Always loopback: the credential names a port, never an address.
31
- res = await fetch(`http://127.0.0.1:${port}${RELAY}`, {
24
+ res = await fetch(`http://127.0.0.1:${port}${RELAY_REQUEST}`, {
32
25
  method: 'POST',
33
26
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
34
27
  body: JSON.stringify({ name, data }),
35
28
  signal: AbortSignal.timeout(5000),
36
29
  });
37
30
  } catch { continue; } // that server has stopped
38
- reached += 1;
39
- if (!res.ok) {
40
- // Whatever answers on that port wrote the body; it is reported by
41
- // status only, never echoed.
42
- throw new Error(`The dev server on port ${port} refused the event (HTTP ${res.status}).`);
43
- }
44
- const out = await res.json().catch(() => ({}));
45
- delivered += Number(out.delivered) || 0;
46
- agent = agent || out.agent === true;
31
+ // Whatever answers on that port wrote the body; a refusal is reported by
32
+ // status only, never echoed. Another server for the same folder (one
33
+ // from an older CLI, say) may still take it.
34
+ if (!res.ok) { answers.push({ ok: false, reason: 'error', message: `The dev server on port ${port} refused it (HTTP ${res.status}).` }); continue; }
35
+ answers.push(await res.json().catch(() => ({ ok: false, reason: 'error', message: 'not an answer' })));
47
36
  }
48
- if (!reached) throw new Error(`No running 'joystack dev' for ${dir}.`);
49
- log(`Published ${name} to ${delivered} listener${delivered === 1 ? '' : 's'}.`);
50
- return { delivered, agent };
37
+ if (!answers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
38
+ return answers;
51
39
  }
52
40
 
53
- // Read one server's stream, calling `onEvent` for each event frame, until
54
- // the stream ends or `signal` aborts.
55
- async function tailOne(port, token, patterns, onEvent, signal) {
56
- const listen = encodeURIComponent(JSON.stringify(patterns.map((p) => [p, false])));
57
- const res = await fetch(`http://127.0.0.1:${port}${RELAY}/stream?listen=${listen}`, { headers: { Authorization: `Bearer ${token}` }, signal });
58
- if (!res.ok) throw new Error(`The dev server on port ${port} refused the stream (HTTP ${res.status}).`);
59
- let buffer = '';
60
- for await (const chunk of res.body) {
61
- buffer += Buffer.from(chunk).toString('utf8');
62
- let at;
63
- while ((at = buffer.indexOf('\n\n')) !== -1) {
64
- const block = buffer.slice(0, at);
65
- buffer = buffer.slice(at + 2);
66
- const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
67
- if (!data) continue;
68
- let frame;
69
- try { frame = JSON.parse(data); } catch { continue; }
70
- if (frame.type === 'event' && frame.event) onEvent(frame.event, port);
71
- }
72
- }
73
- }
74
-
75
- // Tail every server for `dir`. Resolves when every stream has ended (the
76
- // servers stopped, or `signal` aborted); throws when none is running.
77
- export async function relayTail({ dir = process.cwd(), patterns = [], onEvent = (e) => console.log(JSON.stringify(e)), signal = undefined, log = console.error } = {}) {
78
- const servers = connections(dir);
79
- if (!servers.length) throw new Error(`No running 'joystack dev' for ${dir}.`);
80
- const wanted = patterns.length ? patterns : ['*'];
81
- log(`Tailing ${wanted.join(', ')} on ${servers.length} dev server${servers.length === 1 ? '' : 's'} (port${servers.length === 1 ? '' : 's'} ${servers.map((s) => s.port).join(', ')})…`);
82
- const results = await Promise.allSettled(servers.map(({ port, token }) => tailOne(port, token, wanted, onEvent, signal)));
83
- const failed = results.filter((r) => r.status === 'rejected' && r.reason?.name !== 'AbortError');
84
- if (failed.length === results.length) throw failed[0].reason;
41
+ // Answer the note `id` with `text`, on every dev server running for `dir`:
42
+ // each keeps the same file and announces it to the pages it serves.
43
+ export async function relayReply({ dir = process.cwd(), id, text, log = console.log }) {
44
+ if (!id || !text?.trim()) throw new Error(`Usage: ${RELAY_HELP}`);
45
+ const answers = await askAsAgent(dir, 'agent.reply', { to: id, text });
46
+ const kept = answers.filter((a) => a?.ok);
47
+ if (!kept.length) throw new Error(answers[0]?.message || 'The reply was refused.');
48
+ const shown = kept.some((a) => a.value?.shown);
49
+ log(shown ? `Replied to note ${id}; it shows under the note.` : `Replied to note ${id}; it shows under the note when the creator's page is open again.`);
50
+ return { shown };
85
51
  }
86
52
 
87
- // The command line: `relay publish [--dir d] <name> [<json>]` or `relay tail
88
- // [--dir d] [<pattern>…]`.
53
+ // The command line: `relay reply [--dir d] <note id> <text>`.
89
54
  export async function relayCommand(rest, { dir, log = console.log } = {}) {
90
55
  const [verb, ...args] = rest;
91
- if (verb === 'publish') {
92
- const [name, raw] = args;
93
- let data = null;
94
- if (raw !== undefined) {
95
- try { data = JSON.parse(raw); } catch { throw new Error(`The data must be JSON, for example '{"to":"ab12cd34","text":"Done."}'. Usage: ${RELAY_HELP}`); }
96
- }
97
- return relayPublish({ dir, name, data, log });
98
- }
99
- if (verb === 'tail') {
100
- const controller = new AbortController();
101
- const stop = () => controller.abort();
102
- process.once('SIGINT', stop);
103
- process.once('SIGTERM', stop);
104
- try {
105
- return await relayTail({ dir, patterns: args, signal: controller.signal });
106
- } finally {
107
- process.off('SIGINT', stop);
108
- process.off('SIGTERM', stop);
109
- }
56
+ if (verb === 'reply') {
57
+ const [id, ...words] = args;
58
+ return relayReply({ dir, id, text: words.join(' '), log });
110
59
  }
111
60
  throw new Error(`Usage: ${RELAY_HELP}`);
112
61
  }
@@ -1,7 +1,6 @@
1
- // The hub's credential for the agent: each running dev server writes its port
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 publish or tail as the
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 hub's own listener for the laptop conversation: a `note.sent` event
2
- // (the note in shared/ui/note/, once the person pressed Send) becomes files
3
- // the agent can open under .joystack/relay/ and a message in the Claude Code
4
- // session that started this dev server, through that session's inbox socket
5
- // (agent-session.mjs). Installed only when a session started the server; a
6
- // server from a plain terminal has no such listener, and the maker's dev
7
- // server has none either (its conversation is the maker page's).
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 person sent from
10
- // the creator's own browser wakes it: the hub stamps `from.creator`, and a
11
- // page a phone on the wifi or a --share tester loaded is never the creator's.
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 publish in a loop. The agent is woken at most this often: a note
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 an event name (relay.mjs NAME_RE) or nothing.
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
- const reply = JSON.stringify({ to: id, text: '<what you did>' });
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
+