@homie-rocks/studio 0.30.2 → 0.31.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
@@ -14,15 +14,34 @@
14
14
  * extension, Claude Code or any MCP client builds in the same chat that shows
15
15
  * the cards (lib/mcp.mjs); --studios: the folder the studios live in
16
16
  * homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
17
- * homie-studio game remix <source.json url> --id <new id>
18
17
  * homie-studio games
19
- * homie-studio build [<id>] [--maps] (--maps: also keep each bundle's source map and module sizes in .studio/maps/<id>/,
20
- * never in site/dist: what `perf` reads a CPU profile through)
21
- * homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] (--stop: stop exactly this studio's dev server, nothing else;
18
+ * homie-studio build [<id>] [--maps] [--types] (--maps: also keep each bundle's source map and module sizes in
19
+ * .studio/maps/<id>/, never in site/dist: what `perf` reads a CPU profile through;
20
+ * --types: check the games' TypeScript first, with the studio's own
21
+ * `typescript`; a type error stops the build. The site is built in a folder of
22
+ * its own and put in place only when all of it is there: a game that does not
23
+ * build fails the command and leaves site/dist as it was. Each game's line says
24
+ * changed or unchanged, its build hash, and whether its source is shared)
25
+ * homie-studio preview <id> [--port 8788] (one built game's files at an address on this computer, nothing else: no
26
+ * Wrangler, no rooms, no database; for a capture, a screenshot or a perf script.
27
+ * The game plays offline with its bots. Stop it with Ctrl-C)
28
+ * homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] [--timestamps] (--stop: stop exactly this studio's dev server,
29
+ * nothing else, and clear a stale record; rooms here are always local: production
30
+ * routes in wrangler.jsonc are left out; a game built while it runs is picked up;
31
+ * --timestamps: a time on every line of Wrangler's, not only the room and error ones;
22
32
  * AI guides, chat review and game decisions think with Clef on this computer
23
33
  * when Ollama has clef-flash (free; dev never downloads it), else scripted;
24
34
  * --remote-ai: the real Workers AI, billed)
25
35
  * homie-studio check <id> [--url <site>] [--shots <dir>]
36
+ * homie-studio shoot <id> [--url <site> | --preview] [--frames 60] [--fps 30] [--device computer|phone]
37
+ * [--out <dir>] [--hold <key code>] [--no-smoke] [--timeout 180]
38
+ * pictures of the game on a clock this command steps (one frame = 1/fps s
39
+ * of the game's own time, however slow the renderer: for a 3D game on a
40
+ * machine with no GPU), after a two-client seat smoke check in a private
41
+ * room. Frames and shoot.json go to --out (default .studio/shoot/<id>/).
42
+ * --preview: no site needed; it serves the built game itself (as
43
+ * `preview` does: alone, offline) and shoots that, with no smoke check,
44
+ * and says which build (its hash and bundle) the frames are of.
26
45
  * homie-studio perf <id> [--url <site>] [--device computer,phone] [--runs 1] [--seconds 15] [--warm 3] [--profile]
27
46
  * [--cpu 4] [--out <dir>] [--max-load 0.8] [--pair <label>]
28
47
  * (how fast it runs: per device, two browsers in a fresh room, the host and a replica,
@@ -32,6 +51,10 @@
32
51
  * each browser and its hottest functions. Files under .perf/<id>/<time>/; the phone is
33
52
  * emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
34
53
  * skill runs the whole measure, change, compare, keep-or-revert loop)
54
+ * homie-studio trailer <id> [--url <site>] [--seconds 40] [--length 20] [--title "…"] [--end "…"] [--skills <folder>]
55
+ * (a trailer in one command, by the plugin's video skill: the game rendered frame by
56
+ * frame on a virtual clock, its sound rebuilt from its own files and what it played,
57
+ * the highlights picked, an end card, 16:9, 1:1 and 9:16 in videos/<id>-trailer/)
35
58
  * homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
36
59
  * build --maps, which modules make up the bundle; each big script read for whether it
37
60
  * is minified, from its code, and how much of it is GLSL shader source in strings)
@@ -61,9 +84,20 @@
61
84
  * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
62
85
  * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
63
86
  * homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
87
+ * One deploy of a studio at a time (a lock in .studio/); it says which games changed
88
+ * with each one's content hash, keeps the studio's own custom-domain and exact-host
89
+ * routes in wrangler.jsonc, and never deploys a wildcard or catch-all route.
90
+ * On a custom domain, the plan and the deploy read the domain's Worker routes and
91
+ * warn when another site's catch-all or wildcard covers the studio's hostname
92
+ * (never edit or remove that route). --own-route adds the studio's own
93
+ * exact-host route, the one safe fix, when that read shows it is needed.
64
94
  * In Cloudflare's Workers Builds (WORKERS_CI=1, or --ci) it only applies the D1
65
95
  * migrations and deploys: the Worker and database are the Deploy button's.
