@homie-rocks/studio 0.48.3 → 0.49.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +15 -11
  34. package/guides/app/SKILL.md +72 -0
  35. package/guides/parts/SKILL.md +158 -0
  36. package/guides/shop/SKILL.md +149 -0
  37. package/guides/studio-setup/SKILL.md +419 -0
  38. package/lib/authoring-guides.mjs +5 -4
  39. package/lib/build.mjs +6 -4
  40. package/lib/check-site.mjs +6 -6
  41. package/lib/check.mjs +17 -6
  42. package/lib/dev.mjs +4 -2
  43. package/lib/doctor.mjs +1 -1
  44. package/lib/handoff.mjs +13 -8
  45. package/lib/mcp-tools.mjs +10 -9
  46. package/lib/parts.mjs +6 -6
  47. package/lib/rules-wiring.mjs +30 -0
  48. package/lib/scaffold.mjs +36 -10
  49. package/lib/shop-links.mjs +2 -2
  50. package/lib/shop.mjs +5 -3
  51. package/lib/studio.mjs +5 -4
  52. package/lib/template-history.json +84 -0
  53. package/package.json +4 -4
  54. package/worker/app-format.mjs +1 -1
  55. package/worker/build-id.mjs +1 -1
  56. package/worker/index.mjs +30 -10
  57. package/worker/pages.mjs +4 -2
  58. package/worker/parts.mjs +4 -4
  59. package/worker/rules-wiring.mjs +2 -0
  60. package/worker/shop-page.mjs +21 -15
  61. package/worker/shop-rules.mjs +4 -3
  62. package/worker/site.mjs +4 -2
  63. package/worker/version.mjs +1 -1
