@homie-rocks/studio 0.7.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 +29 -1
- package/bin/homie-studio.mjs +137 -5
- package/lib/check.mjs +50 -4
- package/lib/port-check.mjs +26 -4
- package/lib/progress.mjs +420 -0
- package/package.json +1 -1
- package/worker/version.mjs +1 -1
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.
|
|
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
|
|
@@ -64,6 +64,34 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
64
64
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
65
65
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
66
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
|
+
|
|
67
95
|
## The site's address
|
|
68
96
|
|
|
69
97
|
`deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -29,6 +29,24 @@
|
|
|
29
29
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
30
30
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
31
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
|
+
*
|
|
32
50
|
* Every command prints a few lines for a person; --json prints the result.
|
|
33
51
|
*/
|
|
34
52
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -46,10 +64,11 @@ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
|
|
|
46
64
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
|
|
47
65
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
48
66
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
67
|
+
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, startProgress } from '../lib/progress.mjs';
|
|
49
68
|
|
|
50
69
|
const argv = process.argv.slice(2);
|
|
51
70
|
const flags = new Map();
|
|
52
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
|
|
71
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share'];
|
|
53
72
|
const positional = [];
|
|
54
73
|
for (let i = 0; i < argv.length; i++) {
|
|
55
74
|
const a = argv[i];
|
|
@@ -155,6 +174,27 @@ function print(result) {
|
|
|
155
174
|
case 'stats share':
|
|
156
175
|
lines.push(result.message);
|
|
157
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
|
+
}
|
|
158
198
|
case 'look':
|
|
159
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}`);
|
|
160
200
|
break;
|
|
@@ -181,17 +221,18 @@ async function main() {
|
|
|
181
221
|
|
|
182
222
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
183
223
|
const root = requireStudio();
|
|
224
|
+
if (cmd === 'progress') return progressCommand(root, sub);
|
|
184
225
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
185
226
|
if (cmd === 'port' && sub === 'check') {
|
|
186
227
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
187
228
|
const url = flags.get('url') ?? siteUrl(root);
|
|
188
229
|
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 });
|
|
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');
|
|
190
231
|
}
|
|
191
232
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
192
233
|
if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
|
|
193
234
|
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 });
|
|
235
|
+
if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log }), 'build');
|
|
195
236
|
if (cmd === 'status') {
|
|
196
237
|
const studio = readStudio(root);
|
|
197
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)) };
|
|
@@ -202,7 +243,7 @@ async function main() {
|
|
|
202
243
|
const game = positional[1] ?? listGames(root)[0]?.id;
|
|
203
244
|
const url = flags.get('url') ?? siteUrl(root);
|
|
204
245
|
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 });
|
|
246
|
+
return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
|
|
206
247
|
}
|
|
207
248
|
if (cmd === 'look') {
|
|
208
249
|
const url = flags.get('url') ?? siteUrl(root);
|
|
@@ -218,7 +259,7 @@ async function main() {
|
|
|
218
259
|
return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
|
|
219
260
|
}
|
|
220
261
|
if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
|
|
221
|
-
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');
|
|
222
263
|
if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
|
|
223
264
|
if (cmd === 'storage') {
|
|
224
265
|
const cf = readStudio(root).cloudflare ?? {};
|
|
@@ -241,6 +282,94 @@ async function main() {
|
|
|
241
282
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
242
283
|
}
|
|
243
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
|
+
|
|
244
373
|
/*
|
|
245
374
|
* WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
|
|
246
375
|
* and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
|
|
@@ -325,11 +454,14 @@ function mediaPut(root, file, as) {
|
|
|
325
454
|
|
|
326
455
|
try {
|
|
327
456
|
const result = await main();
|
|
457
|
+
// A shared progress feed sends its last word before the command exits.
|
|
458
|
+
await flushProgress();
|
|
328
459
|
if (result && result.command !== 'help') print(result);
|
|
329
460
|
if (result?.ok === false) process.exitCode = 1;
|
|
330
461
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
331
462
|
if (result?.command === 'check' || result?.command === 'port check' || result?.command === 'look') process.exit(process.exitCode ?? 0);
|
|
332
463
|
} catch (error) {
|
|
464
|
+
await flushProgress().catch(() => {});
|
|
333
465
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
334
466
|
process.exitCode = 1;
|
|
335
467
|
}
|
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
|
-
|
|
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) {
|
|
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 });
|
package/lib/port-check.mjs
CHANGED
|
@@ -379,7 +379,19 @@ async function ownerTests(h, how, log) {
|
|
|
379
379
|
|
|
380
380
|
/* ------------------------------------------------------------------ the check */
|
|
381
381
|
|
|
382
|
-
|
|
382
|
+
/** What a progress feed calls each port check row. */
|
|
383
|
+
export const PORT_CHECK_LABELS = {
|
|
384
|
+
'owner-desk': 'Moves on a computer', 'owner-phone': 'Moves on a phone', 'owner-iphone': 'Moves on an iPhone (WebKit)',
|
|
385
|
+
audio: 'Sound starts on the first touch', sandbox: 'Runs in the sandbox', 'ui-cover': 'UI stays out of the middle',
|
|
386
|
+
round: 'Two browsers finish a round', 'host-kill': 'The host leaves, the round goes on', 'late-join': 'A late joiner takes a bot\'s seat',
|
|
387
|
+
tv: 'The big screen watches', errors: 'No errors in the game',
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `report` (optional; the open progress feed, lib/progress.mjs) hears each row as it runs, passes or fails, gets a
|
|
392
|
+
* small picture after each screenshot, and can ask the check to stop between rows. Without it nothing changes.
|
|
393
|
+
*/
|
|
394
|
+
export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000, report = null }) {
|
|
383
395
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
384
396
|
const base = String(url).replace(/\/+$/, '');
|
|
385
397
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
@@ -393,13 +405,21 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
393
405
|
mkdirSync(out, { recursive: true });
|
|
394
406
|
if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
|
|
395
407
|
const rows = [];
|
|
396
|
-
const
|
|
408
|
+
const tell = (name, state, note) => { try { report?.check?.(name, state, { label: PORT_CHECK_LABELS[name] ?? name, ...(note ? { note } : {}) }); } catch { /* a feed never breaks a check */ } };
|
|
409
|
+
const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); tell(name, ok === null ? 'skip' : ok ? 'pass' : 'fail', detail?.why); };
|
|
397
410
|
const open_ = [];
|
|
398
411
|
const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
|
|
399
412
|
const close = async (h) => { const i = open_.indexOf(h); if (i >= 0) open_.splice(i, 1); await h.close(); };
|
|
400
|
-
const shot = async (h, name) => {
|
|
413
|
+
const shot = async (h, name) => {
|
|
414
|
+
await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
|
|
415
|
+
if (report?.preview && h.vp) {
|
|
416
|
+
const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
|
|
417
|
+
if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name }); } catch { /* too big: no picture */ } }
|
|
418
|
+
}
|
|
419
|
+
};
|
|
401
420
|
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
|
402
421
|
const errorsSeen = [];
|
|
422
|
+
let stoppedAt = null;
|
|
403
423
|
const collect = (h) => { for (const e of h.errors) errorsSeen.push(`${h.engine}-${h.kind}: ${e}`); for (const r of h.badResponses) errorsSeen.push(`${h.engine}-${h.kind}: HTTP ${r}`); };
|
|
404
424
|
const started = Date.now();
|
|
405
425
|
// The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
|
|
@@ -408,6 +428,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
408
428
|
try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
|
|
409
429
|
};
|
|
410
430
|
const gate = async (name) => {
|
|
431
|
+
if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
|
|
432
|
+
tell(name, 'running');
|
|
411
433
|
if (await alive()) return true;
|
|
412
434
|
row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
|
|
413
435
|
return false;
|
|
@@ -631,7 +653,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
631
653
|
}
|
|
632
654
|
const failed = rows.filter((r) => r.ok === false);
|
|
633
655
|
const skipped = rows.filter((r) => r.ok === null);
|
|
634
|
-
const result = { ok: failed.length === 0 && rows.length > 0, command: 'port check', game, url: base, out, totalMs: Date.now() - started, passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
|
|
656
|
+
const result = { ok: failed.length === 0 && rows.length > 0 && !stoppedAt, command: 'port check', game, url: base, out, totalMs: Date.now() - started, ...(stoppedAt ? { stopped: true, why: `stopped by the person (before ${stoppedAt})` } : {}), passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
|
|
635
657
|
writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
636
658
|
return result;
|
|
637
659
|
}
|
package/lib/progress.mjs
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.8.0';
|