@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/README.md +120 -8
- package/bin/homie-studio.mjs +128 -13
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +44 -4
- package/lib/check.mjs +14 -12
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +92 -18
- package/lib/codex.mjs +664 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/look.mjs +5 -10
- package/lib/port-check.mjs +5 -11
- package/lib/progress.mjs +122 -6
- package/lib/scaffold.mjs +138 -21
- package/lib/setup.mjs +90 -0
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +47 -0
- package/lib/version.mjs +7 -3
- package/package.json +1 -1
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +75 -4
- package/worker/stats-page.mjs +34 -2
- package/worker/version.mjs +1 -1
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 {
|
|
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 =
|
|
67
|
-
if (!chrome) return { ok: false, command: 'check', why:
|
|
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: [
|
|
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}
|
|
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
|
+
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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 =
|
|
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) =>
|
|
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(
|
|
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
|
|
215
|
-
//
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|
|
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>'] };
|