@homie-rocks/studio 0.52.0 → 0.54.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 (61) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +9 -3
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/guides/app/SKILL.md +2 -2
  9. package/guides/shop/SKILL.md +6 -0
  10. package/lib/app-needs.mjs +27 -11
  11. package/lib/app-ready.mjs +1 -1
  12. package/lib/build.mjs +4 -3
  13. package/lib/check.mjs +47 -15
  14. package/lib/dev.mjs +9 -1
  15. package/lib/fork.mjs +47 -0
  16. package/lib/jobs.mjs +1 -0
  17. package/lib/mcp-tools.mjs +1 -1
  18. package/lib/needs-headless.mjs +4 -3
  19. package/lib/scaffold.mjs +27 -1
  20. package/lib/shop-links.mjs +39 -21
  21. package/lib/shop-mcp.mjs +13 -1
  22. package/lib/stripe-connect.mjs +37 -2
  23. package/lib/template-history.json +84 -0
  24. package/lib/tools-cli.mjs +25 -3
  25. package/lib/worker-config.mjs +1 -0
  26. package/package.json +1 -1
  27. package/rules/rules.ts +1 -1
  28. package/rules/view.ts +1 -1
  29. package/shop/SHOP.md +13 -9
  30. package/shop/shop.ts +3 -3
  31. package/tools/tools.ts +3 -2
  32. package/worker/app-format.mjs +6 -3
  33. package/worker/app-records.mjs +20 -6
  34. package/worker/app-shell.mjs +10 -5
  35. package/worker/app-streams.mjs +26 -9
  36. package/worker/app-tools.mjs +2 -1
  37. package/worker/app-web.mjs +23 -0
  38. package/worker/booking-calendar.mjs +26 -0
  39. package/worker/booking-holds.mjs +18 -5
  40. package/worker/booking-options.mjs +23 -0
  41. package/worker/booking-resources.mjs +55 -0
  42. package/worker/build-id.mjs +1 -1
  43. package/worker/chat-store.mjs +5 -4
  44. package/worker/customer-mcp.mjs +1 -1
  45. package/worker/event-resources.mjs +6 -1
  46. package/worker/index.mjs +74 -27
  47. package/worker/local-test.mjs +10 -0
  48. package/worker/mcp-events.mjs +2 -2
  49. package/worker/mcp-tools.mjs +20 -2
  50. package/worker/office.mjs +108 -6
  51. package/worker/operational-schema.mjs +30 -0
  52. package/worker/pages.mjs +7 -1
  53. package/worker/records-page.mjs +2 -2
  54. package/worker/request-db.mjs +53 -0
  55. package/worker/servers.mjs +5 -4
  56. package/worker/shop-links.mjs +1 -1
  57. package/worker/shop-page.mjs +1 -0
  58. package/worker/shop-rules.mjs +3 -2
  59. package/worker/shop-store.mjs +4 -3
  60. package/worker/shop.mjs +68 -8
  61. package/worker/version.mjs +1 -1
