@homie-rocks/studio 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
10
10
  and the person approves Cloudflare once in their browser.
11
11
 
12
12
  ```sh
13
- npx -y --package=https://homie.rocks/npm/homie-studio-0.5.0.tgz homie-studio new ./night-owls --name "Night Owls"
13
+ npx -y --package=https://homie.rocks/npm/homie-studio-0.6.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
@@ -18,8 +18,43 @@ npx homie-studio check crown-thief --url http://127.0.0.1:8787
18
18
  npx homie-studio deploy --plan # what deploy will create, and what it costs; changes nothing
19
19
  npx homie-studio deploy # the studio's own Cloudflare
20
20
  npx homie-studio publish # the homie.rocks directory
21
+ npx homie-studio stats # the studio's own numbers, for its owner
21
22
  ```
22
23
 
24
+ ## Rooms of up to 32
25
+
26
+ Every room of a game has the seats its netplay manifest names: game.json's `netplay`
27
+ block (`maxPlayers`), or a `netplay.json` beside game.json or in the game's build
28
+ (`maxPlayers` or `players.max`), else game.json's `players.max`, else 8; at most 32.
29
+ Bots, a late joiner taking a bot's body and a new host after the old one leaves work
30
+ the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
31
+ on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
32
+
33
+ ## The studio's own stats
34
+
35
+ The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
36
+ people playing at once, rounds finished, songs played, videos watched, and which site
37
+ sent each visitor (a host name: homie.rocks, another studio, search, the web, or a
38
+ `?via=` tag). They are daily counters in the studio's own D1, on the free plan (a write
39
+ per visit and per round, never per frame), with no cookie on a visitor and nothing sent
40
+ to anyone. Prefetches, crawlers and house QA are not counted. Only the owner reads them:
41
+
42
+ ```sh
43
+ npx homie-studio stats [--range 7d] [--game crown-thief] # prints them
44
+ npx homie-studio stats link # a one-time link: /_studio/stats in the owner's browser
45
+ npx homie-studio stats key # a 1-hour read key for the Homie MCP tool studio_stats
46
+ npx homie-studio stats share on # tell the directory "played this week" (two numbers)
47
+ ```
48
+
49
+ Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
50
+ studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
51
+
52
+ ## The site's address
53
+
54
+ `deploy` keeps the `workers.dev` address in `.studio/local.json`, which git ignores: it
55
+ names the Cloudflare account, often after its owner. A custom domain goes in studio.json
56
+ as `cloudflare.domain` and is what the directory claim, `publish`, `check` and `stats` use.
57
+
23
58
  ## No payment method needed
24
59
 
25
60
  `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
@@ -61,7 +96,8 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
61
96
  | `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
62
97
  | `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
98
  | `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>`). |
99
+ | `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`); `/music/<slug>/` and `/videos/<slug>/` are song and video pages (their files served with byte ranges, from the site or from storage at `/media/<key>`). `seats.mjs`: room sizes (up to 32). |
100
+ | `worker/stats.mjs`, `worker/stats-page.mjs`, `lib/stats.mjs` | The studio's own stats: what is counted and how, the owner-only `/api/stats` and `/_studio/stats`, and `homie-studio stats`. |
65
101
  | `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. |
