@homie-rocks/studio 0.12.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.12.0 upgrade # the plan; changes nothing
76
- npx -y @homie-rocks/studio@0.12.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
 
@@ -142,7 +142,7 @@ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
142
142
  ## Setup status
143
143
 
144
144
  ```sh
145
- npx -y @homie-rocks/studio@0.12.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
146
146
  npx homie-studio setup status # in a studio (also: homie-studio doctor)
147
147
  ```
148
148
 
@@ -231,10 +231,27 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
231
231
  person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
232
232
  manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
233
233
 
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.
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.
238
255
 
239
256
  The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
240
257
  directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
@@ -297,6 +314,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
297
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). |
298
315
  | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
299
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. |
300
318
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
301
319
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
302
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. |
@@ -108,6 +108,7 @@ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, reco
108
108
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
109
109
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
110
110
  import { installStatusLine, statusLine } from '../lib/statusline.mjs';
111
+ import { restartWithProxy } from '../lib/net.mjs';
111
112
 
112
113
  const argv = process.argv.slice(2);
113
114
  const flags = new Map();
@@ -616,6 +617,13 @@ function mediaPut(root, file, as) {
616
617
  });
617
618
  }
618
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
+
619
627
  try {
620
628
  const result = await main();
621
629
  // A shared progress feed sends its last word before the command exits.
@@ -22,6 +22,7 @@ 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 { repoOf } from './repo.mjs';
25
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
 
@@ -198,7 +199,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
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
@@ -289,9 +300,11 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
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
+ }
package/lib/progress.mjs CHANGED
@@ -57,6 +57,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
57
57
  import { extname, join } from 'node:path';
58
58
  import { refreshCodexFile } from './codex.mjs';
59
59
  import { readStudio, siteUrl } from './studio.mjs';
60
+ import { request, whyRefused } from './net.mjs';
60
61
 
61
62
  export const PROGRESS_KIND = 'homie-studio-progress';
62
63
  export const PROGRESS_DIR = join('.studio', 'progress');
@@ -89,19 +90,10 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
89
90
  const currentPath = (root) => join(dir(root), 'current');
90
91
  const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
91
92
 
92
- /*
93
- * A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
94
- * in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
95
- * `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
96
- * instead of "did not answer"; the build itself goes on with its local feed.
97
- */
93
+ /** Kept for callers of 0.11.0: whether an answer is the proxy refusing the host (lib/net.mjs says the rest). */
98
94
  export function networkWhy(res, at) {
99
- if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
100
- let host = at;
101
- try { host = new URL(at).host; } catch { /* as given */ }
102
- return `this Claude Code cloud environment's network does not reach ${host}. 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"), and start a new session; until then the build goes on with its local feed only`;
103
- }
104
- return null;
95
+ const r = whyRefused(res, null, '', at);
96
+ return r.needs === 'network' ? r.why : null;
105
97
  }
106
98
 