66
- * homie-studio publish
96
+ * homie-studio publish [--before] (the directory's beta has a daily cap: it says how many publishes are left)
97
+ * --before publishes NOTHING: it asks the directory (a read) whether the site
98
+ * is listed and how many publishes are left today, and says the parts' licences.
99
+ * A command that changes something outside this computer (deploy, publish,
100
+ * storage add, the office, …) stops at a flag it does not know: nothing is sent.
67
101
  * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
68
102
  * homie-studio media list (every song and video page, where each file is served from, what moves to R2)
69
103
  * homie-studio media move [<file>...] [--dry-run] [--verify]
@@ -94,7 +128,7 @@
94
128
  * homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
95
129
  * homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
96
130
  * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>] [--server <id>]
97
- * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
131
+ * homie-studio office launch <game> private|invite|public [--max <n>|game]
98
132
  * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
99
133
  * homie-studio office mute <game> <room> <seat number | name> [--minutes 10] [--off]
100
134
  * homie-studio office close <game> <room> [--minutes 10] [--reopen]
@@ -236,6 +270,14 @@
236
270
  * homie-studio anim add <id> <asset> --verbs jump,attack [--from <library item>] more verbs, retargeted onto its
237
271
  * skeleton at build time into its clip library (public/anims/<skeleton>.glb)
238
272
  * homie-studio anim preview <id> [--asset <asset>] looping previews of every clip (animated WebP) and a sheet
273
+ * homie-studio collision bake <id> <model.glb> [--cell 0.25] a height grid off a model's triangles, beside it, with
274
+ * a checksum of the mesh and a true-scale plan (lib/collision.mjs)
275
+ * homie-studio collision check <id> every collision file: stale against its model, its proxies, its routes walked
276
+ *
277
+ * homie-studio parts find|new|add|share|unshare|check …
278
+ * game parts (parts/PARTS.md): pieces of games a studio shares, and brings into
279
+ * its own games from other studios. The plumbing behind the chat tools
280
+ * parts_find, part_add, part_new and part_share; nobody is asked to type it
239
281
  *
240
282
  * homie-studio statusline the current build in one line (what Claude Code's status line shows)
241
283
  * homie-studio statusline --install [--project <folder>]
@@ -245,32 +287,37 @@
245
287
  *
246
288
  * Every command prints a few lines for a person; --json prints the result.
247
289
  */
290
+ // FIRST, before any other file of the toolkit is evaluated: a Node.js that is too old ends here, with the reason.
291
+ import '../lib/node-version.mjs';
248
292
  import { spawn, spawnSync } from 'node:child_process';
249
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
250
- import { basename, dirname, join, relative, resolve } from 'node:path';
293
+ import { existsSync, readFileSync, statSync } from 'node:fs';
294
+ import { basename, join, relative, resolve } from 'node:path';
251
295
  import { build } from '../lib/build.mjs';
296
+ import { PREVIEW_PORT, previewServer } from '../lib/preview.mjs';
252
297
  import { check } from '../lib/check.mjs';
253
- import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
298
+ import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin, zonePlan } from '../lib/cloudflare.mjs';
254
299
  import { setupAttach } from '../lib/setup.mjs';
255
300
  import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
256
- import { publish } from '../lib/directory.mjs';
301
+ import { deployWords } from '../lib/deploy-state.mjs';
302
+ import { publish, publishBefore } from '../lib/directory.mjs';
257
303
  import { look } from '../lib/look.mjs';
258
304
  import { importPort, planPort } from '../lib/port.mjs';
259
305
  import { portCheck } from '../lib/port-check.mjs';
260
306
  import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
261
307
  import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
262
308
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
263
- import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
309
+ import { newStudio } from '../lib/scaffold.mjs';
264
310
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
265
311
  import { whatsNewLines } from '../lib/changelog.mjs';