66
102
  | `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
67
103
  | `starters/gem-rush/` | The reference multiplayer starter. |
@@ -21,6 +21,11 @@
21
21
  * homie-studio media put <file> [--as <key>]
22
22
  * homie-studio media list
23
23
  * homie-studio status
24
+ * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
25
+ * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
26
+ * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
27
+ * homie-studio stats revoke (every stats key and page session ends)
28
+ * homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
24
29
  *
25
30
  * Every command prints a few lines for a person; --json prints the result.
26
31
  */
@@ -34,9 +39,10 @@ import { publish } from '../lib/directory.mjs';
34
39
  import { importPort, planPort } from '../lib/port.mjs';
35
40
  import { portCheck } from '../lib/port-check.mjs';
36
41
  import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
37
- import { newStudio } from '../lib/scaffold.mjs';
38
- import { listGames, newGame, readStudio, remixGame, requireStudio, starters } from '../lib/studio.mjs';
42
+ import { ensureStatsMigration, newStudio } from '../lib/scaffold.mjs';
43
+ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters } from '../lib/studio.mjs';
39
44
  import { STUDIO_VERSION } from '../lib/version.mjs';
45
+ import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
40
46
 
41
47
  const argv = process.argv.slice(2);
42
48
  const flags = new Map();
@@ -85,7 +91,9 @@ function print(result) {
85
91
  }
86
92
  break;
87
93
  case 'deploy':
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' : ''}.`,
94
+ lines.push(`Live: ${result.url}`, ...(result.workersDev && result.workersDev !== result.url ? [` also at ${result.workersDev}`] : []),
95
+ ...(result.local ? [` (the workers.dev address names your Cloudflare account, so it is kept in ${result.local} on this computer, never in studio.json)`] : []),
96
+ ...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
97
  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
98
  break;
91
99
  case 'deploy plan':
@@ -116,6 +124,29 @@ function print(result) {
116
124
  lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
117
125
  ...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
118
126
  break;
127
+ case 'stats': {
128
+ const t = result.totals;
129
+ const n = (x) => Number(x || 0).toLocaleString('en-US');
130
+ lines.push(`${result.studio ?? 'Studio'}: ${result.range.from} to ${result.range.to} (UTC)${result.only ? `, ${result.only.kind} ${result.only.id}` : ''}`,
131
+ ` ${n(t.visits)} visits · ${n(t.plays)} Play presses · ${n(t.rooms)} rooms opened · ${n(t.rounds)} rounds finished (${n(t.roundsWithPeople)} with people, ${n(t.peopleInRounds)} people in them)`,
132
+ ` most playing at once: ${n(t.peakPlayers)} (${n(t.peakInOneRoom)} in one room) · playing now: ${n(t.playingNow)} · songs played: ${n(t.songPlays)} · videos watched: ${n(t.videoViews)}`,
133
+ ` came from homie.rocks: ${n(result.crossings.fromHub)} · other studios: ${n(result.crossings.fromStudios)} · search: ${n(result.crossings.fromSearch)} · the web: ${n(result.crossings.fromWeb)} · ?via= links: ${n(result.crossings.fromLinks)}`);
134
+ for (const g of result.games) lines.push(` game ${g.id}: ${n(g.visits)} visits, ${n(g.plays)} plays, ${n(g.rooms)} rooms, ${n(g.rounds)} rounds, peak ${n(g.peakPlayers)}, now ${n(g.playingNow)}`);
135
+ for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
136
+ for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
137
+ for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
138
+ break;
139
+ }
140
+ case 'stats key':
141
+ lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
142
+ break;
143
+ case 'stats link':
144
+ lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
145
+ break;
146
+ case 'stats revoke':
147
+ case 'stats share':
148
+ lines.push(result.message);
149
+ break;
119
150
  case 'check':
120
151
  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.`,
121
152
  ...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
@@ -142,7 +173,7 @@ async function main() {
142
173
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
143
174
  if (cmd === 'port' && sub === 'check') {
144
175
  const game = positional[2] ?? listGames(root)[0]?.id;
145
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
176
+ const url = flags.get('url') ?? siteUrl(root);
146
177
  if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
147
178
  return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
148
179
  }
@@ -152,13 +183,13 @@ async function main() {
152
183
  if (cmd === 'build') return build(root, { only: positional[1] ?? null, log });
153
184
  if (cmd === 'status') {
154
185
  const studio = readStudio(root);
155
- return { ok: true, command: 'status', root, studio, games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
186
+ return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
156
187
  }
157
188
  if (cmd === 'dev' && flags.has('stop')) return stopDev(root);
158
189
  if (cmd === 'dev') return dev(root);
159
190
  if (cmd === 'check') {
160
191
  const game = positional[1] ?? listGames(root)[0]?.id;
161
- const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
192
+ const url = flags.get('url') ?? siteUrl(root);
162
193
  if (!url) return { ok: false, command: 'check', why: 'give --url (the local dev address or the live site)' };
163
194
  return check({ url, game, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
164
195
  }
@@ -171,12 +202,17 @@ async function main() {
171
202
  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
203
  }
173
204
  if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
205
+ if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
206
+ if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
207
+ if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
208
+ if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
209
+ if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
174
210
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
175
211
  if (cmd === 'media' && sub === 'list') {
176
212
  const studio = readStudio(root);
177
213
  const r2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
178
214
  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') };
215
+ return { ok: true, command: 'media list', r2, site: siteUrl(root, studio), music: view('music'), videos: view('videos') };
180
216
  }
181
217
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
182
218
  }
@@ -222,6 +258,8 @@ async function dev(root) {
222
258
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
223
259
  const studio = readStudio(root);
224
260
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
261
+ const added = ensureStatsMigration(root);
262
+ if (added) log(`added ${added} (the studio's own stats)`);
225
263
  await new Promise((done) => {
226
264
  const m = spawn(bin, ['d1', 'migrations', 'apply', studio.cloudflare.d1, '--local'], { cwd: join(root, 'site'), env, stdio: ['ignore', 'ignore', 'inherit'] });
227
265
  m.on('close', done);
@@ -253,8 +291,10 @@ function mediaPut(root, file, as) {
253
291
  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'] });
254
292
  p.on('close', (code) => {
255
293
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
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 });
294
+ // The manifest is committed: it names the file by its path on the site, never the workers.dev address.
295
+ const rec = recordUpload(root, rel, key, statSync(file).size);
296
+ const site = siteUrl(root, studio);
297
+ done({ ok: true, command: 'media put', key, manifest: rec.manifest, entry: rec.entry, url: site ? `${site}/media/${key}` : null });
258
298
  });
259
299
  });
260
300
  }
package/lib/build.mjs CHANGED
@@ -23,6 +23,7 @@ import { createRequire } from 'node:module';
23
23
  import { join, relative } from 'node:path';
24
24
  import { buildMedia } from './media.mjs';
25
25
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
26
+ import { SEAT_MAX } from '../worker/seats.mjs';
26
27
  import { STUDIO_VERSION } from './version.mjs';
27
28
 
28
29
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
@@ -76,6 +77,31 @@ export function sourceOf(dir, id) {
76
77
  return { v: 1, kind: 'homie-game-source', id, files };
77
78
  }
78
79
 
80
+ function readJson(path) {
81
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
82
+ }
83
+
84
+ /**
85
+ * A game's netplay manifest: game.json's `netplay` block, over a `netplay.json` beside game.json or in the game's
86
+ * built output (a ported Vite game ships public/netplay.json), over nothing.
87
+ */
88
+ export function netplayOf(g, out = null) {
89
+ const file = readJson(join(g.dir, 'netplay.json')) ?? (out ? readJson(join(out, 'netplay.json')) : null) ?? {};
90
+ return { ...(file && typeof file === 'object' ? file : {}), ...(g.netplay && typeof g.netplay === 'object' ? g.netplay : {}) };
91
+ }
92
+
93
+ /**
94
+ * How many seats a room of this game has: the netplay manifest's `maxPlayers` (or `players.max`), else game.json's
95
+ * `players.max`, else 8; at most SEAT_MAX (32). `asked` is what the game named, so a build can say it was capped.
96
+ */
97
+ export function seatsFor(g, net = netplayOf(g)) {
98
+ const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
99
+ const asked = named ?? 8;
100
+ const max = Math.min(SEAT_MAX, asked);
101
+ const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
102
+ return { min: Math.min(max, minNamed ?? 1), max, asked };
103
+ }
104
+
79
105
  export async function build(root, { only = null, log = () => {} } = {}) {
80
106
  const require = createRequire(join(root, 'package.json'));
81
107
  let esbuild;
@@ -134,7 +160,9 @@ export async function build(root, { only = null, log = () => {} } = {}) {
134
160
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
135
161
  const main = join(out, 'assets', 'main.js');
136
162
  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 });
163
+ const seats = seatsFor(g, netplayOf(g, out));
164
+ if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
165
+ built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max });
138
166
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
139
167
  }
