@homie-rocks/studio 0.9.0 → 0.10.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 CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y @homie-rocks/studio new ./night-owls --name "Night Owls"
14
14
  cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
@@ -22,6 +22,32 @@ npx homie-studio stats # the studio's own number
22
22
  npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
23
23
  ```
24
24
 
25
+ ## From GitHub, with Cloudflare's own CI (Workers Builds, Previews, Deploy to Cloudflare)
26
+
27
+ A studio keeps `wrangler.jsonc` at its root (a studio made before 0.10.0 keeps `site/wrangler.jsonc`, and every
28
+ command still finds it), so Cloudflare's Workers Builds can build and deploy it from its GitHub repository with no
29
+ computer involved:
30
+
31
+ | Workers Builds runs | On | What happens |
32
+ | --- | --- | --- |
33
+ | `npm run build` | every push | `homie-studio build`: the site, with the commit it is built from |
34
+ | `npm run deploy` | the production branch | In Workers Builds (`WORKERS_CI=1`), `homie-studio deploy` only applies the D1 migrations (by binding name) and deploys: the Worker and database are Cloudflare's to make, so it creates and refuses nothing. The first deploy creates the database as it goes. |
35
+ | `npx wrangler preview` | every other branch | A **Preview**: its own URL, and its own Durable Object namespace, so a branch's public rooms never meet production's. A Preview has no D1: it counts nothing into the studio's stats and never claims itself in the directory. |
36
+
37
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/homie-rocks/homie/tree/main/template)
38
+
39
+ The button copies the public template (`template/` in this repository, exactly what `homie-studio new --template`
40
+ writes) into the person's GitHub, makes the Worker, the D1 database and the rooms on their Cloudflare account,
41
+ and connects Workers Builds. The site plays its first game (Gem Rush) the moment it is up, and its Home has a
42
+ **Connect to Claude** band: one tap links it to the Claude chat that set it up (the Homie MCP tool `studio_setup`).
43
+ In the Claude Code session that works in the repository, `homie-studio setup attach <hs_…>` then gives the studio
44
+ the name chosen in the chat and removes the band.
45
+
46
+ **The site claims itself in the directory.** The homie.rocks directory lists a studio only when its site serves
47
+ the claim the directory handed out for that address. From 0.10.0 the site asks for it the first time its
48
+ `/.well-known/homie-studio.json` is read (by `studio_publish`, or by `deploy` reading it once) and keeps it in
49
+ its D1; nothing is stored by hand. studio.json `homie.directory: false` keeps a studio out.
50
+
25
51
  ## The site: the hub's shape, and a landing for every game
26
52
 
27
53
  Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
@@ -46,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
46
72
  A studio pins one version, so nothing changes until it asks. To take a newer one:
47
73
 
48
74
  ```sh
49
- npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade # the plan; changes nothing
50
- npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade --apply # after the person agrees
75
+ npx -y @homie-rocks/studio@0.10.0 upgrade # the plan; changes nothing
76
+ npx -y @homie-rocks/studio@0.10.0 upgrade --apply # after the person agrees
51
77
  npm install && npm run build
52
78
  ```
53
79
 
@@ -108,6 +134,27 @@ check green ends the build. The AI marks what only it knows with `progress stage
108
134
  one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
109
135
  browsers and says so, and nothing already built or deployed is undone.
110
136
 
137
+ **From the Claude app.** The chat can open the build first (the Homie MCP tool `build_open`, or the "Build it"
138
+ card on a make, port or remix) and hand the work to a Claude Code session with a prefilled prompt. The session
139
+ takes the build once, with no key in the prompt, and the card follows it; the change goes out as a pull request
140
+ the person merges with one tap:
141
+
142
+ ```sh
143
+ npx homie-studio progress attach hb_… # this session takes the build the chat opened (once)
144
+ npx homie-studio progress change "Crowns spawn twice as often" # its mark, in changes/, committed with the change
145
+ git switch -c faster-crowns && git add -A && git commit -m "Faster crowns" && git push -u origin HEAD
146
+ gh pr create --fill # then:
147
+ npx homie-studio progress pr --url https://github.com/<owner>/<repo>/pull/<n>
148
+ ```
149
+
150
+ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the pull request in GitHub, where the
151
+ person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
152
+ manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
153
+
154
+ A Claude Code cloud session's default network ("Trusted") does not reach homie.rocks; the toolkit says so, and
155
+ names the setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The
156
+ build goes on with its local feed.
157
+
111
158
  The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
