@homie-rocks/studio 0.10.0 → 0.12.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/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
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `homie-studio players` — the studio's players, for its owner (worker/players.mjs, saves/SAVES.md).
3
+ *
4
+ * homie-studio players [--url <site>] how many have accounts, guests with saves, who played this week
5
+ * homie-studio players owner [--url <site>] a one-time link that marks the owner's OWN player account as the
6
+ * owner's (open it in the browser where they are signed in on the site)
7
+ * homie-studio players owner --revoke no account is the owner's any more
8
+ *
9
+ * The proof of ownership is the studio's own Cloudflare login, as for `stats`: the link's key is minted here and
10
+ * only its SHA-256 goes into the studio's D1 (through Wrangler). The link works once, for 30 minutes.
11
+ */
12
+ import { runner } from './cloudflare.mjs';
13
+ import { loopback, mint, siteOf, statsShow } from './stats.mjs';
14
+
15
+ export async function playersShow(root, { url } = {}) {
16
+ const s = await statsShow(root, { url, range: '7d' });
17
+ if (!s.ok) return { ...s, command: 'players' };
18
+ if (!s.players) return { ok: false, command: 'players', why: 'this studio\'s D1 has no player tables yet: `npm run deploy` applies migration 0004_players.sql (or `npm run dev` for a local site)' };
19
+ return { ok: true, command: 'players', site: s.site, players: s.players, note: 'Counts only. A player\'s name, passkeys and saves are theirs; the back office lists names, never a passkey or an email.' };
20
+ }
21
+
22
+ export function playersOwner(root, { url, revoke = false } = {}) {
23
+ const { studio, site } = siteOf(root, url);
24
+ if (!site) return { ok: false, command: 'players owner', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
25
+ const local = loopback(site);
26
+ if (revoke) {
27
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
28
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "UPDATE players SET owner = 0; DELETE FROM stats_keys WHERE kind = 'player-owner';"]);
29
+ return res.code === 0 ? { ok: true, command: 'players owner', site, revoked: true, message: 'No player account is the owner\'s now. `homie-studio players owner` marks one again.' }
30
+ : { ok: false, command: 'players owner', why: res.out.trim().split('\n').slice(-3).join(' ') };
31
+ }
32
+ const k = mint(root, studio, 'player-owner', 30 * 60_000, { local });
33
+ if (!k.ok) return { ok: false, command: 'players owner', why: k.why };
34
+ return {
35
+ ok: true, command: 'players owner', site, link: `${site}/account/?owner=${k.key}`, expiresAt: k.expiresAt,
36
+ use: 'Give this link to the studio\'s owner to open in the browser where they play: they sign in (or make an account with a passkey) on that page, then press "Yes, this account is mine". It works once, within 30 minutes. It is theirs alone; do not post it anywhere.',
37
+ };
38
+ }
package/lib/progress.mjs CHANGED
@@ -19,6 +19,10 @@
19
19
  * knows (the plan, money spent, a song's lyric check, a video's shots) with the other `progress` commands.
20
20
  * WITHOUT AN OPEN FEED NOTHING CHANGES: every command behaves exactly as before.
21
21
  *
22
+ * WHERE A PERSON SEES IT: the Claude app's build card (shared feeds), the Build status tab of the game's codex page
23
+ * (lib/codex.mjs: it redraws itself at every change), and Claude Code's status line (lib/statusline.mjs), all read
24
+ * through lib/feed-summary.mjs so they agree.
25
+ *
22
26
  * SHARED (`progress start --share`): the directory named in studio.json (homie.rocks by default) keeps a copy for
23
27
  * 24 hours, so the Homie MCP tool `build_progress { build }` can show it in the Claude app, and answers every
24
28
  * write with whether the person pressed Stop there. It receives the feed and nothing else: never a key, a file
@@ -51,6 +55,7 @@
51
55
  import { createHash, randomBytes } from 'node:crypto';
52
56
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
53
57
  import { extname, join } from 'node:path';
58
+ import { refreshCodexFile } from './codex.mjs';
54
59
  import { readStudio, siteUrl } from './studio.mjs';
55
60
 
56
61
  export const PROGRESS_KIND = 'homie-studio-progress';
