@homie-rocks/studio 0.8.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.8.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
@@ -19,8 +19,35 @@ npx homie-studio deploy --plan # what deploy will create
19
19
  npx homie-studio deploy # the studio's own Cloudflare
20
20
  npx homie-studio publish # the homie.rocks directory
21
21
  npx homie-studio stats # the studio's own numbers, for its owner
22
+ npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
22
23
  ```
23
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
+
24
51
  ## The site: the hub's shape, and a landing for every game
25
52
 
26
53
  Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
@@ -33,8 +60,30 @@ live rooms, credits (the original and its licence for a port), and "Make a game
33
60
  files. `site/SITE.md` says all of it.
34
61
 
35
62
  The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
36
- screen and the room code. Every HTML answer is `no-transform`, and the site's pages are never framed by another
37
- site.
63
+ screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
64
+ the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
65
+ picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
66
+ game's landing still. Every HTML answer is `no-transform`, and the site's pages are never framed by another
67
+ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
68
+ `not-a-homie`, at the site's root as under the game.
69
+
70
+ ## Upgrading a studio
71
+
72
+ A studio pins one version, so nothing changes until it asks. To take a newer one:
73
+
74
+ ```sh
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
77
+ npm install && npm run build
78
+ ```
79
+
80
+ `upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
81
+ a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
82
+ older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
83
+ the newer text. Anything the studio changed or wrote itself is kept as it is: the plan lists it, and `--diff`
84
+ shows how it differs from the template. `--apply` also pins this version in package.json and sets studio.json's
85
+ `homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
86
+ fails until it has).
38
87
 
39
88
  ## Rooms of up to 32
40
89
 
@@ -85,6 +134,27 @@ check green ends the build. The AI marks what only it knows with `progress stage
85
134
  one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
86
135
  browsers and says so, and nothing already built or deployed is undone.
87
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
+
88
158
  The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
89
159
  directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
90
160
  hours so the Claude app can show it: the feed only (plain bounded text, pictures under
@@ -98,6 +168,14 @@ feed every command behaves exactly as before. `lib/progress.mjs` has the whole f
98
168
  names the Cloudflare account, often after its owner. A custom domain goes in studio.json
99
169
  as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
100
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
+
101
179
  ## No payment method needed
102
180
 
103
181
  `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
@@ -134,8 +212,11 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
134
212
  | `bin/homie-studio.mjs` | The CLI. |
135
213
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
136
214
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
137
- | `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. |
138
- | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
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). |
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`. |
139
220
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
140
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. |
141
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`. |
@@ -147,9 +228,10 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
147
228
  | `starters/gem-rush/` | The reference multiplayer starter. |
148
229
 
149
230
  Versions are immutable: a published version never changes, so a studio that pinned one
150
- never changes by surprise. A change ships as a new `version`, on the npm registry as
151
- `@homie-rocks/studio` and as a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`
152
- (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.
153
235
 
154
236
  ## Beta
155
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,17 +13,25 @@
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>]
24
29
  * homie-studio media list
25
30
  * homie-studio status
31
+ * homie-studio upgrade [--apply] [--diff]
32
+ * (what this version's template adds to an existing studio: AGENTS.md
33
+ * sections, READMEs, .gitignore lines, the pin; changes nothing until
34
+ * --apply, and never a file or section the studio changed)
26
35
  * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
27
36
  * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
28
37
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
@@ -42,11 +51,18 @@
42
51
  * homie-studio progress log "<one line>"
43
52
  * homie-studio progress stop (ask the running build to stop at its next safe point)
44
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)
45
58
  * homie-studio progress show [<build>]
46
59
  * While a feed is open, build, check, port check and deploy report
47
60
  * into it (stage, each check going green, a preview picture) and stop
48
61
  * when asked. Without one, nothing changes.
49
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
+ *
50
66
  * Every command prints a few lines for a person; --json prints the result.
51
67
  */
52
68
  import { spawn, spawnSync } from 'node:child_process';
@@ -54,21 +70,24 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
54
70
  import { basename, dirname, join, relative, resolve } from 'node:path';
55
71
  import { build } from '../lib/build.mjs';
56
72
  import { check } from '../lib/check.mjs';
57
- 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';
58
76
  import { publish } from '../lib/directory.mjs';
59
77
  import { look } from '../lib/look.mjs';
60
78
  import { importPort, planPort } from '../lib/port.mjs';
61
79
  import { portCheck } from '../lib/port-check.mjs';
62
80
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
63
81
  import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
64
- import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
82
+ import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
83
+ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
65
84
  import { STUDIO_VERSION } from '../lib/version.mjs';
66
85
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
67
- 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';
68
87
 
69
88
  const argv = process.argv.slice(2);
70
89
  const flags = new Map();
71
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
90
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh'];
72
91
  const positional = [];
73
92
  for (let i = 0; i < argv.length; i++) {
74
93
  const a = argv[i];
@@ -118,10 +137,14 @@ function print(result) {
118
137
  }
119
138
  break;
120
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
+ }
121
144
  lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
122
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)`] : []),
123
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' : ''}.`,
124
- 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).');
125
148
  break;
126
149
  case 'deploy plan':
127
150
  lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
