@homie-rocks/studio 0.9.0 → 0.11.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/lib/check.mjs CHANGED
@@ -16,16 +16,10 @@
16
16
  * the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
17
17
  * each finished round and whether that browser lost its connection, instead of only "no round finished".
18
18
  */
19
- import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
19
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
20
20
  import { cpus, loadavg, tmpdir } from 'node:os';
21
21
  import { join } from 'node:path';
22
-
23
- const CHROMES = [
24
- process.env.CHROME_PATH,
25
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
26
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
27
- '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
28
- ].filter(Boolean);
22
+ import { SOFTWARE_GL, chromeArgs, findChrome, measureFrames, noChrome } from './chrome.mjs';
29
23
 
30
24
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
25
 
@@ -63,8 +57,8 @@ export function judgeRounds(browsers, missed = new Map()) {
63
57
 
64
58
  export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
65
59
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
66
- const chrome = CHROMES.find((p) => existsSync(p));
67
- if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
60
+ const chrome = findChrome();
61
+ if (!chrome) return { ok: false, command: 'check', why: noChrome() };
68
62
  let puppeteer;
69
63
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
70
64
  const base = String(url).replace(/\/+$/, '');
@@ -101,7 +95,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
101
95
  profiles.push(profile);
102
96
  const browser = await puppeteer.launch({
103
97
  executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
104
- args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
98
+ args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
105
99
  });
106
100
  browsers.push(browser);
107
101
  const page = await browser.newPage();
@@ -157,6 +151,12 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
157
151
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
158
152
  await sleep(1500);
159
153
  await picture('Playing: computer and phone in one room');
154
+ // How fast each browser draws the game while it plays (reported, never judged: a VM without a GPU draws
155
+ // with SwiftShader, which is not a person's frame rate).
156
+ const frames = await Promise.all(players.map((p) => measureFrames(p.page)));
157
+ const software = frames.some((f) => f.renderer && SOFTWARE_GL.test(f.renderer));
158
+ const drawn = frames.map((f, i) => `${kinds[i].name} ${f.fps ?? '?'} fps`).join(', ');
159
+ log(`drawing: ${drawn}${frames[0]?.renderer ? ` (${frames[0].renderer})` : ''}`);
160
160
  // Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
161
161
  // counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
162
162
  // in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
@@ -202,7 +202,7 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
202
202
  return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
203
203
  }
204
204
  const humans = hit.people;
205
- step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bots${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}` });
205
+ step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bot${hit.results.length - humans === 1 ? '' : 's'}${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}; ${drawn}${software ? ' (software GL: not measurable here)' : ''}` });
206
206
  await picture(`Round ${hit.n} finished with both; the next one is on`);
207
207
  return {
208
208
  ok: sameRoom && humans >= 2,
@@ -213,6 +213,8 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
213
213
  round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
214
214
  ...(missed.size ? { missed: [...missed.values()] } : {}),
215
215
  connection,
216
+ frames: frames.map((f, i) => ({ browser: kinds[i].name, fps: f.fps, renderer: f.renderer })),
217
+ ...(software ? { software: 'This machine has no GPU (WebGL is SwiftShader): seats, rooms and rounds are measured; the frame rate is not a person\'s.' } : {}),
216
218
  why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
217
219
  totalMs: Date.now() - started,
218
220
  };
