@homie-rocks/studio 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/look.mjs CHANGED
@@ -36,8 +36,9 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
36
36
  const base = String(url).replace(/\/+$/, '');
37
37
  mkdirSync(shots, { recursive: true });
38
38
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
39
+ // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
39
40
  const browser = await puppeteer.launch({
40
- executablePath: chrome, headless: true, userDataDir: profile,
41
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: 150_000, protocolTimeout: 180_000,
41
42
  args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
42
43
  });
43
44
  const rows = [];
@@ -51,7 +52,7 @@ export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVIC
51
52
  await page.setViewport(LOOK_DEVICES[device]);
52
53
  await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
53
54
  const t0 = Date.now();
54
- const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
+ const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 90_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
55
56
  const ms = Date.now() - t0;
56
57
  await sleep(900);
57
58
  const fold = await page.evaluate(() => {
package/lib/media.mjs CHANGED
@@ -50,6 +50,11 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
50
50
  const entries = [];
51
51
  const skipped = [];
52
52
  const seen = new Set();
53
+ // The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
54
+ // its own. A path in the studio (copied into the site) or an https:// address, like any file.
55
+ const artRole = kind === 'music' ? 'cover' : 'poster';
56
+ const shared = manifest[artRole];
57
+ const sharedFile = typeof shared === 'string' ? (/^https:\/\//.test(shared) ? { url: shared } : { path: shared }) : shared && typeof shared === 'object' ? shared : null;
53
58
  for (const item of manifest.items) {
54
59
  if (!item || typeof item !== 'object') continue;
55
60
  const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
@@ -58,7 +63,9 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
58
63
  seen.add(item.slug);
59
64
  if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
60
65
  const files = [];
61
- for (const f of Array.isArray(item.files) ? item.files : []) {
66
+ const own = Array.isArray(item.files) ? item.files : [];
67
+ const list = sharedFile && !own.some((f) => f?.role === artRole) ? [...own, { ...sharedFile, role: artRole }] : own;
68
+ for (const f of list) {
62
69
  if (!f || f.public === false) continue;
63
70
  let src = null;
64
71
  if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
@@ -197,7 +197,8 @@ function descendants(pid) {
197
197
  async function chrome(puppeteer, kind, exe) {
198
198
  const dir = mkdtempSync(join(tmpdir(), 'homie-port-check-'));
199
199
  const vp = kind === 'phone' ? { width: 412, height: 915 } : kind === 'tv' ? { width: 1280, height: 720 } : { width: 1280, height: 800 };
200
- const browser = await puppeteer.launch({ executablePath: exe, headless: true, userDataDir: dir, protocolTimeout: 120_000, args: [...GPU, `--window-size=${vp.width},${vp.height}`] });
200
+ // 150 s for Chrome to start: on a loaded computer a cold start has taken over a minute.
201
+ 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
202
  const page = (await browser.pages())[0] ?? await browser.newPage();
202
203
  if (kind === 'phone') await page.emulate({ viewport: { ...vp, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true }, userAgent: `${ANDROID_UA} ${TAG}` });
203
204
  else { await page.setViewport({ ...vp, deviceScaleFactor: 1 }); await page.setUserAgent(`${await browser.userAgent()} ${TAG}`); }
@@ -215,7 +216,7 @@ async function chrome(puppeteer, kind, exe) {
215
216
  }
216
217
 
217
218
  async function webkit(pw) {
218
- const browser = await pw.webkit.launch({ headless: true });
219
+ const browser = await pw.webkit.launch({ headless: true, timeout: 150_000 });
219
220
  const dev = pw.devices['iPhone 15'];
220
221
  const ctx = await browser.newContext({ ...dev, userAgent: `${dev.userAgent} ${TAG}` });
221
222
  const page = await ctx.newPage();
@@ -379,7 +380,19 @@ async function ownerTests(h, how, log) {
379
380
 
380
381
  /* ------------------------------------------------------------------ the check */
381
382
 
382
- export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000 }) {
383
+ /** What a progress feed calls each port check row. */
384
+ export const PORT_CHECK_LABELS = {
385
+ 'owner-desk': 'Moves on a computer', 'owner-phone': 'Moves on a phone', 'owner-iphone': 'Moves on an iPhone (WebKit)',
386
+ audio: 'Sound starts on the first touch', sandbox: 'Runs in the sandbox', 'ui-cover': 'UI stays out of the middle',
387
+ round: 'Two browsers finish a round', 'host-kill': 'The host leaves, the round goes on', 'late-join': 'A late joiner takes a bot\'s seat',
388
+ tv: 'The big screen watches', errors: 'No errors in the game',
389
+ };
390
+
391
+ /**
392
+ * `report` (optional; the open progress feed, lib/progress.mjs) hears each row as it runs, passes or fails, gets a
393
+ * small picture after each screenshot, and can ask the check to stop between rows. Without it nothing changes.
394
+ */
395
+ export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000, report = null }) {
383
396
  if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
384
397
  const base = String(url).replace(/\/+$/, '');
385
398
  const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
@@ -393,13 +406,21 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
393
406
  mkdirSync(out, { recursive: true });
394
407
  if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
395
408
  const rows = [];
396
- const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); };
409
+ const tell = (name, state, note) => { try { report?.check?.(name, state, { label: PORT_CHECK_LABELS[name] ?? name, ...(note ? { note } : {}) }); } catch { /* a feed never breaks a check */ } };
410
+ const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); tell(name, ok === null ? 'skip' : ok ? 'pass' : 'fail', detail?.why); };
397
411
  const open_ = [];
398
412
  const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
399
413
  const close = async (h) => { const i = open_.indexOf(h); if (i >= 0) open_.splice(i, 1); await h.close(); };
400
- const shot = async (h, name) => { await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000); };
414
+ const shot = async (h, name) => {
415
+ await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
416
+ if (report?.preview && h.vp) {
417
+ const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
418
+ if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name }); } catch { /* too big: no picture */ } }
419
+ }
420
+ };
401
421
  const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
