@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/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.7.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.9.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
@@ -19,6 +19,7 @@ npx homie-studio deploy --plan # what deploy will create
19
19
  npx homie-studio deploy # the studio's own Cloudflare
20
20
  npx homie-studio publish # the homie.rocks directory
21
21
  npx homie-studio stats # the studio's own numbers, for its owner
22
+ npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
22
23
  ```
23
24
 
24
25
  ## The site: the hub's shape, and a landing for every game
@@ -33,8 +34,30 @@ live rooms, credits (the original and its licence for a port), and "Make a game
33
34
  files. `site/SITE.md` says all of it.
34
35
 
35
36
  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.
37
+ screen and the room code. game.json's `screen.share` puts it where each game has room, per device (a corner or
38
+ the top's middle, moved in by `x` / `y`, or kept a small icon), so it never covers a scoreboard. A game whose
39
+ picture is white or cream gets a light landing (`landing.scheme: "light"`). Every card and the directory show a
40
+ game's landing still. Every HTML answer is `no-transform`, and the site's pages are never framed by another
41
+ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__homie/call`) is told
42
+ `not-a-homie`, at the site's root as under the game.
43
+
44
+ ## Upgrading a studio
45
+
46
+ A studio pins one version, so nothing changes until it asks. To take a newer one:
47
+
48
+ ```sh
49
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade # the plan; changes nothing
50
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.9.0.tgz homie-studio upgrade --apply # after the person agrees
51
+ npm install && npm run build
52
+ ```
53
+
54
+ `upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
55
+ a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
56
+ older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
57
+ the newer text. Anything the studio changed or wrote itself is kept as it is: the plan lists it, and `--diff`
58
+ shows how it differs from the template. `--apply` also pins this version in package.json and sets studio.json's
59
+ `homie.studio`. When the template's words change, `node scripts/studio-template-history.mjs` records them (a test
60
+ fails until it has).
38
61
 
39
62
  ## Rooms of up to 32
40
63
 
@@ -64,6 +87,34 @@ npx homie-studio stats share on # tell the directory "pl
64
87
  Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
65
88
  studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
66
89
 
90
+ ## A build the person can watch (the progress feed)
91
+
92
+ A build of a game, song or video can keep a small progress feed, so the person follows it
93
+ from wherever they are (the Claude app on a phone, through the Homie MCP's build progress
94
+ card), sees each check go green, what it spent against its budget, and can press Stop:
95
+
96
+ ```sh
97
+ npx homie-studio progress start crown-thief --share --title "Crown Thief: faster rounds" --budget 2
98
+ npx homie-studio progress stage plan done --note "Rounds from 60 s to 45 s"
99
+ npm run build && npx homie-studio check crown-thief --url http://127.0.0.1:8787 && npm run deploy
100
+ ```
101
+
102
+ While a feed is open, `build`, `check`, `port check` and `deploy` report into it by
103
+ themselves: their stage (a game: plan → build → checks → deploy), each check as it runs
104
+ and passes, a small picture of the game and the address to play it; a deploy with every
105
+ check green ends the build. The AI marks what only it knows with `progress stage`,
106
+ `progress check`, `progress spend`, `progress shot` (a video's shots) and `progress song`
107
+ (a song's waveform and lyric check). Before each stage starts, and every few seconds while
108
+ one runs, a shared feed asks whether Stop was pressed; a stopped command closes its
109
+ browsers and says so, and nothing already built or deployed is undone.
110
+
111
+ The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
112
+ directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
113
+ hours so the Claude app can show it: the feed only (plain bounded text, pictures under
114
+ 96 KB), never a key, a path or code. Its write key stays in `.studio/progress/` and is
115
+ never printed; the shared id is printed for the MCP tool `build_progress`. Without an open
116
+ feed every command behaves exactly as before. `lib/progress.mjs` has the whole format.
117
+
67
118
  ## The site's address
68
119
 
69
120
  `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
@@ -107,7 +158,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
107
158
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
108
159
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
109
160
  | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
110
- | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
161
+ | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. |
162
+ | `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
111
163
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
112
164
  | `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. |
113
165
  | `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`. |
@@ -23,12 +23,34 @@
23
23
  * homie-studio media put <file> [--as <key>]
24
24
  * homie-studio media list
25
25
  * homie-studio status
26
+ * homie-studio upgrade [--apply] [--diff]
27
+ * (what this version's template adds to an existing studio: AGENTS.md
28
+ * sections, READMEs, .gitignore lines, the pin; changes nothing until
29
+ * --apply, and never a file or section the studio changed)
26
30
  * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
