@homie-rocks/studio 0.11.0 → 0.12.1

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
@@ -72,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
72
72
  A studio pins one version, so nothing changes until it asks. To take a newer one:
73
73
 
74
74
  ```sh
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
75
+ npx -y @homie-rocks/studio@0.12.1 upgrade # the plan; changes nothing
76
+ npx -y @homie-rocks/studio@0.12.1 upgrade --apply # after the person agrees
77
77
  npm install && npm run build
78
78
  ```
79
79
 
@@ -94,6 +94,32 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
94
94
  the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
95
95
  on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
96
96
 
97
+ ## Player accounts and cloud saves
98
+
99
+ A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
100
+ keeps it in **saves**, not in its room: a room forgets everything 60 s after its last player leaves.
101
+
102
+ ```json
103
+ { "id": "my-rpg", "saves": true }
104
+ ```
105
+
106
+ ```ts
107
+ import { createSaves } from '@homie-rocks/studio/saves';
108
+ const saves = createSaves({ game: 'my-rpg' });
109
+ const hero = (await saves.get('hero')) ?? newHero();
110
+ await saves.set('hero', hero); // on this device at once, in the studio's D1 when online
111
+ saves.stats.add({ kills: 1 }); // lifetime numbers that outlive any hero
112
+ await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore
113
+ ```
114
+
115
+ Pressing Play needs no account. A player who saves something is a guest on that browser; with a **passkey**
116
+ (Face ID, a fingerprint, a PIN, no password, no email) it becomes an account on this studio only, and the same
117
+ saves follow them to every device they sign in on. Saves are per player and game, versioned (a stale offline
118
+ copy is a conflict, never an overwrite), capped in size, and offline-tolerant. Players see, download and delete
119
+ everything at `/account/`; the owner sees counts (`homie-studio players`) and, through the back office, names,
120
+ never a passkey or an email. The `ember-vale` starter is a small persistent-character game on the same public
121
+ rooms. Everything, including what is stored and the back office's API: [saves/SAVES.md](saves/SAVES.md).
122
+
97
123
  ## The studio's own stats
98
124
 
99
125
  The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
@@ -116,7 +142,7 @@ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
116
142
  ## Setup status
117
143
 
118
144
  ```sh
119
- npx -y @homie-rocks/studio@0.11.0 setup status --connector yes # before a studio exists
145
+ npx -y @homie-rocks/studio@0.12.1 setup status --connector yes # before a studio exists
120
146
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
121
147
  ```
122
148
 
@@ -205,9 +231,27 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
205
231
  person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
206
232
  manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