402
422
  const errorsSeen = [];
423
+ let stoppedAt = null;
403
424
  const collect = (h) => { for (const e of h.errors) errorsSeen.push(`${h.engine}-${h.kind}: ${e}`); for (const r of h.badResponses) errorsSeen.push(`${h.engine}-${h.kind}: HTTP ${r}`); };
404
425
  const started = Date.now();
405
426
  // The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
@@ -408,6 +429,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
408
429
  try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
409
430
  };
410
431
  const gate = async (name) => {
432
+ if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
433
+ tell(name, 'running');
411
434
  if (await alive()) return true;
412
435
  row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
413
436
  return false;
@@ -631,7 +654,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
631
654
  }
632
655
  const failed = rows.filter((r) => r.ok === false);
633
656
  const skipped = rows.filter((r) => r.ok === null);
634
- const result = { ok: failed.length === 0 && rows.length > 0, command: 'port check', game, url: base, out, totalMs: Date.now() - started, passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
657
+ const result = { ok: failed.length === 0 && rows.length > 0 && !stoppedAt, command: 'port check', game, url: base, out, totalMs: Date.now() - started, ...(stoppedAt ? { stopped: true, why: `stopped by the person (before ${stoppedAt})` } : {}), passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
635
658
  writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
636
659
  return result;
637
660
  }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * THE PROGRESS FEED — what a build is doing right now, for a person watching from anywhere (a terminal, or the
3
+ * Homie MCP's build progress widget in the Claude app on a phone).
4
+ *
5
+ * One build is one small JSON document (kind "homie-studio-progress", v 1), rewritten whole at every step:
6
+ *
7
+ * .studio/progress/<build>.json the feed
8
+ * .studio/progress/<build>.key the shared copy's write key (only when shared; never printed, git-ignored)
9
+ * .studio/progress/current the open build's id
10
+ *
11
+ * The stages depend on what is being made:
12
+ * a game plan → build → checks → deploy
13
+ * a song plan → render → lyric check → publish
14
+ * a video plan → shots → cut → publish
15
+ *
16
+ * `homie-studio progress start` opens a feed. While one is open, `build`, `check`, `port check` and `deploy` write
17
+ * their stage into it, their checks as each goes green or red, and a preview (a small picture of the game and the
18
+ * address to play it), and they stop at the next safe point when a stop was asked. The AI marks what only it
19
+ * knows (the plan, money spent, a song's lyric check, a video's shots) with the other `progress` commands.
20
+ * WITHOUT AN OPEN FEED NOTHING CHANGES: every command behaves exactly as before.
21
+ *
22
+ * SHARED (`progress start --share`): the directory named in studio.json (homie.rocks by default) keeps a copy for
23
+ * 24 hours, so the Homie MCP tool `build_progress { build }` can show it in the Claude app, and answers every
24
+ * write with whether the person pressed Stop there. It receives the feed and nothing else: never a key, a file
25
+ * path, code, or more than the feed's last few log lines. Only a loopback directory may be plain http.
26
+ *
27
+ * The document (every text is plain, bounded; pictures are small data: URLs):
28
+ * { v: 1, kind, build, what: game|song|video, id, title, studio,
29
+ * state: running|passed|failed|stopped, stage, startedAt, updatedAt, endedAt,
30
+ * stages: [{ id, label, state: pending|running|done|failed|skipped|stopped, startedAt, endedAt, note }],
31
+ * checks: [{ id, label, stage, state: pending|running|pass|fail|skip, ms, note }],
32
+ * preview: { url, image, caption, at } | null,
33
+ * spend: { unit: usd|credits, used, budget, items: [{ what, amount, at, receipt }] },
34
+ * stop: { requested, at, by },
35
+ * song?: { peaks: [0..1], lyrics: [{ line, sung: true|false|null }] },
36
+ * video?: { shots: [{ id, label, state, image }] },
37
+ * log: [{ at, text }], error, shared?: { build, directory } }
38
+ */
39
+ import { randomBytes } from 'node:crypto';
40
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
41
+ import { extname, join } from 'node:path';
42
+ import { readStudio } from './studio.mjs';
43
+
44
+ export const PROGRESS_KIND = 'homie-studio-progress';
45
+ export const PROGRESS_DIR = join('.studio', 'progress');
46
+ export const STAGES = Object.freeze({
47
+ game: [['plan', 'Plan'], ['build', 'Build'], ['checks', 'Checks'], ['deploy', 'Deploy']],
48
+ song: [['plan', 'Plan'], ['render', 'Render'], ['check', 'Lyric check'], ['publish', 'Publish']],
49
+ video: [['plan', 'Plan'], ['shots', 'Shots'], ['cut', 'Cut'], ['publish', 'Publish']],
50
+ });
51
+ /** The same bounds the directory enforces on a shared feed. */
52
+ export const LIMITS = Object.freeze({
53
+ title: 120, note: 200, label: 80, logLines: 12, logLine: 160, checks: 24, shots: 24, items: 50, peaks: 240, lyrics: 80,
54
+ image: 96 * 1024, shotImage: 32 * 1024, doc: 256 * 1024,
55
+ });
56
+ const STAGE_STATES = new Set(['pending', 'running', 'done', 'failed', 'skipped', 'stopped']);
57
+ const CHECK_STATES = new Set(['pending', 'running', 'pass', 'fail', 'skip']);
58
+ const END_STATES = new Set(['passed', 'failed', 'stopped']);
59
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
60
+ const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
61
+ const REMOTE_ID = /^hb_[a-f0-9]{32}$/;
62
+ const PUSH_GAP_MS = 1200;
63
+ const HEARTBEAT_MS = 5000;
64
+ const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
65
+
66
+ const now = () => new Date().toISOString();
67
+ /** Plain text of at most `max` characters: no control characters, trimmed. */
68
+ export const plain = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
69
+ const dir = (root) => join(root, PROGRESS_DIR);
70
+ const docPath = (root, id) => join(dir(root), `${id}.json`);
71
+ const keyPath = (root, id) => join(dir(root), `${id}.key`);
72
+ const currentPath = (root) => join(dir(root), 'current');
73
+ const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
74
+
75
+ function writeAtomic(file, text) {
76
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
77
+ writeFileSync(tmp, text);
78
+ renameSync(tmp, file);
79
+ }
80
+
81
+ export function readFeed(root, id) {
82
+ if (!BUILD_ID.test(String(id ?? ''))) return null;
83
+ try { const d = JSON.parse(readFileSync(docPath(root, id), 'utf8')); return d?.kind === PROGRESS_KIND ? d : null; } catch { return null; }
84
+ }
85
+
86
+ /** The open build's id, or null. */
87
+ export function currentId(root) {
88
+ try { const id = readFileSync(currentPath(root), 'utf8').trim(); return readFeed(root, id)?.state === 'running' ? id : null; } catch { return null; }
89
+ }
90
+
91
+ /** A picture for the feed: a data: URL as it is, or a png/jpg/webp file, at most `max` bytes. */
92
+ export function imageData(value, max = LIMITS.image) {
93
+ const raw = String(value ?? '');
94
+ if (!raw) return null;
95
+ if (raw.startsWith('data:')) {
96
+ if (!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(raw)) throw new Error('a picture is a data:image/png, jpeg or webp base64 URL, or a .png/.jpg/.webp file');
97
+ if (raw.length > Math.ceil(max * 4 / 3) + 40) throw new Error(`the picture is over ${Math.round(max / 1024)} KB; use a smaller JPEG`);
98
+ return raw;
99
+ }
100
+ const type = IMAGE_TYPES[extname(raw).toLowerCase()];
101
+ if (!type) throw new Error('a picture is a .png, .jpg or .webp file');
102
+ if (!existsSync(raw) || !statSync(raw).isFile()) throw new Error(`no picture at ${raw}`);
103
+ if (statSync(raw).size > max) throw new Error(`${raw} is over ${Math.round(max / 1024)} KB; use a smaller JPEG (about 480 px wide)`);
104
+ return `data:${type};base64,${readFileSync(raw).toString('base64')}`;
105
+ }
106
+
107
+ /* ------------------------------------------------------------------ the shared copy */
108
+
109
+ const pushers = new Map();
110
+
111
+ async function pushDoc(root, id) {
112
+ const doc = readFeed(root, id);
113
+ if (!doc?.shared?.build) return null;
114
+ let key = null;
115
+ try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
116
+ const { shared, ...body } = doc;
117
+ const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
118
+ method: 'PUT',
119
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
120
+ body: JSON.stringify({ ...body, build: shared.build }),
121
+ signal: AbortSignal.timeout(8000),
122
+ });
123
+ if (res.status === 404 || res.status === 410) {
124
+ // The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
125
+ mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
126
+ return null;
127
+ }
128
+ const reply = await res.json().catch(() => null);
129
+ if (reply?.stop) {
130
+ mutateDoc(root, id, (d) => {
131
+ if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
132
+ });
133
+ }
134
+ return reply;
135
+ }
136
+
137
+ function schedule(root, id, { soon = false } = {}) {
138
+ const doc = readFeed(root, id);
139
+ if (!doc?.shared?.build || doc.shared.ended) return;
140
+ const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
141
+ pushers.set(id, p);
142
+ p.dirty = true;
143
+ if (p.inflight) return;
144
+ const wait = soon ? 0 : Math.max(0, p.last + PUSH_GAP_MS - Date.now());
145
+ if (p.timer) { if (!soon) return; clearTimeout(p.timer); }
146
+ p.timer = setTimeout(() => { p.timer = null; send(id, p); }, wait);
147
+ p.timer.unref?.();
148
+ }
149
+
150
+ function send(id, p) {
151
+ p.dirty = false;
152
+ p.last = Date.now();
153
+ p.inflight = pushDoc(p.root, id).catch(() => null).finally(() => {
154
+ p.inflight = null;
155
+ if (p.dirty) schedule(p.root, id);
156
+ });
157
+ return p.inflight;
158
+ }
159
+
160
+ /** While a stage runs, re-send the feed every few seconds: the widget sees it is alive, and hears Stop. */
161
+ function heartbeat(root, id, on) {
162
+ const p = pushers.get(id) ?? { root, last: 0, timer: null, inflight: null, dirty: false, beat: null };
163
+ pushers.set(id, p);
164
+ if (on && !p.beat) { p.beat = setInterval(() => { if (!p.inflight) send(id, p); }, HEARTBEAT_MS); p.beat.unref?.(); }
165
+ if (!on && p.beat) { clearInterval(p.beat); p.beat = null; }
166
+ }
167
+
168
+ /** Send whatever is waiting, now (a command calls this before it exits). Never throws, never waits past `ms`. */
169
+ export async function flushProgress(ms = 6000) {
170
+ const waits = [];
171
+ for (const [id, p] of pushers) {
172
+ if (p.beat) { clearInterval(p.beat); p.beat = null; }
173
+ if (p.timer) { clearTimeout(p.timer); p.timer = null; p.dirty = true; }
174
+ if (p.inflight) waits.push(p.inflight.then(() => (p.dirty ? send(id, p) : null)));
175
+ else if (p.dirty) waits.push(send(id, p));
176
+ }
177
+ if (!waits.length) return;
178
+ await Promise.race([Promise.allSettled(waits), new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); })]);
179
+ }
180
+
181
+ /* ------------------------------------------------------------------ the document */
182
+
183
+ function pushLog(d, text) {
184
+ d.log = [...(d.log ?? []), { at: now(), text: plain(text, LIMITS.logLine) }].slice(-LIMITS.logLines);
185
+ }
186
+
187
+ function mutateDoc(root, id, fn) {
188
+ const d = readFeed(root, id);
189
+ if (!d) return null;
190
+ fn(d);
191
+ d.updatedAt = now();
192
+ let text = `${JSON.stringify(d, null, 1)}\n`;
193
+ // A feed never grows past its bound: the oldest shot pictures, then the preview picture, give way first.
194
+ while (text.length > LIMITS.doc && d.video?.shots?.some((s) => s.image)) { d.video.shots.find((s) => s.image).image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
195
+ if (text.length > LIMITS.doc && d.preview?.image) { d.preview.image = null; text = `${JSON.stringify(d, null, 1)}\n`; }
196
+ writeAtomic(docPath(root, id), text);
197
+ return d;
198
+ }
199
+
200
+ /** One open build's feed. Every call re-reads the file, changes it and writes it whole, so two commands can share it. */
201
+ export class Feed {
202
+ constructor(root, id) { this.root = root; this.id = id; }
203
+
204
+ get doc() { return readFeed(this.root, this.id); }
205
+
206
+ change(fn, { soon = false } = {}) {
207
+ const d = mutateDoc(this.root, this.id, fn);
208
+ if (d) schedule(this.root, this.id, { soon });
209
+ return d;
210
+ }
211
+
212
+ /** A stage's state. Starting one ends the stage before it (done) and skips any never started. */
213
+ stage(stage, state, note) {
214
+ if (!STAGE_STATES.has(state)) throw new Error(`a stage is ${[...STAGE_STATES].join(', ')}`);
215
+ const d = this.change((doc) => {
216
+ const at = doc.stages.findIndex((s) => s.id === stage);
217
+ if (at < 0) throw new Error(`this ${doc.what} build has no stage "${stage}" (${doc.stages.map((s) => s.id).join(', ')})`);
218
+ const s = doc.stages[at];
219
+ if (state === 'running') {
220
+ for (const before of doc.stages.slice(0, at)) {
221
+ if (before.state === 'running') { before.state = 'done'; before.endedAt = now(); }
222
+ else if (before.state === 'pending') before.state = 'skipped';
223
+ }
224
+ s.startedAt = now(); s.endedAt = null;
225
+ doc.stage = stage;
226
+ } else if (state !== 'pending') s.endedAt = now();
227
+ s.state = state;
228
+ if (note !== undefined) s.note = plain(note, LIMITS.note);
229
+ if (state === 'failed' && note) doc.error = plain(note, LIMITS.note);
230
+ if (state === 'running' || state === 'done') doc.error = null;
231
+ pushLog(doc, `${s.label}: ${state}${note ? ` (${plain(note, 100)})` : ''}`);
232
+ }, { soon: true });
233
+ heartbeat(this.root, this.id, d?.stages?.some((s) => s.state === 'running') && d.state === 'running');
234
+ return d;
235
+ }
236
+
237
+ /** One check's state; a check is added the first time it is named. */
238
+ check(id, state, { label, note, ms, stage } = {}) {
239
+ if (!CHECK_STATES.has(state)) throw new Error(`a check is ${[...CHECK_STATES].join(', ')}`);
240
+ return this.change((doc) => {
241
+ const key = plain(id, 40);
242
+ let c = doc.checks.find((x) => x.id === key);
243
+ if (!c) {
244
+ if (doc.checks.length >= LIMITS.checks) return;
245
+ c = { id: key, label: plain(label ?? key, LIMITS.label), stage: stage ?? doc.stage ?? null, state: 'pending', ms: null, note: '' };
246
+ doc.checks.push(c);
247
+ }
248
+ c.state = state;
249
+ if (label) c.label = plain(label, LIMITS.label);
250
+ if (note !== undefined) c.note = plain(note, LIMITS.note);
251
+ if (Number.isFinite(ms)) c.ms = Math.round(ms);
252
+ });
253
+ }
254
+
255
+ /** Forget the checks of one stage (a stage run again starts its checks afresh). */
256
+ resetChecks(stage) { return this.change((doc) => { doc.checks = doc.checks.filter((c) => c.stage !== stage); }); }
257
+
258
+ preview({ url, image, caption } = {}) {
259
+ const picture = image ? imageData(image) : undefined;
260
+ return this.change((doc) => {
261
+ doc.preview = {
262
+ url: url ? String(url).slice(0, 300) : doc.preview?.url ?? null,
263
+ image: picture ?? doc.preview?.image ?? null,
264
+ caption: caption !== undefined ? plain(caption, LIMITS.label) : doc.preview?.caption ?? '',
265
+ at: now(),
266
+ };
267
+ }, { soon: true });
268
+ }
269
+
270
+ spend(amount, what, { receipt, unit } = {}) {
271
+ const n = Number(amount);
272
+ if (!Number.isFinite(n) || n < 0 || n > 1e6) throw new Error('spend is a number of dollars (or credits) of 0 or more');
273
+ return this.change((doc) => {
274
+ if (unit && unit !== doc.spend.unit) throw new Error(`this build counts ${doc.spend.unit}, not ${unit}`);
275
+ doc.spend.used = Math.round((doc.spend.used + n) * 10000) / 10000;
276
+ doc.spend.items = [...doc.spend.items, { what: plain(what ?? 'spend', LIMITS.label), amount: n, at: now(), receipt: receipt ? plain(receipt, LIMITS.label) : null }].slice(-LIMITS.items);
277
+ pushLog(doc, `Spent ${doc.spend.unit === 'usd' ? `$${n.toFixed(2)}` : `${n} credits`} on ${plain(what ?? 'spend', 80)}`);
278
+ }, { soon: true });
279
+ }
280
+
281
+ log(text) { return this.change((doc) => pushLog(doc, text)); }
282
+
283
+ /** A video's shot (it fills in with its picture as it renders). */
284
+ shot(id, state, { label, image } = {}) {
285
+ if (!CHECK_STATES.has(state)) throw new Error(`a shot is ${[...CHECK_STATES].join(', ')}`);
286
+ const picture = image ? imageData(image, LIMITS.shotImage) : undefined;
287
+ return this.change((doc) => {
288
+ doc.video ??= { shots: [] };
289
+ let s = doc.video.shots.find((x) => x.id === plain(id, 40));
290
+ if (!s) { if (doc.video.shots.length >= LIMITS.shots) return; s = { id: plain(id, 40), label: plain(label ?? id, LIMITS.label), state: 'pending', image: null }; doc.video.shots.push(s); }
291
+ s.state = state;
292
+ if (label) s.label = plain(label, LIMITS.label);
293
+ if (picture !== undefined) s.image = picture;
294
+ });
295
+ }
296
+
297
+ /** A song's waveform (peaks of 0..1) and lyric lines as the check hears them. */
298
+ song({ peaks, lyric, sung } = {}) {
299
+ return this.change((doc) => {
300
+ doc.song ??= { peaks: [], lyrics: [] };
301
+ if (Array.isArray(peaks)) doc.song.peaks = peaks.slice(0, LIMITS.peaks).map((p) => Math.max(0, Math.min(1, Math.round(Number(p) * 1000) / 1000 || 0)));
302
+ if (lyric) {
303
+ const line = plain(lyric, LIMITS.label);
304
+ const known = doc.song.lyrics.find((l) => l.line === line);
305
+ const value = sung === true || sung === false ? sung : null;
306
+ if (known) known.sung = value;
307
+ else if (doc.song.lyrics.length < LIMITS.lyrics) doc.song.lyrics.push({ line, sung: value });
308
+ }
309
+ });
310
+ }
311
+
312
+ stopRequested() { return Boolean(this.doc?.stop?.requested); }
313
+
314
+ /**
315
+ * Ask the shared copy now (one write, answered with the person's Stop). A stage calls this before it starts,
316
+ * so a Stop pressed in the Claude app while nothing was running still stops the next command before it opens a
317
+ * browser or deploys. A local feed, or a directory that does not answer, changes nothing.
318
+ */
319
+ async sync() {
320
+ if (!this.doc?.shared?.build || this.doc.shared.ended) return;
321
+ await pushDoc(this.root, this.id).catch(() => null);
322
+ }
323
+
324
+ /** Ask the build to stop (the terminal's own Stop; the Claude app's arrives through the shared copy). */
325
+ stop(by = 'local') {
326
+ return this.change((doc) => { if (!doc.stop.requested) { doc.stop = { requested: true, at: now(), by }; pushLog(doc, 'Stop asked; stopping at the next safe point.'); } }, { soon: true });
327
+ }
328
+
329
+ /** The build is over: passed, failed or stopped. */
330
+ end(state, note) {
331
+ if (!END_STATES.has(state)) throw new Error(`a build ends ${[...END_STATES].join(', ')}`);
332
+ const d = this.change((doc) => {
333
+ for (const s of doc.stages) {
334
+ if (s.state === 'running') { s.state = state === 'passed' ? 'done' : state === 'stopped' ? 'stopped' : 'failed'; s.endedAt = now(); }
335
+ }
336
+ for (const c of doc.checks) if (c.state === 'running') c.state = state === 'passed' ? 'pass' : 'skip';
337
+ doc.state = state;
338
+ doc.endedAt = now();
339
+ if (note) { if (state === 'failed') doc.error = plain(note, LIMITS.note); pushLog(doc, note); }
340
+ pushLog(doc, state === 'passed' ? 'Done.' : state === 'stopped' ? 'Stopped.' : 'Failed.');
341
+ }, { soon: true });
342
+ heartbeat(this.root, this.id, false);
343
+ try { if (readFileSync(currentPath(this.root), 'utf8').trim() === this.id) rmSync(currentPath(this.root), { force: true }); } catch { /* none */ }
344
+ return d;
345
+ }
346
+ }
347
+
348
+ /** The open feed, or null (then a command reports nothing and behaves exactly as before). */
349
+ export function currentFeed(root) {
350
+ const id = root ? currentId(root) : null;
351
+ return id ? new Feed(root, id) : null;
352
+ }
353
+
354
+ /** The feed as the Claude app's widget and `progress show` see it (never the write key). */
355
+ export function publicFeed(doc) {
356
+ if (!doc) return null;
357
+ const { shared, ...rest } = doc;
358
+ return { ...rest, ...(shared ? { shared: { build: shared.build, directory: shared.directory, ...(shared.ended ? { ended: true } : {}) } } : {}) };
359
+ }
360
+
361
+ /**
362
+ * Open a feed for one build. `share` asks the studio's directory (studio.json `homie.directory`, or `directory`)
363
+ * to keep a copy for the Claude app's widget; if it cannot, the build goes on with the local feed only.
364
+ */
365
+ export async function startProgress(root, { what = 'game', id, title, budget, unit, share = false, directory } = {}) {
366
+ if (!STAGES[what]) return { ok: false, command: 'progress start', why: `what is being made: ${Object.keys(STAGES).join(', ')}` };
367
+ 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
+ const studio = readStudio(root);
369
+ const open = currentId(root);
370
+ if (open) return { ok: false, command: 'progress start', why: `build ${open} is still open: end it first (homie-studio progress end passed|failed|stopped)`, build: open };
371
+ const b = budget === undefined || budget === null || budget === true ? null : Number(budget);
372
+ if (b !== null && (!Number.isFinite(b) || b < 0)) return { ok: false, command: 'progress start', why: 'the budget is a number (dollars, or credits with --unit credits)' };
373
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '').toLowerCase();
374
+ const build = `${stamp}-${randomBytes(3).toString('hex')}`;
375
+ const doc = {
376
+ v: 1, kind: PROGRESS_KIND, build, what, id: id ?? null,
377
+ title: plain(title || `${id ?? studio.name ?? 'Studio'}`, LIMITS.title),
378
+ studio: plain(studio.name ?? '', 60),
379
+ state: 'running', stage: STAGES[what][0][0], startedAt: now(), updatedAt: now(), endedAt: null,
380
+ stages: STAGES[what].map(([sid, label], i) => ({ id: sid, label, state: i === 0 ? 'running' : 'pending', startedAt: i === 0 ? now() : null, endedAt: null, note: '' })),
381
+ checks: [], preview: null,
382
+ spend: { unit: unit === 'credits' ? 'credits' : 'usd', used: 0, budget: b, items: [] },
383
+ stop: { requested: false, at: null, by: null },
384
+ log: [{ at: now(), text: `Started: ${plain(title || id || what, 100)}` }],
385
+ error: null,
386
+ };
387
+ mkdirSync(dir(root), { recursive: true });
388
+ writeAtomic(docPath(root, build), `${JSON.stringify(doc, null, 1)}\n`);
389
+ writeAtomic(currentPath(root), `${build}\n`);
390
+ let shared = null;
391
+ let why = null;
392
+ if (share) {
393
+ const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
394
+ if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
395
+ else {
396
+ try {
397
+ const res = await fetch(`${at}/api/studio/progress`, {
398
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
399
+ body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
400
+ });
401
+ const body = await res.json().catch(() => null);
402
+ if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
403
+ writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
404
+ try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
405
+ shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
406
+ mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
407
+ schedule(root, build, { soon: true });
408
+ heartbeat(root, build, true);
409
+ } else why = body?.message ?? `${at} answered ${res.status}`;
410
+ } catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
411
+ }
412
+ }
413
+ return {
414
+ ok: true, command: 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: doc.title,
415
+ stages: doc.stages.map((s) => s.id), budget: doc.spend.budget, unit: doc.spend.unit,
416
+ shared: shared ? { build: shared.build, directory: shared.directory, expiresAt: shared.expiresAt } : null,
417
+ ...(share && !shared ? { sharedWhy: `not shared: ${why}; the feed is local only` } : {}),
418
+ widget: shared ? `In the Claude app: call the Homie MCP tool build_progress with { "build": "${shared.build}" }` : null,
419
+ };
420
+ }
package/lib/scaffold.mjs CHANGED
@@ -116,6 +116,10 @@ studio's pinned copy, never a registry lookup of the bare name.
116
116
  \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