@@ -85,9 +90,10 @@ const currentPath = (root) => join(dir(root), 'current');
85
90
  const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
86
91
 
87
92
  /*
88
- * A CLAUDE CODE CLOUD SESSION ON THE DEFAULT NETWORK. Its "Trusted" network reaches package registries and GitHub,
89
- * not homie.rocks: the session's proxy answers 403 with `x-deny-reason: host_not_allowed`. Say which setting lets
90
- * the card follow the build, instead of "did not answer"; the build itself goes on with its local feed.
93
+ * A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
94
+ * in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
95
+ * `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
96
+ * instead of "did not answer"; the build itself goes on with its local feed.
91
97
  */
92
98
  export function networkWhy(res, at) {
93
99
  if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
@@ -245,6 +251,8 @@ export class Feed {
245
251
  change(fn, { soon = false } = {}) {
246
252
  const d = mutateDoc(this.root, this.id, fn);
247
253
  if (d) schedule(this.root, this.id, { soon });
254
+ // The game's codex page on this computer (.studio/codex/<id>.html), when there is one, shows the change too.
255
+ if (d?.what === 'game' && d.id) refreshCodexFile(this.root, d.id);
248
256
  return d;
249
257
  }
250
258
 
package/lib/scaffold.mjs CHANGED
@@ -24,6 +24,7 @@ import { homedir } from 'node:os';
24
24
  import { dirname, join, relative, resolve } from 'node:path';
25
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
26
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
+ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
27
28
  import { themeFile } from './site.mjs';
28
29
  import { PACKAGE_ROOT } from './studio.mjs';
29
30
 
@@ -149,6 +150,10 @@ studio's pinned copy, never a registry lookup of the bare name.
149
150
  \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
150
151
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
151
152
  nothing sent anywhere; house QA and \`check\` runs are not counted.
153
+ - \`npx --no-install homie-studio players\` — how many players have accounts here (and guests, and who played
154
+ this week): counts and names for the owner, never a passkey or an email. \`players owner\` gives the owner a
155
+ one-time link that marks their own player account (a passkey on this site) as the owner's, so their games and
156
+ the studio's back office recognise them.
152
157
  - \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
153
158
  \`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
154
159
  sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
@@ -191,6 +196,31 @@ studio's pinned copy, never a registry lookup of the bare name.
191
196
  a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
192
197
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
193
198
  - A game's id is its URL (\`/<id>/\`); keep it once published.
199
+ - **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
200
+ a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
201
+ \`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
202
+ player and game, versioned, offline-tolerant, in this studio's own D1. Pressing Play needs no account; a guest's
203
+ progress stays on that device until they make a passkey account (at \`/account/\`, or the game's own button), and
204
+ then it follows them to every device. Lifetime stats and a hardcore "hall of the fallen" are in it too. The
205
+ \`ember-vale\` starter shows the whole pattern.
206
+
207
+ ## The Game Codex and progress
208
+
209
+ - **Every game has a Game Codex:** \`games/<id>/CODEX.md\`, its plan in plain words for everyone who makes it, coder
210
+ or not: concept, world, characters, art direction, controls per device, rooms and players, music and sound,
211
+ milestones, open questions, and under Latest the decisions as they are made, newest first, each with its date.
212
+ It is the source of truth: when a decision changes, change the codex in the same change.
213
+ \`npx --no-install homie-studio codex new <id>\` starts one with every section.
214
+ - \`npx --no-install homie-studio codex <id>\` draws it as a page in the game's own look
215
+ (\`.studio/codex/<id>.html\`, \`--open\` opens it; \`--artifact\` makes a copy to publish as a Claude artifact).
216
+ The site has it too, for the owner only and never listed: \`codex link <id>\` gives the one-time sign-in link.
217
+ - **Progress:** \`npx --no-install homie-studio progress start <id> --title "<what this build does>"\` opens a
218
+ build's progress feed; build, check and deploy report into it. The codex page's Build status tab redraws itself
219
+ as it moves (stages, checks going green, how to try it, spend), and \`homie-studio statusline --install\` puts
220
+ one line of it under the prompt in Claude Code.
221
+ - \`npx --no-install homie-studio setup status\` says what this computer and the person's accounts have (Node,
222
+ Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
223
+ block anything.
194
224
 
195
225
  ## Rules
196
226
 
@@ -288,6 +318,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
288
318
  const GITIGNORE = `node_modules/
289
319
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
290
320
  .studio/
321
+ # This person's own Claude Code settings for this studio (the status line, for one).
322
+ .claude/settings.local.json
291
323
  site/dist/
292
324
  site/.wrangler/
293
325
  .wrangler/
@@ -339,7 +371,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
339
371
  products: ['Workers', 'D1', 'Durable Objects'],
340
372
  bindings: {
341
373
  STUDIO_NAME: { description: 'Your studio\'s name, as its site shows it (for example **Night Owls**). Claude can change it later.' },
342
- DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor) and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
374
+ DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor), player accounts and cloud saves for games that keep them, and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
343
375
  },