package/lib/fork.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import {existsSync,mkdirSync,readdirSync,readFileSync,writeFileSync,copyFileSync,rmSync} from 'node:fs';
2
+ import {resolve,join,dirname,extname,relative} from 'node:path';
3
+ import {refreshWorkerConfig} from './worker-config.mjs';
4
+ /** Copy editable source, never account state, installed code or a deployment. */
5
+ export async function forkStudio(source,target,{name,slug,ids={}}={}) {
6
+ source=resolve(source);target=resolve(target);
7
+ if(existsSync(target))throw new Error('Fork target already exists');
8
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(slug??'')||typeof name!=='string'||!name.trim())throw new Error('Supply name and a lowercase slug (1–40 letters, digits and hyphens)');
9
+ if(!relative(source,target).startsWith('..'))throw new Error('Fork target must be outside the example');
10
+ const studio=JSON.parse(readFileSync(join(source,'studio.json'),'utf8'));
11
+ const excluded=new Set(['node_modules','.git','.studio','dist','.wrangler','.DS_Store','wrangler.json','wrangler.jsonc','wrangler.toml','wrangler.custom.json','package-lock.json']);
12
+ const files=[];
13
+ function walk(dir){for(const entry of readdirSync(join(source,dir),{withFileTypes:true})) {
14
+ if(excluded.has(entry.name)||entry.name.startsWith('.')&&entry.name!=='.gitignore'||entry.name.startsWith('.dev.vars')||entry.name.endsWith('.sqlite')||entry.name.endsWith('.db')||entry.name.endsWith('.pem')||entry.name.endsWith('.key')||entry.isSymbolicLink())continue;
15
+ const path=join(dir,entry.name);if(entry.isDirectory())walk(path);else if(entry.isFile())files.push(path);
16
+ }}walk('');
17
+ const mapping=new Map([[studio.slug,slug],[studio.name,name]]);
18
+ const collect=value=>{if(!value||typeof value!=='object')return;if(typeof value.id==='string'&&/^[a-z][a-z0-9-]*$/.test(value.id))mapping.set(value.id,ids[value.id]??`${slug}-${value.id}`);for(const child of Object.values(value))collect(child);};
19
+ for(const file of files.filter(p=>p.endsWith('.json')&&!['studio.json','package.json'].includes(p)))try{collect(JSON.parse(readFileSync(join(source,file),'utf8')));}catch{}
20
+ const ts=(await import('typescript')).default;
21
+ for(const file of files.filter(p=>/\.(?:[cm]?js|tsx?)$/.test(p))) {
22
+ const tree=ts.createSourceFile(file,readFileSync(join(source,file),'utf8'),ts.ScriptTarget.Latest,true);
23
+ const visit=node=>{
24
+ if(ts.isPropertyAssignment(node)&&(ts.isIdentifier(node.name)||ts.isStringLiteral(node.name))&&node.name.text==='id'&&ts.isStringLiteral(node.initializer))collect({id:node.initializer.text});
25
+ ts.forEachChild(node,visit);
26
+ };visit(tree);
27
+ }
28
+ for(const [old,next] of Object.entries(ids))mapping.set(old,next);
29
+ for(const [old,next] of mapping)if(typeof next!=='string'||!next.length)throw new Error(`Invalid replacement for ${old}`);
30
+ for(const [old,next] of mapping)if(old!==studio.name&&(!/^[a-z][a-z0-9-]{0,39}$/.test(next)))throw new Error(`Renamed id ${next} exceeds 40 characters; supply ids.${old}`);
31
+ const escape=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
32
+ const pattern=new RegExp(`(?<![A-Za-z0-9_-])(?:${[...mapping.keys()].filter(Boolean).sort((a,b)=>b.length-a.length).map(escape).join('|')})(?![A-Za-z0-9_-])`,'g');
33
+ const rewrite=s=>s.replace(pattern,value=>mapping.get(value));
34
+ mkdirSync(target);
35
+ try {
36
+ for(const file of files) {
37
+ const to=join(target,...file.split('/').map(part=>mapping.get(part)??part));mkdirSync(dirname(to),{recursive:true});
38
+ if(['.json','.ts','.js','.mjs','.html','.css','.md','.sql','.txt','.svg','.yaml','.yml'].includes(extname(file)))writeFileSync(to,rewrite(readFileSync(join(source,file),'utf8')));else copyFileSync(join(source,file),to);
39
+ }
40
+ const fresh=JSON.parse(readFileSync(join(target,'studio.json'),'utf8'));fresh.name=name;fresh.slug=slug;delete fresh.github;delete fresh.template;
41
+ fresh.cloudflare={worker:slug,d1:slug+'-db',created:[]};fresh.homie={...fresh.homie,directory:false};
42
+ writeFileSync(join(target,'studio.json'),JSON.stringify(fresh,null,2)+'\n');
43
+ const pkg=JSON.parse(readFileSync(join(target,'package.json'),'utf8'));pkg.name=slug;delete pkg.repository;writeFileSync(join(target,'package.json'),JSON.stringify(pkg,null,2)+'\n');
44
+ refreshWorkerConfig(target);
45
+ return {ok:true,command:'fork',root:target,renamed:Object.fromEntries(mapping),next:['npm install','homie-studio check']};
46
+ }catch(error){rmSync(target,{recursive:true,force:true});throw error;}
47
+ }
package/lib/jobs.mjs CHANGED
@@ -158,6 +158,7 @@ export function jobView(job, { lines = 12 } = {}) {
158
158
  startedAt: job.startedAt, endedAt: job.endedAt,
159
159
  seconds: Math.round(((job.endedAt ? Date.parse(job.endedAt) : Date.now()) - Date.parse(job.startedAt)) / 1000),
160
160
  tail: text.slice(-lines), log: job.log, result: job.result,
161
+ events: job.err.split('\n').filter(line=>line.startsWith('HOMIE_EVENT ')).flatMap(line=>{try{return [JSON.parse(line.slice(12))];}catch{return [];}}),
161
162
  };
