@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 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.12.0 upgrade # the plan; changes nothing
76
- npx -y @homie-rocks/studio@0.12.0 upgrade --apply # after the person agrees
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.12.0 setup status --connector yes # before a studio exists
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's default network ("Trusted") reaches homie.rocks. If a session's network blocks it
235
- (its proxy answers 403 `host_not_allowed`, and the progress card stops updating), the toolkit says so and names the
236
- setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The build goes on
237
- with its local feed.
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. |
@@ -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} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
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
  });
@@ -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 { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
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} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats: counts, never tracks'})`);
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} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
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 res = await fetch(`${directory}/api/studio/publish`, {
15
+ const sent = await request(`${directory}/api/studio/publish`, {
15
16
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
16
- }).catch((error) => ({ ok: false, status: 0, json: async () => ({ error: String(error.message) }) }));
17
- const body = await res.json().catch(() => ({}));
18
- return { ok: Boolean(res.ok && body.ok), command: 'publish', directory, site: url, ...body };
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) { return { ok: false, why: error?.name === 'TimeoutError' ? 'did not answer in time' : 'did not answer' }; }
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 = `this environment's network does not reach ${new URL(directory).host}`; }
137
- else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; ${new URL(directory).host} ${net.why ?? 'did not answer'} just now`; }
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
- else { state = net.ok ? 'unknown' : 'act'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `${new URL(directory).host} ${net.why ?? 'did not answer'}`; }
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
+ }