@homie-rocks/studio 0.51.1 → 0.51.2

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/build.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { directoryUrl } from '../worker/directory-choice.mjs';
2
+ import { domainOrigin } from './studio.mjs';
1
3
  import { appProblems } from '../worker/app-format.mjs';
2
4
  /**
3
5
  * `homie-studio build` — every game in games/ bundled into site/dist, plus the
@@ -707,8 +709,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
707
709
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
708
710
  // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
709
711
  // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
710
- directory: /^https:\/\/[a-z0-9.-]+$/i.test(process.env.HOMIE_DIRECTORY ?? '') ? process.env.HOMIE_DIRECTORY
711
- : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
712
+ directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
713
+ canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
712
714
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
713
715
  ...(studio.template === true ? { template: true } : {}),
714
716
  // studio.json audience is a studio label; shop policies are optional.
@@ -1,3 +1,4 @@
1
+ import { directoryUrl } from '../worker/directory-choice.mjs';
1
2
  import { roomLayout } from '../worker/gate.mjs';
2
3
  import {workerEntry} from './worker-build.mjs';
3
4
  import { seatCount } from '../worker/seats.mjs';
@@ -51,6 +52,11 @@ import { projectsCloudflareEnv } from './projects-env.mjs';
51
52
 
52
53
  const ANSI = /\u001b\[[0-9;]*m/g;
53
54
 
55
+ export function experienceAddresses(root, id, url) {
56
+ const app = listGames(root).find(g => g.id === id)?.kind === 'app';
57
+ return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
58
+ }
59
+
54
60
  export function wranglerBin(root) {
55
61
  const local = join(root, 'node_modules', '.bin', 'wrangler');
56
62
  return existsSync(local) ? local : null;
@@ -65,6 +71,8 @@ export function runner(root, env) {
65
71
  const bin = wranglerBin(root);
66
72
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
67
73
  return (args, { cwd = workerDir(root), input } = {}) => {
74
+ const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
75
+ if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
68
76
  const res = spawnSync(bin, args, {
69
77
  cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
70
78
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
@@ -211,11 +219,13 @@ export function deployPlan(root) {
211
219
  ? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
212
220
  : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
213
221
  ],
214
- cost: 'Account subscription not queried. Cloudflare bills usage under the account’s current plan; estimates are not a billing quote.',
222
+ cost: null,
215
223
  login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
216
224
  address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
217
225
  directory: {
218
- site: studio.homie?.directory || 'https://homie.rocks',
226
+ site: directoryUrl(studio.homie?.directory),
227
+ requested: Boolean(directoryUrl(studio.homie?.directory)),
228
+ choice: 'A deploy claims a listing only when homie.directory is true or a directory URL. Existing claims stay honoured; studios that never asked are not listed.',
219
229
  stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
220
230
  },
221
231
  never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
@@ -302,8 +312,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
302
312
  const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
303
313
  announced.push(
304
314
  `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).`,
305
- 'Account subscription not queried; usage is billed under the account’s current Cloudflare plan.',
306
- `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.`,
315
+ deployPlan(root).directory.choice,
307
316
  );
308
317
  for (const line of announced) log(line);
309
318
  }
@@ -425,7 +434,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
425
434
  if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
426
435
  step(`closed ${synced.expired} obsolete part Checkouts`);
427
436
  }
428
- const directory = homie || studio.homie?.directory || 'https://homie.rocks';
437
+ const directory = directoryUrl(homie ?? studio.homie?.directory);
429
438
  recordDeploy(root, builtNow);
430
439
  let claim = null;
431
440
  if (url) {
@@ -441,10 +450,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
441
450
  } else if (read.state === 'unreachable') {
442
451
  // This computer could not ask (its own name lookup, no connection): a network preflight failure, never a
443
452
  // verdict on the site that was just deployed.
444
- const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} The site claims itself when the directory first reads it.`;
453
+ const said = `deployed, but this computer could not read ${url} back to check it: ${read.why}. ${read.preflight === 'dns' ? 'That is this computer\'s name lookup (a browser here may still open the site), not the deploy.' : 'That is this computer\'s connection, not the deploy.'} Listing follows the studio’s directory choice.`;
445
454
  notes.push(said);
446
455
  step(said, { preflight: read.preflight ?? 'network' });
447
- } else step('the directory claim is not there yet (the site claims itself when the directory first reads it)');
456
+ } else step(directory ? 'the requested directory claim is not there yet' : 'no new listing requested; existing claims are honoured');
448
457
  }
449
458
 
450
459
  // The deploy before this one came from a toolkit that still had remix, and left games open to be taken whole. No
@@ -454,7 +463,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
454
463
  let reread = null;
455
464
  if (url && delta.withdrawn.length) {
456
465
  const which = delta.withdrawn.join(', ');
457
- const dir = String(directory).replace(/\/+$/, '');
466
+ const dir = directory ?? readLocal(root).listed?.directory;
458
467
  if (listedHere(root, { site: url, directory: dir })) {
459
468
  const again = await publish(root, { homie: dir, site: url, ...(fetchFn ? { fetchFn } : {}) });
460
469
  reread = { ok: again.ok, games: delta.withdrawn, ...(again.ok ? {} : { why: again.why }), ...(again.publishes ? { publishes: again.publishes } : {}) };
@@ -474,7 +483,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
474
483
  return {
475
484
  ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
476
485
  ...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
477
- games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
486
+ games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
478
487
  ...(delta.removed.length ? { removed: delta.removed } : {}),
479
488
  ...(routes ? { routes: routes.map((r) => r.pattern) } : {}),
480
489
  ...(zone ? { zone: zoneView(zone) } : {}),
@@ -643,7 +652,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
643
652
  ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
644
653
  commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
645
654
  // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
646
- games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
655
+ games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, ...experienceAddresses(root, id, url), hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
647
656
  claim: Boolean(claim),
648
657
  };
649
658
  }
package/lib/dev.mjs CHANGED
@@ -197,7 +197,7 @@ export function devConfig(root, remoteAi) {
197
197
  const routed = ROUTE_KEYS.some((k) => k in json);
198
198
  const aiNote = remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : null;
199
199
  const entry=workerEntry(root);
200
- if (!entry && Boolean(json.ai) === remoteAi && !routed) return { args: [], note: aiNote, stripped: [] };
200
+ // Always retain the exact local binding configuration for CLI commands after a deploy.
201
201
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
202
202
  delete json.$schema;
203
203
  json.main = entry ?? abs(json.main);
package/lib/directory.mjs CHANGED
@@ -4,8 +4,9 @@
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 { directoryUrl } from '../worker/directory-choice.mjs';
7
8
  import { request } from './net.mjs';
8
- import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
9
+ import { domainOrigin, listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal, writeStudio } from './studio.mjs';
9
10
  import { licenceProblems, readManifest } from './asset-manifest.mjs';
10
11
  import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
11
12
 
@@ -103,8 +104,8 @@ function licenceRefusals(root) {
103
104
  */