@@ -174,6 +197,10 @@ function print(result) {
174
197
  case 'stats share':
175
198
  lines.push(result.message);
176
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':
177
204
  case 'progress start':
178
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`}` : ''}`,
179
206
  ` feed: ${result.file}`,
@@ -198,9 +225,42 @@ function print(result) {
198
225
  case 'look':
199
226
  lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
200
227
  break;
228
+ case 'upgrade': {
229
+ const show = (text, n = 14) => { const l = String(text ?? '').replace(/\n+$/, '').split('\n'); return [...l.slice(0, n).map((x) => ` ${x}`), ...(l.length > n ? [` … (${l.length - n} more lines; --json has all of it)`] : [])]; };
230
+ const mark = { 'add-file': '+', 'add-section': '+', 'add-lines': '+', scripts: '+', 'update-file': '~', 'update-section': '~', pin: '~', version: '~' };
231
+ const list = result.applied ? result.done : result.changes;
232
+ lines.push(result.applied
233
+ ? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
234
+ : list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
235
+ if (list.length) lines.push('');
236
+ for (const c of list) {
237
+ lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
238
+ if (result.applied) continue;
239
+ if (c.kind === 'add-section' || (c.kind === 'add-file' && c.file.endsWith('.md'))) lines.push(...show(c.text));
240
+ if (c.kind === 'update-section' || c.kind === 'update-file') lines.push(...lineDiff(c.from, c.text).map((x) => ` ${x}`));
241
+ }
242
+ for (const c of result.skipped ?? []) lines.push(` ! ${c.file.padEnd(18)} not applied: ${c.why}`);
243
+ if (result.kept?.length) {
244
+ lines.push('', 'Kept as this studio wrote them (never changed by upgrade; the template\'s text differs):');
245
+ for (const k of result.kept) {
246
+ lines.push(` = ${k.file.padEnd(18)} ${k.what}`);
247
+ if (flags.has('diff') && k.mine !== undefined) lines.push(...lineDiff(k.mine, k.template).map((x) => ` ${x}`));
248
+ }
249
+ if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
250
+ }
251
+ 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.`);
252
+ if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
253
+ break;
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;
201
259
  case 'check':
202
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.`,
203
- ...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}`] : []));
204
264
  break;
205
265
  default:
206
266
  lines.push(JSON.stringify(result, null, 2));
@@ -216,12 +276,15 @@ async function main() {
216
276
  return { ok: true, command: 'help' };
217
277
  }
218
278
  if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
219
- 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') });
220
280
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
221
281
 
222
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() }; }
223
285
  const root = requireStudio();
224
286
  if (cmd === 'progress') return progressCommand(root, sub);
287
+ if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
225
288
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
226
289
  if (cmd === 'port' && sub === 'check') {
227
290
  const game = positional[2] ?? listGames(root)[0]?.id;
@@ -237,6 +300,10 @@ async function main() {
237
300
  const studio = readStudio(root);
238
301
  return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
239
302
  }
303
+ if (cmd === 'upgrade') {
304
+ const plan = upgradePlan(root);
305
+ return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
306
+ }
240
307
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
241
308
  if (cmd === 'dev') return dev(root);
242
309
  if (cmd === 'check') {
@@ -259,6 +326,8 @@ async function main() {
259
326
  return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
260
327
  }
261
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');
262
331
  if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
263
332
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
264
333
  if (cmd === 'storage') {
@@ -336,6 +405,12 @@ async function progressCommand(root, sub) {
336
405
  budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
337
406
  });
338
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
+ }
339
414
  if (sub === 'show') {
340
415
  const id = arg ?? currentId(root) ?? flags.get('build');
341
416
  const doc = readFeed(root, id);
@@ -364,8 +439,10 @@ async function progressCommand(root, sub) {
364
439
  }
365
440
  case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
366
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}`);
367
444
  case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
368
- 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)` };
369
446
  }
370
447
  } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
371
448
  }
@@ -375,7 +452,7 @@ async function progressCommand(root, sub) {
375
452
  * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
376
453
  * never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
377
454
  */
378
- const devFile = (root) => join(root, 'site', '.wrangler', 'homie-dev.json');
455
+ const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
379
456
  function processOfStudio(pid, root, kind) {
380
457
  if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
381
458
  try { process.kill(pid, 0); } catch { return false; }
@@ -414,11 +491,11 @@ async function dev(root) {
414
491
  const added = ensureStatsMigration(root);
415
492
  if (added) log(`added ${added} (the studio's own stats)`);
416
493
  await new Promise((done) => {
417
- 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'] });
418
495
  m.on('close', done);
419
496
  });
420
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)`);
421
- 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' });
422
499
  mkdirSync(dirname(devFile(root)), { recursive: true });
423
500
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
424
501
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
@@ -441,7 +518,7 @@ function mediaPut(root, file, as) {
441
518
  const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
442
519
  const bin = wranglerBin(root);
443
520
  return new Promise((done) => {
444
- 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'] });
445
522
  p.on('close', (code) => {
446
523
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
447
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,7 +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 } } : {}),
239
+ // studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
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),
211
248
  },
212
249
  games: rows,
213
250
  songs: media.songs,