@homie-rocks/studio 0.3.0 → 0.5.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 +40 -11
- package/bin/homie-studio.mjs +99 -25
- package/lib/build.mjs +17 -2
- package/lib/cloudflare.mjs +146 -28
- package/lib/media.mjs +126 -0
- package/lib/scaffold.mjs +41 -18
- package/lib/studio.mjs +1 -1
- package/media/MEDIA.md +81 -0
- package/package.json +3 -2
- package/worker/index.mjs +94 -11
- package/worker/pages.mjs +125 -3
package/README.md
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
# @homie-rocks/studio
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
`videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
|
|
5
|
-
browser renders, strangers meet in public rooms, bots fill seats, rounds
|
|
6
|
-
site Worker with Table/Lobby Durable Objects
|
|
7
|
-
own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
|
|
3
|
+
A studio for games, music and video on its own Cloudflare. One repository (`AGENTS.md`,
|
|
4
|
+
`games/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
|
|
5
|
+
contract (every browser renders, strangers meet in public rooms, bots fill seats, rounds
|
|
6
|
+
restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
|
|
7
|
+
to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
|
|
8
8
|
|
|
9
9
|
Most people never run this by hand: the Homie plugin for Claude Code and Codex does,
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.5.0.tgz homie-studio new ./night-owls --name "Night Owls"
|
|
14
14
|
cd night-owls && npm install
|
|
15
15
|
npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
|
|
16
16
|
npx homie-studio dev # the whole site locally
|
|
17
17
|
npx homie-studio check crown-thief --url http://127.0.0.1:8787
|
|
18
|
+
npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
|
|
18
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
19
20
|
npx homie-studio publish # the homie.rocks directory
|
|
20
21
|
```
|
|
21
22
|
|
|
23
|
+
## No payment method needed
|
|
24
|
+
|
|
25
|
+
`deploy` creates only what Cloudflare's free Workers plan gives a new account with no
|
|
26
|
+
payment method: one Worker, one D1 database and two SQLite-backed Durable Objects
|
|
27
|
+
(`Table`, `Lobby`). It never creates or binds R2. A brand-new Cloudflare account verifies
|
|
28
|
+
its email address before it can run a Worker; a missing `workers.dev` subdomain is
|
|
29
|
+
registered by Wrangler when Claude Code or Codex runs the deploy.
|
|
30
|
+
|
|
31
|
+
Storage for large media (songs, videos, big art) is a separate, optional step:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx homie-studio storage add # an R2 bucket, bound as MEDIA, served at /media/<key>
|
|
35
|
+
npx homie-studio deploy
|
|
36
|
+
npx homie-studio media put music/theme.wav
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Cloudflare asks for a payment method on the account before R2 works, even inside R2's free
|
|
40
|
+
tier (10 GB-month of storage, 1 million writes and 10 million reads a month), so `storage add`
|
|
41
|
+
refuses with the dashboard link until the account has R2 turned on, and creates nothing.
|
|
42
|
+
The free plan's daily limits (100,000 Worker requests; D1 5 million rows read and 100,000
|
|
43
|
+
written) reset at 00:00 UTC; past them requests fail until the reset and nothing is charged.
|
|
44
|
+
|
|
22
45
|
An existing single-player web game becomes a multiplayer one with the port toolkit
|
|
23
46
|
(the plugin's `port` skill drives it):
|
|
24
47
|
|
|
@@ -33,19 +56,25 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
33
56
|
| `bin/homie-studio.mjs` | The CLI. |
|
|
34
57
|
| `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
|
|
35
58
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
36
|
-
| `lib/cloudflare.mjs` | `deploy
|
|
59
|
+
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
|
|
37
60
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
|
|
38
61
|
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
39
62
|
| `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. |
|
|
40
63
|
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
41
|
-
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`). |
|
|
64
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). |
|
|
65
|
+
| `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
|
|
42
66
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
43
67
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
|
44
68
|
|
|
45
69
|
Versions are immutable: a published version never changes, so a studio that pinned one
|
|
46
|
-
never changes by surprise. A change ships as a new `version
|
|
47
|
-
|
|
48
|
-
`
|
|
70
|
+
never changes by surprise. A change ships as a new `version`, on the npm registry as
|
|
71
|
+
`@homie-rocks/studio` and as a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`
|
|
72
|
+
(which is what `homie-studio new` pins).
|
|
73
|
+
|
|
74
|
+
## Beta
|
|
75
|
+
|
|
76
|
+
This is a beta. Bugs, port requests and questions go to
|
|
77
|
+
<https://github.com/homie-rocks/homie/issues/new/choose>.
|
|
49
78
|
|
|
50
79
|
## License
|
|
51
80
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* homie-studio — make a
|
|
4
|
-
* studio's own Cloudflare
|
|
3
|
+
* homie-studio — make a studio (games, music, videos, posts), make its games,
|
|
4
|
+
* put its site on the studio's own Cloudflare (free plan, no payment method), and
|
|
5
|
+
* list its games in the homie.rocks directory.
|
|
5
6
|
*
|
|
6
7
|
* homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install]
|
|
7
8
|
* homie-studio starters
|
|
@@ -9,34 +10,37 @@
|
|
|
9
10
|
* homie-studio game remix <source.json url> --id <new id>
|
|
10
11
|
* homie-studio games
|
|
11
12
|
* homie-studio build [<id>]
|
|
12
|
-
* homie-studio dev [--port 8787]
|
|
13
|
+
* homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
|
|
13
14
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
14
15
|
* homie-studio port plan <game folder>
|
|
15
16
|
* homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
|
|
16
17
|
* homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
|
|
17
|
-
* homie-studio deploy
|
|
18
|
+
* homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
|
|
18
19
|
* homie-studio publish
|
|
20
|
+
* homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
|
|
19
21
|
* homie-studio media put <file> [--as <key>]
|
|
22
|
+
* homie-studio media list
|
|
20
23
|
* homie-studio status
|
|
21
24
|
*
|
|
22
25
|
* Every command prints a few lines for a person; --json prints the result.
|
|
23
26
|
*/
|
|
24
|
-
import { spawn } from 'node:child_process';
|
|
25
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { basename, join, relative, resolve } from 'node:path';
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
27
30
|
import { build } from '../lib/build.mjs';
|
|
28
31
|
import { check } from '../lib/check.mjs';
|
|
29
|
-
import { deploy, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
32
|
+
import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
30
33
|
import { publish } from '../lib/directory.mjs';
|
|
31
34
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
32
35
|
import { portCheck } from '../lib/port-check.mjs';
|
|
36
|
+
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
33
37
|
import { newStudio } from '../lib/scaffold.mjs';
|
|
34
|
-
import { listGames, newGame, readStudio, remixGame, requireStudio, starters
|
|
38
|
+
import { listGames, newGame, readStudio, remixGame, requireStudio, starters } from '../lib/studio.mjs';
|
|
35
39
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
36
40
|
|
|
37
41
|
const argv = process.argv.slice(2);
|
|
38
42
|
const flags = new Map();
|
|
39
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install'];
|
|
43
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
|
|
40
44
|
const positional = [];
|
|
41
45
|
for (let i = 0; i < argv.length; i++) {
|
|
42
46
|
const a = argv[i];
|
|
@@ -57,7 +61,7 @@ function print(result) {
|
|
|
57
61
|
const lines = [];
|
|
58
62
|
switch (result.command) {
|
|
59
63
|
case 'new':
|
|
60
|
-
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
64
|
+
lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
|
|
61
65
|
break;
|
|
62
66
|
case 'game remix':
|
|
63
67
|
lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
@@ -65,12 +69,33 @@ function print(result) {
|
|
|
65
69
|
case 'game new':
|
|
66
70
|
lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
|
|
67
71
|
break;
|
|
72
|
+
case 'dev stop':
|
|
73
|
+
lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
|
|
74
|
+
break;
|
|
68
75
|
case 'build':
|
|
69
|
-
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || '
|
|
76
|
+
lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
|
|
77
|
+
if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
|
|
78
|
+
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
79
|
+
break;
|
|
80
|
+
case 'media list':
|
|
81
|
+
for (const kind of ['music', 'videos']) {
|
|
82
|
+
lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
|
|
83
|
+
for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
|
|
84
|
+
for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
85
|
+
}
|
|
70
86
|
break;
|
|
71
87
|
case 'deploy':
|
|
72
|
-
lines.push(`Live: ${result.url}`, ...result.games.map((g) => ` ${g.id}: ${g.play}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}${result.r2 ? `, R2 ${result.r2}` : '
|
|
73
|
-
result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
|
|
88
|
+
lines.push(`Live: ${result.url}`, ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
|
|
89
|
+
result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
|
|
90
|
+
break;
|
|
91
|
+
case 'deploy plan':
|
|
92
|
+
lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
|
|
93
|
+
...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
|
|
94
|
+
`Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
|
|
95
|
+
`The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never);
|
|
96
|
+
break;
|
|
97
|
+
case 'storage add':
|
|
98
|
+
lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
|
|
74
99
|
break;
|
|
75
100
|
case 'publish':
|
|
76
101
|
lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
|
|
@@ -129,6 +154,7 @@ async function main() {
|
|
|
129
154
|
const studio = readStudio(root);
|
|
130
155
|
return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
|
|
131
156
|
}
|
|
157
|
+
if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
|
|
132
158
|
if (cmd === 'dev') return dev(root);
|
|
133
159
|
if (cmd === 'check') {
|
|
134
160
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
@@ -136,17 +162,62 @@ async function main() {
|
|
|
136
162
|
if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
|
|
137
163
|
return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
138
164
|
}
|
|
165
|
+
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
139
166
|
if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
|
|
167
|
+
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
168
|
+
if (cmd === 'storage') {
|
|
169
|
+
const cf = readStudio(root).cloudflare ?? {};
|
|
170
|
+
const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
|
|
171
|
+
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
172
|
+
}
|
|
140
173
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
141
174
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
175
|
+
if (cmd === 'media' && sub === 'list') {
|
|
176
|
+
const studio = readStudio(root);
|
|
177
|
+
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
178
|
+
const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped }; };
|
|
179
|
+
return { ok: true, command: 'media list', r2, site: studio.cloudflare?.url ?? null, music: view('music'), videos: view('videos') };
|
|
180
|
+
}
|
|
142
181
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
143
182
|
}
|
|
144
183
|
|
|
184
|
+
/*
|
|
185
|
+
* WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
|
|
186
|
+
* and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
|
|
187
|
+
* never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
|
|
188
|
+
*/
|
|
189
|
+
const devFile = (root) => join(root, 'site', '.wrangler', 'homie-dev.json');
|
|
190
|
+
function processOfStudio(pid, root, kind) {
|
|
191
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
192
|
+
try { process.kill(pid, 0); } catch { return false; }
|
|
193
|
+
const ps = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
|
|
194
|
+
if (ps.status !== 0) return process.platform === 'win32'; // no ps: the PID file is all there is
|
|
195
|
+
let real = root;
|
|
196
|
+
try { real = realpathSync(root); } catch { /* keep */ }
|
|
197
|
+
// A PID the file names is still ours when it runs from this studio, or is still the kind of process it was
|
|
198
|
+
// (a recycled PID is neither), so nothing else on the machine is ever stopped.
|
|
199
|
+
return ps.stdout.includes(root) || ps.stdout.includes(real) || (kind === 'dev' ? /homie-studio/.test(ps.stdout) : /wrangler|workerd/.test(ps.stdout));
|
|
200
|
+
}
|
|
201
|
+
async function stopDev(root) {
|
|
202
|
+
const file = devFile(root);
|
|
203
|
+
if (!existsSync(file)) return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
|
|
204
|
+
let rec = {};
|
|
205
|
+
try { rec = JSON.parse(readFileSync(file, 'utf8')); } catch { /* a torn file */ }
|
|
206
|
+
const pids = [[rec.child, 'wrangler'], [rec.pid, 'dev']].filter(([p, kind]) => processOfStudio(p, root, kind)).map(([p]) => p);
|
|
207
|
+
for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
|
|
208
|
+
const deadline = Date.now() + 8000;
|
|
209
|
+
const alive = () => pids.filter((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
|
|
210
|
+
while (alive().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
|
211
|
+
for (const pid of alive()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
|
|
212
|
+
rmSync(file, { force: true });
|
|
213
|
+
return { ok: true, command: 'dev stop', stopped: pids };
|
|
214
|
+
}
|
|
215
|
+
|
|
145
216
|
/** The whole site locally: pages, rooms (room.mjs in a local Durable Object), D1. */
|
|
146
217
|
async function dev(root) {
|
|
147
218
|
const port = String(flags.get('port') ?? 8787);
|
|
148
219
|
const b = await build(root, { log });
|
|
149
|
-
if (!b.catalogue.length) return { ok: false, command: 'dev', why: '
|
|
220
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'dev', why: 'nothing to show yet: npx homie-studio game new <id> --from gem-rush, or publish a song or video (media/MEDIA.md)' };
|
|
150
221
|
const bin = wranglerBin(root);
|
|
151
222
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
152
223
|
const studio = readStudio(root);
|
|
@@ -157,30 +228,33 @@ async function dev(root) {
|
|
|
157
228
|
});
|
|
158
229
|
log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
|
|
159
230
|
const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: join(root, 'site'), env, stdio: 'inherit' });
|
|
231
|
+
mkdirSync(dirname(devFile(root)), { recursive: true });
|
|
232
|
+
writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
|
|
233
|
+
log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
|
|
234
|
+
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
235
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
|
|
160
236
|
await new Promise((done) => child.on('close', done));
|
|
237
|
+
rmSync(devFile(root), { force: true });
|
|
161
238
|
return { ok: true, command: 'dev', stopped: true };
|
|
162
239
|
}
|
|
163
240
|
|
|
164
|
-
/** A big file into the studio's R2, and its
|
|
241
|
+
/** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
|
|
165
242
|
function mediaPut(root, file, as) {
|
|
166
243
|
if (!file || !existsSync(file) || !statSync(file).isFile()) return { ok: false, command: 'media put', why: 'usage: homie-studio media put <file> [--as <key>]' };
|
|
167
244
|
const studio = readStudio(root);
|
|
168
245
|
const r2 = studio.cloudflare?.r2;
|
|
169
|
-
if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', why: 'this studio has no R2 bucket
|
|
246
|
+
if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', needs: 'storage', why: 'this studio has no storage yet. `npx --no-install homie-studio storage add` adds an R2 bucket; Cloudflare asks for a payment method on the account before R2 works (10 GB-month free), so ask the person first. Games never need it.' };
|
|
170
247
|
const rel = relative(root, resolve(file));
|
|
171
248
|
const folder = rel.startsWith('videos/') ? 'videos' : 'music';
|
|
172
|
-
|
|
249
|
+
// A file already in music/ or videos/ keeps its repository path as its key (two songs may share a file name).
|
|
250
|
+
const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
|
|
173
251
|
const bin = wranglerBin(root);
|
|
174
252
|
return new Promise((done) => {
|
|
175
|
-
const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
253
|
+
const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
176
254
|
p.on('close', (code) => {
|
|
177
255
|
if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
m.items = [...(m.items ?? []).filter((i) => i.key !== key), { key, file: rel, bytes: statSync(file).size, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null, at: new Date().toISOString() }];
|
|
181
|
-
writeFileSync(manifest, `${JSON.stringify(m, null, 2)}\n`);
|
|
182
|
-
writeStudio(root, studio);
|
|
183
|
-
done({ ok: true, command: 'media put', key, manifest: relative(root, manifest) });
|
|
256
|
+
const rec = recordUpload(root, rel, key, statSync(file).size, studio.cloudflare?.url ?? null);
|
|
257
|
+
done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null });
|
|
184
258
|
});
|
|
185
259
|
});
|
|
186
260
|
}
|
package/lib/build.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
|
|
6
6
|
* site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
|
|
7
7
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
|
-
* site/dist/games.json { studio, games[] } from studio.json
|
|
8
|
+
* site/dist/games.json { studio, games[], songs[], videos[] } from studio.json, game.json
|
|
9
|
+
* files and the music/ and videos/ manifests (media/MEDIA.md)
|
|
10
|
+
* site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
|
|
9
11
|
*
|
|
10
12
|
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
11
13
|
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
@@ -19,6 +21,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
19
21
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
22
|
import { createRequire } from 'node:module';
|
|
21
23
|
import { join, relative } from 'node:path';
|
|
24
|
+
import { buildMedia } from './media.mjs';
|
|
22
25
|
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
23
26
|
import { STUDIO_VERSION } from './version.mjs';
|
|
24
27
|
|
|
@@ -135,6 +138,13 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
135
138
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
|
|
136
139
|
}
|
|
137
140
|
const all = listGames(root);
|
|
141
|
+
// Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
|
|
142
|
+
const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
143
|
+
let media = null;
|
|
144
|
+
if (!only) media = buildMedia(root, dist, { r2, log });
|
|
145
|
+
else {
|
|
146
|
+
try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
|
|
147
|
+
}
|
|
138
148
|
const catalogue = {
|
|
139
149
|
studio: { name: studio.name, slug: studio.slug, version: STUDIO_VERSION },
|
|
140
150
|
games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
|
|
@@ -142,7 +152,12 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
142
152
|
roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
|
|
143
153
|
...(g.screen ? { screen: g.screen } : {}),
|
|
144
154
|
})),
|
|
155
|
+
songs: media.songs,
|
|
156
|
+
videos: media.videos,
|
|
145
157
|
};
|
|
146
158
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|
|
147
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
161
|
+
songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
|
|
162
|
+
};
|
|
148
163
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
* The person's only step is Wrangler's sign-in (`npx wrangler login`, one
|
|
6
6
|
* approval in their browser); Wrangler keeps that login, this never sees a key.
|
|
7
7
|
*
|
|
8
|
-
* It creates what
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* NO CREDIT CARD. It creates only what Cloudflare's free Workers plan gives a
|
|
9
|
+
* brand-new account with no payment method, by the names in studio.json:
|
|
10
|
+
* the Worker (pages + rooms), a D1 database, and the Table/Lobby Durable
|
|
11
|
+
* Objects (SQLite-backed, declared by the Worker).
|
|
12
|
+
* It never creates or binds R2: Cloudflare asks for a payment method before R2
|
|
13
|
+
* works, even inside its free tier, so storage for large media is its own later
|
|
14
|
+
* step (`homie-studio storage add`) that the person agrees to.
|
|
15
|
+
*
|
|
16
|
+
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
12
17
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
13
|
-
* can never overwrite someone's existing site.
|
|
18
|
+
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
19
|
+
* before anything happens and calls nothing.
|
|
14
20
|
*/
|
|
15
21
|
import { spawnSync } from 'node:child_process';
|
|
16
22
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
@@ -53,19 +59,76 @@ function parseJson(text) {
|
|
|
53
59
|
try { return JSON.parse(text.slice(start)); } catch { return null; }
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/*
|
|
63
|
+
* WHAT A NEW ACCOUNT TRIPS ON, said as the next step instead of a Wrangler stack.
|
|
64
|
+
* Measured names: 10034 is Cloudflare's "verify your email address" (a new
|
|
65
|
+
* account cannot deploy a Worker until it does); a missing workers.dev
|
|
66
|
+
* subdomain is registered by Wrangler itself when an agent runs it (Claude Code
|
|
67
|
+
* and Codex are detected), and otherwise needs one visit to the onboarding page.
|
|
68
|
+
*/
|
|
69
|
+
export function explainCloudflare(out, accountId = null) {
|
|
70
|
+
const text = String(out ?? '');
|
|
71
|
+
const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
|
|
72
|
+
if (/\b10034\b|verify your email/i.test(text)) {
|
|
73
|
+
return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it, then run \`npm run deploy\` again. No payment method is needed.` };
|
|
74
|
+
}
|
|
75
|
+
if (/register a workers\.dev subdomain|workers\/onboarding/i.test(text)) {
|
|
76
|
+
return { needs: 'workers-dev-subdomain', why: `This Cloudflare account has no workers.dev address yet. Ask the person to open ${dash}/workers/onboarding once and pick one (it is free), then run \`npm run deploy\` again.` };
|
|
77
|
+
}
|
|
78
|
+
if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
|
|
79
|
+
return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
|
|
80
|
+
}
|
|
81
|
+
if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
|
|
82
|
+
return { needs: 'free-plan-daily-limit', why: 'This account used its free plan\'s 100,000 Worker requests for today (it resets at 00:00 UTC). Nothing is broken; try again after the reset.' };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
|
|
88
|
+
export function deployPlan(root) {
|
|
89
|
+
const studio = readStudio(root);
|
|
90
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
91
|
+
const created = new Set(cf.created ?? []);
|
|
92
|
+
const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
93
|
+
const mark = (key) => (created.has(key) ? 'exists (this studio made it)' : 'will be created');
|
|
94
|
+
return {
|
|
95
|
+
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
96
|
+
cloudflare: [
|
|
97
|
+
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
98
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim and every finished round', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
99
|
+
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
100
|
+
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
101
|
+
storage
|
|
102
|
+
? { kind: 'R2 bucket', name: storage, what: 'large media for /media/<key> (added with storage add)', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
103
|
+
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
104
|
+
],
|
|
105
|
+
cost: storage
|
|
106
|
+
? 'Free on Cloudflare\'s Workers Free plan. R2 storage is free up to 10 GB-month; beyond that Cloudflare bills the account directly.'
|
|
107
|
+
: 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
|
|
108
|
+
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
109
|
+
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
110
|
+
directory: {
|
|
111
|
+
site: studio.homie?.directory || 'https://homie.rocks',
|
|
112
|
+
stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
|
|
113
|
+
},
|
|
114
|
+
never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
56
118
|
export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
57
119
|
const studio = readStudio(root);
|
|
58
|
-
const cf = { r2:
|
|
120
|
+
const cf = { r2: null, created: [], ...studio.cloudflare };
|
|
59
121
|
const created = new Set(cf.created ?? []);
|
|
60
122
|
const who = whoami(root);
|
|
61
123
|
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.' };
|
|
124
|
+
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 (a free account works, no payment method). Then run `npm run deploy` again.' };
|
|
63
125
|
}
|
|
64
126
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
65
127
|
if (!accountId) {
|
|
66
128
|
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
129
|
}
|
|
68
130
|
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
131
|
+
const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
|
|
69
132
|
// Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
|
|
70
133
|
// instead of refusing its own database as "someone else's" next time.
|
|
71
134
|
const remember = (key) => {
|
|
@@ -75,9 +138,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
75
138
|
const steps = [];
|
|
76
139
|
const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
|
|
77
140
|
|
|
141
|
+
// The first deploy says what it is about to create, and what it costs, before it creates anything: the person
|
|
142
|
+
// reads it in the transcript whether or not the AI repeated it.
|
|
143
|
+
const announced = [];
|
|
144
|
+
if (!created.has(`worker:${cf.worker}`)) {
|
|
145
|
+
const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
|
|
146
|
+
announced.push(
|
|
147
|
+
`First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1}, and the Durable Objects Table and Lobby (SQLite-backed).`,
|
|
148
|
+
'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
|
|
149
|
+
`The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
|
|
150
|
+
);
|
|
151
|
+
for (const line of announced) log(line);
|
|
152
|
+
}
|
|
153
|
+
|
|
78
154
|
const b = await build(root, { log });
|
|
79
|
-
if (!b.catalogue.length) return { ok: false, command: 'deploy', why: '
|
|
80
|
-
step(`built ${b.catalogue.length} game(s)`);
|
|
155
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'deploy', why: 'nothing to put online yet: make a game (`npx --no-install homie-studio game new <id> --from gem-rush`) or publish a song or video in music/ or videos/ (media/MEDIA.md)' };
|
|
156
|
+
step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
|
|
81
157
|
|
|
82
158
|
// The Worker: never one this studio did not make.
|
|
83
159
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -85,7 +161,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
85
161
|
if (exists.code === 0) {
|
|
86
162
|
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
163
|
}
|
|
88
|
-
if (!/10007|does not exist/i.test(exists.out)) return
|
|
164
|
+
if (!/10007|does not exist/i.test(exists.out)) return refuse(`could not check the Worker name: ${exists.out.trim().split('\n').slice(-3).join(' ')}`, exists.out);
|
|
89
165
|
}
|
|
90
166
|
|
|
91
167
|
// D1.
|
|
@@ -98,34 +174,25 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
98
174
|
if (!db) {
|
|
99
175
|
const made = w(['d1', 'create', cf.d1]);
|
|
100
176
|
const id = /"?database_id"?\s*[:=]\s*"([0-9a-f-]{36})"/.exec(made.out)?.[1];
|
|
101
|
-
if (made.code !== 0 || !id) return
|
|
177
|
+
if (made.code !== 0 || !id) return refuse(`could not create the D1 database "${cf.d1}": ${made.out.trim().split('\n').slice(-4).join(' ')}`, made.out);
|
|
102
178
|
db = { name: cf.d1, uuid: id };
|
|
103
179
|
remember(`d1:${cf.d1}`);
|
|
104
180
|
step(`created D1 ${cf.d1}`);
|
|
105
181
|
}
|
|
106
182
|
|
|
107
|
-
// R2
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
}
|
|
183
|
+
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
184
|
+
// free account with no payment method deploys the whole studio.
|
|
185
|
+
const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
186
|
+
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
120
187
|
|
|
121
188
|
writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
|
|
122
189
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
123
|
-
if (migrate.code !== 0) return
|
|
190
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
124
191
|
step('D1 migrations applied');
|
|
125
192
|
|
|
126
193
|
const started = Date.now();
|
|
127
194
|
const dep = w(['deploy']);
|
|
128
|
-
if (dep.code !== 0) return
|
|
195
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
129
196
|
remember(`worker:${cf.worker}`);
|
|
130
197
|
const url = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? cf.url ?? null;
|
|
131
198
|
step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url });
|
|
@@ -145,11 +212,62 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
145
212
|
}
|
|
146
213
|
}
|
|
147
214
|
|
|
148
|
-
const next = { ...cf, accountId, url, d1Id: db.uuid, r2, created: [...created].sort(), deployedAt: new Date().toISOString() };
|
|
215
|
+
const next = { ...cf, accountId, url, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort(), deployedAt: new Date().toISOString() };
|
|
149
216
|
writeStudio(root, { ...studio, cloudflare: next });
|
|
150
217
|
return {
|
|
151
|
-
ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, steps,
|
|
218
|
+
ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
152
219
|
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
220
|
+
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
221
|
+
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
153
222
|
claim: Boolean(claim), directory,
|
|
154
223
|
};
|
|
155
224
|
}
|
|
225
|
+
|
|
226
|
+
/*
|
|
227
|
+
* `homie-studio storage add` — the studio's own storage for large media (songs,
|
|
228
|
+
* videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
|
|
229
|
+
* /media/<key>. It is separate from deploy on purpose: Cloudflare asks for a
|
|
230
|
+
* payment method on the account before R2 works, even inside R2's free tier
|
|
231
|
+
* (10 GB-month), so this is a step the person agrees to, and a studio that only
|
|
232
|
+
* makes games never needs it. It changes nothing on Cloudflare but the one bucket.
|
|
233
|
+
*/
|
|
234
|
+
export async function storageAdd(root, { log = () => {} } = {}) {
|
|
235
|
+
const studio = readStudio(root);
|
|
236
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
237
|
+
const created = new Set(cf.created ?? []);
|
|
238
|
+
const bucket = cf.r2 || `${studio.slug}-media`;
|
|
239
|
+
if (created.has(`r2:${bucket}`)) {
|
|
240
|
+
return { ok: true, command: 'storage add', bucket, already: true, next: ['npm run deploy (binds it as MEDIA if the live site does not have it yet)'] };
|
|
241
|
+
}
|
|
242
|
+
const who = whoami(root);
|
|
243
|
+
if (!who) return { ok: false, command: 'storage add', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` first (one approval in the browser).' };
|
|
244
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
|
|
245
|
+
if (!accountId) return { ok: false, command: 'storage add', needs: 'cloudflare-account', why: 'This login reaches several Cloudflare accounts; put the studio\'s in studio.json (cloudflare.accountId) first.', accounts: who.accounts };
|
|
246
|
+
const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
|
|
247
|
+
const enable = {
|
|
248
|
+
ok: false, command: 'storage add', needs: 'r2-payment-method', bucket,
|
|
249
|
+
why: `R2 is not turned on for this Cloudflare account. Cloudflare asks for a payment method before R2 works, even inside its free tier (10 GB-month of storage, 1 million writes and 10 million reads a month free). Only the person can decide that: if they want storage, they open https://dash.cloudflare.com/${accountId}/r2/overview, add R2 there, and you run \`npx --no-install homie-studio storage add\` again. Nothing was created; the studio keeps working without storage.`,
|
|
250
|
+
};
|
|
251
|
+
const buckets = w(['r2', 'bucket', 'list']);
|
|
252
|
+
if (buckets.code !== 0) {
|
|
253
|
+
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(buckets.out)) return enable;
|
|
254
|
+
return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${buckets.out.trim().split('\n').slice(-3).join(' ')}` }) };
|
|
255
|
+
}
|
|
256
|
+
const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
|
|
257
|
+
if (names.includes(bucket)) {
|
|
258
|
+
return { ok: false, command: 'storage add', why: `An R2 bucket named "${bucket}" already exists on this account and this studio did not create it; nothing was created. Put another name in studio.json (cloudflare.r2) and run storage add again.` };
|
|
259
|
+
}
|
|
260
|
+
const made = w(['r2', 'bucket', 'create', bucket]);
|
|
261
|
+
if (made.code !== 0) {
|
|
262
|
+
if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(made.out)) return enable;
|
|
263
|
+
return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
|
|
264
|
+
}
|
|
265
|
+
created.add(`r2:${bucket}`);
|
|
266
|
+
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
267
|
+
writeStudio(root, next);
|
|
268
|
+
if (next.cloudflare.d1Id) {
|
|
269
|
+
writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket }));
|
|
270
|
+
}
|
|
271
|
+
log(`created R2 ${bucket}`);
|
|
272
|
+
return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
|
|
273
|
+
}
|