@homie-rocks/studio 0.2.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +40 -0
- package/bin/homie-studio.mjs +166 -0
- package/lib/build.mjs +86 -0
- package/lib/check.mjs +123 -0
- package/lib/cloudflare.mjs +155 -0
- package/lib/directory.mjs +19 -0
- package/lib/scaffold.mjs +254 -0
- package/lib/studio.mjs +102 -0
- package/lib/version.mjs +17 -0
- package/netplay/NETPLAY.md +575 -0
- package/netplay/netplay.ts +1309 -0
- package/package.json +44 -0
- package/starters/gem-rush/game.json +9 -0
- package/starters/gem-rush/index.html +17 -0
- package/starters/gem-rush/src/main.ts +719 -0
- package/worker/index.mjs +349 -0
- package/worker/pages.mjs +205 -0
- package/worker/room.mjs +659 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
|
|
3
|
+
* account, through Cloudflare's own tool (Wrangler, pinned in the studio).
|
|
4
|
+
*
|
|
5
|
+
* The person's only step is Wrangler's sign-in (`npx wrangler login`, one
|
|
6
|
+
* approval in their browser); Wrangler keeps that login, this never sees a key.
|
|
7
|
+
*
|
|
8
|
+
* It creates what the studio needs, by the names in studio.json:
|
|
9
|
+
* the Worker (pages + rooms), a D1 database, an R2 bucket (when the account
|
|
10
|
+
* has R2), the Table/Lobby Durable Objects (declared by the Worker)
|
|
11
|
+
* and it REFUSES to touch a Worker, database or bucket of that name that this
|
|
12
|
+
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
13
|
+
* can never overwrite someone's existing site.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { build } from './build.mjs';
|
|
19
|
+
import { wranglerConfig } from './scaffold.mjs';
|
|
20
|
+
import { readStudio, writeStudio } from './studio.mjs';
|
|
21
|
+
|
|
22
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
23
|
+
|
|
24
|
+
export function wranglerBin(root) {
|
|
25
|
+
const local = join(root, 'node_modules', '.bin', 'wrangler');
|
|
26
|
+
return existsSync(local) ? local : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runner(root, env) {
|
|
30
|
+
const bin = wranglerBin(root);
|
|
31
|
+
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
32
|
+
return (args, { cwd = join(root, 'site'), input } = {}) => {
|
|
33
|
+
const res = spawnSync(bin, args, {
|
|
34
|
+
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
35
|
+
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
36
|
+
});
|
|
37
|
+
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
|
|
38
|
+
return { code: res.status ?? 1, out, stdout: (res.stdout ?? '').replace(ANSI, '') };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Who Wrangler is signed in as, or null. Never prints a token. */
|
|
43
|
+
export function whoami(root, env = {}) {
|
|
44
|
+
const w = runner(root, env);
|
|
45
|
+
const res = w(['whoami', '--json'], { cwd: root });
|
|
46
|
+
if (res.code !== 0) return null;
|
|
47
|
+
try { const d = JSON.parse(res.stdout); return d.loggedIn ? { accounts: (d.accounts ?? []).map((a) => ({ id: a.id, name: a.name })), authType: d.authType } : null; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseJson(text) {
|
|
51
|
+
const start = text.search(/[[{]/);
|
|
52
|
+
if (start < 0) return null;
|
|
53
|
+
try { return JSON.parse(text.slice(start)); } catch { return null; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
57
|
+
const studio = readStudio(root);
|
|
58
|
+
const cf = { r2: `${studio.slug}-media`, created: [], ...studio.cloudflare };
|
|
59
|
+
const created = new Set(cf.created ?? []);
|
|
60
|
+
const who = whoami(root);
|
|
61
|
+
if (!who) {
|
|
62
|
+
return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once. Then run `npm run deploy` again.' };
|
|
63
|
+
}
|
|
64
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
65
|
+
if (!accountId) {
|
|
66
|
+
return { ok: false, command: 'deploy', needs: 'cloudflare-account', why: `This Cloudflare login can reach ${who.accounts.length} accounts; ask the person which one this studio uses and put its id in studio.json (cloudflare.accountId).`, accounts: who.accounts };
|
|
67
|
+
}
|
|
68
|
+
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
69
|
+
// Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
|
|
70
|
+
// instead of refusing its own database as "someone else's" next time.
|
|
71
|
+
const remember = (key) => {
|
|
72
|
+
created.add(key);
|
|
73
|
+
writeStudio(root, { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, created: [...created].sort() } });
|
|
74
|
+
};
|
|
75
|
+
const steps = [];
|
|
76
|
+
const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
|
|
77
|
+
|
|
78
|
+
const b = await build(root, { log });
|
|
79
|
+
if (!b.catalogue.length) return { ok: false, command: 'deploy', why: 'no games built yet: make one with `npx --no-install homie-studio game new <id> --from gem-rush`' };
|
|
80
|
+
step(`built ${b.catalogue.length} game(s)`);
|
|
81
|
+
|
|
82
|
+
// The Worker: never one this studio did not make.
|
|
83
|
+
if (!created.has(`worker:${cf.worker}`)) {
|
|
84
|
+
const exists = w(['versions', 'list', '--name', cf.worker, '--json']);
|
|
85
|
+
if (exists.code === 0) {
|
|
86
|
+
return { ok: false, command: 'deploy', why: `A Worker named "${cf.worker}" already exists on this Cloudflare account and this studio did not create it, so nothing was deployed. Pick another name in studio.json (cloudflare.worker) and site/wrangler.jsonc (name).` };
|
|
87
|
+
}
|
|
88
|
+
if (!/10007|does not exist/i.test(exists.out)) return { ok: false, command: 'deploy', why: `could not check the Worker name: ${exists.out.trim().split('\n').slice(-3).join(' ')}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// D1.
|
|
92
|
+
const list = w(['d1', 'list', '--json']);
|
|
93
|
+
const dbs = parseJson(list.out) ?? [];
|
|
94
|
+
let db = dbs.find((d) => d.name === cf.d1);
|
|
95
|
+
if (db && !created.has(`d1:${cf.d1}`)) {
|
|
96
|
+
return { ok: false, command: 'deploy', why: `A D1 database named "${cf.d1}" already exists on this account and this studio did not create it; nothing was deployed. Pick another name in studio.json (cloudflare.d1).` };
|
|
97
|
+
}
|
|
98
|
+
if (!db) {
|
|
99
|
+
const made = w(['d1', 'create', cf.d1]);
|
|
100
|
+
const id = /"?database_id"?\s*[:=]\s*"([0-9a-f-]{36})"/.exec(made.out)?.[1];
|
|
101
|
+
if (made.code !== 0 || !id) return { ok: false, command: 'deploy', why: `could not create the D1 database "${cf.d1}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
102
|
+
db = { name: cf.d1, uuid: id };
|
|
103
|
+
remember(`d1:${cf.d1}`);
|
|
104
|
+
step(`created D1 ${cf.d1}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// R2, when the account has it (R2 needs a payment method on the account; without it the site runs without MEDIA).
|
|
108
|
+
let r2 = cf.r2 || null;
|
|
109
|
+
if (r2 && !created.has(`r2:${r2}`)) {
|
|
110
|
+
const buckets = w(['r2', 'bucket', 'list']);
|
|
111
|
+
const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
|
|
112
|
+
if (names.includes(r2)) {
|
|
113
|
+
return { ok: false, command: 'deploy', why: `An R2 bucket named "${r2}" already exists on this account and this studio did not create it; nothing was deployed. Pick another name in studio.json (cloudflare.r2).` };
|
|
114
|
+
}
|
|
115
|
+
const made = w(['r2', 'bucket', 'create', r2]);
|
|
116
|
+
if (made.code === 0) { remember(`r2:${r2}`); step(`created R2 ${r2}`); }
|
|
117
|
+
else if (/enable R2|R2 is not enabled|10042|purchase|subscription/i.test(made.out)) { step('R2 is not enabled on this account: the site deploys without media storage for now'); r2 = null; }
|
|
118
|
+
else return { ok: false, command: 'deploy', why: `could not create the R2 bucket "${r2}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
|
|
122
|
+
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
123
|
+
if (migrate.code !== 0) return { ok: false, command: 'deploy', why: `D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
124
|
+
step('D1 migrations applied');
|
|
125
|
+
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
const dep = w(['deploy']);
|
|
128
|
+
if (dep.code !== 0) return { ok: false, command: 'deploy', why: `wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}` };
|
|
129
|
+
remember(`worker:${cf.worker}`);
|
|
130
|
+
const url = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? cf.url ?? null;
|
|
131
|
+
step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url });
|
|
132
|
+
|
|
133
|
+
// The homie.rocks directory claim: the site serves it, which proves this studio controls the site.
|
|
134
|
+
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
135
|
+
let claim = null;
|
|
136
|
+
if (url) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${directory.replace(/\/+$/, '')}/api/studio/claim?site=${encodeURIComponent(url)}`);
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
if (res.ok && /^[a-f0-9]{16,128}$/.test(body.claim ?? '')) claim = body.claim;
|
|
141
|
+
} catch { /* the directory is optional for a live site */ }
|
|
142
|
+
if (claim) {
|
|
143
|
+
const put = w(['d1', 'execute', cf.d1, '--remote', '--command', `INSERT OR REPLACE INTO meta (key, value) VALUES ('homie_claim', '${claim}')`]);
|
|
144
|
+
step(put.code === 0 ? 'directory claim stored' : 'could not store the directory claim (publish will say so)');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const next = { ...cf, accountId, url, d1Id: db.uuid, r2, created: [...created].sort(), deployedAt: new Date().toISOString() };
|
|
149
|
+
writeStudio(root, { ...studio, cloudflare: next });
|
|
150
|
+
return {
|
|
151
|
+
ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, steps,
|
|
152
|
+
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
153
|
+
claim: Boolean(claim), directory,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The homie.rocks directory, from a studio's side. The directory lists games;
|
|
3
|
+
* it never hosts them. A studio proves it controls its site by serving the
|
|
4
|
+
* claim the directory gave it (`homie-studio deploy` stores it in D1), so
|
|
5
|
+
* nobody can list games under someone else's site.
|
|
6
|
+
*/
|
|
7
|
+
import { readStudio } from './studio.mjs';
|
|
8
|
+
|
|
9
|
+
export async function publish(root, { homie, site } = {}) {
|
|
10
|
+
const studio = readStudio(root);
|
|
11
|
+
const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
12
|
+
const url = site || studio.cloudflare?.url;
|
|
13
|
+
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
|
+
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 };
|
|
19
|
+
}
|
package/lib/scaffold.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
|
|
3
|
+
* monorepo: a visible folder the person opens in Claude or Codex.
|
|
4
|
+
*
|
|
5
|
+
* AGENTS.md what this studio is and how to work in it (both apps)
|
|
6
|
+
* CLAUDE.md imports AGENTS.md (Claude Code's documented way to share it)
|
|
7
|
+
* studio.json the studio's name, slug, and its Cloudflare resources
|
|
8
|
+
* package.json @homie-rocks/studio and wrangler, pinned
|
|
9
|
+
* games/<id>/ one folder per game (game.json, index.html, src/)
|
|
10
|
+
* music/ videos/ manifests in the repo; the big files live in the studio's R2
|
|
11
|
+
* posts/ words the studio publishes
|
|
12
|
+
* site/ the studio's Worker (pages, rooms), its D1 migrations
|
|
13
|
+
* .claude/skills/ skills only this studio uses
|
|
14
|
+
*
|
|
15
|
+
* It writes only into a folder that is new or empty, never guesses one, and
|
|
16
|
+
* lists every file it writes, so the person sees exactly what changed.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
22
|
+
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
23
|
+
|
|
24
|
+
export const COMPAT_DATE = '2026-06-01';
|
|
25
|
+
export const WRANGLER_VERSION = '4.126.0';
|
|
26
|
+
|
|
27
|
+
export function slugify(name) {
|
|
28
|
+
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '') || 'studio';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The Worker config, generated so a later deploy can rewrite exactly what it owns. */
|
|
33
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null }) {
|
|
34
|
+
const config = {
|
|
35
|
+
$schema: '../node_modules/wrangler/config-schema.json',
|
|
36
|
+
name: worker,
|
|
37
|
+
main: 'src/worker.mjs',
|
|
38
|
+
compatibility_date: COMPAT_DATE,
|
|
39
|
+
workers_dev: true,
|
|
40
|
+
preview_urls: false,
|
|
41
|
+
assets: { directory: './dist', binding: 'ASSETS', run_worker_first: true },
|
|
42
|
+
durable_objects: { bindings: [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }] },
|
|
43
|
+
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
44
|
+
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: 'migrations' }],
|
|
45
|
+
...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
|
|
46
|
+
vars: { STUDIO_NAME: name },
|
|
47
|
+
observability: { enabled: true },
|
|
48
|
+
};
|
|
49
|
+
return `// This studio's site on its own Cloudflare account (written by homie-studio; \`homie-studio deploy\`
|
|
50
|
+
// fills in the D1 id, and drops the R2 binding when the account has no R2).
|
|
51
|
+
${JSON.stringify(config, null, 2)}
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function agentsMd({ name, slug }) {
|
|
56
|
+
return `# ${name}
|
|
57
|
+
|
|
58
|
+
This folder is a game studio: **${name}** (\`${slug}\`). It is one repository.
|
|
59
|
+
Its games, music, videos and posts live here; its website and public game rooms
|
|
60
|
+
run on the studio's **own Cloudflare account** (a Worker, D1, R2, and the
|
|
61
|
+
Table/Lobby Durable Objects), built from \`@homie-rocks/studio\`, pinned in
|
|
62
|
+
\`package.json\`. The homie.rocks directory lists its games; homie.rocks does
|
|
63
|
+
not host them.
|
|
64
|
+
|
|
65
|
+
## Layout
|
|
66
|
+
|
|
67
|
+
| Path | What it is |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
70
|
+
| \`music/\`, \`videos/\` | \`manifest.json\` lists each file and its R2 key. Large files go to R2 (\`npx --no-install homie-studio media put <file>\`), never into git. |
|
|
71
|
+
| \`posts/\` | Markdown the studio publishes. |
|
|
72
|
+
| \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
|
|
73
|
+
| \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
|
|
74
|
+
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
75
|
+
|
|
76
|
+
## Commands (all through the pinned CLI in node_modules)
|
|
77
|
+
|
|
78
|
+
Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
|
|
79
|
+
studio's pinned copy, never a registry lookup of the bare name.
|
|
80
|
+
|
|
81
|
+
- \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
|
|
82
|
+
multiplayer starter (one live public room from its first build, bots fill seats).
|
|
83
|
+
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
84
|
+
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
85
|
+
Durable Object, D1): open the printed address in two browsers and they share a room.
|
|
86
|
+
- \`npx --no-install homie-studio check <id> --url <site>\` — two headless browsers press Play and must
|
|
87
|
+
land in the same room and finish a round. Run it before you say a game works.
|
|
88
|
+
- \`npm run deploy\` — the site on this studio's Cloudflare (Workers, D1, R2).
|
|
89
|
+
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
90
|
+
their browser. It never overwrites a Worker or database this studio did not create.
|
|
91
|
+
- \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
|
|
92
|
+
(or call the Homie MCP tool \`studio_publish\`).
|
|
93
|
+
|
|
94
|
+
## Making games
|
|
95
|
+
|
|
96
|
+
- Every game is multiplayer on the web through the netplay contract
|
|
97
|
+
(\`node_modules/@homie-rocks/studio/netplay/NETPLAY.md\`): every browser renders the game
|
|
98
|
+
itself, one browser hosts the rules, strangers meet in public rooms, bots fill empty
|
|
99
|
+
seats, anyone arriving takes a bot's place, rounds end and restart on their own.
|
|
100
|
+
- Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
|
|
101
|
+
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
102
|
+
centre of the screen clear during play.
|
|
103
|
+
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
104
|
+
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
105
|
+
|
|
106
|
+
## Rules
|
|
107
|
+
|
|
108
|
+
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
109
|
+
keychain. Never write a key, token or password into this repository.
|
|
110
|
+
- Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
|
|
111
|
+
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function claudeMd() {
|
|
116
|
+
return `@AGENTS.md
|
|
117
|
+
|
|
118
|
+
<!-- Claude Code reads AGENTS.md through the import above (the documented way to share it with Codex).
|
|
119
|
+
Claude-only notes go below this line. -->
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readme({ name }) {
|
|
124
|
+
return `# ${name}
|
|
125
|
+
|
|
126
|
+
A game studio made with [Homie](https://homie.rocks). Open this folder in Claude Code or Codex
|
|
127
|
+
and ask for a game; \`AGENTS.md\` says how everything here works.
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const MIGRATION = `-- A studio's own D1: the directory claim, and every finished round of every public room.
|
|
132
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
133
|
+
key TEXT PRIMARY KEY,
|
|
134
|
+
value TEXT NOT NULL
|
|
135
|
+
);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS rounds (
|
|
137
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
138
|
+
game TEXT NOT NULL,
|
|
139
|
+
room TEXT NOT NULL,
|
|
140
|
+
n INTEGER NOT NULL,
|
|
141
|
+
humans INTEGER NOT NULL,
|
|
142
|
+
bots INTEGER NOT NULL,
|
|
143
|
+
results TEXT NOT NULL,
|
|
144
|
+
at TEXT NOT NULL
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const GITIGNORE = `node_modules/
|
|
150
|
+
site/dist/
|
|
151
|
+
site/.wrangler/
|
|
152
|
+
.wrangler/
|
|
153
|
+
.dev.vars
|
|
154
|
+
.env
|
|
155
|
+
*.log
|
|
156
|
+
# Large media lives in this studio's R2; the manifests beside it are committed.
|
|
157
|
+
music/**/*.wav
|
|
158
|
+
music/**/*.mp3
|
|
159
|
+
music/**/*.flac
|
|
160
|
+
videos/**/*.mp4
|
|
161
|
+
videos/**/*.mov
|
|
162
|
+
videos/**/*.webm
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
/** Every file a new studio gets, relative to its folder. */
|
|
166
|
+
export function studioFiles({ name, slug, homie }) {
|
|
167
|
+
const worker = slug;
|
|
168
|
+
const studio = {
|
|
169
|
+
name, slug,
|
|
170
|
+
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
171
|
+
cloudflare: { worker, d1: `${slug}-db`, r2: `${slug}-media`, accountId: null, url: null, created: [] },
|
|
172
|
+
};
|
|
173
|
+
const pkg = {
|
|
174
|
+
name: `${slug}-studio`,
|
|
175
|
+
private: true,
|
|
176
|
+
type: 'module',
|
|
177
|
+
scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
|
|
178
|
+
devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
'AGENTS.md': agentsMd({ name, slug }),
|
|
182
|
+
'CLAUDE.md': claudeMd(),
|
|
183
|
+
'README.md': readme({ name }),
|
|
184
|
+
'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
|
|
185
|
+
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
186
|
+
'.gitignore': GITIGNORE,
|
|
187
|
+
'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush`.\n',
|
|
188
|
+
'music/README.md': 'Songs and stems. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
|
|
189
|
+
'music/manifest.json': '{ "items": [] }\n',
|
|
190
|
+
'videos/README.md': 'Trailers and clips. `manifest.json` lists them; the files themselves go to R2 with `npx --no-install homie-studio media put`.\n',
|
|
191
|
+
'videos/manifest.json': '{ "items": [] }\n',
|
|
192
|
+
'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
|
|
193
|
+
'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1 and R2.
|
|
194
|
+
// The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
|
|
195
|
+
export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
196
|
+
`,
|
|
197
|
+
'site/migrations/0001_studio.sql': MIGRATION,
|
|
198
|
+
'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
|
|
199
|
+
'.claude/skills/.gitkeep': '',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function insideGit(dir) {
|
|
204
|
+
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function newStudio(folder, { name, homie, slug: askedSlug, install = true } = {}) {
|
|
208
|
+
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.');
|
|
209
|
+
if (!name || !String(name).trim()) throw new Error('give the studio a name: --name "Night Owls". Nothing was written.');
|
|
210
|
+
const dir = resolve(folder.replace(/^~(?=\/|$)/, homedir()));
|
|
211
|
+
const home = realpathSync(homedir());
|
|
212
|
+
let real = null;
|
|
213
|
+
try { real = realpathSync(dir); } catch { /* new */ }
|
|
214
|
+
const at = real ?? dir;
|
|
215
|
+
if (at === home || at === '/' || home.startsWith(`${at}/`)) throw new Error(`${at} is your home folder or above it; a studio needs its own folder. Nothing was written.`);
|
|
216
|
+
if (real) {
|
|
217
|
+
if (!statSync(real).isDirectory()) throw new Error(`${real} is a file. Nothing was written.`);
|
|
218
|
+
const entries = readdirSync(real).filter((n) => n !== '.DS_Store' && n !== '.git');
|
|
219
|
+
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.`);
|
|
220
|
+
}
|
|
221
|
+
const slug = slugify(askedSlug || name);
|
|
222
|
+
const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks' });
|
|
223
|
+
mkdirSync(dir, { recursive: true });
|
|
224
|
+
const wrote = [];
|
|
225
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
226
|
+
const path = join(dir, rel);
|
|
227
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
228
|
+
writeFileSync(path, content);
|
|
229
|
+
wrote.push(rel);
|
|
230
|
+
}
|
|
231
|
+
let git = 'already inside a git repository';
|
|
232
|
+
if (!insideGit(dir)) {
|
|
233
|
+
try { execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); git = 'initialised'; wrote.push('.git/'); } catch { git = 'git not found'; }
|
|
234
|
+
}
|
|
235
|
+
// Install the pinned @homie-rocks/studio and wrangler now, so every later `npx homie-studio` is this studio's own
|
|
236
|
+
// copy and never a registry lookup of the bare name.
|
|
237
|
+
let installed = 'skipped (--no-install): run npm install before any other command';
|
|
238
|
+
if (install) {
|
|
239
|
+
try {
|
|
240
|
+
execFileSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, npm_config_update_notifier: 'false' } });
|
|
241
|
+
installed = 'npm install done';
|
|
242
|
+
wrote.push('node_modules/', 'package-lock.json');
|
|
243
|
+
} catch (error) { installed = `npm install failed (${String(error.stderr ?? error.message).trim().split('\n').slice(-2).join(' ')}): run it in the studio folder`; }
|
|
244
|
+
}
|
|
245
|
+
const q = JSON.stringify(realpathSync(dir));
|
|
246
|
+
return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
|
|
247
|
+
`cd ${q}${install ? '' : ' && npm install'}`,
|
|
248
|
+
'npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
249
|
+
'npm run dev (then: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
250
|
+
'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
|
|
251
|
+
] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const _test = { relative };
|
package/lib/studio.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding and reading a studio: the folder with studio.json at or above the
|
|
3
|
+
* current directory, and its games.
|
|
4
|
+
*/
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11
|
+
const RESERVED_IDS = new Set(['api', 'media', 'games', 'assets', '_homie', 'well-known', 'index', 'play', 'studio', 'studios']);
|
|
12
|
+
|
|
13
|
+
export function findStudio(from = process.cwd()) {
|
|
14
|
+
let at = resolve(from);
|
|
15
|
+
for (;;) {
|
|
16
|
+
if (existsSync(join(at, 'studio.json'))) return at;
|
|
17
|
+
const up = dirname(at);
|
|
18
|
+
if (up === at) return null;
|
|
19
|
+
at = up;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireStudio(from) {
|
|
24
|
+
const root = findStudio(from);
|
|
25
|
+
if (!root) throw new Error('not inside a studio (no studio.json here or above). Make one: npx homie-studio new <folder> --name "<Studio Name>"');
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
|
|
30
|
+
export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
|
|
31
|
+
|
|
32
|
+
export function listGames(root) {
|
|
33
|
+
const dir = join(root, 'games');
|
|
34
|
+
if (!existsSync(dir)) return [];
|
|
35
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
36
|
+
.filter((d) => d.isDirectory() && existsSync(join(dir, d.name, 'game.json')))
|
|
37
|
+
.map((d) => {
|
|
38
|
+
const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
|
|
39
|
+
return { ...meta, id: meta.id ?? d.name, dir: join(dir, d.name) };
|
|
40
|
+
})
|
|
41
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function starters() {
|
|
45
|
+
const dir = join(PACKAGE_ROOT, 'starters');
|
|
46
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
47
|
+
const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
|
|
48
|
+
return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. */
|
|
53
|
+
export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
54
|
+
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
55
|
+
const src = join(PACKAGE_ROOT, 'starters', from);
|
|
56
|
+
if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
|
|
57
|
+
const dest = join(root, 'games', id);
|
|
58
|
+
if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id or change that game`);
|
|
59
|
+
cpSync(src, dest, { recursive: true });
|
|
60
|
+
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
61
|
+
meta.id = id;
|
|
62
|
+
if (name) meta.name = String(name).slice(0, 60);
|
|
63
|
+
meta.from = { starter: from, studio: JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version };
|
|
64
|
+
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
65
|
+
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
66
|
+
if (existsSync(main)) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
67
|
+
const html = join(dest, 'index.html');
|
|
68
|
+
if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
|
|
69
|
+
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `homie-studio game remix <source.json url> --id <new id>`: a shared game brought in as this studio's own. */
|
|
73
|
+
export async function remixGame(root, source, id, { name } = {}) {
|
|
74
|
+
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('give the new game an id: --id <lowercase-id>');
|
|
75
|
+
const dest = join(root, 'games', id);
|
|
76
|
+
if (existsSync(dest)) throw new Error(`games/${id} already exists`);
|
|
77
|
+
let url;
|
|
78
|
+
try { url = new URL(source); } catch { throw new Error('usage: homie-studio game remix <https://studio.site/games/<id>/source.json> --id <new id>'); }
|
|
79
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost'].includes(url.hostname)) throw new Error('a remix source is an https:// address');
|
|
80
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) });
|
|
81
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
82
|
+
const body = await res.json();
|
|
83
|
+
if (body?.kind !== 'homie-game-source' || !body.files || typeof body.files !== 'object') throw new Error('that address is not a shared Homie game source');
|
|
84
|
+
const wrote = [];
|
|
85
|
+
for (const [rel, text] of Object.entries(body.files)) {
|
|
86
|
+
if (typeof text !== 'string' || rel.includes('..') || rel.startsWith('/') || !/^[A-Za-z0-9._/-]+$/.test(rel)) continue;
|
|
87
|
+
const path = join(dest, rel);
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
89
|
+
writeFileSync(path, text);
|
|
90
|
+
wrote.push(rel);
|
|
91
|
+
}
|
|
92
|
+
if (!existsSync(join(dest, 'game.json'))) throw new Error('the shared source has no game.json');
|
|
93
|
+
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
94
|
+
const from = meta.id;
|
|
95
|
+
meta.remixOf = { source: String(url), id: from, name: meta.name };
|
|
96
|
+
meta.id = id;
|
|
97
|
+
if (name) meta.name = String(name).slice(0, 60);
|
|
98
|
+
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
99
|
+
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
100
|
+
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
101
|
+
return { ok: true, command: 'game remix', id, from: String(url), files: wrote };
|
|
102
|
+
}
|
package/lib/version.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place @homie-rocks/studio says which version it is and where that exact
|
|
3
|
+
* version lives. A studio pins this tarball (package.json + package-lock
|
|
4
|
+
* integrity), so a published game never changes because Homie changed.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
export const STUDIO_VERSION = pkg.version;
|
|
10
|
+
|
|
11
|
+
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). */
|
|
12
|
+
export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
|
|
13
|
+
return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** What a studio's package.json names: until @homie-rocks/studio is on the npm registry, its versioned tarball URL. */
|
|
17
|
+
export function packageSpec(base) { return tarballUrl(base); }
|