162
163
  }
163
164
 
package/lib/mcp-tools.mjs CHANGED
@@ -1195,7 +1195,7 @@ export function toolDefs(ctx, avail = {}) {
1195
1195
  run:async a=>{
1196
1196
  const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1197
1197
  await waitJob(job,Math.min(ctx.waitMs,1000));
1198
- return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
1198
+ return ok('Follow studio_job. Its events contain any required Stripe pairing; a saved login needs no new approval.',{kind:'job',...jobView(job)});
1199
1199
  },
1200
1200
  },
1201
1201
  {
@@ -1,13 +1,14 @@
1
1
  import {localCookie} from './local-test.mjs';
2
2
 
3
3
  /** Each action has an explicit local identity, using the real MCP transport and permissions. */
4
- export function needsHeadless({url,secret,app,meta,scope}) {
4
+ export function needsHeadless({url,secret,app,meta,scope,sessionFor}) {
5
5
  return async (item,role) => {
6
- if(!secret)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
6
+ if(!secret&&!sessionFor)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
7
7
  if(!role || (role!=='owner'&&!Object.hasOwn(meta.roles??{},role)))throw new Error(`Name a declared role (or owner) for headless proof; got ${role??'none'}`);
8
8
  const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
9
9
  const client=new Client({name:'Homie needs check',version:'1.0.0'});
10
- const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
10
+ const cookie=sessionFor?await sessionFor(role):{name:'homie_test',value:localCookie(secret,{app,role,scope})};
11
+ const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{...(secret?{authorization:`Bearer ${secret}`} : {}),...(cookie?{cookie:`${cookie.name}=${cookie.value}`}:{})}}});
11
12
  try {
12
13
  await client.connect(transport);
13
14
  let call=item.mcp;
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
2
+ import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
1
3
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
2
4
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
3
5
  import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
@@ -827,6 +829,10 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
827
829
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
828
830
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
829
831
  [`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
832
+ [`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
833
+ [`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
834
+ [`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
835
+ [`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
830
836
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
831
837
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
832
838
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1069,8 +1075,28 @@ function ensureBookingHolds(root) {
1069
1075
  if(existsSync(file))return null;
1070
1076
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
1071
1077
  }
1078
+ function ensureBookingResources(root) {
1079
+ const file=join(root,'site','migrations',BOOKING_RESOURCES_MIGRATION_FILE);
1080
+ if(existsSync(file))return null;
1081
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
1082
+ }
1083
+ function ensureToolNamespaces(root) {
1084
+ const file=join(root,'site','migrations',TOOL_NAMESPACE_MIGRATION_FILE);
1085
+ if(existsSync(file))return null;
1086
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_NAMESPACE_MIGRATION);return `site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`;
1087
+ }
1088
+ function ensureCache(root) {
1089
+ const file=join(root,'site','migrations',CACHE_MIGRATION_FILE);
1090
+ if(existsSync(file))return null;
1091
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,CACHE_MIGRATION);return `site/migrations/${CACHE_MIGRATION_FILE}`;
1092
+ }
1093
+ function ensureOperational(root) {
1094
+ const file=join(root,'site','migrations',OPERATIONAL_MIGRATION_FILE);
1095
+ if(existsSync(file))return null;
1096
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
1097
+ }
1072
1098
  export function ensureMigrations(root) {
1073
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root)].filter(Boolean);
1099
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root)].filter(Boolean);
1074
1100
  }
1075
1101
 
1076
1102
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -1,3 +1,6 @@
1
+ import {mkdirSync,writeFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {redactText} from './redact.mjs';
1
4
  /** Reconcile through the owner's official Stripe CLI session; never export its credentials. */
2
5
  import { createHash, randomUUID } from 'node:crypto';
3
6
  import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
@@ -85,7 +88,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
85
88
  catalogPrices.set(item.id, price.id);
86
89
  if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
87
90
  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 } } : {}) }],
91
+ 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: item.maxQuantity??999999 } } : {}) }],
89
92
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
90
93
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
91
94
  ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
@@ -97,13 +100,13 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
97
100
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
98
101
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
99
102
  keepLinks.add(link.id);
100
- config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
103
+ config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, maxQuantity: shop.policy.repeatPurchases?(item.maxQuantity??999999):1, revision: linkRevision });
101
104
  }));
102
105
  // Menus use paid invoice snapshots: Checkout events omit their line items.
103
106
  // Stripe allows 20 Payment Link lines; invoice snapshots include only the first
104
107
  // 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
105
108
  config.menus = [];
106
- const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
109
+ const menuItems = shop.items.filter(i => !['supporter','deposit'].includes(i.kind) && typeof i.price === 'number' && i.price > 0 && !i.interval);
107
110
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
108
111
  const tasks=[];
109
112
  const groups=Map.groupBy(menuItems,i=>i.game??'');
@@ -111,7 +114,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
111
114
  const groupOffset=offset;tasks.push(async()=>{
112
115
  const items = group.slice(groupOffset, groupOffset + 10);
113
116
  if (items.length < 2) return;
114
- const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
117
+ const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?(i.maxQuantity??99):1}})),
115
118
  invoice_creation:{enabled:true,invoice_data:{metadata}},
116
119
  after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
117
120
  automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
@@ -121,7 +124,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
121
124
  await verifyLink(link,params);
122
125
  if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
123
126
  keepLinks.add(link.id);
124
- config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
127
+ config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id),maxQuantity:shop.policy.repeatPurchases?(i.maxQuantity??99):1}))});
125
128
  });}
126
129
  // One aggregate payment represents a frozen local basket, including one-item baskets.
127
130
  // Runtime uses prefilled_amount, never a shared Price mutation: concurrent
@@ -152,7 +155,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
152
155
  await verifyLink(link,params);
153
156
  if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
154
157
  keepLinks.add(link.id);
155
- config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
158
+ config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id),maxQuantity:shop.policy.repeatPurchases?(i.maxQuantity??99):1}))});
156
159
  });}
157
160
  config.slots=[];
158
161
  const pause=async link=>{
@@ -174,6 +177,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
174
177
  const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
175
178
  let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
176
179
  if(!link && (!slot.capacity||slot.active===false))return;
180
+ if(link && !link.line_items)link=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
177
181
  if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
178
182
  const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
179
183
  const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
@@ -183,14 +187,15 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
183
187
  // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
184
188
  const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
185
189
  const active=slot.active!==false && limit>count;
186
- const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
190
+ const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1,adjustable_quantity:{enabled:true,minimum:1,maximum:Math.max(1,Math.min(999999,slot.capacity-(slot.confirmed??count)))}}],payment_method_types:['card'],
187
191
  restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
188
- if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
192
+ if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id),...(slot.confirmed!==undefined?{seats_revision:seatsRevision,seats_limit:String(limit)}:{})}},`slot-${identity}`);
189
193
  else {
190
194
  // Pause first whenever publishing a changed budget, so old sessions cannot
191
195
  // spend a previous capacity after staff moves or removes a slot.
192
- if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
193
- if (link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
196
+ const groupChanged=!matchesFields((link.line_items?.data??link.line_items)?.[0]?.adjustable_quantity,params.line_items[0].adjustable_quantity);
197
+ if(!active || groupChanged || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
198
+ if (groupChanged || link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,...(groupChanged?{line_items:[{id:(link.line_items?.data??link.line_items)?.[0]?.id,quantity:1,adjustable_quantity:params.line_items[0].adjustable_quantity}]}:{}),active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
194
199
  }
195
200
  await verifyLink(link,params);
196
201
  keepLinks.add(link.id);
@@ -198,7 +203,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
198
203
  });
199
204
  }
200
205
  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');
206
+ const portals=knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:await list('/v1/billing_portal/configurations');
202
207
  let portal=portals.find(p=>own(p));
203
208
  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
209
  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})}`);
@@ -234,7 +239,7 @@ export async function syncPaymentLinks(options) {
234
239
  return all;
235
240
  };
236
241
  const secrets=await w(['secret','list']);
237
- if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
242
+ if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(secrets)};
238
243
  const names=JSON.parse(secrets.stdout??secrets.out);
239
244
  const has=name=>names.some(s=>s.name===name);
240
245
 
@@ -258,19 +263,32 @@ export async function syncPaymentLinks(options) {
258
263
  secret = endpoint.secret;
259
264
  }
260
265
  const fingerprint = hash({config, purchases:purchases?.config});
261
- const unchanged = current && !secret && receipt?.fingerprint === fingerprint && (options.catalog || has('STRIPE_SHOP_LINKS')) && !has('STRIPE_KEY');
266
+ const unchanged = current && !secret && receipt?.fingerprint === fingerprint && Boolean(root || options.catalog) && !has('STRIPE_KEY');
262
267
  if (!unchanged) {
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
- 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.' };
268
+ const secrets={...purchaseSecrets,...(secret?{STRIPE_WEBHOOK_SECRET:secret}:{})};
269
+ const saved=Object.keys(secrets).length?await w(['secret','bulk'],{input:JSON.stringify(secrets)}):{code:0};
270
+ if(saved.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(saved)};
265
271
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
266
272
  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.' };
267
273
  saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
268
274
  }
269
- await purchases?.retire();
270
- // Retire only after the replacement configuration and signing secret are installed.
271
- if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
275
+ if(root && !options.catalog) {
276
+ const bookingSlots=options.bookingSlots??[];
277
+ mkdirSync(join(root,'.studio'),{recursive:true});
278
+ writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{mode,slots:bookingSlots,url:site},source:hash({shop,slots:bookingSlots}),sourceConfig:{shop,slots:bookingSlots},result:{verified:true,verifiedAt:new Date().toISOString(),local:false,site,mode,config}},null,2));
279
+ writeFileSync(join(root,'.studio','stripe-purchase-links.json'),JSON.stringify(purchases?.config??{offers:[]}));
280
+ const {refreshWorkerConfig}=await import('./worker-config.mjs');refreshWorkerConfig(root);
281
+ }
282
+ if(has('STRIPE_CHECKOUT_MODE')) {
283
+ const removed=await w(['secret','delete','STRIPE_CHECKOUT_MODE'],{input:'y\n'});
284
+ if(removed.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(removed)};
285
+ }
286
+ // Public assets take effect on deploy. Keep old links live until that deployment;
287
+ // the planner returns retirement operations for the subsequent reconciliation.
272
288
  for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
