@homie-rocks/studio 0.13.0 → 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 +31 -4
- package/bin/homie-studio.mjs +32 -5
- package/lib/cloudflare.mjs +2 -2
- 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/scaffold.mjs +56 -24
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +27 -0
- package/lib/upgrade.mjs +1 -1
- 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/package.json +2 -1
- package/worker/site.mjs +24 -2
- package/worker/version.mjs +1 -1
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
|
+
}
|