266
- import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
312
+ import { listGames, newGame, readStudio, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
267
313
  import { STUDIO_VERSION } from '../lib/version.mjs';
268
314
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
269
315
  import { playersOwner, playersShow } from '../lib/players.mjs';
270
316
  import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
271
317
  import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
272
318
  import { loungeLines, loungeMod, loungeNight, loungeRemove, loungeRulesSet, loungeShow } from '../lib/lounge-cli.mjs';
273
- import { detectLocalAi, localAiVars } from '../lib/local-ai.mjs';
319
+ import { detectLocalAi } from '../lib/local-ai.mjs';
320
+ import { dev, stopDev } from '../lib/dev.mjs';
274
321
  import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, agentsTry, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
275
322
  import { AgentSeat } from '../lib/agent-seat.mjs';
276
323
  import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
@@ -285,11 +332,16 @@ import { restartWithProxy } from '../lib/net.mjs';
285
332
  import { demoGames, formatDemo } from '../lib/demo.mjs';
286
333
  import { serveMcp } from '../lib/mcp.mjs';
287
334
  import { animCommand, artLines, assetsCommand, castView, styleCommand } from '../lib/art-cli.mjs';
335
+ import { collisionCommand } from '../lib/collision.mjs';
288
336
  import { formatHandoff, handoff } from '../lib/handoff.mjs';
337
+ // GAME PARTS (parts/PARTS.md): `parts …` is lib/parts-cli.mjs; the deploy plan shows brought-in parts' licences.
338
+ import { partsCommand, partsLines } from '../lib/parts-cli.mjs';
339
+ import { partsPlanLines, partsPublishReport } from '../lib/parts-build.mjs';
340
+ import { trailerCommand, trailerLines } from '../lib/trailer.mjs';
289
341
 
290
342
  const argv = process.argv.slice(2);
291
343
  const flags = new Map();
292
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
344
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before'];
293
345
  const positional = [];
294
346
  for (let i = 0; i < argv.length; i++) {
295
347
  const a = argv[i];
@@ -306,21 +358,16 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
306
358
  function print(result) {
307
359
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
308
360
  // A check that found problems still prints them (its rows say what to fix), never a bare "failed".
309
- if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
361
+ if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows) && !(result.command === 'assets check' && result.rows) && !(result.command === 'parts check' && result.rows) && !(result.command === 'shoot' && result.frames !== undefined)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
310
362
  if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
311
363
  const lines = [];
364
+ if (/^parts /.test(String(result.command ?? ''))) { process.stdout.write(`${partsLines(result).join('\n')}\n`); return; }
312
365
  if (/^(style|assets|cast|anim)( |$)/.test(String(result.command ?? ''))) { process.stdout.write(`${artLines(result).join('\n')}\n`); return; }
366
+ if (/^collision /.test(String(result.command ?? '')) && result.lines) { process.stdout.write(`${result.lines.join('\n')}\n`); return; }
313
367
  switch (result.command) {
314
368
  case 'new':
315
369
  lines.push(`${result.name} is a studio now: ${result.dir}`, '', 'Wrote:', ...result.wrote.map((f) => ` ${f}`), '', `Dependencies: ${result.installed}`, '', 'Next:', ...result.next.map((n) => ` ${n}`), '', result.online);
316
370
  break;
317
- case 'game remix':
318
- lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Its game.json credits the original: "${result.credit}"${result.page ? ` (${result.page})` : ''}, on its landing and in its credits. Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
319
- if (result.assets?.carried) lines.push(` assets: ${result.assets.fetched.length} carried from the original (checked by SHA-256)${result.assets.placeholders.length ? `, ${result.assets.placeholders.length} grey placeholder(s) where the licence does not let a remix carry them (${result.assets.placeholders.map((p) => p.asset).join(', ')}): see games/${result.id}/assets/RIGHTS.md` : ''}`);
320
- if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
321
- for (const h of result.needsHeld ?? []) lines.push(`It was written against ${h.name} ${h.want}; this studio pins ${h.have}, which stays.`);
322
- if (result.needsNotAdded?.length) lines.push(`Its game.json also asks for ${result.needsNotAdded.join(', ')}: not added (a remix adds only what the toolkit's starters use). Read the code before adding any.`);
323
- break;
324
371
  case 'game new':
325
372
  lines.push(`games/${result.id} is a new game from the ${result.from} starter. Change it in games/${result.id}/src/, then: npx homie-studio dev`);
326
373
  if (result.installNeeded) lines.push(`It needs ${result.needsAdded.map((n) => `${n.name} ${n.version}`).join(', ')}, now in the studio's package.json: run npm install first.`);
@@ -332,12 +379,22 @@ function print(result) {
332
379
  }
333
380
  break;
334
381
  case 'dev stop':
335
- lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
382
+ lines.push(result.stopped.length ? `Stopped this studio's dev server (${result.stopped.join(', ')}).${result.why ? ` Note: ${result.why}.` : ''}` : `Nothing to stop: ${result.why ?? 'no dev server of this studio is running'}.`);
336
383
  break;
337
384
  case 'build':
338
385
  lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'no games'} into ${relative(process.cwd(), result.dist) || result.dist}`);
386
+ // Per game: did it change since the build before, which build it is (the hash the live site's manifest says once
387
+ // deployed), and what loads later.
388
+ for (const g of result.games) lines.push(` ${g.id}: ${g.changed ?? 'new'}, build ${g.hash ?? '?'}${g.bundle ? ` (${g.bundle}${g.chunks ? ` + ${g.chunks} ${g.chunks === 1 ? 'chunk' : 'chunks'} loaded later` : ''})` : ''}`);
389
+ if (result.retired) lines.push(`Note: ${result.retired}`);
390
+ if (result.types) lines.push(`Types: ${result.types.games.filter((g) => g.checked).map((g) => g.id).join(', ') || 'no game has TypeScript to check'}${result.types.games.some((g) => g.checked) ? ` checked with TypeScript ${result.types.typescript ?? '?'}, no errors` : ''}`);
339
391
  if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
340
- for (const l of result.landings ?? []) lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (add a cover or hero/ footage)'}${l.credits ? ', credits' : ''}${l.source ? ', "Make a game like this" with its source' : ''}`);
392
+ for (const l of result.landings ?? []) {
393
+ lines.push(`Landing /${l.id}/: hero from ${l.hero === 'footage' ? 'its footage' : l.hero === 'art' ? 'its art (moving)' : 'the studio\'s colours (it has no picture or footage yet)'}${l.credits ? ', credits' : ''}`);
394
+ // Which files, and what shape: the names the landing looks for (lib/site.mjs HERO_NAMES). Any size is shown,
395
+ // cropped to fill; these are the sizes that fill a computer and an upright phone without waste.
396
+ if (l.hero === 'colours') lines.push(` give it one: games/${l.id}/hero/wide.jpg (16:9, 1280×720 or larger) and games/${l.id}/hero/tall.jpg (9:16, 720×1280, for an upright phone); footage beside them as hero/wide.mp4 and hero/tall.mp4 (the same shapes, a silent loop of 8 to 15 s, under 3 MB each); or "cover" in its game.json`);
397
+ }
341
398
  if (result.posts?.length) lines.push(`Posts: ${result.posts.map((x) => `/posts/${x}/`).join(', ')} (feeds: /posts/feed.xml, /posts/feed.json)`);
342
399
  if (result.pages?.length || result.partials?.length) lines.push(`The studio's own: ${[...(result.pages ?? []).map((x) => `page ${x}`), ...(result.partials ?? []).map((x) => `partial ${x}`)].join(', ')}`);
343
400
  for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
@@ -394,12 +451,14 @@ function print(result) {
394
451
  }
395
452
  case 'deploy':
396
453
  if (result.ci) {
397
- lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}`));
454
+ lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`));
398
455
  break;
