@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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.6.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.8.0.tgz homie-studio new ./night-owls --name "Night Owls"
14
14
  cd night-owls && npm install
15
15
  npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
16
16
  npx homie-studio dev # the whole site locally
@@ -21,6 +21,21 @@ npx homie-studio publish # the homie.rocks directo
21
21
  npx homie-studio stats # the studio's own numbers, for its owner
22
22
  ```
23
23
 
24
+ ## The site: the hub's shape, and a landing for every game
25
+
26
+ Every studio site has the same sections as homie.rocks, in the studio's own look (`site/theme.json`): Home (the
27
+ featured game, live rooms, the latest posts), Games, Music, Videos, Rooms (every public room playing now,
28
+ joinable) and Posts (`posts/*.md`, with Atom and JSON feeds). A section with nothing in it has no tab, and its
29
+ page answers 404. Every game gets a landing at `/<game>/`: a full-bleed hero from its own footage or art, the
30
+ pitch, a big Play button into a public room, how to play on a phone, a computer and a TV (with the join code),
31
+ live rooms, credits (the original and its licence for a port), and "Make a game like this". Every page ends with
32
+ "Made with Homie". Anything the studio puts in `site/` wins: a whole page, a partial, its tokens, its CSS, its
33
+ files. `site/SITE.md` says all of it.
34
+
35
+ The play page writes its room into the address, and a small room button at the edge shares it: Invite, Big
36
+ screen and the room code. Every HTML answer is `no-transform`, and the site's pages are never framed by another
37
+ site.
38
+
24
39
  ## Rooms of up to 32
25
40
 
26
41
  Every room of a game has the seats its netplay manifest names: game.json's `netplay`
@@ -49,6 +64,34 @@ npx homie-studio stats share on # tell the directory "pl
49
64
  Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
50
65
  studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
51
66
 
67
+ ## A build the person can watch (the progress feed)
68
+
69
+ A build of a game, song or video can keep a small progress feed, so the person follows it
70
+ from wherever they are (the Claude app on a phone, through the Homie MCP's build progress
71
+ card), sees each check go green, what it spent against its budget, and can press Stop:
72
+
73
+ ```sh
74
+ npx homie-studio progress start crown-thief --share --title "Crown Thief: faster rounds" --budget 2
75
+ npx homie-studio progress stage plan done --note "Rounds from 60 s to 45 s"
76
+ npm run build && npx homie-studio check crown-thief --url http://127.0.0.1:8787 && npm run deploy
77
+ ```
78
+
79
+ While a feed is open, `build`, `check`, `port check` and `deploy` report into it by
80
+ themselves: their stage (a game: plan → build → checks → deploy), each check as it runs
81
+ and passes, a small picture of the game and the address to play it; a deploy with every
82
+ check green ends the build. The AI marks what only it knows with `progress stage`,
83
+ `progress check`, `progress spend`, `progress shot` (a video's shots) and `progress song`
84
+ (a song's waveform and lyric check). Before each stage starts, and every few seconds while
85
+ one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
86
+ browsers and says so, and nothing already built or deployed is undone.
87
+
88
+ The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
89
+ directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
90
+ hours so the Claude app can show it: the feed only (plain bounded text, pictures under
91
+ 96 KB), never a key, a path or code. Its write key stays in `.studio/progress/` and is
92
+ never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
93
+ feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
94
+
52
95
  ## The site's address
53
96
 
54
97
  `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
@@ -97,6 +140,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
97
140
  | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
98
141
  | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
99
142
  | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
143
+ | `worker/site.mjs`, `lib/site.mjs`, `lib/markdown.mjs`, `site/SITE.md` | The site: its sections, each game's landing, posts and their feeds, the look (theme tokens) and what the studio's `site/` folder overrides; the safe markdown posts are written in. `worker/pages.mjs`: the play page. |
100
144
  | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
101
145
  | `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
102
146
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
@@ -12,6 +12,8 @@
12
12
  * homie-studio build [<id>]
13
13
  * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
14
14
  * homie-studio check <id> [--url <site>] [--shots <dir>]
15
+ * homie-studio look [<path>...] [--url <site>] [--shots <dir>] [--only computer,phone,sideways]
16
+ * (the site's pages on a computer and a phone, as pictures, with what is wrong)
15
17
  * homie-studio port plan <game folder>
16
18
  * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
17
19
  * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
@@ -27,6 +29,24 @@
27
29
  * homie-studio stats revoke (every stats key and page session ends)
28
30
  * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
29
31
  *
32
+ * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
33
+ * [--budget <dollars>] [--unit usd|credits] [--share]
34
+ * (a progress feed for one build; --share shows it in the Claude app
35
+ * through the Homie MCP's build_progress widget, which can press Stop)
36
+ * homie-studio progress stage <stage> running|done|failed|skipped [--note "<one line>"]
37
+ * homie-studio progress check <id> pending|running|pass|fail|skip [--label "<words>"] [--note "<one line>"]
38
+ * homie-studio progress preview [--url <address>] [--image <small .jpg/.png>] [--caption "<words>"]
39
+ * homie-studio progress spend <amount> --what "<what it bought>" [--receipt <file>]
40
+ * homie-studio progress shot <id> pending|running|pass|fail [--label "<words>"] [--image <small .jpg>]
41
+ * homie-studio progress song [--peaks <peaks.json>] [--lyric "<line>" --sung yes|no]
42
+ * homie-studio progress log "<one line>"
43
+ * homie-studio progress stop (ask the running build to stop at its next safe point)
44
+ * homie-studio progress end passed|failed|stopped [--note "<one line>"]
45
+ * homie-studio progress show [<build>]
46
+ * While a feed is open, build, check, port check and deploy report
47
+ * into it (stage, each check going green, a preview picture) and stop
48
+ * when asked. Without one, nothing changes.
49
+ *
30
50
  * Every command prints a few lines for a person; --json prints the result.
31
51
  */
