@homie-rocks/studio 0.9.0 → 0.10.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 +66 -8
- package/bin/homie-studio.mjs +54 -13
- package/lib/build.mjs +36 -1
- package/lib/check.mjs +14 -12
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +5 -10
- package/lib/port-check.mjs +5 -11
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +118 -21
- package/lib/setup.mjs +90 -0
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +23 -0
- package/lib/version.mjs +7 -3
- package/package.json +1 -1
- package/worker/index.mjs +75 -4
- package/worker/version.mjs +1 -1
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,
|
|
@@ -186,7 +186,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
186
186
|
const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
187
187
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
188
188
|
|
|
189
|
-
writeFileSync(
|
|
189
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
190
190
|
const added = ensureStatsMigration(root);
|
|
191
191
|
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
192
192
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -211,20 +211,14 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
211
211
|
const url = siteUrl(root);
|
|
212
212
|
step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
|
|
213
213
|
|
|
214
|
-
// The homie.rocks directory claim
|
|
215
|
-
//
|
|
214
|
+
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
215
|
+
// reading it once now is all it takes; nothing is stored by hand.
|
|
216
216
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
217
217
|
let claim = null;
|
|
218
218
|
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
|
-
}
|
|
219
|
+
claim = await warmClaim(url);
|
|
220
|
+
if (claim) step('the site claimed itself in the directory');
|
|
221
|
+
else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
|
|
228
222
|
}
|
|
229
223
|
|
|
230
224
|
// studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
|
|
@@ -240,6 +234,81 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
240
234
|
};
|
|
241
235
|
}
|
|
242
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Read the live site's manifest once, which makes a 0.10.0 site claim itself in its directory. Returns the claim it
|
|
239
|
+
* serves, or null (the directory did not answer, the site is not reachable yet, or it is a Preview). A fresh deploy
|
|
240
|
+
* can take a few seconds to answer everywhere, so it asks three times.
|
|
241
|
+
*/
|
|
242
|
+
export async function warmClaim(site, { tries = 3, wait = 2500 } = {}) {
|
|
243
|
+
if (process.env.HOMIE_STUDIO_WARM === '0') return null; // the toolkit's own tests: their live site is made up
|
|
244
|
+
for (let i = 0; i < tries; i++) {
|
|
245
|
+
try {
|
|
246
|
+
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) });
|
|
247
|
+
if (res.ok) {
|
|
248
|
+
const body = await res.json();
|
|
249
|
+
if (/^[a-f0-9]{16,128}$/.test(body?.claim ?? '')) return body.claim;
|
|
250
|
+
}
|
|
251
|
+
} catch { /* not answering yet */ }
|
|
252
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, wait));
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The Worker's name and D1 database, as the studio's wrangler.jsonc says (Cloudflare's forms may have renamed them). */
|
|
258
|
+
export function configNames(root) {
|
|
259
|
+
try {
|
|
260
|
+
const text = readFileSync(configPath(root), 'utf8');
|
|
261
|
+
const json = JSON.parse(text.replace(/^\s*\/\/.*$/gm, ''));
|
|
262
|
+
return { worker: json.name ?? null, d1: json.d1_databases?.[0]?.database_name ?? null, binding: json.d1_databases?.[0]?.binding ?? 'DB' };
|
|
263
|
+
} catch { return { worker: null, d1: null, binding: 'DB' }; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/*
|
|
267
|
+
* `npm run deploy` IN WORKERS BUILDS (WORKERS_CI=1, or `deploy --ci`). Cloudflare's own CI has already run
|
|
268
|
+
* `npm run build`, and holds its own API token for this one account; the Worker and its database were made by the
|
|
269
|
+
* "Deploy to Cloudflare" flow or by an earlier deploy. So this only applies the D1 migrations (by binding name: the
|
|
270
|
+
* form may have renamed the database) and deploys. It creates nothing by hand and refuses nothing, and writes
|
|
271
|
+
* nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
|
|
272
|
+
* creates the one the binding names while it deploys, and the migrations run right after.
|
|
273
|
+
*/
|
|
274
|
+
export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
275
|
+
const w = runner(root, {});
|
|
276
|
+
const names = configNames(root);
|
|
277
|
+
const steps = [];
|
|
278
|
+
const step = (what) => { steps.push({ what }); log(what); };
|
|
279
|
+
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
280
|
+
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 };
|
|
281
|
+
if (ensureStatsMigration(root)) step('added the stats migration (the studio\'s own counters)');
|
|
282
|
+
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
283
|
+
let migrate = apply();
|
|
284
|
+
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
285
|
+
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
286
|
+
if (!first) step('D1 migrations applied');
|
|
287
|
+
const started = Date.now();
|
|
288
|
+
const dep = w(['deploy']);
|
|
289
|
+
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
290
|
+
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
291
|
+
if (first) {
|
|
292
|
+
migrate = apply();
|
|
293
|
+
if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
294
|
+
step('D1 migrations applied (the database was created with this first deploy)');
|
|
295
|
+
}
|
|
296
|
+
// The live address, from what Wrangler printed (a custom domain in studio.json wins).
|
|
297
|
+
const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
|
|
298
|
+
let url = null;
|
|
299
|
+
try { url = siteUrl(root) ?? workersDev; } catch { url = workersDev; }
|
|
300
|
+
const claim = url ? await warmClaim(url) : null;
|
|
301
|
+
step(claim ? 'the site claimed itself in the directory' : 'no directory claim yet (the site claims itself when the directory first reads it)');
|
|
302
|
+
let games = [];
|
|
303
|
+
try { games = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { games = []; }
|
|
304
|
+
return {
|
|
305
|
+
ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
|
|
306
|
+
commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
|
|
307
|
+
games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })), songs: [], videos: [],
|
|
308
|
+
claim: Boolean(claim),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
243
312
|
/*
|
|
244
313
|
* `homie-studio storage add` — the studio's own storage for large media (songs,
|
|
245
314
|
* videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
|
|
@@ -283,7 +352,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
283
352
|
const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
|
|
284
353
|
writeStudio(root, next);
|
|
285
354
|
if (next.cloudflare.d1Id) {
|
|
286
|
-
writeFileSync(
|
|
355
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
|
|
287
356
|
}
|
|
288
357
|
log(`created R2 ${bucket}`);
|
|
289
358
|
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>'] };
|
package/lib/look.mjs
CHANGED
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
|
|
8
8
|
* (the studio's stats leave them out).
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
13
14
|
|
|
14
|
-
const CHROMES = [
|
|
15
|
-
process.env.CHROME_PATH,
|
|
16
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
19
|
-
].filter(Boolean);
|
|
20
15
|
|
|
21
16
|
export const LOOK_DEVICES = {
|
|
22
17
|
computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
@@ -29,8 +24,8 @@ const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi,
|
|
|
29
24
|
|
|
30
25
|
export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
|
|
31
26
|
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
32
|
-
const chrome =
|
|
33
|
-
if (!chrome) return { ok: false, command: 'look', why:
|
|
27
|
+
const chrome = findChrome();
|
|
28
|
+
if (!chrome) return { ok: false, command: 'look', why: noChrome() };
|
|
34
29
|
let puppeteer;
|
|
35
30
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
36
31
|
const base = String(url).replace(/\/+$/, '');
|
|
@@ -39,7 +34,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
|
|
|
39
34
|
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
40
35
|
const browser = await puppeteer.launch({
|
|
41
36
|
executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
|
|
42
|
-
args: [
|
|
37
|
+
args: [...chromeArgs(), '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
43
38
|
});
|
|
44
39
|
const rows = [];
|
|
45
40
|
try {
|
package/lib/port-check.mjs
CHANGED
|
@@ -29,20 +29,14 @@
|
|
|
29
29
|
* Exit 0 only when every row passed.
|
|
30
30
|
*/
|
|
31
31
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
import {
|
|
32
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import { createRequire } from 'node:module';
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
|
+
import { chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
36
37
|
|
|
37
|
-
const CHROMES = [
|
|
38
|
-
process.env.CHROME_PATH,
|
|
39
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
40
|
-
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
41
|
-
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
42
|
-
].filter(Boolean);
|
|
43
38
|
const GPU = [
|
|
44
|
-
...(
|
|
45
|
-
'--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required',
|
|
39
|
+
...chromeArgs(), '--autoplay-policy=no-user-gesture-required',
|
|
46
40
|
'--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding',
|
|
47
41
|
'--no-first-run', '--no-default-browser-check',
|
|
48
42
|
];
|
|
@@ -396,8 +390,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
396
390
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
397
391
|
const base = String(url).replace(/\/+$/, '');
|
|
398
392
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
399
|
-
const exe =
|
|
400
|
-
if (!exe) return { ok: false, command: 'port check', why:
|
|
393
|
+
const exe = findChrome();
|
|
394
|
+
if (!exe) return { ok: false, command: 'port check', why: noChrome() };
|
|
401
395
|
const req = createRequire(join(root ?? process.cwd(), 'package.json'));
|
|
402
396
|
let puppeteer;
|
|
403
397
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { try { puppeteer = req('puppeteer-core'); } catch { return { ok: false, command: 'port check', why: 'puppeteer-core is missing (it comes with @homie-rocks/studio: npm install)' }; } }
|
package/lib/progress.mjs
CHANGED
|
@@ -34,12 +34,24 @@
|
|
|
34
34
|
* stop: { requested, at, by },
|
|
35
35
|
* song?: { peaks: [0..1], lyrics: [{ line, sung: true|false|null }] },
|
|
36
36
|
* video?: { shots: [{ id, label, state, image }] },
|
|
37
|
+
* change?: { url, number, title, repo, branch, state: open|merged|closed, files, additions, deletions, preview, mark, at },
|
|
38
|
+
* site?: the studio's live address (for the Claude app's card to tell when a merged change is live),
|
|
37
39
|
* log: [{ at, text }], error, shared?: { build, directory } }
|
|
40
|
+
*
|
|
41
|
+
* A BUILD OPENED IN THE CHAT (`build_open`, the Claude app's "Build it" card): the chat gets the build id first
|
|
42
|
+
* and shows its card; the Claude Code session that does the work attaches to it ONCE with
|
|
43
|
+
* `progress attach <hb_…>`, and the directory hands the write key to that first attach only. No key ever travels
|
|
44
|
+
* in a prompt; a second attach is refused.
|
|
45
|
+
*
|
|
46
|
+
* A CHANGE THAT GOES OUT AS A PULL REQUEST: `progress change "<what it does>"` writes changes/<date>-<mark>.json
|
|
47
|
+
* (committed with the change; the site lists the newest marks in its manifest), and `progress pr --url <pr>` puts
|
|
48
|
+
* the pull request on the card, whose Publish button opens it in GitHub for the person's one-tap merge. When the
|
|
49
|
+
* live site lists the change's mark, Workers Builds has deployed it: the card says Live by itself.
|
|
38
50
|
*/
|
|
39
|
-
import { randomBytes } from 'node:crypto';
|
|
51
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
40
52
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
41
53
|
import { extname, join } from 'node:path';
|
|
42
|
-
import { readStudio } from './studio.mjs';
|
|
54
|
+
import { readStudio, siteUrl } from './studio.mjs';
|
|
43
55
|
|
|
44
56
|
export const PROGRESS_KIND = 'homie-studio-progress';
|
|
45
57
|
export const PROGRESS_DIR = join('.studio', 'progress');
|
|
@@ -72,6 +84,28 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
|
|
|
72
84
|
const currentPath = (root) => join(dir(root), 'current');
|
|
73
85
|
const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
|
|
74
86
|
|
|
87
|
+
/*
|
|
88
|
+
* A CLAUDE CODE CLOUD SESSION ON THE DEFAULT NETWORK. Its "Trusted" network reaches package registries and GitHub,
|
|
89
|
+
* not homie.rocks: the session's proxy answers 403 with `x-deny-reason: host_not_allowed`. Say which setting lets
|
|
90
|
+
* the card follow the build, instead of "did not answer"; the build itself goes on with its local feed.
|
|
91
|
+
*/
|
|
92
|
+
export function networkWhy(res, at) {
|
|
93
|
+
if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
|
|
94
|
+
let host = at;
|
|
95
|
+
try { host = new URL(at).host; } catch { /* as given */ }
|
|
96
|
+
return `this Claude Code cloud environment's network does not reach ${host}. In claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"), and start a new session; until then the build goes on with its local feed only`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
|
|
102
|
+
export function liveSite(url) {
|
|
103
|
+
try { const u = new URL(String(url ?? '')); return u.protocol === 'https:' || (u.protocol === 'http:' && loopback(u.href)) ? u.origin : null; } catch { return null; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A change's mark: what the site lists once the change is live. It names the build without being able to read it. */
|
|
107
|
+
export const changeMark = (build) => createHash('sha256').update(`homie-change\n${build}`).digest('hex').slice(0, 16);
|
|
108
|
+
|
|
75
109
|
function writeAtomic(file, text) {
|
|
76
110
|
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
|
|
77
111
|
writeFileSync(tmp, text);
|
|
@@ -120,6 +154,11 @@ async function pushDoc(root, id) {
|
|
|
120
154
|
body: JSON.stringify({ ...body, build: shared.build }),
|
|
121
155
|
signal: AbortSignal.timeout(8000),
|
|
122
156
|
});
|
|
157
|
+
const blocked = networkWhy(res, shared.directory);
|
|
158
|
+
if (blocked) {
|
|
159
|
+
mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app cannot follow this build: ${blocked}`); });
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
123
162
|
if (res.status === 404 || res.status === 410) {
|
|
124
163
|
// The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
|
|
125
164
|
mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
|
|
@@ -309,6 +348,33 @@ export class Feed {
|
|
|
309
348
|
});
|
|
310
349
|
}
|
|
311
350
|
|
|
351
|
+
/** The pull request this build's change went out as: the card's Publish button opens it for the person's merge. */
|
|
352
|
+
pr({ url, number, title, repo, branch, state = 'open', files, additions, deletions, preview, site } = {}) {
|
|
353
|
+
let u;
|
|
354
|
+
try { u = new URL(String(url ?? '')); } catch { throw new Error('--url is the pull request\'s https://github.com/<owner>/<repo>/pull/<n> address'); }
|
|
355
|
+
const m = /^\/([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})\/pull\/(\d{1,7})\/?$/.exec(u.pathname);
|
|
356
|
+
if (u.protocol !== 'https:' || u.hostname !== 'github.com' || !m) throw new Error('--url is the pull request\'s https://github.com/<owner>/<repo>/pull/<n> address');
|
|
357
|
+
if (!['open', 'merged', 'closed'].includes(state)) throw new Error('a pull request is open, merged or closed');
|
|
358
|
+
const count = (n) => (n === undefined || n === null || n === true ? null : Number.isFinite(Number(n)) && Number(n) >= 0 ? Math.round(Number(n)) : null);
|
|
359
|
+
return this.change((doc) => {
|
|
360
|
+
const before = doc.change ?? {};
|
|
361
|
+
doc.change = {
|
|
362
|
+
url: `https://github.com/${m[1]}/${m[2]}/pull/${m[3]}`, number: Number(m[3]), repo: `${m[1]}/${m[2]}`,
|
|
363
|
+
title: plain(title ?? before.title ?? doc.title, LIMITS.title), branch: branch ? plain(branch, 100) : before.branch ?? null, state,
|
|
364
|
+
files: count(files) ?? before.files ?? null, additions: count(additions) ?? before.additions ?? null, deletions: count(deletions) ?? before.deletions ?? null,
|
|
365
|
+
preview: preview ? String(preview).slice(0, 300) : before.preview ?? null, mark: before.mark ?? null, at: now(),
|
|
366
|
+
};
|
|
367
|
+
// The live address may have been learned after the build was opened (`setup attach`): the card reads it.
|
|
368
|
+
if (liveSite(site)) doc.site = liveSite(site);
|
|
369
|
+
const deploy = doc.stages.find((s) => s.id === 'deploy' || s.id === 'publish');
|
|
370
|
+
if (deploy && state === 'open' && deploy.state === 'pending') { deploy.state = 'running'; deploy.startedAt = now(); deploy.note = 'Waiting for the merge: Publish on the card opens the pull request'; doc.stage = deploy.id; }
|
|
371
|
+
pushLog(doc, `Pull request #${m[3]} ${state}`);
|
|
372
|
+
}, { soon: true });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The studio's live address, so the card can read the site's own manifest for the change once it is merged. */
|
|
376
|
+
site(url) { return this.change((doc) => { doc.site = url ? String(url).slice(0, 200) : null; }); }
|
|
377
|
+
|
|
312
378
|
stopRequested() { return Boolean(this.doc?.stop?.requested); }
|
|
313
379
|
|
|
314
380
|
/**
|
|
@@ -362,7 +428,9 @@ export function publicFeed(doc) {
|
|
|
362
428
|
* Open a feed for one build. `share` asks the studio's directory (studio.json `homie.directory`, or `directory`)
|
|
363
429
|
* to keep a copy for the Claude app's widget; if it cannot, the build goes on with the local feed only.
|
|
364
430
|
*/
|
|
365
|
-
export async function startProgress(root, { what = 'game', id, title, budget, unit, share = false, directory } = {}) {
|
|
431
|
+
export async function startProgress(root, { what = 'game', id, title, budget, unit, share = false, directory, attach = null } = {}) {
|
|
432
|
+
if (attach !== null && !REMOTE_ID.test(String(attach))) return { ok: false, command: 'progress attach', why: 'attach to the build id the Claude app showed: hb_ and 32 hex digits' };
|
|
433
|
+
if (attach) share = true;
|
|
366
434
|
if (!STAGES[what]) return { ok: false, command: 'progress start', why: `what is being made: ${Object.keys(STAGES).join(', ')}` };
|
|
367
435
|
if (id !== undefined && id !== null && !SLUG.test(String(id))) return { ok: false, command: 'progress start', why: 'the id is the game, song or video id (lowercase letters, digits and hyphens)' };
|
|
368
436
|
const studio = readStudio(root);
|
|
@@ -384,6 +452,9 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
|
|
|
384
452
|
log: [{ at: now(), text: `Started: ${plain(title || id || what, 100)}` }],
|
|
385
453
|
error: null,
|
|
386
454
|
};
|
|
455
|
+
let live = null;
|
|
456
|
+
try { live = siteUrl(root, studio); } catch { live = null; }
|
|
457
|
+
if (liveSite(live)) doc.site = liveSite(live);
|
|
387
458
|
mkdirSync(dir(root), { recursive: true });
|
|
388
459
|
writeAtomic(docPath(root, build), `${JSON.stringify(doc, null, 1)}\n`);
|
|
389
460
|
writeAtomic(currentPath(root), `${build}\n`);
|
|
@@ -394,12 +465,16 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
|
|
|
394
465
|
if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
|
|
395
466
|
else {
|
|
396
467
|
try {
|
|
397
|
-
|
|
468
|
+
// A build the chat opened is attached (once); otherwise a new one is opened here.
|
|
469
|
+
const res = await fetch(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
|
|
398
470
|
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
|
|
399
471
|
body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
|
|
400
472
|
});
|
|
401
473
|
const body = await res.json().catch(() => null);
|
|
402
|
-
if (res.ok &&
|
|
474
|
+
if (!res.ok && networkWhy(res, at)) why = networkWhy(res, at);
|
|
475
|
+
else if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
|
|
476
|
+
// The chat named the build: its title is the one the person saw.
|
|
477
|
+
if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
|
|
403
478
|
writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
|
|
404
479
|
try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
|
|
405
480
|
shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
|
|
@@ -410,11 +485,45 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
|
|
|
410
485
|
} catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
|
|
411
486
|
}
|
|
412
487
|
}
|
|
488
|
+
if (attach && !shared) {
|
|
489
|
+
// Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
|
|
490
|
+
rmSync(docPath(root, build), { force: true });
|
|
491
|
+
rmSync(currentPath(root), { force: true });
|
|
492
|
+
return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
|
|
493
|
+
}
|
|
413
494
|
return {
|
|
414
|
-
ok: true, command: 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: doc.title,
|
|
495
|
+
ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
|
|
415
496
|
stages: doc.stages.map((s) => s.id), budget: doc.spend.budget, unit: doc.spend.unit,
|
|
416
497
|
shared: shared ? { build: shared.build, directory: shared.directory, expiresAt: shared.expiresAt } : null,
|
|
417
498
|
...(share && !shared ? { sharedWhy: `not shared: ${why}; the feed is local only` } : {}),
|
|
418
499
|
widget: shared ? `In the Claude app: call the Homie MCP tool build_progress with { "build": "${shared.build}" }` : null,
|
|
419
500
|
};
|
|
420
501
|
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* `progress change "<what it does>"`: the change's mark, as a small file the change commits (changes/<date>-<mark>.json).
|
|
505
|
+
* `homie-studio build` lists the newest marks in the site's manifest; when the live site lists this one, the change
|
|
506
|
+
* is deployed, and the Claude app's card says so. Returns the file, relative to the studio.
|
|
507
|
+
*/
|
|
508
|
+
export function recordChange(root, id, title) {
|
|
509
|
+
const doc = readFeed(root, id);
|
|
510
|
+
if (!doc) throw new Error('no build is open: start or attach one first');
|
|
511
|
+
const mark = changeMark(doc.shared?.build ?? doc.build);
|
|
512
|
+
const folder = join(root, 'changes');
|
|
513
|
+
mkdirSync(folder, { recursive: true });
|
|
514
|
+
if (!existsSync(join(folder, 'README.md'))) writeFileSync(join(folder, 'README.md'), CHANGES_README);
|
|
515
|
+
const rel = join('changes', `${now().slice(0, 10)}-${mark.slice(0, 8)}.json`);
|
|
516
|
+
const text = `${JSON.stringify({ v: 1, change: mark, title: plain(title || doc.title, LIMITS.title), at: now() }, null, 2)}\n`;
|
|
517
|
+
writeFileSync(join(root, rel), text);
|
|
518
|
+
mutateDoc(root, id, (d) => { d.change = { ...(d.change ?? {}), mark }; pushLog(d, `Change recorded: ${plain(title || doc.title, 100)}`); });
|
|
519
|
+
schedule(root, id, { soon: true });
|
|
520
|
+
return { file: rel, mark };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export const CHANGES_README = `# changes/
|
|
524
|
+
|
|
525
|
+
One small file per change this studio made through a pull request, written by \`homie-studio progress change\`:
|
|
526
|
+
what it does, when, and its mark. \`npm run build\` lists the newest marks in the site's
|
|
527
|
+
\`/.well-known/homie-studio.json\`, so the Claude app's card can tell when a merged change is live. Nothing in
|
|
528
|
+
here is secret; old files may be deleted.
|
|
529
|
+
`;
|