@homie-rocks/studio 0.3.0 → 0.6.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.
@@ -5,19 +5,25 @@
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 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
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';
17
23
  import { join } from 'node:path';
18
24
  import { build } from './build.mjs';
19
- import { wranglerConfig } from './scaffold.mjs';
20
- import { readStudio, writeStudio } from './studio.mjs';
25
+ import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
26
+ import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
21
27
 
22
28
  const ANSI = /\u001b\[[0-9;]*m/g;
23
29
 
@@ -26,7 +32,8 @@ export function wranglerBin(root) {
26
32
  return existsSync(local) ? local : null;
27
33
  }
28
34
 
29
- function runner(root, env) {
35
+ /** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
36
+ export function runner(root, env) {
30
37
  const bin = wranglerBin(root);
31
38
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
32
39
  return (args, { cwd = join(root, 'site'), input } = {}) => {
@@ -53,19 +60,76 @@ function parseJson(text) {
53
60
  try { return JSON.parse(text.slice(start)); } catch { return null; }
54
61
  }
55
62
 
63
+ /*
64
+ * WHAT A NEW ACCOUNT TRIPS ON, said as the next step instead of a Wrangler stack.
65
+ * Measured names: 10034 is Cloudflare's "verify your email address" (a new
66
+ * account cannot deploy a Worker until it does); a missing workers.dev
67
+ * subdomain is registered by Wrangler itself when an agent runs it (Claude Code
68
+ * and Codex are detected), and otherwise needs one visit to the onboarding page.
69
+ */
70
+ export function explainCloudflare(out, accountId = null) {
71
+ const text = String(out ?? '');
72
+ const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
73
+ if (/\b10034\b|verify your email/i.test(text)) {
74
+ 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.` };
75
+ }
76
+ if (/register a workers\.dev subdomain|workers\/onboarding/i.test(text)) {
77
+ 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.` };
78
+ }
79
+ if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
80
+ 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.' };
81
+ }
82
+ if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
83
+ 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.' };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
89
+ export function deployPlan(root) {
90
+ const studio = readStudio(root);
91
+ const cf = { created: [], ...studio.cloudflare };
92
+ const created = new Set(cf.created ?? []);
93
+ const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
94
+ const mark = (key) => (created.has(key) ? 'exists (this studio made it)' : 'will be created');
95
+ return {
96
+ ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
97
+ cloudflare: [
98
+ { 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' },
99
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, and the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor)', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
100
+ { 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)' },
101
+ { 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)' },
102
+ storage
103
+ ? { 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)' }
104
+ : { 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 },
105
+ ],
106
+ cost: storage
107
+ ? 'Free on Cloudflare\'s Workers Free plan. R2 storage is free up to 10 GB-month; beyond that Cloudflare bills the account directly.'
108
+ : '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.',
109
+ 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).',
110
+ address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
111
+ directory: {
112
+ site: studio.homie?.directory || 'https://homie.rocks',
113
+ 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.',
114
+ },
115
+ never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
116
+ };
117
+ }
118
+
56
119
  export async function deploy(root, { log = () => {}, homie } = {}) {
57
120
  const studio = readStudio(root);
58
- const cf = { r2: `${studio.slug}-media`, created: [], ...studio.cloudflare };
121
+ const cf = { r2: null, created: [], ...studio.cloudflare };
59
122
  const created = new Set(cf.created ?? []);
60
123
  const who = whoami(root);
61
124
  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.' };
125
+ 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
126
  }
64
127
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
65
128
  if (!accountId) {
66
129
  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
130
  }
68
131
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
132
+ const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
69
133
  // Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
70
134
  // instead of refusing its own database as "someone else's" next time.
