@homie-rocks/studio 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,39 +1,80 @@
1
1
  # @homie-rocks/studio
2
2
 
3
- A game studio on its own Cloudflare. One repository (`AGENTS.md`, `games/`, `music/`,
4
- `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay contract (every
5
- browser renders, strangers meet in public rooms, bots fill seats, rounds restart), and a
6
- site Worker with Table/Lobby Durable Objects, D1 and R2 that the studio deploys to its
7
- own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
3
+ A studio for games, music and video on its own Cloudflare. One repository (`AGENTS.md`,
4
+ `games/`, `music/`, `videos/`, `posts/`, `site/`), multiplayer games on the Homie netplay
5
+ contract (every browser renders, strangers meet in public rooms, bots fill seats, rounds
6
+ restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
7
+ to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
8
8
 
9
9
  Most people never run this by hand: the Homie plugin for Claude Code and Codex does,
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.2.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.5.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
17
17
  npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
+ npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
18
19
  npx homie-studio deploy # the studio's own Cloudflare
19
20
  npx homie-studio publish # the homie.rocks directory
20
21
  ```
21
22
 
23
+ ## No payment method needed
24
+
25
+ `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
26
+ payment method: one Worker, one D1 database and two SQLite-backed Durable Objects
27
+ (`Table`, `Lobby`). It never creates or binds R2. A brand-new Cloudflare account verifies
28
+ its email address before it can run a Worker; a missing `workers.dev` subdomain is
29
+ registered by Wrangler when Claude Code or Codex runs the deploy.
30
+
31
+ Storage for large media (songs, videos, big art) is a separate, optional step:
32
+
33
+ ```sh
34
+ npx homie-studio storage add # an R2 bucket, bound as MEDIA, served at /media/<key>
35
+ npx homie-studio deploy
36
+ npx homie-studio media put music/theme.wav
37
+ ```
38
+
39
+ Cloudflare asks for a payment method on the account before R2 works, even inside R2's free
40
+ tier (10 GB-month of storage, 1 million writes and 10 million reads a month), so `storage add`
41
+ refuses with the dashboard link until the account has R2 turned on, and creates nothing.
42
+ The free plan's daily limits (100,000 Worker requests; D1 5 million rows read and 100,000
43
+ written) reset at 00:00 UTC; past them requests fail until the reset and nothing is charged.
44
+
45
+ An existing single-player web game becomes a multiplayer one with the port toolkit
46
+ (the plugin's `port` skill drives it):
47
+
48
+ ```sh
49
+ npx homie-studio port plan ../my-game # grade it: easy, medium, hard or not a fit, and why
50
+ npx homie-studio port import ../my-game --id my-game # bring it into games/ (static, bundle or its own build)
51
+ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner tests, real touch, host kill, late joiner, a round
52
+ ```
53
+
22
54
  | Path | What it is |
23
55
  | --- | --- |
24
56
  | `bin/homie-studio.mjs` | The CLI. |
25
57
  | `lib/scaffold.mjs` | `new`: the studio monorepo, only into a new or empty folder, every file listed. |
26
58
  | `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
27
- | `lib/cloudflare.mjs` | `deploy`: Wrangler, D1, R2, migrations, the directory claim; refuses resources it did not create. |
59
+ | `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations, the directory claim; refuses resources it did not create. `storage add`: the optional R2 bucket. |
28
60
  | `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
29
- | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects. |
61
+ | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
62
+ | `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
63
+ | `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
64
+ | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). |
65
+ | `lib/media.mjs`, `media/MEDIA.md` | The `music/` and `videos/` manifests: which entries get a page, and where each file's bytes come from (the site itself up to 25 MiB a file, the studio's storage, or a link). `media list` shows it; `media put` uploads a file to storage and records its key. |
30
66
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
31
67
  | `starters/gem-rush/` | The reference multiplayer starter. |
32
68
 
33
69
  Versions are immutable: a published version never changes, so a studio that pinned one
