@homie-rocks/studio 0.6.0 → 0.8.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.
@@ -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
@@ -9,8 +9,8 @@
9
9
  * games/<id>/ one folder per game (game.json, index.html, src/)
10
10
  * music/ videos/ manifests in the repo; the big files go to the studio's own storage
11
11
  * (R2) once it is added with `homie-studio storage add`
12
- * posts/ words the studio publishes
13
- * site/ the studio's Worker (pages, rooms), its D1 migrations (no R2
12
+ * posts/ the studio's news and drops (markdown; the site's Posts, with feeds)
13
+ * site/ the studio's Worker (pages, rooms), its look (theme.json), its D1 migrations (no R2
14
14
  * binding until the studio adds storage: a free Cloudflare account
15
15
  * without a payment method cannot use R2, and a new studio needs none)
16
16
  * .claude/skills/ skills only this studio uses
@@ -24,6 +24,7 @@ import { homedir } from 'node:os';
24
24
  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
+ import { themeFile } from './site.mjs';
27
28
 
28
29
  export const COMPAT_DATE = '2026-06-01';
29
30
  export const WRANGLER_VERSION = '4.126.0';
@@ -73,8 +74,8 @@ directory lists its games; homie.rocks does not host them.
73
74
  | --- | --- |
74
75
  | \`games/<id>/\` | One game: \`game.json\` (id, name, blurb, players, round length), \`index.html\`, \`src/main.ts\`. |
75
76
  | \`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. |
76
- | \`posts/\` | Markdown the studio publishes. |
77
- | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
77
+ | \`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\`), D1 migrations, \`wrangler.jsonc\`. |
78
79
  | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
79
80
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
80
81
 
@@ -116,6 +117,24 @@ studio's pinned copy, never a registry lookup of the bare name.
116
117
  (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
117
118
  nothing sent anywhere; house QA and \`check\` runs are not counted.
118
119
 
120
+ ## The site
121
+
122
+ \`npm run build\` makes the studio's site from what is in this folder
123
+ (\`node_modules/@homie-rocks/studio/site/SITE.md\` says all of it):
124
+
125
+ - **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
126
+ Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
127
+ page is not found.
128
+ - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
129
+ (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
130
+ with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
131
+ live rooms, how to play, credits, and "Make a game like this". Give it words in game.json's \`landing\` block
132
+ (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
133
+ - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
134
+ 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\`).
136
+ - Every page ends with "Made with Homie", linking to homie.rocks/studio/. Restyle it in \`site/theme.css\`; keep it.
137
+
119
138
  ## Making games
120
139
 
121
140
  - Every game is multiplayer on the web through the netplay contract
@@ -181,6 +200,46 @@ CREATE TABLE IF NOT EXISTS rounds (
181
200
  CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
182
201
  `;
183
202
 
203
+ const POSTS_README = `# Posts
204
+
205
+ The studio's news and drops. One markdown file each; the site shows them at \`/posts/\` (newest first), on the home
206
+ page, and in two feeds: \`/posts/feed.xml\` (Atom) and \`/posts/feed.json\` (JSON Feed).
207
+
208
+ The file's name is its address: \`posts/2026-09-30-we-are-live.md\` is \`/posts/we-are-live/\`, dated by its prefix.
209
+
210
+ \`\`\`markdown
211
+ ---
212
+ title: We are live
213
+ date: 2026-09-30
214
+ summary: One line for the cards and the feeds.
215
+ game: crown-thief
216
+ image: /games/crown-thief/cover.jpg
217
+ ---
218
+
219
+ The body, in markdown: **bold**, *italic*, [links](https://homie.rocks/), images, lists, quotes.
220
+ \`\`\`
221
+
222
+ \`game:\`, \`song:\` and \`video:\` link one of this studio's games, songs or videos (by id or slug); the post shows
223
+ it with a Play, Listen or Watch button. \`draft: true\` keeps a post off the site. Raw HTML is shown as text.
224
+ Files README.md, and names starting with \`_\` or \`.\`, are not posts.
225
+ `;
226
+
227
+ const SITE_README = `# site/
228
+
229
+ The studio's site. \`npm run build\` makes every page from the studio (its games, music, videos and posts);
230
+ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site/SITE.md\`.
231
+
232
+ | Put it in | What it does |
233
+ | --- | --- |
234
+ | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
235
+ | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
236
+ | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
237
+ | \`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
+ | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). |
239
+
240
+ \`src/worker.mjs\`, \`migrations/\` and \`wrangler.jsonc\` are the Worker; \`dist/\` is the build (not committed).
241
+ `;
242
+
184
243
  const GITIGNORE = `node_modules/
185
244
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
186
245
  .studio/
@@ -192,6 +251,8 @@ site/.wrangler/
192
251
  *.log
193
252
  # Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
194
253
  games/*/.port/
254
+ # Screenshots from check and look runs.
255
+ .checks/
195
256
  # Large media lives in this studio's storage (R2, after storage add) or is served by the site; the manifests beside it are committed.
196
257
  # Working files of the music and video skills (frames, captures, provider answers) stay on this computer.
197
258
  music/**/work/
@@ -237,7 +298,9 @@ export function studioFiles({ name, slug, homie }) {
237
298
  'music/manifest.json': '{ "v": 1, "items": [] }\n',
238
299
  'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
239
300
  'videos/manifest.json': '{ "v": 1, "items": [] }\n',
240
- 'posts/README.md': 'Markdown posts this studio publishes, one file each.\n',
301
+ 'posts/README.md': POSTS_README,
302
+ 'site/theme.json': themeFile(slug),
303
+ 'site/README.md': SITE_README,
241
304
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
242
305
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
243
306
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';