399
456
  }
400
457
  lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
401
458
  ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
402
- ...result.games.map((g) => ` ${g.id}: ${g.play}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
459
+ // Which games this deploy changed since the last deploy from here, and each one's build hash
460
+ // (lib/deploy-state.mjs): the hash `build` printed and the one the live site's manifest answers with.
461
+ ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`), ...(result.removed?.length ? [` no longer on the site: ${result.removed.join(', ')}`] : []), ...(result.notes ?? []).map((n) => ` Note: ${n}`), ...(result.songs ?? []).map((m) => ` song ${m.slug}: ${m.page}`), ...(result.videos ?? []).map((m) => ` video ${m.slug}: ${m.page}`),
403
462
  ...(result.media?.moved ?? []).map((m) => ` moved to R2 (checked by SHA-256): ${m.path}, still at ${m.url}`), ...(result.media?.failed ?? []).map((m) => ` not moved to R2: ${m.path}: ${m.why}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}, Durable Objects Table + Lobby${result.r2 ? `, R2 ${result.r2}` : ' (no storage: none needed; `homie-studio storage add` adds it for large media)'}. All on the free Workers plan${result.r2 ? ' plus R2' : ''}.`,
404
463
  result.claim ? 'The site claimed itself in the directory: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the site claims itself when the directory first reads it; publish does).');
405
464
  break;
@@ -407,13 +466,20 @@ function print(result) {
407
466
  lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
408
467
  ...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
409
468
  `Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
410
- `The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never);
469
+ `The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never,
470
+ ...(result.zone?.warning ? ['', `Warning: ${result.zone.warning}`] : result.zone?.why ? ['', result.zone.why] : []),
471
+ ...(result.parts && partsPlanLines(result.parts).length ? ['', ...partsPlanLines(result.parts)] : []));
411
472
  break;
412
473
  case 'storage add':
413
474
  lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
414
475
  break;
476
+ case 'publish before':
477
+ lines.push(...result.lines);
478
+ break;
415
479
  case 'publish':
416
- lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
480
+ lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`), ...(result.publishes?.line ? [result.publishes.line] : []),
481
+ // The parts in what was listed, in the deploy plan's own lines (lib/parts-build.mjs).
482
+ ...(result.parts && partsPlanLines(result.parts, { at: 'publish' }).length ? ['', ...partsPlanLines(result.parts, { at: 'publish' })] : []));
417
483
  break;