34
- never changes by surprise. A change ships as a new `version`. Until the package is on the
35
- npm registry, each version's tarball is served at
36
- `https://homie.rocks/npm/homie-studio-<version>.tgz`.
70
+ never changes by surprise. A change ships as a new `version`, on the npm registry as
71
+ `@homie-rocks/studio` and as a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`
72
+ (which is what `homie-studio new` pins).
73
+
74
+ ## Beta
75
+
76
+ This is a beta. Bugs, port requests and questions go to
77
+ <https://github.com/homie-rocks/homie/issues/new/choose>.
37
78
 
38
79
  ## License
39
80
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * homie-studio — make a game studio, make its games, put its site on the
4
- * studio's own Cloudflare, and list its games in the homie.rocks directory.
3
+ * homie-studio — make a studio (games, music, videos, posts), make its games,
4
+ * put its site on the studio's own Cloudflare (free plan, no payment method), and
5
+ * list its games in the homie.rocks directory.
5
6
  *
6
7
  * homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install]
7
8
  * homie-studio starters
@@ -9,35 +10,44 @@
9
10
  * homie-studio game remix <source.json url> --id <new id>
10
11
  * homie-studio games
11
12
  * homie-studio build [<id>]
12
- * homie-studio dev [--port 8787]
13
+ * homie-studio dev [--port 8787] (--stop: stop exactly this studio's dev server, nothing else)
13
14
  * homie-studio check <id> [--url <site>] [--shots <dir>]
14
- * homie-studio deploy
15
+ * homie-studio port plan <game folder>
16
+ * homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
17
+ * homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
18
+ * homie-studio deploy [--plan] (--plan: what it will create on Cloudflare and what it costs; changes nothing)
15
19
  * homie-studio publish
20
+ * homie-studio storage add (large media only: an R2 bucket; needs R2 turned on for the account)
16
21
  * homie-studio media put <file> [--as <key>]
22
+ * homie-studio media list
17
23
  * homie-studio status
18
24
  *
19
25
  * Every command prints a few lines for a person; --json prints the result.
20
26
  */
21
- import { spawn } from 'node:child_process';
22
- import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
23
- import { basename, join, relative, resolve } from 'node:path';
27
+ import { spawn, spawnSync } from 'node:child_process';
28
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
29
+ import { basename, dirname, join, relative, resolve } from 'node:path';
24
30
  import { build } from '../lib/build.mjs';
25
31
  import { check } from '../lib/check.mjs';
26
- import { deploy, whoami, wranglerBin } from '../lib/cloudflare.mjs';
32
+ import { deploy, deployPlan, storageAdd, whoami, wranglerBin } from '../lib/cloudflare.mjs';
27
33
  import { publish } from '../lib/directory.mjs';
34
+ import { importPort, planPort } from '../lib/port.mjs';
35
+ import { portCheck } from '../lib/port-check.mjs';
36
+ import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
28
37
  import { newStudio } from '../lib/scaffold.mjs';
29
- import { listGames, newGame, readStudio, remixGame, requireStudio, starters, writeStudio } from '../lib/studio.mjs';
38
+ import { listGames, newGame, readStudio, remixGame, requireStudio, starters } from '../lib/studio.mjs';
30
39
  import { STUDIO_VERSION } from '../lib/version.mjs';
31
40
 
32
41
  const argv = process.argv.slice(2);
33
42
  const flags = new Map();
43
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop'];
34
44
  const positional = [];
35
45
  for (let i = 0; i < argv.length; i++) {
36
46
  const a = argv[i];
37
47
  if (a.startsWith('--')) {
38
48
  const [k, v] = a.slice(2).split(/=(.*)/s, 2);
39
49
  if (v !== undefined) flags.set(k, v);
40
- else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !['json', 'yes', 'detach', 'no-install'].includes(k)) flags.set(k, argv[++i]);
50
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !BOOL_FLAGS.includes(k)) flags.set(k, argv[++i]);
41
51
  else flags.set(k, true);
42
52
  } else positional.push(a);
43
53
  }
@@ -46,11 +56,12 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
46
56
 
47
57
  function print(result) {
48
58
  if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
49
- if (result.ok === false) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
59
+ if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
60
+ if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
50
61
  const lines = [];
51
62
  switch (result.command) {
52
63
  case 'new':
53
- 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}`));
64
+ 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);
54
65
  break;