112
159
  directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
113
160
  hours so the Claude app can show it: the feed only (plain bounded text, pictures under
@@ -121,6 +168,14 @@ feed every command behaves exactly as before. `lib/progress.mjs` has the whole f
121
168
  names the Cloudflare account, often after its owner. A custom domain goes in studio.json
122
169
  as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
123
170
 
171
+ ## Checks on a computer without a GPU
172
+
173
+ `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
174
+ session, GitHub Actions, a container) `npx homie-studio chrome install` puts Chrome for Testing in
175
+ the home folder's `.cache/homie-studio` (from storage.googleapis.com, which a cloud session's default network reaches), and WebGL
176
+ renders with SwiftShader. `check` reports how fast each browser drew the game and on what renderer; on SwiftShader
177
+ it says the frame rate is not a person's, and judges only seats, rooms and rounds.
178
+
124
179
  ## No payment method needed
125
180
 
126
181
  `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
@@ -157,9 +212,11 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
157
212
  | `bin/homie-studio.mjs` | The CLI. |
158
213
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
159
214
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
160
- | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
161
- | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. |
215
+ | `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. |
216
+ | `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). |
162
217
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
218
+ | `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`. |
219
+ | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
163
220
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
164
221
  | `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. |
165
222
  | `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`. |
@@ -171,9 +228,10 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
171
228
  | `starters/gem-rush/` | The reference multiplayer starter. |
172
229
 
173
230
  Versions are immutable: a published version never changes, so a studio that pinned one
174
- never changes by surprise. A change ships as a new `version`, on the npm registry as
175
- `@homie-rocks/studio` and as a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`
176
- (which is what `homie-studio new` pins).
231
+ never changes by surprise. A change ships as a new `version` on the npm registry as
232
+ `@homie-rocks/studio`, which is what `homie-studio new` pins (the exact version; package-lock.json keeps its
233
+ integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default. Older studios
234
+ pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which stays.
177
235
 
178
236
  ## Beta
179
237
 
@@ -4,7 +4,8 @@
4
4
  * put its site on the studio's own Cloudflare (free plan, no payment method), and
5
5
  * list its games in the homie.rocks directory.
6
6
  *
7
- * homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install]
7
+ * homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install] [--template]
8
+ * (--template: the public "Deploy to Cloudflare" template: a first game and a Connect band)
8
9
  * homie-studio starters
9
10
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
10
11
  * homie-studio game remix <source.json url> --id <new id>
@@ -12,12 +13,16 @@
12
13
  * homie-studio build [<id>]
13
14
  * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
14
15
  * homie-studio check <id> [--url <site>] [--shots <dir>]
16
+ * homie-studio chrome [install] [--fresh] (which Chrome the checks use; on Linux, `install` fetches Chrome for Testing;
17
+ * --fresh fetches it even when the machine has a Chrome)
15
18
  * homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
16
19
  * (the site's pages on a computer and a phone, as pictures, with what is wrong)
17
20
  * homie-studio port plan <game folder>
18
21
  * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
19
22
  * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
20
23
  * homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
24
+ * In Cloudflare's Workers Builds (WORKERS_CI=1, or --ci) it only applies the D1
25
+ * migrations and deploys: the Worker and database are the Deploy button's.
21
26
  * homie-studio publish
22
27
  * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
23
28
  * homie-studio media put <file> [--as <key>]
@@ -46,11 +51,18 @@
46
51
  * homie-studio progress log "<one line>"
47
52
  * homie-studio progress stop (ask the running build to stop at its next safe point)
48
53
  * homie-studio progress end passed|failed|stopped [--note "<one line>"]
54
+ * homie-studio progress attach <hb_…> (the build the Claude app opened with build_open: this session takes it, once)
55
+ * homie-studio progress change "<what the change does>" (its mark in changes/, committed with the change)
56
+ * homie-studio progress pr --url <pull request> [--state open|merged|closed] [--files n --additions n --deletions n]
57
+ * [--preview <Preview URL>] (the card's Publish opens it for the person's merge)
49
58
  * homie-studio progress show [<build>]
50
59
  * While a feed is open, build, check, port check and deploy report
51
60
  * into it (stage, each check going green, a preview picture) and stop
52
61
  * when asked. Without one, nothing changes.
53
62
  *
63
+ * homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
64
+ * learn its live address, and give a template copy its real name)
65
+ *
54
66
  * Every command prints a few lines for a person; --json prints the result.