27
31
  * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
28
32
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
29
33
  * homie-studio stats revoke (every stats key and page session ends)
30
34
  * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
31
35
  *
36
+ * homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
37
+ * [--budget <dollars>] [--unit usd|credits] [--share]
38
+ * (a progress feed for one build; --share shows it in the Claude app
39
+ * through the Homie MCP's build_progress widget, which can press Stop)
40
+ * homie-studio progress stage <stage> running|done|failed|skipped [--note "<one line>"]
41
+ * homie-studio progress check <id> pending|running|pass|fail|skip [--label "<words>"] [--note "<one line>"]
42
+ * homie-studio progress preview [--url <address>] [--image <small .jpg/.png>] [--caption "<words>"]
43
+ * homie-studio progress spend <amount> --what "<what it bought>" [--receipt <file>]
44
+ * homie-studio progress shot <id> pending|running|pass|fail [--label "<words>"] [--image <small .jpg>]
45
+ * homie-studio progress song [--peaks <peaks.json>] [--lyric "<line>" --sung yes|no]
46
+ * homie-studio progress log "<one line>"
47
+ * homie-studio progress stop (ask the running build to stop at its next safe point)
48
+ * homie-studio progress end passed|failed|stopped [--note "<one line>"]
49
+ * homie-studio progress show [<build>]
50
+ * While a feed is open, build, check, port check and deploy report
51
+ * into it (stage, each check going green, a preview picture) and stop
52
+ * when asked. Without one, nothing changes.
53
+ *
32
54
  * Every command prints a few lines for a person; --json prints the result.
33
55
  */
34
56
  import { spawn, spawnSync } from 'node:child_process';
@@ -43,13 +65,15 @@ import { importPort, planPort } from '../lib/port.mjs';
43
65
  import { portCheck } from '../lib/port-check.mjs';
44
66
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
45
67
  import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
68
+ import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
46
69
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
47
70
  import { STUDIO_VERSION } from '../lib/version.mjs';
48
71
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
72
+ import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
49
73
 
50
74
  const argv = process.argv.slice(2);
51
75
  const flags = new Map();
52
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
76
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff'];
53
77
  const positional = [];
54
78
  for (let i = 0; i < argv.length; i++) {
55
79
  const a = argv[i];
@@ -155,9 +179,57 @@ function print(result) {
155
179
  case 'stats share':
156
180
  lines.push(result.message);
157
181
  break;
182
+ case 'progress start':
183
+ lines.push(`Build ${result.build}: ${result.title} (${result.stages.join(' → ')})${result.budget !== null ? `, budget ${result.unit === 'usd' ? `$${result.budget.toFixed(2)}` : `${result.budget} credits`}` : ''}`,
184
+ ` feed: ${result.file}`,
185
+ ...(result.shared ? [` shared for the Claude app until ${result.shared.expiresAt ?? 'tomorrow'}: ${result.widget}`] : []),
186
+ ...(result.sharedWhy ? [` ${result.sharedWhy}`] : []),
187
+ ' 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>"');
188
+ break;
189
+ case 'progress':
190
+ lines.push(result.message ?? `${result.feed?.title}: ${result.feed?.state}${result.feed?.stage ? ` (${result.feed.stage})` : ''}`);
191
+ break;
192
+ case 'progress show': {
193
+ const f = result.feed;
194
+ const mark = { done: 'ok ', pass: 'ok ', running: '... ', failed: 'FAIL', fail: 'FAIL', skipped: 'skip', skip: 'skip', stopped: 'stop', pending: ' ' };
195
+ lines.push(`${f.title} [${f.state}] build ${f.build}${f.shared ? ` (shared as ${f.shared.build})` : ''}`,
196
+ ...f.stages.map((s) => ` ${mark[s.state] ?? s.state} ${s.label}${s.note ? `: ${s.note}` : ''}`),
197
+ ...f.checks.map((c) => ` ${mark[c.state] ?? c.state} ${c.label}${c.note ? `: ${c.note}` : ''}`),
198
+ ...(f.preview?.url ? [` preview: ${f.preview.url}`] : []),
199
+ ` 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`}` : ''}`,
200
+ ...(f.stop?.requested ? [` stop asked (${f.stop.by}) at ${f.stop.at}`] : []));
201
+ break;
202
+ }
158
203
  case 'look':
159
204
  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}`);
160
205
  break;
206
+ case 'upgrade': {
207
+ const show = (text, n = 14) => { const l = String(text ?? '').replace(/\n+$/, '').split('\n'); return [...l.slice(0, n).map((x) => ` ${x}`), ...(l.length > n ? [` … (${l.length - n} more lines; --json has all of it)`] : [])]; };
208
+ const mark = { 'add-file': '+', 'add-section': '+', 'add-lines': '+', scripts: '+', 'update-file': '~', 'update-section': '~', pin: '~', version: '~' };
209
+ const list = result.applied ? result.done : result.changes;
210
+ lines.push(result.applied
211
+ ? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
212
+ : list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
213
+ if (list.length) lines.push('');
214
+ for (const c of list) {
215
+ lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
216
+ if (result.applied) continue;
217
+ if (c.kind === 'add-section' || (c.kind === 'add-file' && c.file.endsWith('.md'))) lines.push(...show(c.text));
218
+ if (c.kind === 'update-section' || c.kind === 'update-file') lines.push(...lineDiff(c.from, c.text).map((x) => ` ${x}`));
219
+ }
220
+ for (const c of result.skipped ?? []) lines.push(` ! ${c.file.padEnd(18)} not applied: ${c.why}`);
221
+ if (result.kept?.length) {
222
+ lines.push('', 'Kept as this studio wrote them (never changed by upgrade; the template\'s text differs):');
223
+ for (const k of result.kept) {
224
+ lines.push(` = ${k.file.padEnd(18)} ${k.what}`);
225
+ if (flags.has('diff') && k.mine !== undefined) lines.push(...lineDiff(k.mine, k.template).map((x) => ` ${x}`));
226
+ }
227
+ if (!flags.has('diff')) lines.push(' (--diff shows how each differs from the template: "-" this studio\'s lines, "+" the template\'s)');
228
+ }
229
+ if (result.uncommitted && !result.applied && result.changes.length) lines.push('', `Note: ${result.uncommitted} file(s) have uncommitted changes; commit them first so the upgrade is a change of its own.`);
230
+ if (result.next?.length) lines.push('', 'Next:', ...result.next.map((n) => ` ${n}`));
231
+ break;
232
+ }
161
233
  case 'check':
162
234
  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.`,
163
235
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -181,28 +253,33 @@ async function main() {
181
253
 
182
254
  if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
183
255
  const root = requireStudio();
256
+ if (cmd === 'progress') return progressCommand(root, sub);
184
257
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
185
258
  if (cmd === 'port' && sub === 'check') {
186
259
  const game = positional[2] ?? listGames(root)[0]?.id;
187
260
  const url = flags.get('url') ?? siteUrl(root);
188
261
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
189
- return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
262
+ 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');
190
263
  }
191
264
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
192
265
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
193
266
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
194
- if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
267
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
195
268
  if (cmd === 'status') {
196
269
  const studio = readStudio(root);
197
270
  return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
198
271
  }
272
+ if (cmd === 'upgrade') {
273
+ const plan = upgradePlan(root);
274
+ return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
275
+ }
199
276
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
200
277
  if (cmd === 'dev') return dev(root);
201
278
  if (cmd === 'check') {
202
279
  const game = positional[1] ?? listGames(root)[0]?.id;
203
280
  const url = flags.get('url') ?? siteUrl(root);
204
281
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
205
- return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
282
+ return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
206
283
  }
207
284
  if (cmd === 'look') {
208
285
  const url = flags.get('url') ?? siteUrl(root);
@@ -218,7 +295,7 @@ async function main() {
218
295
  return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
219
296
  }
220
297
  if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
221
- if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
298
+ if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
222
299
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
223
300
  if (cmd === 'storage') {
224
301
  const cf = readStudio(root).cloudflare ?? {};
@@ -241,6 +318,94 @@ async function main() {
241
318
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
242
319
  }
243
320
 
321
+ /*
322
+ * THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
323
+ * deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
324
+ * stop asked in the Claude app (or by `progress stop`) ends it at the next safe point. With no open feed, or a
325
+ * feed whose build has no such stage (a song has no deploy), the command runs exactly as it did before.
326
+ */
327
+ async function tracked(root, stage, run, command) {
328
+ const feed = currentFeed(root);
329
+ if (!feed || !feed.doc.stages.some((s) => s.id === stage)) return run(null);
330
+ await feed.sync();
331
+ if (feed.stopRequested()) {
332
+ feed.stage(stage, 'stopped', 'stopped by the person before it started');
333
+ feed.end('stopped', 'Stopped by the person.');
334
+ 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' };
335
+ }
336
+ if (stage === 'checks') feed.resetChecks('checks');
337
+ feed.stage(stage, 'running');
338
+ const report = {
339
+ check: (id, state, opts) => feed.check(id, state, { ...opts, stage }),
340
+ preview: (p) => feed.preview(p),
341
+ stopped: () => feed.stopRequested(),
342
+ state: (id) => feed.doc?.checks.find((c) => c.id === id)?.state,
343
+ };
344
+ let result;
345
+ try { result = await run(report); } catch (error) { feed.stage(stage, 'failed', error instanceof Error ? error.message : String(error)); throw error; }
346
+ if (result?.stopped || feed.stopRequested()) {
347
+ feed.stage(stage, 'stopped', 'stopped by the person');
348
+ feed.end('stopped', 'Stopped by the person.');
349
+ return { ...result, ok: false, stopped: true, why: result?.why ?? 'stopped by the person' };
350
+ }
351
+ if (!result?.ok) { feed.stage(stage, 'failed', result?.why ?? 'failed'); return result; }
352
+ const note = stage === 'build' ? `Built ${(result.games ?? []).map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'the site'}`
353
+ : stage === 'deploy' ? `Live: ${result.url ?? 'deployed'}`
354
+ : result.command === 'port check' ? `${result.passed.length} passed${result.skipped.length ? `, ${result.skipped.length} skipped` : ''} in ${Math.round(result.totalMs / 1000)} s`
355
+ : `Room ${result.room}: round ${result.round?.n} finished with ${result.round?.humans} people in ${Math.round(result.totalMs / 1000)} s`;
356
+ feed.stage(stage, 'done', note);
357
+ if (stage === 'deploy') {
358
+ const doc = feed.doc;
359
+ const game = (result.games ?? []).find((g) => g.id === doc.id) ?? (result.games ?? [])[0];
360
+ if (game?.play) feed.preview({ url: game.play });
361
+ // Deployed, and every check that ran passed: the build is done.
362
+ if (doc.checks.every((c) => ['pass', 'skip'].includes(c.state))) feed.end('passed', `Live: ${game?.play ?? result.url}`);
363
+ }
364
+ return result;
365
+ }
366
+
367
+ async function progressCommand(root, sub) {
368
+ const arg = positional[2];
369
+ if (sub === 'start') {
370
+ return startProgress(root, {
371
+ what: flags.get('what') ?? 'game', id: arg ?? flags.get('id') ?? null, title: flags.get('title'),
372
+ budget: flags.get('budget'), unit: flags.get('unit'), share: flags.has('share'), directory: flags.get('homie'),
373
+ });
374
+ }
375
+ if (sub === 'show') {
376
+ const id = arg ?? currentId(root) ?? flags.get('build');
377
+ const doc = readFeed(root, id);
378
+ 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)' };
379
+ }
380
+ const id = flags.get('build') ?? currentId(root);
381
+ if (!id || !readFeed(root, id)) return { ok: false, command: 'progress', why: 'no build is open: start one with `homie-studio progress start <id> --share`' };
382
+ const feed = new Feed(root, id);
383
+ const done = (message) => ({ ok: true, command: 'progress', build: id, message, feed: publicFeed(feed.doc) });
384
+ const note = flags.has('note') ? String(flags.get('note')) : undefined;
385
+ try {
386
+ switch (sub) {
387
+ case 'stage': feed.stage(arg, positional[3], note); return done(`${arg}: ${positional[3]}`);
388
+ case 'check': feed.check(arg, positional[3], { label: flags.get('label'), note }); return done(`check ${arg}: ${positional[3]}`);
389
+ 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');
390
+ case 'spend': feed.spend(arg, flags.get('what'), { receipt: flags.get('receipt'), unit: flags.get('unit') }); {
391
+ const s = feed.doc.spend;
392
+ 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`}` : ''}`);
393
+ }
394
+ 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]}`);
395
+ case 'song': {
396
+ const peaks = flags.get('peaks') ? JSON.parse(readFileSync(resolve(String(flags.get('peaks'))), 'utf8')) : undefined;
397
+ const sung = flags.get('sung') === 'yes' ? true : flags.get('sung') === 'no' ? false : null;
398
+ feed.song({ peaks: Array.isArray(peaks) ? peaks : peaks?.peaks, lyric: flags.get('lyric'), sung });
399
+ return done('song updated');
400
+ }
401
+ case 'log': feed.log(positional.slice(2).join(' ')); return done('logged');
402
+ case 'stop': feed.stop('local'); return done('stop asked: the running command stops at its next safe point');
403
+ case 'end': feed.end(arg, note); return done(`ended: ${arg}`);
404
+ default: return { ok: false, command: 'progress', why: `unknown: progress ${sub ?? ''} (start, stage, check, preview, spend, shot, song, log, stop, end, show)` };
405
+ }
406
+ } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
407
+ }
408
+
244
409
  /*
245
410
  * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
246
411
  * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
@@ -325,11 +490,14 @@ function mediaPut(root, file, as) {
325
490
 
326
491
  try {
327
492
  const result = await main();
493
+ // A shared progress feed sends its last word before the command exits.
494
+ await flushProgress();
328
495
  if (result && result.command !== 'help') print(result);
329
496
  if (result?.ok === false) process.exitCode = 1;
330
497
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
331
498
  if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
332
499
  } catch (error) {
500
+ await flushProgress().catch(() => {});
333
501
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
334
502
  process.exitCode = 1;
335
503
  }
package/lib/build.mjs CHANGED
@@ -208,6 +208,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
208
208
  },
209
209
  // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
210
210
  ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
211
+ // studio.json `"rooms": { "share": false }`: the manifest names no rooms, so the hub shows none of this studio's.
212
+ ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
211
213
  },
212
214
  games: rows,
213
215
  songs: media.songs,
package/lib/check.mjs CHANGED
@@ -4,9 +4,20 @@
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.
11
+ *
12
+ * UNDER LOAD (a busy computer, another project's browsers): Chrome gets 150 s to start, a page 90 s to open and a
13
+ * seat 90 s. A browser that stalls for 10 s is let go by the relay and comes back with its seat token; the round it
14
+ * was away for lists its body as a bot, so that round cannot prove anything and the check waits for the next one
15
+ * (up to `rounds` rounds). A person the game lists as a bot while it keeps their seat (an idle player driven by
16
+ * the game's autopilot) is still that person. When no round counts, the result says which seat was missing from
17
+ * each finished round and whether that browser lost its connection, instead of only "no round finished".
7
18
  */
8
19
  import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
20
+ import { cpus, loadavg, tmpdir } from 'node:os';
10
21
  import { join } from 'node:path';
11
22
 
12
23
  const CHROMES = [
@@ -18,7 +29,39 @@ const CHROMES = [
18
29
 
19
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
31
 
21
- export async function check({ url, game, roundTimeoutMs = 150_000, shots = null, userAgentTag = 'homie-studio-check', log = () => {} }) {
32
+ /** The steps a progress feed shows for this check, in order. */
33
+ /** Chrome's own start, on a loaded computer (a cold start there took over a minute). */
34
+ export const LAUNCH_TIMEOUT_MS = 150_000;
35
+
36
+ export const CHECK_STEPS = [
37
+ ['computer-seated', 'A computer gets a seat'],
38
+ ['phone-seated', 'A phone gets a seat'],
39
+ ['same-room', 'Both in the same public room'],
40
+ ['round', 'They finish a round together'],
41
+ ];
42
+
43
+ class Stopped extends Error {}
44
+
45
+ /**
46
+ * WHICH FINISHED ROUND PROVES THE TWO BROWSERS PLAYED TOGETHER. `browsers`: each `{ kind, seats, seen }`, where
47
+ * `seats` is every seat it held (a Set) and `seen` the rounds it saw finish after both were seated (a Map from
48
+ * `n:endsAt` to `{ n, endsAt, results, ms }`). A round counts when both saw it and each has a row carrying one of
49
+ * its seats, whatever the row's `bot` says (a game may hand an idle person's body to its autopilot and still keep
50
+ * their seat on the row: that is still the person). A round seen by both without one of them is a miss, kept in
51
+ * `missed` with who was missing and the rows, so the check can say why. Returns the round that counts, or null.
52
+ */
53
+ export function judgeRounds(browsers, missed = new Map()) {
54
+ const mine = new Set(browsers.flatMap((b) => [...b.seats]));
55
+ for (const [key, r] of browsers[0]?.seen ?? []) {
56
+ if (missed.has(key) || !browsers.every((b) => b.seen.has(key))) continue;
57
+ const absent = browsers.filter((b) => !(r.results ?? []).some((row) => b.seats.has(row?.seat))).map((b) => b.kind);
58
+ if (!absent.length) return { ...r, key, overMs: browsers.map((b) => b.seen.get(key).ms), people: r.results.filter((row) => !row?.bot || mine.has(row?.seat)).length };
59
+ missed.set(key, { n: r.n, missing: absent, rows: (r.results ?? []).slice(0, 12).map((row) => ({ seat: row?.seat ?? null, name: row?.name ?? null, bot: Boolean(row?.bot) })) });
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
22
65
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
23
66
  const chrome = CHROMES.find((p) => existsSync(p));
24
67
  if (!chrome) return { ok: false, command: 'check', why: 'no Chrome found (set CHROME_PATH)' };
@@ -35,12 +78,29 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
35
78
  { name: 'phone', viewport: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 } },
36
79
  ];
37
80
  const players = [];
81
+ // The playing loop runs until this is false: the round is settled, or the check ends in any way (finally).
82
+ let playingOn = true;
83
+ 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 */ } };
84
+ const halt = () => { if (report?.stopped?.()) throw new Stopped('stopped by the person'); };
85
+ // The computer's view, small (480 px wide), for the feed's preview.
86
+ const picture = async (caption) => {
87
+ if (!report?.preview || !players[0]) return;
88
+ try {
89
+ const vp = kinds[0].viewport;
90
+ 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' });
91
+ report.preview({ url: play, image: `data:image/jpeg;base64,${b64}`, caption });
92
+ } catch { /* no picture this time */ }
93
+ };
94
+ for (const [id] of CHECK_STEPS) step(id, 'pending');
38
95
  try {
96
+ halt();
97
+ step('computer-seated', 'running');
98
+ step('phone-seated', 'running');
39
99
  for (const kind of kinds) {
40
100
  const profile = mkdtempSync(join(tmpdir(), 'homie-studio-check-'));
41
101
  profiles.push(profile);
42
102
  const browser = await puppeteer.launch({
43
- executablePath: chrome, headless: true, userDataDir: profile,
103
+ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, protocolTimeout: 180_000,
44
104
  args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
45
105
  });
46
106
  browsers.push(browser);
@@ -49,74 +109,119 @@ export async function check({ url, game, roundTimeoutMs = 150_000, shots = null,
49
109
  const ua = await browser.userAgent();
50
110
  await page.setUserAgent(`${ua} ${userAgentTag}`);
51
111
  const t0 = Date.now();
52
- await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 60_000 });
112
+ await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 });
53
113
  players.push({ kind: kind.name, page, t0 });
54
114
  log(`${kind.name} opened ${play}`);
55
115
  }
56
116
  // Seated: the shell knows its room and the game reported a seat.
57
117
  const seated = await Promise.all(players.map(async (p) => {
58
- const deadline = Date.now() + 45_000;
118
+ const deadline = Date.now() + 90_000;
59
119
  while (Date.now() < deadline) {
60
120
  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 }; }
121
+ if (s?.room && s.seat !== null && s.seat !== undefined && s.role) {
122
+ p.seatedAt = Date.now(); p.seat = s.seat;
123
+ step(`${p.kind}-seated`, 'pass', { ms: Date.now() - p.t0, note: `seat ${s.seat}, ${s.role}` });
124
+ return { ...s, ms: Date.now() - p.t0 };
125
+ }
126
+ halt();
62
127
  await sleep(250);
63
128
  }
129
+ step(`${p.kind}-seated`, 'fail', { note: 'no seat within 90 s' });
64
130
  return null;
65
131
  }));
