@homie-rocks/studio 0.9.0 → 0.11.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 +120 -8
- package/bin/homie-studio.mjs +128 -13
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +44 -4
- package/lib/check.mjs +14 -12
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +92 -18
- package/lib/codex.mjs +664 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/look.mjs +5 -10
- package/lib/port-check.mjs +5 -11
- package/lib/progress.mjs +122 -6
- package/lib/scaffold.mjs +138 -21
- package/lib/setup.mjs +90 -0
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +47 -0
- package/lib/version.mjs +7 -3
- package/package.json +1 -1
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +75 -4
- package/worker/stats-page.mjs +34 -2
- package/worker/version.mjs +1 -1
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
|
|
3
|
+
* a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
|
|
4
|
+
* unlocks, and the exact fix: one command the AI runs, or one page the person taps.
|
|
5
|
+
*
|
|
6
|
+
* Node.js required the toolkit runs on it
|
|
7
|
+
* Homie connector required the directory, cards and the pinned toolkit's address (the AI says whether its
|
|
8
|
+
* Homie tools are there: --connector yes|no)
|
|
9
|
+
* Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
|
|
10
|
+
* first deploy; a deploy that went through proves it)
|
|
11
|
+
* Chrome for checks the two-browser check, the look pictures and playtests
|
|
12
|
+
* ffmpeg recommended sound effects and the theme, trailers and hero footage
|
|
13
|
+
* GitHub optional a private backup, publishing by pull request, building from the Claude app
|
|
14
|
+
* ElevenLabs optional songs and game scores
|
|
15
|
+
* fal optional painted art and generated video
|
|
16
|
+
* status line optional (Claude Code only) the build's progress under the prompt
|
|
17
|
+
*
|
|
18
|
+
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
19
|
+
* tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
|
|
20
|
+
* is set, fal's free pricing API), never changes anything, and never prints a key, a token or an account's name.
|
|
21
|
+
* Every check has a time limit, so it answers in seconds even with no network.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { findChrome } from './chrome.mjs';
|
|
27
|
+
import { isOurs } from './statusline.mjs';
|
|
28
|
+
import { findStudio, readLocal, readStudio } from './studio.mjs';
|
|
29
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
30
|
+
|
|
31
|
+
/** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
|
|
32
|
+
export function defaultExec(cmd, args, { cwd, timeout = 8000, env } = {}) {
|
|
33
|
+
return new Promise((done) => {
|
|
34
|
+
let stdout = ''; let stderr = ''; let settled = false;
|
|
35
|
+
const finish = (r) => { if (!settled) { settled = true; clearTimeout(timer); done(r); } };
|
|
36
|
+
let child;
|
|
37
|
+
try { child = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); } catch (error) { finish({ code: 127, stdout: '', stderr: String(error?.message ?? error) }); return; }
|
|
38
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish({ code: 124, stdout, stderr }); }, timeout);
|
|
39
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
40
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
41
|
+
child.on('error', (error) => finish({ code: error.code === 'ENOENT' ? 127 : 1, stdout, stderr: String(error.message) }));
|
|
42
|
+
child.on('close', (code) => finish({ code: code ?? 1, stdout, stderr }));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MARK = { ok: '✓', act: '→', missing: '✗', optional: '○', later: '…', unknown: '?' };
|
|
47
|
+
const RANK = { missing: 0, act: 1, unknown: 2, later: 3, optional: 4, ok: 5 };
|
|
48
|
+
const worst = (states) => states.reduce((a, b) => (RANK[b] < RANK[a] ? b : a), 'ok');
|
|
49
|
+
|
|
50
|
+
async function reach(url, fetchFn, ms = 5000) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetchFn(url, { method: 'GET', headers: { 'user-agent': `homie-studio/${STUDIO_VERSION}`, accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
|
|
53
|
+
const deny = res.headers?.get?.('x-deny-reason') ?? '';
|
|
54
|
+
if (res.status === 403 && /host_not_allowed/i.test(deny)) return { ok: false, blocked: true };
|
|
55
|
+
return { ok: true, status: res.status };
|
|
56
|
+
} catch (error) { return { ok: false, why: error?.name === 'TimeoutError' ? 'did not answer in time' : 'did not answer' }; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The checklist. `connector`: what the AI knows about its own tools ('yes' when the Homie MCP tools such as
|
|
61
|
+
* studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `exec` and `fetchFn` are
|
|
62
|
+
* replaceable for tests.
|
|
63
|
+
*/
|
|
64
|
+
export async function setupStatus({
|
|
65
|
+
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null,
|
|
66
|
+
exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node,
|
|
67
|
+
} = {}) {
|
|
68
|
+
const root = findStudio(cwd);
|
|
69
|
+
let studio = null;
|
|
70
|
+
try { studio = root ? readStudio(root) : null; } catch { studio = null; }
|
|
71
|
+
const cli = root ? 'npx --no-install homie-studio' : `npx -y @homie-rocks/studio@${STUDIO_VERSION}`;
|
|
72
|
+
let directory = String(homie || (studio?.homie?.directory && typeof studio.homie.directory === 'string' ? studio.homie.directory : 'https://homie.rocks')).replace(/\/+$/, '');
|
|
73
|
+
try { new URL(directory); } catch { directory = 'https://homie.rocks'; }
|
|
74
|
+
const mac = platform === 'darwin';
|
|
75
|
+
const win = platform === 'win32';
|
|
76
|
+
const rows = [];
|
|
77
|
+
|
|
78
|
+
// Node.js
|
|
79
|
+
const major = Number(String(node).split('.')[0]);
|
|
80
|
+
rows.push({
|
|
81
|
+
id: 'node', label: 'Node.js', need: 'required', state: major >= 22 ? 'ok' : 'missing', detail: `v${node}${major >= 22 ? '' : ' (22 or newer is needed)'}`,
|
|
82
|
+
unlocks: 'everything: the studio\'s toolkit runs on it',
|
|
83
|
+
fix: major >= 22 ? null : { who: 'person', open: 'https://nodejs.org/en/download', say: 'Install Node.js 22 or newer (the LTS download), then start a new session.' },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
|
|
87
|
+
const said = connector === true || connector === 'yes' ? 'yes' : connector === false || connector === 'no' ? 'no' : null;
|
|
88
|
+
const [net, who, gh, eleven, fal] = await Promise.all([
|
|
89
|
+
reach(`${directory}/mcp`, fetchFn),
|
|
90
|
+
(async () => {
|
|
91
|
+
if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
|
|
92
|
+
const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
|
|
93
|
+
if (!bin || !existsSync(bin)) return { signedIn: null };
|
|
94
|
+
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
95
|
+
if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
|
|
96
|
+
try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null }; } catch { return { signedIn: false }; }
|
|
97
|
+
})(),
|
|
98
|
+
(async () => {
|
|
99
|
+
const git = await exec('git', ['--version']);
|
|
100
|
+
// The studio's own repository: on GitHub (a Claude app studio always is: its setup card made it there).
|
|
101
|
+
const remote = root && git.code === 0 ? (await exec('git', ['remote', 'get-url', 'origin'], { cwd: root })).stdout.trim() : '';
|
|
102
|
+
const onGitHub = /(^|[/@.])github\.com[/:]/i.test(remote);
|
|
103
|
+
if (env.GH_TOKEN || env.GITHUB_TOKEN) return { git: git.code === 0, gh: true, signedIn: true, onGitHub };
|
|
104
|
+
const v = await exec('gh', ['--version']);
|
|
105
|
+
if (v.code !== 0) return { git: git.code === 0, gh: false, signedIn: false, onGitHub };
|
|
106
|
+
return { git: git.code === 0, gh: true, signedIn: (await exec('gh', ['auth', 'status'], { timeout: 10_000 })).code === 0, onGitHub };
|
|
107
|
+
})(),
|
|
108
|
+
(async () => {
|
|
109
|
+
const cliBin = env.ELEVENLABS_CLI || 'elevenlabs';
|
|
110
|
+
const v = await exec(cliBin, ['--version']);
|
|
111
|
+
let signedIn = false;
|
|
112
|
+
if (v.code === 0) {
|
|
113
|
+
const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
|
|
114
|
+
try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
|
|
115
|
+
}
|
|
116
|
+
return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY) };
|
|
117
|
+
})(),
|
|
118
|
+
(async () => {
|
|
119
|
+
const key = String(env.FAL_KEY ?? '').trim();
|
|
120
|
+
if (!key) return { key: false };
|
|
121
|
+
const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
|
|
124
|
+
return { key: true, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
|
|
125
|
+
} catch { return { key: true, valid: null }; }
|
|
126
|
+
})(),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// The Homie connector.
|
|
130
|
+
{
|
|
131
|
+
const fixConnector = {
|
|
132
|
+
who: 'person',
|
|
133
|
+
say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. The Claude app: Settings, Connectors, Add custom connector, https://homie.rocks/mcp; then its setup card (studio_setup) makes the studio on your own Cloudflare and GitHub in three taps.',
|
|
134
|
+
};
|
|
135
|
+
let state; let detail;
|
|
136
|
+
if (net.blocked) { state = 'act'; detail = `this environment's network does not reach ${new URL(directory).host}`; }
|
|
137
|
+
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; ${new URL(directory).host} ${net.why ?? 'did not answer'} just now`; }
|
|
138
|
+
else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
|
|
139
|
+
else { state = net.ok ? 'unknown' : 'act'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `${new URL(directory).host} ${net.why ?? 'did not answer'}`; }
|
|
140
|
+
rows.push({
|
|
141
|
+
id: 'connector', label: 'Homie connector', need: 'required', state, detail,
|
|
142
|
+
unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the cards in the Claude app',
|
|
143
|
+
fix: state === 'ok' ? null : net.blocked
|
|
144
|
+
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
|
|
145
|
+
: fixConnector,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cloudflare: signed in, and the account's email verified.
|
|
150
|
+
{
|
|
151
|
+
const local = root ? readLocal(root) : {};
|
|
152
|
+
const created = studio?.cloudflare?.created ?? [];
|
|
153
|
+
const deployed = Boolean(local.deployedAt || created.some((c) => String(c).startsWith('worker:')));
|
|
154
|
+
// A studio the Claude app's setup card made (`setup attach` wrote connectedAt): Cloudflare's Deploy button made
|
|
155
|
+
// it on the person's account and Workers Builds deploys every merge, so no sign-in is needed on this computer.
|
|
156
|
+
const attached = Boolean(local.connectedAt);
|
|
157
|
+
const signedIn = attached || who.signedIn === true ? 'ok' : who.signedIn === false ? 'act' : 'later';
|
|
158
|
+
const verified = deployed || attached ? 'ok' : local.needs === 'cloudflare-verify-email' ? 'act' : 'later';
|
|
159
|
+
const parts = attached ? [
|
|
160
|
+
{ label: 'connected', state: 'ok', detail: 'through the Claude app\'s setup card (Deploy to Cloudflare); Workers Builds deploys every merge' },
|
|
161
|
+
{ label: 'email verified', state: 'ok', detail: 'the site is live' },
|
|
162
|
+
] : [
|
|
163
|
+
{ label: 'signed in', state: signedIn, detail: who.signedIn === true ? (who.how ?? `${who.accounts ?? 1} account${who.accounts === 1 || who.accounts === null || who.accounts === undefined ? '' : 's'}`) : who.signedIn === false ? 'not signed in' : root ? (who.why ?? 'run npm install in the studio, then this again') : 'checked once the studio exists (it brings its own Wrangler)' },
|
|
164
|
+
{ label: 'email verified', state: verified, detail: deployed ? 'a deploy went through' : verified === 'act' ? 'the last deploy was refused until it is' : 'Cloudflare checks it at the first deploy' },
|
|
165
|
+
];
|
|
166
|
+
const fix = attached ? null : signedIn === 'act'
|
|
167
|
+
? { who: 'ai', run: 'npx wrangler login', say: 'Cloudflare opens in your browser: approve once. A free account works; no payment method.', open: 'https://dash.cloudflare.com/sign-up' }
|
|
168
|
+
: verified === 'act'
|
|
169
|
+
? { who: 'person', open: 'https://dash.cloudflare.com/profile', say: 'Open the email Cloudflare sent and click its link (or Profile, "Send verification email"); then deploy again.' }
|
|
170
|
+
: signedIn === 'later' || verified === 'later'
|
|
171
|
+
? { who: 'person', open: 'https://dash.cloudflare.com/sign-up', say: 'No Cloudflare account yet? Make a free one now (no payment method) and click the link in the email it sends. Your AI signs in for you later; you approve once.' }
|
|
172
|
+
: null;
|
|
173
|
+
rows.push({
|
|
174
|
+
id: 'cloudflare', label: 'Cloudflare', need: 'to go online', state: worst(parts.map((p) => p.state)), detail: parts.map((p) => `${p.label}: ${p.detail}`).join('; '), parts,
|
|
175
|
+
unlocks: 'putting the studio\'s site and public rooms online, on its own free Cloudflare account', fix,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Chrome, for the checks.
|
|
180
|
+
{
|
|
181
|
+
const found = chrome();
|
|
182
|
+
rows.push({
|
|
183
|
+
id: 'chrome', label: 'Chrome', need: 'for checks', state: found ? 'ok' : 'act', detail: found ? 'found' : 'no Chrome on this computer',
|
|
184
|
+
unlocks: 'the two-browser check, the look pictures and playtests (games build and run without it)',
|
|
185
|
+
fix: found ? null : { who: 'ai', run: `${cli} chrome install`, say: 'Chrome for Testing goes into a cache folder (about 150 MB), only for the checks.', open: mac || win ? 'https://www.google.com/chrome/' : undefined },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ffmpeg.
|
|
190
|
+
{
|
|
191
|
+
const ok = (await exec('ffmpeg', ['-version'])).code === 0;
|
|
192
|
+
rows.push({
|
|
193
|
+
id: 'ffmpeg', label: 'ffmpeg', need: 'recommended', state: ok ? 'ok' : 'optional', detail: ok ? 'found' : 'not installed',
|
|
194
|
+
unlocks: 'sound effects and a synthesized theme (free, on this computer), trailers, and footage in a landing page\'s hero',
|
|
195
|
+
fix: ok ? null : mac ? { who: 'ai', run: 'brew install ffmpeg', say: 'about a minute with Homebrew; you approve the install.' }
|
|
196
|
+
: win ? { who: 'ai', run: 'winget install --id Gyan.FFmpeg -e', say: 'you approve the install.' }
|
|
197
|
+
: { who: 'person', say: 'Install your system\'s ffmpeg package (for example: sudo apt-get install -y ffmpeg).' },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// GitHub.
|
|
202
|
+
rows.push({
|
|
203
|
+
id: 'github', label: 'GitHub', need: 'optional', state: gh.signedIn || gh.onGitHub ? 'ok' : 'optional',
|
|
204
|
+
detail: [gh.onGitHub ? 'this studio\'s repository is on GitHub' : null, gh.signedIn ? 'the GitHub CLI is signed in' : gh.gh ? 'the GitHub CLI is here, not signed in' : gh.git ? 'git is here; no GitHub CLI' : 'no git'].filter(Boolean).join('; '),
|
|
205
|
+
unlocks: 'a private backup of the studio, publishing changes by pull request, and building from the Claude app',
|
|
206
|
+
fix: gh.signedIn || gh.onGitHub ? null : gh.gh
|
|
207
|
+
? { who: 'ai', run: 'gh auth login --hostname github.com --git-protocol https --web', say: 'GitHub shows a one-time code: open https://github.com/login/device, type it and approve.' }
|
|
208
|
+
: { who: 'person', open: 'https://github.com/signup', say: `Make a free GitHub account; your AI installs the GitHub CLI${mac ? ' (brew install gh)' : ''} when you want the backup.` },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ElevenLabs.
|
|
212
|
+
rows.push({
|
|
213
|
+
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key ? 'ok' : 'optional',
|
|
214
|
+
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
|
|
215
|
+
unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
|
|
216
|
+
fix: eleven.signedIn || eleven.key ? null : eleven.cli
|
|
217
|
+
? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
|
|
218
|
+
: { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// fal.
|
|
222
|
+
rows.push({
|
|
223
|
+
id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
|
|
224
|
+
detail: !fal.key ? 'no FAL_KEY in this environment' : fal.valid === true ? 'FAL_KEY is set and fal accepts it' : fal.valid === false ? 'fal refused the FAL_KEY that is set (wrong or revoked)' : 'FAL_KEY is set (fal did not answer just now)',
|
|
225
|
+
unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
|
|
226
|
+
fix: fal.key && fal.valid !== false ? null : { who: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The Claude Code status line (only in Claude Code, only in a studio).
|
|
230
|
+
if (root && env.CLAUDECODE === '1') {
|
|
231
|
+
let on = false;
|
|
232
|
+
try { on = isOurs(JSON.parse(readFileSync(join(root, '.claude', 'settings.local.json'), 'utf8')).statusLine); } catch { on = false; }
|
|
233
|
+
rows.push({
|
|
234
|
+
id: 'statusline', label: 'Status line', need: 'optional', state: on ? 'ok' : 'optional', detail: on ? 'on in this studio' : 'off',
|
|
235
|
+
unlocks: 'the current build\'s stage and a progress bar under the prompt in Claude Code',
|
|
236
|
+
fix: on ? null : { who: 'ai', run: 'npx --no-install homie-studio statusline --install', say: 'one line under the prompt; off again with --remove.' },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const by = Object.fromEntries(rows.map((r) => [r.id, r]));
|
|
241
|
+
const ready = (state) => (state === 'ok' ? 'ready' : state === 'later' ? 'later' : 'not yet');
|
|
242
|
+
const features = [
|
|
243
|
+
{ feature: 'Make games and play them here', state: ready(by.node.state), needs: ['node'] },
|
|
244
|
+
{ feature: 'Two-browser checks and playtests', state: ready(by.chrome.state), needs: ['chrome'] },
|
|
245
|
+
{ feature: 'Sound effects and a theme (free)', state: ready(by.ffmpeg.state), needs: ['ffmpeg'] },
|
|
246
|
+
{ feature: 'Put the studio online', state: ready(by.cloudflare.parts[0].state === 'ok' ? by.cloudflare.parts[1].state : by.cloudflare.parts[0].state), needs: ['cloudflare'] },
|
|
247
|
+
{ feature: 'List games in the homie.rocks directory', state: ready(by.connector.state), needs: ['connector'] },
|
|
248
|
+
{ feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
|
|
249
|
+
{ feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
|
|
250
|
+
{ feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
|
|
251
|
+
];
|
|
252
|
+
const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
|
|
253
|
+
const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
|
|
254
|
+
return {
|
|
255
|
+
ok: true, command: 'setup status', version: STUDIO_VERSION,
|
|
256
|
+
studio: root ? { name: studio?.name ?? null, root } : null,
|
|
257
|
+
rows, features,
|
|
258
|
+
blocking: rows.filter((r) => r.state === 'missing').map((r) => r.id),
|
|
259
|
+
next: now.map((r) => ({ id: r.id, who: r.fix.who, run: r.fix.run ?? null, open: r.fix.open ?? null, say: r.fix.say })),
|
|
260
|
+
meanwhile: meanwhile.map((r) => ({ id: r.id, open: r.fix.open, say: r.fix.say })),
|
|
261
|
+
note: 'Optional rows never block anything: each one is set up the first time a feature needs it.',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The checklist as a person reads it in a terminal. */
|
|
266
|
+
export function formatStatus(r) {
|
|
267
|
+
const lines = [`Setup status${r.studio?.name ? ` for ${r.studio.name}` : ' (before the studio exists)'}`, ''];
|
|
268
|
+
const pad = Math.max(...r.rows.map((x) => x.label.length)) + 2;
|
|
269
|
+
for (const row of r.rows) {
|
|
270
|
+
lines.push(` ${MARK[row.state] ?? '?'} ${row.label.padEnd(pad)}${row.need === 'optional' ? '(optional) ' : row.need === 'recommended' ? '(recommended) ' : ''}${row.parts ? row.parts.map((p) => `${MARK[p.state]} ${p.label}: ${p.detail}`).join(' ') : row.detail}`);
|
|
271
|
+
lines.push(` ${''.padEnd(pad)}unlocks ${row.unlocks}`);
|
|
272
|
+
if (row.fix && row.state !== 'ok') lines.push(` ${''.padEnd(pad)}${row.fix.run ? `fix: ${row.fix.run} ` : ''}${row.fix.open ? `${row.fix.run ? '' : 'fix: '}${row.fix.open} ` : ''}${row.fix.say}`);
|
|
273
|
+
}
|
|
274
|
+
lines.push('', `Ready: ${r.features.map((f) => `${f.feature} ${f.state === 'ready' ? MARK.ok : f.state === 'later' ? MARK.later : MARK.optional}`).join(' · ')}`);
|
|
275
|
+
if (r.next.length) lines.push('', 'Do this now:', ...r.next.map((n) => ` ${MARK.act} ${n.run ?? n.open ?? ''} ${n.say}`));
|
|
276
|
+
if (r.meanwhile.length) lines.push('', 'Safe to do any time, even while you wait for something else:', ...r.meanwhile.map((m) => ` ${m.open} ${m.say}`));
|
|
277
|
+
lines.push('', r.note, `(${MARK.ok} ready ${MARK.act} do this now ${MARK.missing} missing ${MARK.optional} optional, not set up ${MARK.later} checked later ? your AI knows)`);
|
|
278
|
+
return lines.join('\n');
|
|
279
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A build's progress feed (lib/progress.mjs), read for a person at a glance: how far along it is (a percentage and
|
|
3
|
+
* the stage it is in), its checks, its preview and what it spent. The Game Codex page (lib/codex.mjs) and the Claude
|
|
4
|
+
* Code status line (lib/statusline.mjs) both draw from this, so they always agree.
|
|
5
|
+
*
|
|
6
|
+
* Read-only and small on purpose: the status line runs it every few seconds, so it imports nothing but node:fs.
|
|
7
|
+
*/
|
|
8
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const KIND = 'homie-studio-progress';
|
|
12
|
+
const DIR = join('.studio', 'progress');
|
|
13
|
+
const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
|
|
14
|
+
|
|
15
|
+
function read(root, id) {
|
|
16
|
+
if (!BUILD_ID.test(String(id ?? ''))) return null;
|
|
17
|
+
try { const d = JSON.parse(readFileSync(join(root, DIR, `${id}.json`), 'utf8')); return d?.kind === KIND ? d : null; } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The open build's feed (`.studio/progress/current`), or null. */
|
|
21
|
+
export function currentFeedDoc(root) {
|
|
22
|
+
try {
|
|
23
|
+
const id = readFileSync(join(root, DIR, 'current'), 'utf8').trim();
|
|
24
|
+
const doc = read(root, id);
|
|
25
|
+
return doc?.state === 'running' ? doc : null;
|
|
26
|
+
} catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The newest feed of one game, song or video (`id`), or of anything (`id` null): the open one first. */
|
|
30
|
+
export function latestFeedFor(root, id = null) {
|
|
31
|
+
const open = currentFeedDoc(root);
|
|
32
|
+
if (open && (id === null || open.id === id)) return open;
|
|
33
|
+
let names = [];
|
|
34
|
+
try { names = readdirSync(join(root, DIR)).filter((n) => n.endsWith('.json')); } catch { return null; }
|
|
35
|
+
let best = null;
|
|
36
|
+
for (const name of names) {
|
|
37
|
+
const doc = read(root, name.slice(0, -5));
|
|
38
|
+
if (!doc || (id !== null && doc.id !== id)) continue;
|
|
39
|
+
if (!best || String(doc.updatedAt ?? '') > String(best.updatedAt ?? '')) best = doc;
|
|
40
|
+
}
|
|
41
|
+
return best;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PASSED = new Set(['pass', 'skip']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One feed as a person reads it. `percent` counts every stage alike: a finished or skipped stage is whole, the
|
|
48
|
+
* running one is part done (by its checks going green, when it has checks), and a build is 100 only once it passed.
|
|
49
|
+
*/
|
|
50
|
+
export function summarize(doc) {
|
|
51
|
+
if (!doc || !Array.isArray(doc.stages)) return null;
|
|
52
|
+
const checks = Array.isArray(doc.checks) ? doc.checks : [];
|
|
53
|
+
let units = 0;
|
|
54
|
+
for (const s of doc.stages) {
|
|
55
|
+
if (s.state === 'done' || s.state === 'skipped') units += 1;
|
|
56
|
+
else if (s.state === 'running') {
|
|
57
|
+
const own = checks.filter((c) => c.stage === s.id);
|
|
58
|
+
units += own.length ? 0.1 + 0.85 * (own.filter((c) => PASSED.has(c.state)).length / own.length) : 0.35;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const total = Math.max(1, doc.stages.length);
|
|
62
|
+
const percent = doc.state === 'passed' ? 100 : Math.max(0, Math.min(99, Math.round((units / total) * 100)));
|
|
63
|
+
const at = doc.stages.find((s) => s.id === doc.stage) ?? doc.stages.find((s) => s.state === 'running') ?? null;
|
|
64
|
+
const done = doc.stages.filter((s) => s.state === 'done' || s.state === 'skipped').length;
|
|
65
|
+
const spend = doc.spend ?? { unit: 'usd', used: 0, budget: null };
|
|
66
|
+
const money = (n) => (spend.unit === 'credits' ? `${n} credits` : `$${Number(n).toFixed(2)}`);
|
|
67
|
+
return {
|
|
68
|
+
build: doc.build, what: doc.what, id: doc.id ?? null, title: doc.title ?? doc.id ?? 'Build', studio: doc.studio ?? '',
|
|
69
|
+
state: doc.state, percent, stagesDone: done, stagesTotal: doc.stages.length,
|
|
70
|
+
stage: at ? { id: at.id, label: at.label, state: at.state, note: at.note ?? '' } : null,
|
|
71
|
+
// A build that passed without a stage (a check run that never deployed) skipped it.
|
|
72
|
+
stages: doc.stages.map((s) => ({ id: s.id, label: s.label, state: doc.state === 'passed' && s.state === 'pending' ? 'skipped' : s.state, note: s.note ?? '', checks: checks.filter((c) => c.stage === s.id) })),
|
|
73
|
+
checks: {
|
|
74
|
+
total: checks.length,
|
|
75
|
+
pass: checks.filter((c) => PASSED.has(c.state)).length,
|
|
76
|
+
fail: checks.filter((c) => c.state === 'fail').length,
|
|
77
|
+
running: checks.filter((c) => c.state === 'running').length,
|
|
78
|
+
},
|
|
79
|
+
spend: { unit: spend.unit, used: Number(spend.used) || 0, budget: spend.budget ?? null, text: Number(spend.used) || spend.budget !== null ? `${money(Number(spend.used) || 0)}${spend.budget !== null && spend.budget !== undefined ? ` of ${money(spend.budget)}` : ''}` : '' },
|
|
80
|
+
preview: doc.preview?.url ?? null,
|
|
81
|
+
previewImage: doc.preview?.image ?? null,
|
|
82
|
+
previewCaption: doc.preview?.caption ?? '',
|
|
83
|
+
stopping: Boolean(doc.stop?.requested) && doc.state === 'running',
|
|
84
|
+
last: Array.isArray(doc.log) && doc.log.length ? doc.log[doc.log.length - 1].text : '',
|
|
85
|
+
error: doc.error ?? null,
|
|
86
|
+
startedAt: doc.startedAt ?? null, updatedAt: doc.updatedAt ?? null, endedAt: doc.endedAt ?? null,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/lib/look.mjs
CHANGED
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
|
|
8
8
|
* (the studio's stats leave them out).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
13
14
|
|
|
14
|
-
const CHROMES = [
|
|
15
|
-
process.env.CHROME_PATH,
|
|
16
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
19
|
-
].filter(Boolean);
|
|
20
15
|
|
|
21
16
|
export const LOOK_DEVICES = {
|
|
22
17
|
computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
@@ -29,8 +24,8 @@ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi,
|
|
|
29
24
|
|
|
30
25
|
export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
|
|
31
26
|
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
32
|
-
const chrome =
|
|
33
|
-
if (!chrome) return { ok: false, command: 'look', why:
|
|
27
|
+
const chrome = findChrome();
|
|
28
|
+
if (!chrome) return { ok: false, command: 'look', why: noChrome() };
|
|
34
29
|
let puppeteer;
|
|
35
30
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
36
31
|
const base = String(url).replace(/\/+$/, '');
|
|
@@ -39,7 +34,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
|
|
|
39
34
|
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
40
35
|
const browser = await puppeteer.launch({
|
|
41
36
|
executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
|
|
42
|
-
args: [
|
|
37
|
+
args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
43
38
|
});
|
|
44
39
|
const rows = [];
|
|
45
40
|
try {
|
package/lib/port-check.mjs
CHANGED
|
@@ -29,20 +29,14 @@
|
|
|
29
29
|
* Exit 0 only when every row passed.
|
|
30
30
|
*/
|
|
31
31
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
import {
|
|
32
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import { createRequire } from 'node:module';
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
36
37
|
|
|
37
|
-
const CHROMES = [
|
|
38
|
-
process.env.CHROME_PATH,
|
|
39
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
40
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
41
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
42
|
-
].filter(Boolean);
|
|
43
38
|
const GPU = [
|
|
44
|
-
...(
|
|
45
|
-
'--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required',
|
|
39
|
+
...chromeArgs(), '--autoplay-policy=no-user-gesture-required',
|
|
46
40
|
'--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
|
47
41
|
'--no-first-run', '--no-default-browser-check',
|
|
48
42
|
];
|
|
@@ -396,8 +390,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
396
390
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
397
391
|
const base = String(url).replace(/\/+$/, '');
|
|
398
392
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
399
|
-
const exe =
|
|
400
|
-
if (!exe) return { ok: false, command: 'port check', why:
|
|
393
|
+
const exe = findChrome();
|
|
394
|
+
if (!exe) return { ok: false, command: 'port check', why: noChrome() };
|
|
401
395
|
const req = createRequire(join(root ?? process.cwd(), 'package.json'));
|
|
402
396
|
let puppeteer;
|
|
403
397
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { try { puppeteer = req('puppeteer-core'); } catch { return { ok: false, command: 'port check', why: 'puppeteer-core is missing (it comes with @homie-rocks/studio: npm install)' }; } }
|