@homie-rocks/studio 0.12.1 → 0.14.0
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/README.md +92 -4
- package/bin/homie-studio.mjs +83 -8
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +5 -5
- package/lib/codex.mjs +28 -9
- package/lib/demo.mjs +85 -0
- package/lib/files.mjs +154 -0
- package/lib/handoff.mjs +128 -0
- package/lib/jobs.mjs +181 -0
- package/lib/mcp-tools.mjs +1000 -0
- package/lib/mcp.mjs +180 -0
- package/lib/office.mjs +172 -0
- package/lib/scaffold.mjs +98 -25
- package/lib/stats.mjs +1 -1
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +52 -0
- package/lib/upgrade.mjs +6 -3
- package/mcp/ui/bridge.js +148 -0
- package/mcp/ui/build.js +63 -0
- package/mcp/ui/card.css +73 -0
- package/mcp/ui/codex.js +23 -0
- package/mcp/ui/setup.js +72 -0
- package/mcp/ui/studio.js +41 -0
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +2 -1
- package/worker/index.mjs +172 -33
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/site.mjs +24 -2
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STUDIO'S FILES, FOR THE LOCAL MCP SERVER (lib/mcp.mjs). The Claude desktop app's chat has no file tools of its
|
|
3
|
+
* own, so the server gives it these, and only inside ONE studio folder: list, read, write, edit and search. A path
|
|
4
|
+
* is relative to the studio; nothing outside it is ever read or written (a symbolic link that leads out is refused
|
|
5
|
+
* too). Secrets never pass: .env and .dev.vars files and the progress feeds' write keys are neither read nor
|
|
6
|
+
* written, and git's own folder and node_modules are never written.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: 1536 * 1024, lines: 2000, list: 600, matches: 200 });
|
|
12
|
+
const IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
13
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.studio', 'dist']);
|
|
14
|
+
|
|
15
|
+
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
16
|
+
|
|
17
|
+
function secret(rel) {
|
|
18
|
+
const parts = rel.split(/[\\/]/);
|
|
19
|
+
const name = parts[parts.length - 1];
|
|
20
|
+
return /^\.env(\..*)?$/.test(name) || name === '.dev.vars' || /\.key$/.test(name) && parts.includes('progress');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A path in the studio, checked: { abs, rel }. `write`: also refuse git's folder and node_modules. */
|
|
24
|
+
export function studioPath(root, path, { write = false } = {}) {
|
|
25
|
+
const raw = String(path ?? '').trim() || '.';
|
|
26
|
+
if (raw.includes('\0')) throw new Error('that path has a NUL in it');
|
|
27
|
+
const base = realpathSync(root);
|
|
28
|
+
const abs = resolve(base, raw);
|
|
29
|
+
if (!inside(base, abs)) throw new Error(`${raw} is outside the studio (${base}); only the studio's own files are reachable`);
|
|
30
|
+
// The nearest existing folder on the way must also be inside (a symbolic link must not lead out).
|
|
31
|
+
let at = abs;
|
|
32
|
+
while (!existsSync(at)) at = dirname(at);
|
|
33
|
+
const real = realpathSync(at);
|
|
34
|
+
if (!inside(base, real)) throw new Error(`${raw} leads outside the studio through a link`);
|
|
35
|
+
const rel = relative(base, abs) || '.';
|
|
36
|
+
if (secret(rel)) throw new Error(`${rel} holds a secret: it is never read or written here`);
|
|
37
|
+
if (write) {
|
|
38
|
+
const top = rel.split(/[\\/]/)[0];
|
|
39
|
+
if (top === '.git' || rel.split(/[\\/]/).includes('node_modules')) throw new Error(`${rel} is git's or npm's own: never written by hand`);
|
|
40
|
+
if (rel === '.') throw new Error('name a file');
|
|
41
|
+
}
|
|
42
|
+
return { abs, rel, base };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listFiles(root, path = '.', { depth = 2 } = {}) {
|
|
46
|
+
const { abs, rel } = studioPath(root, path);
|
|
47
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist`);
|
|
48
|
+
if (!statSync(abs).isDirectory()) throw new Error(`${rel} is a file (file_read reads it)`);
|
|
49
|
+
const out = [];
|
|
50
|
+
const walk = (dir, d) => {
|
|
51
|
+
let entries = [];
|
|
52
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
53
|
+
entries.sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (out.length >= FILE_LIMITS.list) return;
|
|
56
|
+
if (e.name === '.DS_Store') continue;
|
|
57
|
+
const p = join(dir, e.name);
|
|
58
|
+
const r = relative(realpathSync(root), p);
|
|
59
|
+
if (secret(r)) continue;
|
|
60
|
+
if (e.isDirectory()) {
|
|
61
|
+
out.push({ path: `${r}/`, type: 'dir' });
|
|
62
|
+
if (d < depth && !SKIP_DIRS.has(e.name)) walk(p, d + 1);
|
|
63
|
+
} else {
|
|
64
|
+
let size = null;
|
|
65
|
+
try { size = lstatSync(p).size; } catch { /* gone */ }
|
|
66
|
+
out.push({ path: r, type: e.isSymbolicLink() ? 'link' : 'file', size });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(abs, 1);
|
|
71
|
+
return { path: rel, entries: out, truncated: out.length >= FILE_LIMITS.list };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Text (with line numbers from `offset`), or a picture as MCP image content. */
|
|
75
|
+
export function readStudioFile(root, path, { offset = 1, limit = FILE_LIMITS.lines } = {}) {
|
|
76
|
+
const { abs, rel } = studioPath(root, path);
|
|
77
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist`);
|
|
78
|
+
const st = statSync(abs);
|
|
79
|
+
if (st.isDirectory()) throw new Error(`${rel} is a folder (file_list lists it)`);
|
|
80
|
+
const mime = IMAGES[extname(abs).toLowerCase()];
|
|
81
|
+
if (mime) {
|
|
82
|
+
if (st.size > FILE_LIMITS.image) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB, over the ${FILE_LIMITS.image / 1024} KB a picture may be here`);
|
|
83
|
+
return { rel, image: { mimeType: mime, data: readFileSync(abs).toString('base64') }, bytes: st.size };
|
|
84
|
+
}
|
|
85
|
+
if (st.size > FILE_LIMITS.read * 8) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB: too big to read here`);
|
|
86
|
+
const buf = readFileSync(abs);
|
|
87
|
+
if (buf.subarray(0, 8000).includes(0)) throw new Error(`${rel} is not a text file`);
|
|
88
|
+
const lines = buf.toString('utf8').split('\n');
|
|
89
|
+
const from = Math.max(1, Number(offset) || 1);
|
|
90
|
+
const n = Math.min(Math.max(1, Number(limit) || FILE_LIMITS.lines), FILE_LIMITS.lines);
|
|
91
|
+
let text = '';
|
|
92
|
+
let shown = 0;
|
|
93
|
+
for (let i = from - 1; i < lines.length && shown < n; i++, shown++) {
|
|
94
|
+
const line = `${String(i + 1).padStart(5)}\t${lines[i]}\n`;
|
|
95
|
+
if (text.length + line.length > FILE_LIMITS.read) break;
|
|
96
|
+
text += line;
|
|
97
|
+
}
|
|
98
|
+
return { rel, text, from, to: from + shown - 1, lines: lines.length, more: from + shown - 1 < lines.length };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeStudioFile(root, path, content) {
|
|
102
|
+
const { abs, rel } = studioPath(root, path, { write: true });
|
|
103
|
+
const text = String(content ?? '');
|
|
104
|
+
if (Buffer.byteLength(text) > FILE_LIMITS.write) throw new Error(`at most ${FILE_LIMITS.write / 1024 / 1024} MB a file`);
|
|
105
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) throw new Error(`${rel} is a folder`);
|
|
106
|
+
const existed = existsSync(abs);
|
|
107
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
108
|
+
writeFileSync(abs, text);
|
|
109
|
+
return { rel, bytes: Buffer.byteLength(text), created: !existed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function editStudioFile(root, path, oldText, newText, { all = false } = {}) {
|
|
113
|
+
const { abs, rel } = studioPath(root, path, { write: true });
|
|
114
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist (file_write makes a new file)`);
|
|
115
|
+
const was = readFileSync(abs, 'utf8');
|
|
116
|
+
const from = String(oldText ?? '');
|
|
117
|
+
if (!from) throw new Error('old is the exact text to replace (it cannot be empty)');
|
|
118
|
+
const count = was.split(from).length - 1;
|
|
119
|
+
if (!count) throw new Error(`the text to replace is not in ${rel} (it must match exactly, spaces and line breaks too)`);
|
|
120
|
+
if (count > 1 && !all) throw new Error(`the text to replace is in ${rel} ${count} times: give more of it so it is unique, or all: true`);
|
|
121
|
+
const next = all ? was.split(from).join(String(newText ?? '')) : was.replace(from, () => String(newText ?? ''));
|
|
122
|
+
if (Buffer.byteLength(next) > FILE_LIMITS.write) throw new Error(`at most ${FILE_LIMITS.write / 1024 / 1024} MB a file`);
|
|
123
|
+
writeFileSync(abs, next);
|
|
124
|
+
return { rel, replaced: all ? count : 1 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Lines matching a regular expression, in the studio's text files (never node_modules, git, builds or .studio). */
|
|
128
|
+
export function searchStudio(root, pattern, { path = '.', ignoreCase = false } = {}) {
|
|
129
|
+
let re;
|
|
130
|
+
try { re = new RegExp(String(pattern ?? ''), ignoreCase ? 'i' : ''); } catch (error) { throw new Error(`not a regular expression: ${error.message}`); }
|
|
131
|
+
const { abs } = studioPath(root, path);
|
|
132
|
+
const base = realpathSync(root);
|
|
133
|
+
const matches = [];
|
|
134
|
+
const walk = (dir) => {
|
|
135
|
+
let entries = [];
|
|
136
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (matches.length >= FILE_LIMITS.matches) return;
|
|
139
|
+
const p = join(dir, e.name);
|
|
140
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(p); continue; }
|
|
141
|
+
if (!e.isFile() || IMAGES[extname(e.name).toLowerCase()]) continue;
|
|
142
|
+
const r = relative(base, p);
|
|
143
|
+
if (secret(r)) continue;
|
|
144
|
+
let buf;
|
|
145
|
+
try { if (statSync(p).size > 1024 * 1024) continue; buf = readFileSync(p); } catch { continue; }
|
|
146
|
+
if (buf.subarray(0, 8000).includes(0)) continue;
|
|
147
|
+
const lines = buf.toString('utf8').split('\n');
|
|
148
|
+
for (let i = 0; i < lines.length && matches.length < FILE_LIMITS.matches; i++) if (re.test(lines[i])) matches.push({ path: r, line: i + 1, text: lines[i].slice(0, 300) });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
152
|
+
else walk(dirname(abs));
|
|
153
|
+
return { pattern: String(pattern), matches, truncated: matches.length >= FILE_LIMITS.matches };
|
|
154
|
+
}
|
package/lib/handoff.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio handoff <hb_…>` — what a Claude Code session started from the Claude app does with the ONE line it
|
|
3
|
+
* was given ("Continue building <Studio>: build hb_…", HANDOFF.md in every studio):
|
|
4
|
+
*
|
|
5
|
+
* 1. fetch the brief the person gave in the chat from the directory (GET /api/studio/progress/<hb>/brief: the
|
|
6
|
+
* build id is the read capability, and it is only ever shown in the person's own chat and that link);
|
|
7
|
+
* 2. for a build that belongs to a studio still being set up, check in from this repository (`setup attach`);
|
|
8
|
+
* 3. take the build once (`progress attach`), so the chat's card follows the work;
|
|
9
|
+
* 4. print the brief and the steps for its kind, in this toolkit's own commands.
|
|
10
|
+
*
|
|
11
|
+
* No key travels in a prompt, and no wall of text either. When this session's network does not reach the directory,
|
|
12
|
+
* it says so and names the other road: the Homie connector's build_progress, which answers with the same brief.
|
|
13
|
+
*/
|
|
14
|
+
import { request } from './net.mjs';
|
|
15
|
+
import { startProgress } from './progress.mjs';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { studioRepo } from './repo.mjs';
|
|
18
|
+
|
|
19
|
+
/** The repository this checkout IS (its origin remote), even Homie's engine one: the session must be in the studio's. */
|
|
20
|
+
function originRepo(root) {
|
|
21
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
22
|
+
if (r.status !== 0) return null;
|
|
23
|
+
// Not repoFromUrl: that one never names the engine repository, and this must, to refuse it.
|
|
24
|
+
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, ''));
|
|
25
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
26
|
+
}
|
|
27
|
+
import { setupAttach } from './setup.mjs';
|
|
28
|
+
import { listGames, readStudio } from './studio.mjs';
|
|
29
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
30
|
+
|
|
31
|
+
const BUILD = /^hb_[a-f0-9]{32}$/;
|
|
32
|
+
const SETUP = /^hs_[a-f0-9]{32}$/;
|
|
33
|
+
|
|
34
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
35
|
+
const line = (v, max) => String(v ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
36
|
+
const lines = (v, max) => String(v ?? '').split(/\r?\n/).map((l) => line(l, max)).filter(Boolean).join('\n').slice(0, max);
|
|
37
|
+
|
|
38
|
+
/** The steps for a kind of build, in this toolkit's commands (the brief says what; these say how). */
|
|
39
|
+
export function stepsFor(h, { games = [] } = {}) {
|
|
40
|
+
const id = ID.test(String(h.id ?? '')) ? h.id : null;
|
|
41
|
+
const has = id && games.includes(id);
|
|
42
|
+
const q = (s) => JSON.stringify(String(s));
|
|
43
|
+
const work = {
|
|
44
|
+
setup: [
|
|
45
|
+
'This studio was just set up from the Claude app. Go through the new-studio checklist with the person, one step at a time and never ahead (the plugin\'s studio-setup skill): it has no game yet, and its home page says "First game coming soon".',
|
|
46
|
+
'See a working game: `npx --no-install homie-studio demo` names a live one on Homie Arcade; copy a starter in only if they ask.',
|
|
47
|
+
'One small change from one sentence of theirs (the studio\'s colours in site/theme.json, a tagline in studio.json, or a first post in posts/), then plan their game into its Game Codex (`npx --no-install homie-studio codex new <id> --name "<Name>"`) before any game is made.',
|
|
48
|
+
],
|
|
49
|
+
make: [
|
|
50
|
+
has ? `games/${id} is here already: change it as the brief says.` : id ? `npx --no-install homie-studio game new ${id} --from ${ID.test(String(h.from ?? '')) ? h.from : 'gem-rush'}${h.name ? ` --name ${q(line(h.name, 60))}` : ''}` : 'Pick an id and a name from the brief, then npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>".',
|
|
51
|
+
'Make it the game the brief describes (its Game Codex, games/<id>/CODEX.md, when there is one).',
|
|
52
|
+
],
|
|
53
|
+
port: [
|
|
54
|
+
`npx --no-install homie-studio port plan ${h.folder ? q(line(h.folder, 200)) : '<the game\'s folder>'}, then port import${id ? ` --id ${id}` : ''} (the plugin's port skill has the whole job).`,
|
|
55
|
+
],
|
|
56
|
+
remix: [
|
|
57
|
+
h.source && id ? `npx --no-install homie-studio game remix ${line(h.source, 300)} --id ${id}` : 'npx --no-install homie-studio game remix <source.json> --id <new id>',
|
|
58
|
+
'Then make it the brief\'s own.',
|
|
59
|
+
],
|
|
60
|
+
change: ['Make the change the brief asks for, in small steps.'],
|
|
61
|
+
}[h.kind] ?? ['Do what the brief asks.'];
|
|
62
|
+
return [
|
|
63
|
+
...work,
|
|
64
|
+
'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
|
|
65
|
+
'npx --no-install homie-studio progress change "<what it does, one line>"; commit on a new branch, push, gh pr create; then npx --no-install homie-studio progress pr --url <the pull request>.',
|
|
66
|
+
'Never merge the pull request: the person publishes it from the chat\'s card, in GitHub.',
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function handoff(root, build, { homie, fetchFn = globalThis.fetch, attach = startProgress, checkIn = setupAttach, repoOf = originRepo } = {}) {
|
|
71
|
+
const studio = readStudio(root);
|
|
72
|
+
// "Continue building <Studio>: setup hs_…": a studio being set up, before any build: check in, then the checklist.
|
|
73
|
+
if (SETUP.test(String(build ?? ''))) {
|
|
74
|
+
const r = await checkIn(root, build, { homie });
|
|
75
|
+
if (!r.ok) return { ok: false, command: 'handoff', setup: build, why: r.why, ...(r.needs ? { needs: r.needs } : {}) };
|
|
76
|
+
const h = { kind: 'setup', what: 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
|
|
77
|
+
return { ok: true, command: 'handoff', build: null, ...h, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h) };
|
|
78
|
+
}
|
|
79
|
+
if (!BUILD.test(String(build ?? ''))) return { ok: false, command: 'handoff', why: 'the id from the one line the Claude app gave: hb_ (a build) or hs_ (a setup) and 32 hex digits' };
|
|
80
|
+
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
81
|
+
// lib/net.mjs: through the environment's proxy, and an honest reason when it fails (the directory's own words, or
|
|
82
|
+
// the connection's error; only a refusal by the proxy names the network setting).
|
|
83
|
+
const got = await request(`${directory}/api/studio/progress/${build}/brief`, { headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` } }, { fetchFn });
|
|
84
|
+
const instead = `If the Homie connector's tools are in this session, call build_progress with { "build": "${build}" }: it answers with the brief. Otherwise ask the person what to build; the card in their chat shows it.`;
|
|
85
|
+
if (!got.ok) return { ok: false, command: 'handoff', build, why: got.why, ...(got.needs ? { needs: got.needs } : {}), ...(got.status === 404 ? {} : { instead }) };
|
|
86
|
+
const body = got.body ?? {};
|
|
87
|
+
// The build is for one repository: a session in any other stops here (claude.ai may open the person's last one).
|
|
88
|
+
const want = studioRepo(body.repo)?.toLowerCase() ?? null;
|
|
89
|
+
const here = want ? (repoOf(root) ?? null)?.toLowerCase() ?? null : null;
|
|
90
|
+
if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
|
|
91
|
+
const h = {
|
|
92
|
+
kind: ['make', 'port', 'remix', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
|
|
93
|
+
what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
|
|
94
|
+
title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
|
|
95
|
+
id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
|
|
96
|
+
source: /^https:\/\/\S{8,300}$/.test(String(body.source ?? '')) ? body.source : null, folder: line(body.folder, 200) || null,
|
|
97
|
+
setup: /^hs_[a-f0-9]{32}$/.test(String(body.setup ?? '')) ? body.setup : null,
|
|
98
|
+
};
|
|
99
|
+
let setup = null;
|
|
100
|
+
if (h.setup) {
|
|
101
|
+
const r = await checkIn(root, h.setup, { homie: directory });
|
|
102
|
+
setup = { ok: r.ok, message: r.ok ? r.message : r.why, renamed: r.renamed ?? [] };
|
|
103
|
+
}
|
|
104
|
+
const taken = await attach(root, { attach: build, what: h.what, id: h.id, directory });
|
|
105
|
+
let games = [];
|
|
106
|
+
try { games = listGames(root).map((g) => g.id); } catch { games = []; }
|
|
107
|
+
return {
|
|
108
|
+
ok: true, command: 'handoff', build, ...h, setup,
|
|
109
|
+
attached: taken.ok ? { feed: taken.build, shared: taken.shared?.build ?? build } : null,
|
|
110
|
+
attachWhy: taken.ok ? null : taken.why,
|
|
111
|
+
steps: stepsFor(h, { games }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The hand-off as the session reads it. */
|
|
116
|
+
export function formatHandoff(r) {
|
|
117
|
+
return [
|
|
118
|
+
`Continue building ${r.studio}: ${r.title || r.kind}`,
|
|
119
|
+
'',
|
|
120
|
+
r.brief ? `The person's brief, from the chat:\n${r.brief.split('\n').map((l) => ` ${l}`).join('\n')}` : 'No brief beyond the title: ask the person one question if anything is unclear.',
|
|
121
|
+
'',
|
|
122
|
+
r.setup ? `Setup: ${r.setup.message}${r.setup.renamed?.length ? ` (changed: ${r.setup.renamed.join(', ')}; commit them on the branch)` : ''}` : null,
|
|
123
|
+
r.attached ? `This session took build ${r.build}: the chat's card follows the work.` : r.build ? `The chat's card will not follow this session: ${r.attachWhy}` : null,
|
|
124
|
+
'',
|
|
125
|
+
'Steps:',
|
|
126
|
+
...r.steps.map((s, i) => ` ${i + 1}. ${s}`),
|
|
127
|
+
].filter((l) => l !== null).join('\n');
|
|
128
|
+
}
|
package/lib/jobs.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WORK THE LOCAL MCP SERVER STARTS (lib/mcp.mjs): every studio command runs as its own process, with the
|
|
3
|
+
* studio's OWN pinned toolkit (node_modules/@homie-rocks/studio, the version its package.json names), and the
|
|
4
|
+
* system's Node. A tool call waits for it a little while (under a minute: the Claude desktop app gives a local tool
|
|
5
|
+
* call 60 s), then hands back a job id; `studio_job` reads it later, and a check, playtest or deploy also reports
|
|
6
|
+
* into the studio's progress feed, which the build card follows.
|
|
7
|
+
*
|
|
8
|
+
* WHICH NODE. Inside the Claude desktop app the server may run on the app's own built-in Node, which may not start
|
|
9
|
+
* another copy of itself (process.execPath is the app). A studio needs the system's Node and npm anyway (npm
|
|
10
|
+
* install, Wrangler, esbuild), so commands always run with the first Node 22 or newer on the PATH. A GUI app's PATH
|
|
11
|
+
* is short, so the usual places Node lives are added (Homebrew, nvm, Volta, asdf, fnm, the Node installer).
|
|
12
|
+
*
|
|
13
|
+
* Logs go to .studio/mcp/<job>.log in the studio (git-ignored). Nothing here ever runs a shell.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { PACKAGE_ROOT } from './studio.mjs';
|
|
21
|
+
|
|
22
|
+
const WIN = process.platform === 'win32';
|
|
23
|
+
|
|
24
|
+
const byVersionDesc = (a, b) => {
|
|
25
|
+
const pa = a.replace(/^v/, '').split('.').map(Number); const pb = b.replace(/^v/, '').split('.').map(Number);
|
|
26
|
+
for (let i = 0; i < 3; i++) if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
|
|
27
|
+
return 0;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Where Node and npm usually live, after this process's own PATH. */
|
|
31
|
+
export function toolPath(env = process.env, platform = process.platform, home = homedir()) {
|
|
32
|
+
const nvm = (() => {
|
|
33
|
+
const dir = join(home, '.nvm', 'versions', 'node');
|
|
34
|
+
try { return readdirSync(dir).filter((v) => /^v\d+\./.test(v)).sort(byVersionDesc).map((v) => join(dir, v, 'bin')); } catch { return []; }
|
|
35
|
+
})();
|
|
36
|
+
const extra = platform === 'win32'
|
|
37
|
+
? [join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'npm'), join(env.ProgramFiles ?? 'C:\\Program Files', 'nodejs'), join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'Volta', 'bin')]
|
|
38
|
+
: ['/opt/homebrew/bin', '/usr/local/bin', join(home, '.volta', 'bin'), join(home, '.asdf', 'shims'), join(home, '.local', 'bin'), join(home, '.fnm', 'aliases', 'default', 'bin'), join(home, '.bun', 'bin'), ...nvm, '/usr/bin', '/bin'];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const p of [...String(env.PATH ?? env.Path ?? '').split(delimiter), ...extra]) if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
42
|
+
return out.join(delimiter);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let nodeCache = null;
|
|
46
|
+
/** The first Node 22 or newer on the PATH (never the Claude app's own binary): { bin, version, npm } or null. */
|
|
47
|
+
export function findNode({ fresh = false } = {}) {
|
|
48
|
+
if (nodeCache && !fresh) return nodeCache;
|
|
49
|
+
const exe = WIN ? 'node.exe' : 'node';
|
|
50
|
+
const candidates = [];
|
|
51
|
+
// A plain Node process (Claude Code, a terminal, a test) can run itself; the Claude app's built-in one cannot.
|
|
52
|
+
if (!process.versions.electron) candidates.push(process.execPath);
|
|
53
|
+
for (const dir of toolPath().split(delimiter)) candidates.push(join(dir, exe));
|
|
54
|
+
const tried = new Set();
|
|
55
|
+
for (const bin of candidates) {
|
|
56
|
+
if (tried.has(bin) || !existsSync(bin)) continue;
|
|
57
|
+
tried.add(bin);
|
|
58
|
+
const env = { ...process.env };
|
|
59
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
60
|
+
const r = spawnSync(bin, ['-v'], { encoding: 'utf8', timeout: 5000, env });
|
|
61
|
+
const version = String(r.stdout ?? '').trim().replace(/^v/, '');
|
|
62
|
+
if (r.status === 0 && Number(version.split('.')[0]) >= 22) {
|
|
63
|
+
const npmNext = join(dirname(bin), WIN ? 'npm.cmd' : 'npm');
|
|
64
|
+
nodeCache = { bin, version, npm: existsSync(npmNext) ? npmNext : which(WIN ? 'npm.cmd' : 'npm') };
|
|
65
|
+
return nodeCache;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function which(name) {
|
|
72
|
+
for (const dir of toolPath().split(delimiter)) { const p = join(dir, name); if (existsSync(p)) return p; }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The environment a studio command runs in: the long PATH (this Node's folder first), no update nags. */
|
|
77
|
+
export function toolEnv(extra = {}) {
|
|
78
|
+
const node = findNode();
|
|
79
|
+
const path = toolPath();
|
|
80
|
+
const env = { ...process.env, PATH: node ? `${dirname(node.bin)}${delimiter}${path}` : path, npm_config_update_notifier: 'false', npm_config_fund: 'false', npm_config_audit: 'false', WRANGLER_SEND_METRICS: 'false', ...extra };
|
|
81
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
82
|
+
return env;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The studio's own pinned CLI, else this package's (a studio before its npm install). */
|
|
86
|
+
export function cliOf(root) {
|
|
87
|
+
const pinned = join(root, 'node_modules', '@homie-rocks', 'studio', 'bin', 'homie-studio.mjs');
|
|
88
|
+
return existsSync(pinned) ? pinned : join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs');
|
|
89
|
+
}
|
|
90
|
+
export const installed = (root) => existsSync(join(root, 'node_modules', '@homie-rocks', 'studio', 'package.json')) && existsSync(join(root, 'node_modules', 'wrangler'));
|
|
91
|
+
|
|
92
|
+
const jobs = new Map();
|
|
93
|
+
const MAX_OUT = 4 * 1024 * 1024;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Start one process for a studio. `json`: the command prints a JSON result on stdout (homie-studio --json), kept
|
|
97
|
+
* as job.result. `onEnd(job)` runs once when it exits.
|
|
98
|
+
*/
|
|
99
|
+
export function startJob({ root, label, cmd, args, env = toolEnv(), json = false, onEnd = null, keep = false }) {
|
|
100
|
+
const id = `j_${randomBytes(6).toString('hex')}`;
|
|
101
|
+
const dir = join(root, '.studio', 'mcp');
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
const log = join(dir, `${id}.log`);
|
|
104
|
+
appendFileSync(log, `$ ${[cmd, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}\n`);
|
|
105
|
+
const child = spawn(cmd, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: WIN && /\.cmd$/i.test(cmd) });
|
|
106
|
+
const job = { id, label, root, args, pid: child.pid ?? null, startedAt: new Date().toISOString(), endedAt: null, code: null, out: '', err: '', log, result: null, json, keep, child };
|
|
107
|
+
let finish;
|
|
108
|
+
job.done = new Promise((r) => { finish = r; });
|
|
109
|
+
child.stdout.on('data', (d) => { if (job.out.length < MAX_OUT) job.out += d; try { appendFileSync(log, d); } catch { /* full disk: keep going */ } });
|
|
110
|
+
child.stderr.on('data', (d) => { job.err = (job.err + d).slice(-64 * 1024); try { appendFileSync(log, d); } catch { /* keep going */ } });
|
|
111
|
+
const end = (code) => {
|
|
112
|
+
if (job.endedAt) return;
|
|
113
|
+
job.code = code;
|
|
114
|
+
job.endedAt = new Date().toISOString();
|
|
115
|
+
if (json) job.result = lastJson(job.out);
|
|
116
|
+
job.child = null;
|
|
117
|
+
try { onEnd?.(job); } catch { /* the job's own result stands */ }
|
|
118
|
+
finish(job);
|
|
119
|
+
};
|
|
120
|
+
child.on('error', (error) => { job.err += `\n${error.message}`; end(127); });
|
|
121
|
+
child.on('close', (code) => end(code ?? 1));
|
|
122
|
+
jobs.set(id, job);
|
|
123
|
+
return job;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The last JSON document a command printed (homie-studio --json prints one, pretty). */
|
|
127
|
+
export function lastJson(text) {
|
|
128
|
+
const s = String(text ?? '').trim();
|
|
129
|
+
if (!s) return null;
|
|
130
|
+
try { return JSON.parse(s); } catch { /* look for the last top-level object */ }
|
|
131
|
+
for (let i = s.lastIndexOf('\n{'); i >= 0; i = s.lastIndexOf('\n{', i - 1)) {
|
|
132
|
+
try { return JSON.parse(s.slice(i + 1)); } catch { /* earlier */ }
|
|
133
|
+
if (i === 0) break;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const getJob = (id) => jobs.get(String(id ?? '')) ?? null;
|
|
139
|
+
export const runningJobs = (root) => [...jobs.values()].filter((j) => !j.endedAt && (!root || j.root === root));
|
|
140
|
+
|
|
141
|
+
/** Wait for a job at most `ms`; true when it ended. */
|
|
142
|
+
export async function waitJob(job, ms) {
|
|
143
|
+
if (job.endedAt) return true;
|
|
144
|
+
let t;
|
|
145
|
+
const ended = await Promise.race([job.done.then(() => true), new Promise((r) => { t = setTimeout(() => r(false), ms); })]);
|
|
146
|
+
clearTimeout(t);
|
|
147
|
+
return ended;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a person (and the model) is told about a job. */
|
|
151
|
+
export function jobView(job, { lines = 12 } = {}) {
|
|
152
|
+
const text = `${job.err}\n${job.json ? '' : job.out}`.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
|
153
|
+
return {
|
|
154
|
+
job: job.id, label: job.label, state: !job.endedAt ? 'running' : job.code === 0 ? 'done' : 'failed', code: job.code,
|
|
155
|
+
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
156
|
+
seconds: Math.round(((job.endedAt ? Date.parse(job.endedAt) : Date.now()) - Date.parse(job.startedAt)) / 1000),
|
|
157
|
+
tail: text.slice(-lines), log: job.log, result: job.result,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Run the studio's pinned CLI as a job: `homie-studio <args> --json`. */
|
|
162
|
+
export function cliJob(root, label, args, opts = {}) {
|
|
163
|
+
const node = findNode();
|
|
164
|
+
if (!node) throw new Error('no Node.js 22 or newer on this computer: install it from https://nodejs.org/en/download (the LTS), then ask again');
|
|
165
|
+
return startJob({ root, label, cmd: node.bin, args: [cliOf(root), ...args, '--json'], json: true, ...opts });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `npm install` in a studio (its pinned toolkit and Wrangler). */
|
|
169
|
+
export function installJob(root, opts = {}) {
|
|
170
|
+
const node = findNode();
|
|
171
|
+
if (!node?.npm) throw new Error('npm was not found next to Node.js on this computer: install Node.js 22 or newer from https://nodejs.org/en/download, then ask again');
|
|
172
|
+
return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)', cmd: node.npm, args: ['install', '--no-audit', '--no-fund'], ...opts });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** When the server ends (the app quit), nothing it started keeps running, except what asked to (none today). */
|
|
176
|
+
export function stopAllJobs() {
|
|
177
|
+
for (const job of jobs.values()) {
|
|
178
|
+
if (job.endedAt || !job.child || job.keep) continue;
|
|
179
|
+
try { job.child.kill('SIGTERM'); } catch { /* gone */ }
|
|
180
|
+
}
|
|
181
|
+
}
|