package/lib/handoff.mjs CHANGED
@@ -37,13 +37,18 @@ const line = (v, max) => String(v ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').repla
37
37
  const lines = (v, max) => String(v ?? '').split(/\r?\n/).map((l) => line(l, max)).filter(Boolean).join('\n').slice(0, max);
38
38
 
39
39
  /** The steps for a kind of build, in this toolkit's commands (the brief says what; these say how). */
40
- export function stepsFor(h, { games = [], client = 'chat' } = {}) {
40
+ export function stepsFor(h, { games = [], client = 'chat', studioKind = 'game' } = {}) {
41
41
  const id = ID.test(String(h.id ?? '')) ? h.id : null;
42
42
  const has = id && games.includes(id);
43
43
  const q = (s) => JSON.stringify(String(s));
44
- const work = {
44
+ const app = h.what === 'app' || ['business', 'cause', 'app'].includes(studioKind);
45
+ const work = app && ['setup', 'make'].includes(h.kind) ? [
46
+ 'Follow the new-studio checklist and app skill from the request through building and checks without waiting unless the person asks.',
47
+ has ? `apps/${id} is here already: change it as the brief says.` : `npx --no-install homie-studio app new ${id ?? '<id>'} --from ${ID.test(String(h.from ?? '')) ? h.from : 'welcome'}`,
48
+ 'Use apps/<id>/app.json for the organisation’s words, roles and records; site/pages/ for public pages; shop init --giving --currency <currency> for gifts.',
49
+ ] : {
45
50
  setup: [
46
- `This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}. Go through the new-studio checklist with the person, one step at a time and never ahead (the plugin's studio-setup skill): it has no game yet, and its home page says "First game coming soon".`,
51
+ `This studio was just set up from ${client === 'grok' ? 'Grok' : client === 'codex' ? 'Codex' : client === 'claude' ? 'the Claude app' : 'this chat'}. Proceed through the new-studio checklist, building and checks from the request without waiting unless the person asks (the plugin's studio-setup skill): for a business or cause, start with the app skill and app new; choose the starter that fits the requested work.`,
47
52
  'See a working game: `npx --no-install homie-studio demo` names a live one on Homie Arcade; copy a starter in only if they ask.',
48
53
  'One small change from one sentence of theirs (the studio\'s colours in site/theme.json, a tagline in studio.json, or a first post in posts/), then plan their game into its Game Codex (`npx --no-install homie-studio codex new <id> --name "<Name>"`) before any game is made.',
49
54
  ],
@@ -58,7 +63,7 @@ export function stepsFor(h, { games = [], client = 'chat' } = {}) {
58
63
  }[h.kind] ?? ['Do what the brief asks.'];
59
64
  return [
60
65
  ...work,
61
- 'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
66
+ 'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url <the printed dev address> (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
62
67
  'npx --no-install homie-studio progress change "<what it does, one line>"; commit on a new branch, push, gh pr create; then npx --no-install homie-studio progress pr --url <the pull request>.',
63
68
  'Never merge the pull request: the person publishes it from the chat\'s card, in GitHub.',
64
69
  ];
@@ -71,8 +76,8 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
71
76
  if (SETUP.test(String(build ?? ''))) {
72
77
  const r = await checkIn(root, build, { homie, client: who.id === 'chat' ? null : who.id });
73
78
  if (!r.ok) return { ok: false, command: 'handoff', setup: build, why: r.why, ...(r.needs ? { needs: r.needs } : {}) };
74
- const h = { kind: 'setup', what: 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
75
- return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id }) };
79
+ const h = { kind: 'setup', what: ['business', 'cause', 'app'].includes(studio.kind) ? 'app' : 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
80
+ return { ok: true, command: 'handoff', build: null, ...h, client: who.id, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h, { client: who.id, studioKind: studio.kind }) };
76
81
  }
77
82
  if (!BUILD.test(String(build ?? ''))) return { ok: false, command: 'handoff', why: `the id from the one line the chat gave: hb_ (a build) or hs_ (a setup) and 32 hex digits` };
78
83
  const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
@@ -88,7 +93,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
88
93
  if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
89
94
  const h = {
90
95
  kind: ['make', 'port', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
91
- what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
96
+ what: ['app', 'game', 'song', 'video'].includes(body.what) ? body.what : 'game',
92
97
  title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
93
98
  id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
94
99
  folder: line(body.folder, 200) || null,
@@ -106,7 +111,7 @@ export async function handoff(root, build, { homie, client, fetchFn = globalThis
106
111
  ok: true, command: 'handoff', build, ...h, setup,
107
112
  attached: taken.ok ? { feed: taken.build, shared: taken.shared?.build ?? build } : null,
108
113
  attachWhy: taken.ok ? null : taken.why,
109
- steps: stepsFor(h, { games, client: who.id }),
114
+ steps: stepsFor(h, { games, client: who.id, studioKind: studio.kind }),
110
115
  };
111
116
  }
112
117
 
package/lib/mcp-tools.mjs CHANGED
@@ -339,7 +339,7 @@ async function studioCard(ctx, root) {
339
339
  const cover = budget > 0 ? coverData(g.dir) : null;
340
340
  if (cover) budget -= cover.length;
341
341
  const isLive = live && onLine.has(g.id);
342
- return { id: g.id, name: String(g.name ?? g.id).slice(0, 60), blurb: String(g.blurb ?? '').slice(0, 160), cover, live: Boolean(isLive), play: { dev: dev ? `${dev.url}/${g.id}/play` : null, live: isLive ? `${live}/${g.id}/play` : null }, page: isLive ? `${live}/${g.id}/` : null };
342
+ return { id: g.id, name: String(g.name ?? g.id).slice(0, 60), blurb: String(g.blurb ?? '').slice(0, 160), cover, live: Boolean(isLive), play: { dev: dev ? `${dev.url}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` : null, live: isLive ? `${live}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` : null }, page: isLive ? `${live}/${g.id}/` : null };
343
343
  });
344
344
  const posts = (() => { try { return readdirSync(join(root, 'posts')).filter((f) => /\.md$/.test(f) && f !== 'README.md' && !/^[_.]/.test(f)).length; } catch { return 0; } })();
345
345
  let rooms = [];
@@ -350,7 +350,8 @@ async function studioCard(ctx, root) {
350
350
  rooms = (Array.isArray(body?.rooms) ? body.rooms : []).slice(0, 8).map((r) => ({ game: String(r.game ?? '').slice(0, 40), room: String(r.room ?? '').slice(0, 20), players: Number(r.players) || 0 }));
351
351
  } catch { rooms = []; }
352
352
  }
353
- const demo = games.length ? null : await demoGames({ timeoutMs: 4000 }).then((d) => ({ name: d.pick.name, play: d.pick.play, studio: d.studio.name })).catch(() => null);
353
+ const appStudio = ['business', 'cause', 'app'].includes(studio.kind);
354
+ const demo = games.length || appStudio ? null : await demoGames({ timeoutMs: 4000 }).then((d) => ({ name: d.pick.name, play: d.pick.play, studio: d.studio.name })).catch(() => null);
354
355
  const data = {
355
356
  kind: 'studio', folder: basename(root), root, name: studio.name, tagline: studio.tagline ?? null,
356
357
  games, songs: published(root, 'music'), videos: published(root, 'videos'), posts,
@@ -360,7 +361,7 @@ async function studioCard(ctx, root) {
360
361
  if (behind) data.behind = { ...behind, whatsNew: behind.whatsNew ? { ...behind.whatsNew, versions: behind.whatsNew.versions.slice(0, 6).map(({ version, summary, upgrade }) => ({ version, summary, upgrade: upgrade.slice(0, 2) })) } : null };
361
362
  const text = [
362
363
  `${studio.name} (${root})`,
363
- games.length ? `Games: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : 'No game yet: its home page says "First game coming soon".',
364
+ games.length ? `${appStudio ? 'Apps' : 'Apps and games'}: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : appStudio ? 'No app yet: app new creates your first live app.' : 'No game yet: its home page says "First game coming soon".',
364
365
  dev ? `Running here: ${dev.url}/` : 'Not running here (preview_run starts it).',
365
366
  live ? `Live: ${live}/` : 'Not online yet (studio_deploy).',
366
367
  ...(rooms.length ? [`Live rooms: ${rooms.map((r) => `${r.game} ${r.room} (${r.players})`).join(', ')}`] : []),
@@ -814,8 +815,8 @@ export function toolDefs(ctx, avail = {}) {
814
815
  },
815
816
  {
816
817
  name: 'studio_scaffold', title: 'Make a studio',
817
- description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). It has NO game: its home page says "First game coming soon". Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
818
- inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
818
+ description: 'Make a studio for a business, cause, app or game ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). Choose kind business or cause for an app-first setup. Proceed through setup, build and checks without waiting unless asked. Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
819
+ inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), kind: { type: 'string', enum: ['game', 'business', 'cause', 'app'], description: 'Use business or cause for customer apps' }, folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
819
820
  annotations: { title: 'Make a studio', ...RW }, _meta: ui(UI.setup),
820
821
  run: async (a) => {
821
822
  const name = String(a.name ?? '').trim().slice(0, 60) || 'My Studio';
@@ -833,7 +834,7 @@ export function toolDefs(ctx, avail = {}) {
833
834
  let at = dir;
834
835
  if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
835
836
  let made;
836
- try { made = newStudio(at, { name, install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
837
+ try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
837
838
  ctx.current = made.dir;
838
839
  ctx.note(made.dir).earlier = earlier.map((e) => e.path);
839
840
  let install = null;
@@ -844,7 +845,7 @@ export function toolDefs(ctx, avail = {}) {
844
845
  }
845
846
  const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
846
847
  const fold = earlier.length ? `\n\nAn earlier folder for this name is on this computer, and it is not a studio: ${earlier.map((e) => `${e.path} (${e.files.join(', ') || 'empty'})`).join('; ')}. ASK the person: fold its premise into ${name}? With their yes, studio_fold { "from": "<that folder>" } copies its notes into the studio (notes/earlier/) and shows them to you, so the plan can use them. Then, only with another yes, studio_fold { "from": "<that folder>", "remove": true } moves the old folder to the Trash.` : '';
847
- r.content[0].text = `Made ${name} at ${made.dir} (no game yet: its home page says "First game coming soon").\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
848
+ r.content[0].text = `Made ${name} at ${made.dir} (${!a.kind || a.kind === 'game' ? 'its home page says "First game coming soon"' : 'ready for the requested app'}).\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}${fold}\n\n${r.content[0].text}`;
848
849
  if (r.structuredContent) r.structuredContent.earlier = earlier;
849
850
  return r;
850
851
  },
@@ -908,12 +909,12 @@ export function toolDefs(ctx, avail = {}) {
908
909
  {
909
910
  name: 'app_make', title: 'Make an app',
910
911
  description: 'Make one morphing screen with customer, staff and wall roles, lasting records and a shared 3D scene. Uses the same engines, netplay, parts and standalone builds as games. No rounds required.',
911
- inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), ...STUDIO_ARG }, required: ['id', 'name'] },
912
+ inputSchema: { type: 'object', properties: { id: str('App id: lowercase, digits, hyphens'), name: str('Display name'), from: { type: 'string', enum: ['welcome', 'gathering', 'giving', 'quiz', 'jobs'] }, ...STUDIO_ARG }, required: ['id', 'name'] },
912
913
  annotations: { title: 'Make an app', ...RW },
913
914
  run: async (a) => {
914
915
  if (!GAME_ID.test(String(a.id ?? ''))) return fail('invalid app id');
915
916
  const root = ctx.root(a.studio);
916
- const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--name', String(a.name ?? a.id).slice(0, 60)]);
917
+ const r = await cli(ctx, root, `app new ${a.id}`, ['app', 'new', a.id, '--from', a.from ?? 'welcome', '--name', String(a.name ?? a.id).slice(0, 60)]);
917
918
  if (!r.ended) return stillRunning(r.job, 'Making the app');
918
919
  if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
919
920
  let install = '';
package/lib/parts.mjs CHANGED
@@ -559,15 +559,15 @@ function liftPlan(root, game, paths) {
559
559
  const files = [];
560
560
  const walk = (rel) => {
561
561
  const abs = join(gdir, rel);
562
- if (!existsSync(abs)) throw new Error(`games/${game}/${rel} is not there`);
562
+ if (!existsSync(abs)) throw new Error(`${relative(root, experienceDir(root, game))}/${rel} is not there`);
563
563
  if (statSync(abs).isDirectory()) { for (const e of readdirSync(abs, { withFileTypes: true })) if (!e.name.startsWith('.') && e.name !== 'node_modules') walk(`${rel}/${e.name}`); } else files.push(rel);
564
564
  };
565
565
  for (const raw of paths) {
566
566
  const rel = posix(String(raw)).replace(/^\.\//, '').replace(/^(?:games|apps)\/[^/]+\//, '').replace(/\/+$/, '');
567
- if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside games/${game}`);
567
+ if (!rel || rel.split('/').includes('..') || rel.startsWith('/')) throw new Error(`"${raw}" is not a path inside ${relative(root, experienceDir(root, game))}`);
568
568
  walk(rel);
569
569
  }
570
- if (!files.length) throw new Error(`name the files of games/${game} that become the part (src/creature.ts, or a folder)`);
570
+ if (!files.length) throw new Error(`name the files of ${relative(root, experienceDir(root, game))} that become the part (src/creature.ts, or a folder)`);
571
571
  // A part is a piece of a game, never the whole game: its entry, its page and its game.json stay the game's.
572
572
  let entry = 'src/main.ts';
573
573
  try { entry = JSON.parse(readFileSync(experienceFile(root, game), 'utf8')).entry ?? entry; } catch { /* the default entry */ }
@@ -607,7 +607,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
607
607
  if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(from)) || !existsSync(experienceFile(root, from))) return { ok: false, command: 'parts new', why: `there is no game "${from}" in this studio to lift a part out of` };
608
608
  let plan;
609
609
  try { plan = liftPlan(root, from, paths); } catch (error) { return { ok: false, command: 'parts new', why: error.message }; }
610
- if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of games/${from}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
610
+ if (plan.outside.length) return { ok: false, command: 'parts new', why: `These files still reach into the rest of ${relative(root, experienceDir(root, from))}: ${plan.outside.slice(0, 6).join('; ')}. A part has to stand without the game: lift those files too, or pass what they provide in as an argument first. Nothing was moved.`, outside: plan.outside };
611
611
  mkdirSync(dir, { recursive: true });
612
612
  lifted = { moved: [], copied: [] };
613
613
  for (const f of plan.files) {
@@ -654,7 +654,7 @@ export function newPart(root, id, { kind = 'mechanic', name = null, from = null,
654
654
  return {
655
655
  ok: true, command: 'parts new', id, dir: `${PARTS_DIR}/${id}`, kind, entry, files: part.files.map((f) => f.path), ...(lifted ? { from, lifted } : {}),
656
656
  next: [
657
- ...(lifted ? [`games/${from} imports it now (each lifted module's old path re-exports the part's); build the game to see it unchanged.`] : []),
657
+ ...(lifted ? [`${relative(root, experienceDir(root, from))} imports it now (each lifted module's old path re-exports the part's); build the app or game to see it unchanged.`] : []),
658
658
  `Write a one-sentence "summary" in parts/${id}/part.json, and what it needs from a game under "contract".`,
659
659
  `Another game of this studio uses it with: import … from '@parts/${id}'`,
660
660
  'It is private. Sharing it needs a licence (an SPDX identifier such as CC-BY-4.0) and is live after the studio\'s next deploy.',
@@ -739,7 +739,7 @@ export function syncPartCredits(root, game, origins) {
739
739
  if (!existsSync(join(experienceDir(root, game)))) return null;
740
740
  const text = `${JSON.stringify({ ...credits, parts: [...keep, ...mine] }, null, 2)}\n`;
741
741
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
742
- return `games/${game}/credits.json`;
742
+ return `${relative(root, experienceDir(root, game))}/credits.json`;
743
743
  }
744
744
 
745
745
  /* ------------------------------------------------------------------ where brought-in parts came from */
@@ -0,0 +1,30 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from '@babel/parser';
4
+ import { RULES_WIRING_MESSAGE } from '../worker/rules-wiring.mjs';
5
+
6
+ /** Check executable module statements, never a comment or a function nobody calls. */
7
+ export function registersRules(text) {
8
+ const body = parse(text, { sourceType: 'module' }).program.body;
9
+ const hosts = new Set(), tables = new Set();
10
+ for (const node of body) if (node.type === 'ImportDeclaration') {
11
+ for (const s of node.specifiers) {
12
+ if (/^@homie-rocks\/studio\/worker(?:\/selling)?$/.test(node.source.value) && s.imported?.name === 'hostRules') hosts.add(s.local.name);
13
+ if (node.source.value === './rules/index.mjs' && s.type === 'ImportDefaultSpecifier') tables.add(s.local.name);
14
+ }
15
+ }
16
+ return body.some(n => n.type === 'ExpressionStatement' && n.expression.type === 'CallExpression' && hosts.has(n.expression.callee.name) && tables.has(n.expression.arguments[0]?.name));
17
+ }
18
+
19
+ export async function ensureRulesWiring(root) {
20
+ const file = join(root, 'site/src/worker.mjs');
21
+ let text;
22
+ try { text = readFileSync(file, 'utf8'); } catch { throw new Error(RULES_WIRING_MESSAGE); }
23
+ if (registersRules(text)) return;
24
+ // Upgrade already knows every exact generated Worker fingerprint. Reuse that
25
+ // ownership check; a comment claiming to be generated is not ownership.
26
+ const { upgradePlan } = await import('./upgrade.mjs');
27
+ const update = upgradePlan(root).changes?.find(c => c.file === 'site/src/worker.mjs' && c.kind === 'update-file');
28
+ if (update && registersRules(update.text)) { writeFileSync(file, update.text); return; }
29
+ throw new Error(RULES_WIRING_MESSAGE);
30
+ }
package/lib/scaffold.mjs CHANGED
@@ -40,7 +40,7 @@ import { rulesIndex } from './studio.mjs';
40
40
 
41
41
  export const COMPAT_DATE = '2026-06-01';
42
42
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
43
- export const WRANGLER_VERSION = '4.145.0';
43
+ export const WRANGLER_VERSION = '4.150.0';
44
44
  /**
45
45
  * The TypeScript a new studio asks for, so `build --types` (and an editor) has a compiler: the studio's own
46
46
  * devDependency, never one of the toolkit's (a studio that never checks types pays nothing for it at runtime).
@@ -112,11 +112,28 @@ ${JSON.stringify(config, null, 2)}
112
112
  `;
113
113
  }
114
114
 
115
- export function agentsMd({ name, slug }) {
115
+ export function agentsMd({ name, slug, kind = 'game' }) {
116
+ if (kind !== 'game') return `# ${name}
117
+
118
+ This is **${name}** (\`${slug}\`), a ${kind === 'cause' ? 'cause' : 'business'} studio. Its apps, pages and shop run on its own Cloudflare account.
119
+
120
+ Read \`node_modules/@homie-rocks/studio/guides/app/SKILL.md\` first. Proceed from the owner's request through building and checking; ask for an interview only when requested or when essential information is missing.
121
+
122
+ - \`apps/<id>/app.json\` defines the app's words, roles, records and surfaces; \`src/view.ts\` draws it.
123
+ - \`site/pages/<path>/index.html\` adds a page and a header link. A page at an app's id replaces its landing, while \`/<id>/open\` and \`/<id>/tv\` remain the phone and wall entrances.
124
+ - \`shop.json\` defines products, services or gifts. Local dev previews items before Stripe is connected.
125
+ - \`studio.json\` names the organisation and its currency. \`site/theme.json\` sets its colours and fonts.
126
+
127
+ Start with \`homie-studio app new <id> --from welcome|gathering|giving|quiz|jobs\`, then \`npm install\`, \`npm run build\`, \`npm run dev\`. Use the printed address; a busy port is skipped. Run \`homie-studio check <id> --url <address> --shots .checks\` and inspect the wall and phone captures.
128
+
129
+ For gifts, use \`homie-studio shop init --giving --currency <currency>\`. The organisation supplies its own receipt/tax wording. The giving wall records intentions, not paid gifts. Service requests are not confirmed reservations.
130
+
131
+ Keep credentials, private staff links and personal records out of source control. Use the studio's own roles and records APIs. Publish only when the owner requests it. Existing authored files belong to the owner; do not overwrite them during upgrades.
132
+ `;
116
133
  return `# ${name}
117
134
 
118
135
  This folder is a studio: **${name}** (\`${slug}\`). It is one repository.
119
- Its games, music, videos and posts live here; its website and public game rooms
136
+ ${kind === 'game' ? 'Its games, music, videos and posts live here;' : 'Its apps and public pages live here. Read node_modules/@homie-rocks/studio/guides/app/SKILL.md first;'} its website and public game rooms
120
137
  run on the studio's **own Cloudflare account** (one Worker, one D1 database and
121
138
  the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
122
139
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
@@ -555,7 +572,7 @@ chat's card follows the work from then on) and, for a studio still being set up,
555
572
  the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
556
573
  unrequested deploys and the other held calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
557
574
  the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
558
- holds are on. A deploy the person requested in their own words is already approved for that task; do not ask again or demand a typed code. Stop an unrequested deploy and ask once. When a call is refused, say what you
575
+ holds are on. A deploy the person requested in their own words is already approved for that task; do not ask again or demand a typed code. The original task also counts in non-interactive sessions, including a prompt piped into codex exec. Stop an unrequested deploy and ask once: the person can say “Please deploy this studio.” When a call is refused, say what you
559
576
  meant to do and why; do not retry it unless the person asks.
560
577
 
561
578
  ## Telling Homie
@@ -679,7 +696,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
679
696
  | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
680
697
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
681
698
  | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
682
- | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
699
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces an app or game landing; the app still opens at \`/<id>/open\` and its wall at \`/<id>/tv\`) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
683
700
  | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
684
701
 
685
702
  \`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
@@ -726,10 +743,11 @@ art/**/raw/
726
743
  `;
727
744
 
728
745
  /** Every file a new studio gets, relative to its folder. */
729
- export function studioFiles({ name, slug, homie, template = false }) {
746
+ export function studioFiles({ name, slug, homie, template = false, kind = 'game' }) {
730
747
  const worker = slug;
731
748
  const studio = {
732
749
  name, slug,
750
+ ...(kind !== 'game' ? { kind } : {}),
733
751
  // A copy of the public template (Deploy to Cloudflare) until `homie-studio setup attach` names it for real:
734
752
  // meanwhile its site shows the name the person typed in Cloudflare's form (STUDIO_NAME).
735
753
  ...(template ? { template: true } : {}),
@@ -759,7 +777,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
759
777
  },
760
778
  };
761
779
  const files = {
762
- 'AGENTS.md': agentsMd({ name, slug }),
780
+ 'AGENTS.md': agentsMd({ name, slug, kind }),
763
781
  'CLAUDE.md': claudeMd(),
764
782
  'README.md': template ? templateReadme() : readme({ name }),
765
783
  'HANDOFF.md': HANDOFF_MD,
@@ -775,6 +793,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
775
793
  'posts/README.md': POSTS_README,
776
794
  'site/theme.json': themeFile(slug),
777
795
  'site/README.md': SITE_README,
796
+ 'site/pages/.gitkeep': '',
778
797
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
779
798
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
780
799
  import { hostRules, useTools } from '@homie-rocks/studio/worker';
@@ -1019,7 +1038,7 @@ function insideGit(dir) {
1019
1038
  try { return execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'true'; } catch { return false; }
1020
1039
  }
1021
1040
 
1022
- export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false } = {}) {
1041
+ export function newStudio(folder, { name, homie, slug: askedSlug, install = true, template = false, kind = 'game' } = {}) {
1023
1042
  if (typeof folder !== 'string' || !folder.trim()) throw new Error('name the folder for the new studio, e.g. `homie-studio new ~/studios/night-owls --name "Night Owls"`. Nothing was written.');
1024
1043
  if (!name || !String(name).trim()) throw new Error('give the studio a name: --name "Night Owls". Nothing was written.');
1025
1044
  const dir = resolve(folder.replace(/^~(?=\/|$)/, homedir()));
@@ -1034,7 +1053,7 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1034
1053
  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.`);
1035
1054
  }
1036
1055
  const slug = slugify(askedSlug || name);
1037
- const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template });
1056
+ const files = studioFiles({ name: String(name).trim(), slug, homie: homie || 'https://homie.rocks', template, kind });
1038
1057
  mkdirSync(dir, { recursive: true });
1039
1058
  const wrote = [];
1040
1059
  for (const [rel, content] of Object.entries(files)) {
@@ -1058,7 +1077,14 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
1058
1077
  } catch (error) { installed = `npm install failed (${String(error.stderr ?? error.message).trim().split('\n').slice(-2).join(' ')}): run it in the studio folder`; }
1059
1078
  }
1060
1079
  const q = JSON.stringify(realpathSync(dir));
1061
- return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
1080
+ return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: kind !== 'game' ? [
1081
+ `cd ${q}`,
1082
+ 'Read the app guide. Proceed from the request through build and checks without waiting unless the person asks for an interview.',
1083
+ 'npx --no-install homie-studio app new <id> --from welcome --name "<Name>"',
1084
+ 'npm install && npm run build; npm run dev (use the printed address)',
1085
+ 'npx --no-install homie-studio check <id> --url <printed address> --shots .checks',
1086
+ 'The phone opens /<id>/open; the wall /<id>/tv. Publish when requested.',
1087
+ ] : [
1062
1088
  `cd ${q}${install ? '' : ' && npm install'}`,
1063
1089
  'read the game skill and RULES.md; choose sensible defaults and make the requested game as server rules plus view',
1064
1090
  'optional live example, without copying a game here: npx --no-install homie-studio demo',
@@ -39,7 +39,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
39
39
  else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
40
  // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
41
  // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
42
- const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
42
+ const priceFields = { product: id, currency: shop.currency, ...(item.interval ? { recurring: { interval: item.interval } } : {}), tax_behavior: 'exclusive', ...(item.price === 'choose'
43
43
  ? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
44
44
  : { unit_amount: item.price }) };
45
45
  const revision = hash(priceFields);
@@ -52,7 +52,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
52
52
  const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
53
53
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
54
54
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
55
- payment_intent_data: { metadata },
55
+ ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
56
56
  };
57
57
  const linkRevision = hash({ params, item, policy: shop.policy });
58
58
  let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
package/lib/shop.mjs CHANGED
@@ -107,13 +107,15 @@ export function supporterItem({ price = 500, days = 365, name = 'Supporter' } =
107
107
  return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
108
108
  }
109
109
 
110
- export function shopInit(root, { supporter = false, currency = 'usd', price = 500, managed = false } = {}) {
110
+ export function shopInit(root, { supporter = false, giving = false, currency = null, price = 500, managed = false } = {}) {
111
111
  const file = join(root, SHOP_FILE);
112
112
  if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
113
113
  const studio = readStudio(root);
114
+ if (giving && !currency && !studio.currency) return { ok: false, command: 'shop init', why: 'Set studio.json currency or give --currency for gifts.' };
114
115
  const shop = {
115
- till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
116
- items: supporter ? [supporterItem({ price: Number(price) })] : [],
116
+ ...(giving ? { giving: { receiptText: '' } } : {}),
117
+ till: managed ? 'stripe-managed' : 'stripe', currency: String(currency ?? studio.currency ?? 'usd').toLowerCase(),
118
+ items: giving ? [1000, 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) })] : [],
117
119
  };
118
120
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
119
121
  if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
package/lib/studio.mjs CHANGED
@@ -300,16 +300,17 @@ export function addNeeds(root, needs) {
300
300
  }
301
301
 
302
302
 
303
- export async function newApp(root, id, { name } = {}) {
303
+ export async function newApp(root, id, { name, from = 'welcome' } = {}) {
304
304
  if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error('invalid app id');
305
305
  if (listExperiences(root).some((g) => g.id === id) || existsSync(join(root, 'apps', id))) throw new Error(`app/game ${id} already exists`);
306
306
  const dest = join(root, 'apps', id);
307
- cpSync(join(PACKAGE_ROOT, 'app-starters', 'welcome'), dest, { recursive: true });
307
+ if (!['welcome', 'giving', 'gathering', 'quiz', 'jobs'].includes(from)) throw new Error('app starter: welcome, giving, gathering, quiz or jobs');
308
+ cpSync(join(PACKAGE_ROOT, 'app-starters', from), dest, { recursive: true });
308
309
  const file = join(dest, 'app.json');
309
310
  const meta = JSON.parse(readFileSync(file, 'utf8'));
310
- meta.id = id; meta.name = String(name ?? 'Welcome together').slice(0, 60);
311
+ meta.id = id; meta.name = String(name ?? meta.name).slice(0, 60);
311
312
  writeFileSync(file, `${JSON.stringify(meta, null, 2)}\n`);
312
313
  const main = join(dest, meta.entry ?? 'src/main.ts');
313
314
  writeFileSync(main, readFileSync(main, 'utf8').replaceAll("game: 'welcome'", `game: '${id}'`));
314
- return { ok: true, command: 'app new', id, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
+ return { ok: true, command: 'app new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...addNeeds(root, meta.needs) };
315
316
  }
@@ -3069,6 +3069,90 @@
3069
3069
  }
3070
3070
  }
3071
3071
  }
3072
+ },
3073
+ "0.48.4": {
3074
+ "files": {
3075
+ "CLAUDE.md": "ddf2b2a0376202f6",
3076
+ "README.md": "684b3cca8001673c",
3077
+ "HANDOFF.md": "29337e2ace95b2e7",
3078
+ "games/README.md": "5ad32fe80c61f117",
3079
+ "music/README.md": "a3dc042dadd39df8",
3080
+ "videos/README.md": "16babc3437d5545b",
3081
+ "posts/README.md": "37841aae476bd540",
3082
+ "site/README.md": "732894273a32dc3f",
3083
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3084
+ },
3085
+ "sections": {
3086
+ "AGENTS.md": {
3087
+ "(intro)": "394cbbe934e80722",
3088
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3089
+ "## Layout": "f3a280b8c96cd068",
3090
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3091
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3092
+ "## The site": "0cb8dd9a6f8a0096",
3093
+ "## Making games": "bb715272fd1a615b",
3094
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3095
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3096
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3097
+ "## Servers and AI seats": "1ccfd549d2369a60",
3098
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3099
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3100
+ "## Rules": "b2234d80f6cbe71d",
3101
+ "## Telling Homie": "80b46fdd9e578db8",
3102
+ "## Beta": "9d02638207a26e73"
3103
+ }
3104
+ },
3105
+ "named": {
3106
+ "Homie Arcade": {
3107
+ "sections": {
3108
+ "AGENTS.md": {
3109
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3110
+ }
3111
+ }
3112
+ }
3113
+ }
3114
+ },
3115
+ "0.49.0": {
3116
+ "files": {
3117
+ "CLAUDE.md": "ddf2b2a0376202f6",
3118
+ "README.md": "684b3cca8001673c",
3119
+ "HANDOFF.md": "29337e2ace95b2e7",
3120
+ "games/README.md": "5ad32fe80c61f117",
3121
+ "music/README.md": "a3dc042dadd39df8",
3122
+ "videos/README.md": "16babc3437d5545b",
3123
+ "posts/README.md": "37841aae476bd540",
3124
+ "site/README.md": "1dbdf22c03a1e505",
3125
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3126
+ },
3127
+ "sections": {
3128
+ "AGENTS.md": {
3129
+ "(intro)": "394cbbe934e80722",
3130
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3131
+ "## Layout": "f3a280b8c96cd068",
3132
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3133
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3134
+ "## The site": "0cb8dd9a6f8a0096",
3135
+ "## Making games": "bb715272fd1a615b",
3136
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3137
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3138
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3139
+ "## Servers and AI seats": "1ccfd549d2369a60",
3140
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3141
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3142
+ "## Rules": "b2234d80f6cbe71d",
3143
+ "## Telling Homie": "80b46fdd9e578db8",
3144
+ "## Beta": "9d02638207a26e73"
3145
+ }
3146
+ },
3147
+ "named": {
3148
+ "Homie Arcade": {
3149
+ "sections": {
3150
+ "AGENTS.md": {
3151
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3152
+ }
3153
+ }
3154
+ }
3155
+ }
3072
3156
  }
3073
3157
  }
3074
3158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.48.3",
3
+ "version": "0.49.0",
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",
@@ -141,11 +141,11 @@
141
141
  "@gltf-transform/extensions": "4.5.1",
142
142
  "@gltf-transform/functions": "4.5.1",
143
143
  "@jridgewell/trace-mapping": "0.3.31",
144
- "@modelcontextprotocol/sdk": "1.30.0",
145
- "@modelcontextprotocol/server": "2.0.0",
144
+ "@modelcontextprotocol/sdk": "1.32.1",
145
+ "@modelcontextprotocol/server": "2.3.1",
146
146
  "@x402/core": "2.28.0",
147
147
  "@x402/evm": "2.28.0",
148
- "agents": "0.28.0",
148
+ "agents": "0.28.1",
149
149
  "esbuild": "0.28.1",
150
150
  "gltf-validator": "2.0.0-dev.3.10",
151
151
  "jose": "6.2.12",
@@ -21,7 +21,7 @@ export function appProblems(meta) {
21
21
  for (const [field, spec] of Object.entries(c.fields)) if (!key.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field`);
22
22
  }
23
23
  for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
24
- for (const [k, v] of Object.entries(meta.words ?? {})) if (!Object.hasOwn(APP_WORDS, k) || typeof v !== 'string' || !v.trim() || v.length > 60) bad.push(`words.${k}: use a short label`);
24
+ for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
25
25
  return bad;
26
26
  }
27
27
  export const appWords = (g) => g?.kind === 'app' ? { ...APP_WORDS, ...g.words } : { open: 'Play', join: 'Scan to play', one: 'player', many: 'players' };
@@ -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.48.3","sha256":"52a096d783a92de61024123ffd3ce7e3e73c2c805d2a7238d342d2fc77d3830d"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.49.0","sha256":"26095bfdf585bf237d209c64a8ee5e2f99cbb901814dcf972d5872d753127413"});