55
66
  case 'game remix':
56
67
  lines.push(`games/${result.id} is a remix of ${result.from} (${result.files.length} files). Make it yours in games/${result.id}/src/, then: npx homie-studio dev`);
@@ -58,16 +69,53 @@ function print(result) {
58
69
  case 'game new':
59
70
  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`);
60
71
  break;
72
+ case 'dev stop':
73
+ 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'}.`);
74
+ break;
61
75
  case 'build':
62
- lines.push(`Built ${result.games.map((g) => `${g.id} (${Math.round(g.bytes / 1024)} KB)`).join(', ') || 'nothing'} into ${relative(process.cwd(), result.dist) || result.dist}`);
76
+ 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}`);
77
+ if (result.songs?.length || result.videos?.length) lines.push(`Media pages: ${[...result.songs.map((x) => `/music/${x}/`), ...result.videos.map((x) => `/videos/${x}/`)].join(', ')}`);
78
+ for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
79
+ break;
80
+ case 'media list':
81
+ for (const kind of ['music', 'videos']) {
82
+ lines.push(`${kind}/manifest.json: ${result[kind].pages.length} page(s)`);
83
+ for (const e of result[kind].pages) lines.push(` /${kind}/${e.slug}/ ${e.title} (${e.files.map((f) => `${f.role} from ${f.from}`).join(', ')})`);
84
+ for (const m of result[kind].skipped) lines.push(` left out: ${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
85
+ }
63
86
  break;
64
87
  case 'deploy':
65
- lines.push(`Live: ${result.url}`, ...result.games.map((g) => ` ${g.id}: ${g.play}`), '', `Cloudflare: Worker ${result.worker}, D1 ${result.d1}${result.r2 ? `, R2 ${result.r2}` : ', no R2 (not enabled on this account)'}`,
66
- result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
88
+ lines.push(`Live: ${result.url}`, ...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}`), '', `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' : ''}.`,
89
+ result.claim ? 'Directory claim stored: list the games with the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish' : 'No directory claim yet (the directory did not answer); publish will try again.');
90
+ break;
91
+ case 'deploy plan':
92
+ lines.push(`What \`npm run deploy\` does for ${result.studio}, on the Cloudflare account the person approves:`, '',
93
+ ...result.cloudflare.map((r) => ` ${r.kind}${r.name ? ` ${r.name}` : ''}: ${r.what} [${r.state}${r.plan ? `; ${r.plan}` : ''}]`), '',
94
+ `Cost: ${result.cost}`, `Sign-in: ${result.login}`, `Address: ${result.address}`,
95
+ `The directory (${result.directory.site}) stores: ${result.directory.stores}`, result.never);
96
+ break;
97
+ case 'storage add':
98
+ lines.push(result.already ? `Storage is already added: R2 bucket ${result.bucket}.` : `Storage added: R2 bucket ${result.bucket}.`, 'Next:', ...result.next.map((n) => ` ${n}`));
67
99
  break;
68
100
  case 'publish':
69
101
  lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
70
102
  break;