71
135
  const remember = (key) => {
@@ -75,9 +139,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
75
139
  const steps = [];
76
140
  const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
77
141
 
142
+ // The first deploy says what it is about to create, and what it costs, before it creates anything: the person
143
+ // reads it in the transcript whether or not the AI repeated it.
144
+ const announced = [];
145
+ if (!created.has(`worker:${cf.worker}`)) {
146
+ const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
147
+ announced.push(
148
+ `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} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
149
+ '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).',
150
+ `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.`,
151
+ );
152
+ for (const line of announced) log(line);
153
+ }
154
+
78
155
  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)`);
156
+ 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)' };
157
+ step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
81
158
 
82
159
  // The Worker: never one this studio did not make.
83
160
  if (!created.has(`worker:${cf.worker}`)) {
@@ -85,7 +162,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
85
162
  if (exists.code === 0) {
86
163
  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
164
  }
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(' ')}` };
165
+ 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
166
  }
90
167
 
91
168
  // D1.
@@ -98,39 +175,44 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
98
175
  if (!db) {
99
176
  const made = w(['d1', 'create', cf.d1]);
100
177
  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(' ')}` };
178
+ 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
179
  db = { name: cf.d1, uuid: id };
103
180
  remember(`d1:${cf.d1}`);
104
181
  step(`created D1 ${cf.d1}`);
105
182
  }
106
183
 
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
- }
184
+ // Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
185
+ // free account with no payment method deploys the whole studio.
186
+ const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
187
+ if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
120
188
 
121
189
  writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
190
+ const added = ensureStatsMigration(root);
191
+ if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
122
192
  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(' ')}` };
193
+ if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
124
194
  step('D1 migrations applied');
125
195
 
126
196
  const started = Date.now();
127
197
  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(' ')}` };
198
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
129
199
  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 });
200
+ // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
201
+ // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
202
+ if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
203
+ const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? readLocal(root).url ?? (isWorkersDev(cf.url) ? cf.url : null);
204
+ if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString() });
205
+ const movedOut = isWorkersDev(cf.url);
206
+ if (movedOut) {
207
+ cf.url = null;
208
+ writeStudio(root, { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, url: null } });
209
+ step(`studio.json no longer keeps the workers.dev address (it names the Cloudflare account); it is in ${LOCAL_STATE} on this computer. An earlier commit of studio.json still has it in the repository's history.`);
210
+ }
211
+ const url = siteUrl(root);
212
+ step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
132
213
 
133
- // The homie.rocks directory claim: the site serves it, which proves this studio controls the site.
214
+ // The homie.rocks directory claim, for the site's public address (its own domain when it has one): the site
215
+ // serves it, which proves this studio controls the site.
134
216
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
135
217
  let claim = null;
136
218
  if (url) {
@@ -145,11 +227,64 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
145
227
  }
146
228
  }
147
229
 
148
- const next = { ...cf, accountId, url, d1Id: db.uuid, r2, created: [...created].sort(), deployedAt: new Date().toISOString() };
149
- writeStudio(root, { ...studio, cloudflare: next });
230
+ // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
231
+ const { url: _url, deployedAt: _at, ...kept } = cf;
232
+ const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
233
+ writeStudio(root, { ...readStudio(root), cloudflare: next });
150
234
  return {
151
- ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, steps,
235
+ ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
152
236
  games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
237
+ songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
238
+ videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
153
239
  claim: Boolean(claim), directory,
154
240
  };
155
241
  }
