@homie-rocks/studio 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -8
- package/bin/homie-studio.mjs +54 -13
- package/lib/build.mjs +36 -1
- package/lib/check.mjs +14 -12
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +5 -10
- package/lib/port-check.mjs +5 -11
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +118 -21
- package/lib/setup.mjs +90 -0
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +23 -0
- package/lib/version.mjs +7 -3
- package/package.json +1 -1
- package/worker/index.mjs +75 -4
- package/worker/version.mjs +1 -1
package/lib/scaffold.mjs
CHANGED
|
@@ -25,40 +25,66 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
25
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
26
|
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
27
27
|
import { themeFile } from './site.mjs';
|
|
28
|
+
import { PACKAGE_ROOT } from './studio.mjs';
|
|
28
29
|
|
|
29
30
|
export const COMPAT_DATE = '2026-06-01';
|
|
30
|
-
|
|
31
|
+
/** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
|
|
32
|
+
export const WRANGLER_VERSION = '4.145.0';
|
|
31
33
|
|
|
32
34
|
export function slugify(name) {
|
|
33
35
|
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
34
36
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '') || 'studio';
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
/*
|
|
40
|
+
* The Worker config, generated so a later deploy can rewrite exactly what it owns.
|
|
41
|
+
*
|
|
42
|
+
* `layout: 'root'` (every studio from 0.10.0) keeps it at the studio's root, where Cloudflare's Workers Builds and
|
|
43
|
+
* the "Deploy to Cloudflare" button look for it; `'site'` is the older site/wrangler.jsonc.
|
|
44
|
+
*
|
|
45
|
+
* PREVIEWS (Cloudflare Worker Previews, Wrangler 4.135.0+): a branch deploys with `npx wrangler preview` (Workers
|
|
46
|
+
* Builds does it on every push that is not to the production branch) to its own URL, and Cloudflare gives every
|
|
47
|
+
* Preview its OWN Durable Object namespace, so a Preview's public rooms never meet production's. The `previews`
|
|
48
|
+
* block binds the same Table and Lobby classes and names the Preview (HOMIE_PREVIEW). It binds no D1 on purpose:
|
|
49
|
+
* a Preview counts nothing into the studio's stats, records no rounds and never claims itself in the directory;
|
|
50
|
+
* its pages and rooms work without it.
|
|
51
|
+
*
|
|
52
|
+
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
53
|
+
* the binding names and keep it linked.
|
|
54
|
+
*/
|
|
55
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root' }) {
|
|
56
|
+
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
57
|
+
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
58
|
+
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
39
59
|
const config = {
|
|
40
|
-
$schema:
|
|
60
|
+
$schema: `${at.schema}/wrangler/config-schema.json`,
|
|
41
61
|
name: worker,
|
|
42
|
-
main:
|
|
62
|
+
main: at.main,
|
|
43
63
|
compatibility_date: COMPAT_DATE,
|
|
64
|
+
// The site's own fetches (its directory claim, "played this week") go out as any browser's would, so a studio
|
|
65
|
+
// whose directory is on the same zone (a house studio on *.homie.rocks, or two Workers on one workers.dev
|
|
66
|
+
// subdomain) reaches it instead of failing with 1042.
|
|
67
|
+
compatibility_flags: ['global_fetch_strictly_public'],
|
|
44
68
|
workers_dev: true,
|
|
45
|
-
preview_urls:
|
|
46
|
-
assets: { directory:
|
|
47
|
-
durable_objects: { bindings:
|
|
69
|
+
preview_urls: true,
|
|
70
|
+
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
71
|
+
durable_objects: { bindings: rooms },
|
|
48
72
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
49
|
-
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir:
|
|
73
|
+
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
|
|
50
74
|
...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
|
|
51
75
|
vars: { STUDIO_NAME: name },
|
|
52
76
|
observability: { enabled: true },
|
|
77
|
+
previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
|
|
53
78
|
};
|
|
54
|
-
return `// This studio's site on its own Cloudflare account (written by homie-studio
|
|
55
|
-
//
|
|
79
|
+
return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
|
|
80
|
+
// Cloudflare's Workers Free plan. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
|
|
81
|
+
// \`npx wrangler preview\` puts a branch on its own Preview URL with its own rooms. There is no R2 binding
|
|
56
82
|
// until \`homie-studio storage add\` gives the studio storage for large media.
|
|
57
83
|
${JSON.stringify(config, null, 2)}
|
|
58
84
|
`;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
function agentsMd({ name, slug }) {
|
|
87
|
+
export function agentsMd({ name, slug }) {
|
|
62
88
|
return `# ${name}
|
|
63
89
|
|
|
64
90
|
This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
|
|
@@ -75,7 +101,9 @@ directory lists its games; homie.rocks does not host them.
|
|
|
75
101
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
76
102
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
77
103
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
78
|
-
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`)
|
|
104
|
+
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
105
|
+
| \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
|
|
106
|
+
| \`changes/\` | One small file per change that went out through a pull request (\`homie-studio progress pr\` writes it): the site lists the newest, so the Claude app can tell when a merged change is live. |
|
|
79
107
|
| \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
|
|
80
108
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
81
109
|
|
|
@@ -103,6 +131,11 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
103
131
|
SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
|
|
104
132
|
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
105
133
|
their browser. It never overwrites a Worker or database this studio did not create.
|
|
134
|
+
- **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
|
|
135
|
+
every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
|
|
136
|
+
the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
|
|
137
|
+
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
|
|
138
|
+
in the homie.rocks directory the first time it is read, so nothing is stored by hand.
|
|
106
139
|
- \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
|
|
107
140
|
videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
|
|
108
141
|
payment method on the account before R2 works (its first 10 GB a month are free), so this
|
|
@@ -186,7 +219,7 @@ Claude-only notes go below this line. -->
|
|
|
186
219
|
`;
|
|
187
220
|
}
|
|
188
221
|
|
|
189
|
-
function readme({ name }) {
|
|
222
|
+
export function readme({ name }) {
|
|
190
223
|
return `# ${name}
|
|
191
224
|
|
|
192
225
|
A game studio made with [Homie](https://homie.rocks). Open this folder in Claude Code or Codex
|
|
@@ -249,7 +282,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
|
|
|
249
282
|
| \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`. |
|
|
250
283
|
| \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
|
|
251
284
|
|
|
252
|
-
\`src/worker.mjs
|
|
285
|
+
\`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
|
|
253
286
|
`;
|
|
254
287
|
|
|
255
288
|
const GITIGNORE = `node_modules/
|
|
@@ -278,10 +311,13 @@ videos/**/*.webm
|
|
|
278
311
|
`;
|
|
279
312
|
|
|
280
313
|
/** Every file a new studio gets, relative to its folder. */
|
|
281
|
-
export function studioFiles({ name, slug, homie }) {
|
|
314
|
+
export function studioFiles({ name, slug, homie, template = false }) {
|
|
282
315
|
const worker = slug;
|
|
283
316
|
const studio = {
|
|
284
317
|
name, slug,
|
|
318
|
+
// A copy of the public template (Deploy to Cloudflare) until `homie-studio setup attach` names it for real:
|
|
319
|
+
// meanwhile its site shows the name the person typed in Cloudflare's form (STUDIO_NAME).
|
|
320
|
+
...(template ? { template: true } : {}),
|
|
285
321
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
286
322
|
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
287
323
|
// `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
|
|
@@ -297,11 +333,20 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
297
333
|
type: 'module',
|
|
298
334
|
scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
|
|
299
335
|
devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
|
|
336
|
+
// What Cloudflare's "Deploy to Cloudflare" form says about each setting (package.json `cloudflare.bindings`).
|
|
337
|
+
cloudflare: {
|
|
338
|
+
label: 'Homie studio',
|
|
339
|
+
products: ['Workers', 'D1', 'Durable Objects'],
|
|
340
|
+
bindings: {
|
|
341
|
+
STUDIO_NAME: { description: 'Your studio\'s name, as its site shows it (for example **Night Owls**). Claude can change it later.' },
|
|
342
|
+
DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor) and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
|
|
343
|
+
},
|
|
344
|
+
},
|
|
300
345
|
};
|
|
301
|
-
|
|
346
|
+
const files = {
|
|
302
347
|
'AGENTS.md': agentsMd({ name, slug }),
|
|
303
348
|
'CLAUDE.md': claudeMd(),
|
|
304
|
-
'README.md': readme({ name }),
|
|
349
|
+
'README.md': template ? templateReadme() : readme({ name }),
|
|
305
350
|
'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
|
|
306
351
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
307
352
|
'.gitignore': GITIGNORE,
|
|
@@ -319,9 +364,61 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
319
364
|
`,
|
|
320
365
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
321
366
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
322
|
-
'
|
|
367
|
+
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
323
368
|
'.claude/skills/.gitkeep': '',
|
|
324
369
|
};
|
|
370
|
+
if (template) {
|
|
371
|
+
// A first game, so the site plays the moment Cloudflare deploys it; and a band on Home that connects the new
|
|
372
|
+
// studio to the Claude chat that set it up (`setup attach` removes both once Claude works in the studio).
|
|
373
|
+
for (const [rel, text] of starterFiles('gem-rush')) files[`games/gem-rush/${rel}`] = text;
|
|
374
|
+
files['site/partials/home.html'] = CONNECT_BAND;
|
|
375
|
+
}
|
|
376
|
+
return files;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A starter's files, as text, relative to its folder. */
|
|
380
|
+
function starterFiles(id) {
|
|
381
|
+
const dir = join(PACKAGE_ROOT, 'starters', id);
|
|
382
|
+
const out = [];
|
|
383
|
+
const walk = (rel) => {
|
|
384
|
+
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
385
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
386
|
+
if (entry.isDirectory()) walk(path);
|
|
387
|
+
else out.push([path, readFileSync(join(dir, path), 'utf8')]);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
walk('');
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The template's first-run band on Home: one link that connects this studio to the Claude chat that set it up. */
|
|
395
|
+
export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once Claude works in the studio
|
|
396
|
+
(homie-studio setup attach removes it); delete it by hand any time. -->
|
|
397
|
+
<div class="band-in" style="text-align:center">
|
|
398
|
+
<p class="kicker">New studio</p>
|
|
399
|
+
<h2>{{studio.name}} is live</h2>
|
|
400
|
+
<p>It runs on your own Cloudflare account. Connect it to the Claude chat that set it up, and Claude takes it from here.</p>
|
|
401
|
+
<p><a class="btn" href="/_studio/connect">Connect to Claude</a></p>
|
|
402
|
+
</div>
|
|
403
|
+
`;
|
|
404
|
+
|
|
405
|
+
export function templateReadme() {
|
|
406
|
+
return `# A Homie studio
|
|
407
|
+
|
|
408
|
+
This repository is a game studio made with [Homie](https://homie.rocks): its games, music, videos and posts,
|
|
409
|
+
and a site with public multiplayer rooms that runs on **your own Cloudflare account**, on the free plan.
|
|
410
|
+
|
|
411
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/homie-rocks/homie/tree/main/template)
|
|
412
|
+
|
|
413
|
+
The button copies this studio into your GitHub, creates its Worker, database and rooms on your Cloudflare, and
|
|
414
|
+
deploys it with Workers Builds: every push to \`main\` goes live, and every other branch gets its own Preview.
|
|
415
|
+
The site plays a first game the moment it is up.
|
|
416
|
+
|
|
417
|
+
Then open the site and tap **Connect to Claude**, or ask Claude in the Claude app to set up your studio with the
|
|
418
|
+
Homie connector: it makes games, songs and videos here, in a pull request you merge with one tap.
|
|
419
|
+
|
|
420
|
+
\`AGENTS.md\` says how everything here works.
|
|
421
|
+
`;
|
|
325
422
|
}
|
|
326
423
|
|
|
327
424
|
/**
|
|
@@ -352,7 +449,7 @@ function insideGit(dir) {
|
|
|
352
449
|
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
353
450
|
}
|
|
354
451
|
|
|
355
|
-
export function newStudio(folder, { name, homie, slug: askedSlug, install = true } = {}) {
|
|
452
|
+
export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false } = {}) {
|
|
356
453
|
if (typeof folder !== 'string' || !folder.trim()) throw new Error('name the folder for the new studio, e.g. `homie-studio new ~/studios/night-owls --name "Night Owls"`. Nothing was written.');
|
|
357
454
|
if (!name || !String(name).trim()) throw new Error('give the studio a name: --name "Night Owls". Nothing was written.');
|
|
358
455
|
const dir = resolve(folder.replace(/^~(?=\/|$)/, homedir()));
|
|
@@ -367,7 +464,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
367
464
|
if (entries.length) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
|
|
368
465
|
}
|
|
369
466
|
const slug = slugify(askedSlug || name);
|
|
370
|
-
const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks' });
|
|
467
|
+
const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template });
|
|
371
468
|
mkdirSync(dir, { recursive: true });
|
|
372
469
|
const wrote = [];
|
|
373
470
|
for (const [rel, content] of Object.entries(files)) {
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio setup attach <hs_…>` — this repository is the studio the Claude app's setup card (the Homie MCP
|
|
3
|
+
* tool studio_setup) is making, said once from the Claude Code session that works in it:
|
|
4
|
+
*
|
|
5
|
+
* - the directory records that Claude works in this repository (the card's "Let Claude work in it" goes green),
|
|
6
|
+
* and answers with the studio's live address once its site is connected (the card's Cloudflare step);
|
|
7
|
+
* - a copy of the public template (studio.json `template: true`) gets the name the person chose in the chat:
|
|
8
|
+
* studio.json, the Worker's STUDIO_NAME, and AGENTS.md and README.md while they are untouched; its first-run
|
|
9
|
+
* "Connect to Claude" band goes; studio.json takes the Worker and database names Cloudflare's form chose;
|
|
10
|
+
* - the live address goes into .studio/local.json (git-ignored), so `check --url` and the card know it.
|
|
11
|
+
*
|
|
12
|
+
* It sends the repository's owner/name and the studio's name and slug, nothing else: no key, no path, no code.
|
|
13
|
+
* A setup id is single use per repository: a second attach from the same repository answers the same; another
|
|
14
|
+
* repository is refused.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { configNames } from './cloudflare.mjs';
|
|
20
|
+
import { networkWhy } from './progress.mjs';
|
|
21
|
+
import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
|
|
22
|
+
import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
|
|
23
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
24
|
+
|
|
25
|
+
const SETUP_ID = /^hs_[a-f0-9]{32}$/;
|
|
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
|
+
export async function setupAttach(root, id, { homie } = {}) {
|
|
37
|
+
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
|
+
const studio = readStudio(root);
|
|
39
|
+
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
40
|
+
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}` };
|
|
53
|
+
|
|
54
|
+
const renamed = [];
|
|
55
|
+
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
|
|
56
|
+
if (studio.template === true) {
|
|
57
|
+
const next = { ...readStudio(root) };
|
|
58
|
+
delete next.template;
|
|
59
|
+
const was = { name: next.name, slug: next.slug };
|
|
60
|
+
if (name) { next.name = name; next.slug = slugify(name); }
|
|
61
|
+
// The Worker and database are whatever Cloudflare's form named them: studio.json follows wrangler.jsonc.
|
|
62
|
+
const names = configNames(root);
|
|
63
|
+
next.cloudflare = { ...next.cloudflare, ...(names.worker ? { worker: names.worker } : {}), ...(names.d1 ? { d1: names.d1 } : {}) };
|
|
64
|
+
writeStudio(root, next);
|
|
65
|
+
renamed.push('studio.json');
|
|
66
|
+
if (name) {
|
|
67
|
+
const cfg = configPath(root);
|
|
68
|
+
const text = readFileSync(cfg, 'utf8');
|
|
69
|
+
const fixed = text.replace(/("STUDIO_NAME"\s*:\s*)"(?:[^"\\]|\\.)*"/g, (_, k) => `${k}${JSON.stringify(name)}`);
|
|
70
|
+
if (fixed !== text) { writeFileSync(cfg, fixed); renamed.push('wrangler.jsonc'); }
|
|
71
|
+
const untouched = (file, before, after) => {
|
|
72
|
+
const path = join(root, file);
|
|
73
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === before) { writeFileSync(path, after); renamed.push(file); }
|
|
74
|
+
};
|
|
75
|
+
untouched('AGENTS.md', agentsMd(was), agentsMd({ name: next.name, slug: next.slug }));
|
|
76
|
+
untouched('README.md', templateReadme(), readme({ name: next.name }));
|
|
77
|
+
}
|
|
78
|
+
const band = join(root, 'site', 'partials', 'home.html');
|
|
79
|
+
if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
|
|
80
|
+
}
|
|
81
|
+
if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
|
|
82
|
+
return {
|
|
83
|
+
ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
|
|
84
|
+
message: body.site ? `This repository is ${name ?? studio.name}, live at ${body.site}. The Claude app's card shows it.` : `This repository is ${name ?? studio.name}. Its site is not connected yet: ${body.connect ?? 'open the site and tap Connect to Claude'}.`,
|
|
85
|
+
next: [
|
|
86
|
+
...(renamed.length ? ['commit the renamed files on a branch and open a pull request (merging it names the live site)'] : []),
|
|
87
|
+
'npm install, then npx --no-install homie-studio progress attach <hb_…> when the chat opened a build',
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -26,6 +26,17 @@ export function requireStudio(from) {
|
|
|
26
26
|
return root;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/*
|
|
30
|
+
* WHERE THE WORKER'S CONFIG LIVES. From 0.10.0 a new studio keeps `wrangler.jsonc` at its root, which is what
|
|
31
|
+
* Cloudflare's Workers Builds and its "Deploy to Cloudflare" button read (they run the studio's own `npm run build`
|
|
32
|
+
* and `npm run deploy` at the repository's root). A studio made before 0.10.0 keeps `site/wrangler.jsonc`; every
|
|
33
|
+
* command runs Wrangler next to whichever the studio has, so both keep working.
|
|
34
|
+
*/
|
|
35
|
+
export function workerDir(root) { return existsSync(join(root, 'wrangler.jsonc')) || !existsSync(join(root, 'site', 'wrangler.jsonc')) ? root : join(root, 'site'); }
|
|
36
|
+
export function configPath(root) { return join(workerDir(root), 'wrangler.jsonc'); }
|
|
37
|
+
/** 'root' (0.10.0 and later: Workers Builds ready) or 'site' (older studios). */
|
|
38
|
+
export function layoutOf(root) { return workerDir(root) === root ? 'root' : 'site'; }
|
|
39
|
+
|
|
29
40
|
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
41
|
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
42
|
|
|
@@ -152,6 +152,29 @@
|
|
|
152
152
|
"## Beta": "9d02638207a26e73"
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
},
|
|
156
|
+
"0.10.0": {
|
|
157
|
+
"files": {
|
|
158
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
159
|
+
"README.md": "684b3cca8001673c",
|
|
160
|
+
"games/README.md": "5e5e942baa7e0372",
|
|
161
|
+
"music/README.md": "b8a828de9311bd28",
|
|
162
|
+
"videos/README.md": "70614388cbb203c7",
|
|
163
|
+
"posts/README.md": "37841aae476bd540",
|
|
164
|
+
"site/README.md": "ca131cd0472464fb",
|
|
165
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
166
|
+
},
|
|
167
|
+
"sections": {
|
|
168
|
+
"AGENTS.md": {
|
|
169
|
+
"(intro)": "214aa46eb3a895d5",
|
|
170
|
+
"## Layout": "f14d641159480e6c",
|
|
171
|
+
"## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
|
|
172
|
+
"## The site": "7d3525ba39b7f262",
|
|
173
|
+
"## Making games": "c204473d9b468f49",
|
|
174
|
+
"## Rules": "969318425c99f5a5",
|
|
175
|
+
"## Beta": "9d02638207a26e73"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
155
178
|
}
|
|
156
179
|
}
|
|
157
180
|
}
|
package/lib/version.mjs
CHANGED
|
@@ -8,10 +8,14 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
9
|
export const STUDIO_VERSION = pkg.version;
|
|
10
10
|
|
|
11
|
-
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). */
|
|
11
|
+
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
|
|
12
12
|
export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
|
|
13
13
|
return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
|
|
18
|
+
* integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
|
|
19
|
+
* their default network settings; a directory's /npm/ tarball is not.
|
|
20
|
+
*/
|
|
21
|
+
export function packageSpec() { return STUDIO_VERSION; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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
|
@@ -90,9 +90,71 @@ async function postsOf(env, origin) {
|
|
|
90
90
|
} catch { return []; }
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
/*
|
|
94
|
+
* THE SITE CLAIMS ITSELF IN THE DIRECTORY (0.10.0). The homie.rocks directory lists a studio only when its site
|
|
95
|
+
* serves the claim the directory handed out for that exact address, which proves the studio controls the site.
|
|
96
|
+
* Before 0.10.0 `homie-studio deploy` fetched the claim and stored it in D1 from the person's computer; a site built
|
|
97
|
+
* by Cloudflare's Workers Builds has no such step. So the site asks for its own claim the first time its manifest
|
|
98
|
+
* is read (by the directory publishing it, or `deploy` reading it once), for the address it is being read at, and
|
|
99
|
+
* keeps it in its D1 (`meta`, `homie_claim:<origin>`).
|
|
100
|
+
*
|
|
101
|
+
* It never asks from a Preview (HOMIE_PREVIEW: a Preview is a branch under review, not the studio), from a studio
|
|
102
|
+
* whose studio.json says `homie.directory: false`, or without its D1. A directory that does not answer is asked
|
|
103
|
+
* again at most once a minute per address; the page answers without a claim meanwhile.
|
|
104
|
+
*/
|
|
105
|
+
const CLAIM = /^[a-f0-9]{16,128}$/;
|
|
106
|
+
const claimed = new Map();
|
|
107
|
+
const claimMissed = new Map();
|
|
108
|
+
|
|
109
|
+
async function storedClaim(env, origin) {
|
|
94
110
|
if (!env.DB) return null;
|
|
95
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
|
|
113
|
+
return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
|
|
114
|
+
} catch { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function directoryOf(cat) {
|
|
118
|
+
const d = cat?.studio?.directory;
|
|
119
|
+
if (d === null || d === false) return null;
|
|
120
|
+
const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
|
|
121
|
+
try {
|
|
122
|
+
const u = new URL(at);
|
|
123
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
|
|
124
|
+
} catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
|
|
128
|
+
async function claimOf(env, cat, url) {
|
|
129
|
+
const origin = url.origin;
|
|
130
|
+
if (claimed.has(origin)) return claimed.get(origin);
|
|
131
|
+
const kept = await storedClaim(env, origin);
|
|
132
|
+
if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
|
|
133
|
+
const directory = directoryOf(cat);
|
|
134
|
+
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
135
|
+
const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
|
|
136
|
+
&& Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
|
|
137
|
+
if (may) {
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
|
|
140
|
+
headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
|
|
141
|
+
});
|
|
142
|
+
const body = await res.json().catch(() => null);
|
|
143
|
+
if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
|
|
144
|
+
await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
|
|
145
|
+
claimed.set(origin, body.claim);
|
|
146
|
+
return body.claim;
|
|
147
|
+
}
|
|
148
|
+
} catch { /* the directory is optional for a live site */ }
|
|
149
|
+
claimMissed.set(origin, Date.now());
|
|
150
|
+
}
|
|
151
|
+
return kept?.older ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
|
|
155
|
+
function named(cat, env) {
|
|
156
|
+
if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
|
|
157
|
+
return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
|
|
96
158
|
}
|
|
97
159
|
|
|
98
160
|
/** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
|
|
@@ -303,8 +365,14 @@ async function route(request, env, ctx) {
|
|
|
303
365
|
const parts = path.split('/').filter(Boolean);
|
|
304
366
|
const read = request.method === 'GET' || request.method === 'HEAD';
|
|
305
367
|
let catP = null;
|
|
306
|
-
const getCat = () => (catP ??= catalogue(env, url.origin));
|
|
368
|
+
const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
307
369
|
|
|
370
|
+
// "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
|
|
371
|
+
if (path === '/_studio/connect' && read) {
|
|
372
|
+
const directory = directoryOf(await getCat());
|
|
373
|
+
if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
|
|
374
|
+
return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
|
|
375
|
+
}
|
|
308
376
|
if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
|
|
309
377
|
if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
|
|
310
378
|
if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
|
|
@@ -336,7 +404,10 @@ async function route(request, env, ctx) {
|
|
|
336
404
|
...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
|
|
337
405
|
site: url.origin,
|
|
338
406
|
studio: cat.studio?.version ?? null,
|
|
339
|
-
claim: await claimOf(env),
|
|
407
|
+
claim: await claimOf(env, cat, url),
|
|
408
|
+
// What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
|
|
409
|
+
// its mark is here). A Preview says it is one.
|
|
410
|
+
...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
|
|
340
411
|
games: (cat.games ?? []).map((g) => {
|
|
341
412
|
// The card picture is the landing's hero still (what the landing leads with), else the game's cover.
|
|
342
413
|
const cover = gameCover(g);
|
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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.10.0';
|