32
52
  import { spawn, spawnSync } from 'node:child_process';
@@ -36,6 +56,7 @@ import { build } from '../lib/build.mjs';
36
56
  import { check } from '../lib/check.mjs';
37
57
  import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
38
58
  import { publish } from '../lib/directory.mjs';
59
+ import { look } from '../lib/look.mjs';
39
60
  import { importPort, planPort } from '../lib/port.mjs';
40
61
  import { portCheck } from '../lib/port-check.mjs';
41
62
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
@@ -43,10 +64,11 @@ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
43
64
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
44
65
  import { STUDIO_VERSION } from '../lib/version.mjs';
45
66
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
67
+ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
46
68
 
47
69
  const argv = process.argv.slice(2);
48
70
  const flags = new Map();
49
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
71
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
50
72
  const positional = [];
51
73
  for (let i = 0; i < argv.length; i++) {
52
74
  const a = argv[i];
@@ -62,7 +84,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
62
84
 
63
85
  function print(result) {
64
86
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
65
- if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
87
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
66
88
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
67
89
  const lines = [];
68
90
  switch (result.command) {
@@ -81,7 +103,12 @@ function print(result) {
81
103
  case 'build':
82
104
  lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
83
105
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
106
+ for (const l of result.landings ?? []) lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (add a cover or hero/ footage)'}${l.credits ? ', credits' : ''}${l.source ? ', "Make a game like this" with its source' : ''}`);
107
+ if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
108
+ if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
84
109
  for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
110
+ for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
111
+ for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
85
112
  break;
86
113
  case 'media list':
87
114
  for (const kind of ['music', 'videos']) {
@@ -147,6 +174,30 @@ function print(result) {
147
174
  case 'stats share':
148
175
  lines.push(result.message);
149
176
  break;
177
+ case 'progress start':
178
+ lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
179
+ ` feed: ${result.file}`,
180
+ ...(result.shared ? [` shared for the Claude app until ${result.shared.expiresAt ?? 'tomorrow'}: ${result.widget}`] : []),
181
+ ...(result.sharedWhy ? [` ${result.sharedWhy}`] : []),
182
+ ' build, check, port check and deploy now report into it; mark the plan done with: npx --no-install homie-studio progress stage plan done --note "<the plan in one line>"');
183
+ break;
184
+ case 'progress':
185
+ lines.push(result.message ?? `${result.feed?.title}: ${result.feed?.state}${result.feed?.stage ? ` (${result.feed.stage})` : ''}`);
186
+ break;
187
+ case 'progress show': {
188
+ const f = result.feed;
189
+ const mark = { done: 'ok ', pass: 'ok ', running: '... ', failed: 'FAIL', fail: 'FAIL', skipped: 'skip', skip: 'skip', stopped: 'stop', pending: ' ' };
190
+ lines.push(`${f.title} [${f.state}] build ${f.build}${f.shared ? ` (shared as ${f.shared.build})` : ''}`,
191
+ ...f.stages.map((s) => ` ${mark[s.state] ?? s.state} ${s.label}${s.note ? `: ${s.note}` : ''}`),
192
+ ...f.checks.map((c) => ` ${mark[c.state] ?? c.state} ${c.label}${c.note ? `: ${c.note}` : ''}`),
193
+ ...(f.preview?.url ? [` preview: ${f.preview.url}`] : []),
194
+ ` spent ${f.spend.unit === 'usd' ? `$${f.spend.used.toFixed(2)}` : `${f.spend.used} credits`}${f.spend.budget !== null ? ` of ${f.spend.unit === 'usd' ? `$${f.spend.budget.toFixed(2)}` : `${f.spend.budget} credits`}` : ''}`,
195
+ ...(f.stop?.requested ? [` stop asked (${f.stop.by}) at ${f.stop.at}`] : []));
196
+ break;
197
+ }
198
+ case 'look':
199
+ lines.push(`${result.ok ? 'Looks right' : 'Look again'}: ${result.rows.length} views of ${result.url}`, ...result.rows.map((r) => ` ${r.ok ? 'ok ' : 'FIX '} ${r.path} on ${({ computer: 'a computer', phone: 'a phone', sideways: 'a phone turned sideways' })[r.device] ?? r.device}${r.problems.length ? `: ${r.problems.join('; ')}` : ''}`), '', `Pictures (open them and look): ${result.shots}`);
200
+ break;
150
201
  case 'check':
151
202
  lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
152
203
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -170,17 +221,18 @@ async function main() {
170
221
 
171
222
  if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
172
223
  const root = requireStudio();
224
+ if (cmd === 'progress') return progressCommand(root, sub);
173
225
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
174
226
  if (cmd === 'port' && sub === 'check') {
175
227
  const game = positional[2] ?? listGames(root)[0]?.id;
176
228
  const url = flags.get('url') ?? siteUrl(root);
177
229
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
178
- return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
230
+ return tracked(root, 'checks', (report) => portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'port check');
179
231
  }
180
232
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
181
233
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
182
234
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
183
- if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
235
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
184
236
  if (cmd === 'status') {
185
237
  const studio = readStudio(root);
186
238
  return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
@@ -191,10 +243,23 @@ async function main() {
191
243
  const game = positional[1] ?? listGames(root)[0]?.id;
192
244
  const url = flags.get('url') ?? siteUrl(root);
193
245
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
194
- return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
246
+ return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
247
+ }
248
+ if (cmd === 'look') {
249
+ const url = flags.get('url') ?? siteUrl(root);
250
+ if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
251
+ let paths = positional.slice(1).map((p) => (p.startsWith('/') ? p : `/${p}`));
252
+ if (!paths.length) {
253
+ let cat = null;
254
+ try { cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')); } catch { /* not built */ }
255
+ const games = cat?.games?.map((g) => g.id) ?? listGames(root).map((g) => g.id);
256
+ paths = ['/', ...games.map((id) => `/${id}/`), ...(games.length ? ['/games/', '/rooms/'] : []), ...(cat?.posts?.length ? ['/posts/', `/posts/${cat.posts[0].slug}/`] : [])];
257
+ }
258
+ const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
259
+ return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
195
260
  }
196
261
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
197
- if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
262
+ if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
198
263
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
199
264
  if (cmd === 'storage') {
200
265
  const cf = readStudio(root).cloudflare ?? {};
@@ -217,6 +282,94 @@ async function main() {
217
282
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
218
283
  }
219
284
 
285
+ /*
286
+ * THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
287
+ * deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
288
+ * stop asked in the Claude app (or by `progress stop`) ends it at the next safe point. With no open feed, or a
289
+ * feed whose build has no such stage (a song has no deploy), the command runs exactly as it did before.
290
+ */
291
+ async function tracked(root, stage, run, command) {
292
+ const feed = currentFeed(root);
293
+ if (!feed || !feed.doc.stages.some((s) => s.id === stage)) return run(null);
294
+ await feed.sync();
295
+ if (feed.stopRequested()) {
296
+ feed.stage(stage, 'stopped', 'stopped by the person before it started');
297
+ feed.end('stopped', 'Stopped by the person.');
298
+ return { ok: false, command, stopped: true, why: 'stopped by the person (in the Claude app, or `progress stop`): the build is over; ask them before starting again' };
299
+ }
300
+ if (stage === 'checks') feed.resetChecks('checks');
301
+ feed.stage(stage, 'running');
302
+ const report = {
303
+ check: (id, state, opts) => feed.check(id, state, { ...opts, stage }),
304
+ preview: (p) => feed.preview(p),
305
+ stopped: () => feed.stopRequested(),
306
+ state: (id) => feed.doc?.checks.find((c) => c.id === id)?.state,
307
+ };
308
+ let result;
309
+ try { result = await run(report); } catch (error) { feed.stage(stage, 'failed', error instanceof Error ? error.message : String(error)); throw error; }
310
+ if (result?.stopped || feed.stopRequested()) {
311
+ feed.stage(stage, 'stopped', 'stopped by the person');
312
+ feed.end('stopped', 'Stopped by the person.');
313
+ return { ...result, ok: false, stopped: true, why: result?.why ?? 'stopped by the person' };
314
+ }
315
+ if (!result?.ok) { feed.stage(stage, 'failed', result?.why ?? 'failed'); return result; }
316
+ const note = stage === 'build' ? `Built ${(result.games ?? []).map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'the site'}`
317
+ : stage === 'deploy' ? `Live: ${result.url ?? 'deployed'}`
318
+ : result.command === 'port check' ? `${result.passed.length} passed${result.skipped.length ? `, ${result.skipped.length} skipped` : ''} in ${Math.round(result.totalMs / 1000)} s`
319
+ : `Room ${result.room}: round ${result.round?.n} finished with ${result.round?.humans} people in ${Math.round(result.totalMs / 1000)} s`;
320
+ feed.stage(stage, 'done', note);
321
+ if (stage === 'deploy') {
322
+ const doc = feed.doc;
323
+ const game = (result.games ?? []).find((g) => g.id === doc.id) ?? (result.games ?? [])[0];
324
+ if (game?.play) feed.preview({ url: game.play });
325
+ // Deployed, and every check that ran passed: the build is done.
326
+ if (doc.checks.every((c) => ['pass', 'skip'].includes(c.state))) feed.end('passed', `Live: ${game?.play ?? result.url}`);
327
+ }
328
+ return result;
329
+ }
330
+
331
+ async function progressCommand(root, sub) {
332
+ const arg = positional[2];
333
+ if (sub === 'start') {
334
+ return startProgress(root, {
335
+ what: flags.get('what') ?? 'game', id: arg ?? flags.get('id') ?? null, title: flags.get('title'),
336
+ budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
337
+ });
338
+ }
339
+ if (sub === 'show') {
340
+ const id = arg ?? currentId(root) ?? flags.get('build');
341
+ const doc = readFeed(root, id);
342
+ return doc ? { ok: true, command: 'progress show', feed: publicFeed(doc) } : { ok: false, command: 'progress show', why: id ? `no feed ${id} in .studio/progress/` : 'no build is open (homie-studio progress start)' };
343
+ }
344
+ const id = flags.get('build') ?? currentId(root);
345
+ if (!id || !readFeed(root, id)) return { ok: false, command: 'progress', why: 'no build is open: start one with `homie-studio progress start <id> --share`' };
346
+ const feed = new Feed(root, id);
347
+ const done = (message) => ({ ok: true, command: 'progress', build: id, message, feed: publicFeed(feed.doc) });
348
+ const note = flags.has('note') ? String(flags.get('note')) : undefined;
349
+ try {
350
+ switch (sub) {
351
+ case 'stage': feed.stage(arg, positional[3], note); return done(`${arg}: ${positional[3]}`);
352
+ case 'check': feed.check(arg, positional[3], { label: flags.get('label'), note }); return done(`check ${arg}: ${positional[3]}`);
353
+ case 'preview': feed.preview({ url: flags.get('url'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined, caption: flags.get('caption') }); return done('preview updated');
354
+ case 'spend': feed.spend(arg, flags.get('what'), { receipt: flags.get('receipt'), unit: flags.get('unit') }); {
355
+ const s = feed.doc.spend;
356
+ return done(`spent ${s.unit === 'usd' ? `$${s.used.toFixed(2)}` : `${s.used} credits`}${s.budget !== null ? ` of ${s.unit === 'usd' ? `$${s.budget.toFixed(2)}` : `${s.budget} credits`}` : ''}`);
357
+ }
358
+ case 'shot': feed.shot(arg, positional[3], { label: flags.get('label'), image: flags.get('image') ? resolve(String(flags.get('image'))) : undefined }); return done(`shot ${arg}: ${positional[3]}`);
359
+ case 'song': {
360
+ const peaks = flags.get('peaks') ? JSON.parse(readFileSync(resolve(String(flags.get('peaks'))), 'utf8')) : undefined;
361
+ const sung = flags.get('sung') === 'yes' ? true : flags.get('sung') === 'no' ? false : null;
362
+ feed.song({ peaks: Array.isArray(peaks) ? peaks : peaks?.peaks, lyric: flags.get('lyric'), sung });
363
+ return done('song updated');
364
+ }
365
+ case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
366
+ case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
367
+ case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
368
+ default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
369
+ }
370
+ } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
371
+ }
372
+
220
373
  /*
221
374
  * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
222
375
  * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
@@ -301,11 +454,14 @@ function mediaPut(root, file, as) {
301
454
 
302
455
  try {
303
456
  const result = await main();
457
+ // A shared progress feed sends its last word before the command exits.
458
+ await flushProgress();
304
459
  if (result && result.command !== 'help') print(result);
305
460
  if (result?.ok === false) process.exitCode = 1;
306
461
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
307
- if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
462
+ if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
308
463
  } catch (error) {
464
+ await flushProgress().catch(() => {});
309
465
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
310
466
  process.exitCode = 1;
311
467
  }
package/lib/build.mjs CHANGED
@@ -5,8 +5,12 @@
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
- * site/dist/games.json { studio, games[], songs[], videos[] } from studio.json, game.json
9
- * files and the music/ and videos/ manifests (media/MEDIA.md)
8
+ * site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
9
+ * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
10
+ * posts/*.md and the studio's site/ folder (site/SITE.md)
11
+ * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
12
+ * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
13
+ * site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
10
14
  * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
11
15
  *
12
16
  * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
@@ -22,6 +26,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statS
22
26
  import { createRequire } from 'node:module';
23
27
  import { join, relative } from 'node:path';
24
28
  import { buildMedia } from './media.mjs';
29
+ import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
25
30
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
26
31
  import { SEAT_MAX } from '../worker/seats.mjs';
27
32
  import { STUDIO_VERSION } from './version.mjs';
@@ -173,28 +178,49 @@ export async function build(root, { only = null, log = () => {} } = {}) {
173
178
  else {
174
179
  try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
175
180
  }
181
+ // The site around the games (site/SITE.md): the look, each game's landing, posts, and what site/ overrides.
182
+ const theme = readTheme(root, { log });
183
+ const shown = all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html')));
184
+ const rows = shown.map((g) => {
185
+ // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
186
+ const net = netplayOf(g, join(dist, 'games', g.id));
187
+ const { min, max } = seatsFor(g, net);
188
+ return {
189
+ id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
190
+ roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
191
+ ...(g.screen ? { screen: g.screen } : {}),
192
+ landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
193
+ };
194
+ });
195
+ const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
196
+ const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
197
+ mkdirSync(join(dist, '_site'), { recursive: true });
198
+ writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
199
+ const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
176
200
  const catalogue = {
177
201
  studio: {
178
202
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
203
+ ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
204
+ theme,
205
+ site: {
206
+ ...(rows.some((g) => g.id === s.featured) ? { featured: s.featured } : {}),
207
+ ...(Array.isArray(s.frameAncestors) ? { frameAncestors: s.frameAncestors.filter((o) => /^https:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(String(o))).slice(0, 8) } : {}),
208
+ },
179
209
  // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
180
210
  ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
181
211
  },
182
- games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => {
183
- // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
184
- const net = netplayOf(g, join(dist, 'games', g.id));
185
- const { min, max } = seatsFor(g, net);
186
- return {
187
- id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
188
- roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
189
- ...(g.screen ? { screen: g.screen } : {}),
190
- };
191
- }),
212
+ games: rows,
192
213
  songs: media.songs,
193
214
  videos: media.videos,
215
+ // Summaries only; each post's HTML is in _site/posts.json.
216
+ posts: posts.map(({ html, record, ...p }) => p),
217
+ site: { pages: site.pages, partials: site.partials, ...(site.css ? { css: site.css } : {}) },
194
218
  };
195
219
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
196
220
  return {
197
221
  ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
198
222
  songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
223
+ posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
224
+ landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
199
225
  };
200
226
  }
package/lib/check.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  * phone) press Play, land in the SAME public room, play, and both see the round
5
5
  * finish. Exit 0 only when all of that happened. Uses this Mac's Chrome through
6
6
  * puppeteer-core, on the GPU (a software renderer fakes a stuck game).
7
+ *
8
+ * `report` (optional; the open progress feed, lib/progress.mjs) hears each step as it goes green or red, gets a
9
+ * small picture of the computer's view, and can ask the check to stop: then the browsers close and the result
10
+ * says `stopped`. Without it the check is exactly what it was.
7
11
  */
8
12
  import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
9
13
  import { tmpdir } from 'node:os';
@@ -18,7 +22,17 @@ const CHROMES = [
18
22
 
19
23
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
24
 
21
- export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {} }) {
25
+ /** The steps a progress feed shows for this check, in order. */
26
+ export const CHECK_STEPS = [
27
+ ['computer-seated', 'A computer gets a seat'],
28
+ ['phone-seated', 'A phone gets a seat'],
29
+ ['same-room', 'Both in the same public room'],
30
+ ['round', 'They finish a round together'],
31
+ ];
32
+
33
+ class Stopped extends Error {}
34
+
35
+ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
22
36
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
23
37
  const chrome = CHROMES.find((p) => existsSync(p));
24
38
  if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
@@ -35,7 +49,22 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
35
49
  { name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
36
50
  ];
37
51
  const players = [];
52
+ const step = (id, state, opts = {}) => { try { report?.check?.(id, state, { label: CHECK_STEPS.find(([k]) => k === id)?.[1], ...opts }); } catch { /* a feed never breaks a check */ } };
53
+ const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
54
+ // The computer's view, small (480 px wide), for the feed's preview.
55
+ const picture = async (caption) => {
56
+ if (!report?.preview || !players[0]) return;
57
+ try {
58
+ const vp = kinds[0].viewport;
59
+ const b64 = await players[0].page.screenshot({ type: 'jpeg', quality: 62, clip: { x: 0, y: 0, width: vp.width, height: vp.height, scale: 480 / vp.width }, encoding: 'base64' });
60
+ report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption });
61
+ } catch { /* no picture this time */ }
62
+ };
63
+ for (const [id] of CHECK_STEPS) step(id, 'pending');
38
64
  try {
65
+ halt();
66
+ step('computer-seated', 'running');
67
+ step('phone-seated', 'running');
39
68
  for (const kind of kinds) {
40
69
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
41
70
  profiles.push(profile);
@@ -58,15 +87,23 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
58
87
  const deadline = Date.now() + 45_000;
59
88
  while (Date.now() < deadline) {
60
89
  const s = await p.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, players: s.facts?.counts?.players ?? null } : null; }).catch(() => null);
61
- if (s?.room && s.seat !== null && s.seat !== undefined && s.role) { p.seatedAt = Date.now(); p.seat = s.seat; return { ...s, ms: Date.now() - p.t0 }; }
90
+ if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
91
+ p.seatedAt = Date.now(); p.seat = s.seat;
92
+ step(`${p.kind}-seated`, 'pass', { ms: Date.now() - p.t0, note: `seat ${s.seat}, ${s.role}` });
93
+ return { ...s, ms: Date.now() - p.t0 };
94
+ }
95
+ halt();
62
96
  await sleep(250);
63
97
  }
98
+ step(`${p.kind}-seated`, 'fail', { note: 'no seat within 45 s' });
64
99
  return null;
65
100
  }));
66
101
  if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
67
102
  const sameRoom = seated[0].room === seated[1].room;
68
103
  log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
104
+ step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
69
105
  if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
106
+ step('round', 'running');
70
107
  // Play: the computer holds keys, the phone drags from the lower left.
71
108
  const playing = (async () => {
72
109
  const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
@@ -80,10 +117,12 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
80
117
  await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
81
118
  await sleep(300);
82
119
  await players[1].page.touchscreen.touchEnd().catch(() => {});
83
- if (players.every((p) => p.over)) break;
120
+ if (players.every((p) => p.over) || report?.stopped?.()) break;
84
121
  }
85
122
  })();
86
123
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
124
+ await sleep(1500);
125
+ await picture('Playing: computer and phone in one room');
87
126
  // Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
88
127
  // idle room remembers its last round for a while, and that stale result proves nothing.
89
128
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
@@ -97,15 +136,18 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
97
136
  return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
98
137
  }, bothSeated, seats).catch(() => null);
