@homie-rocks/studio 0.12.0 → 0.13.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 +86 -7
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +18 -5
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/office.mjs +172 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/setup.mjs +15 -23
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +49 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +197 -40
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ 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 office # who is playing now, in every live room (the back office)
|
|
22
23
|
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
23
24
|
```
|
|
24
25
|
|
|
@@ -72,8 +73,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
72
73
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
+
npx -y @homie-rocks/studio@0.13.0 upgrade # the plan; changes nothing
|
|
77
|
+
npx -y @homie-rocks/studio@0.13.0 upgrade --apply # after the person agrees
|
|
77
78
|
npm install && npm run build
|
|
78
79
|
```
|
|
79
80
|
|
|
@@ -139,10 +140,70 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
139
140
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
140
141
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
141
142
|
|
|
143
|
+
## The back office: run your live games
|
|
144
|
+
|
|
145
|
+
The owner sees and runs the studio's live games from `/_studio/office`, a private page
|
|
146
|
+
that refreshes itself: every live room of every game (players, bots, the round, how long
|
|
147
|
+
it has been up) and who is in it (each seat's handle, its device, whether it hosts, the
|
|
148
|
+
browser it came from; signed-in players once a studio has player accounts). From there,
|
|
149
|
+
and from a small Owner button in the owner's own play page (nobody else's page carries
|
|
150
|
+
it):
|
|
151
|
+
|
|
152
|
+
- **Kick** a player: they get a polite notice and cannot come back to that room for the
|
|
153
|
+
minutes the owner chose (their seat, their browser, their account; their network only
|
|
154
|
+
when asked, since a household shares one).
|
|
155
|
+
- **Mute** a player: their chat and emotes reach nobody (the `say`, `chat` and `emote`
|
|
156
|
+
events; a game hides the rest with `net.isMuted(seat)`).
|
|
157
|
+
- **Announce** one line to a room, a game or the whole studio: every player sees it as a
|
|
158
|
+
banner, and a game can show it its own way (`net.on('announce')`).
|
|
159
|
+
- **Close** a room: everyone is sent out with a thank-you; nobody gets in until it opens.
|
|
160
|
+
- Per game: **players per room**, the **launch state** (`private`: only the owner;
|
|
161
|
+
`invite`: an invite-only beta, where each invite link or code lets a browser in;
|
|
162
|
+
`public`: anyone, listed), and **remixable** (publish or withdraw the game's source).
|
|
163
|
+
A game that is not public is in no list and not in the directory manifest, so the
|
|
164
|
+
directory drops it the next time it reads the studio. A new game is private from its
|
|
165
|
+
first deploy with `"launch": "private"` in its game.json.
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
npx homie-studio office link # a one-time link that signs the owner's browser in
|
|
169
|
+
npx homie-studio office link --to /crown-thief/play # ... onto a private game, on the owner's phone
|
|
170
|
+
npx homie-studio office # every live room and who is in it, now
|
|
171
|
+
npx homie-studio office announce "Double gems this round!" --game crown-thief
|
|
172
|
+
npx homie-studio office invite crown-thief --label "Sam" --uses 1
|
|
173
|
+
npx homie-studio office launch crown-thief invite # asks: the owner confirms with one tap
|
|
174
|
+
npx homie-studio office kick crown-thief pub-3 2 # asks: seat 2 of Room 3
|
|
175
|
+
npx homie-studio office key # a key for the Homie MCP's owner tools
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Going private or invite-only never cuts a round short: each live room finishes its current round with a notice to
|
|
179
|
+
the players, then everyone the new state leaves out is sent out with a thank-you (the owner, and in a beta the
|
|
180
|
+
invited, play on). Mute drops a player's `say…`, `chat…` and `emote…` events, so a game that sends its chat and
|
|
181
|
+
emotes under those kinds needs nothing more (`netplay/NETPLAY.md` section 15). Kicking a whole network address is in
|
|
182
|
+
the studio's API (`address: true`) and deliberately not in the office. A Preview (a branch's own unlisted address, with
|
|
183
|
+
no D1) enforces no launch state and has no office.
|
|
184
|
+
|
|
185
|
+
**Privacy.** The site still sets no cookie on a visitor to count or follow them. Two things are kept for the back
|
|
186
|
+
office, both functional: a random room key in the play page's own storage (what a kick holds; it says nothing about
|
|
187
|
+
who the player is), and, for an invited player of an invite-only beta only, their pass to that game: an HttpOnly
|
|
188
|
+
cookie for that game's pages alone, for 90 days. D1 keeps only a hash of each pass.
|
|
189
|
+
|
|
190
|
+
**Only the owner.** The owner's browser is signed in with the same one-time link as the
|
|
191
|
+
stats (an HttpOnly session no page or game can read; games run in a sandboxed, opaque
|
|
192
|
+
frame). An office key (minted with the studio's own Cloudflare login) lets the owner's AI
|
|
193
|
+
look, announce and invite at once; a kick, a mute, a closed room or a launch change is only
|
|
194
|
+
**asked** for, and the owner confirms it with one tap in their own browser. No key can
|
|
195
|
+
confirm. The Worker signs each control with the studio's own secret and the room verifies
|
|
196
|
+
it before it acts (`netplay/NETPLAY.md` section 15). Everything is in the studio's own
|
|
197
|
+
Worker and D1 (migration `0003_studio_office.sql`); homie.rocks stores none of it.
|
|
198
|
+
|
|
199
|
+
With player accounts (0.12.0), a signed-in player's play page names their account in its room ticket (an invited
|
|
200
|
+
player's names the invite and the account), so the office shows them by name and a kick holds the account on every
|
|
201
|
+
device; the owner's own passkey account (`homie-studio players owner`) counts as the owner.
|
|
202
|
+
|
|
142
203
|
## Setup status
|
|
143
204
|
|
|
144
205
|
```sh
|
|
145
|
-
npx -y @homie-rocks/studio@0.
|
|
206
|
+
npx -y @homie-rocks/studio@0.13.0 setup status --connector yes # before a studio exists
|
|
146
207
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
147
208
|
```
|
|
148
209
|
|
|
@@ -231,10 +292,27 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
|
|
|
231
292
|
person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
|
|
232
293
|
manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
|
|
233
294
|
|
|
234
|
-
A Claude Code cloud session
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
295
|
+
A Claude Code cloud session sends all its traffic through a proxy (`HTTPS_PROXY`). curl, npm and git use it;
|
|
296
|
+
Node's own `fetch` does not unless Node is started with `NODE_USE_ENV_PROXY=1` (Node.js 22.21 and later). So from
|
|
297
|
+
0.12.1 the CLI restarts itself once with that set whenever a proxy is in the environment. Before that, `setup attach`
|
|
298
|
+
and `progress attach` connected directly, failed the lookup, and said "did not answer", which read like a blocked
|
|
299
|
+
network in a session with full network access. A failed request now says what happened (`lib/net.mjs`):
|
|
300
|
+
|
|
301
|
+
- the directory's status and its own words (`homie.rocks answered 409: …`);
|
|
302
|
+
- or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, an untrusted certificate) and whether the
|
|
303
|
+
proxy was used.
|
|
304
|
+
|
|
305
|
+
Only a refusal by the proxy itself (a 403 with `x-deny-reason: host_not_allowed`, or a refused CONNECT) names the
|
|
306
|
+
network setting (the environment's Network access: Custom, add homie.rocks). The build goes on with its local feed.
|
|
307
|
+
|
|
308
|
+
**The hand-off opens the studio's repository.** The chat's "Start building" and "Build it" open a Claude Code
|
|
309
|
+
session in one repository, and homie.rocks never asks GitHub which one Deploy to Cloudflare made. So the studio's
|
|
310
|
+
Worker tells it: `deploy` (and Workers Builds, which runs it) passes the repository as the Worker's private
|
|
311
|
+
`HOMIE_REPO` variable, from `HOMIE_REPO`, studio.json `github`, or the git remote, in that order (`lib/repo.mjs`;
|
|
312
|
+
Workers Builds sets no repository variable of its own). The Worker says it with its claim, a request Cloudflare
|
|
313
|
+
stamps with the Worker's own zone. It is never in the public manifest. `setup attach` writes it into studio.json
|
|
314
|
+
`github` too. When the directory does not know it, the card asks the person for it. It never opens Homie's engine
|
|
315
|
+
repository (`homie-rocks/homie`), which is also never accepted as a studio's.
|
|
238
316
|
|
|
239
317
|
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
240
318
|
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
|
@@ -297,6 +375,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
297
375
|
| `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). |
|
|
298
376
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
299
377
|
| `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`. |
|
|
378
|
+
| `lib/net.mjs`, `lib/repo.mjs` | The toolkit's own web requests: through the environment's proxy, and an honest reason when one fails. The studio's repository (`owner/name`), from its remote, never the engine's. |
|
|
300
379
|
| `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
|
|
301
380
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
302
381
|
| `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. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -40,6 +40,19 @@
|
|
|
40
40
|
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
41
|
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
42
42
|
*
|
|
43
|
+
* homie-studio office [--url <site>] the back office: every live room of every game and who is in it, now
|
|
44
|
+
* homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office; or
|
|
45
|
+
* --to /<game>/play: a private game on the owner's own phone)
|
|
46
|
+
* homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
|
|
47
|
+
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
48
|
+
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
49
|
+
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
50
|
+
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
51
|
+
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
52
|
+
* (kick, close and launch are ASKED for: the owner confirms each with one
|
|
53
|
+
* tap in their own browser, from the link this prints)
|
|
54
|
+
* homie-studio office revoke (every office key, play ticket and pending ask ends)
|
|
55
|
+
*
|
|
43
56
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
44
57
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
45
58
|
* (a progress feed for one build; --share shows it in the Claude app
|
|
@@ -98,20 +111,22 @@ import { look } from '../lib/look.mjs';
|
|
|
98
111
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
99
112
|
import { portCheck } from '../lib/port-check.mjs';
|
|
100
113
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
101
|
-
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
114
|
+
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
102
115
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
103
116
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
104
117
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
105
118
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
119
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
120
|
+
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
107
121
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
108
122
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
109
123
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
110
124
|
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
125
|
+
import { restartWithProxy } from '../lib/net.mjs';
|
|
111
126
|
|
|
112
127
|
const argv = process.argv.slice(2);
|
|
113
128
|
const flags = new Map();
|
|
114
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
129
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen'];
|
|
115
130
|
const positional = [];
|
|
116
131
|
for (let i = 0; i < argv.length; i++) {
|
|
117
132
|
const a = argv[i];
|
|
@@ -231,6 +246,28 @@ function print(result) {
|
|
|
231
246
|
case 'stats share':
|
|
232
247
|
lines.push(result.message);
|
|
233
248
|
break;
|
|
249
|
+
case 'office':
|
|
250
|
+
lines.push(...officeLines(result));
|
|
251
|
+
break;
|
|
252
|
+
case 'office link':
|
|
253
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
254
|
+
break;
|
|
255
|
+
case 'office key':
|
|
256
|
+
lines.push(`Office key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
257
|
+
break;
|
|
258
|
+
case 'office announce':
|
|
259
|
+
case 'office revoke':
|
|
260
|
+
lines.push(result.message);
|
|
261
|
+
break;
|
|
262
|
+
case 'office invite':
|
|
263
|
+
for (const i of result.invites) lines.push(`Invite ${i.code}${i.label ? ` (${i.label})` : ''}, ${i.maxUses ? `${i.maxUses} use${i.maxUses === 1 ? '' : 's'}` : 'any number of uses'}: ${i.link}`);
|
|
264
|
+
if (result.note) lines.push(result.note);
|
|
265
|
+
break;
|
|
266
|
+
case 'office launch':
|
|
267
|
+
case 'office kick':
|
|
268
|
+
case 'office close':
|
|
269
|
+
lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
|
|
270
|
+
break;
|
|
234
271
|
case 'setup status':
|
|
235
272
|
lines.push(formatStatus(result));
|
|
236
273
|
break;
|
|
@@ -407,6 +444,18 @@ async function main() {
|
|
|
407
444
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
408
445
|
if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
|
|
409
446
|
if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
|
|
447
|
+
if (cmd === 'office') {
|
|
448
|
+
const url = flags.get('url');
|
|
449
|
+
if (!sub) return officeShow(root, { url });
|
|
450
|
+
if (sub === 'link') return officeLink(root, { url, to: flags.get('to') });
|
|
451
|
+
if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
|
|
452
|
+
if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
|
|
453
|
+
if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days') });
|
|
454
|
+
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
|
|
455
|
+
if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
|
|
456
|
+
if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
|
|
457
|
+
if (sub === 'revoke') return officeRevoke(root, { url });
|
|
458
|
+
}
|
|
410
459
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
411
460
|
if (cmd === 'media' && sub === 'list') {
|
|
412
461
|
const studio = readStudio(root);
|
|
@@ -576,7 +625,7 @@ async function dev(root) {
|
|
|
576
625
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
577
626
|
const studio = readStudio(root);
|
|
578
627
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
579
|
-
for (const added of ensureMigrations(root)) log(`added ${added} (${
|
|
628
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
580
629
|
await new Promise((done) => {
|
|
581
630
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
582
631
|
m.on('close', done);
|
|
@@ -616,6 +665,13 @@ function mediaPut(root, file, as) {
|
|
|
616
665
|
});
|
|
617
666
|
}
|
|
618
667
|
|
|
668
|
+
/*
|
|
669
|
+
* A machine whose web traffic goes through a proxy (a Claude Code cloud session: HTTPS_PROXY) runs this command once
|
|
670
|
+
* more with Node's own proxy support on, so the toolkit's requests go the way curl's and npm's do (lib/net.mjs).
|
|
671
|
+
*/
|
|
672
|
+
const proxied = await restartWithProxy();
|
|
673
|
+
if (proxied !== null) process.exit(proxied);
|
|
674
|
+
|
|
619
675
|
try {
|
|
620
676
|
const result = await main();
|
|
621
677
|
// A shared progress feed sends its last word before the command exits.
|
package/lib/build.mjs
CHANGED
|
@@ -223,6 +223,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
223
223
|
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
224
|
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
225
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
226
|
+
// game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
|
|
227
|
+
...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
|
|
226
228
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
227
229
|
};
|
|
228
230
|
});
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { repoOf } from './repo.mjs';
|
|
26
|
+
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
26
27
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
27
28
|
|
|
28
29
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -192,13 +193,13 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
192
193
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
193
194
|
|
|
194
195
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
195
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
196
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
196
197
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
197
198
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
198
199
|
step('D1 migrations applied');
|
|
199
200
|
|
|
200
201
|
const started = Date.now();
|
|
201
|
-
const dep = w(['deploy']);
|
|
202
|
+
const dep = w(['deploy', ...repoVar(root)]);
|
|
202
203
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
203
204
|
remember(`worker:${cf.worker}`);
|
|
204
205
|
// The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
|
|
@@ -238,6 +239,16 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/*
|
|
243
|
+
* THE STUDIO'S REPOSITORY, FOR ITS WORKER (lib/repo.mjs). The Claude app's hand-off opens Claude Code on the studio's
|
|
244
|
+
* own GitHub repository, and only the studio knows which it is: the deploy hands it to the Worker as a variable
|
|
245
|
+
* (HOMIE_REPO), never into a public file, and the Worker tells its directory alongside its claim.
|
|
246
|
+
*/
|
|
247
|
+
export function repoVar(root) {
|
|
248
|
+
const repo = repoOf(root);
|
|
249
|
+
return repo ? ['--var', `HOMIE_REPO:${repo}`] : [];
|
|
250
|
+
}
|
|
251
|
+
|
|
241
252
|
/**
|
|
242
253
|
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
243
254
|
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
@@ -282,16 +293,18 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
282
293
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
283
294
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
284
295
|
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
285
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
296
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
286
297
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
287
298
|
let migrate = apply();
|
|
288
299
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
289
300
|
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
290
301
|
if (!first) step('D1 migrations applied');
|
|
291
302
|
const started = Date.now();
|
|
292
|
-
const dep = w(['deploy']);
|
|
303
|
+
const dep = w(['deploy', ...repoVar(root)]);
|
|
293
304
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
294
305
|
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
306
|
+
const repo = repoOf(root);
|
|
307
|
+
step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
|
|
295
308
|
if (first) {
|
|
296
309
|
migrate = apply();
|
|
297
310
|
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
package/lib/directory.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
5
|
* nobody can list games under someone else's site.
|
|
6
6
|
*/
|
|
7
|
+
import { request } from './net.mjs';
|
|
7
8
|
import { readStudio, siteUrl } from './studio.mjs';
|
|
8
9
|
|
|
9
10
|
export async function publish(root, { homie, site } = {}) {
|
|
@@ -11,9 +12,9 @@ export async function publish(root, { homie, site } = {}) {
|
|
|
11
12
|
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
12
13
|
const url = site || siteUrl(root, studio);
|
|
13
14
|
if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
|
|
14
|
-
const
|
|
15
|
+
const sent = await request(`${directory}/api/studio/publish`, {
|
|
15
16
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return {
|
|
17
|
+
}, { timeout: 30_000 });
|
|
18
|
+
// The directory's own answer (what it listed, or why not), or the connection's error as it is.
|
|
19
|
+
return { ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, ...(sent.ok ? {} : { why: sent.body?.message ?? sent.why, ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) }) };
|
|
19
20
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn } from 'node:child_process';
|
|
|
24
24
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { findChrome } from './chrome.mjs';
|
|
27
|
+
import { whyFailed } from './net.mjs';
|
|
27
28
|
import { isOurs } from './statusline.mjs';
|
|
28
29
|
import { findStudio, readLocal, readStudio } from './studio.mjs';
|
|
29
30
|
import { STUDIO_VERSION } from './version.mjs';
|
|
@@ -47,13 +48,17 @@ const MARK = { ok: '✓', act: '→', missing: '✗', optional: '○', later: '
|
|
|
47
48
|
const RANK = { missing: 0, act: 1, unknown: 2, later: 3, optional: 4, ok: 5 };
|
|
48
49
|
const worst = (states) => states.reduce((a, b) => (RANK[b] < RANK[a] ? b : a), 'ok');
|
|
49
50
|
|
|
51
|
+
/** Whether the directory answers at all (any status is an answer), and if not, the connection's own error. */
|
|
50
52
|
async function reach(url, fetchFn, ms = 5000) {
|
|
51
53
|
try {
|
|
52
54
|
const res = await fetchFn(url, { method: 'GET', headers: { 'user-agent': `homie-studio/${STUDIO_VERSION}`, accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
|
|
53
55
|
const deny = res.headers?.get?.('x-deny-reason') ?? '';
|
|
54
56
|
if (res.status === 403 && /host_not_allowed/i.test(deny)) return { ok: false, blocked: true };
|
|
55
57
|
return { ok: true, status: res.status };
|
|
56
|
-
} catch (error) {
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const w = whyFailed(error, url);
|
|
60
|
+
return { ok: false, blocked: w.needs === 'network', why: w.why, code: w.code };
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
@@ -133,16 +138,17 @@ export async function setupStatus({
|
|
|
133
138
|
say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. The Claude app: Settings, Connectors, Add custom connector, https://homie.rocks/mcp; then its setup card (studio_setup) makes the studio on your own Cloudflare and GitHub in three taps.',
|
|
134
139
|
};
|
|
135
140
|
let state; let detail;
|
|
136
|
-
if (net.blocked) { state = 'act'; detail = `
|
|
137
|
-
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here;
|
|
141
|
+
if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${new URL(directory).host}`; }
|
|
142
|
+
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; this computer's request to the directory failed just now: ${net.why}`; }
|
|
138
143
|
else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
|
|
139
|
-
|
|
144
|
+
// The directory not answering this computer says nothing about the connector: say what failed, as it is.
|
|
145
|
+
else { state = 'unknown'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
|
|
140
146
|
rows.push({
|
|
141
147
|
id: 'connector', label: 'Homie connector', need: 'required', state, detail,
|
|
142
148
|
unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the cards in the Claude app',
|
|
143
149
|
fix: state === 'ok' ? null : net.blocked
|
|
144
150
|
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
|
|
145
|
-
: fixConnector,
|
|
151
|
+
: said === 'no' || net.ok ? fixConnector : null,
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
|
package/lib/net.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TOOLKIT'S OWN WEB REQUESTS, AND WHAT TO SAY WHEN ONE FAILS.
|
|
3
|
+
*
|
|
4
|
+
* A Claude Code cloud session sends ALL of its traffic through an HTTP proxy named in HTTPS_PROXY. curl, npm and
|
|
5
|
+
* git use that proxy. Node's own fetch() does not, unless the process started with NODE_USE_ENV_PROXY=1 (Node
|
|
6
|
+
* 22.21 and later): it connects directly, the VM's direct egress fails ("getaddrinfo ENOTFOUND"), and the toolkit
|
|
7
|
+
* used to report "did not answer". On 2026-09-30 a session with FULL network access read that as a blocked network
|
|
8
|
+
* and told the person to change their network settings, while curl in the same session reached homie.rocks.
|
|
9
|
+
*
|
|
10
|
+
* So:
|
|
11
|
+
* - The CLI restarts itself once with NODE_USE_ENV_PROXY=1 when a proxy is set and this Node can use it
|
|
12
|
+
* (`restartWithProxy`), so every request it makes goes the way curl's do. Loopback addresses never go
|
|
13
|
+
* through the proxy.
|
|
14
|
+
* - A failed request is reported as what it is (`request`, `whyFailed`):
|
|
15
|
+
* * the status and the server's own message;
|
|
16
|
+
* * or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, a TLS error) and whether a proxy
|
|
17
|
+
* was used.
|
|
18
|
+
* - The network setting is named ONLY when the proxy itself refused the host: a 403 with
|
|
19
|
+
* `x-deny-reason: host_not_allowed`, or a CONNECT it refused.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
24
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '::1', '[::1]'];
|
|
25
|
+
|
|
26
|
+
/** The proxy this machine's web requests go through (the environment's HTTPS_PROXY and friends), or null. */
|
|
27
|
+
export function proxyOf(env = process.env) {
|
|
28
|
+
for (const k of PROXY_VARS) if (String(env[k] ?? '').trim()) return { variable: k, url: String(env[k]).trim() };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this process's fetch() uses that proxy: Node's NODE_USE_ENV_PROXY / --use-env-proxy is on, and the variable
|
|
34
|
+
* Node reads for https (https_proxy, then HTTPS_PROXY; an empty one counts, as "none") names one.
|
|
35
|
+
*/
|
|
36
|
+
export function proxyInUse(env = process.env, execArgv = process.execArgv) {
|
|
37
|
+
const on = env.NODE_USE_ENV_PROXY === '1' || execArgv.includes('--use-env-proxy');
|
|
38
|
+
return on && Boolean(String(env.https_proxy ?? env.HTTPS_PROXY ?? '').trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether this Node.js can use the environment's proxy at all (22.21 and later have --use-env-proxy). */
|
|
42
|
+
export function canUseProxy() {
|
|
43
|
+
try { return Boolean(process.allowedNodeEnvironmentFlags?.has('--use-env-proxy')); } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** NO_PROXY with this machine's own loopback addresses in it (a dev site or a local directory never goes out). */
|
|
47
|
+
export function noProxyWithLoopback(value) {
|
|
48
|
+
const have = String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
return [...new Set([...have, ...LOOPBACK.filter((h) => h !== '[::1]')])].join(',');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run this CLI once more with Node's proxy support on, when a proxy is set and not yet in use. Resolves to the exit
|
|
54
|
+
* status of that run, or null when there was nothing to do (no proxy, already on, or a Node without it). A signal
|
|
55
|
+
* this process gets (Ctrl-C, or a kill by its process id) is passed on, so the run never outlives it.
|
|
56
|
+
*/
|
|
57
|
+
export function restartWithProxy({ argv = process.argv, env = process.env, execArgv = process.execArgv } = {}) {
|
|
58
|
+
if (!proxyOf(env) || proxyInUse(env, execArgv) || !canUseProxy() || env.HOMIE_STUDIO_NO_PROXY_RESTART === '1') return Promise.resolve(null);
|
|
59
|
+
// Node reads https_proxy before HTTPS_PROXY, and an EMPTY one counts: it would silently mean "no proxy".
|
|
60
|
+
const next = Object.fromEntries(Object.entries(env).filter(([k, v]) => !(PROXY_VARS.includes(k) && !String(v ?? '').trim())));
|
|
61
|
+
const quiet = execArgv.some((a) => a.startsWith('--disable-warning=UNDICI-EHPA')) ? [] : ['--disable-warning=UNDICI-EHPA'];
|
|
62
|
+
return new Promise((done) => {
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(process.execPath, [...quiet, ...execArgv, ...argv.slice(1)], {
|
|
66
|
+
stdio: 'inherit',
|
|
67
|
+
env: { ...next, NODE_USE_ENV_PROXY: '1', HOMIE_STUDIO_NO_PROXY_RESTART: '1', NO_PROXY: noProxyWithLoopback(env.NO_PROXY ?? env.no_proxy), no_proxy: noProxyWithLoopback(env.no_proxy ?? env.NO_PROXY) },
|
|
68
|
+
});
|
|
69
|
+
} catch { done(null); return; } // could not start it: carry on in this process, and the errors will say why
|
|
70
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
71
|
+
// Ctrl-C at a terminal reaches the whole process group already: pass SIGINT on only when nothing else will.
|
|
72
|
+
const pass = (signal) => { if (signal === 'SIGINT' && process.stdin.isTTY) return; try { child.kill(signal); } catch { /* already gone */ } };
|
|
73
|
+
for (const signal of signals) process.on(signal, pass);
|
|
74
|
+
child.on('error', () => { for (const signal of signals) process.off(signal, pass); done(null); });
|
|
75
|
+
child.on('exit', (code, signal) => { for (const signal of signals) process.off(signal, pass); done(code ?? (signal ? 1 : 0)); });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hostOf = (url) => { try { return new URL(String(url)).host; } catch { return String(url); } };
|
|
80
|
+
const oneLine = (text, max = 200) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
81
|
+
|
|
82
|
+
/** The one network setting a Claude Code cloud session can change, said only when its proxy refused the host. */
|
|
83
|
+
export function networkSetting(host) {
|
|
84
|
+
return `the network proxy of this machine refused ${host}. In a Claude Code cloud session that means the environment's network access leaves it out: in claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"; Full works too), and start a new session`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why a request that never got an answer failed: the connection error's own code and words, and the proxy's part.
|
|
89
|
+
* { why, code, needs } where needs is 'network' only for a refusal by the proxy itself.
|
|
90
|
+
*/
|
|
91
|
+
export function whyFailed(error, url, { env = process.env, execArgv = process.execArgv } = {}) {
|
|
92
|
+
const host = hostOf(url);
|
|
93
|
+
// fetch() wraps the real error: TypeError "fetch failed" > (a DOMException) > the socket's or the proxy's own.
|
|
94
|
+
const chain = [];
|
|
95
|
+
for (let e = error, i = 0; e && i < 6; e = e.cause, i += 1) chain.push(e);
|
|
96
|
+
const deepest = chain.at(-1) ?? {};
|
|
97
|
+
const code = chain.map((e) => e?.code).reverse().find((c) => typeof c === 'string' && c.trim())
|
|
98
|
+
?? (chain.some((e) => e?.name === 'TimeoutError') ? 'TIMEOUT' : null);
|
|
99
|
+
const words = oneLine(deepest.message ?? String(error));
|
|
100
|
+
const proxy = proxyOf(env);
|
|
101
|
+
const used = Boolean(proxy) && proxyInUse(env, execArgv);
|
|
102
|
+
const refused = /Proxy response \((\d{3})\)/i.exec(chain.map((e) => e?.message ?? '').join(' '));
|
|
103
|
+
if (refused && refused[1] === '403') return { why: networkSetting(host), code: 'PROXY_403', needs: 'network' };
|
|
104
|
+
if (refused) return { why: `the network proxy (${proxy?.variable ?? 'HTTPS_PROXY'}) answered ${refused[1]} when asked to connect to ${host}`, code: `PROXY_${refused[1]}` };
|
|
105
|
+
const via = proxy ? (used ? ` (through the proxy in ${proxy.variable})` : ` (directly: this machine's proxy in ${proxy.variable} was not used${canUseProxy() ? '' : `, because Node.js ${process.versions.node} cannot use it; Node.js 22.21 or newer can`})`) : '';
|
|
106
|
+
if (code === 'TIMEOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return { why: `no answer from ${host} in time${via}`, code: code ?? 'TIMEOUT' };
|
|
107
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { why: `could not look up ${host} (${code})${via}`, code };
|
|
108
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return { why: `could not connect to ${host} (${code})${via}`, code };
|
|
109
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/i.test(code ?? '') || /certificate/i.test(words)) {
|
|
110
|
+
return { why: `the TLS certificate ${host} presented is not trusted here (${code ?? words})${via}; when a proxy inspects traffic, its CA belongs in NODE_EXTRA_CA_CERTS`, code: code ?? 'CERT' };
|
|
111
|
+
}
|
|
112
|
+
return { why: `the request to ${host} failed (${code ? `${code}: ` : ''}${words})${via}`, code };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Why an answer is a refusal: the proxy's (and then the one network setting), or the server's own words. */
|
|
116
|
+
export function whyRefused(res, body, text, url) {
|
|
117
|
+
const host = hostOf(url);
|
|
118
|
+
if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) return { why: networkSetting(host), needs: 'network', status: 403 };
|
|
119
|
+
const said = oneLine(body?.message ?? body?.error ?? (body ? '' : text), 300);
|
|
120
|
+
return { why: `${host} answered ${res?.status}${said ? `: ${said}` : ''}`, status: res?.status ?? null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One JSON request with an honest result: { ok: true, status, body } or { ok: false, why, status?, code?, needs? }.
|
|
125
|
+
* A 2xx answer whose JSON says `ok: false` is a refusal too.
|
|
126
|
+
*/
|
|
127
|
+
export async function request(url, init = {}, { timeout = 10_000, fetchFn = globalThis.fetch } = {}) {
|
|
128
|
+
let res; let text = '';
|
|
129
|
+
try {
|
|
130
|
+
res = await fetchFn(url, { ...init, signal: init.signal ?? AbortSignal.timeout(timeout) });
|
|
131
|
+
text = await res.text();
|
|
132
|
+
} catch (error) { return { ok: false, ...whyFailed(error, url) }; }
|
|
133
|
+
let body = null;
|
|
134
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
135
|
+
if (!res.ok || body?.ok === false) return { ok: false, body, ...whyRefused(res, body, text, url) };
|
|
136
|
+
return { ok: true, status: res.status, body, headers: res.headers };
|
|
137
|
+
}
|