207
233
 
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.
234
+ A Claude Code cloud session sends all its traffic through a proxy (`HTTPS_PROXY`). curl, npm and git use it;
235
+ Node's own `fetch` does not unless Node is started with `NODE_USE_ENV_PROXY=1` (Node.js 22.21 and later). So from
236
+ 0.12.1 the CLI restarts itself once with that set whenever a proxy is in the environment. Before that, `setup attach`
237
+ and `progress attach` connected directly, failed the lookup, and said "did not answer", which read like a blocked
238
+ network in a session with full network access. A failed request now says what happened (`lib/net.mjs`):
239
+
240
+ - the directory's status and its own words (`homie.rocks answered 409: …`);
241
+ - or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, an untrusted certificate) and whether the
242
+ proxy was used.
243
+
244
+ Only a refusal by the proxy itself (a 403 with `x-deny-reason: host_not_allowed`, or a refused CONNECT) names the
245
+ network setting (the environment's Network access: Custom, add homie.rocks). The build goes on with its local feed.
246
+
247
+ **The hand-off opens the studio's repository.** The chat's "Start building" and "Build it" open a Claude Code
248
+ session in one repository, and homie.rocks never asks GitHub which one Deploy to Cloudflare made. So the studio's
249
+ Worker tells it: `deploy` (and Workers Builds, which runs it) passes the repository as the Worker's private
250
+ `HOMIE_REPO` variable, from `HOMIE_REPO`, studio.json `github`, or the git remote, in that order (`lib/repo.mjs`;
251
+ Workers Builds sets no repository variable of its own). The Worker says it with its claim, a request Cloudflare
252
+ stamps with the Worker's own zone. It is never in the public manifest. `setup attach` writes it into studio.json
253
+ `github` too. When the directory does not know it, the card asks the person for it. It never opens Homie's engine
254
+ repository (`homie-rocks/homie`), which is also never accepted as a studio's.
211
255
 
212
256
  The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
213
257
  directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
@@ -270,6 +314,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
270
314
  | `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). |
271
315
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
272
316
  | `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`. |
317
+ | `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. |
273
318
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
274
319
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
275
320
  | `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. |
@@ -37,6 +37,8 @@
37
37
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
38
38
  * homie-studio stats revoke (every stats key and page session ends)
39
39
  * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
40
+ * homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
41
+ * homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
40
42
  *
41
43
  * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
42
44
  * [--budget <dollars>] [--unit usd|credits] [--share]
@@ -96,19 +98,21 @@ import { look } from '../lib/look.mjs';
96
98
  import { importPort, planPort } from '../lib/port.mjs';
97
99
  import { portCheck } from '../lib/port-check.mjs';
98
100
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
99
- import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
101
+ import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
100
102
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
101
103
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
102
104
  import { STUDIO_VERSION } from '../lib/version.mjs';
103
105
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
106
+ import { playersOwner, playersShow } from '../lib/players.mjs';
104
107
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
105
108
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
106
109
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
107
110
  import { installStatusLine, statusLine } from '../lib/statusline.mjs';
111
+ import { restartWithProxy } from '../lib/net.mjs';
108
112
 
109
113
  const argv = process.argv.slice(2);
110
114
  const flags = new Map();
111
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
115
+ const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
112
116
  const positional = [];
113
117
  for (let i = 0; i < argv.length; i++) {
114
118
  const a = argv[i];
@@ -207,8 +211,17 @@ function print(result) {
207
211
  for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
208
212
  for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
209
213
  for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
214
+ if (result.players) lines.push(` players: ${n(result.players.accounts)} accounts (${n(result.players.newAccounts7d)} new this week), ${n(result.players.guests)} guests with saves, ${n(result.players.active7d)} played this week`);
210
215
  break;
211
216
  }
217
+ case 'players': {
218
+ const p = result.players;
219
+ lines.push(`${p.accounts} player accounts (${p.newAccounts7d} new this week), ${p.guests} guests with saves, ${p.active7d} played this week.`, result.note);
220
+ break;
221
+ }
222
+ case 'players owner':
223
+ lines.push(result.revoked ? result.message : `One-time link (until ${result.expiresAt}): ${result.link}`, ...(result.revoked ? [] : [result.use]));
224
+ break;
212
225
  case 'stats key':
213
226
  lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
214
227
  break;
@@ -388,6 +401,8 @@ async function main() {
388
401
  return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
389
402
  }
390
403
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
404
+ if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
405
+ if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
391
406
  if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
392
407
  if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
393
408
  if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
@@ -562,8 +577,7 @@ async function dev(root) {
562
577
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
563
578
  const studio = readStudio(root);
564
579
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
565
- const added = ensureStatsMigration(root);
566
- if (added) log(`added ${added} (the studio's own stats)`);
580
+ for (const added of ensureMigrations(root)) log(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
567
581
  await new Promise((done) => {
568
582
  const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
569
583
  m.on('close', done);
@@ -603,6 +617,13 @@ function mediaPut(root, file, as) {
603
617
  });
604
618
  }
605
619
 
620
+ /*
621
+ * A machine whose web traffic goes through a proxy (a Claude Code cloud session: HTTPS_PROXY) runs this command once
622
+ * 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).
623
+ */
624
+ const proxied = await restartWithProxy();
625
+ if (proxied !== null) process.exit(proxied);
626
+
606
627
  try {
607
628
  const result = await main();
608
629
  // A shared progress feed sends its last word before the command exits.
package/lib/build.mjs CHANGED
@@ -220,6 +220,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
220
220
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
221
221
  roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
222
222
  ...(g.screen ? { screen: g.screen } : {}),
223
+ // game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
224
+ // saves calls, and the site's nav and the game's landing offer a player account.
225
+ ...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
223
226
  landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
224
227
  };
225
228
  });
@@ -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, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
25
+ import { repoOf } from './repo.mjs';
26
+ import { ensureLocalIgnored, ensureMigrations, 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;
@@ -96,7 +97,7 @@ export function deployPlan(root) {
96
97
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
97
98
  cloudflare: [
98
99
  { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
99
- { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, and the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor)', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
100
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
100
101
  { kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
101
102
  { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
102
103
  storage
@@ -192,14 +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
- const added = ensureStatsMigration(root);
196
- if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
196
+ 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'})`);
197
197
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
198
198
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
199
199
  step('D1 migrations applied');
200
200
 
201
201
  const started = Date.now();
202
- const dep = w(['deploy']);
202
+ const dep = w(['deploy', ...repoVar(root)]);
203
203
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
204
204
  remember(`worker:${cf.worker}`);
205
205
  // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
@@ -239,6 +239,16 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
239
239
  };
240
240
  }
241
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
+
242
252
  /**
243
253
  * Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
244
254
  * serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
@@ -283,16 +293,18 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
283
293
  const step = (what) => { steps.push({ what }); log(what); };
284
294
  const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
285
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 };
286
- if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
296
+ for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
287
297
  const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
288
298
  let migrate = apply();
289
299
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
290
300
  if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
291
301
  if (!first) step('D1 migrations applied');
292
302
  const started = Date.now();
293
- const dep = w(['deploy']);
303
+ const dep = w(['deploy', ...repoVar(root)]);
294
304
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
295
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');
296
308
  if (first) {
297
309
  migrate = apply();
298
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/codex.mjs CHANGED
@@ -637,6 +637,7 @@ ${game.blurb ?? ''}
637
637
  - **Players in a room:** ${players}
638
638
  - **Round:** ${game.roundSeconds ? `${game.roundSeconds} s` : 'how long, and how it ends'}
639
639
  - **Bots:** fill empty seats; a person who arrives takes a bot's place
640
+ - **Progress that lasts:** ${game.saves ? 'the player\'s own saves (cloud saves: what is kept, lifetime stats, hardcore or not)' : 'none: a round is all there is (or cloud saves, if the plan says progress persists across sessions or devices)'}
640
641
 
641
642
  ## Music and sound
642
643
 
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
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `homie-studio players` — the studio's players, for its owner (worker/players.mjs, saves/SAVES.md).
3
+ *
4
+ * homie-studio players [--url <site>] how many have accounts, guests with saves, who played this week
5
+ * homie-studio players owner [--url <site>] a one-time link that marks the owner's OWN player account as the
6
+ * owner's (open it in the browser where they are signed in on the site)
7
+ * homie-studio players owner --revoke no account is the owner's any more
8
+ *
9
+ * The proof of ownership is the studio's own Cloudflare login, as for `stats`: the link's key is minted here and
10
+ * only its SHA-256 goes into the studio's D1 (through Wrangler). The link works once, for 30 minutes.
11
+ */
12
+ import { runner } from './cloudflare.mjs';
13
+ import { loopback, mint, siteOf, statsShow } from './stats.mjs';
14
+
15
+ export async function playersShow(root, { url } = {}) {
16
+ const s = await statsShow(root, { url, range: '7d' });
17
+ if (!s.ok) return { ...s, command: 'players' };
18
+ if (!s.players) return { ok: false, command: 'players', why: 'this studio\'s D1 has no player tables yet: `npm run deploy` applies migration 0004_players.sql (or `npm run dev` for a local site)' };
19
+ return { ok: true, command: 'players', site: s.site, players: s.players, note: 'Counts only. A player\'s name, passkeys and saves are theirs; the back office lists names, never a passkey or an email.' };
20
+ }
21
+
22
+ export function playersOwner(root, { url, revoke = false } = {}) {
23
+ const { studio, site } = siteOf(root, url);
24
+ if (!site) return { ok: false, command: 'players owner', why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
25
+ const local = loopback(site);
26
+ if (revoke) {
27
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
28
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "UPDATE players SET owner = 0; DELETE FROM stats_keys WHERE kind = 'player-owner';"]);
29
+ return res.code === 0 ? { ok: true, command: 'players owner', site, revoked: true, message: 'No player account is the owner\'s now. `homie-studio players owner` marks one again.' }
30
+ : { ok: false, command: 'players owner', why: res.out.trim().split('\n').slice(-3).join(' ') };
31
+ }
32
+ const k = mint(root, studio, 'player-owner', 30 * 60_000, { local });
33
+ if (!k.ok) return { ok: false, command: 'players owner', why: k.why };
34
+ return {
35
+ ok: true, command: 'players owner', site, link: `${site}/account/?owner=${k.key}`, expiresAt: k.expiresAt,
36
+ use: 'Give this link to the studio\'s owner to open in the browser where they play: they sign in (or make an account with a passkey) on that page, then press "Yes, this account is mine". It works once, within 30 minutes. It is theirs alone; do not post it anywhere.',
37
+ };
38
+ }