@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +68 -1
  4. package/bin/homie-studio.mjs +8 -2
  5. package/docs/shop-migration.md +9 -0
  6. package/docs/toolkit-batch.md +49 -0
  7. package/docs/toolkit-place-patterns.md +6 -0
  8. package/functions/FUNCTIONS.md +42 -1
  9. package/functions/functions.ts +4 -0
  10. package/guides/app/PLACES.md +146 -0
  11. package/guides/app/SKILL.md +3 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/lib/app-needs.mjs +59 -15
  15. package/lib/app-ready.mjs +1 -1
  16. package/lib/build.mjs +8 -2
  17. package/lib/check.mjs +40 -16
  18. package/lib/cloudflare.mjs +2 -2
  19. package/lib/dev-restart.mjs +25 -0
  20. package/lib/dev.mjs +7 -1
  21. package/lib/fork.mjs +47 -0
  22. package/lib/functions-build.mjs +3 -0
  23. package/lib/jobs.mjs +1 -0
  24. package/lib/mcp-tools.mjs +3 -3
  25. package/lib/needs-headless.mjs +4 -3
  26. package/lib/scaffold.mjs +29 -1
  27. package/lib/shop-links.mjs +7 -7
  28. package/lib/shop-mcp.mjs +88 -14
  29. package/lib/stripe-connect.mjs +46 -6
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +25 -3
  32. package/lib/upgrade-places.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/lib/worker-config.mjs +7 -3
  35. package/links/links.d.mts +5 -0
  36. package/links/links.d.ts +2 -0
  37. package/links/links.mjs +2 -0
  38. package/links/time.mjs +20 -0
  39. package/package.json +1 -1
  40. package/rules/rules.ts +1 -1
  41. package/shop/SHOP.md +27 -12
  42. package/shop/shop.ts +3 -3
  43. package/tools/tools.ts +13 -0
  44. package/worker/app-format.mjs +6 -3
  45. package/worker/app-records.mjs +21 -5
  46. package/worker/app-shell.mjs +28 -5
  47. package/worker/app-streams.mjs +57 -0
  48. package/worker/app-tools.mjs +44 -0
  49. package/worker/app-web.mjs +23 -0
  50. package/worker/booking-calendar.mjs +26 -0
  51. package/worker/booking-holds.mjs +100 -0
  52. package/worker/booking-options.mjs +23 -0
  53. package/worker/booking-resources.mjs +55 -0
  54. package/worker/build-id.mjs +1 -1
  55. package/worker/business-events.mjs +31 -0
  56. package/worker/customer-mcp.mjs +1 -1
  57. package/worker/event-resources.mjs +1 -1
  58. package/worker/functions.mjs +5 -0
  59. package/worker/index.mjs +54 -9
  60. package/worker/local-test.mjs +10 -0
  61. package/worker/mcp-events.mjs +3 -3
  62. package/worker/mcp-tools.mjs +23 -2
  63. package/worker/mcp.mjs +3 -3
  64. package/worker/office.mjs +27 -0
  65. package/worker/pages.mjs +4 -4
  66. package/worker/records-page.mjs +3 -3
  67. package/worker/shop-links.mjs +12 -0
  68. package/worker/shop.mjs +20 -5
  69. package/worker/version.mjs +1 -1
@@ -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, knownLinks = [], knownPortal }) {
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
- ()=>scoped?parallel([...new Set(knownLinks)].map(id=>()=>api('GET',`/v1/payment_links/${id}`,{expand:['line_items']}))):list('/v1/payment_links'),
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=scoped?(knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:[]):await list('/v1/billing_portal/configurations');
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
- const body=found.body;
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,knownLinks,knownPortal:saved?.result?.site===site&&saved.result.mode===mode?saved.result.config.portalId:undefined});
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:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
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
- if(!saved.result?.verified || saved.source!==hash({shop:readShop(root).shop,slots}) || saved.result.site!==new URL(siteUrl(root)).origin)throw new Error('Shop configuration changed; verify through stripe_shop_plan before deploying or installing its webhook.');
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
+ }
@@ -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('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
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.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).');
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??''))return fail('account','Verify with the Stripe MCP account ID first.');
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) return fail('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
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
- if(accountResult.code!==0 || json(accountResult.stdout)?.id!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
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
- if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
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.
@@ -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
- const verified=verifiedMcpConfig(root);
31
- if(verified)next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:JSON.stringify(verified.result.config)};
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
@@ -14,3 +14,5 @@ export function appLink(address, params = {}, base = globalThis.location?.href)
14
14
  }
15
15
  return url.href;
16
16
  }
17
+
18
+ export {placeTime} from './time.mjs';
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.51.8",
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
- if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
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);