273
- return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
274
- message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
275
- next: ['Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
289
+ return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false, retireAfterDeploy:retire,
290
+ message: 'Your shop uses Stripe Payment Links. Public configuration is saved for the next deploy; no Stripe API key is stored in the Worker.',
291
+ next: ['Deploy the public link configuration before retiring replaced Payment Links.', 'Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
276
292
  }
293
+
294
+ function wranglerError(result) {return 'Cloudflare: '+redactText([result.stderr,result.out,result.stdout].filter(Boolean).join('\n')||'Wrangler failed').text.slice(-4000);}
package/lib/shop-mcp.mjs CHANGED
@@ -65,11 +65,17 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
65
65
  found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
66
66
  }
67
67
  }
68
+ if (!found && method==='POST' && path==='/v1/products' && params.id) {
69
+ const existing=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===`/v1/products/${params.id}`);
70
+ if (!existing) throw {next:{method:'GET',path:`/v1/products/${params.id}`,params:{expand:['default_price']}}};
71
+ if (existing.body?.id===params.id) return existing.body;
72
+ if (existing.body?.error?.code!=='resource_missing') throw new Error('Stripe product lookup failed; resolve the read before creating a product');
73
+ }
68
74
  if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
69
75
  let body=found.body;
70
76
  if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
71
77
  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') {
