@homie-rocks/studio 0.51.7 → 0.52.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 +54 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/bin/homie-studio.mjs +9 -4
- package/docs/toolkit-place-patterns.md +68 -0
- package/functions/FUNCTIONS.md +27 -0
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +123 -0
- package/guides/app/SKILL.md +11 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-needs.mjs +41 -6
- package/lib/app-ready.mjs +31 -0
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +13 -10
- package/lib/cloudflare.mjs +2 -2
- package/lib/connector-token.mjs +30 -0
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +6 -1
- package/lib/functions-build.mjs +3 -0
- package/lib/mcp-tools.mjs +2 -2
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +4 -4
- package/lib/shop-mcp.mjs +82 -14
- package/lib/stripe-connect.mjs +9 -4
- package/lib/template-history.json +84 -0
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +6 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +61 -17
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/booking-holds.mjs +87 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/connector-token.mjs +11 -0
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +24 -6
- package/worker/join-area.mjs +35 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +17 -9
- package/worker/pages.mjs +10 -7
- package/worker/records-page.mjs +5 -4
- package/worker/records-prefs.mjs +24 -0
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/lib/shop-mcp.mjs
CHANGED
|
@@ -6,11 +6,11 @@ import {join} from 'node:path';
|
|
|
6
6
|
import {readShop, SHOP_HOOK_EVENTS} from './shop.mjs';
|
|
7
7
|
import {readStudio, siteUrl} from './studio.mjs';
|
|
8
8
|
import {reconcilePaymentLinks} from './shop-links.mjs';
|
|
9
|
-
import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
9
|
+
import {fullerReason, linkSettings, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
10
10
|
const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
|
11
11
|
export const readKey = (path, params) => canonicalJson([path,params]);
|
|
12
12
|
const slotsOf=(root,slots)=>slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
|
|
13
|
-
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
|
|
13
|
+
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false, refresh = false, account} = {}) {
|
|
14
14
|
const checked=readShop(root);
|
|
15
15
|
if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
|
|
16
16
|
if(!['test','live'].includes(mode))throw new Error('Choose test or live mode');
|
|
@@ -26,8 +26,38 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
26
26
|
const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
|
|
27
27
|
// Reject secrets before persisting observations. Webhook creation is never part of this plan.
|
|
28
28
|
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
29
|
+
let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
|
|
30
|
+
const cacheFile=join(root,'.studio','stripe-mcp-observations.json');
|
|
31
|
+
let cache;try{cache=JSON.parse(readFileSync(cacheFile,'utf8'));}catch{}
|
|
32
|
+
const source=hash({shop:checked.shop,slots:bookingSlots});
|
|
33
|
+
const reusable=!refresh && cache?.site===site && cache.mode===mode && (!account || cache.account===account);
|
|
34
|
+
const legacy=!refresh && saved?.result?.site===site && saved.result.mode===mode && (!account || saved.options?.account===account);
|
|
35
|
+
let prior=reusable?cache.observations:legacy?saved.options?.observations??[]:[];
|
|
36
|
+
// Catalogue edits retain product/price discovery, but refresh mutable slot counts and writes.
|
|
37
|
+
const legacySourceMatches=legacy && (saved.source===source || saved.result.fingerprint===hash({shop:checked.shop,bookingSlots,site,mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}}));
|
|
38
|
+
if(reusable && cache.source!==source || !reusable && legacy && !legacySourceMatches)prior=prior.filter(o=>/\/v1\/(products|prices)(\/|$)/.test(o.path));
|
|
39
|
+
observations=[...prior,...observations];
|
|
40
|
+
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
41
|
+
// Latest response per operation, bounded by operations rather than replanning attempts.
|
|
42
|
+
const latest=new Map();
|
|
43
|
+
for(const o of observations){const key=canonicalJson([o.method??'GET',o.path,o.params??{}]);latest.delete(key);latest.set(key,o);}
|
|
44
|
+
observations=[...latest.values()];
|
|
45
|
+
const observedAt=refresh?new Date().toISOString():reusable?cache.observedAt:legacy?saved.result.verifiedAt??new Date().toISOString():new Date().toISOString();
|
|
46
|
+
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
47
|
+
writeFileSync(cacheFile,JSON.stringify({site,mode,account:account??cache?.account??saved?.options?.account,source,observedAt,observations}));
|
|
29
48
|
const api=async(method,path,params={},idempotency)=>{
|
|
30
49
|
let found=observations.findLast(o=>(o.method??'GET')===method && readKey(o.path,o.params??{})===readKey(path,params));
|
|
50
|
+
// Upgrade old list reads to the scoped searches, and hydrate old link lists
|
|
51
|
+
// from the expanded reads already saved by 0.51.6.
|
|
52
|
+
if(!found && method==='GET' && /\/v1\/(products|prices)\/search$/.test(path) && !params.page)
|
|
53
|
+
found=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path.replace('/search','') && o.body?.has_more===false);
|
|
54
|
+
if(!found && method==='GET' && path==='/v1/payment_links' && !params.starting_after) {
|
|
55
|
+
const old=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path && o.body?.has_more===false);
|
|
56
|
+
if(old) {
|
|
57
|
+
const data=old.body.data.map(l=>observations.findLast(o=>o.path===`${path}/${l.id}`)?.body??l);
|
|
58
|
+
if(data.every(l=>l.line_items?.data))found={...old,body:{...old.body,data}};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
31
61
|
if(method==='GET'&&path.split('/').length>=4&&!path.endsWith('/search')) {
|
|
32
62
|
const written=observations.findLast(o=>o.method==='POST'&&o.body?.id===path.split('/').at(-1));
|
|
33
63
|
if(written&&observations.indexOf(written)>observations.indexOf(found)) {
|
|
@@ -36,22 +66,24 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
36
66
|
}
|
|
37
67
|
}
|
|
38
68
|
if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
|
|
39
|
-
|
|
69
|
+
let body=found.body;
|
|
70
|
+
if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
|
|
71
|
+
const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
|
|
72
|
+
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
|
|
73
|
+
if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
|
|
74
|
+
if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
|
|
75
|
+
}
|
|
76
|
+
body={...body,data:[...rows.values()]};
|
|
77
|
+
}
|
|
40
78
|
if(!body || body.error || body.livemode !== undefined && body.livemode !== (mode==='live') || body.data?.some(o=>o.livemode !== undefined && o.livemode !== (mode==='live')))throw new Error('Stripe read has an error or wrong mode');
|
|
41
79
|
return body;
|
|
42
80
|
};
|
|
43
81
|
const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
|
|
44
82
|
webhook:{method:'POST',path:'/v1/webhook_endpoints',private:true,params:{url:`${site}/api/shop/hook`,enabled_events:SHOP_HOOK_EVENTS,api_version:LINKS_STRIPE_VERSION,metadata:{homie:'shop-v1'},description:'Studio shop'}}};
|
|
45
|
-
let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
|
|
46
|
-
const knownLinks=saved?.result?.site===site&&saved.result.mode===mode?[...(saved.result.config.items??[]),...(saved.result.config.menus??[]),...(saved.result.config.slots??[])].map(l=>l.id):[];
|
|
47
|
-
for(const observation of observations) {
|
|
48
|
-
const body=observation.body;
|
|
49
|
-
if(/^plink_[A-Za-z0-9]+$/.test(body?.id??'')&&body.metadata?.homie==='shop-links-v1'&&body.metadata.studio===slug&&body.metadata.origin===site)knownLinks.push(body.id);
|
|
50
|
-
}
|
|
51
83
|
try {
|
|
52
|
-
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,
|
|
84
|
+
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,knownPortal:saved?.result?.site===site&&saved.result.mode===mode&&(!account||saved.options?.account===account)?saved.result.config?.portalId:undefined});
|
|
53
85
|
if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
|
|
54
|
-
const result={...base,ok:true,verified:true,verifiedAt:
|
|
86
|
+
const result={...base,ok:true,verified:true,verifiedAt:observedAt,...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
|
|
55
87
|
return result;
|
|
56
88
|
} catch(e) {
|
|
57
89
|
if(!e.next&&!e.operations)throw e;
|
|
@@ -69,7 +101,7 @@ export async function saveMcpVerification(root, options) {
|
|
|
69
101
|
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
70
102
|
const slots=slotsOf(root,options.slots);
|
|
71
103
|
if(options.slots!==undefined)writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(slots));
|
|
72
|
-
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
|
|
104
|
+
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account??JSON.parse(readFileSync(join(root,'.studio','stripe-mcp-observations.json'),'utf8')).account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),sourceConfig:{shop:readShop(root).shop,slots},result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
|
|
73
105
|
}
|
|
74
106
|
return result;
|
|
75
107
|
}
|
|
@@ -78,8 +110,44 @@ export async function saveMcpVerification(root, options) {
|
|
|
78
110
|
export function verifiedMcpConfig(root) {
|
|
79
111
|
const file=join(root,'.studio','stripe-mcp.json');if(!existsSync(file))return null;
|
|
80
112
|
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
81
|
-
if(saved.result?.local)return null; // Local builds read .dev.vars, never production bindings.
|
|
113
|
+
if(!saved.result?.verified || saved.result.local)return null; // Local builds read .dev.vars, never production bindings.
|
|
82
114
|
const slots=existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):saved.options?.slots??[];
|
|
83
|
-
|
|
115
|
+
const shop=readShop(root).shop, source=hash({shop,slots}), site=new URL(siteUrl(root)).origin;
|
|
116
|
+
const legacyFingerprint=hash({shop,bookingSlots:slots,site,mode:saved.result.mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}});
|
|
117
|
+
if(!saved.source && saved.result.fingerprint===legacyFingerprint) {
|
|
118
|
+
saved.source=source;saved.sourceConfig={shop,slots};
|
|
119
|
+
writeFileSync(file,JSON.stringify(saved,null,2));
|
|
120
|
+
}
|
|
121
|
+
if(saved.source!==source || saved.result.site!==site) {
|
|
122
|
+
const changes=[];
|
|
123
|
+
if(saved.result.site!==site)changes.push('site URL');
|
|
124
|
+
const previous=saved.sourceConfig?.shop;
|
|
125
|
+
if(previous)for(const key of new Set([...Object.keys(previous),...Object.keys(shop??{})])) {
|
|
126
|
+
if(canonicalJson(previous[key]??null)!==canonicalJson(shop?.[key]??null))changes.push(`shop.${key}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
let before;try{before=JSON.parse(saved.result.config?.settings);}catch{}
|
|
130
|
+
const after=JSON.parse(linkSettings(shop));
|
|
131
|
+
if(before)for(const key of new Set([...Object.keys(before),...Object.keys(after)])) {
|
|
132
|
+
if(canonicalJson(before[key]??null)!==canonicalJson(after[key]??null))changes.push(`shop.${key}`);
|
|
133
|
+
}
|
|
134
|
+
else changes.push('shop settings');
|
|
135
|
+
const receipt=saved.options?.observations?.find(o=>o.body?.custom_text?.submit?.message)?.body.custom_text.submit.message;
|
|
136
|
+
if(receipt!==(shop.giving?.receiptText||undefined))changes.push('shop.giving.receiptText');
|
|
137
|
+
}
|
|
138
|
+
if(canonicalJson(saved.sourceConfig?.slots??saved.options?.slots??[])!==canonicalJson(slots))changes.push('booking slots');
|
|
139
|
+
throw new Error(`Shop configuration changed: ${changes.join(', ')||'shop settings (saved verification differs)'}. Run stripe_shop_plan with verify:true before deploying or installing its webhook.`);
|
|
140
|
+
}
|
|
84
141
|
return saved;
|
|
85
142
|
}
|
|
143
|
+
|
|
144
|
+
/** Unfinished public plans also publish a closed setup, even before first verification. */
|
|
145
|
+
export function publicMcpSetup(root) {
|
|
146
|
+
for(const name of ['stripe-mcp.json','stripe-mcp-observations.json']) {
|
|
147
|
+
const file=join(root,'.studio',name);
|
|
148
|
+
if(!existsSync(file))continue;
|
|
149
|
+
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
150
|
+
return !saved.result?.local && !(saved.site??saved.result?.site??saved.options?.url??'').startsWith('http:');
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -37,11 +37,11 @@ const stripeReason = (result, call) => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// This output stays private, including CLI config, completion, errors and API responses.
|
|
40
|
-
export function stripeProcess(args, { cwd, env = process.env } = {}) {
|
|
40
|
+
export function stripeProcess(args, { cwd, env = process.env, command = 'stripe' } = {}) {
|
|
41
41
|
const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
|
|
42
42
|
for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
|
|
43
43
|
return new Promise((resolve) => {
|
|
44
|
-
const p = spawn(
|
|
44
|
+
const p = spawn(command, args, { cwd, env: childEnv, shell: process.platform==='win32' && command==='npm.cmd', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
45
45
|
let stdout = '', stderr = '', ended = false;
|
|
46
46
|
const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
|
|
47
47
|
const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
|
|
@@ -92,7 +92,7 @@ export async function shopConnect(root, options = {}) {
|
|
|
92
92
|
try {
|
|
93
93
|
const {verifiedMcpConfig} = await import('./shop-mcp.mjs');
|
|
94
94
|
const saved=verifiedMcpConfig(root);
|
|
95
|
-
if(!saved
|
|
95
|
+
if(!saved?.result?.verifiedAt || Date.now()-Date.parse(saved.result.verifiedAt)>15*60_000) return fail('verify','Run shop verify with fresh Stripe MCP reads before installing the webhook (verification is valid for 15 minutes).');
|
|
96
96
|
const verified=saved.result;
|
|
97
97
|
if(!verified.verified || verified.local || verified.mode!==mode || verified.site!==new URL(siteUrl(root)).origin) return fail('verify','Run shop verify with fresh Stripe MCP reads for this site and mode first.');
|
|
98
98
|
mcpAccount=saved.options.account;
|
|
@@ -110,7 +110,12 @@ export async function shopConnect(root, options = {}) {
|
|
|
110
110
|
try {
|
|
111
111
|
const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
|
|
112
112
|
const installed = await cli(['--version']);
|
|
113
|
-
if (installed.code !== 0)
|
|
113
|
+
if (installed.code !== 0) {
|
|
114
|
+
log('Installing Stripe’s official CLI with npm…');
|
|
115
|
+
const install=options.installExec??((command,args)=>stripeProcess(args,{cwd:root,command}));
|
|
116
|
+
const result=await install(process.platform==='win32'?'npm.cmd':'npm',['install','--global','@stripe/cli@latest']);
|
|
117
|
+
if(result.code!==0 || (await cli(['--version'])).code!==0)return fail('stripe-cli','Automatic Stripe CLI installation failed. Check npm install permissions and PATH, then run shop connect again.',{next:['npm install --global @stripe/cli@latest']});
|
|
118
|
+
}
|
|
114
119
|
|
|
115
120
|
// A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
|
|
116
121
|
let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
@@ -3837,6 +3837,90 @@
|
|
|
3837
3837
|
}
|
|
3838
3838
|
}
|
|
3839
3839
|
}
|
|
3840
|
+
},
|
|
3841
|
+
"0.51.8": {
|
|
3842
|
+
"files": {
|
|
3843
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3844
|
+
"README.md": "684b3cca8001673c",
|
|
3845
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3846
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3847
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3848
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3849
|
+
"posts/README.md": "37841aae476bd540",
|
|
3850
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3851
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3852
|
+
},
|
|
3853
|
+
"sections": {
|
|
3854
|
+
"AGENTS.md": {
|
|
3855
|
+
"(intro)": "394cbbe934e80722",
|
|
3856
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3857
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3858
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3859
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3860
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3861
|
+
"## Making games": "bb715272fd1a615b",
|
|
3862
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3863
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3864
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3865
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3866
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3867
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3868
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3869
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3870
|
+
"## Beta": "9d02638207a26e73"
|
|
3871
|
+
}
|
|
3872
|
+
},
|
|
3873
|
+
"named": {
|
|
3874
|
+
"Homie Arcade": {
|
|
3875
|
+
"sections": {
|
|
3876
|
+
"AGENTS.md": {
|
|
3877
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
},
|
|
3883
|
+
"0.52.0": {
|
|
3884
|
+
"files": {
|
|
3885
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3886
|
+
"README.md": "684b3cca8001673c",
|
|
3887
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3888
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3889
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3890
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3891
|
+
"posts/README.md": "37841aae476bd540",
|
|
3892
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3893
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3894
|
+
},
|
|
3895
|
+
"sections": {
|
|
3896
|
+
"AGENTS.md": {
|
|
3897
|
+
"(intro)": "394cbbe934e80722",
|
|
3898
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3899
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3900
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3901
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3902
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3903
|
+
"## Making games": "bb715272fd1a615b",
|
|
3904
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3905
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3906
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3907
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3908
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3909
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3910
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3911
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3912
|
+
"## Beta": "9d02638207a26e73"
|
|
3913
|
+
}
|
|
3914
|
+
},
|
|
3915
|
+
"named": {
|
|
3916
|
+
"Homie Arcade": {
|
|
3917
|
+
"sections": {
|
|
3918
|
+
"AGENTS.md": {
|
|
3919
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3840
3924
|
}
|
|
3841
3925
|
}
|
|
3842
3926
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {createRequire} from 'node:module';
|
|
2
|
+
import {existsSync,readdirSync,readFileSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
/** Only a standalone, named refresh timer over known record clients is mechanical. */
|
|
5
|
+
export function upgradePlaceView(source,root){
|
|
6
|
+
// Desktop ships no compiler; use the studio’s installed compiler when available.
|
|
7
|
+
let ts;try{ts=createRequire(root?join(root,'package.json'):import.meta.url)('typescript');}catch{return source;}
|
|
8
|
+
const file=ts.createSourceFile('view.ts',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);
|
|
9
|
+
const factories=new Set(),clients=[];
|
|
10
|
+
for(const statement of file.statements){
|
|
11
|
+
if(ts.isImportDeclaration(statement)&&statement.moduleSpecifier.text==='@homie-rocks/studio/apps'){
|
|
12
|
+
for(const name of statement.importClause?.namedBindings?.elements??[])if((name.propertyName?.text??name.name.text)==='createAppRecords')factories.add(name.name.text);
|
|
13
|
+
}
|
|
14
|
+
if(ts.isVariableStatement(statement))for(const d of statement.declarationList.declarations){
|
|
15
|
+
if(ts.isIdentifier(d.name)&&d.initializer&&ts.isCallExpression(d.initializer)&&ts.isIdentifier(d.initializer.expression)&&factories.has(d.initializer.expression.text))clients.push(d.name.text);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if(!clients.length)return source;
|
|
19
|
+
const edits=[];
|
|
20
|
+
for(const statement of file.statements){
|
|
21
|
+
if(!ts.isExpressionStatement(statement)||!ts.isCallExpression(statement.expression))continue;
|
|
22
|
+
const call=statement.expression;
|
|
23
|
+
if(!ts.isIdentifier(call.expression)||call.expression.text!=='setInterval'||call.arguments.length!==2)continue;
|
|
24
|
+
const [callback,delay]=call.arguments;
|
|
25
|
+
if(!ts.isIdentifier(callback)||!/^refresh/i.test(callback.text)||!ts.isNumericLiteral(delay)||+delay.text<2000||+delay.text>3000)continue;
|
|
26
|
+
edits.push({start:statement.getStart(file),end:statement.end,text:clients.map(name=>`${name}.subscribe(${callback.text});`).join('\n')});
|
|
27
|
+
}
|
|
28
|
+
for(const edit of edits.reverse())source=source.slice(0,edit.start)+edit.text+source.slice(edit.end);
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
/** Exact obsolete notes only. Never rewrite owner's words, evidence, dates or unresolved needs. */
|
|
32
|
+
export function upgradePlaceCodex(source){
|
|
33
|
+
const replacements=new Map([
|
|
34
|
+
['The runner has no staff surface or MCP step.','The toolkit supports staff surfaces and MCP needs. Rerun the needs; earlier manual evidence is not a passing proof.'],
|
|
35
|
+
['Real Homie gap: no push for studio events, so the AI polls.','Declared studio event feeds support push subscriptions; reconnect from the last cursor.'],
|
|
36
|
+
['Staff device runs the jobs Homie has no scheduler for.','Run these jobs in scheduled server functions; remove the duplicate staff-device job after proving its replacement.'],
|
|
37
|
+
['ctx.database has only get/put (no atomic append).','Use versioned database writes and named event feeds; do not maintain an event log with sleep-and-retry.']
|
|
38
|
+
]);
|
|
39
|
+
return source.split('\n').map(line=>replacements.get(line)??line).join('\n');
|
|
40
|
+
}
|
|
41
|
+
export function placeUpgradeChanges(root){
|
|
42
|
+
const dir=join(root,'apps');if(!existsSync(dir))return [];
|
|
43
|
+
const changes=[];
|
|
44
|
+
for(const app of readdirSync(dir,{withFileTypes:true}).filter(d=>d.isDirectory()))for(const [suffix,rewrite] of [['CODEX.md',upgradePlaceCodex],['src/view.ts',upgradePlaceView]]){
|
|
45
|
+
const file=`apps/${app.name}/${suffix}`,path=join(root,file);if(!existsSync(path))continue;
|
|
46
|
+
const from=readFileSync(path,'utf8'),text=rewrite(from,root);if(from!==text)changes.push({file,kind:'update-file',from,text,what:'replace a recognized obsolete place workaround with the supported toolkit contract; rerun needs'});
|
|
47
|
+
}
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {placeUpgradeChanges} from './upgrade-places.mjs';
|
|
1
2
|
import { upgradeMediaGuidance } from './upgrade-media.mjs';
|
|
2
3
|
import {appPresetChanges} from './upgrade-app-presets.mjs';
|
|
3
4
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
@@ -176,7 +177,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
176
177
|
const who = { name, slug };
|
|
177
178
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
178
179
|
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
179
|
-
const changes = appPresetChanges(root);
|
|
180
|
+
const changes = [...appPresetChanges(root),...placeUpgradeChanges(root)];
|
|
180
181
|
const kept = [];
|
|
181
182
|
|
|
182
183
|
// package.json: the pin, and the scripts the template's studios have.
|
package/lib/worker-config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {verifiedMcpConfig} from './shop-mcp.mjs';
|
|
1
|
+
import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
|
|
2
2
|
import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
3
3
|
import {join} from 'node:path';
|
|
4
4
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
@@ -27,8 +27,11 @@ export function updateWorkerConfig(root,generated) {
|
|
|
27
27
|
writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
|
|
28
28
|
}
|
|
29
29
|
const next=merge(base,custom);
|
|
30
|
-
|
|
31
|
-
if(
|
|
30
|
+
verifiedMcpConfig(root);
|
|
31
|
+
if(publicMcpSetup(root)) {
|
|
32
|
+
next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
|
|
33
|
+
delete next.vars.STRIPE_SHOP_LINKS;
|
|
34
|
+
}
|
|
32
35
|
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
33
36
|
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
34
37
|
next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function randomId(): string;
|
|
2
|
+
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
|
+
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
|
4
|
+
/** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
|
|
5
|
+
export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
|
package/links/links.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export function randomId(): string;
|
|
2
2
|
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
3
|
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
|
4
|
+
/** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
|
|
5
|
+
export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
|
package/links/links.mjs
CHANGED
package/links/time.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Interpret a place's calendar date and wall time using the runtime's IANA zone data. */
|
|
2
|
+
export function placeTime(date,time,timeZone,{disambiguation='reject'}={}){
|
|
3
|
+
if(!/^\d{4}-\d{2}-\d{2}$/.test(date)||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time))throw new Error('Use a calendar date YYYY-MM-DD and time HH:MM');
|
|
4
|
+
if(typeof timeZone!=='string'||/^[+-]/.test(timeZone))throw new Error('Use an IANA time zone');
|
|
5
|
+
if(!['reject','earlier','later'].includes(disambiguation))throw new Error('Unknown time disambiguation');
|
|
6
|
+
const naive=Date.parse(date+'T'+time+':00Z');
|
|
7
|
+
if(!Number.isFinite(naive)||new Date(naive).toISOString().slice(0,10)!==date)throw new Error('Invalid calendar date');
|
|
8
|
+
let format;try{format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});}catch{throw new Error('Use a valid IANA time zone');}
|
|
9
|
+
const wall=epoch=>{
|
|
10
|
+
const p=Object.fromEntries(format.formatToParts(epoch).map(p=>[p.type,p.value]));
|
|
11
|
+
return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
|
|
12
|
+
};
|
|
13
|
+
const offsets=new Set();
|
|
14
|
+
// Sample both sides of even a whole-day civil transition; do not assume an hour-sized DST shift.
|
|
15
|
+
for(let h=-48;h<=48;h+=6){const sample=naive+h*3600000;offsets.add(wall(sample)-sample);}
|
|
16
|
+
const choices=[...offsets].map(offset=>naive-offset).filter(epoch=>wall(epoch)===naive).sort((a,b)=>a-b);
|
|
17
|
+
if(!choices.length)throw new Error('This local time does not exist in the place’s time zone');
|
|
18
|
+
if(choices.length>1&&disambiguation==='reject')throw new Error('This local time is ambiguous; choose earlier or later explicitly');
|
|
19
|
+
return disambiguation==='later'?choices.at(-1):choices[0];
|
|
20
|
+
}
|
package/netplay/netplay.ts
CHANGED
|
@@ -116,6 +116,8 @@ export type Movement = 'owner' | 'host';
|
|
|
116
116
|
|
|
117
117
|
/** What the shell injects as `window.HOMIE_NET` before the game boots. */
|
|
118
118
|
export interface NetConfig {
|
|
119
|
+
/** The served experience kind, independent of standalone packaging. */
|
|
120
|
+
kind?: 'app';
|
|
119
121
|
v: number;
|
|
120
122
|
/** The relay socket: dev `ws://127.0.0.1:<port>/__net?room=main`, site `wss://<host>/api/table/<CODE>/ws?as=net&s=<nonce>`. */
|
|
121
123
|
url: string;
|
|
@@ -1939,6 +1941,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1939
1941
|
}
|
|
1940
1942
|
/** What is true of a page that plays by itself: it never says "reconnecting" to somebody who was never connected. */
|
|
1941
1943
|
function aloneText(): string {
|
|
1944
|
+
if (cfg?.kind === 'app') return 'Reconnecting. Your saved updates will appear shortly.';
|
|
1942
1945
|
if (!canOffline) return 'This game needs a connection.';
|
|
1943
1946
|
if (rulesGame && !rulesHost) return 'Loading offline play…';
|
|
1944
1947
|
if (aloneWhy === 'reconnect-timeout') return 'Playing on your own · the room dropped, still trying…';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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",
|
package/rules/collision.ts
CHANGED
|
@@ -123,6 +123,7 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
123
123
|
charge(24);
|
|
124
124
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
125
125
|
let best: Hit3 | null = null;
|
|
126
|
+
const currentBest = (): Hit3 | null => best;
|
|
126
127
|
const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1) { const hit = id ? {...h, id} : h; if (hits) {charge(8); hits.push(hit);} if (!best || h.t < best.t) best = hit; } };
|
|
127
128
|
for (const axis of ['x', 'y', 'z'] as const) {
|
|
128
129
|
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
@@ -141,10 +142,11 @@ function castMap3Uncached(map: Map3, p: Vec3, d: Vec3, body: BodyShape, hits?: H
|
|
|
141
142
|
charge(terrain.length*(16+Math.ceil(Math.log2(terrain.length+1))));const order=new Map(terrain.map(t=>[t,first(t)]));terrain.sort((a,b)=>order.get(a)!-order.get(b)!);
|
|
142
143
|
}
|
|
143
144
|
for (const tile of terrain) {
|
|
144
|
-
|
|
145
|
+
const nearest: Hit3 | null = currentBest();
|
|
146
|
+
if(exactTerrain && !hits && nearest?.t===0)break;
|
|
145
147
|
// Once a nearer surface is known, later solids only need to beat that time.
|
|
146
148
|
// This also bounds work on densely tessellated terrain behind the first hit.
|
|
147
|
-
const limit = exactTerrain && !hits &&
|
|
149
|
+
const limit = exactTerrain && !hits && nearest ? nearest.t : 1;
|
|
148
150
|
const delta = limit < 1 ? point(d.x * limit, d.y * limit, d.z * limit) : d;
|
|
149
151
|
const hit = tile.base === undefined ? castHeightTile(tile, p, delta) : (exactTerrain ? castTerrain : legacyTerrain)(tile, a, delta);
|
|
150
152
|
if (hit) take({...hit, t: hit.t * limit});
|
package/rules/terrain.ts
CHANGED
|
@@ -136,12 +136,13 @@ function roundedCast(source:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
|
136
136
|
|
|
137
137
|
const speed2=dot(d,d);if(speed2<1e-20)return null;
|
|
138
138
|
let best:Hit3|null=null;
|
|
139
|
+
const currentBest=():Hit3|null=>best;
|
|
139
140
|
const take=(t:number,normal:Vec3)=>{if(t< -1e-9||t>1||best&&t>=best.t)return;const len=Math.sqrt(dot(normal,normal));if(len<1e-12||dot(normal,d)>=-1e-12)return;best={t:Math.max(0,t),nx:normal.x/len,ny:normal.y/len,nz:normal.z/len};};
|
|
140
141
|
const sphere=(v:Vec3)=>{const o=sub(p,v),b=dot(o,d),c=dot(o,o)-r*r,h=b*b-speed2*c;if(h<0)return;const t=(-b-Math.sqrt(h))/speed2;take(t,sub(add(p,d,t),v));};
|
|
141
142
|
// Reuse the plane times from clipping. Parallel and receding faces cannot
|
|
142
143
|
// be entry contacts; do not project or retest their already-known times.
|
|
143
144
|
for(const {plane,t} of approaching){
|
|
144
|
-
charge(8);if(
|
|
145
|
+
charge(8);const nearest=currentBest();if(nearest&&t>=nearest.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
|
|
145
146
|
for(const q of prism.planes){charge(4);if(dot(q.n,hit)>q.c+1e-10){inside=false;break;}}
|
|
146
147
|
if(inside)take(t,plane.n);
|
|
147
148
|
}
|
package/rules/tiles.ts
CHANGED
|
@@ -45,10 +45,14 @@ export function compileTiles(raw: any, wrap?: MapWrap): {
|
|
|
45
45
|
return { tiles, boxes };
|
|
46
46
|
}
|
|
47
47
|
export function tileApi(get: () => GameMap) {
|
|
48
|
-
const cell = (x: unknown, y: unknown) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const cell = (x: unknown, y: unknown) => {
|
|
49
|
+
const t = get().tiles;
|
|
50
|
+
if (!t || typeof x !== 'number' || typeof y !== 'number' || !Number.isInteger(x) || !Number.isInteger(y)) return -1;
|
|
51
|
+
const w = get().wrap;
|
|
52
|
+
const cx = w?.x ? ((x % t.width) + t.width) % t.width : x;
|
|
53
|
+
const cy = w?.y ? ((y % t.height) + t.height) % t.height : y;
|
|
54
|
+
return cx < 0 || cy < 0 || cx >= t.width || cy >= t.height ? -1 : cy * t.width + cx;
|
|
55
|
+
};
|
|
52
56
|
return {
|
|
53
57
|
tile: (x: unknown, y: unknown) => { charge(4); const id = cell(x, y); return id < 0 ? 1 : get().tiles!.data[id]; },
|
|
54
58
|
distance: (name: unknown, x: unknown, y: unknown) => { charge(4); const id = cell(x, y), f = typeof name === 'string' && get().tiles ? fields.get(get().tiles!)?.get(name) : null; return id < 0 || !f ? -1 : f.distance[id]; },
|
package/shop/SHOP.md
CHANGED
|
@@ -122,27 +122,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
122
122
|
3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
|
|
123
123
|
for **writes as well as reads**. Keep this setup's observations across batches.
|
|
124
124
|
Product creation includes its default Price; link creation expands line items.
|
|
125
|
-
Product and Price Search use studio/origin metadata.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
Product and Price Search use studio/origin metadata. Payment Links use paginated
|
|
126
|
+
list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
|
|
127
|
+
Stripe has no Payment Link metadata search. There is no per-link read loop.
|
|
128
|
+
The planner saves public observations in `.studio/stripe-mcp-observations.json` and
|
|
129
|
+
reuses them on the next call; send only new responses. Keep that file and
|
|
130
|
+
`.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
|
|
131
|
+
mutable links while retaining product/price discovery. Account, mode and origin
|
|
132
|
+
changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
|
|
133
|
+
verification before webhook installation (CLI: include it in `--have` input).
|
|
134
|
+
Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
|
|
135
|
+
retry its exact idempotency key; never discard successful responses or invent IDs.
|
|
134
136
|
4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
|
|
135
137
|
`homie-studio shop verify --have plan-input.json`). Verification checks real fields,
|
|
136
138
|
line items, menus, slot limits and portal settings. It saves compact public Worker
|
|
137
139
|
configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
|
|
138
|
-
|
|
140
|
+
Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
|
|
141
|
+
that asset, with only its path in Wrangler vars. Large catalogues never use a text
|
|
142
|
+
binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
|
|
139
143
|
it cannot open until signed payment events can confirm orders. Changing shop.json,
|
|
140
144
|
the slot manifest or origin requires verification again. Local test configuration
|
|
141
145
|
goes to `.dev.vars`.
|
|
142
146
|
5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
|
|
143
147
|
Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
|
|
144
|
-
checks the same account as the MCP, and installs the signing secret
|
|
145
|
-
|
|
148
|
+
checks the same account as the MCP, and installs only the signing secret through
|
|
149
|
+
Wrangler stdin. If Stripe CLI is missing, the tool installs the official
|
|
150
|
+
`@stripe/cli@latest` npm package automatically, checks the executable, then continues.
|
|
151
|
+
An installation failure reports npm permissions/PATH and a retry command. The CLI
|
|
152
|
+
needs one separate browser approval if its
|
|
146
153
|
studio profile is not already connected. MCP approval cannot be reused or exported.
|
|
147
154
|
Cloudflare authorization and an existing deployment are prerequisites.
|
|
148
155
|
6. After installation, perform the returned `retire` operations through Stripe MCP to
|
|
@@ -153,6 +160,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
153
160
|
endpoint's delivery result and the app's verified records. Do not type a card into
|
|
154
161
|
a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
|
|
155
162
|
|
|
163
|
+
Upgrades accept 0.51.6 verifications without a source fingerprint only when their
|
|
164
|
+
saved content fingerprint still matches the current shop, slots, site and mode.
|
|
165
|
+
Actual changes name the affected settings and require verification. An unfinished
|
|
166
|
+
setup still builds and deploys with checkout closed and one next step: finish
|
|
167
|
+
`stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
|
|
168
|
+
old link secrets cannot reopen it. Cached observations never renew the verification
|
|
169
|
+
time; use fresh reads if the private webhook tool requests them.
|
|
170
|
+
|
|
156
171
|
Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
|
|
157
172
|
|
|
158
173
|
**Why the separate approval (checked 2026-10-14).** Stripe's
|
package/shop/shop.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface Shop {
|
|
|
71
71
|
cart(): CartLine[];
|
|
72
72
|
clear(): void;
|
|
73
73
|
/** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
|
|
74
|
-
book(item: string, booking: { collection: string; slot: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
|
|
74
|
+
book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
|
|
75
75
|
checkout(options?: { reference?: string }): Promise<boolean>;
|
|
76
76
|
buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
|
|
77
77
|
/** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
|
package/tools/tools.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface ToolContext {
|
|
|
6
6
|
records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
|
|
7
7
|
office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
|
|
8
8
|
shop(): Promise<unknown>;
|
|
9
|
+
bookings: {
|
|
10
|
+
hold(args: {app: string; collection: string; slot: string; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
|
|
11
|
+
get(args: {id: string; token?: string}): Promise<BookingHold>;
|
|
12
|
+
confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
|
13
|
+
release(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
|
14
|
+
releaseExpired(args: {app: string; collection: string; now?: number}): Promise<{released: number}>;
|
|
15
|
+
};
|
|
16
|
+
events: { publish(event: {feed: string; id: string; data: unknown}): Promise<{id: string}> };
|
|
9
17
|
rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
|
|
10
18
|
database: {
|
|
11
19
|
get(key: string): Promise<unknown>;
|
|
@@ -32,7 +40,11 @@ export interface StudioTool<Input = Record<string, unknown>> {
|
|
|
32
40
|
namespace?: string;
|
|
33
41
|
/** A named HMAC secret delegates this one tool as an existing account. */
|
|
34
42
|
webhook?: { secret: string; person: string };
|
|
43
|
+
/** Named business feeds this trusted definition may publish. */
|
|
44
|
+
publish?: string[];
|
|
35
45
|
events?: Record<string, {game: string; schema: Record<string, unknown>; audience?: Audience}>;
|
|
36
46
|
handler(input: Input, context: ToolContext): unknown | Promise<unknown>;
|
|
37
47
|
}
|
|
38
48
|
export function defineTool<Input = Record<string, unknown>>(tool: StudioTool<Input>): StudioTool<Input> { return tool; }
|
|
49
|
+
|
|
50
|
+
export interface BookingHold { id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
|
|
2
|
+
if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
|
|
3
|
+
try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
|
|
4
|
+
const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
|
|
5
|
+
p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
|
|
6
|
+
const canvas=document.querySelector('canvas');
|
|
7
|
+
canvas.insertAdjacentElement('afterend',p);
|
|
8
|
+
}
|