@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.
- package/CHANGELOG.md +57 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +9 -3
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +27 -11
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +4 -3
- package/lib/check.mjs +47 -15
- package/lib/dev.mjs +9 -1
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +27 -1
- package/lib/shop-links.mjs +39 -21
- package/lib/shop-mcp.mjs +13 -1
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +20 -6
- package/worker/app-shell.mjs +10 -5
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +2 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +74 -27
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +108 -6
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/records-page.mjs +2 -2
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +68 -8
- 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
|
|
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
|
{
|
package/lib/needs-headless.mjs
CHANGED
|
@@ -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
|
|
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). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -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?
|
|
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
|
-
|
|
193
|
-
if
|
|
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=
|
|
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:
|
|
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
|
|
266
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && Boolean(root || options.catalog) && !has('STRIPE_KEY');
|
|
262
267
|
if (!unchanged) {
|
|
263
|
-
const
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
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
|
+
}
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/worker-config.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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),
|