103
+ case 'port plan': {
104
+ const f = result.facts;
105
+ lines.push(`Port plan for ${result.folder}`, '', `Difficulty: ${result.grade.toUpperCase()}`, ...result.reasons.map((r) => ` - ${r}`), '',
106
+ `What it is: ${f.engine.join(', ')}; ${f.loc} lines of game code in ${f.files} files (${Math.round(f.bytes / 1024)} KB); ${f.turnBased ? 'turn-based / moves on input' : 'real time'}${f.physics.length ? `; physics: ${f.physics.join(', ')}` : ''}.`,
107
+ `Input: keys ${f.input.keys}, mouse ${f.input.mouse}, touch ${f.input.touch}, pointer ${f.input.pointer}${f.input.pointerLock ? ', pointer lock' : ''}.`,
108
+ `Recommended: movement "${result.recommend.movement}", check view "${result.recommend.view}", build "${result.recommend.build}".`,
109
+ `Licence: ${result.licence.kind}${result.licence.file ? ` (${result.licence.file})` : ''}.`, '', 'Risks:', ...(result.risks.length ? result.risks.map((r) => ` - ${r}`) : [' - none found by reading; the checks will say']));
110
+ break;
111
+ }
112
+ case 'port import':
113
+ lines.push(`games/${result.id} is a port of ${result.from} (${result.files} files, build "${result.mode}", draft grade ${result.plan.grade}).`, ...result.edits.map((e) => ` ${e}`), '', 'Next:', ...result.next.map((n) => ` - ${n}`));
114
+ break;
115
+ case 'port check':
116
+ lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
117
+ ...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
118
+ break;
71
119
  case 'check':
72
120
  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.`,
73
121
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -89,7 +137,15 @@ async function main() {
89
137
  if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install') });
90
138
  if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
91
139
 
140
+ if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
92
141
  const root = requireStudio();
142
+ if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
143
+ if (cmd === 'port' && sub === 'check') {
144
+ const game = positional[2] ?? listGames(root)[0]?.id;
145
+ const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
146
+ if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
147
+ return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
148
+ }
93
149
  if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
94
150
  if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
95
151
  if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
@@ -98,6 +154,7 @@ async function main() {
98
154
  const studio = readStudio(root);
99
155
  return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
100
156
  }
157
+ if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
101
158
  if (cmd === 'dev') return dev(root);
102
159
  if (cmd === 'check') {
103
160
  const game = positional[1] ?? listGames(root)[0]?.id;
@@ -105,17 +162,62 @@ async function main() {
105
162
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
106
163
  return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
107
164
  }
165
+ if (cmd === 'deploy' && flags.has('plan')) return deployPlan(root);
108
166
  if (cmd === 'deploy') return deploy(root, { log, homie: flags.get('homie') });
167
+ if (cmd === 'storage' && sub === 'add') return storageAdd(root, { log });
168
+ if (cmd === 'storage') {
169
+ const cf = readStudio(root).cloudflare ?? {};
170
+ const has = Boolean(cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`));
171
+ 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)' };
172
+ }
109
173
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
110
174
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
175
+ if (cmd === 'media' && sub === 'list') {
176
+ const studio = readStudio(root);
177
+ const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
178
+ const view = (kind) => { const r = resolveMedia(root, kind, { r2 }); return { pages: r.entries.map((e) => ({ slug: e.slug, title: e.title, kind: e.kind, files: e.files.map(({ abs, rel, ...f }) => f) })), skipped: r.skipped }; };
179
+ return { ok: true, command: 'media list', r2, site: studio.cloudflare?.url ?? null, music: view('music'), videos: view('videos') };
180
+ }
111
181
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
112
182
  }
113
183
 