140
168
  const all = listGames(root);
@@ -146,12 +174,21 @@ export async function build(root, { only = null, log = () => {} } = {}) {
146
174
  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
175
  }
148
176
  const catalogue = {
149
- studio: { name: studio.name, slug: studio.slug, version: STUDIO_VERSION },
150
- games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
151
- id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
152
- roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
153
- ...(g.screen ? { screen: g.screen } : {}),
154
- })),
177
+ studio: {
178
+ name: studio.name, slug: studio.slug, version: STUDIO_VERSION,
179
+ // studio.json `stats.share`: the site tells the directory two numbers for the hub (played this week).
180
+ ...(studio.stats?.share === true ? { stats: { share: true }, directory: studio.homie?.directory ?? 'https://homie.rocks' } : {}),
181
+ },
182
+ games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => {
183
+ // The seats come from the game's netplay manifest (NETPLAY.md §3): what the Worker gives every room of it.
184
+ const net = netplayOf(g, join(dist, 'games', g.id));
185
+ const { min, max } = seatsFor(g, net);
186
+ return {
187
+ id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
188
+ roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
189
+ ...(g.screen ? { screen: g.screen } : {}),
190
+ };
191
+ }),
155
192
  songs: media.songs,
156
193
  videos: media.videos,
157
194
  };
