@homie-rocks/studio 0.8.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 +90 -8
- package/bin/homie-studio.mjs +90 -13
- package/lib/build.mjs +38 -1
- package/lib/check.mjs +109 -48
- package/lib/chrome.mjs +126 -0
- package/lib/cloudflare.mjs +85 -16
- package/lib/look.mjs +8 -12
- package/lib/media.mjs +8 -1
- package/lib/port-check.mjs +8 -13
- package/lib/progress.mjs +115 -6
- package/lib/scaffold.mjs +131 -22
- package/lib/setup.mjs +90 -0
- package/lib/site.mjs +8 -1
- package/lib/studio.mjs +11 -0
- package/lib/template-history.json +180 -0
- package/lib/upgrade.mjs +285 -0
- package/lib/version.mjs +7 -3
- package/media/MEDIA.md +13 -0
- package/package.json +1 -1
- package/site/SITE.md +51 -6
- package/worker/index.mjs +125 -25
- package/worker/pages.mjs +67 -11
- package/worker/site.mjs +64 -19
- package/worker/stats.mjs +17 -0
- package/worker/version.mjs +1 -1
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
|
];
|
|
@@ -197,7 +191,8 @@ function descendants(pid) {
|
|
|
197
191
|
async function chrome(puppeteer, kind, exe) {
|
|
198
192
|
const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
|
|
199
193
|
const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
|
|
200
|
-
|
|
194
|
+
// 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
|
|
195
|
+
const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, timeout: 150_000, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
|
|
201
196
|
const page = (await browser.pages())[0] ?? await browser.newPage();
|
|
202
197
|
if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
|
|
203
198
|
else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
|
|
@@ -215,7 +210,7 @@ async function chrome(puppeteer, kind, exe) {
|
|
|
215
210
|
}
|
|
216
211
|
|
|
217
212
|
async function webkit(pw) {
|
|
218
|
-
const browser = await pw.webkit.launch({ headless: true });
|
|
213
|
+
const browser = await pw.webkit.launch({ headless: true, timeout: 150_000 });
|
|
219
214
|
const dev = pw.devices['iPhone 15'];
|
|
220
215
|
const ctx = await browser.newContext({ ...dev, userAgent: `${dev.userAgent} ${TAG}` });
|
|
221
216
|
const page = await ctx.newPage();
|
|
@@ -395,8 +390,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
395
390
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
396
391
|
const base = String(url).replace(/\/+$/, '');
|
|
397
392
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
398
|
-
const exe =
|
|
399
|
-
if (!exe) return { ok: false, command: 'port check', why:
|
|
393
|
+
const exe = findChrome();
|
|
394
|
+
if (!exe) return { ok: false, command: 'port check', why: noChrome() };
|
|
400
395
|
const req = createRequire(join(root ?? process.cwd(), 'package.json'));
|
|
401
396
|
let puppeteer;
|
|
402
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
|
+
`;
|
package/lib/scaffold.mjs
CHANGED
|
@@ -25,40 +25,66 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
25
25
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
26
26
|
import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
27
27
|
import { themeFile } from './site.mjs';
|
|
28
|
+
import { PACKAGE_ROOT } from './studio.mjs';
|
|
28
29
|
|
|
29
30
|
export const COMPAT_DATE = '2026-06-01';
|
|
30
|
-
|
|
31
|
+
/** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
|
|
32
|
+
export const WRANGLER_VERSION = '4.145.0';
|
|
31
33
|
|
|
32
34
|
export function slugify(name) {
|
|
33
35
|
return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
34
36
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/g, '') || 'studio';
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
/*
|
|
40
|
+
* The Worker config, generated so a later deploy can rewrite exactly what it owns.
|
|
41
|
+
*
|
|
42
|
+
* `layout: 'root'` (every studio from 0.10.0) keeps it at the studio's root, where Cloudflare's Workers Builds and
|
|
43
|
+
* the "Deploy to Cloudflare" button look for it; `'site'` is the older site/wrangler.jsonc.
|
|
44
|
+
*
|
|
45
|
+
* PREVIEWS (Cloudflare Worker Previews, Wrangler 4.135.0+): a branch deploys with `npx wrangler preview` (Workers
|
|
46
|
+
* Builds does it on every push that is not to the production branch) to its own URL, and Cloudflare gives every
|
|
47
|
+
* Preview its OWN Durable Object namespace, so a Preview's public rooms never meet production's. The `previews`
|
|
48
|
+
* block binds the same Table and Lobby classes and names the Preview (HOMIE_PREVIEW). It binds no D1 on purpose:
|
|
49
|
+
* a Preview counts nothing into the studio's stats, records no rounds and never claims itself in the directory;
|
|
50
|
+
* its pages and rooms work without it.
|
|
51
|
+
*
|
|
52
|
+
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
53
|
+
* the binding names and keep it linked.
|
|
54
|
+
*/
|
|
55
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root' }) {
|
|
56
|
+
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
57
|
+
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
58
|
+
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
39
59
|
const config = {
|
|
40
|
-
$schema:
|
|
60
|
+
$schema: `${at.schema}/wrangler/config-schema.json`,
|
|
41
61
|
name: worker,
|
|
42
|
-
main:
|
|
62
|
+
main: at.main,
|
|
43
63
|
compatibility_date: COMPAT_DATE,
|
|
64
|
+
// The site's own fetches (its directory claim, "played this week") go out as any browser's would, so a studio
|
|
65
|
+
// whose directory is on the same zone (a house studio on *.homie.rocks, or two Workers on one workers.dev
|
|
66
|
+
// subdomain) reaches it instead of failing with 1042.
|
|
67
|
+
compatibility_flags: ['global_fetch_strictly_public'],
|
|
44
68
|
workers_dev: true,
|
|
45
|
-
preview_urls:
|
|
46
|
-
assets: { directory:
|
|
47
|
-
durable_objects: { bindings:
|
|
69
|
+
preview_urls: true,
|
|
70
|
+
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
71
|
+
durable_objects: { bindings: rooms },
|
|
48
72
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
49
|
-
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir:
|
|
73
|
+
d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
|
|
50
74
|
...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
|
|
51
75
|
vars: { STUDIO_NAME: name },
|
|
52
76
|
observability: { enabled: true },
|
|
77
|
+
previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
|
|
53
78
|
};
|
|
54
|
-
return `// This studio's site on its own Cloudflare account (written by homie-studio
|
|
55
|
-
//
|
|
79
|
+
return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
|
|
80
|
+
// Cloudflare's Workers Free plan. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
|
|
81
|
+
// \`npx wrangler preview\` puts a branch on its own Preview URL with its own rooms. There is no R2 binding
|
|
56
82
|
// until \`homie-studio storage add\` gives the studio storage for large media.
|
|
57
83
|
${JSON.stringify(config, null, 2)}
|
|
58
84
|
`;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
function agentsMd({ name, slug }) {
|
|
87
|
+
export function agentsMd({ name, slug }) {
|
|
62
88
|
return `# ${name}
|
|
63
89
|
|
|
64
90
|
This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
|
|
@@ -75,7 +101,9 @@ directory lists its games; homie.rocks does not host them.
|
|
|
75
101
|
| \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
|
|
76
102
|
| \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
|
|
77
103
|
| \`posts/\` | The studio's news and drops: one markdown file each (\`posts/2026-09-30-we-are-live.md\`: \`title:\`, \`date:\`, \`summary:\`, and \`game:\` / \`song:\` / \`video:\` to link one). They are the site's Posts, with Atom and JSON feeds. |
|
|
78
|
-
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`)
|
|
104
|
+
| \`site/\` | The studio's site: its look (\`theme.json\`), and anything of its own that wins over the generated pages (\`site/README.md\`); the Worker (\`src/worker.mjs\`) and its D1 migrations. |
|
|
105
|
+
| \`wrangler.jsonc\` | The Worker's Cloudflare config (the Worker, D1, the Table and Lobby Durable Objects, and \`previews\` for branch Previews), at the root, where Cloudflare's Workers Builds reads it. A studio made before 0.10.0 keeps it in \`site/\` and deploys from a computer; every command finds either. |
|
|
106
|
+
| \`changes/\` | One small file per change that went out through a pull request (\`homie-studio progress pr\` writes it): the site lists the newest, so the Claude app can tell when a merged change is live. |
|
|
79
107
|
| \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
|
|
80
108
|
| \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
|
|
81
109
|
|
|
@@ -103,6 +131,11 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
103
131
|
SQLite-backed Durable Objects, all on the free Workers plan (no payment method needed).
|
|
104
132
|
If Wrangler is not signed in, run \`npx wrangler login\`: the person approves once in
|
|
105
133
|
their browser. It never overwrites a Worker or database this studio did not create.
|
|
134
|
+
- **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
|
|
135
|
+
every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
|
|
136
|
+
the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
|
|
137
|
+
\`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
|
|
138
|
+
in the homie.rocks directory the first time it is read, so nothing is stored by hand.
|
|
106
139
|
- \`npx --no-install homie-studio storage add\` — only when the studio needs large media (songs,
|
|
107
140
|
videos): an R2 bucket for \`media put\`, served at \`/media/<key>\`. Cloudflare asks for a
|
|
108
141
|
payment method on the account before R2 works (its first 10 GB a month are free), so this
|
|
@@ -116,6 +149,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
116
149
|
\`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
|
|
117
150
|
(played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
|
|
118
151
|
nothing sent anywhere; house QA and \`check\` runs are not counted.
|
|
152
|
+
- \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
|
|
153
|
+
\`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
|
|
154
|
+
sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
|
|
155
|
+
touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
|
|
119
156
|
|
|
120
157
|
## The site
|
|
121
158
|
|
|
@@ -132,7 +169,11 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
132
169
|
(\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
|
|
133
170
|
- **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
|
|
134
171
|
page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
|
|
135
|
-
\`site/theme.css\` (\`site/README.md\`).
|
|
172
|
+
\`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
|
|
173
|
+
game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
|
|
174
|
+
- **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
|
|
175
|
+
a cover of its own shows the music manifest's \`cover\`, else its game's still.
|
|
176
|
+
- **Live rooms** are listed on homie.rocks too; studio.json \`"rooms": { "share": false }\` keeps them off it.
|
|
136
177
|
- Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
|
|
137
178
|
|
|
138
179
|
## Making games
|
|
@@ -144,6 +185,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
144
185
|
- Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
|
|
145
186
|
- Phones and computers: touch controls on phones only, keys on computers; keep the
|
|
146
187
|
centre of the screen clear during play.
|
|
188
|
+
- The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
|
|
189
|
+
scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
|
|
190
|
+
device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
|
|
191
|
+
a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
|
|
147
192
|
- Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
|
|
148
193
|
- A game's id is its URL (\`/<id>/\`); keep it once published.
|
|
149
194
|
|
|
@@ -174,7 +219,7 @@ Claude-only notes go below this line. -->
|
|
|
174
219
|
`;
|
|
175
220
|
}
|
|
176
221
|
|
|
177
|
-
function readme({ name }) {
|
|
222
|
+
export function readme({ name }) {
|
|
178
223
|
return `# ${name}
|
|
179
224
|
|
|
180
225
|
A game studio made with [Homie](https://homie.rocks). Open this folder in Claude Code or Codex
|
|
@@ -237,7 +282,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
|
|
|
237
282
|
| \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`. |
|
|
238
283
|
| \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
|
|
239
284
|
|
|
240
|
-
\`src/worker.mjs
|
|
285
|
+
\`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
|
|
241
286
|
`;
|
|
242
287
|
|
|
243
288
|
const GITIGNORE = `node_modules/
|
|
@@ -266,10 +311,13 @@ videos/**/*.webm
|
|
|
266
311
|
`;
|
|
267
312
|
|
|
268
313
|
/** Every file a new studio gets, relative to its folder. */
|
|
269
|
-
export function studioFiles({ name, slug, homie }) {
|
|
314
|
+
export function studioFiles({ name, slug, homie, template = false }) {
|
|
270
315
|
const worker = slug;
|
|
271
316
|
const studio = {
|
|
272
317
|
name, slug,
|
|
318
|
+
// A copy of the public template (Deploy to Cloudflare) until `homie-studio setup attach` names it for real:
|
|
319
|
+
// meanwhile its site shows the name the person typed in Cloudflare's form (STUDIO_NAME).
|
|
320
|
+
...(template ? { template: true } : {}),
|
|
273
321
|
homie: { studio: STUDIO_VERSION, directory: homie },
|
|
274
322
|
// r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
|
|
275
323
|
// `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
|
|
@@ -285,11 +333,20 @@ export function studioFiles({ name, slug, homie }) {
|
|
|
285
333
|
type: 'module',
|
|
286
334
|
scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
|
|
287
335
|
devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
|
|
336
|
+
// What Cloudflare's "Deploy to Cloudflare" form says about each setting (package.json `cloudflare.bindings`).
|
|
337
|
+
cloudflare: {
|
|
338
|
+
label: 'Homie studio',
|
|
339
|
+
products: ['Workers', 'D1', 'Durable Objects'],
|
|
340
|
+
bindings: {
|
|
341
|
+
STUDIO_NAME: { description: 'Your studio\'s name, as its site shows it (for example **Night Owls**). Claude can change it later.' },
|
|
342
|
+
DB: { description: 'The studio\'s own database: finished rounds, the studio\'s own stats (counts, never a visitor) and its claim in the [homie.rocks](https://homie.rocks/studios/) directory. Free plan.' },
|
|
343
|
+
},
|
|
344
|
+
},
|
|
288
345
|
};
|
|
289
|
-
|
|
346
|
+
const files = {
|
|
290
347
|
'AGENTS.md': agentsMd({ name, slug }),
|
|
291
348
|
'CLAUDE.md': claudeMd(),
|
|
292
|
-
'README.md': readme({ name }),
|
|
349
|
+
'README.md': template ? templateReadme() : readme({ name }),
|
|
293
350
|
'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
|
|
294
351
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
295
352
|
'.gitignore': GITIGNORE,
|
|
@@ -307,9 +364,61 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
307
364
|
`,
|
|
308
365
|
'site/migrations/0001_studio.sql': MIGRATION,
|
|
309
366
|
[`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
|
|
310
|
-
'
|
|
367
|
+
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
311
368
|
'.claude/skills/.gitkeep': '',
|
|
312
369
|
};
|
|
370
|
+
if (template) {
|
|
371
|
+
// A first game, so the site plays the moment Cloudflare deploys it; and a band on Home that connects the new
|
|
372
|
+
// studio to the Claude chat that set it up (`setup attach` removes both once Claude works in the studio).
|
|
373
|
+
for (const [rel, text] of starterFiles('gem-rush')) files[`games/gem-rush/${rel}`] = text;
|
|
374
|
+
files['site/partials/home.html'] = CONNECT_BAND;
|
|
375
|
+
}
|
|
376
|
+
return files;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** A starter's files, as text, relative to its folder. */
|
|
380
|
+
function starterFiles(id) {
|
|
381
|
+
const dir = join(PACKAGE_ROOT, 'starters', id);
|
|
382
|
+
const out = [];
|
|
383
|
+
const walk = (rel) => {
|
|
384
|
+
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
385
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
386
|
+
if (entry.isDirectory()) walk(path);
|
|
387
|
+
else out.push([path, readFileSync(join(dir, path), 'utf8')]);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
walk('');
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The template's first-run band on Home: one link that connects this studio to the Claude chat that set it up. */
|
|
395
|
+
export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once Claude works in the studio
|
|
396
|
+
(homie-studio setup attach removes it); delete it by hand any time. -->
|
|
397
|
+
<div class="band-in" style="text-align:center">
|
|
398
|
+
<p class="kicker">New studio</p>
|
|
399
|
+
<h2>{{studio.name}} is live</h2>
|
|
400
|
+
<p>It runs on your own Cloudflare account. Connect it to the Claude chat that set it up, and Claude takes it from here.</p>
|
|
401
|
+
<p><a class="btn" href="/_studio/connect">Connect to Claude</a></p>
|
|
402
|
+
</div>
|
|
403
|
+
`;
|
|
404
|
+
|
|
405
|
+
export function templateReadme() {
|
|
406
|
+
return `# A Homie studio
|
|
407
|
+
|
|
408
|
+
This repository is a game studio made with [Homie](https://homie.rocks): its games, music, videos and posts,
|
|
409
|
+
and a site with public multiplayer rooms that runs on **your own Cloudflare account**, on the free plan.
|
|
410
|
+
|
|
411
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/homie-rocks/homie/tree/main/template)
|
|
412
|
+
|
|
413
|
+
The button copies this studio into your GitHub, creates its Worker, database and rooms on your Cloudflare, and
|
|
414
|
+
deploys it with Workers Builds: every push to \`main\` goes live, and every other branch gets its own Preview.
|
|
415
|
+
The site plays a first game the moment it is up.
|
|
416
|
+
|
|
417
|
+
Then open the site and tap **Connect to Claude**, or ask Claude in the Claude app to set up your studio with the
|
|
418
|
+
Homie connector: it makes games, songs and videos here, in a pull request you merge with one tap.
|
|
419
|
+
|
|
420
|
+
\`AGENTS.md\` says how everything here works.
|
|
421
|
+
`;
|
|
313
422
|
}
|
|
314
423
|
|
|
315
424
|
/**
|
|
@@ -340,7 +449,7 @@ function insideGit(dir) {
|
|
|
340
449
|
try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
|
|
341
450
|
}
|
|
342
451
|
|
|
343
|
-
export function newStudio(folder, { name, homie, slug: askedSlug, install = true } = {}) {
|
|
452
|
+
export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false } = {}) {
|
|
344
453
|
if (typeof folder !== 'string' || !folder.trim()) throw new Error('name the folder for the new studio, e.g. `homie-studio new ~/studios/night-owls --name "Night Owls"`. Nothing was written.');
|
|
345
454
|
if (!name || !String(name).trim()) throw new Error('give the studio a name: --name "Night Owls". Nothing was written.');
|
|
346
455
|
const dir = resolve(folder.replace(/^~(?=\/|$)/, homedir()));
|
|
@@ -355,7 +464,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
355
464
|
if (entries.length) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
|
|
356
465
|
}
|
|
357
466
|
const slug = slugify(askedSlug || name);
|
|
358
|
-
const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks' });
|
|
467
|
+
const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template });
|
|
359
468
|
mkdirSync(dir, { recursive: true });
|
|
360
469
|
const wrote = [];
|
|
361
470
|
for (const [rel, content] of Object.entries(files)) {
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio setup attach <hs_…>` — this repository is the studio the Claude app's setup card (the Homie MCP
|
|
3
|
+
* tool studio_setup) is making, said once from the Claude Code session that works in it:
|
|
4
|
+
*
|
|
5
|
+
* - the directory records that Claude works in this repository (the card's "Let Claude work in it" goes green),
|
|
6
|
+
* and answers with the studio's live address once its site is connected (the card's Cloudflare step);
|
|
7
|
+
* - a copy of the public template (studio.json `template: true`) gets the name the person chose in the chat:
|
|
8
|
+
* studio.json, the Worker's STUDIO_NAME, and AGENTS.md and README.md while they are untouched; its first-run
|
|
9
|
+
* "Connect to Claude" band goes; studio.json takes the Worker and database names Cloudflare's form chose;
|
|
10
|
+
* - the live address goes into .studio/local.json (git-ignored), so `check --url` and the card know it.
|
|
11
|
+
*
|
|
12
|
+
* It sends the repository's owner/name and the studio's name and slug, nothing else: no key, no path, no code.
|
|
13
|
+
* A setup id is single use per repository: a second attach from the same repository answers the same; another
|
|
14
|
+
* repository is refused.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { configNames } from './cloudflare.mjs';
|
|
20
|
+
import { networkWhy } from './progress.mjs';
|
|
21
|
+
import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
|
|
22
|
+
import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
|
|
23
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
24
|
+
|
|
25
|
+
const SETUP_ID = /^hs_[a-f0-9]{32}$/;
|
|
26
|
+
|
|
27
|
+
/** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
|
|
28
|
+
export function repoOf(root) {
|
|
29
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
30
|
+
if (r.status !== 0) return null;
|
|
31
|
+
const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
|
|
32
|
+
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
|
|
33
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function setupAttach(root, id, { homie } = {}) {
|
|
37
|
+
if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
|
|
38
|
+
const studio = readStudio(root);
|
|
39
|
+
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
40
|
+
const repo = repoOf(root);
|
|
41
|
+
let res; let body = null;
|
|
42
|
+
try {
|
|
43
|
+
res = await fetch(`${directory}/api/studio/setup/${id}/attach`, {
|
|
44
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
|
|
45
|
+
body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
|
|
46
|
+
signal: AbortSignal.timeout(10_000),
|
|
47
|
+
});
|
|
48
|
+
body = await res.json().catch(() => null);
|
|
49
|
+
} catch (error) { return { ok: false, command: 'setup attach', why: `${directory} did not answer (${error?.message ?? error})` }; }
|
|
50
|
+
const blocked = networkWhy(res, directory);
|
|
51
|
+
if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
|
|
52
|
+
if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
|
|
53
|
+
|
|
54
|
+
const renamed = [];
|
|
55
|
+
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
|
|
56
|
+
if (studio.template === true) {
|
|
57
|
+
const next = { ...readStudio(root) };
|
|
58
|
+
delete next.template;
|
|
59
|
+
const was = { name: next.name, slug: next.slug };
|
|
60
|
+
if (name) { next.name = name; next.slug = slugify(name); }
|
|
61
|
+
// The Worker and database are whatever Cloudflare's form named them: studio.json follows wrangler.jsonc.
|
|
62
|
+
const names = configNames(root);
|
|
63
|
+
next.cloudflare = { ...next.cloudflare, ...(names.worker ? { worker: names.worker } : {}), ...(names.d1 ? { d1: names.d1 } : {}) };
|
|
64
|
+
writeStudio(root, next);
|
|
65
|
+
renamed.push('studio.json');
|
|
66
|
+
if (name) {
|
|
67
|
+
const cfg = configPath(root);
|
|
68
|
+
const text = readFileSync(cfg, 'utf8');
|
|
69
|
+
const fixed = text.replace(/("STUDIO_NAME"\s*:\s*)"(?:[^"\\]|\\.)*"/g, (_, k) => `${k}${JSON.stringify(name)}`);
|
|
70
|
+
if (fixed !== text) { writeFileSync(cfg, fixed); renamed.push('wrangler.jsonc'); }
|
|
71
|
+
const untouched = (file, before, after) => {
|
|
72
|
+
const path = join(root, file);
|
|
73
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === before) { writeFileSync(path, after); renamed.push(file); }
|
|
74
|
+
};
|
|
75
|
+
untouched('AGENTS.md', agentsMd(was), agentsMd({ name: next.name, slug: next.slug }));
|
|
76
|
+
untouched('README.md', templateReadme(), readme({ name: next.name }));
|
|
77
|
+
}
|
|
78
|
+
const band = join(root, 'site', 'partials', 'home.html');
|
|
79
|
+
if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
|
|
80
|
+
}
|
|
81
|
+
if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
|
|
82
|
+
return {
|
|
83
|
+
ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
|
|
84
|
+
message: body.site ? `This repository is ${name ?? studio.name}, live at ${body.site}. The Claude app's card shows it.` : `This repository is ${name ?? studio.name}. Its site is not connected yet: ${body.connect ?? 'open the site and tap Connect to Claude'}.`,
|
|
85
|
+
next: [
|
|
86
|
+
...(renamed.length ? ['commit the renamed files on a branch and open a pull request (merging it names the live site)'] : []),
|
|
87
|
+
'npm install, then npx --no-install homie-studio progress attach <hb_…> when the chat opened a build',
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
package/lib/site.mjs
CHANGED
|
@@ -416,6 +416,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
416
416
|
if (texts.length) { mkdirSync(landDir, { recursive: true }); writeFileSync(join(landDir, 'credits.json'), `${JSON.stringify({ v: 1, texts })}\n`); }
|
|
417
417
|
|
|
418
418
|
const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
|
|
419
|
+
// The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
|
|
420
|
+
const theme = L.theme && typeof L.theme === 'object' ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(L.theme[k] ?? ''))).map((k) => [k, L.theme[k]])) : null;
|
|
421
|
+
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
422
|
+
// landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
|
|
423
|
+
// dark game in a light studio. Anything else follows the studio's look.
|
|
424
|
+
if (L.scheme !== undefined && L.scheme !== 'light' && L.scheme !== 'dark') log(`warning: games/${g.id}/game.json landing.scheme is "light" or "dark"; left out`);
|
|
419
425
|
return {
|
|
420
426
|
pitch: str(L.pitch, 240) ?? str(credits.tagline, 240),
|
|
421
427
|
kicker: str(L.kicker, 120),
|
|
@@ -431,7 +437,8 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
431
437
|
music: forGame(songs).map((e) => e.slug),
|
|
432
438
|
source: g.share?.source !== false,
|
|
433
439
|
tv: g.screen?.tv !== false && L.tv !== false,
|
|
434
|
-
theme:
|
|
440
|
+
theme: theme && Object.keys(theme).length ? theme : null,
|
|
441
|
+
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
|
|
435
442
|
};
|
|
436
443
|
}
|
|
437
444
|
|