78
+ for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST' || (o.method??'GET')==='GET' && /^\/v1\/products\/[^/]+$/.test(o.path) && o.body?.id) {
73
79
  if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
74
80
  if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
75
81
  }
@@ -151,3 +157,9 @@ export function publicMcpSetup(root) {
151
157
  }
152
158
  return false;
153
159
  }
160
+
161
+ /** Record a batch from the official plugin and return the next dependency batch. */
162
+ export async function recordShopObservations(root, options = {}) {
163
+ if(!Array.isArray(options.observations) || !options.observations.length)throw new Error('Supply observations: [{method,path,params,body}] from Stripe’s official plugin.');
164
+ return shopMcpPlan(root, options);
165
+ }
@@ -96,7 +96,7 @@ export async function shopConnect(root, options = {}) {
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
  }
@@ -126,6 +126,7 @@ export async function shopConnect(root, options = {}) {
126
126
  if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
127
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'));
128
128
  }
129
+ options.onEvent?.({type:'stripe.pairing',code:login.verification_code,url:login.browser_url});
129
130
  log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
130
131
  open(login.browser_url);
131
132
  const finished = await cli(complete);
@@ -136,7 +137,23 @@ export async function shopConnect(root, options = {}) {
136
137
  }
137
138
  if (options.webhookOnly) {
138
139
  const accountResult=await cli(['get','/v1/account',...(live?['--live']:[])]);
139
- 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
+ }
140
157
  }