242
+
243
+ /*
244
+ * `homie-studio storage add` — the studio's own storage for large media (songs,
245
+ * videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
246
+ * /media/<key>. It is separate from deploy on purpose: Cloudflare asks for a
247
+ * payment method on the account before R2 works, even inside R2's free tier
248
+ * (10 GB-month), so this is a step the person agrees to, and a studio that only
249
+ * makes games never needs it. It changes nothing on Cloudflare but the one bucket.
250
+ */
251
+ export async function storageAdd(root, { log = () => {} } = {}) {
252
+ const studio = readStudio(root);
253
+ const cf = { created: [], ...studio.cloudflare };
254
+ const created = new Set(cf.created ?? []);
255
+ const bucket = cf.r2 || `${studio.slug}-media`;
256
+ if (created.has(`r2:${bucket}`)) {
257
+ 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)'] };
258
+ }
259
+ const who = whoami(root);
260
+ 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).' };
261
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
262
+ 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 };
263
+ const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
264
+ const enable = {
265
+ ok: false, command: 'storage add', needs: 'r2-payment-method', bucket,
266
+ 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.`,
267
+ };
268
+ const buckets = w(['r2', 'bucket', 'list']);
269
+ if (buckets.code !== 0) {
270
+ if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(buckets.out)) return enable;
271
+ return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${buckets.out.trim().split('\n').slice(-3).join(' ')}` }) };
272
+ }
273
+ const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
274
+ if (names.includes(bucket)) {
275
+ 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.` };
276
+ }
277
+ const made = w(['r2', 'bucket', 'create', bucket]);
278
+ if (made.code !== 0) {
279
+ if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(made.out)) return enable;
280
+ return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
281
+ }
282
+ created.add(`r2:${bucket}`);
283
+ const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
284
+ writeStudio(root, next);
285
+ if (next.cloudflare.d1Id) {
286
+ writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket }));
287
+ }
288
+ log(`created R2 ${bucket}`);
289
+ 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>'] };
290
+ }
package/lib/directory.mjs CHANGED
@@ -4,12 +4,12 @@
4
4
  * claim the directory gave it (`homie-studio deploy` stores it in D1), so
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
- import { readStudio } from './studio.mjs';
7
+ import { readStudio, siteUrl } from './studio.mjs';
8
8
 
9
9
  export async function publish(root, { homie, site } = {}) {
10
10
  const studio = readStudio(root);
11
11
  const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
12
- const url = site || studio.cloudflare?.url;
12
+ const url = site || siteUrl(root, studio);
13
13
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
14
14
  const res = await fetch(`${directory}/api/studio/publish`, {
15
15
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
package/lib/media.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A studio's music/ and videos/ manifests (media/MEDIA.md): what the site shows,
3
+ * and where each file's bytes come from (R2 key, a file in the repository copied
4
+ * into the site, or an absolute https address).
5
+ */
6
+ import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
8
+
9
+ export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
10
+ export const KINDS = {
11
+ music: ['song', 'score', 'loop', 'stem', 'sfx'],
12
+ videos: ['trailer', 'music-video', 'cutscene', 'clip'],
13
+ };
14
+ /** Cloudflare Workers static assets: one file may be at most 25 MiB. */
15
+ export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
16
+ const TYPES = {
17
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
18
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
19
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.vtt': 'text/vtt', '.json': 'application/json',
20
+ };
21
+ export const typeOf = (path) => TYPES[(/\.[a-z0-9]+$/i.exec(String(path)) ?? [''])[0].toLowerCase()] ?? 'application/octet-stream';
22
+
23
+ export function manifestPath(root, kind) { return join(root, kind, 'manifest.json'); }
24
+
25
+ export function readManifest(root, kind) {
26
+ const file = manifestPath(root, kind);
27
+ if (!existsSync(file)) return { v: 1, items: [] };
28
+ const m = JSON.parse(readFileSync(file, 'utf8'));
29
+ return { v: 1, ...m, items: Array.isArray(m.items) ? m.items : [] };
30
+ }
31
+
32
+ export function writeManifest(root, kind, manifest) {
33
+ mkdirSync(join(root, kind), { recursive: true });
34
+ writeFileSync(manifestPath(root, kind), `${JSON.stringify({ v: 1, ...manifest }, null, 2)}\n`);
35
+ }
36
+
37
+ /** A path inside the studio, or null (no absolute paths, no `..` out of the repository). */
38
+ function inside(root, rel) {
39
+ if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
40
+ const abs = resolve(root, normalize(rel));
41
+ return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
42
+ }
43
+
44
+ /**
45
+ * Every entry of one manifest with its files resolved, and why anything is left out.
46
+ * `r2`: the studio has its own bucket (so a `key` is served at /media/<key>).
47
+ */
48
+ export function resolveMedia(root, kind, { r2 = false } = {}) {
49
+ const manifest = readManifest(root, kind);
50
+ const entries = [];
51
+ const skipped = [];
52
+ const seen = new Set();
53
+ for (const item of manifest.items) {
54
+ if (!item || typeof item !== 'object') continue;
55
+ const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
56
+ if (!SLUG.test(String(item.slug ?? '')) || !item.title) { if (item.published !== false) skipped.push({ kind, item: label, why: 'no slug and title: kept in the manifest, no page' }); continue; }
57
+ if (seen.has(item.slug)) { skipped.push({ kind, item: label, why: 'a second entry with the same slug' }); continue; }
58
+ seen.add(item.slug);
59
+ if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
60
+ const files = [];
61
+ for (const f of Array.isArray(item.files) ? item.files : []) {
62
+ if (!f || f.public === false) continue;
63
+ let src = null;
64
+ if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
65
+ if (!src && f.path) {
66
+ const abs = inside(root, f.path);
67
+ if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
68
+ if (existsSync(abs) && statSync(abs).isFile()) {
69
+ const bytes = statSync(abs).size;
70
+ if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`, abs, rel: relative(root, abs), bytes };
71
+ else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself: make it smaller, or give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and upload it (homie-studio media put ${f.path})` });
72
+ } else if (f.key && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its storage key "${f.key}" needs the studio's storage (homie-studio storage add)` });
73
+ else if (!f.key && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2 key or url' });
74
+ }
75
+ if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
76
+ if (!src) continue;
77
+ files.push({ role: String(f.role ?? 'file'), name: f.name ?? null, type: f.type ?? typeOf(f.path ?? f.key ?? f.url), bytes: f.bytes ?? src.bytes ?? null, bars: f.bars ?? null, url: src.url, from: src.from, ...(src.abs ? { abs: src.abs, rel: src.rel } : {}) });
78
+ }
79
+ const main = kind === 'music' ? 'audio' : 'video';
80
+ if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
81
+ entries.push({
82
+ slug: item.slug, kind: KINDS[kind].includes(item.kind) ? item.kind : KINDS[kind][0], title: String(item.title).slice(0, 120), blurb: String(item.blurb ?? '').slice(0, 600),
83
+ duration: Number.isFinite(item.duration) ? item.duration : null, bpm: Number.isFinite(item.bpm) ? item.bpm : null, key: item.key ?? null,
84
+ lyrics: typeof item.lyrics === 'string' ? item.lyrics.slice(0, 8000) : null, credits: typeof item.credits === 'string' ? item.credits.slice(0, 600) : null,
85
+ rights: item.rights && typeof item.rights === 'object' ? item.rights : null, for: item.for && typeof item.for === 'object' ? item.for : null,
86
+ honesty: typeof item.honesty === 'string' ? item.honesty.slice(0, 600) : null, made: item.made?.at ? { at: item.made.at, provider: item.made.provider ?? null } : null,
87
+ files,
88
+ });
89
+ }
90
+ return { entries, skipped };
91
+ }
92
+
93
+ /** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
94
+ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
95
+ const out = {};
96
+ const skipped = [];
97
+ for (const kind of ['music', 'videos']) {
98
+ const r = resolveMedia(root, kind, { r2 });
99
+ skipped.push(...r.skipped);
100
+ out[kind] = r.entries.map((e) => ({
101
+ ...e,
102
+ files: e.files.map(({ abs, rel, from, ...f }) => {
103
+ if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
104
+ return f;
105
+ }),
106
+ }));
107
+ if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
108
+ }
109
+ for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
110
+ return { songs: out.music, videos: out.videos, skipped };
111
+ }
112
+
113
+ /** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
114
+ export function recordUpload(root, rel, key, bytes) {
115
+ const kind = rel.startsWith('videos/') ? 'videos' : 'music';
116
+ const m = readManifest(root, kind);
117
+ let hit = false;
118
+ for (const item of m.items) {
119
+ for (const f of Array.isArray(item?.files) ? item.files : []) {
120
+ if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
121
+ }
122
+ }
123
+ if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
124
+ writeManifest(root, kind, m);
125
+ return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
126
+ }