package/lib/chrome.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WHICH CHROME THE CHECKS RUN, AND HOW (check, port check, look).
3
+ *
4
+ * On a Mac: that Mac's Chrome, on its GPU (`--use-angle=metal`). A software renderer runs a 3D game at a
5
+ * few frames a second and makes it look stuck, so a Mac never falls back to one.
6
+ *
7
+ * On Linux (a Claude Code cloud session, GitHub Actions, a container) there is usually no GPU and often no
8
+ * Chrome. `homie-studio chrome install` puts Chrome for Testing in this user's cache (from
9
+ * storage.googleapis.com, which a Claude Code cloud session's default "Trusted" network reaches), and the
10
+ * checks render WebGL with SwiftShader (`--enable-unsafe-swiftshader`, needed since Chrome 137), headless,
11
+ * without the setuid sandbox when they run as root (as a container does). What they measure there is seats,
12
+ * rooms and rounds on the room's own clock; a frame rate on SwiftShader is not a person's, so the checks
13
+ * report it with the renderer's name and never judge it.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ /** Where `homie-studio chrome install` keeps Chrome for Testing (one copy per user, shared by every studio). */
20
+ export const CHROME_CACHE = join(homedir(), '.cache', 'homie-studio', 'chrome');
21
+
22
+ /** The newest build of `browser` in a @puppeteer/browsers or Playwright cache, or null. */
23
+ function newestIn(dir, rel) {
24
+ if (!existsSync(dir)) return null;
25
+ const builds = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
26
+ .sort((a, b) => b.localeCompare(a, 'en', { numeric: true }));
27
+ for (const b of builds) {
28
+ for (const r of rel) {
29
+ const path = join(dir, b, ...r);
30
+ if (existsSync(path)) return path;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Chrome for this machine: CHROME_PATH, an installed Chrome, or one in a known cache. */
37
+ export function findChrome() {
38
+ const fixed = [
39
+ process.env.CHROME_PATH,
40
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
41
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
42
+ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/opt/google/chrome/chrome',
43
+ ].filter(Boolean);
44
+ const found = fixed.find((p) => existsSync(p));
45
+ if (found) return found;
46
+ const home = homedir();
47
+ return newestIn(join(CHROME_CACHE, 'chrome'), [['chrome-linux64', 'chrome'], ['chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'], ['chrome-mac-x64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing']])
48
+ ?? newestIn(join(home, '.cache', 'puppeteer', 'chrome'), [['chrome-linux64', 'chrome']])
49
+ ?? newestIn(join(home, '.cache', 'ms-playwright'), [['chrome-linux', 'chrome'], ['chrome-linux64', 'chrome']]);
50
+ }
51
+
52
+ /** Linux without a GPU renders with SwiftShader: say so with every number measured there. */
53
+ export const SOFTWARE_GL = /swiftshader|llvmpipe|softpipe|software/i;
54
+
55
+ /*
56
+ * Chrome's own sandbox needs unprivileged user namespaces. Running as root (a container), or on a Linux that
57
+ * restricts them with AppArmor (Ubuntu 23.10 and later: a system Chrome package brings its own AppArmor profile,
58
+ * Chrome for Testing does not), Chrome refuses to start without --no-sandbox. The checks only open the studio's
59
+ * own site, in throwaway profiles.
60
+ */
61
+ function noSandboxNeeded() {
62
+ if (typeof process.getuid === 'function' && process.getuid() === 0) return true;
63
+ try { return readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1'; } catch { return false; }
64
+ }
65
+
66
+ /** The launch flags for this machine (the GPU on a Mac; SwiftShader, headless and container-safe on Linux). */
67
+ export function chromeArgs() {
68
+ if (process.platform === 'darwin') return ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist'];
69
+ return [
70
+ '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-dev-shm-usage',
71
+ ...(noSandboxNeeded() ? ['--no-sandbox'] : []),
72
+ ];
73
+ }
74
+
75
+ /** Why there is no Chrome, and the one command that fixes it on this machine. */
76
+ export function noChrome() {
77
+ return process.platform === 'linux'
78
+ ? 'no Chrome on this machine: run `npx --no-install homie-studio chrome install` (Chrome for Testing into .cache/homie-studio in the home folder, from storage.googleapis.com), or set CHROME_PATH'
79
+ : 'no Chrome found: install Google Chrome, or set CHROME_PATH';
80
+ }
81
+
82
+ /**
83
+ * `homie-studio chrome install`: Chrome for Testing (stable) into CHROME_CACHE, through @puppeteer/browsers (it comes
84
+ * with puppeteer-core). About 170 MB, once per user; a second run finds it and downloads nothing.
85
+ */
86
+ export async function installChrome({ log = () => {}, fresh = false } = {}) {
87
+ // `fresh`: Chrome for Testing even when the machine has a Chrome (what a machine without one would get).
88
+ const existing = fresh ? null : findChrome();
89
+ if (existing) return { ok: true, command: 'chrome install', chrome: existing, already: true };
90
+ let browsers;
91
+ try { browsers = await import('@puppeteer/browsers'); } catch { return { ok: false, command: 'chrome install', why: '@puppeteer/browsers is missing (it comes with @homie-rocks/studio: npm install)' }; }
92
+ const platform = browsers.detectBrowserPlatform();
93
+ if (!platform) return { ok: false, command: 'chrome install', why: `Chrome for Testing has no build for ${process.platform} ${process.arch}` };
94
+ const buildId = await browsers.resolveBuildId(browsers.Browser.CHROME, platform, 'stable');
95
+ log(`downloading Chrome for Testing ${buildId} (${platform}) into ${CHROME_CACHE}`);
96
+ const got = await browsers.install({ browser: browsers.Browser.CHROME, buildId, cacheDir: CHROME_CACHE });
97
+ return { ok: true, command: 'chrome install', chrome: got.executablePath, buildId, platform };
98
+ }
99
+
100
+ /**
101
+ * The frame rate a page's game frame draws at, over `ms` (requestAnimationFrame in the game's own frame, or the
102
+ * page's), and the renderer WebGL reports there. Never throws: { fps: null } when it cannot tell.
103
+ */
104
+ export async function measureFrames(page, ms = 2000) {
105
+ const frame = page.frames().find((f) => /\/__game\//.test(f.url())) ?? page.mainFrame();
106
+ // Runs inside the frame (puppeteer sends the function itself, so a game's CSP never blocks it).
107
+ const probe = (span) => new Promise((resolve) => {
108
+ let renderer = null;
109
+ try {
110
+ const c = document.createElement('canvas');
111
+ const gl = c.getContext('webgl2') || c.getContext('webgl');
112
+ const d = gl && gl.getExtension('WEBGL_debug_renderer_info');
113
+ renderer = gl ? String(d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
114
+ } catch { renderer = null; }
115
+ let n = 0;
116
+ const t0 = performance.now();
117
+ const tick = () => { n++; if (performance.now() - t0 < span) requestAnimationFrame(tick); else resolve({ fps: Math.round((n * 1000) / (performance.now() - t0)), renderer }); };
118
+ requestAnimationFrame(tick);
119
+ });
120
+ try {
121
+ return await Promise.race([
122
+ frame.evaluate(probe, ms),
123
+ new Promise((r) => setTimeout(() => r({ fps: null, renderer: null }), ms + 5000)),
124
+ ]);
125
+ } catch { return { fps: null, renderer: null }; }
126
+ }
@@ -19,11 +19,11 @@
19
19
  * before anything happens and calls nothing.
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
- import { existsSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { build } from './build.mjs';
25
25
  import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
26
- import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
26
+ import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
27
27
 
28
28
  const ANSI = /\u001b\[[0-9;]*m/g;
29
29
 
@@ -36,7 +36,7 @@ export function wranglerBin(root) {
36
36
  export function runner(root, env) {
37
37
  const bin = wranglerBin(root);
38
38
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
39
- return (args, { cwd = join(root, 'site'), input } = {}) => {
39
+ return (args, { cwd = workerDir(root), input } = {}) => {
40
40
  const res = spawnSync(bin, args, {
41
41
  cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
42
42
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
@@ -129,7 +129,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
129
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 };
130
130
  }
131
131
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
132
- const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
132
+ const refuse = (why, out) => {
133
+ const r = { ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) };
134
+ // What the account asked for (an email to verify…) stays on this computer, for `setup status`.
135
+ if (r.needs) { try { writeLocal(root, { needs: r.needs, needsAt: new Date().toISOString() }); } catch { /* not recorded */ } }
136
+ return r;
137
+ };
133
138
  // Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
134
139
  // instead of refusing its own database as "someone else's" next time.
135
140
  const remember = (key) => {
@@ -186,7 +191,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
186
191
  const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
187
192
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
188
193
 
189
- writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
194
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
190
195
  const added = ensureStatsMigration(root);
191
196
  if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
192
197
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -201,7 +206,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
201
206
  // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
202
207
  if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
203
208
  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() });
209
+ if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString(), needs: null, needsAt: null });
205
210
  const movedOut = isWorkersDev(cf.url);
206
211
  if (movedOut) {
207
212
  cf.url = null;
@@ -211,20 +216,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
211
216
  const url = siteUrl(root);
212
217
  step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
213
218
 
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.
219
+ // The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
220
+ // reading it once now is all it takes; nothing is stored by hand.
216
221
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
217
222
  let claim = null;
218
223
  if (url) {
219
- try {
220
- const res = await fetch(`${directory.replace(/\/+$/, '')}/api/studio/claim?site=${encodeURIComponent(url)}`);
221
- const body = await res.json();
222
- if (res.ok && /^[a-f0-9]{16,128}$/.test(body.claim ?? '')) claim = body.claim;
223
- } catch { /* the directory is optional for a live site */ }
224
- if (claim) {
225
- const put = w(['d1', 'execute', cf.d1, '--remote', '--command', `INSERT OR REPLACE INTO meta (key, value) VALUES ('homie_claim', '${claim}')`]);
226
- step(put.code === 0 ? 'directory claim stored' : 'could not store the directory claim (publish will say so)');
227
- }
224
+ claim = await warmClaim(url);
225
+ if (claim) step('the site claimed itself in the directory');
226
+ else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
228
227
  }
229
228
 
230
229
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
@@ -240,6 +239,81 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
240
239
  };
241
240
  }
242
241
 
242
+ /**
243
+ * Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
244
+ * serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
245
+ * can take a few seconds to answer everywhere, so it asks three times.
246
+ */
247
+ export async function warmClaim(site, { tries = 3, wait = 2500 } = {}) {
248
+ if (process.env.HOMIE_STUDIO_WARM === '0') return null; // the toolkit's own tests: their live site is made up
249
+ for (let i = 0; i < tries; i++) {
250
+ try {
251
+ const res = await fetch(`${String(site).replace(/\/+$/, '')}/.well-known/homie-studio.json`, { headers: { accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(15_000) });
252
+ if (res.ok) {
253
+ const body = await res.json();
254
+ if (/^[a-f0-9]{16,128}$/.test(body?.claim ?? '')) return body.claim;
255
+ }
256
+ } catch { /* not answering yet */ }
257
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
258
+ }
259
+ return null;
260
+ }
261
+
262
+ /** The Worker's name and D1 database, as the studio's wrangler.jsonc says (Cloudflare's forms may have renamed them). */
263
+ export function configNames(root) {
264
+ try {
265
+ const text = readFileSync(configPath(root), 'utf8');
266
+ const json = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ''));
267
+ return { worker: json.name ?? null, d1: json.d1_databases?.[0]?.database_name ?? null, binding: json.d1_databases?.[0]?.binding ?? 'DB' };
268
+ } catch { return { worker: null, d1: null, binding: 'DB' }; }
269
+ }
270
+
271
+ /*
272
+ * `npm run deploy` IN WORKERS BUILDS (WORKERS_CI=1, or `deploy --ci`). Cloudflare's own CI has already run
273
+ * `npm run build`, and holds its own API token for this one account; the Worker and its database were made by the
274
+ * "Deploy to Cloudflare" flow or by an earlier deploy. So this only applies the D1 migrations (by binding name: the
275
+ * form may have renamed the database) and deploys. It creates nothing by hand and refuses nothing, and writes
276
+ * nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
277
+ * creates the one the binding names while it deploys, and the migrations run right after.
278
+ */
279
+ export async function ciDeploy(root, { log = () => {} } = {}) {
280
+ const w = runner(root, {});
281
+ const names = configNames(root);
282
+ const steps = [];
283
+ const step = (what) => { steps.push({ what }); log(what); };
284
+ const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
285
+ if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
286
+ if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
287
+ const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
288
+ let migrate = apply();
289
+ const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
290
+ if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
291
+ if (!first) step('D1 migrations applied');
292
+ const started = Date.now();
293
+ const dep = w(['deploy']);
294
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
295
+ step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
296
+ if (first) {
297
+ migrate = apply();
298
+ if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
299
+ step('D1 migrations applied (the database was created with this first deploy)');
300
+ }
301
+ // The live address, from what Wrangler printed (a custom domain in studio.json wins).
302
+ const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
303
+ let url = null;
304
+ try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
305
+ const claim = url ? await warmClaim(url) : null;
306
+ step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
307
+ let games = [];
308
+ try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
309
+ return {
310
+ ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
311
+ commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
312
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })), songs: [], videos: [],
313
+ claim: Boolean(claim),
314
+ };
315
+ }
316
+
243
317
  /*
244
318
  * `homie-studio storage add` — the studio's own storage for large media (songs,
245
319
  * videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
@@ -283,7 +357,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
283
357
  const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
284
358
  writeStudio(root, next);
285
359
  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 }));
360
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
287
361
  }
288
362
  log(`created R2 ${bucket}`);
289
363
  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>'] };