@homie-rocks/studio 0.51.8 → 0.53.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 +59 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +68 -1
- package/bin/homie-studio.mjs +8 -2
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/docs/toolkit-place-patterns.md +6 -0
- package/functions/FUNCTIONS.md +42 -1
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +146 -0
- package/guides/app/SKILL.md +3 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/lib/app-needs.mjs +59 -15
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +8 -2
- package/lib/check.mjs +40 -16
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +7 -1
- package/lib/fork.mjs +47 -0
- package/lib/functions-build.mjs +3 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +29 -1
- package/lib/shop-links.mjs +7 -7
- package/lib/shop-mcp.mjs +88 -14
- package/lib/stripe-connect.mjs +46 -6
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +7 -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/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +13 -0
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +21 -5
- package/worker/app-shell.mjs +28 -5
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +100 -0
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +54 -9
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +3 -3
- package/worker/mcp-tools.mjs +23 -2
- package/worker/mcp.mjs +3 -3
- package/worker/office.mjs +27 -0
- package/worker/pages.mjs +4 -4
- package/worker/records-page.mjs +3 -3
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +20 -5
- package/worker/version.mjs +1 -1
package/lib/shop-links.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
9
|
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
-
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false,
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false, knownPortal }) {
|
|
11
11
|
if(scoped) {
|
|
12
12
|
const transport=api;
|
|
13
13
|
api=(method,path,params={},idempotency)=>transport(method,path,method==='POST'&&path.startsWith('/v1/payment_links/')?{...params,expand:['line_items']}:params,idempotency);
|
|
@@ -44,7 +44,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
44
44
|
const [products,prices,links]=await parallel([
|
|
45
45
|
()=>scoped?search('products'):list('/v1/products'),
|
|
46
46
|
()=>scoped?search('prices'):list('/v1/prices'),
|
|
47
|
-
()=>
|
|
47
|
+
()=>list('/v1/payment_links',scoped?{expand:['data.line_items']}:{}),
|
|
48
48
|
]);
|
|
49
49
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
50
50
|
const verifyLink = async (link, params) => {
|
|
@@ -85,7 +85,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
85
85
|
catalogPrices.set(item.id, price.id);
|
|
86
86
|
if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
|
|
87
87
|
if (item.price === 0) return; // Free orders never leave the studio.
|
|
88
|
-
const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
|
|
88
|
+
const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' && !['supporter','deposit'].includes(item.kind) ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
|
|
89
89
|
after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
|
|
90
90
|
...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
|
|
91
91
|
...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
|
|
@@ -103,7 +103,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
103
103
|
// Stripe allows 20 Payment Link lines; invoice snapshots include only the first
|
|
104
104
|
// 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
|
|
105
105
|
config.menus = [];
|
|
106
|
-
const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
106
|
+
const menuItems = shop.items.filter(i => !['supporter','deposit'].includes(i.kind) && typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
107
107
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
108
108
|
const tasks=[];
|
|
109
109
|
const groups=Map.groupBy(menuItems,i=>i.game??'');
|
|
@@ -198,7 +198,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
198
198
|
});
|
|
199
199
|
}
|
|
200
200
|
if(shop.items.some(i=>i.interval)) tasks.push(async()=>{
|
|
201
|
-
const portals=
|
|
201
|
+
const portals=knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:await list('/v1/billing_portal/configurations');
|
|
202
202
|
let portal=portals.find(p=>own(p));
|
|
203
203
|
const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
|
|
204
204
|
if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
|
|
@@ -258,9 +258,9 @@ export async function syncPaymentLinks(options) {
|
|
|
258
258
|
secret = endpoint.secret;
|
|
259
259
|
}
|
|
260
260
|
const fingerprint = hash({config, purchases:purchases?.config});
|
|
261
|
-
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
|
|
261
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && (options.catalog || has('STRIPE_SHOP_LINKS')) && !has('STRIPE_KEY');
|
|
262
262
|
if (!unchanged) {
|
|
263
|
-
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
263
|
+
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', ...(options.catalog ? {} : {STRIPE_SHOP_LINKS: JSON.stringify(config)}), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
264
264
|
if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
|
|
265
265
|
// Explicitly running connect selects keyless; manual selects the existing keyed checkout.
|
|
266
266
|
if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
|
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,50 @@ 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
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Record a batch from the official plugin and return the next dependency batch. */
|
|
156
|
+
export async function recordShopObservations(root, options = {}) {
|
|
157
|
+
if(!Array.isArray(options.observations) || !options.observations.length)throw new Error('Supply observations: [{method,path,params,body}] from Stripe’s official plugin.');
|
|
158
|
+
return shopMcpPlan(root, options);
|
|
159
|
+
}
|
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,11 +92,11 @@ 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;
|
|
99
|
-
if(!/^acct_[A-Za-z0-9]+$/.test(mcpAccount
|
|
99
|
+
if(mcpAccount && !/^acct_[A-Za-z0-9]+$/.test(mcpAccount))return fail('account','Invalid saved Stripe account ID.');
|
|
100
100
|
catalog={config:verified.config,retire:[]};
|
|
101
101
|
} catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
|
|
102
102
|
}
|
|
@@ -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'] : [])]);
|
|
@@ -121,6 +126,7 @@ export async function shopConnect(root, options = {}) {
|
|
|
121
126
|
if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
|
|
122
127
|
return fail('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.' + stripeReason(start, 'login'));
|
|
123
128
|
}
|
|
129
|
+
options.onEvent?.({type:'stripe.pairing',code:login.verification_code,url:login.browser_url});
|
|
124
130
|
log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
|
|
125
131
|
open(login.browser_url);
|
|
126
132
|
const finished = await cli(complete);
|
|
@@ -131,7 +137,23 @@ export async function shopConnect(root, options = {}) {
|
|
|
131
137
|
}
|
|
132
138
|
if (options.webhookOnly) {
|
|
133
139
|
const accountResult=await cli(['get','/v1/account',...(live?['--live']:[])]);
|
|
134
|
-
|
|
140
|
+
const account=json(accountResult.stdout)?.id;
|
|
141
|
+
if(accountResult.code!==0 || !/^acct_[A-Za-z0-9]+$/.test(account??'') || mcpAccount && account!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
|
|
142
|
+
if(!mcpAccount) {
|
|
143
|
+
// Old verifications predate account IDs. Establish ownership before adopting
|
|
144
|
+
// the CLI account; never silently bind a different account's catalog.
|
|
145
|
+
const links=[...(catalog.config.items??[]),...(catalog.config.menus??[]),...(catalog.config.slots??[])];
|
|
146
|
+
for(const link of links) {
|
|
147
|
+
const read=await cli(['get',`/v1/payment_links/${link.id}`,...(live?['--live']:[])]);
|
|
148
|
+
const actual=json(read.stdout);
|
|
149
|
+
if(read.code!==0 || actual?.id!==link.id || actual.url!==link.url || actual.livemode!==(mode==='live'))return fail('account','The signed-in Stripe account does not own the verified links. Select their account and retry.');
|
|
150
|
+
}
|
|
151
|
+
const file=join(root,'.studio','stripe-mcp.json');
|
|
152
|
+
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
153
|
+
saved.options={...saved.options,account};
|
|
154
|
+
writeFileSync(file,JSON.stringify(saved,null,2));
|
|
155
|
+
mcpAccount=account;
|
|
156
|
+
}
|
|
135
157
|
}
|
|
136
158
|
const site = siteUrl(root);
|
|
137
159
|
if (!site) return fail('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
|
|
@@ -151,6 +173,24 @@ export async function shopConnect(root, options = {}) {
|
|
|
151
173
|
const reason = fullerReason(checked.shop);
|
|
152
174
|
if (reason) return fail('fuller-checkout', reason, { mode, connected: true });
|
|
153
175
|
const studio = readStudio(root);
|
|
176
|
+
if(options.webhookOnly && catalog.config.portal && !catalog.config.portalId) {
|
|
177
|
+
// 0.51.6 saved the login URL alone. Discover its identity without creating
|
|
178
|
+
// a second portal or making webhook installation depend on this new field.
|
|
179
|
+
try {
|
|
180
|
+
let after;
|
|
181
|
+
for(let page=0;page<100;page++) {
|
|
182
|
+
const found=await api('GET','/v1/billing_portal/configurations',{limit:100,...(after?{starting_after:after}:{})});
|
|
183
|
+
const portal=found.data?.find(p=>p.login_page?.url===catalog.config.portal && p.metadata?.homie==='shop-links-v1' && p.metadata?.studio===studio.slug && p.metadata?.origin===target.origin);
|
|
184
|
+
if(portal) {
|
|
185
|
+
catalog.config.portalId=portal.id;
|
|
186
|
+
const file=join(root,'.studio','stripe-mcp.json'),saved=JSON.parse(readFileSync(file,'utf8'));
|
|
187
|
+
saved.result.config.portalId=portal.id;writeFileSync(file,JSON.stringify(saved,null,2));break;
|
|
188
|
+
}
|
|
189
|
+
if(!found.has_more||!found.data?.length)break;
|
|
190
|
+
after=found.data.at(-1).id;
|
|
191
|
+
}
|
|
192
|
+
} catch { log('The existing billing portal URL is retained. The next Stripe plugin observation can fill its portal ID.'); }
|
|
193
|
+
}
|
|
154
194
|
const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
155
195
|
const receiptFile = join(dir, `${mode}.json`);
|
|
156
196
|
const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
|
|
@@ -3879,6 +3879,90 @@
|
|
|
3879
3879
|
}
|
|
3880
3880
|
}
|
|
3881
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
|
+
}
|
|
3924
|
+
},
|
|
3925
|
+
"0.53.0": {
|
|
3926
|
+
"files": {
|
|
3927
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3928
|
+
"README.md": "684b3cca8001673c",
|
|
3929
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3930
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3931
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3932
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3933
|
+
"posts/README.md": "37841aae476bd540",
|
|
3934
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3935
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3936
|
+
},
|
|
3937
|
+
"sections": {
|
|
3938
|
+
"AGENTS.md": {
|
|
3939
|
+
"(intro)": "394cbbe934e80722",
|
|
3940
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3941
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3942
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3943
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3944
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3945
|
+
"## Making games": "bb715272fd1a615b",
|
|
3946
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3947
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3948
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3949
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3950
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3951
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3952
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3953
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3954
|
+
"## Beta": "9d02638207a26e73"
|
|
3955
|
+
}
|
|
3956
|
+
},
|
|
3957
|
+
"named": {
|
|
3958
|
+
"Homie Arcade": {
|
|
3959
|
+
"sections": {
|
|
3960
|
+
"AGENTS.md": {
|
|
3961
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3882
3966
|
}
|
|
3883
3967
|
}
|
|
3884
3968
|
}
|
package/lib/tools-cli.mjs
CHANGED
|
@@ -16,9 +16,10 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
|
|
|
16
16
|
const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
|
|
17
17
|
const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
|
|
18
18
|
const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
|
|
19
|
-
|
|
19
|
+
const fetch=startupFetch({log});
|
|
20
|
+
if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url,{fetch}));return client;}
|
|
20
21
|
const secret=localCredential(root,url);
|
|
21
|
-
if(secret){await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
|
|
22
|
+
if(secret){await client.connect(new StreamableHTTPClientTransport(url,{fetch,requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
|
|
22
23
|
const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
|
|
23
24
|
let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
|
|
24
25
|
// Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
|
|
@@ -46,7 +47,7 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
|
|
|
46
47
|
if(back.searchParams.has('error'))throw new Error('Connection declined');
|
|
47
48
|
await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
|
|
48
49
|
}
|
|
49
|
-
await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
|
|
50
|
+
await client.connect(new StreamableHTTPClientTransport(url,{fetch,authProvider:provider}));return client;
|
|
50
51
|
}finally{server.close();}
|
|
51
52
|
}
|
|
52
53
|
function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
|
|
@@ -56,3 +57,24 @@ export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
|
|
|
56
57
|
const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
|
|
57
58
|
try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
/** A new Worker can briefly return Cloudflare 1042 before MCP starts. */
|
|
62
|
+
export function startupFetch({fetch: send=globalThis.fetch,log=()=>{},sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}) {
|
|
63
|
+
return async(input,init)=>{
|
|
64
|
+
const request=new Request(input,init);
|
|
65
|
+
for(let attempt=0;;attempt++) {
|
|
66
|
+
const response=await send(request.clone());
|
|
67
|
+
if(response.status!==500 || attempt>=3)return response;
|
|
68
|
+
const reader=response.clone().body?.getReader();
|
|
69
|
+
let text='';
|
|
70
|
+
if(reader)try {
|
|
71
|
+
const decoder=new TextDecoder();
|
|
72
|
+
while(text.length<4096) {const {value,done}=await reader.read();if(done)break;text+=decoder.decode(value).slice(0,4096-text.length);}
|
|
73
|
+
}finally{void reader.cancel().catch(()=>{});}
|
|
74
|
+
if(!/error code:\s*1042\b/i.test(text))return response;
|
|
75
|
+
log(`Cloudflare 1042 during Worker startup; retrying MCP (${attempt+1}/3).`);
|
|
76
|
+
await response.body?.cancel();
|
|
77
|
+
await sleep(250*2**attempt);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -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,12 @@ 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
|
-
|
|
30
|
+
next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
|
|
31
|
+
verifiedMcpConfig(root);
|
|
32
|
+
if(publicMcpSetup(root)) {
|
|
33
|
+
next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
|
|
34
|
+
delete next.vars.STRIPE_SHOP_LINKS;
|
|
35
|
+
}
|
|
32
36
|
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
33
37
|
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
34
38
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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/rules.ts
CHANGED
|
@@ -607,7 +607,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
607
607
|
if (dims === 3 && b.height !== undefined && (!Number.isFinite(b.height) || b.height <= 0 || b.shape === 'capsule' && b.height < b.radius * 2)) throw new Error(`${at}.body.height must be finite, positive and at least twice the radius for a capsule`);
|
|
608
608
|
body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner', ...(b.terrain === 'exact' ? {terrain: 'exact' as const} : {}) };
|
|
609
609
|
}
|
|
610
|
-
|
|
610
|
+
// Turn-based participants may own fields and commands without physics.
|
|
611
611
|
const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
|
|
612
612
|
if (body && player && !moveFn) throw new Error(`${at}: a player's body moves with move.${name}(body, input, ctx) in move.ts`);
|
|
613
613
|
const scores = fields.filter(([, fd]) => fd.score);
|