@homie-rocks/studio 0.9.0 → 0.11.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 +120 -8
- package/bin/homie-studio.mjs +128 -13
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +44 -4
- package/lib/check.mjs +14 -12
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +92 -18
- package/lib/codex.mjs +664 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/look.mjs +5 -10
- package/lib/port-check.mjs +5 -11
- package/lib/progress.mjs +122 -6
- package/lib/scaffold.mjs +138 -21
- package/lib/setup.mjs +90 -0
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +47 -0
- package/lib/version.mjs +7 -3
- package/package.json +1 -1
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +75 -4
- package/worker/stats-page.mjs +34 -2
- 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
|
|
@@ -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
|
+
[](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
|
|
50
|
-
npx -y
|
|
75
|
+
npx -y @homie-rocks/studio@0.11.0 upgrade # the plan; changes nothing
|
|
76
|
+
npx -y @homie-rocks/studio@0.11.0 upgrade --apply # after the person agrees
|
|
51
77
|
npm install && npm run build
|
|
52
78
|
```
|
|
53
79
|
|
|
@@ -87,6 +113,60 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
87
113
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
88
114
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
89
115
|
|
|
116
|
+
## Setup status
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npx -y @homie-rocks/studio@0.11.0 setup status --connector yes # before a studio exists
|
|
120
|
+
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
One checklist of what this computer and the person's accounts have for a studio: Node.js, the Homie
|
|
124
|
+
connector (the AI says whether its Homie tools are there; the directory is reached), Cloudflare (signed in
|
|
125
|
+
with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
|
|
126
|
+
deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
|
|
127
|
+
fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
|
|
128
|
+
unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
|
|
129
|
+
seconds, and never prints a key, a token or an account's name. A studio the Claude app's setup card made
|
|
130
|
+
(`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
|
|
131
|
+
|
|
132
|
+
## The Game Codex
|
|
133
|
+
|
|
134
|
+
`games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
|
|
135
|
+
cover, pixel art) and a few conventions that become cards (`### Name` with a picture, `` `M-01` `` id and tag
|
|
136
|
+
chips, an italic subtitle and `**Key:** value` stats), tables, checklists, a dated decision log and open
|
|
137
|
+
questions (`lib/codex.mjs` has the whole format).
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
npx homie-studio codex new crown-thief # every section, in the studio's colours
|
|
141
|
+
npx homie-studio codex crown-thief --open # .studio/codex/crown-thief.html, in the browser
|
|
142
|
+
npx homie-studio codex crown-thief --artifact # one self-contained page to publish as a Claude artifact
|
|
143
|
+
npx homie-studio codex link crown-thief # a one-time link to the site's private copy
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The page has a tab per section and a **Build status** tab from the game's progress feed; the local copy
|
|
147
|
+
redraws itself whenever the feed changes and refreshes in the browser while a build runs. `npm run build`
|
|
148
|
+
puts each game's codex on the site at `/_studio/codex/<id>/`, served only to the studio's owner (the same
|
|
149
|
+
one-time sign-in as the stats page), never listed or indexed. Text is escaped and the page runs only its own
|
|
150
|
+
script (the site allows it by its hash); pictures and fonts come only from inside the studio and are
|
|
151
|
+
embedded, so it loads nothing but Google Fonts. CODEX.md is never in a game's remix source or among a
|
|
152
|
+
static game's served files.
|
|
153
|
+
|
|
154
|
+
## The Claude Code status line
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
npx homie-studio statusline --install # .claude/settings.local.json: this person, this studio
|
|
158
|
+
npx homie-studio statusline --install --project .. # Claude Code started in the folder above the studio
|
|
159
|
+
npx homie-studio statusline --remove
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
One line under the prompt, from the current build's feed: `▶ Crown Thief · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks
|
|
163
|
+
· $0.40 of $2.00`; the studio's name, dimmed, when nothing runs; a finished build's result for ten minutes.
|
|
164
|
+
Claude Code runs `bin/statusline.mjs` with the session as JSON on stdin (it reads only the studio's progress
|
|
165
|
+
files, in about 40 ms) and, with the 5 s `refreshInterval` it sets, keeps it moving while a long check runs.
|
|
166
|
+
It never replaces a status line the person already has (`--replace` puts this one in for this studio only).
|
|
167
|
+
Plugins cannot set a status line, so it is always the person's yes. Codex CLI's status line takes only its
|
|
168
|
+
own built-in items, so there the codex page is the progress view.
|
|
169
|
+
|
|
90
170
|
## A build the person can watch (the progress feed)
|
|
91
171
|
|
|
92
172
|
A build of a game, song or video can keep a small progress feed, so the person follows it
|
|
@@ -108,6 +188,27 @@ check green ends the build. The AI marks what only it knows with `progress stage
|
|
|
108
188
|
one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
|
|
109
189
|
browsers and says so, and nothing already built or deployed is undone.
|
|
110
190
|
|
|
191
|
+
**From the Claude app.** The chat can open the build first (the Homie MCP tool `build_open`, or the "Build it"
|
|
192
|
+
card on a make, port or remix) and hand the work to a Claude Code session with a prefilled prompt. The session
|
|
193
|
+
takes the build once, with no key in the prompt, and the card follows it; the change goes out as a pull request
|
|
194
|
+
the person merges with one tap:
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
npx homie-studio progress attach hb_… # this session takes the build the chat opened (once)
|
|
198
|
+
npx homie-studio progress change "Crowns spawn twice as often" # its mark, in changes/, committed with the change
|
|
199
|
+
git switch -c faster-crowns && git add -A && git commit -m "Faster crowns" && git push -u origin HEAD
|
|
200
|
+
gh pr create --fill # then:
|
|
201
|
+
npx homie-studio progress pr --url https://github.com/<owner>/<repo>/pull/<n>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Workers Builds deploys the branch as a Preview; the card's **Publish** opens the pull request in GitHub, where the
|
|
205
|
+
person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
|
|
206
|
+
manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
|
|
207
|
+
|
|
208
|
+
A Claude Code cloud session's default network ("Trusted") does not reach homie.rocks; the toolkit says so, and
|
|
209
|
+
names the setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The
|
|
210
|
+
build goes on with its local feed.
|
|
211
|
+
|
|
111
212
|
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
112
213
|
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
|
113
214
|
hours so the Claude app can show it: the feed only (plain bounded text, pictures under
|
|
@@ -121,6 +222,14 @@ feed every command behaves exactly as before. `lib/progress.mjs` has the whole f
|
|
|
121
222
|
names the Cloudflare account, often after its owner. A custom domain goes in studio.json
|
|
122
223
|
as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
|
|
123
224
|
|
|
225
|
+
## Checks on a computer without a GPU
|
|
226
|
+
|
|
227
|
+
`check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
|
|
228
|
+
session, GitHub Actions, a container) `npx homie-studio chrome install` puts Chrome for Testing in
|
|
229
|
+
the home folder's `.cache/homie-studio` (from storage.googleapis.com, which a cloud session's default network reaches), and WebGL
|
|
230
|
+
renders with SwiftShader. `check` reports how fast each browser drew the game and on what renderer; on SwiftShader
|
|
231
|
+
it says the frame rate is not a person's, and judges only seats, rooms and rounds.
|
|
232
|
+
|
|
124
233
|
## No payment method needed
|
|
125
234
|
|
|
126
235
|
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
@@ -157,9 +266,11 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
157
266
|
| `bin/homie-studio.mjs` | The CLI. |
|
|
158
267
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
159
268
|
| `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
|
|
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. |
|
|
269
|
+
| `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. |
|
|
270
|
+
| `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
271
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
272
|
+
| `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`. |
|
|
273
|
+
| `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
|
|
163
274
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
164
275
|
| `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
276
|
| `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 +282,10 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
171
282
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
|
172
283
|
|
|
173
284
|
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
|
|
175
|
-
`@homie-rocks/studio
|
|
176
|
-
|
|
285
|
+
never changes by surprise. A change ships as a new `version` on the npm registry as
|
|
286
|
+
`@homie-rocks/studio`, which is what `homie-studio new` pins (the exact version; package-lock.json keeps its
|
|
287
|
+
integrity). The registry is what Workers Builds and a Claude Code cloud session reach by default. Older studios
|
|
288
|
+
pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which stays.
|
|
177
289
|
|
|
178
290
|
## Beta
|
|
179
291
|
|
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,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,36 @@
|
|
|
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
|
+
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
66
|
+
* what this computer and the person's accounts have for a studio: Node, the Homie
|
|
67
|
+
* connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
|
|
68
|
+
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
69
|
+
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
70
|
+
*
|
|
71
|
+
* homie-studio codex new <id> games/<id>/CODEX.md: the Game Codex, every section, in the game's colours
|
|
72
|
+
* homie-studio codex <id> [--artifact] [--open]
|
|
73
|
+
* the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
|
|
74
|
+
* itself as the build's progress changes); --artifact: a copy to publish as a
|
|
75
|
+
* Claude artifact; --open: open it in this computer's browser
|
|
76
|
+
* homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
|
|
77
|
+
*
|
|
78
|
+
* homie-studio statusline the current build in one line (what Claude Code's status line shows)
|
|
79
|
+
* homie-studio statusline --install [--project <folder>]
|
|
80
|
+
* turn it on in Claude Code for this studio (.claude/settings.local.json);
|
|
81
|
+
* --project: the folder Claude Code was started in, when that is above the
|
|
82
|
+
* studio; --remove turns it off; --replace when another status line is set
|
|
83
|
+
*
|
|
54
84
|
* Every command prints a few lines for a person; --json prints the result.
|
|
55
85
|
*/
|
|
56
86
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -58,7 +88,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, wr
|
|
|
58
88
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
59
89
|
import { build } from '../lib/build.mjs';
|
|
60
90
|
import { check } from '../lib/check.mjs';
|
|
61
|
-
import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
91
|
+
import { ciDeploy, deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
92
|
+
import { setupAttach } from '../lib/setup.mjs';
|
|
93
|
+
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
62
94
|
import { publish } from '../lib/directory.mjs';
|
|
63
95
|
import { look } from '../lib/look.mjs';
|
|
64
96
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
@@ -66,14 +98,17 @@ import { portCheck } from '../lib/port-check.mjs';
|
|
|
66
98
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
67
99
|
import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
68
100
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
69
|
-
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
101
|
+
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
70
102
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
71
103
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
72
|
-
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
|
|
104
|
+
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
105
|
+
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
106
|
+
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
107
|
+
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
73
108
|
|
|
74
109
|
const argv = process.argv.slice(2);
|
|
75
110
|
const flags = new Map();
|
|
76
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
|
|
111
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
77
112
|
const positional = [];
|
|
78
113
|
for (let i = 0; i < argv.length; i++) {
|
|
79
114
|
const a = argv[i];
|
|
@@ -114,6 +149,7 @@ function print(result) {
|
|
|
114
149
|
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
115
150
|
for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
|
|
116
151
|
for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
|
|
152
|
+
if (result.codexes?.length) lines.push(`Game Codex (the owner's private pages; codex link <id> opens one): ${result.codexes.map((x) => `/_studio/codex/${x}/`).join(', ')}`);
|
|
117
153
|
break;
|
|
118
154
|
case 'media list':
|
|
119
155
|
for (const kind of ['music', 'videos']) {
|
|
@@ -123,10 +159,14 @@ function print(result) {
|
|
|
123
159
|
}
|
|
124
160
|
break;
|
|
125
161
|
case 'deploy':
|
|
162
|
+
if (result.ci) {
|
|
163
|
+
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}`));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
126
166
|
lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
|
|
127
167
|
...(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
168
|
...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 ? '
|
|
169
|
+
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
170
|
break;
|
|
131
171
|
case 'deploy plan':
|
|
132
172
|
lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
|
|
@@ -179,6 +219,31 @@ function print(result) {
|
|
|
179
219
|
case 'stats share':
|
|
180
220
|
lines.push(result.message);
|
|
181
221
|
break;
|
|
222
|
+
case 'setup status':
|
|
223
|
+
lines.push(formatStatus(result));
|
|
224
|
+
break;
|
|
225
|
+
case 'codex':
|
|
226
|
+
lines.push(`The Game Codex for ${result.title}: ${result.file}${result.opened ? ' (opened in the browser)' : ''}`,
|
|
227
|
+
` tabs: ${result.sections.map((x) => x.title).join(' · ')} · Build status${result.build ? ` (${result.build.state}, ${result.build.percent}%)` : ''}`,
|
|
228
|
+
...(result.missing.length ? [` not decided yet: ${result.missing.join(', ')}`] : [' every section has something in it']),
|
|
229
|
+
` open questions: ${result.openQuestions}`,
|
|
230
|
+
...result.warnings.map((w) => ` warning: ${w}`),
|
|
231
|
+
result.mode === 'artifact' ? ' publish this file as an artifact where your app can (Claude: the Artifact tool); it is one self-contained page' : ' on the site, for the owner only, after the next deploy: npx --no-install homie-studio codex link ' + result.id);
|
|
232
|
+
break;
|
|
233
|
+
case 'codex new':
|
|
234
|
+
lines.push(`Wrote ${result.file}.`, ...result.next.map((n) => ` next: ${n}`));
|
|
235
|
+
break;
|
|
236
|
+
case 'codex link':
|
|
237
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
238
|
+
break;
|
|
239
|
+
case 'statusline install':
|
|
240
|
+
case 'statusline remove':
|
|
241
|
+
lines.push(result.message);
|
|
242
|
+
break;
|
|
243
|
+
case 'setup attach':
|
|
244
|
+
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}`));
|
|
245
|
+
break;
|
|
246
|
+
case 'progress attach':
|
|
182
247
|
case 'progress start':
|
|
183
248
|
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
249
|
` feed: ${result.file}`,
|
|
@@ -230,9 +295,15 @@ function print(result) {
|
|
|
230
295
|
if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
231
296
|
break;
|
|
232
297
|
}
|
|
298
|
+
case 'chrome':
|
|
299
|
+
case 'chrome install':
|
|
300
|
+
lines.push(result.already || result.command === 'chrome' ? `Chrome: ${result.chrome}` : `Installed Chrome for Testing ${result.buildId}: ${result.chrome}`);
|
|
301
|
+
break;
|
|
233
302
|
case 'check':
|
|
234
303
|
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`)
|
|
304
|
+
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
|
|
305
|
+
...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
|
|
306
|
+
...(result.software ? [` ${result.software}`] : []));
|
|
236
307
|
break;
|
|
237
308
|
default:
|
|
238
309
|
lines.push(JSON.stringify(result, null, 2));
|
|
@@ -248,12 +319,24 @@ async function main() {
|
|
|
248
319
|
return { ok: true, command: 'help' };
|
|
249
320
|
}
|
|
250
321
|
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') });
|
|
322
|
+
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
323
|
if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
|
|
253
324
|
|
|
254
325
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
326
|
+
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
327
|
+
if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
|
|
328
|
+
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null });
|
|
329
|
+
if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
|
|
330
|
+
const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
|
|
331
|
+
if (asJson) return { ok: true, command: 'statusline', line };
|
|
332
|
+
if (line) process.stdout.write(`${line}\n`);
|
|
333
|
+
return { ok: true, command: 'help' };
|
|
334
|
+
}
|
|
255
335
|
const root = requireStudio();
|
|
336
|
+
if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
|
|
337
|
+
if (cmd === 'codex') return codexCommand(root, sub);
|
|
256
338
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
339
|
+
if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
|
|
257
340
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
258
341
|
if (cmd === 'port' && sub === 'check') {
|
|
259
342
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
@@ -295,6 +378,8 @@ async function main() {
|
|
|
295
378
|
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
296
379
|
}
|
|
297
380
|
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
381
|
+
// Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
|
|
382
|
+
if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
|
|
298
383
|
if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
|
|
299
384
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
300
385
|
if (cmd === 'storage') {
|
|
@@ -318,6 +403,28 @@ async function main() {
|
|
|
318
403
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
319
404
|
}
|
|
320
405
|
|
|
406
|
+
/** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
|
|
407
|
+
function codexCommand(root, sub) {
|
|
408
|
+
const games = listGames(root);
|
|
409
|
+
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined));
|
|
410
|
+
if (sub === 'link') {
|
|
411
|
+
const id = positional[2] ?? (games.length === 1 ? games[0].id : null);
|
|
412
|
+
if (!id || !games.some((g) => g.id === id)) return { ok: false, command: 'codex link', why: 'name the game: homie-studio codex link <id>' };
|
|
413
|
+
const r = statsLink(root, { url: flags.get('url') });
|
|
414
|
+
if (!r.ok) return { ...r, command: 'codex link' };
|
|
415
|
+
return { ...r, command: 'codex link', link: `${r.link}&to=${encodeURIComponent(codexTarget(id))}`, use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, opens the codex, and keeps that browser signed in to the studio\'s private pages (the codex and the stats) for 30 days. Do not post it anywhere.' };
|
|
416
|
+
}
|
|
417
|
+
const id = sub ?? (games.length === 1 ? games[0].id : null);
|
|
418
|
+
if (!id) return { ok: false, command: 'codex', why: `name the game: homie-studio codex <id> (${games.map((g) => g.id).join(', ') || 'no games yet'})` };
|
|
419
|
+
let r;
|
|
420
|
+
try { r = writeCodexPage(root, id, { mode: flags.has('artifact') ? 'artifact' : 'file' }); } catch (error) { return { ok: false, command: 'codex', why: error.message }; }
|
|
421
|
+
if (flags.has('open')) {
|
|
422
|
+
const opener = process.platform === 'darwin' ? ['open', [r.path]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', r.path]] : ['xdg-open', [r.path]];
|
|
423
|
+
r.opened = spawnSync(opener[0], opener[1], { stdio: 'ignore', timeout: 10_000 }).status === 0;
|
|
424
|
+
}
|
|
425
|
+
return r;
|
|
426
|
+
}
|
|
427
|
+
|
|
321
428
|
/*
|
|
322
429
|
* THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
|
|
323
430
|
* deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
|
|
@@ -372,6 +479,12 @@ async function progressCommand(root, sub) {
|
|
|
372
479
|
budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
|
|
373
480
|
});
|
|
374
481
|
}
|
|
482
|
+
if (sub === 'attach') {
|
|
483
|
+
return startProgress(root, {
|
|
484
|
+
attach: arg ?? null, what: flags.get('what') ?? 'game', id: flags.get('id') ?? null, title: flags.get('title'),
|
|
485
|
+
budget: flags.get('budget'), unit: flags.get('unit'), directory: flags.get('homie'),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
375
488
|
if (sub === 'show') {
|
|
376
489
|
const id = arg ?? currentId(root) ?? flags.get('build');
|
|
377
490
|
const doc = readFeed(root, id);
|
|
@@ -400,8 +513,10 @@ async function progressCommand(root, sub) {
|
|
|
400
513
|
}
|
|
401
514
|
case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
|
|
402
515
|
case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
|
|
516
|
+
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 }; }
|
|
517
|
+
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
518
|
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)` };
|
|
519
|
+
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
520
|
}
|
|
406
521
|
} catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
|
|
407
522
|
}
|
|
@@ -411,7 +526,7 @@ async function progressCommand(root, sub) {
|
|
|
411
526
|
* and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
|
|
412
527
|
* never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
|
|
413
528
|
*/
|
|
414
|
-
const devFile = (root) => join(root, '
|
|
529
|
+
const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
|
|
415
530
|
function processOfStudio(pid, root, kind) {
|
|
416
531
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
417
532
|
try { process.kill(pid, 0); } catch { return false; }
|
|
@@ -450,11 +565,11 @@ async function dev(root) {
|
|
|
450
565
|
const added = ensureStatsMigration(root);
|
|
451
566
|
if (added) log(`added ${added} (the studio's own stats)`);
|
|
452
567
|
await new Promise((done) => {
|
|
453
|
-
const m = spawn(bin, ['d1', 'migrations', 'apply',
|
|
568
|
+
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
454
569
|
m.on('close', done);
|
|
455
570
|
});
|
|
456
571
|
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:
|
|
572
|
+
const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: workerDir(root), env, stdio: 'inherit' });
|
|
458
573
|
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
459
574
|
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
460
575
|
log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
|
|
@@ -477,7 +592,7 @@ function mediaPut(root, file, as) {
|
|
|
477
592
|
const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
|
|
478
593
|
const bin = wranglerBin(root);
|
|
479
594
|
return new Promise((done) => {
|
|
480
|
-
const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd:
|
|
595
|
+
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
596
|
p.on('close', (code) => {
|
|
482
597
|
if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
|
|
483
598
|
// The manifest is committed: it names the file by its path on the site, never the workers.dev address.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The Claude Code status line for a Homie studio (lib/statusline.mjs). Claude Code runs it with the session as
|
|
4
|
+
* JSON on stdin and shows what it prints; it must be quick and must never fail loudly, so it reads only the
|
|
5
|
+
* studio's own progress files and prints nothing at all when anything goes wrong.
|
|
6
|
+
*
|
|
7
|
+
* echo '{"workspace":{"current_dir":"."}}' | node node_modules/@homie-rocks/studio/bin/statusline.mjs
|
|
8
|
+
*/
|
|
9
|
+
import { statusLine } from '../lib/statusline.mjs';
|
|
10
|
+
|
|
11
|
+
let input = '';
|
|
12
|
+
let printed = false;
|
|
13
|
+
function print() {
|
|
14
|
+
if (printed) return;
|
|
15
|
+
printed = true;
|
|
16
|
+
let dirs = [process.cwd()];
|
|
17
|
+
try {
|
|
18
|
+
const session = JSON.parse(input || '{}');
|
|
19
|
+
dirs = [session?.workspace?.current_dir, session?.workspace?.project_dir, session?.cwd, process.cwd()].filter((d) => typeof d === 'string' && d);
|
|
20
|
+
} catch { /* no session: this folder */ }
|
|
21
|
+
// `--studio <folder>`: set up in the folder above the studio, the setting names the studio.
|
|
22
|
+
const named = process.argv.indexOf('--studio');
|
|
23
|
+
if (named > 0 && process.argv[named + 1]) dirs.push(process.argv[named + 1]);
|
|
24
|
+
try {
|
|
25
|
+
const columns = Number(process.env.COLUMNS) || 100;
|
|
26
|
+
const color = !process.env.NO_COLOR;
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
const line = statusLine({ dir, columns, color });
|
|
29
|
+
if (line) { process.stdout.write(`${line}\n`); break; }
|
|
30
|
+
}
|
|
31
|
+
} catch { /* quiet: a status line never shows a stack */ }
|
|
32
|
+
// A caller that never closes stdin must not keep this process alive.
|
|
33
|
+
if (!process.stdin.isTTY) process.stdin.destroy();
|
|
34
|
+
}
|
|
35
|
+
if (process.stdin.isTTY) print();
|
|
36
|
+
else {
|
|
37
|
+
const timer = setTimeout(print, 400);
|
|
38
|
+
process.stdin.on('data', (d) => { input += d; });
|
|
39
|
+
process.stdin.on('end', () => { clearTimeout(timer); print(); });
|
|
40
|
+
process.stdin.on('error', () => { clearTimeout(timer); print(); });
|
|
41
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* posts/*.md and the studio's site/ folder (site/SITE.md)
|
|
11
11
|
* site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
|
|
12
12
|
* site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
|
|
13
|
+
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
14
|
+
* the studio's owner (worker/stats-page.mjs), never listed or indexed
|
|
13
15
|
* site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
|
|
14
16
|
* site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
|
|
15
17
|
*
|
|
@@ -25,6 +27,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
25
27
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
26
28
|
import { createRequire } from 'node:module';
|
|
27
29
|
import { join, relative } from 'node:path';
|
|
30
|
+
import { buildCodexPages } from './codex.mjs';
|
|
28
31
|
import { buildMedia } from './media.mjs';
|
|
29
32
|
import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
|
|
30
33
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
@@ -33,7 +36,7 @@ import { STUDIO_VERSION } from './version.mjs';
|
|
|
33
36
|
|
|
34
37
|
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
35
38
|
/** Never copied into a static game's served folder. */
|
|
36
|
-
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md']);
|
|
39
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md']);
|
|
37
40
|
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
38
41
|
|
|
39
42
|
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
@@ -67,7 +70,7 @@ export function sourceOf(dir, id) {
|
|
|
67
70
|
let total = 0;
|
|
68
71
|
const walk = (rel) => {
|
|
69
72
|
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
70
|
-
if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name)) continue;
|
|
73
|
+
if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && entry.name === 'CODEX.md')) continue;
|
|
71
74
|
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
72
75
|
if (entry.isDirectory()) walk(path);
|
|
73
76
|
else if (TEXT.test(entry.name)) {
|
|
@@ -82,6 +85,34 @@ export function sourceOf(dir, id) {
|
|
|
82
85
|
return { v: 1, kind: 'homie-game-source', id, files };
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/*
|
|
89
|
+
* WHAT THIS BUILD IS, for the site's manifest: the commit and branch (Cloudflare's Workers Builds names them in
|
|
90
|
+
* WORKERS_CI_COMMIT_SHA and WORKERS_CI_BRANCH; elsewhere git is asked), when it was built, and the marks of the
|
|
91
|
+
* newest changes in changes/ (lib/progress.mjs recordChange), so the Claude app's card can tell when a merged pull
|
|
92
|
+
* request is live. Nothing here names a person or an account.
|
|
93
|
+
*/
|
|
94
|
+
export function buildInfo(root) {
|
|
95
|
+
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; } };
|
|
96
|
+
const commit = /^[a-f0-9]{40}$/.test(process.env.WORKERS_CI_COMMIT_SHA ?? '') ? process.env.WORKERS_CI_COMMIT_SHA : git(['rev-parse', 'HEAD']);
|
|
97
|
+
const branch = String(process.env.WORKERS_CI_BRANCH || git(['rev-parse', '--abbrev-ref', 'HEAD']) || '').slice(0, 100) || null;
|
|
98
|
+
const marks = [];
|
|
99
|
+
const dir = join(root, 'changes');
|
|
100
|
+
if (existsSync(dir)) {
|
|
101
|
+
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
|
|
102
|
+
const c = readJson(join(dir, name));
|
|
103
|
+
if (c && /^[a-f0-9]{16}$/.test(String(c.change)) && !Number.isNaN(Date.parse(c.at))) marks.push({ mark: c.change, at: c.at });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
marks.sort((a, b) => b.at.localeCompare(a.at));
|
|
107
|
+
return {
|
|
108
|
+
commit: /^[a-f0-9]{40}$/.test(String(commit)) ? commit : null,
|
|
109
|
+
branch: branch && /^[A-Za-z0-9._/-]+$/.test(branch) && branch !== 'HEAD' ? branch : null,
|
|
110
|
+
at: new Date().toISOString(),
|
|
111
|
+
ci: process.env.WORKERS_CI === '1' ? 'workers-builds' : null,
|
|
112
|
+
changes: [...new Set(marks.map((m) => m.mark))].slice(0, 50),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
function readJson(path) {
|
|
86
117
|
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
87
118
|
}
|
|
@@ -194,6 +225,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
194
225
|
});
|
|
195
226
|
const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
|
|
196
227
|
const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
|
|
228
|
+
// Each game's Game Codex (games/<id>/CODEX.md), as the owner's private page at /_studio/codex/<id>/ (never listed).
|
|
229
|
+
const codexes = buildCodexPages(root, dist, games.map((g) => g.id), { log });
|
|
197
230
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
198
231
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
199
232
|
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
@@ -207,9 +240,16 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
207
240
|
...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
|
|
208
241
|
},
|
|
209
242
|
// 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 }
|
|
243
|
+
...(studio.stats?.share === true ? { stats: { share: true } } : {}),
|
|
211
244
|
// studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
|
|
212
245
|
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
246
|
+
// The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
|
|
247
|
+
// HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
|
|
248
|
+
directory: /^https:\/\/[a-z0-9.-]+$/i.test(process.env.HOMIE_DIRECTORY ?? '') ? process.env.HOMIE_DIRECTORY
|
|
249
|
+
: studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
|
|
250
|
+
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
251
|
+
...(studio.template === true ? { template: true } : {}),
|
|
252
|
+
build: buildInfo(root),
|
|
213
253
|
},
|
|
214
254
|
games: rows,
|
|
215
255
|
songs: media.songs,
|
|
@@ -222,7 +262,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
222
262
|
return {
|
|
223
263
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
224
264
|
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
225
|
-
posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
265
|
+
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
226
266
|
landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
|
|
227
267
|
};
|
|
228
268
|
}
|