55
67
  */
56
68
  import { spawn, spawnSync } from 'node:child_process';
@@ -58,7 +70,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
58
70
  import { basename, dirname, join, relative, resolve } from 'node:path';
59
71
  import { build } from '../lib/build.mjs';
60
72
  import { check } from '../lib/check.mjs';
61
- import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
73
+ import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
74
+ import { setupAttach } from '../lib/setup.mjs';
75
+ import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
62
76
  import { publish } from '../lib/directory.mjs';
63
77
  import { look } from '../lib/look.mjs';
64
78
  import { importPort, planPort } from '../lib/port.mjs';
@@ -66,14 +80,14 @@ import { portCheck } from '../lib/port-check.mjs';
66
80
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
67
81
  import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
68
82
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
69
- import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
83
+ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
70
84
  import { STUDIO_VERSION } from '../lib/version.mjs';
71
85
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
72
- import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
86
+ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
73
87
 
74
88
  const argv = process.argv.slice(2);
75
89
  const flags = new Map();
76
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
90
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh'];
77
91
  const positional = [];
78
92
  for (let i = 0; i < argv.length; i++) {
79
93
  const a = argv[i];
@@ -123,10 +137,14 @@ function print(result) {
123
137
  }
124
138
  break;
125
139
  case 'deploy':
140
+ if (result.ci) {
141
+ 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}`));
142
+ break;
143
+ }
126
144
  lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
127
145
  ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
128
146
  ...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' : ''}.`,
129
- result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
147
+ 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).');
130
148
  break;
131
149
  case 'deploy plan':
132
150
  lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
@@ -179,6 +197,10 @@ function print(result) {
179
197
  case 'stats share':
180
198
  lines.push(result.message);
181
199
  break;
200
+ case 'setup attach':
201
+ lines.push(result.message, ...(result.site ? [` live site: ${result.site}`] : []), ...(result.renamed?.length ? [' changed (commit these on a branch):', ...result.renamed.map((f) => ` ${f}`)] : []), ...(result.next ?? []).map((n) => ` next: ${n}`));
202
+ break;
203
+ case 'progress attach':
182
204
  case 'progress start':
183
205
  lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
184
206
  ` feed: ${result.file}`,
@@ -230,9 +252,15 @@ function print(result) {
230
252
  if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
231
253
  break;
232
254
  }
255
+ case 'chrome':
256
+ case 'chrome install':
257
+ lines.push(result.already || result.command === 'chrome' ? `Chrome: ${result.chrome}` : `Installed Chrome for Testing ${result.buildId}: ${result.chrome}`);
258
+ break;
233
259
  case 'check':
234
260
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
235
- ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
261
+ ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
262
+ ...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
263
+ ...(result.software ? [` ${result.software}`] : []));
236
264
  break;
237
265
  default:
238
266
  lines.push(JSON.stringify(result, null, 2));
@@ -248,12 +276,15 @@ async function main() {
248
276
  return { ok: true, command: 'help' };
249
277
  }
250
278
  if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
251
- if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install') });
279
+ if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
252
280
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
253
281
 
254
282
  if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
283
+ if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
284
+ if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
255
285
  const root = requireStudio();
256
286
  if (cmd === 'progress') return progressCommand(root, sub);
