@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 +25 -7
- package/bin/homie-studio.mjs +8 -0
- package/lib/cloudflare.mjs +15 -2
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/progress.mjs +39 -44
- package/lib/repo.mjs +51 -0
- package/lib/setup.mjs +15 -23
- package/lib/template-history.json +24 -0
- package/package.json +1 -1
- package/worker/index.mjs +25 -7
- package/worker/version.mjs +1 -1
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.
|
|
76
|
-
npx -y @homie-rocks/studio@0.12.
|
|
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.
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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.
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|
15
|
+
const sent = await request(`${directory}/api/studio/publish`, {
|
|
15
16
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return {
|
|
17
|
+
}, { timeout: 30_000 });
|
|
18
|
+
// The directory's own answer (what it listed, or why not), or the connection's error as it is.
|
|
19
|
+
return { ...(sent.body ?? {}), ok: sent.ok, command: 'publish', directory, site: url, ...(sent.ok ? {} : { why: sent.body?.message ?? sent.why, ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) }) };
|
|
19
20
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn } from 'node:child_process';
|
|
|
24
24
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { findChrome } from './chrome.mjs';
|
|
27
|
+
import { whyFailed } from './net.mjs';
|
|
27
28
|
import { isOurs } from './statusline.mjs';
|
|
28
29
|
import { findStudio, readLocal, readStudio } from './studio.mjs';
|
|
29
30
|
import { STUDIO_VERSION } from './version.mjs';
|
|
@@ -47,13 +48,17 @@ const MARK = { ok: '✓', act: '→', missing: '✗', optional: '○', later: '
|
|
|
47
48
|
const RANK = { missing: 0, act: 1, unknown: 2, later: 3, optional: 4, ok: 5 };
|
|
48
49
|
const worst = (states) => states.reduce((a, b) => (RANK[b] < RANK[a] ? b : a), 'ok');
|
|
49
50
|
|
|
51
|
+
/** Whether the directory answers at all (any status is an answer), and if not, the connection's own error. */
|
|
50
52
|
async function reach(url, fetchFn, ms = 5000) {
|
|
51
53
|
try {
|
|
52
54
|
const res = await fetchFn(url, { method: 'GET', headers: { 'user-agent': `homie-studio/${STUDIO_VERSION}`, accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
|
|
53
55
|
const deny = res.headers?.get?.('x-deny-reason') ?? '';
|
|
54
56
|
if (res.status === 403 && /host_not_allowed/i.test(deny)) return { ok: false, blocked: true };
|
|
55
57
|
return { ok: true, status: res.status };
|
|
56
|
-
} catch (error) {
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const w = whyFailed(error, url);
|
|
60
|
+
return { ok: false, blocked: w.needs === 'network', why: w.why, code: w.code };
|
|
61
|
+
}
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
@@ -133,16 +138,17 @@ export async function setupStatus({
|
|
|
133
138
|
say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. The Claude app: Settings, Connectors, Add custom connector, https://homie.rocks/mcp; then its setup card (studio_setup) makes the studio on your own Cloudflare and GitHub in three taps.',
|
|
134
139
|
};
|
|
135
140
|
let state; let detail;
|
|
136
|
-
if (net.blocked) { state = 'act'; detail = `
|
|
137
|
-
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here;
|
|
141
|
+
if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${new URL(directory).host}`; }
|
|
142
|
+
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; this computer's request to the directory failed just now: ${net.why}`; }
|
|
138
143
|
else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
|
|
139
|
-
|
|
144
|
+
// The directory not answering this computer says nothing about the connector: say what failed, as it is.
|
|
145
|
+
else { state = 'unknown'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
|
|
140
146
|
rows.push({
|
|
141
147
|
id: 'connector', label: 'Homie connector', need: 'required', state, detail,
|
|
142
148
|
unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the cards in the Claude app',
|
|
143
149
|
fix: state === 'ok' ? null : net.blocked
|
|
144
150
|
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
|
|
145
|
-
: fixConnector,
|
|
151
|
+
: said === 'no' || net.ok ? fixConnector : null,
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
154
|
|
package/lib/net.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TOOLKIT'S OWN WEB REQUESTS, AND WHAT TO SAY WHEN ONE FAILS.
|
|
3
|
+
*
|
|
4
|
+
* A Claude Code cloud session sends ALL of its traffic through an HTTP proxy named in HTTPS_PROXY. curl, npm and
|
|
5
|
+
* git use that proxy. Node's own fetch() does not, unless the process started with NODE_USE_ENV_PROXY=1 (Node
|
|
6
|
+
* 22.21 and later): it connects directly, the VM's direct egress fails ("getaddrinfo ENOTFOUND"), and the toolkit
|
|
7
|
+
* used to report "did not answer". On 2026-09-30 a session with FULL network access read that as a blocked network
|
|
8
|
+
* and told the person to change their network settings, while curl in the same session reached homie.rocks.
|
|
9
|
+
*
|
|
10
|
+
* So:
|
|
11
|
+
* - The CLI restarts itself once with NODE_USE_ENV_PROXY=1 when a proxy is set and this Node can use it
|
|
12
|
+
* (`restartWithProxy`), so every request it makes goes the way curl's do. Loopback addresses never go
|
|
13
|
+
* through the proxy.
|
|
14
|
+
* - A failed request is reported as what it is (`request`, `whyFailed`):
|
|
15
|
+
* * the status and the server's own message;
|
|
16
|
+
* * or the connection error's code (ENOTFOUND, ECONNREFUSED, a timeout, a TLS error) and whether a proxy
|
|
17
|
+
* was used.
|
|
18
|
+
* - The network setting is named ONLY when the proxy itself refused the host: a 403 with
|
|
19
|
+
* `x-deny-reason: host_not_allowed`, or a CONNECT it refused.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
24
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '::1', '[::1]'];
|
|
25
|
+
|
|
26
|
+
/** The proxy this machine's web requests go through (the environment's HTTPS_PROXY and friends), or null. */
|
|
27
|
+
export function proxyOf(env = process.env) {
|
|
28
|
+
for (const k of PROXY_VARS) if (String(env[k] ?? '').trim()) return { variable: k, url: String(env[k]).trim() };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this process's fetch() uses that proxy: Node's NODE_USE_ENV_PROXY / --use-env-proxy is on, and the variable
|
|
34
|
+
* Node reads for https (https_proxy, then HTTPS_PROXY; an empty one counts, as "none") names one.
|
|
35
|
+
*/
|
|
36
|
+
export function proxyInUse(env = process.env, execArgv = process.execArgv) {
|
|
37
|
+
const on = env.NODE_USE_ENV_PROXY === '1' || execArgv.includes('--use-env-proxy');
|
|
38
|
+
return on && Boolean(String(env.https_proxy ?? env.HTTPS_PROXY ?? '').trim());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether this Node.js can use the environment's proxy at all (22.21 and later have --use-env-proxy). */
|
|
42
|
+
export function canUseProxy() {
|
|
43
|
+
try { return Boolean(process.allowedNodeEnvironmentFlags?.has('--use-env-proxy')); } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** NO_PROXY with this machine's own loopback addresses in it (a dev site or a local directory never goes out). */
|
|
47
|
+
export function noProxyWithLoopback(value) {
|
|
48
|
+
const have = String(value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
49
|
+
return [...new Set([...have, ...LOOPBACK.filter((h) => h !== '[::1]')])].join(',');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run this CLI once more with Node's proxy support on, when a proxy is set and not yet in use. Resolves to the exit
|
|
54
|
+
* status of that run, or null when there was nothing to do (no proxy, already on, or a Node without it). A signal
|
|
55
|
+
* this process gets (Ctrl-C, or a kill by its process id) is passed on, so the run never outlives it.
|
|
56
|
+
*/
|
|
57
|
+
export function restartWithProxy({ argv = process.argv, env = process.env, execArgv = process.execArgv } = {}) {
|
|
58
|
+
if (!proxyOf(env) || proxyInUse(env, execArgv) || !canUseProxy() || env.HOMIE_STUDIO_NO_PROXY_RESTART === '1') return Promise.resolve(null);
|
|
59
|
+
// Node reads https_proxy before HTTPS_PROXY, and an EMPTY one counts: it would silently mean "no proxy".
|
|
60
|
+
const next = Object.fromEntries(Object.entries(env).filter(([k, v]) => !(PROXY_VARS.includes(k) && !String(v ?? '').trim())));
|
|
61
|
+
const quiet = execArgv.some((a) => a.startsWith('--disable-warning=UNDICI-EHPA')) ? [] : ['--disable-warning=UNDICI-EHPA'];
|
|
62
|
+
return new Promise((done) => {
|
|
63
|
+
let child;
|
|
64
|
+
try {
|
|
65
|
+
child = spawn(process.execPath, [...quiet, ...execArgv, ...argv.slice(1)], {
|
|
66
|
+
stdio: 'inherit',
|
|
67
|
+
env: { ...next, NODE_USE_ENV_PROXY: '1', HOMIE_STUDIO_NO_PROXY_RESTART: '1', NO_PROXY: noProxyWithLoopback(env.NO_PROXY ?? env.no_proxy), no_proxy: noProxyWithLoopback(env.no_proxy ?? env.NO_PROXY) },
|
|
68
|
+
});
|
|
69
|
+
} catch { done(null); return; } // could not start it: carry on in this process, and the errors will say why
|
|
70
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
71
|
+
// Ctrl-C at a terminal reaches the whole process group already: pass SIGINT on only when nothing else will.
|
|
72
|
+
const pass = (signal) => { if (signal === 'SIGINT' && process.stdin.isTTY) return; try { child.kill(signal); } catch { /* already gone */ } };
|
|
73
|
+
for (const signal of signals) process.on(signal, pass);
|
|
74
|
+
child.on('error', () => { for (const signal of signals) process.off(signal, pass); done(null); });
|
|
75
|
+
child.on('exit', (code, signal) => { for (const signal of signals) process.off(signal, pass); done(code ?? (signal ? 1 : 0)); });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hostOf = (url) => { try { return new URL(String(url)).host; } catch { return String(url); } };
|
|
80
|
+
const oneLine = (text, max = 200) => String(text ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
81
|
+
|
|
82
|
+
/** The one network setting a Claude Code cloud session can change, said only when its proxy refused the host. */
|
|
83
|
+
export function networkSetting(host) {
|
|
84
|
+
return `the network proxy of this machine refused ${host}. In a Claude Code cloud session that means the environment's network access leaves it out: in claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"; Full works too), and start a new session`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why a request that never got an answer failed: the connection error's own code and words, and the proxy's part.
|
|
89
|
+
* { why, code, needs } where needs is 'network' only for a refusal by the proxy itself.
|
|
90
|
+
*/
|
|
91
|
+
export function whyFailed(error, url, { env = process.env, execArgv = process.execArgv } = {}) {
|
|
92
|
+
const host = hostOf(url);
|
|
93
|
+
// fetch() wraps the real error: TypeError "fetch failed" > (a DOMException) > the socket's or the proxy's own.
|
|
94
|
+
const chain = [];
|
|
95
|
+
for (let e = error, i = 0; e && i < 6; e = e.cause, i += 1) chain.push(e);
|
|
96
|
+
const deepest = chain.at(-1) ?? {};
|
|
97
|
+
const code = chain.map((e) => e?.code).reverse().find((c) => typeof c === 'string' && c.trim())
|
|
98
|
+
?? (chain.some((e) => e?.name === 'TimeoutError') ? 'TIMEOUT' : null);
|
|
99
|
+
const words = oneLine(deepest.message ?? String(error));
|
|
100
|
+
const proxy = proxyOf(env);
|
|
101
|
+
const used = Boolean(proxy) && proxyInUse(env, execArgv);
|
|
102
|
+
const refused = /Proxy response \((\d{3})\)/i.exec(chain.map((e) => e?.message ?? '').join(' '));
|
|
103
|
+
if (refused && refused[1] === '403') return { why: networkSetting(host), code: 'PROXY_403', needs: 'network' };
|
|
104
|
+
if (refused) return { why: `the network proxy (${proxy?.variable ?? 'HTTPS_PROXY'}) answered ${refused[1]} when asked to connect to ${host}`, code: `PROXY_${refused[1]}` };
|
|
105
|
+
const via = proxy ? (used ? ` (through the proxy in ${proxy.variable})` : ` (directly: this machine's proxy in ${proxy.variable} was not used${canUseProxy() ? '' : `, because Node.js ${process.versions.node} cannot use it; Node.js 22.21 or newer can`})`) : '';
|
|
106
|
+
if (code === 'TIMEOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ETIMEDOUT') return { why: `no answer from ${host} in time${via}`, code: code ?? 'TIMEOUT' };
|
|
107
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { why: `could not look up ${host} (${code})${via}`, code };
|
|
108
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH') return { why: `could not connect to ${host} (${code})${via}`, code };
|
|
109
|
+
if (/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/i.test(code ?? '') || /certificate/i.test(words)) {
|
|
110
|
+
return { why: `the TLS certificate ${host} presented is not trusted here (${code ?? words})${via}; when a proxy inspects traffic, its CA belongs in NODE_EXTRA_CA_CERTS`, code: code ?? 'CERT' };
|
|
111
|
+
}
|
|
112
|
+
return { why: `the request to ${host} failed (${code ? `${code}: ` : ''}${words})${via}`, code };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Why an answer is a refusal: the proxy's (and then the one network setting), or the server's own words. */
|
|
116
|
+
export function whyRefused(res, body, text, url) {
|
|
117
|
+
const host = hostOf(url);
|
|
118
|
+
if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) return { why: networkSetting(host), needs: 'network', status: 403 };
|
|
119
|
+
const said = oneLine(body?.message ?? body?.error ?? (body ? '' : text), 300);
|
|
120
|
+
return { why: `${host} answered ${res?.status}${said ? `: ${said}` : ''}`, status: res?.status ?? null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One JSON request with an honest result: { ok: true, status, body } or { ok: false, why, status?, code?, needs? }.
|
|
125
|
+
* A 2xx answer whose JSON says `ok: false` is a refusal too.
|
|
126
|
+
*/
|
|
127
|
+
export async function request(url, init = {}, { timeout = 10_000, fetchFn = globalThis.fetch } = {}) {
|
|
128
|
+
let res; let text = '';
|
|
129
|
+
try {
|
|
130
|
+
res = await fetchFn(url, { ...init, signal: init.signal ?? AbortSignal.timeout(timeout) });
|
|
131
|
+
text = await res.text();
|
|
132
|
+
} catch (error) { return { ok: false, ...whyFailed(error, url) }; }
|
|
133
|
+
let body = null;
|
|
134
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
135
|
+
if (!res.ok || body?.ok === false) return { ok: false, body, ...whyRefused(res, body, text, url) };
|
|
136
|
+
return { ok: true, status: res.status, body, headers: res.headers };
|
|
137
|
+
}
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
mutateDoc(root, id, (d) => { d.shared = { ...d.shared,
|
|
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 =
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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). */
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.12.1';
|