117
117
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
118
118
  nothing sent anywhere; house QA and \`check\` runs are not counted.
119
+ - \`npx --no-install homie-studio upgrade\` — after pinning a newer \`@homie-rocks/studio\` (or through
120
+ \`npx -y --package=<its tarball> homie-studio upgrade\`): what the newer template adds to this studio (AGENTS.md
121
+ sections, READMEs, .gitignore lines) and what it keeps. It changes nothing until \`--apply\`, and never
122
+ touches a file or section this studio changed; \`--diff\` shows how those differ from the template's.
119
123
 
120
124
  ## The site
121
125
 
@@ -132,7 +136,11 @@ studio's pinned copy, never a registry lookup of the bare name.
132
136
  (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
133
137
  - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
134
138
  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\`).
139
+ \`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
140
+ game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
141
+ - **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
142
+ a cover of its own shows the music manifest's \`cover\`, else its game's still.
143
+ - **Live rooms** are listed on homie.rocks too; studio.json \`"rooms": { "share": false }\` keeps them off it.
136
144
  - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
137
145
 
138
146
  ## Making games
@@ -144,6 +152,10 @@ studio's pinned copy, never a registry lookup of the bare name.
144
152
  - Import it as \`import { createNetplay } from '@homie-rocks/studio/netplay'\`.
145
153
  - Phones and computers: touch controls on phones only, keys on computers; keep the
146
154
  centre of the screen clear during play.
155
+ - The play page's small room button (Invite, Big screen, the room code) sits top right. If the game's
156
+ scoreboard or a bar is there, move it in game.json \`"screen": { "share": … }\`: a corner or \`top-center\`, per
157
+ device (\`desk\`, \`phone\`, \`sideways\`), with an \`x\` / \`y\` offset in pixels, and \`"label": false\` to keep it
158
+ a small icon (\`node_modules/@homie-rocks/studio/site/SITE.md\`). Look at it on a phone and a computer.
147
159
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
148
160
  - A game's id is its URL (\`/<id>/\`); keep it once published.
149
161
 
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: 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,
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