141
158
  const site = siteUrl(root);
142
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 });
@@ -156,6 +173,24 @@ export async function shopConnect(root, options = {}) {
156
173
  const reason = fullerReason(checked.shop);
157
174
  if (reason) return fail('fuller-checkout', reason, { mode, connected: true });
158
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
+ }
159
194
  const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
160
195
  const receiptFile = join(dir, `${mode}.json`);
161
196
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
@@ -3921,6 +3921,90 @@
3921
3921
  }
3922
3922
  }
3923
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
+ }
3966
+ },
3967
+ "0.54.0": {
3968
+ "files": {
3969
+ "CLAUDE.md": "ddf2b2a0376202f6",
3970
+ "README.md": "684b3cca8001673c",
3971
+ "HANDOFF.md": "29337e2ace95b2e7",
3972
+ "games/README.md": "5ad32fe80c61f117",
3973
+ "music/README.md": "a3dc042dadd39df8",
3974
+ "videos/README.md": "16babc3437d5545b",
3975
+ "posts/README.md": "37841aae476bd540",
3976
+ "site/README.md": "1dbdf22c03a1e505",
3977
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3978
+ },
3979
+ "sections": {
3980
+ "AGENTS.md": {
3981
+ "(intro)": "394cbbe934e80722",
3982
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3983
+ "## Layout": "f3a280b8c96cd068",
3984
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3985
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3986
+ "## The site": "0cb8dd9a6f8a0096",
3987
+ "## Making games": "bb715272fd1a615b",
3988
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3989
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3990
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3991
+ "## Servers and AI seats": "1ccfd549d2369a60",
3992
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3993
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3994
+ "## Rules": "b2234d80f6cbe71d",
3995
+ "## Telling Homie": "80b46fdd9e578db8",
3996
+ "## Beta": "9d02638207a26e73"
3997
+ }
3998
+ },
3999
+ "named": {
4000
+ "Homie Arcade": {
4001
+ "sections": {
4002
+ "AGENTS.md": {
4003
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4004
+ }
4005
+ }
4006
+ }
4007
+ }
3924
4008
  }
3925
4009
  }
3926
4010
  }
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
+ }
@@ -27,6 +27,7 @@ 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
+ next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
30
31
  verifiedMcpConfig(root);
31
32
  if(publicMcpSetup(root)) {
32
33
  next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.52.0",
3
+ "version": "0.54.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);
package/rules/view.ts CHANGED
@@ -1314,7 +1314,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1314
1314
  get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
1315
1315
  get status() { return solo ? 'offline' : status; },
1316
1316
  get seat() { return solo ? solo.seat : net.seat; },
1317
- get me() { pump(); return meNow(true); },
1317
+ get me() { if (!presentedEntity) pump(); return meNow(true); },
1318
1318
  each, get,
1319
1319
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
1320
1320
  rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),