287
+ if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
257
288
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
258
289
  if (cmd === 'port' && sub === 'check') {
259
290
  const game = positional[2] ?? listGames(root)[0]?.id;
@@ -295,6 +326,8 @@ async function main() {
295
326
  return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
296
327
  }
297
328
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
329
+ // Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
330
+ if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
298
331
  if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
299
332
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
300
333
  if (cmd === 'storage') {
@@ -372,6 +405,12 @@ async function progressCommand(root, sub) {
372
405
  budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
373
406
  });
374
407
  }
408
+ if (sub === 'attach') {
409
+ return startProgress(root, {
410
+ attach: arg ?? null, what: flags.get('what') ?? 'game', id: flags.get('id') ?? null, title: flags.get('title'),
411
+ budget: flags.get('budget'), unit: flags.get('unit'), directory: flags.get('homie'),
412
+ });
413
+ }
375
414
  if (sub === 'show') {
376
415
  const id = arg ?? currentId(root) ?? flags.get('build');
377
416
  const doc = readFeed(root, id);
@@ -400,8 +439,10 @@ async function progressCommand(root, sub) {
400
439
  }
401
440
  case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
402
441
  case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
442
+ case 'change': { const c = recordChange(root, id, positional.slice(2).join(' ') || flags.get('title')); return { ...done(`change recorded: commit ${c.file} with the change`), file: c.file, mark: c.mark }; }
443
+ case 'pr': feed.pr({ url: flags.get('url'), state: flags.get('state') ?? 'open', title: flags.get('title'), branch: flags.get('branch'), files: flags.get('files'), additions: flags.get('additions'), deletions: flags.get('deletions'), preview: flags.get('preview'), site: siteUrl(root) }); return done(`pull request on the card: ${feed.doc.change.url}`);
403
444
  case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
404
- default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
445
+ default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, attach, stage, check, preview, spend, shot, song, log, change, pr, stop, end, show)` };
405
446
  }
406
447
  } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
407
448
  }
@@ -411,7 +452,7 @@ async function progressCommand(root, sub) {
411
452
  * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
412
453
  * never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
413
454
  */
414
- const devFile = (root) => join(root, 'site', '.wrangler', 'homie-dev.json');
455
+ const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
415
456
  function processOfStudio(pid, root, kind) {
416
457
  if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
417
458
  try { process.kill(pid, 0); } catch { return false; }
@@ -450,11 +491,11 @@ async function dev(root) {
450
491
  const added = ensureStatsMigration(root);
451
492
  if (added) log(`added ${added} (the studio's own stats)`);
452
493
  await new Promise((done) => {
453
- const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
494
+ const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
454
495
  m.on('close', done);
455
496
  });
456
497
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
457
- const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: join(root, 'site'), env, stdio: 'inherit' });
498
+ const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: workerDir(root), env, stdio: 'inherit' });
458
499
  mkdirSync(dirname(devFile(root)), { recursive: true });