184
+ /*
185
+ * WHICH DEV SERVER IS THIS STUDIO'S. `dev` writes its own PID and Wrangler's to site/.wrangler/homie-dev.json,
186
+ * and `dev --stop` stops exactly those two (each checked to still be a process of this studio's folder), so an AI
187
+ * never has to reach for `pkill -f "wrangler dev"`, which stops every project's dev server on the machine.
188
+ */
189
+ const devFile = (root) => join(root, 'site', '.wrangler', 'homie-dev.json');
190
+ function processOfStudio(pid, root, kind) {
191
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
192
+ try { process.kill(pid, 0); } catch { return false; }
193
+ const ps = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
194
+ if (ps.status !== 0) return process.platform === 'win32'; // no ps: the PID file is all there is
195
+ let real = root;
196
+ try { real = realpathSync(root); } catch { /* keep */ }
197
+ // A PID the file names is still ours when it runs from this studio, or is still the kind of process it was
198
+ // (a recycled PID is neither), so nothing else on the machine is ever stopped.
199
+ return ps.stdout.includes(root) || ps.stdout.includes(real) || (kind === 'dev' ? /homie-studio/.test(ps.stdout) : /wrangler|workerd/.test(ps.stdout));
200
+ }
201
+ async function stopDev(root) {
202
+ const file = devFile(root);
203
+ if (!existsSync(file)) return { ok: true, command: 'dev stop', stopped: [], why: 'no dev server of this studio is running' };
204
+ let rec = {};
205
+ try { rec = JSON.parse(readFileSync(file, 'utf8')); } catch { /* a torn file */ }
206
+ const pids = [[rec.child, 'wrangler'], [rec.pid, 'dev']].filter(([p, kind]) => processOfStudio(p, root, kind)).map(([p]) => p);
207
+ for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } }
208
+ const deadline = Date.now() + 8000;
209
+ const alive = () => pids.filter((p) => { try { process.kill(p, 0); return true; } catch { return false; } });
210
+ while (alive().length && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
211
+ for (const pid of alive()) { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }
212
+ rmSync(file, { force: true });
213
+ return { ok: true, command: 'dev stop', stopped: pids };
214
+ }
215
+
114
216
  /** The whole site locally: pages, rooms (room.mjs in a local Durable Object), D1. */
115
217
  async function dev(root) {
116
218
  const port = String(flags.get('port') ?? 8787);
117
219
  const b = await build(root, { log });
118
- if (!b.catalogue.length) return { ok: false, command: 'dev', why: 'no games yet: npx homie-studio game new <id> --from gem-rush' };
220
+ if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'dev', why: 'nothing to show yet: npx homie-studio game new <id> --from gem-rush, or publish a song or video (media/MEDIA.md)' };
119
221
  const bin = wranglerBin(root);
120
222
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
121
223
  const studio = readStudio(root);
@@ -126,30 +228,33 @@ async function dev(root) {
126
228
  });
127
229
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
128
230
  const child = spawn(bin, ['dev', '--local', '--ip', '127.0.0.1', '--port', port], { cwd: join(root, 'site'), env, stdio: 'inherit' });
231
+ mkdirSync(dirname(devFile(root)), { recursive: true });
232
+ writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
233
+ log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
234
+ // Stopping this process stops Wrangler with it, so no dev server is left running on its own.
235
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
129
236
  await new Promise((done) => child.on('close', done));
237
+ rmSync(devFile(root), { force: true });
130
238
  return { ok: true, command: 'dev', stopped: true };
131
239
  }
132
240
 
133
- /** A big file into the studio's R2, and its line in music/ or videos/ manifest.json. */
241
+ /** A big file into the studio's R2, and its key on the music/ or videos/ manifest entry that names it. */
134
242
  function mediaPut(root, file, as) {
135
243
  if (!file || !existsSync(file) || !statSync(file).isFile()) return { ok: false, command: 'media put', why: 'usage: homie-studio media put <file> [--as <key>]' };
136
244
  const studio = readStudio(root);
137
245
  const r2 = studio.cloudflare?.r2;
138
- if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', why: 'this studio has no R2 bucket yet (deploy first; R2 needs to be enabled on the Cloudflare account)' };
246
+ if (!r2 || !(studio.cloudflare?.created ?? []).includes(`r2:${r2}`)) return { ok: false, command: 'media put', needs: 'storage', why: 'this studio has no storage yet. `npx --no-install homie-studio storage add` adds an R2 bucket; Cloudflare asks for a payment method on the account before R2 works (10 GB-month free), so ask the person first. Games never need it.' };
139
247
  const rel = relative(root, resolve(file));
140
248
  const folder = rel.startsWith('videos/') ? 'videos' : 'music';
141
- const key = as ?? `${folder}/${basename(file)}`;
249
+ // A file already in music/ or videos/ keeps its repository path as its key (two songs may share a file name).
250
+ const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
142
251
  const bin = wranglerBin(root);
143
252
  return new Promise((done) => {
144
- const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
253
+ const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: join(root, 'site'), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
145
254
  p.on('close', (code) => {
146
255
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
147
- const manifest = join(root, folder, 'manifest.json');
148
- const m = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : { items: [] };
149
- m.items = [...(m.items ?? []).filter((i) => i.key !== key), { key, file: rel, bytes: statSync(file).size, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null, at: new Date().toISOString() }];
150
- writeFileSync(manifest, `${JSON.stringify(m, null, 2)}\n`);
151
- writeStudio(root, studio);
152
- done({ ok: true, command: 'media put', key, manifest: relative(root, manifest) });
256
+ const rec = recordUpload(root, rel, key, statSync(file).size, studio.cloudflare?.url ?? null);
257
+ done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: studio.cloudflare?.url ? `${studio.cloudflare.url}/media/${key}` : null });
153
258
  });
154
259
  });
