@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 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.17.0 upgrade # the plan; changes nothing
114
- npx -y @homie-rocks/studio@0.17.0 upgrade --apply # after the person agrees
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.17.0 setup status --connector yes # before a studio exists
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 or binds R2. A brand-new Cloudflare account verifies
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 large media (songs, videos, big art) is a separate, optional step:
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 # an R2 bucket, bound as MEDIA, served at /media/<key>
425
- npx homie-studio deploy
426
- npx homie-studio media put music/theme.wav
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
- Cloudflare asks for a payment method on the account before R2 works, even inside R2's free
430
- tier (10 GB-month of storage, 1 million writes and 10 million reads a month), so `storage add`
431
- refuses with the dashboard link until the account has R2 turned on, and creates nothing.
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 storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
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 storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
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
 
@@ -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 put <file> [--as <key>]
36
- * homie-studio media list
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}`), '', `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' : ''}.`,
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
- const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); 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 }; };
560
- return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
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 (R2 keys are served from R2)
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
  };