459
500
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
460
501
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
@@ -477,7 +518,7 @@ function mediaPut(root, file, as) {
477
518
  const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
478
519
  const bin = wranglerBin(root);
479
520
  return new Promise((done) => {
480
- const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
521
+ const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: workerDir(root), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
481
522
  p.on('close', (code) => {
482
523
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
483
524
  // The manifest is committed: it names the file by its path on the site, never the workers.dev address.
package/lib/build.mjs CHANGED
@@ -82,6 +82,34 @@ export function sourceOf(dir, id) {
82
82
  return { v: 1, kind: 'homie-game-source', id, files };
83
83
  }
84
84
 
85
+ /*
86
+ * WHAT THIS BUILD IS, for the site's manifest: the commit and branch (Cloudflare's Workers Builds names them in
87
+ * WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH; elsewhere git is asked), when it was built, and the marks of the
88
+ * newest changes in changes/ (lib/progress.mjs recordChange), so the Claude app's card can tell when a merged pull
89
+ * request is live. Nothing here names a person or an account.
90
+ */
91
+ export function buildInfo(root) {
92
+ const git = (args) => { try { const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 }); return r.status === 0 ? r.stdout.trim() : null; } catch { return null; } };
93
+ const commit = /^[a-f0-9]{40}$/.test(process.env.WORKERS_CI_COMMIT_SHA ?? '') ? process.env.WORKERS_CI_COMMIT_SHA : git(['rev-parse', 'HEAD']);
94
+ const branch = String(process.env.WORKERS_CI_BRANCH || git(['rev-parse', '--abbrev-ref', 'HEAD']) || '').slice(0, 100) || null;
95
+ const marks = [];
96
+ const dir = join(root, 'changes');
97
+ if (existsSync(dir)) {
98
+ for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
99
+ const c = readJson(join(dir, name));
100
+ if (c && /^[a-f0-9]{16}$/.test(String(c.change)) && !Number.isNaN(Date.parse(c.at))) marks.push({ mark: c.change, at: c.at });
101
+ }
102
+ }
103
+ marks.sort((a, b) => b.at.localeCompare(a.at));
104
+ return {
105
+ commit: /^[a-f0-9]{40}$/.test(String(commit)) ? commit : null,
106
+ branch: branch && /^[A-Za-z0-9._/-]+$/.test(branch) && branch !== 'HEAD' ? branch : null,
107
+ at: new Date().toISOString(),
108
+ ci: process.env.WORKERS_CI === '1' ? 'workers-builds' : null,
109
+ changes: [...new Set(marks.map((m) => m.mark))].slice(0, 50),
110
+ };
111
+ }
112
+
85
113
  function readJson(path) {
86
114
  try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
87
115
  }
@@ -207,9 +235,16 @@ export async function build(root, { only = null, log = () => {} } = {}) {
207
235
  ...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
208
236
  },
209
237
  // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
210
- ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
238
+ ...(studio.stats?.share === true ? { stats: { share: true } } : {}),
211
239
  // studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
212
240
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
241
+ // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
242
+ // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
243
+ directory: /^https:\/\/[a-z0-9.-]+$/i.test(process.env.HOMIE_DIRECTORY ?? '') ? process.env.HOMIE_DIRECTORY
244
+ : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
245
+ // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
246
+ ...(studio.template === true ? { template: true } : {}),
247
+ build: buildInfo(root),
213
248
  },
214
249
  games: rows,
215
250
  songs: media.songs,
package/lib/check.mjs CHANGED
@@ -16,16 +16,10 @@
16
16
  * the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
17
17
  * each finished round and whether that browser lost its connection, instead of only "no round finished".
18
18
  */
19
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
19
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
20
20
  import { cpus, loadavg, tmpdir } from 'node:os';
21
21
  import { join } from 'node:path';
22
-
23
- const CHROMES = [
24
- process.env.CHROME_PATH,
25
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
26
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
27
- '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
28
- ].filter(Boolean);
22
+ import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
29
23
 
30
24
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
25
 
@@ -63,8 +57,8 @@ export function judgeRounds(browsers, missed = new Map()) {
63
57
 
64
58
  export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
65
59
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
66
- const chrome = CHROMES.find((p) => existsSync(p));
67
- if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
60
+ const chrome = findChrome();
61
+ if (!chrome) return { ok: false, command: 'check', why: noChrome() };
68
62
  let puppeteer;
69
63
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
70
64
  const base = String(url).replace(/\/+$/, '');
@@ -101,7 +95,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
101
95
  profiles.push(profile);
102
96
  const browser = await puppeteer.launch({
103
97
  executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
104
- args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
98
+ args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
105
99
  });
106
100
  browsers.push(browser);
107
101
  const page = await browser.newPage();
@@ -157,6 +151,12 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
157
151
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
158
152
  await sleep(1500);
159
153
  await picture('Playing: computer and phone in one room');
154
+ // How fast each browser draws the game while it plays (reported, never judged: a VM without a GPU draws
155
+ // with SwiftShader, which is not a person's frame rate).
156
+ const frames = await Promise.all(players.map((p) => measureFrames(p.page)));
157
+ const software = frames.some((f) => f.renderer && SOFTWARE_GL.test(f.renderer));
158
+ const drawn = frames.map((f, i) => `${kinds[i].name} ${f.fps ?? '?'} fps`).join(', ');
159
+ log(`drawing: ${drawn}${frames[0]?.renderer ? ` (${frames[0].renderer})` : ''}`);
160
160
  // Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
161
161
  // counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
162
162
  // in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
@@ -202,7 +202,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
202
202
  return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
203
203
  }
204
204
  const humans = hit.people;
205
- step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bots${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}` });
205
+ step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bot${hit.results.length - humans === 1 ? '' : 's'}${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}; ${drawn}${software ? ' (software GL: not measurable here)' : ''}` });
206
206
  await picture(`Round ${hit.n} finished with both; the next one is on`);