155
260
  }
@@ -159,7 +264,7 @@ try {
159
264
  if (result && result.command !== 'help') print(result);
160
265
  if (result?.ok === false) process.exitCode = 1;
161
266
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
162
- if (result?.command === 'check') process.exit(process.exitCode ?? 0);
267
+ if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
163
268
  } catch (error) {
164
269
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
165
270
  process.exitCode = 1;
package/lib/build.mjs CHANGED
@@ -5,15 +5,55 @@
5
5
  * site/dist/games/<id>/index.html the game's page (the Worker adds HOMIE_NET)
6
6
  * site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
7
7
  * site/dist/games/<id>/... everything in games/<id>/public/
8
- * site/dist/games.json { studio, games[] } from studio.json and game.json files
8
+ * site/dist/games.json { studio, games[], songs[], videos[] } from studio.json, game.json
9
+ * files and the music/ and videos/ manifests (media/MEDIA.md)
10
+ * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
11
+ *
12
+ * game.json "build" picks how a game becomes files (a ported game keeps its own shape):
13
+ * (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
14
+ * { "mode": "static" } the folder copied as it is (plain <script> games), plus homie-port.js,
15
+ * the port toolkit as one classic script (window.HomiePort); an "entry"
16
+ * is bundled to assets/main.js as well
17
+ * { "mode": "command", "command": "npm run build", "out": "dist" }
18
+ * the game's own build (Vite, webpack…), then its output copied; base must be './'
9
19
  */
20
+ import { spawnSync } from 'node:child_process';
10
21
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
22
  import { createRequire } from 'node:module';
12
- import { join } from 'node:path';
13
- import { listGames, readStudio } from './studio.mjs';
23
+ import { join, relative } from 'node:path';
24
+ import { buildMedia } from './media.mjs';
25
+ import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
14
26
  import { STUDIO_VERSION } from './version.mjs';
15
27
 
16
- const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler']);
28
+ const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
29
+ /** Never copied into a static game's served folder. */
30
+ const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md']);
31
+ const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
32
+
33
+ /** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
34
+ async function portScript(esbuild, root, cache) {
35
+ if (cache.text) return cache.text;
36
+ const entry = join(PACKAGE_ROOT, 'port', 'global.ts');
37
+ const res = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'iife', target: 'es2020', minify: true, write: false, absWorkingDir: root, logLevel: 'silent' });
38
+ cache.text = `/* homie-port.js — @homie-rocks/studio port toolkit (window.HomiePort). Load it first in <head>. */\n${res.outputFiles[0].text}`;
39
+ return cache.text;
40
+ }
41
+
42
+ function copyStatic(from, to) {
43
+ for (const entry of readdirSync(from, { withFileTypes: true })) {
44
+ if (STATIC_SKIP.has(entry.name)) continue;
45
+ const src = join(from, entry.name);
46
+ const dest = join(to, entry.name);
47
+ if (entry.isDirectory()) { mkdirSync(dest, { recursive: true }); copyStatic(src, dest); }
48
+ else if (entry.isFile()) cpSync(src, dest);
49
+ }
50
+ }
51
+
52
+ function dirBytes(dir) {
53
+ let n = 0;
54
+ for (const e of readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? dirBytes(join(dir, e.name)) : statSync(join(dir, e.name)).size;
55
+ return n;
56
+ }
17
57
  const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
18
58
  /** Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes. */
19
59
  export function sourceOf(dir, id) {
@@ -47,40 +87,77 @@ export async function build(root, { only = null, log = () => {} } = {}) {
47
87
  if (!only) rmSync(dist, { recursive: true, force: true });
48
88
  mkdirSync(dist, { recursive: true });
49
89
  const built = [];
90
+ const cache = {};
50
91
  for (const g of games) {
51
92
  const out = join(dist, 'games', g.id);
52
93
  rmSync(out, { recursive: true, force: true });
53
94
  mkdirSync(join(out, 'assets'), { recursive: true });
54
- const entry = join(g.dir, g.entry ?? 'src/main.ts');
55
- if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${g.entry ?? 'src/main.ts'} not found`);
95
+ const mode = g.build?.mode ?? 'bundle';
56
96
  const started = Date.now();
57
- const result = await esbuild.build({
58
- entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
59
- outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
60
- loader: { '.png': 'file', '.jpg': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.glb': 'file', '.svg': 'file' },
61
- assetNames: '[name]-[hash]',
62
- }).catch((error) => {
63
- const first = error.errors?.[0];
64
- throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
65
- });
66
- const html = join(g.dir, 'index.html');
67
- if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
68
- writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
69
- if (existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
97
+ let warnings = 0;
98
+ const bundle = async (entryRel) => {
99
+ const entry = join(g.dir, entryRel);
100
+ if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
101
+ const result = await esbuild.build({
102
+ entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
103
+ outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
104
+ loader: LOADERS, assetNames: '[name]-[hash]',
105
+ }).catch((error) => {
106
+ const first = error.errors?.[0];
107
+ throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
108
+ });
109
+ warnings += result.warnings.length;
110
+ };
111
+ if (mode === 'static') {
112
+ copyStatic(g.dir, out);
113
+ writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
114
+ if (g.entry) await bundle(g.entry);
115
+ const html = readFileSync(join(out, 'index.html'), 'utf8');
116
+ if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
117
+ } else if (mode === 'command') {
118
+ const command = String(g.build.command ?? 'npm run build');
119
+ const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
120
+ if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
121
+ const built = join(g.dir, String(g.build.out ?? 'dist'));
122
+ if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
123
+ cpSync(built, out, { recursive: true });
124
+ writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
125
+ } else {
126
+ await bundle(g.entry ?? 'src/main.ts');
127
+ const html = join(g.dir, 'index.html');
128
+ if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
129
+ writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
130
+ }
131
+ if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
132
+ if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
70
133
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
71
134
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
72
- const bytes = statSync(join(out, 'assets', 'main.js')).size;
73
- built.push({ id: g.id, name: g.name, bytes, ms: Date.now() - started, warnings: result.warnings.length });
74
- log(`built ${g.id} (${Math.round(bytes / 1024)} KB)`);
135
+ const main = join(out, 'assets', 'main.js');
136
+ const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
137
+ built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings });
138
+ log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
75
139
  }
76
140
  const all = listGames(root);
141
+ // Songs and videos (music/ and videos/ manifests): rebuilt with every full build; a one-game build keeps them.
142
+ const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
143
+ let media = null;
144
+ if (!only) media = buildMedia(root, dist, { r2, log });
145
+ else {
146
+ try { const prev = JSON.parse(readFileSync(join(dist, 'games.json'), 'utf8')); media = { songs: prev.songs ?? [], videos: prev.videos ?? [], skipped: [] }; } catch { media = { songs: [], videos: [], skipped: [] }; }
147
+ }
77
148
  const catalogue = {
78
149
  studio: { name: studio.name, slug: studio.slug, version: STUDIO_VERSION },
79
150
  games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
80
151
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
81
152
  roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
153
+ ...(g.screen ? { screen: g.screen } : {}),
82
154
  })),
155
+ songs: media.songs,
156
+ videos: media.videos,
83
157
  };
84
158
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
85
- return { ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id) };
159
+ return {
160
+ ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
161
+ songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
162
+ };
86
163
  }