99
138
  if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
139
+ halt();
100
140
  await sleep(500);
101
141
  }
102
142
  return null;
103
143
  }));
104
144
  await playing.catch(() => {});
105
145
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-round-over.png`) }).catch(() => {});
106
- if (over.some((o) => !o)) return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over };
146
+ if (over.some((o) => !o)) { step('round', 'fail', { note: 'no round finished in time' }); return { ok: false, command: 'check', why: 'a browser did not see a round finish in time', seated, over }; }
107
147
  const humans = over[0].results.filter((r) => !r.bot).length;
108
148
  const sameRound = over[0].n === over[1].n && over[0].endsAt === over[1].endsAt && over[0].room === over[1].room;
149
+ step('round', sameRound && humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...over.map((o) => o.ms)), note: `round ${over[0].n}: ${humans} people, ${over[0].results.length - humans} bots` });
150
+ await picture(`Round ${over[0].n} finished with both; the next one is on`);
109
151
  return {
110
152
  ok: sameRoom && sameRound && humans >= 2,
111
153
  command: 'check',
@@ -116,6 +158,10 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
116
158
  why: !sameRound ? 'the two browsers saw different rounds finish' : humans >= 2 ? undefined : `the round's results list ${humans} human(s); both browsers should be in it`,
117
159
  totalMs: Date.now() - started,
118
160
  };
161
+ } catch (error) {
162
+ if (!(error instanceof Stopped)) throw error;
163
+ for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
164
+ return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
119
165
  } finally {
120
166
  for (const b of browsers) await b.close().catch(() => {});
121
167
  for (const p of profiles) rmSync(p, { recursive: true, force: true });