@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/README.md +86 -5
- package/bin/homie-studio.mjs +91 -4
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +11 -3
- package/lib/cloudflare.mjs +11 -7
- package/lib/codex.mjs +665 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +11 -3
- package/lib/scaffold.mjs +51 -1
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +11 -1
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +35 -2
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
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/players.mjs
ADDED
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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();
|