107
99
  /** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
@@ -154,23 +146,24 @@ async function pushDoc(root, id) {
154
146
  let key = null;
155
147
  try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
156
148
  const { shared, ...body } = doc;
157
- const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
149
+ const sent = await request(`${shared.directory}/api/studio/progress/${shared.build}`, {
158
150
  method: 'PUT',
159
151
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
160
152
  body: JSON.stringify({ ...body, build: shared.build }),
161
- signal: AbortSignal.timeout(8000),
162
- });
163
- const blocked = networkWhy(res, shared.directory);
164
- if (blocked) {
165
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app cannot follow this build: ${blocked}`); });
166
- return null;
167
- }
168
- if (res.status === 404 || res.status === 410) {
169
- // The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
170
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
153
+ }, { timeout: 8000 });
154
+ if (!sent.ok) {
155
+ if (sent.status === 404 || sent.status === 410 || sent.needs === 'network') {
156
+ // The shared copy ended (24 hours, or the directory dropped it), or the proxy refuses the directory: keep the
157
+ // local feed, stop sending, and say why in its log.
158
+ mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app's card stopped following this build: ${sent.why}`); });
159
+ return null;
160
+ }
161
+ // Anything else (a slow answer, a refused write): the next write tries again; the log keeps what happened once.
162
+ mutateDoc(root, id, (d) => { if (d.shared && d.shared.lastError !== sent.why) { d.shared = { ...d.shared, lastError: sent.why }; pushLog(d, `Sending to the card failed: ${sent.why}`); } });
171
163
  return null;
172
164
  }
173
- const reply = await res.json().catch(() => null);
165
+ const reply = sent.body;
166
+ if (readFeed(root, id)?.shared?.lastError) mutateDoc(root, id, (d) => { delete d.shared.lastError; });
174
167
  if (reply?.stop) {
175
168
  mutateDoc(root, id, (d) => {
176
169
  if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
@@ -468,36 +461,38 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
468
461
  writeAtomic(currentPath(root), `${build}\n`);
469
462
  let shared = null;
470
463
  let why = null;
464
+ let failure = {};
471
465
  if (share) {
472
466
  const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
473
467
  if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
474
468
  else {
475
- try {
476
- // A build the chat opened is attached (once); otherwise a new one is opened here.
477
- const res = await fetch(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
478
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
479
- body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
480
- });
481
- const body = await res.json().catch(() => null);
482
- if (!res.ok && networkWhy(res, at)) why = networkWhy(res, at);
483
- else if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
484
- // The chat named the build: its title is the one the person saw.
485
- if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
486
- writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
487
- try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
488
- shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
489
- mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
490
- schedule(root, build, { soon: true });
491
- heartbeat(root, build, true);
492
- } else why = body?.message ?? `${at} answered ${res.status}`;
493
- } catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
469
+ // A build the chat opened is attached (once); otherwise a new one is opened here.
470
+ const sent = await request(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
471
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
472
+ body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }),
473
+ });
474
+ const body = sent.body;
475
+ if (sent.ok && REMOTE_ID.test(String(body?.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body?.key))) {
476
+ // The chat named the build: its title is the one the person saw.
477
+ if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
478
+ writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
479
+ try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
480
+ shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
481
+ mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
482
+ schedule(root, build, { soon: true });
483
+ heartbeat(root, build, true);
484
+ } else {
485
+ // As it is: the directory's own answer (its status and message) or the connection's error, never a guess.
486
+ why = sent.ok ? `${at} answered without a build and key` : sent.why;
487
+ failure = { ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) };
488
+ }
494
489
  }
495
490
  }
496
491
  if (attach && !shared) {
497
492
  // Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
498
493
  rmSync(docPath(root, build), { force: true });
499
494
  rmSync(currentPath(root), { force: true });
500
- return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
495
+ return { ok: false, command: 'progress attach', build: attach, ...failure, why: `could not attach to ${attach}: ${why}` };
501
496
  }
502
497
  return {
503
498
  ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
package/lib/repo.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WHICH GITHUB REPOSITORY THIS STUDIO IS. The Claude app's hand-off opens Claude Code on it, so it must be the
3
+ * studio's own: never the engine and template repository (homie-rocks/homie) that Cloudflare's "Deploy to
4
+ * Cloudflare" copied the studio from, and never a guess.
5
+ *
6
+ * In order:
7
+ * 1. HOMIE_REPO, a build variable naming it;
8
+ * 2. studio.json `github` (`setup attach` writes it, and the person may);
9
+ * 3. the checkout's git remotes, origin first. A Workers Builds checkout, a Claude Code cloud session (its remote
10
+ * goes through the session's own git proxy) and a person's clone all carry one. Only owner/name is kept,
11
+ * never a token in the URL.
12
+ *
13
+ * A deploy hands it to the live Worker as a variable (HOMIE_REPO), never into a public file. The Worker tells its
14
+ * directory alongside its claim, so the setup card can open the right repository.
15
+ */
16
+ import { spawnSync } from 'node:child_process';
17
+ import { readStudio } from './studio.mjs';
18
+
19
+ export const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
20
+ /** The engine and template repository: a studio is never this. */
21
+ export const ENGINE_REPO = 'homie-rocks/homie';
22
+
23
+ /** owner/name in a git remote URL (https, ssh, a proxy's /git/owner/name path), without any credentials; or null. */
24
+ export function repoFromUrl(url) {
25
+ const s = String(url ?? '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
26
+ if (!s) return null;
27
+ const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(s);
28
+ return m ? studioRepo(`${m[1]}/${m[2]}`) : null;
29
+ }
30
+
31
+ /** A studio repository slug, or null (not a slug, or the engine repository). */
32
+ export function studioRepo(value) {
33
+ const r = String(value ?? '').trim();
34
+ return REPO.test(r) && r.toLowerCase() !== ENGINE_REPO ? r : null;
35
+ }
36
+
37
+ /** This studio's repository: HOMIE_REPO, studio.json `github`, else its git remotes (origin first). */
38
+ export function repoOf(root, { env = process.env } = {}) {
39
+ const named = studioRepo(env.HOMIE_REPO);
40
+ if (named) return named;
41
+ try { const s = studioRepo(readStudio(root).github); if (s) return s; } catch { /* no studio.json */ }
42
+ const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
43
+ const origin = git(['remote', 'get-url', 'origin']);
44
+ if (origin.status === 0) { const r = repoFromUrl(origin.stdout); if (r) return r; }
45
+ const all = git(['config', '--get-regexp', '^remote\\..*\\.url$']);
46
+ for (const line of (all.status === 0 ? all.stdout : '').split('\n')) {
47
+ const r = repoFromUrl(line.split(/\s+/)[1]);
48
+ if (r) return r;
49
+ }
50
+ return null;
51
+ }
package/lib/setup.mjs CHANGED
@@ -13,43 +13,32 @@
13
13
  * A setup id is single use per repository: a second attach from the same repository answers the same; another
14
14
  * repository is refused.
15
15
  */
16
- import { spawnSync } from 'node:child_process';
17
16
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
17
  import { join } from 'node:path';
19
18
  import { configNames } from './cloudflare.mjs';
20
- import { networkWhy } from './progress.mjs';
19
+ import { request } from './net.mjs';
20
+ import { ENGINE_REPO, repoOf } from './repo.mjs';
21
21
  import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
22
22
  import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
23
23
  import { STUDIO_VERSION } from './version.mjs';
24
24
 
25
25
  const SETUP_ID = /^hs_[a-f0-9]{32}$/;
26
26
 
27
- /** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
28
- export function repoOf(root) {
29
- const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
30
- if (r.status !== 0) return null;
31
- const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
32
- const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
33
- return m ? `${m[1]}/${m[2]}` : null;
34
- }
35
-
36
27
  export async function setupAttach(root, id, { homie } = {}) {
37
28
  if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
38
29
  const studio = readStudio(root);
39
30
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
40
31
  const repo = repoOf(root);
41
- let res; let body = null;
42
- try {
43
- res = await fetch(`${directory}/api/studio/setup/${id}/attach`, {
44
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
45
- body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
46
- signal: AbortSignal.timeout(10_000),
47
- });
48
- body = await res.json().catch(() => null);
49
- } catch (error) { return { ok: false, command: 'setup attach', why: `${directory} did not answer (${error?.message ?? error})` }; }
50
- const blocked = networkWhy(res, directory);
51
- if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
52
- if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
32
+ if (!repo) {
33
+ return { ok: false, command: 'setup attach', needs: 'repository', why: `this checkout names no GitHub repository of its own (its git remote is missing, or is ${ENGINE_REPO}, Homie's engine and template, which a studio never is). Run this in the studio's own repository, the one Cloudflare's Deploy to Cloudflare made, or name it in studio.json as "github": "<owner>/<name>"` };
34
+ }
35
+ const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
36
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
37
+ body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
38
+ });
39
+ // What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
40
+ if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
41
+ const body = sent.body;
53
42
 
54
43
  const renamed = [];
55
44
  const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
@@ -78,6 +67,9 @@ export async function setupAttach(root, id, { homie } = {}) {
78
67
  const band = join(root, 'site', 'partials', 'home.html');
79
68
  if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
80
69
  }
70
+ // The repository goes into studio.json, so the next deploy (and Workers Builds) tells the live Worker which it is.
71
+ const now = readStudio(root);
72
+ if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
81
73
  if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
82
74
  return {
83
75
  ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
@@ -223,6 +223,30 @@
223
223
  "## Beta": "9d02638207a26e73"
224
224
  }
225
225
  }
226
+ },
227
+ "0.12.1": {
228
+ "files": {
229
+ "CLAUDE.md": "ddf2b2a0376202f6",
230
+ "README.md": "684b3cca8001673c",
231
+ "games/README.md": "5e5e942baa7e0372",
232
+ "music/README.md": "b8a828de9311bd28",
233
+ "videos/README.md": "70614388cbb203c7",
234
+ "posts/README.md": "37841aae476bd540",
235
+ "site/README.md": "ca131cd0472464fb",
236
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
237
+ },
238
+ "sections": {
239
+ "AGENTS.md": {
240
+ "(intro)": "214aa46eb3a895d5",
241
+ "## Layout": "f14d641159480e6c",
242
+ "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
243
+ "## The site": "7d3525ba39b7f262",
244
+ "## Making games": "63069b31c87a2aa2",
245
+ "## The Game Codex and progress": "0738ef719cef3a4c",
246
+ "## Rules": "969318425c99f5a5",
247
+ "## Beta": "9d02638207a26e73"
248
+ }
249
+ }
226
250
  }
227
251
  }
228
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/worker/index.mjs CHANGED
@@ -115,11 +115,23 @@ const claimMissed = new Map();
115
115
  async function storedClaim(env, origin) {
116
116
  if (!env.DB) return null;
117
117
  try {
118
- const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
119
- return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
118
+ const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2, ?3)').bind(`homie_claim:${origin}`, 'homie_claim', `homie_repo:${origin}`).all()).results ?? [];
119
+ const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
120
+ return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
120
121
  } catch { return null; }
121
122
  }
122
123
 
124
+ /*
125
+ * THE STUDIO'S GITHUB REPOSITORY (HOMIE_REPO, set by `homie-studio deploy` from the studio's own git remote or
126
+ * studio.json "github"; lib/repo.mjs). The site tells its directory which repository it is built from, with its
127
+ * claim, so the Claude app's hand-off opens Claude Code on the studio's own repository. It is never put in a public
128
+ * page: only the directory hears it, from this Worker (Cloudflare's CF-Worker header names the zone it runs on),
129
+ * and it is said again only when it changes.
130
+ */
131
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
132
+ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
133
+ const repoSaid = new Map();
134
+
123
135
  export function directoryOf(cat) {
124
136
  const d = cat?.studio?.directory;
125
137
  if (d === null || d === false) return null;
@@ -133,28 +145,34 @@ export function directoryOf(cat) {
133
145
  /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
134
146
  async function claimOf(env, cat, url) {
135
147
  const origin = url.origin;
136
- if (claimed.has(origin)) return claimed.get(origin);
148
+ const repo = repoOf(env);
149
+ if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
137
150
  const kept = await storedClaim(env, origin);
138
- if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
151
+ if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
139
152
  const directory = directoryOf(cat);
140
153
  const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
141
154
  const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
142
155
  && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
143
156
  if (may) {
144
157
  try {
145
- const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
158
+ // Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
159
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
146
160
  headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
147
161
  });
148
162
  const body = await res.json().catch(() => null);
149
163
  if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
150
- await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
164
+ await env.DB.batch([
165
+ env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
166
+ ...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
167
+ ]);
151
168
  claimed.set(origin, body.claim);
169
+ if (repo) repoSaid.set(origin, repo);
152
170
  return body.claim;
153
171
  }
154
172
  } catch { /* the directory is optional for a live site */ }
155
173
  claimMissed.set(origin, Date.now());
156
174
  }
157
- return kept?.older ?? null;
175
+ return kept?.own ?? kept?.older ?? null;
158
176
  }
159
177
 
160
178
  /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.12.0';
2
+ export const STUDIO_VERSION_TAG = '0.12.1';