344
376
  },
345
377
  };
@@ -364,6 +396,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
364
396
  `,
365
397
  'site/migrations/0001_studio.sql': MIGRATION,
366
398
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
399
+ [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
367
400
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
368
401
  '.claude/skills/.gitkeep': '',
369
402
  };
@@ -433,6 +466,23 @@ export function ensureStatsMigration(root) {
433
466
  return `site/migrations/${STATS_MIGRATION_FILE}`;
434
467
  }
435
468
 
469
+ /**
470
+ * A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
471
+ * so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
472
+ */
473
+ export function ensurePlayersMigration(root) {
474
+ const file = join(root, 'site', 'migrations', PLAYERS_MIGRATION_FILE);
475
+ if (existsSync(file)) return null;
476
+ mkdirSync(dirname(file), { recursive: true });
477
+ writeFileSync(file, PLAYERS_MIGRATION);
478
+ return `site/migrations/${PLAYERS_MIGRATION_FILE}`;
479
+ }
480
+
481
+ /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
482
+ export function ensureMigrations(root) {
483
+ return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
484
+ }
485
+
436
486
  /**
437
487
  * A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
438
488
  * deploy writes the workers.dev address there. Returns true when it added the line.
package/lib/site.mjs CHANGED
@@ -214,9 +214,9 @@ export function readPosts(root, { games = [], songs = [], videos = [], log = ()
214
214
 
215
215
  export const PARTIALS = ['head', 'header', 'footer', 'home', 'game', 'post'];
216
216
  /** Paths a page of the studio's own can never take: the rooms, the game files, the API and the owner's pages. */
217
- const RESERVED_PAGE = /^\/(?:api|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
217
+ const RESERVED_PAGE = /^\/(?:api|account|_studio|_homie|_site|media|\.well-known|games\/[^/]+)\/|^\/[a-z0-9][a-z0-9-]{0,39}\/(?:play|tv|live|api|__[a-z]+)(?:\/|$)/;
218
218
  /** Top-level names site/public must not take (the build's own output). */
219
- const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media']);
219
+ const RESERVED_PUBLIC = new Set(['games', 'music', 'videos', 'games.json', '_site', '_homie', '_studio', 'api', 'media', 'account']);
220
220
 
221
221
  function walk(dir, fn, rel = '') {
222
222
  for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
package/lib/stats.mjs CHANGED
@@ -21,9 +21,9 @@ const HOUR = 3600_000;
21
21
  const RANGES = new Set(['1d', '7d', '30d', '90d']);
22
22
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
23
 
24
- const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
24
+ export const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
25
25
 
26
- function siteOf(root, url) {
26
+ export function siteOf(root, url) {
27
27
  const studio = readStudio(root);
28
28
  const site = String(url || siteUrl(root, studio) || '').replace(/\/+$/, '');
29
29
  if (!/^https?:\/\//.test(site)) return { studio, site: null };
@@ -31,7 +31,7 @@ function siteOf(root, url) {
31
31
  }
32
32
 
33
33
  /** Put a key's hash into the studio's D1 (remote, or local for a loopback site). The key never leaves this machine. */
34
- function mint(root, studio, kind, ttlMs, { local }) {
34
+ export function mint(root, studio, kind, ttlMs, { local }) {
35
35
  const key = `hsk_${randomBytes(24).toString('hex')}`;
36
36
  const hash = createHash('sha256').update(key).digest('hex');
37
37
  const now = Date.now();