66
- if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 45 s', seated };
132
+ if (seated.some((s) => !s)) return { ok: false, command: 'check', why: 'a browser never got a seat within 90 s', seated };
67
133
  const sameRoom = seated[0].room === seated[1].room;
68
134
  log(`seated: ${seated.map((s, i) => `${kinds[i].name} room ${s.room} seat ${s.seat} ${s.role}`).join('; ')}`);
135
+ step('same-room', sameRoom ? 'pass' : 'fail', { note: sameRoom ? `room ${seated[0].room}` : `rooms ${seated[0].room} and ${seated[1].room}` });
69
136
  if (!sameRoom) return { ok: false, command: 'check', why: `the two browsers landed in different rooms (${seated[0].room}, ${seated[1].room})`, seated };
70
- // Play: the computer holds keys, the phone drags from the lower left.
137
+ step('round', 'running');
138
+ // Play: the computer holds keys; the phone drags from the lower left (a stick) and swipes across the bottom
139
+ // middle (a wheel, a slider). A game that hands an untouched player to its autopilot, or lets an idle one go,
140
+ // sees a hand on both. It plays until the check is over, through as many rounds as that takes.
71
141
  const playing = (async () => {
72
142
  const keys = ['KeyD', 'KeyS', 'KeyA', 'KeyW'];
73
- for (let i = 0; Date.now() - started < roundTimeoutMs && i < 400; i++) {
143
+ const vp = kinds[1].viewport;
144
+ for (let i = 0; playingOn && i < 5000; i++) {
74
145
  const k = keys[i % keys.length];
75
146
  await players[0].page.keyboard.down(k).catch(() => {});
76
147
  await sleep(600);
77
148
  await players[0].page.keyboard.up(k).catch(() => {});
78
- const vp = kinds[1].viewport;
79
- await players[1].page.touchscreen.touchStart(vp.width * 0.2, vp.height * 0.8).catch(() => {});
80
- await players[1].page.touchscreen.touchMove(vp.width * 0.2 + (i % 2 ? 60 : -60), vp.height * 0.8 - 40).catch(() => {});
149
+ const [x, y, dx, dy] = i % 3 === 2 ? [vp.width * 0.5, vp.height * 0.86, i % 2 ? 70 : -70, 0] : [vp.width * 0.2, vp.height * 0.8, i % 2 ? 60 : -60, -40];
150
+ await players[1].page.touchscreen.touchStart(x, y).catch(() => {});
151
+ await players[1].page.touchscreen.touchMove(x + dx, y + dy).catch(() => {});
81
152
  await sleep(300);
82
153
  await players[1].page.touchscreen.touchEnd().catch(() => {});
83
- if (players.every((p) => p.over)) break;
154
+ if (report?.stopped?.()) break;
84
155
  }
85
156
  })();
86
157
  if (shots) for (const [i, p] of players.entries()) await p.page.screenshot({ path: join(shots, `${kinds[i].name}-playing.png`) }).catch(() => {});
87
- // Only a round that finishes AFTER both browsers were seated, with BOTH of them in its results, counts: an
88
- // idle room remembers its last round for a while, and that stale result proves nothing.
158
+ await sleep(1500);
159
+ await picture('Playing: computer and phone in one room');
160
+ // Only a round that finishes AFTER both browsers were seated, seen by both, with BOTH of them in its results,
161
+ // counts: an idle room remembers its last round for a while, and that stale result proves nothing. A browser is
162
+ // in a round when a row carries a seat it held (a reconnect keeps its seat; a game may still mark an idle
163
+ // person's row as a bot, and that is still the person). A round finished without one of them is a miss: its
164
+ // cause is kept, and the check waits for the next round, up to `rounds` of them.
89
165
  const bothSeated = Math.max(...players.map((p) => p.seatedAt));
90
- const seats = players.map((p) => p.seat);
91
- const over = await Promise.all(players.map(async (p) => {
92
- const deadline = Date.now() + roundTimeoutMs;
93
- while (Date.now() < deadline) {
94
- const r = await p.page.evaluate((after, want) => {
95
- const s = window.__shell;
96
- const hit = (s?.results ?? []).find((x) => x.at > after && want.every((seat) => x.results.some((row) => row.seat === seat)));
97
- return hit ? { n: hit.n, endsAt: hit.endsAt, results: hit.results, room: s.room } : null;
98
- }, bothSeated, seats).catch(() => null);
99
- if (r) { p.over = true; return { ...r, ms: Date.now() - p.t0 }; }
100
- await sleep(500);
166
+ for (const p of players) { p.seats = new Set([p.seat]); p.reconnects = null; p.reconnects0 = null; p.closed = null; p.seen = new Map(); }
167
+ const t1 = Date.now();
168
+ const budget = roundTimeoutMs * Math.max(1, rounds);
169
+ const missed = new Map();
170
+ let hit = null;
171
+ while (!hit && Date.now() - t1 < budget && missed.size < Math.max(1, rounds)) {
172
+ for (const p of players) {
173
+ const s = await p.page.evaluate(() => {
174
+ const sh = window.__shell;
175
+ return sh ? { seat: sh.seat, room: sh.room, closed: sh.closed, reconnects: sh.stats?.reconnects ?? null, results: (sh.results ?? []).map((x) => ({ n: x.n, endsAt: x.endsAt, at: x.at, results: x.results })) } : null;
176
+ }).catch(() => null);
177
+ if (!s) continue;
178
+ if (Number.isInteger(s.seat)) p.seats.add(s.seat);
179
+ if (Number.isFinite(s.reconnects)) { p.reconnects0 ??= s.reconnects; p.reconnects = s.reconnects; }
180
+ if (s.closed) p.closed = s.closed;
181
+ for (const r of s.results) if (r.at > bothSeated && Array.isArray(r.results) && !p.seen.has(`${r.n}:${r.endsAt}`)) p.seen.set(`${r.n}:${r.endsAt}`, { ...r, room: s.room, ms: Date.now() - p.t0 });
101
182
  }
102
- return null;
103
- }));
183
+ const before = missed.size;
184
+ hit = judgeRounds(players, missed);
185
+ for (const m of [...missed.values()].slice(before)) log(`round ${m.n} finished without the ${m.missing.join(' and the ')}: waiting for the next one`);
186
+ if (hit) break;
187
+ halt();
188
+ await sleep(700);
189
+ }
190
+ playingOn = false;
104
191
  await playing.catch(() => {});
105
192
  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 };
107
- const humans = over[0].results.filter((r) => !r.bot).length;
108
- const sameRound = over[0].n === over[1].n && over[0].endsAt === over[1].endsAt && over[0].room === over[1].room;
193
+ const connection = players.map((p) => ({ browser: p.kind, seats: [...p.seats], reconnects: p.reconnects !== null && p.reconnects0 !== null ? p.reconnects - p.reconnects0 : null, closed: p.closed }));
194
+ const load = { perCore: +(loadavg()[0] / Math.max(1, cpus().length)).toFixed(2), average: +loadavg()[0].toFixed(1), cores: cpus().length };
195
+ if (!hit) {
196
+ const misses = [...missed.values()];
197
+ const dropped = connection.filter((c) => c.reconnects > 0).map((c) => `the ${c.browser} lost its connection ${c.reconnects} time${c.reconnects === 1 ? '' : 's'}`);
198
+ const why = misses.length
199
+ ? `${misses.length} round${misses.length === 1 ? '' : 's'} finished, and none had both browsers in it: ${misses.map((m) => `round ${m.n} left out the ${m.missing.join(' and the ')}`).join('; ')}.${dropped.length ? ` ${dropped.join(', ')} (a browser silent for 10 s is let go by the relay and its body plays as a bot until it is back).` : ' The game listed that seat\'s body as a bot with no seat: it may hand an idle player to a bot.'}${load.perCore > 1.5 ? ` This computer is busy (load ${load.average} on ${load.cores} cores): run the check again when it is quieter.` : ''}`
200
+ : `no round finished within ${Math.round(budget / 1000)} s of both browsers being seated${load.perCore > 1.5 ? ` (this computer is busy: load ${load.average} on ${load.cores} cores)` : ''}`;
201
+ step('round', 'fail', { note: misses.length ? `${misses.length} round(s) without both` : 'no round finished in time' });
202
+ return { ok: false, command: 'check', why, play, room: seated[0].room, seated, missed: misses, connection, load, totalMs: Date.now() - started };
203
+ }
204
+ const humans = hit.people;
205
+ step('round', humans >= 2 ? 'pass' : 'fail', { ms: Math.max(...hit.overMs), note: `round ${hit.n}: ${humans} people, ${hit.results.length - humans} bots${missed.size ? ` (after ${missed.size} round(s) without both)` : ''}` });
206
+ await picture(`Round ${hit.n} finished with both; the next one is on`);
109
207
  return {
110
- ok: sameRoom && sameRound && humans >= 2,
208
+ ok: sameRoom && humans >= 2,
111
209
  command: 'check',
112
210
  play,
113
211
  room: seated[0].room,
114
212
  seats: seated.map((s, i) => ({ browser: kinds[i].name, seat: s.seat, role: s.role, seatedMs: s.ms })),
115
- round: { n: over[0].n, humans, bots: over[0].results.length - humans, results: over[0].results, overMs: over.map((o) => o.ms) },
116
- 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`,
213
+ round: { n: hit.n, humans, bots: hit.results.length - humans, results: hit.results, overMs: hit.overMs },
214
+ ...(missed.size ? { missed: [...missed.values()] } : {}),
215
+ connection,
216
+ why: humans >= 2 ? undefined : `the round's results list ${humans} person(s); both browsers should be in it`,
117
217
  totalMs: Date.now() - started,
118
218
  };
219
+ } catch (error) {
220
+ if (!(error instanceof Stopped)) throw error;
221
+ for (const [id] of CHECK_STEPS) { try { if (['pending', 'running'].includes(report?.state?.(id))) step(id, 'skip', { note: 'stopped' }); } catch { /* fine */ } }
222
+ return { ok: false, command: 'check', stopped: true, why: 'stopped by the person', play, totalMs: Date.now() - started };
119
223
  } finally {
224
+ playingOn = false;
120
225
  for (const b of browsers) await b.close().catch(() => {});
121
226
  for (const p of profiles) rmSync(p, { recursive: true, force: true });
122
227
  }