418
484
  case 'port plan': {
419
485
  const f = result.facts;
@@ -666,14 +732,75 @@ function print(result) {
666
732
  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.`,
667
733
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`),
668
734
  ...(result.frames ?? []).map((f) => ` ${f.browser} drew ${f.fps ?? '?'} fps${f.renderer ? ` on ${f.renderer}` : ''}`),
735
+ // Seated, ready and connected are three things: each gets its own line beside the verdict.
736
+ ...(result.readiness ?? []).map((r) => ` ${r.browser} ${r.ready === true ? `ready: the loading cover lifted${r.liftedMs !== null ? ` at ${(r.liftedMs / 1000).toFixed(1)} s` : ''}${r.by ? ` (by the ${r.by})` : ''}` : r.ready === false ? `NOT READY: ${r.why}` : `readiness unknown: ${r.why}`}`),
737
+ ` Connection: ${result.uninterrupted === true ? 'uninterrupted' : result.uninterrupted === false ? 'INTERRUPTED' : 'unknown'} (${result.note ?? 'not reported'})`,
669
738
  ...(result.software ? [` ${result.software}`] : []));
670
739
  break;
740
+ case 'shoot':
741
+ lines.push(`Shot ${result.frames} of ${result.asked} frames of ${result.game} at ${result.fps} a second of the game's own clock (${result.virtualSeconds} s of game time in ${result.realSeconds} s) into ${result.out}`,
742
+ ` Smoke (two clients, one private room): ${result.smoke.verdict}${result.smoke.why ? `: ${result.smoke.why}` : ''}${result.smoke.note ? ` (${result.smoke.note})` : ''}`,
743
+ ...(result.build ? [` Build: ${result.build.hash} (${result.build.bundle}${result.build.loaded === true ? ', loaded by the page' : result.build.loaded === false ? ', NOT loaded by the page' : ''})`] : []),
744
+ ` Ready: ${result.ready.ready === true ? 'the loading cover had lifted' : result.ready.ready === false ? `NO: ${result.ready.why}` : `unknown: ${result.ready.why}`}`,
745
+ ` Renderer: ${result.renderer ?? 'unknown'}${result.software ? ' (software: these frames are still one step of the clock each, but slow to make)' : ''}`,
746
+ ...(result.partial ? [` PARTIAL: ${result.partial}`] : []), ...(result.note ? [` ${result.note}`] : []),
747
+ ` ${result.limits}`, ` Every frame's time, round and position: ${result.out}/shoot.json`);
748
+ break;
749
+ case 'trailer':
750
+ lines.push(...trailerLines(result));
751
+ break;
671
752
  default:
672
753
  lines.push(JSON.stringify(result, null, 2));
673
754
  }
674
755
  process.stdout.write(`${lines.join('\n')}\n`);
675
756
  }
676
757
 