104
105
  export async function publishBefore(root, { homie, site, log = () => {}, fetchFn } = {}) {
105
106
  const studio = readStudio(root);
106
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
107
- const url = site || siteUrl(root, studio);
107
+ const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
108
+ const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
108
109
  const refused = licenceRefusals(root);
109
110
  const parts = partsForListing(root);
110
111
  const local = publishesSoFar(root);
@@ -140,8 +141,8 @@ export async function publishBefore(root, { homie, site, log = () => {}, fetchFn
140
141
 
141
142
  export async function publish(root, { homie, site, log = () => {}, fetchFn } = {}) {
142
143
  const studio = readStudio(root);
143
- const directory = (homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
144
- const url = site || siteUrl(root, studio);
144
+ const directory = directoryUrl(homie ?? studio.homie?.directory) ?? 'https://homie.rocks';
145
+ const url = domainOrigin(studio.cloudflare?.domain) || site || siteUrl(root, studio);
145
146
  // Licences first: a public game may ship only assets with a licence record that allows it.
146
147
  const refused = licenceRefusals(root);
147
148
  if (refused.length) return { ok: false, command: 'publish', refused, why: `not listed: ${refused.length} asset licence problem${refused.length === 1 ? '' : 's'}:\n${refused.map((p) => ` ${p.game}/${p.asset}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).join('\n')}\nhomie-studio assets check <id> says the same; fix them, deploy, then publish again.` };
@@ -149,6 +150,22 @@ export async function publish(root, { homie, site, log = () => {}, fetchFn } = {
149
150
  // Parts from other studios in the games about to be listed: their licences and credits, and plainly when two
150
151
  // cannot be combined, in the same lines the deploy plan gave (lib/parts-build.mjs). Said before anything is sent.
151
152
  // It does not stop the listing: the person decides, having been told.
153
+ // An explicit publish is itself the choice to list. Seed the same D1 claim the
154
+ // Worker uses, so a studio deployed without opt-in does not need another deploy.
155
+ if (!directoryUrl(studio.homie?.directory)) {
156
+ let origin;
157
+ try { origin = new URL(url).origin; } catch { return {ok:false,command:'publish',why:'Invalid studio address'}; }
158
+ const claimed = await request(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {}, {timeout:15_000,...(fetchFn?{fetchFn}:{})});
159
+ const token = claimed.body?.claim;
160
+ if (!claimed.ok || !/^[a-f0-9]{16,128}$/.test(token ?? '')) return {ok:false,command:'publish',why:claimed.why ?? 'The directory did not return a claim; nothing was listed.'};
161
+ const {runner} = await import('./cloudflare.mjs');
162
+ const {loopback} = await import('./stats.mjs');
163
+ const local = loopback(origin);
164
+ const w = runner(root, !local && studio.cloudflare?.accountId ? {CLOUDFLARE_ACCOUNT_ID:studio.cloudflare.accountId} : {});
165
+ const saved = w(['d1','execute',studio.cloudflare.d1,local?'--local':'--remote','--command',`INSERT OR REPLACE INTO meta (key,value) VALUES ('homie_claim:${origin.replaceAll("'", "''")}','${token}');`]);
166
+ if(saved.code!==0)return {ok:false,command:'publish',why:'Could not store the directory claim in the studio database: '+saved.out};
167
+ writeStudio(root,{...studio,homie:{...studio.homie,directory}});
168
+ }
152
169
  const parts = partsForListing(root);
153
170
  for (const line of partsPlanLines(parts, { at: 'publish' })) log(line);
154
171
  const before = publishesSoFar(root);
@@ -0,0 +1,9 @@
1
+ /** Defaults shared by shop init and the upgrade of an untouched earlier preset. */
2
+ export function givingItems(amounts = [1000, 2000, 2500, 5000]) {
3
+ return amounts.flatMap(amount => [false, true].map(monthly => ({
4
+ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation',
5
+ name: monthly ? 'Monthly gift' : 'One-time gift', price: amount,
6
+ ...(monthly ? { interval: 'month' } : {}), gives: [],
7
+ blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.',
8
+ })));
9
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -1171,7 +1171,7 @@ export function toolDefs(ctx, avail = {}) {
1171
1171
  if (!r.ended) return stillRunning(r.job, 'The deploy plan');
1172
1172
  if (r.job.code !== 0) return fail(whyOf(r.job));
1173
1173
  const p = r.result;
1174
- return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1174
+ return ok([`What going online does for ${p.studio}:`, 'Rules rooms resume when their stored shape matches. Changes to fields, declaration order or maximum seats start a fresh match.', ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), ...(p.cost ? [`Cost: ${p.cost}`] : []), `Sign-in: ${p.login}`, ...(p.directory?.choice ? [p.directory.choice] : []), ...(p.directory?.site ? [`The directory stores: ${p.directory.stores}`] : []), ...(p.parts ? partsPlanLines(p.parts) : []), ...(p.zone?.warning ? [`Warning (say this to the person as it is): ${p.zone.warning}`] : p.zone?.why ? [p.zone.why] : []), 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
1175
1175
  }
1176
1176
  const studio = readStudio(root);
1177
1177
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
package/lib/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { signinTarget } from '../worker/signin-target.mjs';
1
2
  /**
2
3
  * `homie-studio office` — the studio's back office from the studio folder (worker/office.mjs says what it is).
3
4
  *
@@ -83,7 +84,7 @@ export async function officeShow(root, { url } = {}) {
83
84
  }
84
85
 
85
86
  export function officeLink(root, { url, to } = {}) {
86
- const target = to && /^\/[A-Za-z0-9_/-]{1,80}$/.test(String(to)) ? String(to) : '/_studio/office';
87
+ const target = signinTarget(to, '/_studio/office');
87
88
  const r = signinTo(root, url, target);
88
89
  if (!r.ok) return { ok: false, command: 'office link', why: r.why };
89
90
  return {
package/lib/scaffold.mjs CHANGED
@@ -245,8 +245,8 @@ studio's pinned copy, never a registry lookup of the bare name.
245
245
  - **Workers Builds** (Cloudflare's own CI, set up by the "Deploy to Cloudflare" button or in the dashboard): on
246
246
  every push to \`main\` it runs \`npm run build\` and \`npm run deploy\`, which in Workers Builds only applies
247
247
  the D1 migrations and deploys (it never creates or refuses anything); on every other branch it runs
248
- \`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. The live site claims itself
249
- in the homie.rocks directory the first time it is read, so nothing is stored by hand.
248
+ \`npm run build\` and \`npx wrangler preview\`, a Preview URL with its own rooms. A live site requests a directory claim only with
249
+ studio.json \`homie.directory: true\` (homie.rocks) or a directory URL. Existing claims stay honoured.
250
250
  - \`npx --no-install homie-studio storage add\` — only when the studio has songs or videos: an R2
251
251
  bucket on the studio's own Cloudflare account. Cloudflare asks for a payment method on the account
252
252
  before R2 works, so this is a separate step the person agrees to; nothing else needs it. R2 has no
@@ -1085,14 +1085,17 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1085
1085
  if (real) {
1086
1086
  if (!statSync(real).isDirectory()) throw new Error(`${real} is a file. Nothing was written.`);
1087
1087
  const entries = readdirSync(real).filter((n) => n !== '.DS_Store' && n !== '.git');
1088
- if (entries.length) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
1088
+ let resume = false;
1089
+ try { const existing = JSON.parse(readFileSync(join(real, 'studio.json'), 'utf8')); resume = existing.name === String(name).trim() && existing.slug === slugify(askedSlug || name) && Boolean(existing.homie?.studio); } catch {}
1090
+ if (entries.length && !resume) throw new Error(`${real} is not empty (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). A new studio goes in a new or empty folder. Nothing was written.`);
1089
1091
  }
1090
1092
  const slug = slugify(askedSlug || name);
1091
- const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template, kind });
1093
+ const files = studioFiles({ name: String(name).trim(), slug, homie: homie ?? null, template, kind });
1092
1094
  mkdirSync(dir, { recursive: true });
1093
1095
  const wrote = [];
1094
1096
  for (const [rel, content] of Object.entries(files)) {
1095
1097
  const path = join(dir, rel);
1098
+ if (existsSync(path)) continue;
1096
1099
  mkdirSync(dirname(path), { recursive: true });
1097
1100
  writeFileSync(path, content);
1098
1101
  wrote.push(rel);
package/lib/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {givingItems} from './giving-preset.mjs';
1
2
  import { paidReleases } from './parts-upload.mjs';
2
3
  /**
3
4
  * `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
@@ -115,7 +116,7 @@ export function shopInit(root, { supporter = false, giving = false, currency = n
115
116
  const shop = {
116
117
  ...(giving ? { giving: { receiptText: '' } } : {}),
117
118
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
118
- items: giving ? [1000, 2000, 2500, 5000].flatMap(amount => [false, true].map(monthly => ({ id: `gift-${amount}${monthly ? '-monthly' : ''}`, kind: 'donation', name: monthly ? 'Monthly gift' : 'One-time gift', price: amount, ...(monthly ? { interval: 'month' } : {}), gives: [], blurb: monthly ? 'Give this amount each month. Manage your gift through Stripe.' : 'A gift to support our work.' }))) : supporter ? [supporterItem({ price: Number(price) })] : [],
119
+ items: giving ? givingItems() : supporter ? [supporterItem({ price: Number(price) })] : [],
119
120
  };
120
121
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
121
122
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
@@ -3553,7 +3553,10 @@
3553
3553
  "## Version-matched authoring guides": "f6261a0e5801b96b",
3554
3554
  "## Layout": "f3a280b8c96cd068",
3555
3555
  "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3556
- "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3556
+ "## Commands (all through the pinned CLI in node_modules)": [
3557
+ "5222e08ca55e326e",
3558
+ "c20d90de21cde0c7"
3559
+ ],
3557
3560
  "## The site": "0cb8dd9a6f8a0096",
3558
3561
  "## Making games": "bb715272fd1a615b",
3559
3562
  "## The Game Codex and progress": "0738ef719cef3a4c",
@@ -3571,7 +3574,52 @@
3571
3574
  "Homie Arcade": {
3572
3575
  "sections": {
3573
3576
  "AGENTS.md": {
3574
- "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3577
+ "## Commands (all through the pinned CLI in node_modules)": [
3578
+ "611b437f5b3303f0",
3579
+ "8364e05cebefcba4"
3580
+ ]
3581
+ }
3582
+ }
3583
+ }
3584
+ }
3585
+ },
3586
+ "0.51.2": {
3587
+ "files": {
3588
+ "CLAUDE.md": "ddf2b2a0376202f6",
3589
+ "README.md": "684b3cca8001673c",
3590
+ "HANDOFF.md": "29337e2ace95b2e7",
3591
+ "games/README.md": "5ad32fe80c61f117",
3592
+ "music/README.md": "a3dc042dadd39df8",
3593
+ "videos/README.md": "16babc3437d5545b",
3594
+ "posts/README.md": "37841aae476bd540",
3595
+ "site/README.md": "1dbdf22c03a1e505",
3596
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3597
+ },
3598
+ "sections": {
3599
+ "AGENTS.md": {
3600
+ "(intro)": "394cbbe934e80722",
3601
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3602
+ "## Layout": "f3a280b8c96cd068",
3603
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3604
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3605
+ "## The site": "0cb8dd9a6f8a0096",
3606
+ "## Making games": "bb715272fd1a615b",
3607
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3608
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3609
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3610
+ "## Servers and AI seats": "1ccfd549d2369a60",
3611
+ "## Selling things (the shop)": "43a3556be93d1235",
3612
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3613
+ "## Rules": "b2234d80f6cbe71d",
3614
+ "## Telling Homie": "80b46fdd9e578db8",
3615
+ "## Beta": "9d02638207a26e73"
3616
+ }
3617
+ },
3618
+ "named": {
3619
+ "Homie Arcade": {
3620
+ "sections": {
3621
+ "AGENTS.md": {
3622
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3575
3623
  }
3576
3624
  }
3577
3625
  }
@@ -0,0 +1,49 @@
1
+ import {givingItems} from './giving-preset.mjs';
2
+ import {createHash} from 'node:crypto';
3
+ import {readFileSync,existsSync,readdirSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ import {fileURLToPath} from 'node:url';
6
+ const history=JSON.parse(readFileSync(new URL('./app-preset-history.json',import.meta.url),'utf8'));
7
+ const presets=fileURLToPath(new URL('../app-starters/',import.meta.url));
8
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
9
+ const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
10
+ // A three-way merge: only values still equal to the old preset take the new default.
11
+ function merge(mine,old,next) {
12
+ if(equal(mine,old))return next;
13
+ if(!object(mine)||!object(old)||!object(next))return mine;
14
+ const result={...mine};
15
+ for(const key of new Set([...Object.keys(old),...Object.keys(next)])) {
16
+ const value=merge(mine[key],old[key],next[key]);
17
+ if(value===undefined)delete result[key];else result[key]=value;
18
+ }
19
+ return result;
20
+ }
21
+ export function appPresetChanges(root) {
22
+ const changes=[];
23
+ const shopFile=join(root,'shop.json');
24
+ if(existsSync(shopFile)) {
25
+ const from=readFileSync(shopFile,'utf8');let shop;try{shop=JSON.parse(from);}catch{}
26
+ if(shop?.giving && equal(shop.items,givingItems([1000,2500,5000]))) {
27
+ changes.push({file:'shop.json',kind:'update-file',what:'refresh untouched giving choices; keep currency and receipt wording',from,text:JSON.stringify({...shop,items:givingItems()},null,2)+'\n'});
28
+ }
29
+ }
30
+ const apps=join(root,'apps');if(!existsSync(apps))return changes;
31
+ for(const entry of readdirSync(apps,{withFileTypes:true})) {
32
+ if(!entry.isDirectory())continue;
33
+ const rel=`apps/${entry.name}`,file=join(root,rel,'app.json');if(!existsSync(file))continue;
34
+ const text=readFileSync(file,'utf8');let mine;try{mine=JSON.parse(text);}catch{continue;}
35
+ const kind=mine.starter?.kind,old=history[kind];if(!old)continue;
36
+ const next=JSON.parse(readFileSync(join(presets,kind,'app.json'),'utf8'));
37
+ const merged=merge(mine,old.meta,next);merged.id=mine.id;merged.name=mine.name;
38
+ if(!equal(mine,merged))changes.push({file:rel+'/app.json',kind:'update-file',what:'refresh unchanged app preset defaults; keep studio edits',from:text,text:JSON.stringify(merged,null,2)+'\n'});
39
+ for(const [path,hash] of Object.entries(old.files)) {
40
+ if(path==='app.json'||!existsSync(join(root,rel,path))||!existsSync(join(presets,kind,path)))continue;
41
+ const from=readFileSync(join(root,rel,path),'utf8');
42
+ const normalized=from.replaceAll(`game: '${mine.id}'`,"game: 'welcome'");
43
+ if(createHash('sha256').update(normalized).digest('hex')!==hash)continue;
44
+ const want=readFileSync(join(presets,kind,path),'utf8').replaceAll("game: 'welcome'",`game: '${mine.id}'`);
45
+ if(from!==want)changes.push({file:rel+'/'+path,kind:'update-file',what:'refresh untouched app preset file',from,text:want});
46
+ }
47
+ }
48
+ return changes;
49
+ }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {appPresetChanges} from './upgrade-app-presets.mjs';
1
2
  import {refreshWorkerConfig} from './worker-config.mjs';
2
3
  /**
3
4
  * `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
@@ -174,7 +175,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
174
175
  const who = { name, slug };
175
176
  const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
176
177
  const { files: oldFiles, secs: oldSecs } = known(history, who);
177
- const changes = [];
178
+ const changes = appPresetChanges(root);
178
179
  const kept = [];
179
180
 
180
181
  // package.json: the pin, and the scripts the template's studios have.
package/mcp/ui/build.js CHANGED
@@ -70,9 +70,10 @@
70
70
  C.add(C.el('div', 'cb'), C.el('b', '', x.kind + ' ' + x.name), C.el('span', '', x.state + (x.plan ? ' · ' + x.plan : '')))));
71
71
  });
72
72
  card.appendChild(ul);
73
- if (p.cost) card.appendChild(C.el('p', 'note', p.cost));
73
+ if (p.cost) card.appendChild(C.el('p', 'note billing', p.cost));
74
74
  card.appendChild(C.el('p', 'fine', 'Cloudflare asks you to approve once in your browser if this computer is not signed in (a free account works).'));
75
- if (p.directory && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
75
+ if (p.directory && p.directory.choice) card.appendChild(C.el('p', 'directory-choice', p.directory.choice));
76
+ if (p.directory && p.directory.site && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
76
77
  if (p.never) card.appendChild(C.el('p', 'fine', p.never));
77
78
  return card;
78
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.51.1",
3
+ "version": "0.51.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/site/SITE.md CHANGED
@@ -591,3 +591,16 @@ credentials.
591
591
  X decides what it shows, may cache tags, and may change or stop this behavior.
592
592
  Local success does not prove that X will display a card. The studio can use the
593
593
  off switch above if it does not want to offer a game through a video-oriented card.
594
+
595
+
596
+ ## Canonical address and decorative app frames
597
+
598
+ Set `cloudflare.domain` for the canonical public address. The manifest uses that
599
+ address even when fetched from workers.dev; browser pages on the alias redirect,
600
+ while discovery and tool transports remain reachable. Listing requires an explicit
601
+ `homie.directory: true` or URL; existing claims remain valid.
602
+
603
+ An iframe inside `.hero-media` is decorative and lets the parent page scroll.
604
+ Add `data-interactive` only when people should interact with the hero. Ordinary app
605
+ frames permit Web Share and downloads; browser capability and a user gesture still
606
+ apply. App shells allow normal touch scrolling and text selection.
package/tools/TOOLS.md CHANGED
@@ -179,3 +179,12 @@ and removes secrets directly; remote owner tools `studio_events_webhook_status`
179
179
  and `studio_events_webhook_test` inspect delivery and test it. The receiver verifies
180
180
  Standard Webhooks signatures, deduplicates events, then wakes the owner's AI to use
181
181
  its own service connectors.
182
+
183
+
184
+ ## Signed-in connector address
185
+
186
+ Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
187
+ `WWW-Authenticate` resource metadata URL for `/.well-known/oauth-protected-resource/mcp`.
188
+ Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
189
+ token. An approved connection retains the account’s current app grants, not a copy
190
+ of its old permissions. Manage connections at `/_studio/office/connections`.
@@ -1,3 +1,4 @@
1
+ import {appWords} from './app-format.mjs';
1
2
  /**
2
3
  * THE ARRIVAL (0.26.0): the play page and the big screen never open on a blank screen. From the page's first paint
3
4
  * until the game says it is playable, an arrival card covers the game's frame in the game's own look: its title and
@@ -66,7 +67,7 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
66
67
  const pitch = String(L.pitch ?? '').trim() || firstSentence(g?.blurb);
67
68
  const studio = cat?.studio?.name ?? '';
68
69
  const keys = screen
69
- ? [['tv', L.controls?.tv ?? 'Phones are the controllers: scan the code to play.']]
70
+ ? [['tv', L.controls?.tv ?? (g.kind === 'app' ? appWords(g).join : 'Phones are the controllers: scan the code to play.')]]
70
71
  : [['phone', L.controls?.phone], ['desk', L.controls?.computer]].filter(([, v]) => v);
71
72
  const art = wide || tall
72
73
  ? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.51.1","sha256":"6919e263e0a419df6b77a79f359497fb74c099fdfec14d267dc3417413607e6e"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.2","sha256":"bffe9d1a55a4cbe04cb14a1c1d20fe5d32e7a1aa2d2a637d81b45c10c4087b64"});
@@ -0,0 +1,9 @@
1
+ /** Only an explicit choice enables a new directory claim. */
2
+ export function directoryUrl(value) {
3
+ if (value === true) return 'https://homie.rocks';
4
+ if (typeof value !== 'string') return null;
5
+ try {
6
+ const url = new URL(value);
7
+ return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) ? url.origin : null;
8
+ } catch { return null; }
9
+ }
package/worker/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { directoryUrl } from './directory-choice.mjs';
1
2
  import {APP_STORAGE_JS} from './app-frame.mjs';
2
3
  import {recordsPage} from './records-page.mjs';
3
4
  import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
@@ -235,13 +236,7 @@ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return
235
236
  const repoSaid = new Map();
236
237
 
237
238
  export function directoryOf(cat) {
238
- const d = cat?.studio?.directory;
239
- if (d === null || d === false) return null;
240
- const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
241
- try {
242
- const u = new URL(at);
243
- return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
244
- } catch { return null; }
239
+ return directoryUrl(cat?.studio?.directory);
245
240
  }
246
241
 
247
242
  /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
@@ -251,6 +246,7 @@ async function claimOf(env, cat, url) {
251
246
  if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
252
247
  const kept = await storedClaim(env, origin);
253
248
  if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
249
+ if (kept?.older) return kept.older;
254
250
  const directory = directoryOf(cat);
255
251
  const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
256
252
  const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
@@ -543,7 +539,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
543
539
  'cache-control': 'no-store, no-transform',
544
540
  'access-control-allow-origin': '*',
545
541
  // The game runs in an opaque origin: it cannot read this site's storage or cookies.
546
- 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
542
+ 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
547
543
  },
548
544
  });
549
545
  }
@@ -678,9 +674,18 @@ async function route(request, env, ctx) {
678
674
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
679
675
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
680
676
 
677
+ // Keep tool transports and discovery on the alias; redirect browser pages only.
678
+ if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
679
+ && request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
680
+ && !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
681
+ && !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
682
+ const canonical = (await getAll()).studio?.canonical;
683
+ if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
684
+ }
685
+
681
686
  if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
682
687
 
683
- if (path === '/mcp' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
688
+ if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
684
689
  const { remoteMcp } = await import('./mcp.mjs');
685
690
  return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
686
691
  }
@@ -726,6 +731,10 @@ async function route(request, env, ctx) {
726
731
  }
727
732
  if (path === '/.well-known/homie-studio.json') {
728
733
  const cat = await getCat();
734
+ const canonical = cat.studio?.canonical;
735
+ if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
736
+ const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
737
+ }
729
738
  const sharing = Boolean(cat.studio?.stats?.share);
730
739
  const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
731
740
  // studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
package/worker/mcp.mjs CHANGED
@@ -158,7 +158,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
158
158
  });
159
159
  return s;
160
160
  };
161
- return createMcpHandler(server)(request,env,ctx);
161
+ return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
162
162
  };
163
163
  if(localIdentity)return api(request,scoped,ctx);
164
164
  const provider=new OAuthProvider({
@@ -211,7 +211,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
211
211
  const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
212
212
  const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
213
213
  const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
214
- return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
214
+ return html(`<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
215
215
  }
216
216
  return json({error:'not found'},404);
217
217
  }},