@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.
- package/CHANGELOG.md +42 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +15 -11
- package/guides/app/SKILL.md +72 -0
- package/guides/parts/SKILL.md +158 -0
- package/guides/shop/SKILL.md +149 -0
- package/guides/studio-setup/SKILL.md +419 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +6 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +17 -6
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +10 -9
- package/lib/parts.mjs +6 -6
- package/lib/rules-wiring.mjs +30 -0
- package/lib/scaffold.mjs +36 -10
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +5 -4
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +30 -10
- package/worker/pages.mjs +4 -2
- package/worker/parts.mjs +4 -4
- package/worker/rules-wiring.mjs +2 -0
- package/worker/shop-page.mjs +21 -15
- package/worker/shop-rules.mjs +4 -3
- package/worker/site.mjs +4 -2
- 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
|
|
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'}.
|
|
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
|
|
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}
|
|
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
|
|
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 ?
|
|
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: '
|
|
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} (
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 ? [
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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',
|
package/lib/shop-links.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 ??
|
|
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.
|
|
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.
|
|
145
|
-
"@modelcontextprotocol/server": "2.
|
|
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.
|
|
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",
|
package/worker/app-format.mjs
CHANGED
|
@@ -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 (
|
|
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' };
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.49.0","sha256":"26095bfdf585bf237d209c64a8ee5e2f99cbb901814dcf972d5872d753127413"});
|