207
207
  return {
208
208
  ok: sameRoom && humans >= 2,
@@ -213,6 +213,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
213
213
  round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
214
214
  ...(missed.size ? { missed: [...missed.values()] } : {}),
215
215
  connection,
216
+ frames: frames.map((f, i) => ({ browser: kinds[i].name, fps: f.fps, renderer: f.renderer })),
217
+ ...(software ? { software: 'This machine has no GPU (WebGL is SwiftShader): seats, rooms and rounds are measured; the frame rate is not a person\'s.' } : {}),
216
218
  why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
217
219
  totalMs: Date.now() - started,
218
220
  };
package/lib/chrome.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WHICH CHROME THE CHECKS RUN, AND HOW (check, port check, look).
3
+ *
4
+ * On a Mac: that Mac's Chrome, on its GPU (`--use-angle=metal`). A software renderer runs a 3D game at a
5
+ * few frames a second and makes it look stuck, so a Mac never falls back to one.
6
+ *
7
+ * On Linux (a Claude Code cloud session, GitHub Actions, a container) there is usually no GPU and often no
8
+ * Chrome. `homie-studio chrome install` puts Chrome for Testing in this user's cache (from
9
+ * storage.googleapis.com, which a Claude Code cloud session's default "Trusted" network reaches), and the
10
+ * checks render WebGL with SwiftShader (`--enable-unsafe-swiftshader`, needed since Chrome 137), headless,
11
+ * without the setuid sandbox when they run as root (as a container does). What they measure there is seats,
12
+ * rooms and rounds on the room's own clock; a frame rate on SwiftShader is not a person's, so the checks
13
+ * report it with the renderer's name and never judge it.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ /** Where `homie-studio chrome install` keeps Chrome for Testing (one copy per user, shared by every studio). */
20
+ export const CHROME_CACHE = join(homedir(), '.cache', 'homie-studio', 'chrome');
21
+
22
+ /** The newest build of `browser` in a @puppeteer/browsers or Playwright cache, or null. */
23
+ function newestIn(dir, rel) {
24
+ if (!existsSync(dir)) return null;
25
+ const builds = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
26
+ .sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
27
+ for (const b of builds) {
28
+ for (const r of rel) {
29
+ const path = join(dir, b, ...r);
30
+ if (existsSync(path)) return path;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Chrome for this machine: CHROME_PATH, an installed Chrome, or one in a known cache. */
37
+ export function findChrome() {
38
+ const fixed = [
39
+ process.env.CHROME_PATH,
40
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
41
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
42
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome',
43
+ ].filter(Boolean);
44
+ const found = fixed.find((p) => existsSync(p));
45
+ if (found) return found;
46
+ const home = homedir();
47
+ return newestIn(join(CHROME_CACHE, 'chrome'), [['chrome-linux64', 'chrome'], ['chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'], ['chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']])
48
+ ?? newestIn(join(home, '.cache', 'puppeteer', 'chrome'), [['chrome-linux64', 'chrome']])
49
+ ?? newestIn(join(home, '.cache', 'ms-playwright'), [['chrome-linux', 'chrome'], ['chrome-linux64', 'chrome']]);
50
+ }
51
+
52
+ /** Linux without a GPU renders with SwiftShader: say so with every number measured there. */
53
+ export const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software/i;
54
+
55
+ /*
56
+ * Chrome's own sandbox needs unprivileged user namespaces. Running as root (a container), or on a Linux that
57
+ * restricts them with AppArmor (Ubuntu 23.10 and later: a system Chrome package brings its own AppArmor profile,
58
+ * Chrome for Testing does not), Chrome refuses to start without --no-sandbox. The checks only open the studio's
59
+ * own site, in throwaway profiles.
60
+ */
61
+ function noSandboxNeeded() {
62
+ if (typeof process.getuid === 'function' && process.getuid() === 0) return true;
63
+ try { return readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'; } catch { return false; }
64
+ }
65
+
66
+ /** The launch flags for this machine (the GPU on a Mac; SwiftShader, headless and container-safe on Linux). */
67
+ export function chromeArgs() {
68
+ if (process.platform === 'darwin') return ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist'];
69
+ return [
70
+ '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-dev-shm-usage',
71
+ ...(noSandboxNeeded() ? ['--no-sandbox'] : []),
72
+ ];
73
+ }
74
+
75
+ /** Why there is no Chrome, and the one command that fixes it on this machine. */
76
+ export function noChrome() {
77
+ return process.platform === 'linux'
78
+ ? 'no Chrome on this machine: run `npx --no-install homie-studio chrome install` (Chrome for Testing into .cache/homie-studio in the home folder, from storage.googleapis.com), or set CHROME_PATH'
79
+ : 'no Chrome found: install Google Chrome, or set CHROME_PATH';
80
+ }
81
+
82
+ /**
83
+ * `homie-studio chrome install`: Chrome for Testing (stable) into CHROME_CACHE, through @puppeteer/browsers (it comes
84
+ * with puppeteer-core). About 170 MB, once per user; a second run finds it and downloads nothing.
85
+ */
86
+ export async function installChrome({ log = () => {}, fresh = false } = {}) {
87
+ // `fresh`: Chrome for Testing even when the machine has a Chrome (what a machine without one would get).
88
+ const existing = fresh ? null : findChrome();
89
+ if (existing) return { ok: true, command: 'chrome install', chrome: existing, already: true };
90
+ let browsers;
91
+ try { browsers = await import('@puppeteer/browsers'); } catch { return { ok: false, command: 'chrome install', why: '@puppeteer/browsers is missing (it comes with @homie-rocks/studio: npm install)' }; }
92
+ const platform = browsers.detectBrowserPlatform();
93
+ if (!platform) return { ok: false, command: 'chrome install', why: `Chrome for Testing has no build for ${process.platform} ${process.arch}` };
94
+ const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable');
95
+ log(`downloading Chrome for Testing ${buildId} (${platform}) into ${CHROME_CACHE}`);
96
+ const got = await browsers.install({ browser: browsers.Browser.CHROME, buildId, cacheDir: CHROME_CACHE });
97
+ return { ok: true, command: 'chrome install', chrome: got.executablePath, buildId, platform };
98
+ }
99
+
100
+ /**
101
+ * The frame rate a page's game frame draws at, over `ms` (requestAnimationFrame in the game's own frame, or the
102
+ * page's), and the renderer WebGL reports there. Never throws: { fps: null } when it cannot tell.
103
+ */
104
+ export async function measureFrames(page, ms = 2000) {
105
+ const frame = page.frames().find((f) => /\/__game\//.test(f.url())) ?? page.mainFrame();
106
+ // Runs inside the frame (puppeteer sends the function itself, so a game's CSP never blocks it).
107
+ const probe = (span) => new Promise((resolve) => {
108
+ let renderer = null;
109
+ try {
110
+ const c = document.createElement('canvas');
111
+ const gl = c.getContext('webgl2') || c.getContext('webgl');
112
+ const d = gl && gl.getExtension('WEBGL_debug_renderer_info');
113
+ renderer = gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
114
+ } catch { renderer = null; }
115
+ let n = 0;
116
+ const t0 = performance.now();
117
+ const tick = () => { n++; if (performance.now() - t0 < span) requestAnimationFrame(tick); else resolve({ fps: Math.round((n * 1000) / (performance.now() - t0)), renderer }); };
118
+ requestAnimationFrame(tick);
119
+ });
120
+ try {
121
+ return await Promise.race([
122
+ frame.evaluate(probe, ms),
123
+ new Promise((r) => setTimeout(() => r({ fps: null, renderer: null }), ms + 5000)),
124
+ ]);
125
+ } catch { return { fps: null, renderer: null }; }
126
+ }