@homie-rocks/studio 0.17.0 → 0.18.1
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 +28 -16
- package/bin/homie-studio.mjs +53 -9
- package/lib/brain-probe.mjs +154 -0
- package/lib/build.mjs +9 -5
- package/lib/cloudflare.mjs +135 -9
- package/lib/doctor.mjs +18 -2
- package/lib/media.mjs +185 -16
- package/lib/scaffold.mjs +15 -9
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/package.json +2 -2
- package/port/index.ts +4 -0
- package/port/view.ts +213 -0
- package/starters/ember-vale/index.html +8 -3
- package/starters/ember-vale/src/main.ts +172 -63
- package/starters/gem-rush/src/main.ts +46 -9
- package/worker/index.mjs +80 -14
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -110,8 +110,8 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
|
110
110
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
111
111
|
|
|
112
112
|
```sh
|
|
113
|
-
npx -y @homie-rocks/studio@0.
|
|
114
|
-
npx -y @homie-rocks/studio@0.
|
|
113
|
+
npx -y @homie-rocks/studio@0.18.1 upgrade # the plan; changes nothing
|
|
114
|
+
npx -y @homie-rocks/studio@0.18.1 upgrade --apply # after the person agrees
|
|
115
115
|
npm install && npm run build
|
|
116
116
|
```
|
|
117
117
|
|
|
@@ -278,7 +278,7 @@ npx homie-studio agents pass night-rush --label Claude
|
|
|
278
278
|
## Setup status
|
|
279
279
|
|
|
280
280
|
```sh
|
|
281
|
-
npx -y @homie-rocks/studio@0.
|
|
281
|
+
npx -y @homie-rocks/studio@0.18.1 setup status --connector yes # before a studio exists
|
|
282
282
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
283
283
|
```
|
|
284
284
|
|
|
@@ -288,9 +288,16 @@ with the studio's own Wrangler, and the account's email verified: a deploy that
|
|
|
288
288
|
deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
|
|
289
289
|
fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
|
|
290
290
|
unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
|
|
291
|
-
seconds, and never prints a key, a token or an account's name. A studio the Claude app's setup card made
|
|
291
|
+
seconds, and never prints a key, a token or an account's name or id. A studio the Claude app's setup card made
|
|
292
292
|
(`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
|
|
293
293
|
|
|
294
|
+
When a server's AI guides think with Workers AI (0.18.1), a **Workers AI** row checks that the model they use
|
|
295
|
+
(`HOMIE_BRAIN_MODEL` in wrangler.jsonc `vars`, else `@cf/meta/llama-3.1-8b-instruct-fp8-fast`) answers on the
|
|
296
|
+
studio's account: one tiny call through Cloudflare's API with the studio's own Wrangler login (a one-word prompt,
|
|
297
|
+
one token out: about 0.1 of the 10,000 free neurons a day; `lib/brain-probe.mjs`). A model Cloudflare moved to
|
|
298
|
+
Workers Paid (error 5035) or retired (5007) is named, with what to do: pick another model, or, the person's money,
|
|
299
|
+
Workers Paid. A spent daily allowance (3036) is said as that, not as a broken model.
|
|
300
|
+
|
|
294
301
|
## The Game Codex
|
|
295
302
|
|
|
296
303
|
`games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
|
|
@@ -414,21 +421,26 @@ it says the frame rate is not a person's, and judges only seats, rooms and round
|
|
|
414
421
|
|
|
415
422
|
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
416
423
|
payment method: one Worker, one D1 database and two SQLite-backed Durable Objects
|
|
417
|
-
(`Table`, `Lobby`). It never creates
|
|
424
|
+
(`Table`, `Lobby`). It never creates R2. A brand-new Cloudflare account verifies
|
|
418
425
|
its email address before it can run a Worker; a missing `workers.dev` subdomain is
|
|
419
426
|
registered by Wrangler when Claude Code or Codex runs the deploy.
|
|
420
427
|
|
|
421
|
-
Storage for
|
|
428
|
+
Storage for songs and videos is a separate, optional step. Once a studio has it, its big media
|
|
429
|
+
lives in R2 by default:
|
|
422
430
|
|
|
423
431
|
```sh
|
|
424
|
-
npx homie-studio storage add #
|
|
425
|
-
npx homie-studio
|
|
426
|
-
npx homie-studio
|
|
432
|
+
npx homie-studio storage add # the studio's R2 bucket, bound as MEDIA
|
|
433
|
+
npx homie-studio media move --dry-run # what goes: over 1 MiB, or left out of git
|
|
434
|
+
npx homie-studio deploy # moves it (uploaded, read back, SHA-256 checked), then deploys
|
|
427
435
|
```
|
|
428
436
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
437
|
+
Each moved file keeps its address (`/videos/<slug>/<file>.mp4`), served from R2 with byte ranges,
|
|
438
|
+
HEAD, ETags and the site's own cache headers, and the site's static files stop carrying it; the file
|
|
439
|
+
stays in the studio folder, and the committed manifest names its R2 copy, so a deploy from another
|
|
440
|
+
computer or Workers Builds still has it (`media/MEDIA.md`). Cloudflare asks for a payment method on
|
|
441
|
+
the account before R2 works, even inside R2's free tier (10 GB-month of storage, 1 million writes and
|
|
442
|
+
10 million reads a month; no egress fees; then US$0.015 per GB-month), so `storage add` refuses with
|
|
443
|
+
the dashboard link until the account has R2 turned on, and creates nothing.
|
|
432
444
|
The free plan's daily limits (100,000 Worker requests; D1 5 million rows read and 100,000
|
|
433
445
|
written) reset at 00:00 UTC; past them requests fail until the reset and nothing is charged.
|
|
434
446
|
|
|
@@ -446,7 +458,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
446
458
|
| `bin/homie-studio.mjs` | The CLI. |
|
|
447
459
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
448
460
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
449
|
-
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket. |
|
|
461
|
+
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
|
|
450
462
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
|
|
451
463
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
452
464
|
| `lib/progress.mjs`, `lib/setup.mjs` | The progress feed (`progress …`), a build the chat opened (`progress attach`), a change as a pull request (`progress change`, `progress pr`), and `setup attach`. |
|
|
@@ -454,11 +466,11 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
454
466
|
| `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
|
|
455
467
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
456
468
|
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
457
|
-
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort
|
|
458
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from
|
|
469
|
+
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`; `fitView` and `createLabels` (`port/view.ts`): a flat world on every screen (the whole of it where it reads, else filling a phone held upright and following the player) and names that never pile up when bodies crowd (yours placed first, the rest moved or faded). |
|
|
470
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/<game>/watch` watches a live room from any player's view (NETPLAY.md section 16); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site's files or, once moved, from the studio's R2 at the same address; a loose file from `media put` at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
|
|
459
471
|
| `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
|
|
460
472
|
| `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
|
|
461
|
-
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's
|
|
473
|
+
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's R2, or a link). `media list` shows it; `media move` puts the big ones in R2 and records each one's SHA-256; `media put` uploads a loose file to `/media/<key>`. |
|
|
462
474
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
463
475
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
|
464
476
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -32,8 +32,14 @@
|
|
|
32
32
|
* migrations and deploys: the Worker and database are the Deploy button's.
|
|
33
33
|
* homie-studio publish
|
|
34
34
|
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
35
|
-
* homie-studio media
|
|
36
|
-
* homie-studio media
|
|
35
|
+
* homie-studio media list (every song and video page, where each file is served from, what moves to R2)
|
|
36
|
+
* homie-studio media move [<file>...] [--dry-run] [--verify]
|
|
37
|
+
* (with storage: the big files of published songs and videos into R2, each
|
|
38
|
+
* read back and checked by SHA-256 before the site stops carrying it; the
|
|
39
|
+
* address stays the same and the file stays in this folder. With no file:
|
|
40
|
+
* everything over studio.json media.r2Over, 1 MiB unless set, or left out of
|
|
41
|
+
* git. Every deploy does it too. --verify re-checks what R2 holds)
|
|
42
|
+
* homie-studio media put <file> [--as <key>] (a loose file at /media/<key>; for a song or video, media move)
|
|
37
43
|
* homie-studio status
|
|
38
44
|
* homie-studio upgrade [--apply] [--diff]
|
|
39
45
|
* (what this version's template adds to an existing studio: AGENTS.md
|
|
@@ -136,14 +142,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
|
|
|
136
142
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
137
143
|
import { build } from '../lib/build.mjs';
|
|
138
144
|
import { check } from '../lib/check.mjs';
|
|
139
|
-
import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
145
|
+
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
140
146
|
import { setupAttach } from '../lib/setup.mjs';
|
|
141
147
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
142
148
|
import { publish } from '../lib/directory.mjs';
|
|
143
149
|
import { look } from '../lib/look.mjs';
|
|
144
150
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
145
151
|
import { portCheck } from '../lib/port-check.mjs';
|
|
146
|
-
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
152
|
+
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
147
153
|
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
148
154
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
149
155
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
@@ -164,7 +170,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
|
164
170
|
|
|
165
171
|
const argv = process.argv.slice(2);
|
|
166
172
|
const flags = new Map();
|
|
167
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai'];
|
|
173
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify'];
|
|
168
174
|
const positional = [];
|
|
169
175
|
for (let i = 0; i < argv.length; i++) {
|
|
170
176
|
const a = argv[i];
|
|
@@ -210,10 +216,31 @@ function print(result) {
|
|
|
210
216
|
case 'media list':
|
|
211
217
|
for (const kind of ['music', 'videos']) {
|
|
212
218
|
lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
|
|
213
|
-
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
|
|
219
|
+
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from === 'r2' ? 'R2' : f.from}`).join(', ')})`);
|
|
214
220
|
for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
221
|
+
for (const m of result[kind].notes ?? []) lines.push(` note: ${m.item} ${m.file}: ${m.why}`);
|
|
215
222
|
}
|
|
223
|
+
if (result.moves.length) {
|
|
224
|
+
lines.push('', `Big media${result.r2 ? ' for R2' : ''} (over ${result.over === null ? '(off)' : lineOf(result.over)}, or left out of git):`);
|
|
225
|
+
for (const m of result.moves) lines.push(` ${m.path} ${m.bytes ? sizeOf(m.bytes) : ''} ${m.state === 'too-big' ? m.why : m.reason ?? ''}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.storage) lines.push('', result.storage);
|
|
228
|
+
if (result.r2) lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media once these are in. ${result.cost}`);
|
|
229
|
+
break;
|
|
230
|
+
case 'media move': {
|
|
231
|
+
const verb = result.dryRun ? 'Would move' : 'Moved';
|
|
232
|
+
if (result.ok === false && result.needs === 'storage') { lines.push(result.why); break; }
|
|
233
|
+
if (result.off) { lines.push(result.why); break; }
|
|
234
|
+
const todo = result.rows.filter((r) => r.state === 'move');
|
|
235
|
+
if (result.dryRun) lines.push(todo.length ? `${verb} ${todo.length} file(s) to R2 ${result.bucket}:` : 'Nothing to move: every big file is in R2 already, or none is published.', ...todo.map((r) => ` ${r.path} ${sizeOf(r.bytes)} (${r.reason})`));
|
|
236
|
+
else lines.push(result.moved.length ? `${verb} ${result.moved.length} file(s) to R2 ${result.bucket}, each read back and checked by SHA-256:` : 'Nothing moved.', ...result.moved.map((m) => ` ${m.path} ${sizeOf(m.bytes)} -> served at ${m.url} (the same address)`));
|
|
237
|
+
for (const f of result.failed ?? []) lines.push(` NOT moved: ${f.path}: ${f.why}`);
|
|
238
|
+
for (const v of result.verified ?? []) lines.push(` ${v.ok ? 'verified' : 'MISMATCH'}: ${v.path}${v.why ? `: ${v.why}` : ''}`);
|
|
239
|
+
for (const r of result.rows.filter((x) => x.state === 'too-big' || x.state === 'unlisted' || x.state === 'missing')) lines.push(` ${r.state}: ${r.path}: ${r.why}`);
|
|
240
|
+
if (result.next) lines.push('', result.next);
|
|
241
|
+
lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
|
|
216
242
|
break;
|
|
243
|
+
}
|
|
217
244
|
case 'deploy':
|
|
218
245
|
if (result.ci) {
|
|
219
246
|
lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
|
|
@@ -221,7 +248,8 @@ function print(result) {
|
|
|
221
248
|
}
|
|
222
249
|
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
223
250
|
...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
|
|
224
|
-
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
251
|
+
...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
|
|
252
|
+
...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
225
253
|
result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
|
|
226
254
|
break;
|
|
227
255
|
case 'deploy plan':
|
|
@@ -410,6 +438,12 @@ function print(result) {
|
|
|
410
438
|
if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
|
|
411
439
|
}
|
|
412
440
|
if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
|
|
441
|
+
if (result.media) {
|
|
442
|
+
lines.push('', `Big media (songs and videos over ${result.media.over === null ? '(off)' : lineOf(result.media.over)}, or left out of git):`);
|
|
443
|
+
for (const r of result.media.inR2) lines.push(` = ${r.path} in R2`);
|
|
444
|
+
for (const r of result.media.big) lines.push(` > ${r.path} ${r.bytes ? sizeOf(r.bytes) : ''} ${r.state === 'too-big' ? r.why : r.reason ?? ''}`);
|
|
445
|
+
if (result.media.next) lines.push(` ${result.media.next}`);
|
|
446
|
+
}
|
|
413
447
|
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
414
448
|
break;
|
|
415
449
|
}
|
|
@@ -553,11 +587,21 @@ async function main() {
|
|
|
553
587
|
return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
|
|
554
588
|
}
|
|
555
589
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
590
|
+
if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
|
|
556
591
|
if (cmd === 'media' && sub === 'list') {
|
|
557
592
|
const studio = readStudio(root);
|
|
558
593
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
559
|
-
|
|
560
|
-
|
|
594
|
+
// What a deploy would serve (deploy: true), so "from r2" means the site no longer carries the file.
|
|
595
|
+
const view = (kind) => { const r = resolveMedia(root, kind, { r2, deploy: true }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped, notes: r.notes }; };
|
|
596
|
+
const over = r2OverOf(studio);
|
|
597
|
+
const plan = mediaPlan(root, { over });
|
|
598
|
+
const moves = plan.filter((x) => x.state === 'move' || x.state === 'too-big').map(({ abs, held, ...x }) => x);
|
|
599
|
+
const inR2 = plan.filter((x) => x.state === 'r2' || x.state === 'move').reduce((n, x) => n + (x.bytes ?? 0), 0);
|
|
600
|
+
return {
|
|
601
|
+
ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos'), over, moves, inR2,
|
|
602
|
+
storage: r2 ? (moves.length ? 'the next deploy (or media move now) puts these in R2, checked by SHA-256; their addresses stay the same' : 'every big file is in R2') : (moves.length ? 'no storage: these stay on the site (files up to 25 MiB each), and a deploy from another computer or Workers Builds leaves out any file git does not keep. homie-studio storage add (a payment method on the Cloudflare account, asked first) fixes both' : null),
|
|
603
|
+
cost: R2_COST,
|
|
604
|
+
};
|
|
561
605
|
}
|
|
562
606
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
563
607
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio doctor`'s Workers AI row: when a server's AI guides think with Workers AI (brain `workers-ai`), is the
|
|
3
|
+
* model they use (wrangler.jsonc var HOMIE_BRAIN_MODEL, else worker/brain.mjs DEFAULT_MODEL) one this studio's
|
|
4
|
+
* Cloudflare account can run? Cloudflare moves models to the Workers Paid plan from time to time (three on 2026-07-28,
|
|
5
|
+
* error 5035) and retires others (5007); either way the guides fall back to the game's script without a word, so the
|
|
6
|
+
* doctor asks once.
|
|
7
|
+
*
|
|
8
|
+
* The check is ONE tiny call to the model through Cloudflare's REST API (`POST /accounts/:id/ai/run/:model`, a
|
|
9
|
+
* one-word prompt, max_tokens 1: about 0.1 of the 10,000 free neurons a day), with the studio's own Wrangler login
|
|
10
|
+
* (`wrangler auth token`, held in this process only) or CLOUDFLARE_API_TOKEN. It never prints the token, the account's
|
|
11
|
+
* id or its name. A call that cannot be made (not signed in, several accounts and none named) says so and spends
|
|
12
|
+
* nothing.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { DEFAULT_MODEL } from '../worker/brain.mjs';
|
|
17
|
+
import { configPath, workerDir } from './studio.mjs';
|
|
18
|
+
|
|
19
|
+
const MODELS = 'https://developers.cloudflare.com/workers-ai/models/';
|
|
20
|
+
/** A Workers AI model id: @cf/<maker>/<model> (or @hf/…); nothing that could leave the API path. */
|
|
21
|
+
export const MODEL_ID = /^@[a-z0-9-]+\/[A-Za-z0-9._-]+\/[A-Za-z0-9._:-]+$/;
|
|
22
|
+
|
|
23
|
+
const parseJson = (text) => { const s = String(text ?? ''); const i = s.search(/[[{]/); if (i < 0) return null; try { return JSON.parse(s.slice(i)); } catch { return null; } };
|
|
24
|
+
|
|
25
|
+
/** The model the studio's guides think with: wrangler.jsonc "vars".HOMIE_BRAIN_MODEL, else the toolkit's default. */
|
|
26
|
+
export function brainModelOf(root) {
|
|
27
|
+
try {
|
|
28
|
+
const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
|
|
29
|
+
const v = json?.vars?.HOMIE_BRAIN_MODEL;
|
|
30
|
+
if (typeof v === 'string' && v.trim()) return { model: v.trim(), set: true };
|
|
31
|
+
} catch { /* no config: the default */ }
|
|
32
|
+
return { model: DEFAULT_MODEL, set: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether any open server of the studio's games thinks with Workers AI: a game.json seed in the built catalogue, or a
|
|
37
|
+
* row in the live database (one read-only `wrangler d1 execute --remote`). A database without the servers table, or
|
|
38
|
+
* one that cannot be read, says no.
|
|
39
|
+
*/
|
|
40
|
+
export async function usesWorkersAi(root, { exec, env = process.env, accountId = null, bin } = {}) {
|
|
41
|
+
try {
|
|
42
|
+
const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
|
|
43
|
+
if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
|
|
44
|
+
} catch { /* not built */ }
|
|
45
|
+
if (!bin || !existsSync(bin)) return false;
|
|
46
|
+
let db = 'DB';
|
|
47
|
+
try { db = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''))?.d1_databases?.[0]?.binding ?? 'DB'; } catch { /* the default binding */ }
|
|
48
|
+
const r = await exec(bin, ['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"], {
|
|
49
|
+
cwd: workerDir(root), timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) },
|
|
50
|
+
});
|
|
51
|
+
if (r.code !== 0) return false;
|
|
52
|
+
const parsed = parseJson(r.stdout);
|
|
53
|
+
return Number((Array.isArray(parsed) ? parsed[0] : parsed)?.results?.[0]?.n) > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How to call Cloudflare's API as this computer's Wrangler login: headers, or null. Never printed. */
|
|
57
|
+
export async function cloudflareAuth({ exec, env = process.env, bin, cwd } = {}) {
|
|
58
|
+
if (env.CLOUDFLARE_API_TOKEN) return { authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` };
|
|
59
|
+
if (env.CLOUDFLARE_API_KEY && env.CLOUDFLARE_EMAIL) return { 'x-auth-key': env.CLOUDFLARE_API_KEY, 'x-auth-email': env.CLOUDFLARE_EMAIL };
|
|
60
|
+
if (!bin || !existsSync(bin)) return null;
|
|
61
|
+
const r = await exec(bin, ['auth', 'token', '--json'], { cwd, timeout: 15_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
62
|
+
if (r.code !== 0) return null;
|
|
63
|
+
const j = parseJson(r.stdout);
|
|
64
|
+
if (typeof j?.token === 'string' && j.token) return { authorization: `Bearer ${j.token}` };
|
|
65
|
+
if (typeof j?.key === 'string' && typeof j?.email === 'string') return { 'x-auth-key': j.key, 'x-auth-email': j.email };
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What Cloudflare's answer to the probe means (its errors: developers.cloudflare.com/workers-ai/platform/errors/):
|
|
71
|
+
* `{ why, state }` with why one of ok, paid-only, missing, not-allowed, agreement, allowance, busy, auth, other.
|
|
72
|
+
*/
|
|
73
|
+
export function classifyProbe(status, body) {
|
|
74
|
+
const errs = Array.isArray(body?.errors) ? body.errors : [];
|
|
75
|
+
const codes = errs.map((e) => Number(e?.code)).filter(Number.isFinite);
|
|
76
|
+
const msg = errs.map((e) => String(e?.message ?? '')).filter(Boolean).join('; ');
|
|
77
|
+
const has = (c) => codes.includes(c);
|
|
78
|
+
if (status >= 200 && status < 300 && body?.success !== false) return { why: 'ok', state: 'ok', msg };
|
|
79
|
+
if (has(5035) || (status === 403 && /workers paid|paid plan|upgrade/i.test(msg))) return { why: 'paid-only', state: 'act', msg };
|
|
80
|
+
if (has(5007) || /no such model/i.test(msg) || status === 404) return { why: 'missing', state: 'act', msg };
|
|
81
|
+
if (has(5018)) return { why: 'not-allowed', state: 'act', msg };
|
|
82
|
+
if (has(5016) || /agree/i.test(msg)) return { why: 'agreement', state: 'act', msg };
|
|
83
|
+
if (has(3036) || (status === 429 && /free allocation|neurons/i.test(msg))) return { why: 'allowance', state: 'later', msg };
|
|
84
|
+
if (has(3040) || status === 429 || status >= 500) return { why: 'busy', state: 'unknown', msg };
|
|
85
|
+
if (status === 401 || has(10000) || has(10001) || (status === 403 && /auth/i.test(msg))) return { why: 'auth', state: 'unknown', msg };
|
|
86
|
+
if (status === 400) return { why: 'input', state: 'act', msg };
|
|
87
|
+
return { why: 'other', state: 'unknown', msg };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one call. `{ status, body }`, or `{ error }` when the network failed. `fetchFn` stands in for the network in
|
|
92
|
+
* tests; the call is a one-word prompt with max_tokens 1.
|
|
93
|
+
*/
|
|
94
|
+
export async function probeModel({ model, accountId, headers, fetchFn = globalThis.fetch, base = 'https://api.cloudflare.com/client/v4', ms = 12_000 }) {
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetchFn(`${String(base).replace(/\/+$/, '')}/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { ...headers, 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'homie-studio-doctor' },
|
|
99
|
+
body: JSON.stringify({ messages: [{ role: 'user', content: 'Say OK.' }], max_tokens: 1 }),
|
|
100
|
+
signal: AbortSignal.timeout(ms),
|
|
101
|
+
});
|
|
102
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { error: String(error?.name === 'TimeoutError' ? `no answer in ${Math.round(ms / 1000)} s` : error?.message ?? error) };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The doctor's row, or null when no server thinks with Workers AI. `who`: the doctor's Wrangler answer
|
|
110
|
+
* (`signedIn`, `accountIds`); `bin`: the studio's own Wrangler; `remote`: the studio is live, so its database may be
|
|
111
|
+
* read; `exec`, `fetchFn` replaceable for tests.
|
|
112
|
+
*/
|
|
113
|
+
export async function workersAiRow({ root, studio, env = process.env, exec, fetchFn = globalThis.fetch, who = {}, bin, remote = false, cli = 'npx --no-install homie-studio' }) {
|
|
114
|
+
if (!root) return null;
|
|
115
|
+
const accountId = env.CLOUDFLARE_ACCOUNT_ID || studio?.cloudflare?.accountId || (who.accountIds?.length === 1 ? who.accountIds[0] : null);
|
|
116
|
+
if (!(await usesWorkersAi(root, { exec, env, accountId, bin: remote ? bin : null }))) return null;
|
|
117
|
+
const { model, set } = brainModelOf(root);
|
|
118
|
+
const row = {
|
|
119
|
+
id: 'workers-ai', label: 'Workers AI', need: 'for AI guides', model,
|
|
120
|
+
unlocks: 'AI guides that think and talk on servers whose brain is workers-ai, on the studio\'s own Workers AI (free: 10,000 neurons a day for the account)',
|
|
121
|
+
};
|
|
122
|
+
const where = set ? 'HOMIE_BRAIN_MODEL in wrangler.jsonc' : 'the toolkit\'s default';
|
|
123
|
+
const pick = (lead) => ({
|
|
124
|
+
who: 'ai', open: MODELS,
|
|
125
|
+
say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a text-generation model from ${MODELS} that is not marked Workers Paid${set && model !== DEFAULT_MODEL ? ` (or remove it for the default, ${DEFAULT_MODEL})` : ''}, run \`${cli} doctor\` again, then \`npm run deploy\`. Until then the guides answer from the game's script.`,
|
|
126
|
+
});
|
|
127
|
+
const money = ' Moving the account to Workers Paid ($5 a month) also works, but that is the person\'s money: ask them, never decide it.';
|
|
128
|
+
if (!MODEL_ID.test(model)) return { ...row, state: 'act', detail: `${where} is "${model.slice(0, 80)}", which is not a Workers AI model id (@cf/<maker>/<model>)`, fix: pick('') };
|
|
129
|
+
if (who.signedIn === false && !env.CLOUDFLARE_API_TOKEN) return { ...row, state: 'later', detail: `${model} (${where}): checked once Wrangler is signed in`, fix: null };
|
|
130
|
+
if (!accountId) return { ...row, state: 'unknown', detail: `${model}: this login reaches several Cloudflare accounts and studio.json does not say which is the studio's (cloudflare.accountId, written at the first deploy)`, fix: null };
|
|
131
|
+
const headers = await cloudflareAuth({ exec, env, bin, cwd: workerDir(root) });
|
|
132
|
+
if (!headers) return { ...row, state: 'unknown', detail: `${model}: could not ask Cloudflare (no sign-in to call its API with)`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (you approve once in the browser), then run doctor again.' } };
|
|
133
|
+
const answer = await probeModel({ model, accountId, headers, fetchFn, base: env.CLOUDFLARE_API_BASE_URL || undefined });
|
|
134
|
+
// Cloudflare's own words, without the account's id, and short.
|
|
135
|
+
const plain = (s) => String(s ?? '').split(accountId).join('<account>').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
136
|
+
if (answer.error) return { ...row, state: 'unknown', detail: `${model}: this computer's request to Cloudflare failed (${plain(answer.error)}); run doctor again`, fix: null };
|
|
137
|
+
const c = classifyProbe(answer.status, answer.body);
|
|
138
|
+
const said = c.msg ? ` (Cloudflare: "${plain(c.msg)}")` : '';
|
|
139
|
+
switch (c.why) {
|
|
140
|
+
case 'ok': return { ...row, state: 'ok', detail: `${model} answers on this studio's Cloudflare account (${where}; one tiny call just now)`, fix: null };
|
|
141
|
+
case 'paid-only': {
|
|
142
|
+
const fix = pick(model === DEFAULT_MODEL ? 'The toolkit\'s default model is paid-only now. ' : '');
|
|
143
|
+
return { ...row, state: 'act', detail: `${model} needs the Workers Paid plan, and this account is on Workers Free: every guide on a workers-ai server answers from the game's script${said}`, fix: { ...fix, say: fix.say + money } };
|
|
144
|
+
}
|
|
145
|
+
case 'missing': return { ...row, state: 'act', detail: `Cloudflare has no model ${model} (renamed or retired)${said}`, fix: pick('') };
|
|
146
|
+
case 'not-allowed': return { ...row, state: 'act', detail: `this account may not use ${model} (a private model)${said}`, fix: pick('') };
|
|
147
|
+
case 'agreement': return { ...row, state: 'act', detail: `${model} asks for its maker's licence to be agreed once before it is used${said}`, fix: pick('Agreeing to a licence is the person\'s to do, never yours; or simply: ') };
|
|
148
|
+
case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need a text-generation model${said}`, fix: pick('') };
|
|
149
|
+
case 'allowance': return { ...row, state: 'later', detail: `today's free Workers AI allowance (10,000 neurons for the account) is used up, so ${model} could not be checked; the guides answer from the script until 00:00 UTC`, fix: { who: 'person', say: `Nothing to do now: run doctor again after 00:00 UTC. The studio's daily budget (\`${cli} agents brain <game> <server> workers-ai --budget <neurons>\`) keeps the guides under the allowance.` } };
|
|
150
|
+
case 'busy': return { ...row, state: 'unknown', detail: `Workers AI was busy just now (status ${answer.status})${said}; run doctor again in a minute`, fix: null };
|
|
151
|
+
case 'auth': return { ...row, state: 'unknown', detail: `Cloudflare did not let this sign-in run Workers AI (status ${answer.status})${said}`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (the login includes Workers AI), then run doctor again.' } };
|
|
152
|
+
default: return { ...row, state: 'unknown', detail: `${model}: Cloudflare answered ${answer.status}${said}`, fix: null };
|
|
153
|
+
}
|
|
154
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
16
16
|
* the studio's owner (worker/stats-page.mjs), never listed or indexed
|
|
17
17
|
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
18
|
-
* site/dist/music/..., site/dist/videos/... media files the site serves itself
|
|
18
|
+
* site/dist/music/..., site/dist/videos/... media files the site serves itself. A file the studio's R2 has (media
|
|
19
|
+
* move: the same bytes, by SHA-256) is left out of a deploy's build and
|
|
20
|
+
* served from R2 at the same address; a local build still copies it when it
|
|
21
|
+
* fits, for `dev` (whose R2 is empty). Workers Builds (WORKERS_CI=1) builds
|
|
22
|
+
* as a deploy.
|
|
19
23
|
*
|
|
20
24
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
21
25
|
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
@@ -201,7 +205,7 @@ export function seatsFor(g, net = netplayOf(g)) {
|
|
|
201
205
|
return { min: Math.min(max, minNamed ?? 1), max, asked };
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
export async function build(root, { only = null, log = () => {} } = {}) {
|
|
208
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1' } = {}) {
|
|
205
209
|
const require = createRequire(join(root, 'package.json'));
|
|
206
210
|
let esbuild;
|
|
207
211
|
try { esbuild = require('esbuild'); } catch { esbuild = await import('esbuild'); }
|
|
@@ -270,9 +274,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
270
274
|
// Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
|
|
271
275
|
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
272
276
|
let media = null;
|
|
273
|
-
if (!only) media = buildMedia(root, dist, { r2, log });
|
|
277
|
+
if (!only) media = buildMedia(root, dist, { r2, deploy, log });
|
|
274
278
|
else {
|
|
275
|
-
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
279
|
+
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [], notes: [] }; } catch { media = { songs: [], videos: [], skipped: [], notes: [] }; }
|
|
276
280
|
}
|
|
277
281
|
// The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
|
|
278
282
|
const theme = readTheme(root, { log });
|
|
@@ -345,7 +349,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
345
349
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
346
350
|
return {
|
|
347
351
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
348
|
-
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
352
|
+
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped, mediaNotes: media.notes ?? [],
|
|
349
353
|
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
350
354
|
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
351
355
|
};
|