@@ -22,8 +22,8 @@ import { spawnSync } from 'node:child_process';
22
22
  import { existsSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { build } from './build.mjs';
25
- import { wranglerConfig } from './scaffold.mjs';
26
- import { readStudio, writeStudio } from './studio.mjs';
25
+ import { ensureLocalIgnored, ensureStatsMigration, wranglerConfig } from './scaffold.mjs';
26
+ import { LOCAL_STATE, isWorkersDev, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
27
27
 
28
28
  const ANSI = /\u001b\[[0-9;]*m/g;
29
29
 
@@ -32,7 +32,8 @@ export function wranglerBin(root) {
32
32
  return existsSync(local) ? local : null;
33
33
  }
34
34
 
35
- function runner(root, env) {
35
+ /** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
36
+ export function runner(root, env) {
36
37
  const bin = wranglerBin(root);
37
38
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
38
39
  return (args, { cwd = join(root, 'site'), input } = {}) => {
@@ -95,7 +96,7 @@ export function deployPlan(root) {
95
96
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
96
97
  cloudflare: [
97
98
  { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
98
- { kind: 'D1 database', name: cf.d1, what: 'the directory claim and every finished round', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
99
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, and the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor)', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
99
100
  { kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
100
101
  { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
101
102
  storage
@@ -144,7 +145,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
144
145
  if (!created.has(`worker:${cf.worker}`)) {
145
146
  const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
146
147
  announced.push(
147
- `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1}, and the Durable Objects Table and Lobby (SQLite-backed).`,
148
+ `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1} (rounds, and the studio's own stats: counts for the owner, never a visitor's identity), and the Durable Objects Table and Lobby (SQLite-backed).`,
148
149
  'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
149
150
  `The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
150
151
  );
@@ -186,6 +187,8 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
186
187
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
187
188
 
188
189
  writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
190
+ const added = ensureStatsMigration(root);
191
+ if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
189
192
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
190
193
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
191
194
  step('D1 migrations applied');
@@ -194,10 +197,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
194
197
  const dep = w(['deploy']);
195
198
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
196
199
  remember(`worker:${cf.worker}`);
197
- const url = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? cf.url ?? null;
198
- step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url });
200
+ // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
201
+ // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
202
+ if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
203
+ const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? readLocal(root).url ?? (isWorkersDev(cf.url) ? cf.url : null);
204
+ if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString() });
205
+ const movedOut = isWorkersDev(cf.url);
206
+ if (movedOut) {
207
+ cf.url = null;
208
+ writeStudio(root, { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, url: null } });
209
+ step(`studio.json no longer keeps the workers.dev address (it names the Cloudflare account); it is in ${LOCAL_STATE} on this computer. An earlier commit of studio.json still has it in the repository's history.`);
210
+ }
211
+ const url = siteUrl(root);
212
+ step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url, ...(workersDev && workersDev !== url ? { workersDev } : {}) });
199
213
 
200
- // The homie.rocks directory claim: the site serves it, which proves this studio controls the site.
214
+ // The homie.rocks directory claim, for the site's public address (its own domain when it has one): the site
215
+ // serves it, which proves this studio controls the site.
201
216
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
202
217
  let claim = null;
203
218
  if (url) {
@@ -212,10 +227,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
212
227
  }
213
228
  }
214
229
 
215
- const next = { ...cf, accountId, url, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort(), deployedAt: new Date().toISOString() };
216
- writeStudio(root, { ...studio, cloudflare: next });
230
+ // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
231
+ const { url: _url, deployedAt: _at, ...kept } = cf;
232
+ const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
233
+ writeStudio(root, { ...readStudio(root), cloudflare: next });
217
234
  return {
218
- ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
235
+ ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
219
236
  games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
220
237
  songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
221
238
  videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
package/lib/directory.mjs CHANGED
@@ -4,12 +4,12 @@
4
4
  * claim the directory gave it (`homie-studio deploy` stores it in D1), so
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
- import { readStudio } from './studio.mjs';
7
+ import { readStudio, siteUrl } from './studio.mjs';
8
8
 
9
9
  export async function publish(root, { homie, site } = {}) {
10
10
  const studio = readStudio(root);
11
11
  const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
12
- const url = site || studio.cloudflare?.url;
12
+ const url = site || siteUrl(root, studio);
13
13
  if (!url) return { ok: false, command: 'publish', why: 'this studio has no live site yet: run `npm run deploy` first' };
14
14
  const res = await fetch(`${directory}/api/studio/publish`, {
15
15
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url }),
package/lib/media.mjs CHANGED
@@ -111,7 +111,7 @@ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
111
111
  }
112
112
 
113
113
  /** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
114
- export function recordUpload(root, rel, key, bytes, siteUrl) {
114
+ export function recordUpload(root, rel, key, bytes) {
115
115
  const kind = rel.startsWith('videos/') ? 'videos' : 'music';
116
116
  const m = readManifest(root, kind);
117
117
  let hit = false;
@@ -120,7 +120,7 @@ export function recordUpload(root, rel, key, bytes, siteUrl) {
120
120
  if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
121
121
  }
122
122
  }
123
- if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: siteUrl ? `${siteUrl}/media/${key}` : null, at: new Date().toISOString() }];
123
+ if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: `/media/${key.split('/').map(encodeURIComponent).join('/')}`, at: new Date().toISOString() }];
124
124
  writeManifest(root, kind, m);
125
125
  return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
126
126
  }
package/lib/scaffold.mjs CHANGED
@@ -19,10 +19,11 @@
19
19
  * lists every file it writes, so the person sees exactly what changed.
20
20
  */
21
21
  import { execFileSync } from 'node:child_process';
22
- import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
23
23
  import { homedir } from 'node:os';
24
24
  import { dirname, join, relative, resolve } from 'node:path';
25
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
+ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
26
27
 
27
28
  export const COMPAT_DATE = '2026-06-01';
28
29
  export const WRANGLER_VERSION = '4.126.0';
@@ -74,7 +75,7 @@ directory lists its games; homie.rocks does not host them.
74
75
  | \`music/\`, \`videos/\` | Songs, scores, loops; trailers, music videos, cutscenes. \`manifest.json\` lists each one (\`node_modules/@homie-rocks/studio/media/MEDIA.md\`); a published entry gets a page at \`/music/<slug>/\` or \`/videos/<slug>/\`, served from the site itself (files up to 25 MiB) or, for larger media, from the studio's storage once it has storage (see below; \`npx --no-install homie-studio media put <file>\`). Large files never go into git. The Homie plugin's \`music\` and \`video\` skills make them. |
75
76
  | \`posts/\` | Markdown the studio publishes. |
76
77
  | \`site/\` | The studio's Worker (\`src/worker.mjs\`), D1 migrations, \`wrangler.jsonc\`. |
77
- | \`studio.json\` | The studio's name, slug and Cloudflare resource names. |
78
+ | \`studio.json\` | The studio's name, slug, Cloudflare resource names, custom domain and stats sharing. \`.studio/\` (git-ignored) is this computer's own state. |
78
79
  | \`.claude/skills/\` | Skills only this studio uses. Homie's own skills come from the Homie plugin. |
79
80
 
80
81
  ## Commands (all through the pinned CLI in node_modules)
@@ -107,6 +108,13 @@ studio's pinned copy, never a registry lookup of the bare name.
107
108
  is a separate step the person agrees to; nothing else needs it.
108
109
  - \`npx --no-install homie-studio publish\` — list this studio's games in the homie.rocks directory
109
110
  (or call the Homie MCP tool \`studio_publish\`).
111
+ - \`npx --no-install homie-studio stats\` — the studio's own numbers, for its owner: visits, Play presses,
112
+ rooms, the most people playing at once, rounds, songs played, videos watched, and where visitors came
113
+ from (homie.rocks, other studios, search, the web). \`stats link\` gives the owner a one-time link to the
114
+ private page \`/_studio/stats\` in their own browser; \`stats key\` a short read key for the Homie MCP tool
115
+ \`studio_stats\` (never paste a key anywhere else). \`stats share on\` tells the directory two numbers
116
+ (played this week). The site counts and never tracks: no cookie on a visitor, no person identified,
117
+ nothing sent anywhere; house QA and \`check\` runs are not counted.
110
118
 
111
119
  ## Making games
112
120
 
@@ -125,6 +133,10 @@ studio's pinned copy, never a registry lookup of the bare name.
125
133
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
126
134
  keychain. Never write a key, token or password into this repository.
127
135
  - Never touch a Cloudflare resource this studio did not create (\`studio.json\` says which).
136
+ - The site's workers.dev address names the Cloudflare account (often after its owner): \`deploy\` keeps it in
137
+ \`.studio/local.json\`, which git ignores. Never copy it into a committed file. A custom domain goes in
138
+ studio.json as \`cloudflare.domain\`.
139
+ - A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
128
140
  - Nothing in this studio needs \`~/.homie\` or a Homie box.
129
141
 
130
142
  ## Beta
@@ -170,6 +182,8 @@ CREATE INDEX IF NOT EXISTS rounds_game_at ON rounds (game, at);
170
182
  `;
171
183
 
172
184
  const GITIGNORE = `node_modules/
185
+ # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
186
+ .studio/
173
187
  site/dist/
174
188
  site/.wrangler/
175
189
  .wrangler/
@@ -197,7 +211,12 @@ export function studioFiles({ name, slug, homie }) {
197
211
  name, slug,
198
212
  homie: { studio: STUDIO_VERSION, directory: homie },
199
213
  // r2 stays null until `homie-studio storage add`: a new studio deploys with no R2 at all.
200
- cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, url: null, created: [] },
214
+ // `domain`: the studio's own domain once it has one (e.g. "night-owls.example"). The workers.dev address never
215
+ // goes here: it names the Cloudflare account, so deploy keeps it in .studio/local.json (git-ignored).
216
+ cloudflare: { worker, d1: `${slug}-db`, r2: null, accountId: null, domain: null, created: [] },
217
+ // The site counts visits, plays, rooms, rounds and songs for the owner only (`homie-studio stats`); `share`
218
+ // also tells the homie.rocks directory "played this week" (two numbers) for the hub.
219
+ stats: { share: false },
201
220
  };
202
221
  const pkg = {
203
222
  name: `${slug}-studio`,
@@ -224,11 +243,36 @@ export function studioFiles({ name, slug, homie }) {
224
243
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
225
244
  `,
226
245
  'site/migrations/0001_studio.sql': MIGRATION,
246
+ [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
227
247
  'site/wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2 }),
228
248
  '.claude/skills/.gitkeep': '',
229
249
  };
230
250
  }
231
251
 
252
+ /**
253
+ * A studio made before 0.6.0 has no stats migration: add it (the template owns site/migrations/), so the next
254
+ * `d1 migrations apply` makes the counters. Returns the file it wrote, or null when it was there.
255
+ */
256
+ export function ensureStatsMigration(root) {
257
+ const file = join(root, 'site', 'migrations', STATS_MIGRATION_FILE);
258
+ if (existsSync(file)) return null;
259
+ mkdirSync(dirname(file), { recursive: true });
260
+ writeFileSync(file, STATS_MIGRATION);
261
+ return `site/migrations/${STATS_MIGRATION_FILE}`;
262
+ }
263
+
264
+ /**
265
+ * A studio made before 0.6.0 committed nothing under .studio/ yet: make sure git leaves it out before the first
266
+ * deploy writes the workers.dev address there. Returns true when it added the line.
267
+ */
268
+ export function ensureLocalIgnored(root) {
269
+ const file = join(root, '.gitignore');
270
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
271
+ if (/^\/?\.studio\/?$/m.test(text)) return false;
272
+ writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This computer's own state: the site's workers.dev address (it names the Cloudflare account).\n.studio/\n`);
273
+ return true;
274
+ }
275
+
232
276
  function insideGit(dir) {
233
277
  try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
234
278
  }