758
+ /*
759
+ * A COMMAND THAT CHANGES SOMETHING OUTSIDE THIS COMPUTER NEVER RUNS WITH A FLAG IT DOES NOT KNOW. The parser above
760
+ * keeps every `--word`, and a command reads the ones it knows: `publish --before` (a flag the MCP tool had and this
761
+ * command did not) was read as plain `publish`, and listed the studio while its caller believed it was a preflight.
762
+ * So each command below names every flag it takes, and anything else stops it before anything is sent: a typo, a
763
+ * flag of another command, or one from a newer toolkit's notes. The key is the command and its sub-command, else
764
+ * the command alone. Commands that only read, or only change files in this folder, are not in this table.
765
+ */
766
+ const OUTWARD_FLAGS = {
767
+ publish: ['homie', 'site', 'before'],
768
+ deploy: ['plan', 'ci', 'homie', 'own-route'],
769
+ 'storage add': [],
770
+ 'media put': ['as'],
771
+ 'media move': ['dry-run', 'plan', 'verify'],
772
+ 'setup attach': ['homie', 'client'],
773
+ handoff: ['homie', 'client'],
774
+ 'players owner': ['url', 'revoke'],
775
+ 'stats key': ['url', 'hours'], 'stats link': ['url'], 'stats revoke': ['url'], 'stats share': [],
776
+ 'office link': ['url', 'to'], 'office key': ['url', 'hours'], 'office announce': ['url', 'game', 'room', 'seconds'],
777
+ 'office invite': ['url', 'label', 'uses', 'count', 'days', 'server'], 'office launch': ['url', 'max'],
778
+ 'office kick': ['url', 'minutes'], 'office mute': ['url', 'minutes', 'off'], 'office close': ['url', 'minutes', 'reopen'], 'office revoke': ['url'],
779
+ 'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
780
+ 'chat remove': ['url', 'all'], 'chat budget': ['url'],
781
+ 'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
782
+ 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send'],
783
+ 'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
784
+ 'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
785
+ };
786
+ /** Every command takes these: how it answers, never what it does. */
787
+ const ANY_COMMAND = ['json', 'help', 'version'];
788
+
789
+ /** The refusal for a flag an outward-writing command does not take, or null. Nothing has run when this is asked. */
790
+ function unknownFlags(words, given) {
791
+ const [cmd, sub] = words;
792
+ const key = sub !== undefined && OUTWARD_FLAGS[`${cmd} ${sub}`] ? `${cmd} ${sub}` : OUTWARD_FLAGS[cmd] ? cmd : null;
793
+ if (!key) return null;
794
+ const takes = OUTWARD_FLAGS[key];
795
+ const strange = [...given.keys()].filter((k) => !takes.includes(k) && !ANY_COMMAND.includes(k));
796
+ if (!strange.length) return null;
797
+ const named = strange.map((k) => `--${k}`).join(', ');
798
+ return {
799
+ ok: false, command: key, needs: 'flag', flags: strange, takes: takes.map((k) => `--${k}`),
800
+ why: `homie-studio ${key} does not take ${named}, so it did not run: nothing was sent or changed. It changes something outside this computer, and a flag it does not know is never guessed at. It takes: ${takes.length ? takes.map((k) => `--${k}`).join(', ') : 'no flags of its own'} (and --json).`,
801
+ };
802
+ }
803
+
677
804
  async function main() {
678
805
  const [cmd, sub] = positional;
679
806
  if (!cmd || cmd === 'help' || flags.has('help')) {
@@ -682,6 +809,8 @@ async function main() {
682
809
  return { ok: true, command: 'help' };
683
810
  }
684
811
  if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
812
+ const strange = unknownFlags(positional, flags);
813
+ if (strange) return strange;
685
814
  if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
686
815
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
687
816
  // The floor's built-in words need no studio: anyone can read what a studio's chat always holds.
@@ -710,9 +839,13 @@ async function main() {
710
839
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
711
840
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
712
841
  const root = requireStudio();
842
+ // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
843
+ if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
713
844
  if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
714
845
  if (cmd === 'assets') return assetsCommand(root, sub, positional, flags, { log });
715
846
  if (cmd === 'anim') return animCommand(root, sub, positional, flags, { log });
847
+ if (cmd === 'collision') return collisionCommand(root, sub, positional, flags);
848
+ if (cmd === 'parts') return partsCommand(root, sub, positional, flags, { log });
716
849
  if (cmd === 'cast') { const games = listGames(root); const id = sub ?? (games.length === 1 ? games[0].id : null); if (!id) return { ok: false, command: 'cast', why: `name the game: homie-studio cast <id>${games.length ? ` (${games.map((g) => g.id).join(', ')})` : ''}` }; return castView(root, id); }
717
850
  if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
718
851
  if (cmd === 'codex') return codexCommand(root, sub);
@@ -733,9 +866,12 @@ async function main() {
733
866
  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');
734
867
  }
735
868
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
736
- if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
869
+ // Remix was retired (a game is never handed over whole): the command is refused in a sentence that says where to
870
+ // go, so an old note, an old card or an agent's memory of the toolkit does not end at "unknown command".
871
+ if (cmd === 'game' && sub === 'remix') return { ok: false, command: 'game remix', why: 'remix was retired: a game is no longer handed over whole, and no studio serves one. Games build on each other through parts, the pieces of a game its studio chose to share: find one with `homie-studio parts find <words>` and bring it in with `homie-studio parts add` (in chat: parts_find, part_add; parts/PARTS.md)' };
737
872
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
738
- if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps') }), 'build');
873
+ if (cmd === 'build') return tracked(root, 'build', () => build(root, { only: positional[1] ?? null, log, maps: flags.has('maps'), types: flags.has('types') }), 'build');
874
+ if (cmd === 'preview') return preview(root, positional[1] ?? (listGames(root).length === 1 ? listGames(root)[0].id : null));
739
875
  if (cmd === 'perf' && sub === 'sizes') return perfSizes(root, positional[2] ?? listGames(root)[0]?.id);
740
876
  if (cmd === 'lab') return labCommand(root, sub);
741
877
  if (cmd === 'perf') {
@@ -754,8 +890,9 @@ async function main() {
754
890
  const plan = upgradePlan(root);
755
891
  return flags.has('apply') || flags.has('yes') ? upgradeApply(root, plan) : plan;
756
892
  }
893
+ // lib/dev.mjs: the dev server, its registration file, and what `--stop` will and will not signal.
757
894
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
758
- if (cmd === 'dev') return dev(root);
895
+ if (cmd === 'dev') return dev(root, { port: flags.get('port') ?? 8787, remoteAi: flags.has('remote-ai'), localAi: !flags.has('no-local-ai'), timestamps: flags.has('timestamps'), log });
759
896
  if (cmd === 'check') {
760
897
  const game = positional[1] ?? listGames(root)[0]?.id;
761
898
  const url = flags.get('url') ?? siteUrl(root);
@@ -766,6 +903,20 @@ async function main() {
766
903
  if (big.length) return { ok: false, command: 'check', why: `committed files over 5 MB under games/${game}: ${big.map((f) => `${f.path} (${(f.bytes / 1024 / 1024).toFixed(1)} MB)`).join(', ')}. Big media goes to the studio's R2 (homie-studio storage add, then media move); raw models stay in art/<slug>/raw/ (git-ignored); a shipped model is made phone-sized with homie-studio assets optimise. Take it out of git (git rm --cached), then check again.` };
767
904
  return tracked(root, 'checks', (report) => check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log, report }), 'check');
768
905
  }
906
+ if (cmd === 'shoot') {
907
+ const game = positional[1] ?? listGames(root)[0]?.id;
908
+ // --preview: the frames half from the light preview server this command starts itself (no Wrangler, no rooms).
909
+ const preview = flags.has('preview');
910
+ const url = preview ? null : flags.get('url') ?? siteUrl(root);
911
+ if (!url && !preview) return { ok: false, command: 'shoot', why: 'give --url (the local dev address or the live site), or --preview to shoot the built game by itself with no site running' };
912
+ const { shoot } = await import('../lib/shoot.mjs');
913
+ const num = (k, d) => (flags.get(k) === undefined || flags.get(k) === true ? d : Number(flags.get(k)));
914
+ return shoot({
915
+ url, preview, root, game, frames: num('frames', 60), fps: num('fps', 30), device: String(flags.get('device') ?? 'computer'),
916
+ out: flags.get('out') && flags.get('out') !== true ? resolve(flags.get('out')) : join(root, '.studio', 'shoot', String(game)),
917
+ smoke: !flags.has('no-smoke'), hold: typeof flags.get('hold') === 'string' ? flags.get('hold') : null, timeoutMs: Math.max(20, num('timeout', 180)) * 1000, log,
918
+ });
919
+ }
769
920
  if (cmd === 'look') {
770
921
  const url = flags.get('url') ?? siteUrl(root);
771
922
  if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
@@ -779,17 +930,22 @@ async function main() {
779
930
  const only = flags.get('only') ? String(flags.get('only')).split(',').filter((d) => ['computer', 'phone', 'sideways'].includes(d)) : undefined;
780
931
  return look({ url, paths, shots: flags.get('shots') ? resolve(flags.get('shots')) : join(root, '.studio', 'look'), devices: only, log });
781
932
  }
782
- if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
933
+ if (cmd === 'deploy' && flags.has('plan')) {
934
+ // On a custom domain the plan also reads the domain's Worker routes (lib/routes.mjs): a read, nothing changes.
935
+ const zone = await zonePlan(root);
936
+ return { ...deployPlan(root), parts: partsPublishReport(root), ...(zone ? { zone } : {}) };
937
+ }
783
938
  // Cloudflare's Workers Builds runs `npm run deploy` with WORKERS_CI=1 (and its own token for this one account).
784
939
  if (cmd === 'deploy' && (process.env.WORKERS_CI === '1' || flags.has('ci'))) return tracked(root, 'deploy', () => ciDeploy(root, { log }), 'deploy');
785
- if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie') }), 'deploy');
940
+ if (cmd === 'deploy') return tracked(root, 'deploy', () => deploy(root, { log, homie: flags.get('homie'), ownRoute: flags.has('own-route') }), 'deploy');
786
941
  if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
787
942
  if (cmd === 'storage') {
788
943
  const cf = readStudio(root).cloudflare ?? {};
789
944
  const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
790
945
  return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
791
946
  }
792
- if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
947
+ if (cmd === 'publish' && flags.has('before')) return publishBefore(root, { homie: flags.get('homie'), site: flags.get('site'), log: () => {} });
948
+ if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site'), log });
793
949
  if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
794
950
  if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
795
951
  if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
@@ -804,7 +960,7 @@ async function main() {
804
960
  if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
805
961
  if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
806
962
  if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days'), server: flags.get('server') });
807
- if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
963
+ if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, max: flags.get('max') });
808
964
  if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
809
965
  if (sub === 'mute') return officeMute(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes'), off: flags.has('off') });
810
966
  if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
@@ -1029,97 +1185,21 @@ async function progressCommand(root, sub) {
1029
1185
  } catch (error) { return { ok: false, command: 'progress', why: error instanceof Error ? error.message : String(error) }; }
1030
1186
  }
1031
1187
 
1032
- /*
1033
- * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
1034
- * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
1035
- * never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
1188
+ /**
1189
+ * One built game's files at an address on this computer (lib/preview.mjs), until the process is stopped. Its address
1190
+ * is the first thing on stdout (a line of JSON with --json), so a script that starts it reads where to go.
1036
1191
  */
1037
- const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
1038
- function processOfStudio(pid, root, kind) {
1039
- if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
1040
- try { process.kill(pid, 0); } catch { return false; }
1041
- const ps = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
1042
- if (ps.status !== 0) return process.platform === 'win32'; // no ps: the PID file is all there is
1043
- let real = root;
1044
- try { real = realpathSync(root); } catch { /* keep */ }
1045
- // A PID the file names is still ours when it runs from this studio, or is still the kind of process it was
1046
- // (a recycled PID is neither), so nothing else on the machine is ever stopped.
1047
- return ps.stdout.includes(root) || ps.stdout.includes(real) || (kind === 'dev' ? /homie-studio/.test(ps.stdout) : /wrangler|workerd/.test(ps.stdout));
1048
- }
1049
- async function stopDev(root) {
1050
- const file = devFile(root);
1051
- if (!existsSync(file)) return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
1052
- let rec = {};
1053
- try { rec = JSON.parse(readFileSync(file, 'utf8')); } catch { /* a torn file */ }
1054
- const pids = [[rec.child, 'wrangler'], [rec.pid, 'dev']].filter(([p, kind]) => processOfStudio(p, root, kind)).map(([p]) => p);
1055
- for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
1056
- const deadline = Date.now() + 8000;
1057
- const alive = () => pids.filter((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
1058
- while (alive().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
1059
- for (const pid of alive()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
1060
- rmSync(file, { force: true });
1061
- return { ok: true, command: 'dev stop', stopped: pids };
1062
- }
1063
-
1064
- /** The whole site locally: pages, rooms (room.mjs in a local Durable Object), D1. */
1065
- async function dev(root) {
1066
- const port = String(flags.get('port') ?? 8787);
1067
- const b = await build(root, { log });
1068
- if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
1069
- const bin = wranglerBin(root);
1070
- if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
1071
- const studio = readStudio(root);
1072
- const env = { ...process.env, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1' };
1073
- for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
1192
+ async function preview(root, id) {
1193
+ const p = await previewServer(root, id, { port: flags.get('port') ?? PREVIEW_PORT, strict: flags.has('port') });
1194
+ if (!p.ok) return p;
1195
+ const { server, dir, ...said } = p;
1196
+ process.stdout.write(asJson ? `${JSON.stringify({ ...said, dir: relative(root, dir) })}\n` : `Preview of ${id}: ${p.url} (its built files only, from ${relative(process.cwd(), dir) || dir}: no rooms, it plays offline with its bots; a new build shows on the next reload. Ctrl-C stops it)\n`);
1074
1197
  await new Promise((done) => {
1075
- const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
1076
- m.on('close', done);
1198
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => server.close(done));
1199
+ server.on('close', done);
1077
1200
  });
1078
- log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
1079
- const ai = devConfig(root, flags.has('remote-ai'));
1080
- if (ai.note) log(ai.note);
1081
- // The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
1082
- // guides, chat review and game decisions think here, free. Never a download: dev only says what one would cost.
1083
- let localVars = [];
1084
- if (!flags.has('remote-ai') && !flags.has('no-local-ai')) {
1085
- const found = await detectLocalAi();
1086
- localVars = localAiVars(found);
1087
- log(found.ok ? `AI guides, chat review and game decisions think with ${found.model} on this computer (Ollama ${found.version}): free, nothing sent to Cloudflare.` : found.say);
1088
- }
1089
- // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
1090
- // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
1091
- const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars], { cwd: workerDir(root), env, stdio: 'inherit' });
1092
- mkdirSync(dirname(devFile(root)), { recursive: true });
1093
- writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
1094
- log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
1095
- // Stopping this process stops Wrangler with it, so no dev server is left running on its own.
1096
- for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
1097
- await new Promise((done) => child.on('close', done));
1098
- rmSync(devFile(root), { force: true });
1099
- return { ok: true, command: 'dev', stopped: true };
1100
- }
1101
-
1102
- /**
1103
- * AI guides under `dev` (0.17.0): they think scripted, for free, unless --remote-ai (real Workers AI on the signed-in
1104
- * account, billed like the live site). A config whose AI binding is not what was asked for runs from a copy in
1105
- * .wrangler/ (absolute paths, the same local state), so wrangler.jsonc itself is never changed by dev.
1106
- */
1107
- function devConfig(root, remoteAi) {
1108
- const file = join(workerDir(root), 'wrangler.jsonc');
1109
- let json;
1110
- try { json = JSON.parse(readFileSync(file, 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return { args: [] }; }
1111
- if (Boolean(json.ai) === remoteAi) return { args: [], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null };
1112
- const base = workerDir(root);
1113
- const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
1114
- delete json.$schema;
1115
- json.main = abs(json.main);
1116
- if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
1117
- json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
1118
- if (remoteAi) json.ai = { binding: 'AI', remote: true }; else delete json.ai;
1119
- const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
1120
- mkdirSync(dirname(copy), { recursive: true });
1121
- writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
1122
- return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.' };
1201
+ server.closeAllConnections?.();
1202
+ return { ok: true, command: 'help' };
1123
1203
  }
1124
1204
 
1125
1205
  /** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
@@ -1191,7 +1271,7 @@ try {
1191
1271
  if (result && result.command !== 'help') print(result);
1192
1272
  if (result?.ok === false) process.exitCode = 1;
1193
1273
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
1194
- if (['check', 'port check', 'look', 'perf'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1274
+ if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1195
1275
  } catch (error) {
1196
1276
  await flushProgress().catch(() => {});
1197
1277
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });