@homie-rocks/studio 0.12.0 → 0.13.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/lib/office.mjs ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
3
+ *
4
+ * homie-studio office [--url <site>] every live room of every game and who is in it, now
5
+ * homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office,
6
+ * or --to /<game>/play: a private game on the owner's phone)
7
+ * homie-studio office key [--hours 1] an office key for the Homie MCP's owner tools (studio_office,
8
+ * room_kick, room_announce, room_close, game_launch_state)
9
+ * homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
10
+ * homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
11
+ * homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
12
+ * homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
13
+ * homie-studio office close <game> <room> [--minutes 10] [--reopen]
14
+ * homie-studio office revoke every office key ends; every play ticket and pending ask too
15
+ *
16
+ * THE PROOF OF OWNERSHIP IS THE STUDIO'S OWN CLOUDFLARE LOGIN, as for the stats: a key is minted here and only its
17
+ * SHA-256 goes into the studio's D1. Looking, announcing and inviting happen at once. Kick, mute, closing a room and
18
+ * a game's launch state, remix switch or room size are only ASKED for here: the answer is a one-time sign-in link
19
+ * that opens the ask in the owner's own browser, where one tap does it (the AI that ran this cannot).
20
+ */
21
+ import { readStudio } from './studio.mjs';
22
+ import { runner } from './cloudflare.mjs';
23
+ import { drop, loopback, mint, siteOf } from './stats.mjs';
24
+
25
+ const HOUR = 3600_000;
26
+ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
27
+
28
+ /** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
29
+ async function withKey(root, url, fn) {
30
+ const { studio, site } = siteOf(root, url);
31
+ if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
32
+ const local = loopback(site);
33
+ const k = mint(root, studio, 'office', 10 * 60_000, { local });
34
+ if (!k.ok) return { ok: false, why: /no such table: stats_keys/.test(k.why) ? k.why : k.why };
35
+ try {
36
+ const call = async (path, body = null) => {
37
+ const res = await fetch(`${site}${path}`, {
38
+ method: body ? 'POST' : 'GET',
39
+ headers: { authorization: `Bearer ${k.key}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
40
+ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(20_000),
41
+ });
42
+ const out = await res.json().catch(() => ({ ok: false, message: `${site}${path} answered ${res.status}` }));
43
+ return { status: res.status, ...out };
44
+ };
45
+ return await fn(call, { studio, site, local });
46
+ } catch (error) {
47
+ return { ok: false, why: `could not reach ${site}: ${error.message}` };
48
+ } finally { drop(root, studio, k.hash, { local }); }
49
+ }
50
+
51
+ /** A one-time sign-in link for the owner's browser that lands on `to` (30 minutes). */
52
+ function signinTo(root, url, to) {
53
+ const { studio, site } = siteOf(root, url);
54
+ if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first' };
55
+ const k = mint(root, studio, 'signin', 30 * 60_000, { local: loopback(site) });
56
+ if (!k.ok) return k;
57
+ return { ok: true, site, link: `${site}/_studio/signin?k=${k.key}${to && to !== '/_studio/stats' ? `&to=${encodeURIComponent(to)}` : ''}`, expiresAt: k.expiresAt };
58
+ }
59
+
60
+ const askedFor = (root, url, r, command) => {
61
+ if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
62
+ if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
63
+ const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
64
+ return {
65
+ ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
66
+ link: link.ok ? link.link : r.ask.confirm,
67
+ use: `Waiting for the owner: ${r.ask.what} Give the owner this link to open in their own browser: it signs that browser in (once, within 30 minutes) and shows the ask with one button. It lasts 15 minutes; nothing happens unless the owner taps.`,
68
+ };
69
+ };
70
+
71
+ export async function officeShow(root, { url } = {}) {
72
+ const r = await withKey(root, url, (call) => call('/_studio/api/office'));
73
+ if (!r.ok) return { ok: false, command: 'office', why: r.why ?? r.message ?? 'the office did not answer' };
74
+ return { command: 'office', ...r, ok: true };
75
+ }
76
+
77
+ export function officeLink(root, { url, to } = {}) {
78
+ const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
79
+ const r = signinTo(root, url, target);
80
+ if (!r.ok) return { ok: false, command: 'office link', why: r.why };
81
+ return {
82
+ ...r, command: 'office link', to: target,
83
+ use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, and keeps that browser signed in as the owner for 30 days (the office, the stats, and the owner\'s tools inside their own games). It is theirs alone; do not post it anywhere.',
84
+ };
85
+ }
86
+
87
+ export function officeKey(root, { url, hours } = {}) {
88
+ const { studio, site } = siteOf(root, url);
89
+ if (!site) return { ok: false, command: 'office key', why: 'this studio has no live site yet: `npm run deploy` first' };
90
+ const h = Math.max(1, Math.min(24 * 30, Math.floor(Number(hours) || 1)));
91
+ const k = mint(root, studio, 'office', h * HOUR, { local: loopback(site) });
92
+ if (!k.ok) return { ok: false, command: 'office key', why: k.why };
93
+ return {
94
+ ok: true, command: 'office key', site, key: k.key, expiresAt: k.expiresAt, hours: h,
95
+ use: `Pass { "site": "${site}", "key": "<this key>" } to the Homie MCP's owner tools (studio_office, room_announce, room_kick, room_close, game_launch_state). With it the AI sees the live rooms, announces and makes invites; a kick, a closed room or a launch state change is only asked for, and the owner confirms it with one tap. It also reads the stats (studio_stats). It ends at ${k.expiresAt}; \`homie-studio office revoke\` ends it sooner. Never paste it anywhere else.`,
96
+ };
97
+ }
98
+
99
+ export async function officeAnnounce(root, text, { url, game, room, seconds } = {}) {
100
+ if (!String(text ?? '').trim()) return { ok: false, command: 'office announce', why: 'usage: homie-studio office announce "<text>" [--game <id>] [--room <code>]' };
101
+ const r = await withKey(root, url, (call) => call('/_studio/api/announce', { text, ...(game ? { game } : {}), ...(room ? { room } : {}), ...(seconds ? { seconds: Number(seconds) } : {}) }));
102
+ return r.ok ? { ok: true, command: 'office announce', ...r, message: `Announced to ${r.people ?? 0} people in ${r.rooms ?? 0} room(s).` } : { ok: false, command: 'office announce', why: r.message ?? r.why };
103
+ }
104
+
105
+ export async function officeInvite(root, game, { url, label, uses, count, days } = {}) {
106
+ if (!GAME.test(String(game ?? ''))) return { ok: false, command: 'office invite', why: 'usage: homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1]' };
107
+ const r = await withKey(root, url, (call) => call('/_studio/api/invites', {
108
+ game, ...(label ? { label } : {}), uses: uses === 'any' ? 'any' : uses ? Number(uses) : 1, ...(count ? { count: Number(count) } : {}), ...(days ? { days: Number(days) } : {}),
109
+ }));
110
+ if (!r.ok) return { ok: false, command: 'office invite', why: r.message ?? r.why };
111
+ return { ok: true, command: 'office invite', game, launch: r.launch, invites: r.invites, note: r.launch === 'invite' ? null : `${game} is ${r.launch} now: invites let people in once it is an invite-only beta (homie-studio office launch ${game} invite).` };
112
+ }
113
+
114
+ export async function officeLaunch(root, game, state, { url, remixable, max } = {}) {
115
+ if (!GAME.test(String(game ?? '')) || (state !== undefined && state !== null && !['private', 'invite', 'public'].includes(state))) {
116
+ return { ok: false, command: 'office launch', why: 'usage: homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]' };
117
+ }
118
+ const body = { game, ...(state ? { launch: state } : {}), ...(remixable !== undefined ? { remix: remixable === 'on' || remixable === true } : {}), ...(max !== undefined ? { maxPlayers: max === 'game' ? null : Number(max) } : {}) };
119
+ const r = await withKey(root, url, (call) => call('/_studio/api/game', body));
120
+ return askedFor(root, url, r, 'office launch');
121
+ }
122
+
123
+ export async function officeKick(root, game, room, who, { url, minutes } = {}) {
124
+ if (!GAME.test(String(game ?? '')) || !room || who === undefined) return { ok: false, command: 'office kick', why: 'usage: homie-studio office kick <game> <room> <seat number | name> [--minutes 10]' };
125
+ const r = await withKey(root, url, async (call) => {
126
+ const office = await call('/_studio/api/office');
127
+ const g = (office.games ?? []).find((x) => x.id === game);
128
+ const rm = (g?.rooms ?? []).find((x) => x.room === room || x.label.toLowerCase() === String(room).toLowerCase());
129
+ if (!rm) return { ok: false, message: `nobody is in ${room} of ${game} now (homie-studio office lists the rooms)` };
130
+ const seat = /^\d+$/.test(String(who)) ? Number(who) - 1 : null;
131
+ const c = rm.clients.find((x) => (seat !== null && x.seat === seat) || String(x.name).toLowerCase() === String(who).toLowerCase());
132
+ if (!c) return { ok: false, message: `no "${who}" in ${rm.label} (seats are numbered from 1)` };
133
+ return call('/_studio/api/kick', { game, room: rm.room, id: c.id, minutes: Number(minutes) || 10 });
134
+ });
135
+ return askedFor(root, url, r, 'office kick');
136
+ }
137
+
138
+ export async function officeClose(root, game, room, { url, minutes, reopen } = {}) {
139
+ if (!GAME.test(String(game ?? '')) || !room) return { ok: false, command: 'office close', why: 'usage: homie-studio office close <game> <room> [--minutes 10] [--reopen]' };
140
+ const r = await withKey(root, url, (call) => call('/_studio/api/close', { game, room, minutes: Number(minutes) || 10, reopen: Boolean(reopen) }));
141
+ return askedFor(root, url, r, 'office close');
142
+ }
143
+
144
+ /** Every office key ends, and the office secret is made anew: every play ticket and every signed control ends with it. */
145
+ export function officeRevoke(root, { url } = {}) {
146
+ const { studio, site } = siteOf(root, url);
147
+ const local = site ? loopback(site) : false;
148
+ const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
149
+ const res = w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', "DELETE FROM stats_keys WHERE kind = 'office'; DELETE FROM meta WHERE key = 'office_key'; UPDATE office_asks SET state = 'expired' WHERE state = 'pending';"]);
150
+ return res.code === 0
151
+ ? { ok: true, command: 'office revoke', site, message: 'Every office key has ended, and every play ticket and pending ask with it (a browser let into a private or invite-only game gets a new ticket the next time it opens the game). Invite passes and the owner\'s signed-in browsers stay; `homie-studio stats revoke` ends those sessions.' }
152
+ : { ok: false, command: 'office revoke', why: res.out.trim().split('\n').slice(-3).join(' ') };
153
+ }
154
+
155
+ /** One line per room for a person (the CLI's print). */
156
+ export function officeLines(r) {
157
+ const lines = [`${r.studio}: ${r.playing ? `${r.playing} playing now` : 'nobody playing right now'} (${r.site})`];
158
+ const now = r.now ?? Date.now();
159
+ const mins = (ms) => `${Math.max(0, Math.round(ms / 60000))} min`;
160
+ for (const g of r.games ?? []) {
161
+ lines.push(` ${g.name} (${g.id}): ${g.launch}${g.launch === 'public' ? '' : ' (not listed)'}, ${g.remix ? 'remixable' : 'source closed'}, rooms of ${g.maxPlayers}${g.maxSet ? ` (the game's own: ${g.seats})` : ''}`);
162
+ for (const i of (g.invites ?? []).filter((x) => x.open)) lines.push(` invite ${i.code}${i.label ? ` (${i.label})` : ''}: ${i.uses}${i.maxUses ? ` of ${i.maxUses}` : ''} used · ${i.link}`);
163
+ if (!g.rooms.length) lines.push(' no live rooms');
164
+ for (const rm of g.rooms) {
165
+ const round = rm.round ? `round ${rm.round.n} ${rm.round.phase}` : 'no round';
166
+ lines.push(` ${rm.label}${rm.public ? '' : ' (named)'}: ${rm.players}/${rm.max} players, ${rm.bots} bots, ${round}, up ${rm.openedAt ? mins(now - rm.openedAt) : '?'}${rm.closedUntil ? `, CLOSED for ${mins(rm.closedUntil - now)}` : ''}`);
167
+ for (const c of rm.clients) lines.push(` ${c.seat !== null ? `seat ${c.seat + 1}` : 'watching'}: ${c.name} · ${c.device}${c.role === 'host' ? ' · host' : ''} · ${c.as}${c.muted ? ' · muted' : ''} · here ${mins(now - c.joinedAt)}`);
168
+ for (const b of rm.bans ?? []) lines.push(` kicked: ${b.name} (back in ${mins(b.until - now)})`);
169
+ }
170
+ }
171
+ return lines;
172
+ }
package/lib/progress.mjs CHANGED
@@ -57,6 +57,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, sta
57
57
  import { extname, join } from 'node:path';
58
58
  import { refreshCodexFile } from './codex.mjs';
59
59
  import { readStudio, siteUrl } from './studio.mjs';
60
+ import { request, whyRefused } from './net.mjs';
60
61
 
61
62
  export const PROGRESS_KIND = 'homie-studio-progress';
62
63
  export const PROGRESS_DIR = join('.studio', 'progress');
@@ -89,19 +90,10 @@ const keyPath = (root, id) => join(dir(root), `${id}.key`);
89
90
  const currentPath = (root) => join(dir(root), 'current');
90
91
  const loopback = (url) => { try { return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname); } catch { return false; } };
91
92
 
92
- /*
93
- * A CLAUDE CODE CLOUD SESSION WHOSE NETWORK BLOCKS THE DIRECTORY. The default "Trusted" network reached homie.rocks
94
- * in a real cloud session (2026-10-01); a stricter one makes the session's proxy answer 403 with
95
- * `x-deny-reason: host_not_allowed`. Then, and only then, say which setting lets the card follow the build,
96
- * instead of "did not answer"; the build itself goes on with its local feed.
97
- */
93
+ /** Kept for callers of 0.11.0: whether an answer is the proxy refusing the host (lib/net.mjs says the rest). */
98
94
  export function networkWhy(res, at) {
99
- if (res?.status === 403 && /host_not_allowed/i.test(res.headers?.get?.('x-deny-reason') ?? '')) {
100
- let host = at;
101
- try { host = new URL(at).host; } catch { /* as given */ }
102
- return `this Claude Code cloud environment's network does not reach ${host}. In claude.ai/code, open the environment's settings, set Network access to Custom, add ${host} (keep "Also include default list of common package managers"), and start a new session; until then the build goes on with its local feed only`;
103
- }
104
- return null;
95
+ const r = whyRefused(res, null, '', at);
96
+ return r.needs === 'network' ? r.why : null;
105
97
  }
106
98
 
107
99
  /** The studio's live address for the card: an https origin (or this computer's own dev site), else null. */
@@ -154,23 +146,24 @@ async function pushDoc(root, id) {
154
146
  let key = null;
155
147
  try { key = readFileSync(keyPath(root, id), 'utf8').trim(); } catch { return null; }
156
148
  const { shared, ...body } = doc;
157
- const res = await fetch(`${shared.directory}/api/studio/progress/${shared.build}`, {
149
+ const sent = await request(`${shared.directory}/api/studio/progress/${shared.build}`, {
158
150
  method: 'PUT',
159
151
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': 'homie-studio' },
160
152
  body: JSON.stringify({ ...body, build: shared.build }),
161
- signal: AbortSignal.timeout(8000),
162
- });
163
- const blocked = networkWhy(res, shared.directory);
164
- if (blocked) {
165
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app cannot follow this build: ${blocked}`); });
166
- return null;
167
- }
168
- if (res.status === 404 || res.status === 410) {
169
- // The shared copy ended (24 hours, or the directory dropped it): keep the local feed, stop sending.
170
- mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; });
153
+ }, { timeout: 8000 });
154
+ if (!sent.ok) {
155
+ if (sent.status === 404 || sent.status === 410 || sent.needs === 'network') {
156
+ // The shared copy ended (24 hours, or the directory dropped it), or the proxy refuses the directory: keep the
157
+ // local feed, stop sending, and say why in its log.
158
+ mutateDoc(root, id, (d) => { d.shared = { ...d.shared, ended: true }; pushLog(d, `The Claude app's card stopped following this build: ${sent.why}`); });
159
+ return null;
160
+ }
161
+ // Anything else (a slow answer, a refused write): the next write tries again; the log keeps what happened once.
162
+ mutateDoc(root, id, (d) => { if (d.shared && d.shared.lastError !== sent.why) { d.shared = { ...d.shared, lastError: sent.why }; pushLog(d, `Sending to the card failed: ${sent.why}`); } });
171
163
  return null;
172
164
  }
173
- const reply = await res.json().catch(() => null);
165
+ const reply = sent.body;
166
+ if (readFeed(root, id)?.shared?.lastError) mutateDoc(root, id, (d) => { delete d.shared.lastError; });
174
167
  if (reply?.stop) {
175
168
  mutateDoc(root, id, (d) => {
176
169
  if (!d.stop?.requested) { d.stop = { requested: true, at: now(), by: 'person' }; pushLog(d, 'Stop pressed in the Claude app; stopping at the next safe point.'); }
@@ -468,36 +461,38 @@ export async function startProgress(root, { what = 'game', id, title, budget, un
468
461
  writeAtomic(currentPath(root), `${build}\n`);
469
462
  let shared = null;
470
463
  let why = null;
464
+ let failure = {};
471
465
  if (share) {
472
466
  const at = String(directory || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
473
467
  if (!/^https:\/\//.test(at) && !(/^http:\/\//.test(at) && loopback(at))) why = `the directory ${at} is not https`;
474
468
  else {
475
- try {
476
- // A build the chat opened is attached (once); otherwise a new one is opened here.
477
- const res = await fetch(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
478
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
479
- body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }), signal: AbortSignal.timeout(10_000),
480
- });
481
- const body = await res.json().catch(() => null);
482
- if (!res.ok && networkWhy(res, at)) why = networkWhy(res, at);
483
- else if (res.ok && body?.ok && REMOTE_ID.test(String(body.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body.key))) {
484
- // The chat named the build: its title is the one the person saw.
485
- if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
486
- writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
487
- try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
488
- shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
489
- mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
490
- schedule(root, build, { soon: true });
491
- heartbeat(root, build, true);
492
- } else why = body?.message ?? `${at} answered ${res.status}`;
493
- } catch (error) { why = `${at} did not answer (${error?.message ?? error})`; }
469
+ // A build the chat opened is attached (once); otherwise a new one is opened here.
470
+ const sent = await request(attach ? `${at}/api/studio/progress/${attach}/attach` : `${at}/api/studio/progress`, {
471
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'homie-studio' },
472
+ body: JSON.stringify({ what, id: id ?? null, title: doc.title, studio: doc.studio }),
473
+ });
474
+ const body = sent.body;
475
+ if (sent.ok && REMOTE_ID.test(String(body?.build)) && /^hbk_[a-f0-9]{48}$/.test(String(body?.key))) {
476
+ // The chat named the build: its title is the one the person saw.
477
+ if (attach && body.title && !title) mutateDoc(root, build, (d) => { d.title = plain(body.title, LIMITS.title); });
478
+ writeFileSync(keyPath(root, build), `${body.key}\n`, { mode: 0o600 });
479
+ try { chmodSync(keyPath(root, build), 0o600); } catch { /* not on this filesystem */ }
480
+ shared = { build: body.build, directory: at, expiresAt: body.expiresAt ?? null };
481
+ mutateDoc(root, build, (d) => { d.shared = { build: body.build, directory: at }; });
482
+ schedule(root, build, { soon: true });
483
+ heartbeat(root, build, true);
484
+ } else {
485
+ // As it is: the directory's own answer (its status and message) or the connection's error, never a guess.
486
+ why = sent.ok ? `${at} answered without a build and key` : sent.why;
487
+ failure = { ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}) };
488
+ }
494
489
  }
495
490
  }
496
491
  if (attach && !shared) {
497
492
  // Attaching is the point of `progress attach`: without it, say why and leave no open feed behind.
498
493
  rmSync(docPath(root, build), { force: true });
499
494
  rmSync(currentPath(root), { force: true });
500
- return { ok: false, command: 'progress attach', build: attach, why: `could not attach to ${attach}: ${why}` };
495
+ return { ok: false, command: 'progress attach', build: attach, ...failure, why: `could not attach to ${attach}: ${why}` };
501
496
  }
502
497
  return {
503
498
  ok: true, command: attach ? 'progress attach' : 'progress start', build, file: join(PROGRESS_DIR, `${build}.json`), what, id: id ?? null, title: readFeed(root, build)?.title ?? doc.title,
package/lib/repo.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WHICH GITHUB REPOSITORY THIS STUDIO IS. The Claude app's hand-off opens Claude Code on it, so it must be the
3
+ * studio's own: never the engine and template repository (homie-rocks/homie) that Cloudflare's "Deploy to
4
+ * Cloudflare" copied the studio from, and never a guess.
5
+ *
6
+ * In order:
7
+ * 1. HOMIE_REPO, a build variable naming it;
8
+ * 2. studio.json `github` (`setup attach` writes it, and the person may);
9
+ * 3. the checkout's git remotes, origin first. A Workers Builds checkout, a Claude Code cloud session (its remote
10
+ * goes through the session's own git proxy) and a person's clone all carry one. Only owner/name is kept,
11
+ * never a token in the URL.
12
+ *
13
+ * A deploy hands it to the live Worker as a variable (HOMIE_REPO), never into a public file. The Worker tells its
14
+ * directory alongside its claim, so the setup card can open the right repository.
15
+ */
16
+ import { spawnSync } from 'node:child_process';
17
+ import { readStudio } from './studio.mjs';
18
+
19
+ export const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
20
+ /** The engine and template repository: a studio is never this. */
21
+ export const ENGINE_REPO = 'homie-rocks/homie';
22
+
23
+ /** owner/name in a git remote URL (https, ssh, a proxy's /git/owner/name path), without any credentials; or null. */
24
+ export function repoFromUrl(url) {
25
+ const s = String(url ?? '').trim().replace(/\.git$/, '').replace(/\/+$/, '');
26
+ if (!s) return null;
27
+ const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(s);
28
+ return m ? studioRepo(`${m[1]}/${m[2]}`) : null;
29
+ }
30
+
31
+ /** A studio repository slug, or null (not a slug, or the engine repository). */
32
+ export function studioRepo(value) {
33
+ const r = String(value ?? '').trim();
34
+ return REPO.test(r) && r.toLowerCase() !== ENGINE_REPO ? r : null;
35
+ }
36
+
37
+ /** This studio's repository: HOMIE_REPO, studio.json `github`, else its git remotes (origin first). */
38
+ export function repoOf(root, { env = process.env } = {}) {
39
+ const named = studioRepo(env.HOMIE_REPO);
40
+ if (named) return named;
41
+ try { const s = studioRepo(readStudio(root).github); if (s) return s; } catch { /* no studio.json */ }
42
+ const git = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
43
+ const origin = git(['remote', 'get-url', 'origin']);
44
+ if (origin.status === 0) { const r = repoFromUrl(origin.stdout); if (r) return r; }
45
+ const all = git(['config', '--get-regexp', '^remote\\..*\\.url$']);
46
+ for (const line of (all.status === 0 ? all.stdout : '').split('\n')) {
47
+ const r = repoFromUrl(line.split(/\s+/)[1]);
48
+ if (r) return r;
49
+ }
50
+ return null;
51
+ }
package/lib/scaffold.mjs CHANGED
@@ -25,6 +25,7 @@ import { dirname, join, relative, resolve } from 'node:path';
25
25
  import { STUDIO_VERSION, packageSpec } from './version.mjs';
26
26
  import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
27
27
  import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
28
+ import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
28
29
  import { themeFile } from './site.mjs';
29
30
  import { PACKAGE_ROOT } from './studio.mjs';
30
31
 
@@ -222,6 +223,28 @@ studio's pinned copy, never a registry lookup of the bare name.
222
223
  Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
223
224
  block anything.
224
225
 
226
+ ## Running live games (the back office)
227
+
228
+ - The owner runs the studio's live games from \`/_studio/office\` (\`npx --no-install homie-studio office link\` gives
229
+ the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
230
+ sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
231
+ the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
232
+ room; and per game: launch state, Remixable, players per room and invites.
233
+ - **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
234
+ in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
235
+ as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
236
+ the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
237
+ from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
238
+ - The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
239
+ Mute, Kick; Announce). Nobody else's page has it.
240
+ - **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
241
+ \`office invite <id>\` happen at once; \`office kick\`, \`office close\` and \`office launch\` only ASK, and print a
242
+ one-time link that opens the ask in the owner's own browser, where one tap does it. An ask is not done until the
243
+ owner tapped. \`office key\` gives a key for the Homie MCP's owner tools (\`studio_office\`, \`room_announce\`,
244
+ \`room_kick\`, \`room_close\`, \`game_launch_state\`), which ask the same way; never paste a key anywhere else.
245
+ - A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
246
+ player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
247
+
225
248
  ## Rules
226
249
 
227
250
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
@@ -397,6 +420,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
397
420
  'site/migrations/0001_studio.sql': MIGRATION,
398
421
  [`site/migrations/${STATS_MIGRATION_FILE}`]: STATS_MIGRATION,
399
422
  [`site/migrations/${PLAYERS_MIGRATION_FILE}`]: PLAYERS_MIGRATION,
423
+ [`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
400
424
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
401
425
  '.claude/skills/.gitkeep': '',
402
426
  };
@@ -466,6 +490,18 @@ export function ensureStatsMigration(root) {
466
490
  return `site/migrations/${STATS_MIGRATION_FILE}`;
467
491
  }
468
492
 
493
+ /**
494
+ * A studio made before 0.13.0 has no back-office migration (worker/office.mjs): add it, so the next
495
+ * `d1 migrations apply` makes its tables. Returns the file it wrote, or null when it was there.
496
+ */
497
+ export function ensureOfficeMigration(root) {
498
+ const file = join(root, 'site', 'migrations', OFFICE_MIGRATION_FILE);
499
+ if (existsSync(file)) return null;
500
+ mkdirSync(dirname(file), { recursive: true });
501
+ writeFileSync(file, OFFICE_MIGRATION);
502
+ return `site/migrations/${OFFICE_MIGRATION_FILE}`;
503
+ }
504
+
469
505
  /**
470
506
  * A studio made before 0.12.0 has no players migration (player accounts and cloud saves, saves/SAVES.md): add it,
471
507
  * so the next `d1 migrations apply` makes the tables. Returns the file it wrote, or null when it was there.
@@ -480,7 +516,12 @@ export function ensurePlayersMigration(root) {
480
516
 
481
517
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
482
518
  export function ensureMigrations(root) {
483
- return [ensureStatsMigration(root), ensurePlayersMigration(root)].filter(Boolean);
519
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root)].filter(Boolean);
520
+ }
521
+
522
+ /** What a migration file the template added is for, in a few words (deploy and dev say it). */
523
+ export function migrationWord(file) {
524
+ return /players/.test(file) ? 'player accounts and cloud saves' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
484
525
  }
485
526
 
486
527
  /**
package/lib/setup.mjs CHANGED
@@ -13,43 +13,32 @@
13
13
  * A setup id is single use per repository: a second attach from the same repository answers the same; another
14
14
  * repository is refused.
15
15
  */
16
- import { spawnSync } from 'node:child_process';
17
16
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
17
  import { join } from 'node:path';
19
18
  import { configNames } from './cloudflare.mjs';
20
- import { networkWhy } from './progress.mjs';
19
+ import { request } from './net.mjs';
20
+ import { ENGINE_REPO, repoOf } from './repo.mjs';
21
21
  import { CONNECT_BAND, agentsMd, readme, slugify, templateReadme } from './scaffold.mjs';
22
22
  import { configPath, readStudio, writeLocal, writeStudio } from './studio.mjs';
23
23
  import { STUDIO_VERSION } from './version.mjs';
24
24
 
25
25
  const SETUP_ID = /^hs_[a-f0-9]{32}$/;
26
26
 
27
- /** owner/repo of this checkout's GitHub remote (a Claude Code cloud session's remote goes through its own proxy). */
28
- export function repoOf(root) {
29
- const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
30
- if (r.status !== 0) return null;
31
- const url = r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, '');
32
- const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(url);
33
- return m ? `${m[1]}/${m[2]}` : null;
34
- }
35
-
36
27
  export async function setupAttach(root, id, { homie } = {}) {
37
28
  if (!SETUP_ID.test(String(id ?? ''))) return { ok: false, command: 'setup attach', why: 'attach to the setup id the Claude app\'s setup card showed: hs_ and 32 hex digits' };
38
29
  const studio = readStudio(root);
39
30
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
40
31
  const repo = repoOf(root);
41
- let res; let body = null;
42
- try {
43
- res = await fetch(`${directory}/api/studio/setup/${id}/attach`, {
44
- method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
45
- body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
46
- signal: AbortSignal.timeout(10_000),
47
- });
48
- body = await res.json().catch(() => null);
49
- } catch (error) { return { ok: false, command: 'setup attach', why: `${directory} did not answer (${error?.message ?? error})` }; }
50
- const blocked = networkWhy(res, directory);
51
- if (blocked) return { ok: false, command: 'setup attach', needs: 'network', why: blocked };
52
- if (!res.ok || !body?.ok) return { ok: false, command: 'setup attach', why: body?.message ?? `${directory} answered ${res.status}` };
32
+ if (!repo) {
33
+ return { ok: false, command: 'setup attach', needs: 'repository', why: `this checkout names no GitHub repository of its own (its git remote is missing, or is ${ENGINE_REPO}, Homie's engine and template, which a studio never is). Run this in the studio's own repository, the one Cloudflare's Deploy to Cloudflare made, or name it in studio.json as "github": "<owner>/<name>"` };
34
+ }
35
+ const sent = await request(`${directory}/api/studio/setup/${id}/attach`, {
36
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` },
37
+ body: JSON.stringify({ repo, studio: { name: studio.name, slug: studio.slug, template: studio.template === true }, version: STUDIO_VERSION }),
38
+ });
39
+ // What went wrong, as it is: the directory's own answer, or the connection's error. Never a guess at the network.
40
+ if (!sent.ok) return { ok: false, command: 'setup attach', repo, ...(sent.status ? { status: sent.status } : {}), ...(sent.code ? { code: sent.code } : {}), ...(sent.needs ? { needs: sent.needs } : {}), why: sent.why };
41
+ const body = sent.body;
53
42
 
54
43
  const renamed = [];
55
44
  const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 60) : null;
@@ -78,6 +67,9 @@ export async function setupAttach(root, id, { homie } = {}) {
78
67
  const band = join(root, 'site', 'partials', 'home.html');
79
68
  if (existsSync(band) && readFileSync(band, 'utf8') === CONNECT_BAND) { rmSync(band); renamed.push('site/partials/home.html (removed)'); }
80
69
  }
70
+ // The repository goes into studio.json, so the next deploy (and Workers Builds) tells the live Worker which it is.
71
+ const now = readStudio(root);
72
+ if (now.github !== repo) { writeStudio(root, { ...now, github: repo }); if (!renamed.includes('studio.json')) renamed.push('studio.json'); }
81
73
  if (body.site) writeLocal(root, { url: body.site, connectedAt: new Date().toISOString() });
82
74
  return {
83
75
  ok: true, command: 'setup attach', setup: id, repo, site: body.site ?? null, name: name ?? studio.name, renamed,
package/lib/stats.mjs CHANGED
@@ -48,7 +48,7 @@ export function mint(root, studio, kind, ttlMs, { local }) {
48
48
  return { ok: true, key, hash, expiresAt: new Date(now + ttlMs).toISOString() };
49
49
  }
50
50
 
51
- function drop(root, studio, hash, { local }) {
51
+ export function drop(root, studio, hash, { local }) {
52
52
  const w = runner(root, studio.cloudflare?.accountId && !local ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
53
53
  w(['d1', 'execute', studio.cloudflare.d1, local ? '--local' : '--remote', '--command', `DELETE FROM stats_keys WHERE hash = '${hash}';`]);
54
54
  }
@@ -223,6 +223,55 @@
223
223
  "## Beta": "9d02638207a26e73"
224
224
  }
225
225
  }
226
+ },
227
+ "0.12.1": {
228
+ "files": {
229
+ "CLAUDE.md": "ddf2b2a0376202f6",
230
+ "README.md": "684b3cca8001673c",
231
+ "games/README.md": "5e5e942baa7e0372",
232
+ "music/README.md": "b8a828de9311bd28",
233
+ "videos/README.md": "70614388cbb203c7",
234
+ "posts/README.md": "37841aae476bd540",
235
+ "site/README.md": "ca131cd0472464fb",
236
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
237
+ },
238
+ "sections": {
239
+ "AGENTS.md": {
240
+ "(intro)": "214aa46eb3a895d5",
241
+ "## Layout": "f14d641159480e6c",
242
+ "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
243
+ "## The site": "7d3525ba39b7f262",
244
+ "## Making games": "63069b31c87a2aa2",
245
+ "## The Game Codex and progress": "0738ef719cef3a4c",
246
+ "## Rules": "969318425c99f5a5",
247
+ "## Beta": "9d02638207a26e73"
248
+ }
249
+ }
250
+ },
251
+ "0.13.0": {
252
+ "files": {
253
+ "CLAUDE.md": "ddf2b2a0376202f6",
254
+ "README.md": "684b3cca8001673c",
255
+ "games/README.md": "5e5e942baa7e0372",
256
+ "music/README.md": "b8a828de9311bd28",
257
+ "videos/README.md": "70614388cbb203c7",
258
+ "posts/README.md": "37841aae476bd540",
259
+ "site/README.md": "ca131cd0472464fb",
260
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
261
+ },
262
+ "sections": {
263
+ "AGENTS.md": {
264
+ "(intro)": "214aa46eb3a895d5",
265
+ "## Layout": "f14d641159480e6c",
266
+ "## Commands (all through the pinned CLI in node_modules)": "3a3d3bef7e8d1a50",
267
+ "## The site": "7d3525ba39b7f262",
268
+ "## Making games": "63069b31c87a2aa2",
269
+ "## The Game Codex and progress": "0738ef719cef3a4c",
270
+ "## Running live games (the back office)": "f0566f07cff04113",
271
+ "## Rules": "969318425c99f5a5",
272
+ "## Beta": "9d02638207a26e73"
273
+ }
274
+ }
226
275
  }
227
276
  }
228
277
  }
package/lib/upgrade.mjs CHANGED
@@ -170,11 +170,14 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
170
170
  }
171
171
  const have = sections(mine);
172
172
  const tsecs = sections(want);
173
+ const adding = new Set();
173
174
  for (const [k, t] of tsecs.entries()) {
174
175
  const at = have.find((s) => s.heading === t.heading);
175
176
  if (!at) {
176
- // After the nearest template section before it that the studio has; else before the first one after it.
177
- const before = tsecs.slice(0, k).reverse().find((p) => have.some((s) => s.heading === p.heading));
177
+ // After the nearest template section before it that the studio has (or that this plan adds just before it, so
178
+ // two new sections in a row keep the template's order); else before the first one after it.
179
+ const before = tsecs.slice(0, k).reverse().find((p) => have.some((s) => s.heading === p.heading) || adding.has(p.heading));
180
+ adding.add(t.heading);
178
181
  const after = before ? null : tsecs.slice(k + 1).find((p) => have.some((s) => s.heading === p.heading));
179
182
  changes.push({ file: rel, kind: 'add-section', section: t.heading, what: `new section ${t.heading === INTRO ? 'intro' : `"${t.heading}"`}`, after: before?.heading ?? null, before: after?.heading ?? null, text: t.text });
180
183
  continue;