@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 +90 -8
- package/bin/homie-studio.mjs +90 -13
- package/lib/build.mjs +38 -1
- package/lib/check.mjs +109 -48
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +8 -12
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +8 -13
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +131 -22
- package/lib/setup.mjs +90 -0
- package/lib/site.mjs +8 -1
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +180 -0
- package/lib/upgrade.mjs +285 -0
- package/lib/version.mjs +7 -3
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +125 -25
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
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
|
|
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
|
+
[](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.
|
|
37
|
-
|
|
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
|
|
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
|
|
151
|
-
`@homie-rocks/studio
|
|
152
|
-
|
|
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
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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 {
|
|
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 ? '
|
|
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, '
|
|
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',
|
|
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:
|
|
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:
|
|
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 }
|
|
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,
|