@homie-rocks/studio 0.51.4 → 0.51.5
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 +29 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/SKILL.md +1 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +45 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
package/lib/scaffold.mjs
CHANGED
|
@@ -535,7 +535,7 @@ where it sells and for Stripe's terms. Homie takes no cut.
|
|
|
535
535
|
player refund window covers items; a tip is never taken back by the player.
|
|
536
536
|
- Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
|
|
537
537
|
new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
|
|
538
|
-
- \`shop check\` validates settings; \`
|
|
538
|
+
- \`shop check\` validates settings; \`stripe_shop_plan\` plans and verifies Stripe official MCP operations; \`stripe_webhook\` installs the signing secret privately.
|
|
539
539
|
Never ask a person for a key. Follow the result's next step: deploy first if needed, then rerun connect.
|
|
540
540
|
The default syncs Products, Prices, Payment Links and the webhook through the approved Stripe CLI.
|
|
541
541
|
The Worker stores only links and a signing secret; buying needs no API key or running AI.
|
|
@@ -719,7 +719,7 @@ site/.wrangler/
|
|
|
719
719
|
.wrangler/
|
|
720
720
|
.dev.vars
|
|
721
721
|
.env
|
|
722
|
-
#
|
|
722
|
+
# Provider-owned credential stores and local environment files never belong in git.
|
|
723
723
|
.env.*
|
|
724
724
|
.projects/vault/
|
|
725
725
|
.projects/cache/
|
package/lib/shop-links.mjs
CHANGED
|
@@ -6,7 +6,8 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
|
|
|
6
6
|
import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
|
-
export
|
|
9
|
+
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false }) {
|
|
10
11
|
const list = async (path, params = {}) => {
|
|
11
12
|
const all = []; let cursor;
|
|
12
13
|
do {
|
|
@@ -19,15 +20,16 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
19
20
|
} while (cursor);
|
|
20
21
|
return all;
|
|
21
22
|
};
|
|
22
|
-
if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
|
|
23
|
-
const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
|
|
24
|
-
if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
|
|
25
|
-
const names = JSON.parse(secrets.stdout ?? secrets.out);
|
|
26
|
-
const has = (name) => names.some((s) => s.name === name);
|
|
27
23
|
const products = await list('/v1/products');
|
|
28
24
|
const prices = await list('/v1/prices');
|
|
29
25
|
const links = await list('/v1/payment_links');
|
|
30
26
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
27
|
+
const verifyLink = async (link, params) => {
|
|
28
|
+
if (!verifyObjects) return;
|
|
29
|
+
const actual=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
|
|
30
|
+
const normalized={...actual,line_items:actual.line_items?.data?.map(line=>({...line,price:line.price?.id??line.price}))};
|
|
31
|
+
if(actual.id!==link.id || actual.active!==link.active || actual.url!==link.url || actual.line_items?.has_more===true || !matchesFields(actual.metadata,link.metadata) || !paymentLinkURL(actual.url) || !matchesFields(normalized,params)) throw new Error(`Stripe Payment Link ${link.id} differs from its planned fields. Restore its configuration through Stripe MCP before verifying.`);
|
|
32
|
+
};
|
|
31
33
|
const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
|
|
32
34
|
const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
|
|
33
35
|
const catalogPrices = new Map();
|
|
@@ -36,7 +38,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
36
38
|
const id = await productIdOf(slug, item.id);
|
|
37
39
|
keepProducts.add(id);
|
|
38
40
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
39
|
-
const fields = { name: item.name,
|
|
41
|
+
const fields = { name: item.name, description:item.blurb??'', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
|
|
40
42
|
let product = products.find((p) => p.id === id);
|
|
41
43
|
if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
|
|
42
44
|
else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== (fields.description ?? '') || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
|
|
@@ -48,6 +50,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
48
50
|
const revision = hash(priceFields);
|
|
49
51
|
let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
|
|
50
52
|
if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
|
|
53
|
+
if (verifyObjects && ((price.recurring?.interval_count??1)!==1 || Boolean(price.recurring)!==Boolean(item.interval) || Boolean(price.custom_unit_amount)!==(item.price==='choose'))) throw new Error(`Stripe Price ${price.id} has a different payment type from shop.json`);
|
|
54
|
+
if (verifyObjects && !matchesFields({...price, product:price.product?.id??price.product, ...(price.custom_unit_amount?{custom_unit_amount:{...price.custom_unit_amount,enabled:true}}:{})},priceFields)) throw new Error(`Stripe Price ${price.id} differs from shop.json; restore or archive it through Stripe MCP before planning again.`);
|
|
51
55
|
if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
|
|
52
56
|
keepPrices.add(price.id);
|
|
53
57
|
catalogPrices.set(item.id, price.id);
|
|
@@ -61,6 +65,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
61
65
|
const linkRevision = hash({ params, item, policy: shop.policy });
|
|
62
66
|
let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
|
|
63
67
|
if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
68
|
+
await verifyLink(link,params);
|
|
64
69
|
if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
|
|
65
70
|
if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
|
|
66
71
|
keepLinks.add(link.id);
|
|
@@ -83,13 +88,14 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
83
88
|
const revision=hash(params);
|
|
84
89
|
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
85
90
|
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
|
|
91
|
+
await verifyLink(link,params);
|
|
86
92
|
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
87
93
|
keepLinks.add(link.id);
|
|
88
94
|
config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
89
95
|
}
|
|
90
96
|
config.slots=[];
|
|
91
97
|
const pause=async link=>{
|
|
92
|
-
await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
98
|
+
if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
93
99
|
for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
|
|
94
100
|
await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
|
|
95
101
|
}
|
|
@@ -107,11 +113,13 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
107
113
|
let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
|
|
108
114
|
if(!link && (!slot.capacity||slot.active===false))continue;
|
|
109
115
|
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.');
|
|
110
|
-
|
|
116
|
+
const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
|
|
117
|
+
const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
|
|
118
|
+
if(slot.confirmed!==undefined && !counted && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
|
|
111
119
|
const count=link?.restrictions?.completed_sessions?.count??0;
|
|
112
120
|
// Initial publication uses capacity exactly. After a reviewed move/refund the
|
|
113
121
|
// AI supplies current confirmed seats; keep the existing Stripe lifetime count.
|
|
114
|
-
const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
|
|
122
|
+
const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
|
|
115
123
|
const active=slot.active!==false && limit>count;
|
|
116
124
|
const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
|
|
117
125
|
restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
@@ -119,9 +127,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
119
127
|
else {
|
|
120
128
|
// Pause first whenever publishing a changed budget, so old sessions cannot
|
|
121
129
|
// spend a previous capacity after staff moves or removes a slot.
|
|
122
|
-
if(!active || link.restrictions?.completed_sessions?.limit!==limit)await pause(link);
|
|
123
|
-
link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active});
|
|
130
|
+
if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
|
|
131
|
+
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)}}:{})});
|
|
124
132
|
}
|
|
133
|
+
await verifyLink(link,params);
|
|
125
134
|
keepLinks.add(link.id);
|
|
126
135
|
config.slots.push({...slot,id:link.id,url:link.url,price:catalogPrices.get(item.id),revision:identity,active:link.active,count:link.restrictions?.completed_sessions?.count??0,limit,paused:slot.active===false});
|
|
127
136
|
}
|
|
@@ -132,11 +141,40 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
132
141
|
const portals=await list('/v1/billing_portal/configurations');
|
|
133
142
|
let portal=portals.find(p=>own(p));
|
|
134
143
|
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};
|
|
135
|
-
portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
|
|
144
|
+
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})}`);
|
|
136
145
|
config.portal=portal.login_page?.url;
|
|
137
146
|
}
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
const retire = [
|
|
148
|
+
...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
|
|
149
|
+
...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
|
|
150
|
+
...products.filter(p=>own(p)&&p.active&&!keepProducts.has(p.id)).map(p=>({method:'POST',path:`/v1/products/${p.id}`,params:{active:false}})),
|
|
151
|
+
];
|
|
152
|
+
return {config, retire};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function syncPaymentLinks(options) {
|
|
156
|
+
const {root, api, shop, slug, site, mode, w, receipt, saveReceipt, local = false} = options;
|
|
157
|
+
if (local && mode !== 'test') throw new Error('Local checkout requires Stripe test mode');
|
|
158
|
+
const {config, retire} = options.catalog ?? await reconcilePaymentLinks(options);
|
|
159
|
+
if (local) return {ok:true, command:'shop connect', local:true, mode, config};
|
|
160
|
+
const list = async (path,params={}) => {
|
|
161
|
+
const all=[]; let cursor;
|
|
162
|
+
do {
|
|
163
|
+
const page=await api('GET',path,{...params,limit:100,...(cursor?{starting_after:cursor}:{})});
|
|
164
|
+
if (!Array.isArray(page.data)) throw new Error('Incomplete Stripe list');
|
|
165
|
+
all.push(...page.data);
|
|
166
|
+
const next=page.has_more?page.data.at(-1)?.id:null;
|
|
167
|
+
if(page.has_more && (!next || next===cursor))throw new Error('Incomplete Stripe pagination');
|
|
168
|
+
cursor=next;
|
|
169
|
+
}while(cursor);
|
|
170
|
+
return all;
|
|
171
|
+
};
|
|
172
|
+
const secrets=await w(['secret','list']);
|
|
173
|
+
if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
|
|
174
|
+
const names=JSON.parse(secrets.stdout??secrets.out);
|
|
175
|
+
const has=name=>names.some(s=>s.name===name);
|
|
176
|
+
|
|
177
|
+
const purchases = root && !options.catalog ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
|
|
140
178
|
const purchaseSecrets = {};
|
|
141
179
|
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
142
180
|
const { generateKeyPair, exportJWK } = await import('jose');
|
|
@@ -166,9 +204,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
166
204
|
}
|
|
167
205
|
await purchases?.retire();
|
|
168
206
|
// Retire only after the replacement configuration and signing secret are installed.
|
|
169
|
-
|
|
170
|
-
for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
|
|
171
|
-
for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
|
|
207
|
+
if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
|
|
172
208
|
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 });
|
|
173
209
|
return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
|
|
174
210
|
message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Private local Stripe forwarding. Only the allowlisted readiness line reaches the model. */
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {readFileSync,writeFileSync,existsSync,chmodSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {stripeProcess,completionArgs} from './stripe-connect.mjs';
|
|
6
|
+
export function saveLocalStripe(root,values) {
|
|
7
|
+
const file=join(root,'.dev.vars');
|
|
8
|
+
let text=existsSync(file)?readFileSync(file,'utf8'):'';
|
|
9
|
+
for(const [key,value] of Object.entries(values)) {
|
|
10
|
+
text=text.replace(new RegExp(`^${key}=.*\\n?`,'gm'),'');
|
|
11
|
+
text+=`\n${key}=${value}\n`;
|
|
12
|
+
}
|
|
13
|
+
writeFileSync(file,text,{mode:0o600});
|
|
14
|
+
chmodSync(file,0o600);
|
|
15
|
+
}
|
|
16
|
+
export async function shopListen(root,{profile,port=8787,log=()=>{},exec=stripeProcess,spawnProcess=spawn}={}) {
|
|
17
|
+
if(!profile || !Number.isInteger(Number(port)) || Number(port)<1 || Number(port)>65535)throw new Error('Supply a sandbox profile and local port');
|
|
18
|
+
const cli=args=>exec(['--project-name',profile,...args],{cwd:root});
|
|
19
|
+
const probe=await cli(['get','/v1/products','-d','limit=1']);
|
|
20
|
+
if(probe.code!==0) {
|
|
21
|
+
const start=await cli(['login','--non-interactive']);
|
|
22
|
+
let login;try{login=JSON.parse(start.stdout);}catch{}
|
|
23
|
+
const complete=completionArgs(login?.next_step);
|
|
24
|
+
const u=login?.browser_url;
|
|
25
|
+
if(!complete || !/^https:\/\/(dashboard|access)\.stripe\.com\//.test(u??''))throw new Error('Update Stripe CLI and retry local browser approval');
|
|
26
|
+
log(`Approve Stripe sandbox access: ${u}`);
|
|
27
|
+
if((await cli(complete)).code!==0)throw new Error('Stripe browser approval did not finish');
|
|
28
|
+
}
|
|
29
|
+
const env={...process.env};for(const key of Object.keys(env))if(key.startsWith('STRIPE_'))delete env[key];
|
|
30
|
+
return new Promise((resolve,reject)=>{
|
|
31
|
+
const child=spawnProcess('stripe',['--project-name',profile,'listen','--all-snapshot','--forward-to',`http://localhost:${port}/api/shop/hook`],{cwd:root,env,stdio:['ignore','pipe','pipe']});
|
|
32
|
+
let buffer='',ready=false;
|
|
33
|
+
const capture=data=>{
|
|
34
|
+
buffer=(buffer+data.toString()).slice(-8192);
|
|
35
|
+
const secret=/whsec_[A-Za-z0-9+/=_-]+(?=[\s\x1b])/.exec(buffer)?.[0];
|
|
36
|
+
if(secret && !ready){saveLocalStripe(root,{STRIPE_WEBHOOK_SECRET:secret});ready=true;buffer='';log('Stripe sandbox forwarding ready. Signing secret saved privately; start/restart homie-studio dev.');}
|
|
37
|
+
};
|
|
38
|
+
child.stdout.on('data',capture);child.stderr.on('data',capture);
|
|
39
|
+
child.on('error',()=>reject(new Error('Install Stripe CLI before local forwarding')));
|
|
40
|
+
const stop=()=>child.kill();process.once('SIGTERM',stop);process.once('SIGINT',stop);
|
|
41
|
+
child.on('close',code=>{process.removeListener('SIGTERM',stop);process.removeListener('SIGINT',stop);resolve({ok:ready&&code===0,command:'shop listen',ready});});
|
|
42
|
+
});
|
|
43
|
+
}
|
package/lib/shop-local.mjs
CHANGED
|
@@ -27,5 +27,5 @@ export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={})
|
|
|
27
27
|
vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
|
|
28
28
|
vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
|
|
29
29
|
writeFileSync(file,vars,{mode:0o600});
|
|
30
|
-
return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`
|
|
30
|
+
return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`homie-studio shop listen --profile '${profile}' --port ${origin.port||80}`,'The listener saves the signing secret privately. Start/restart homie-studio dev --lan after it reports ready.','Open the same Wi-Fi address on phones and verify the paid order and app event.']};
|
|
31
31
|
}
|
package/lib/shop-mcp.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Pure, incremental Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {existsSync, readFileSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {readShop, SHOP_HOOK_EVENTS} from './shop.mjs';
|
|
6
|
+
import {readStudio, siteUrl} from './studio.mjs';
|
|
7
|
+
import {reconcilePaymentLinks} from './shop-links.mjs';
|
|
8
|
+
import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
9
|
+
const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
|
10
|
+
export const readKey = (path, params) => JSON.stringify([path,Object.fromEntries(Object.entries(params).sort(([a],[b])=>a.localeCompare(b)))]);
|
|
11
|
+
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
|
|
12
|
+
const checked=readShop(root);
|
|
13
|
+
if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
|
|
14
|
+
if(!['test','live'].includes(mode))throw new Error('Choose test or live mode');
|
|
15
|
+
const reason=fullerReason(checked.shop);
|
|
16
|
+
if(reason)return {ok:false,why:reason+' Adjust those settings to use Payment Links; no key is required.'};
|
|
17
|
+
const studio=readStudio(root), address=url??siteUrl(root);
|
|
18
|
+
if(!address)return {ok:false,needs:'site',why:'Supply the local test URL, or deploy the studio before planning its public checkout links.'};
|
|
19
|
+
const site=new URL(address).origin;
|
|
20
|
+
const local=new URL(site).protocol==='http:';
|
|
21
|
+
if(local && (mode!=='test' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(new URL(site).hostname)))throw new Error('Local checkout requires a private HTTP address and test mode');
|
|
22
|
+
if(!local && new URL(site).protocol!=='https:')throw new Error('Use the studio HTTPS address');
|
|
23
|
+
const bookingSlots=slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
|
|
24
|
+
const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
|
|
25
|
+
// Reject secrets before persisting observations. Webhook creation is never part of this plan.
|
|
26
|
+
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
27
|
+
const api=async(method,path,params={},idempotency)=>{
|
|
28
|
+
if(method!=='GET')throw {next:{method,path,params,...(idempotency?{idempotency}:{}),retry:'Read fresh before retrying; updates set desired fields and are idempotent.'}};
|
|
29
|
+
const found=observations.find(o=>readKey(o.path,o.params??{})===readKey(path,params));
|
|
30
|
+
if(!found)throw {next:{method,path,params}};
|
|
31
|
+
const body=found.body;
|
|
32
|
+
if(!body || body.error || body.livemode !== undefined && body.livemode !== (mode==='live') || body.data?.some(o=>o.livemode !== undefined && o.livemode !== (mode==='live')))throw new Error('Stripe read has an error or wrong mode');
|
|
33
|
+
return body;
|
|
34
|
+
};
|
|
35
|
+
const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
|
|
36
|
+
webhook:{method:'POST',path:'/v1/webhook_endpoints',private:true,params:{url:`${site}/api/shop/hook`,enabled_events:SHOP_HOOK_EVENTS,api_version:LINKS_STRIPE_VERSION,metadata:{homie:'shop-v1'},description:'Studio shop'}}};
|
|
37
|
+
try {
|
|
38
|
+
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true});
|
|
39
|
+
if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
|
|
40
|
+
const result={...base,ok:true,verified:true,verifiedAt:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
|
|
41
|
+
return result;
|
|
42
|
+
} catch(e) {
|
|
43
|
+
if(!e.next)throw e;
|
|
44
|
+
return {...base,ok:!verify,verified:false,next:e.next,instructions:e.next.method==='GET'?'Read through Stripe MCP; append {path,params,body} to observations, including every pagination page.':'Perform this exact operation through Stripe MCP. Then discard observations and read fresh before planning again. Do not create the webhook through MCP.'};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function saveMcpVerification(root, options) {
|
|
48
|
+
const result=await shopMcpPlan(root,{...options,verify:true});
|
|
49
|
+
if(result.verified) {
|
|
50
|
+
if(result.local) {
|
|
51
|
+
const {saveLocalStripe}=await import('./shop-listen.mjs');
|
|
52
|
+
saveLocalStripe(root,{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(JSON.stringify(result.config)).toString('base64')});
|
|
53
|
+
}
|
|
54
|
+
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
55
|
+
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options,result},null,2));
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -72,6 +72,10 @@ export function completionArgs(step) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export async function shopConnect(root, options = {}) {
|
|
75
|
+
const fail=(...args)=>{
|
|
76
|
+
const result=failure(...args);
|
|
77
|
+
return options.webhookOnly?{...result,command:'shop webhook',why:result.why.replaceAll('shop connect','shop webhook')}:result;
|
|
78
|
+
};
|
|
75
79
|
if (options.manual || options.fromClipboard) {
|
|
76
80
|
const result = await shopConnectManual(root, options);
|
|
77
81
|
if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
|
|
@@ -81,19 +85,32 @@ export async function shopConnect(root, options = {}) {
|
|
|
81
85
|
storage = receiptHome(), wrangler = null } = options;
|
|
82
86
|
const mode = live ? 'live' : 'test';
|
|
83
87
|
const checked = readShop(root);
|
|
84
|
-
if (!checked.ok || checked.absent) return
|
|
85
|
-
if (options.managed === true && checked.shop.till !== 'stripe-managed') return
|
|
88
|
+
if (!checked.ok || checked.absent) return fail('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
|
|
89
|
+
if (options.managed === true && checked.shop.till !== 'stripe-managed') return fail('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
|
|
90
|
+
let catalog, mcpAccount;
|
|
91
|
+
if (options.webhookOnly) {
|
|
92
|
+
try {
|
|
93
|
+
const {saveMcpVerification} = await import('./shop-mcp.mjs');
|
|
94
|
+
const saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));
|
|
95
|
+
if(!saved.result?.verifiedAt || Date.now()-Date.parse(saved.result.verifiedAt)>15*60_000) return fail('verify','Run shop verify with fresh Stripe MCP reads before installing the webhook (verification is valid for 15 minutes).');
|
|
96
|
+
const verified=await saveMcpVerification(root,saved.options);
|
|
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
|
+
mcpAccount=saved.options.account;
|
|
99
|
+
if(!/^acct_[A-Za-z0-9]+$/.test(mcpAccount??''))return fail('account','Verify with the Stripe MCP account ID first.');
|
|
100
|
+
catalog={config:verified.config,retire:verified.retire};
|
|
101
|
+
} catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
|
|
102
|
+
}
|
|
86
103
|
const id = hash(realpathSync(root)).slice(0, 24);
|
|
87
|
-
const profile = `homie-${id}`;
|
|
104
|
+
const profile = options.profile ?? `homie-${id}`;
|
|
88
105
|
const dir = join(storage, id);
|
|
89
106
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
107
|
// Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
|
|
91
108
|
const lock = lockDeploy(dir);
|
|
92
|
-
if (!lock.ok) return
|
|
109
|
+
if (!lock.ok) return fail('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
|
|
93
110
|
try {
|
|
94
111
|
const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
|
|
95
112
|
const installed = await cli(['--version']);
|
|
96
|
-
if (installed.code !== 0) return
|
|
113
|
+
if (installed.code !== 0) return fail('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
|
|
97
114
|
|
|
98
115
|
// A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
|
|
99
116
|
let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
@@ -102,21 +119,25 @@ export async function shopConnect(root, options = {}) {
|
|
|
102
119
|
const login = json(start.stdout);
|
|
103
120
|
const complete = completionArgs(login?.next_step);
|
|
104
121
|
if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
|
|
105
|
-
return
|
|
122
|
+
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'));
|
|
106
123
|
}
|
|
107
124
|
log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
|
|
108
125
|
open(login.browser_url);
|
|
109
126
|
const finished = await cli(complete);
|
|
110
|
-
if (finished.code !== 0) return
|
|
127
|
+
if (finished.code !== 0) return fail('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
|
|
111
128
|
|
|
112
129
|
probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
113
|
-
if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return
|
|
130
|
+
if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return fail('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.' + stripeReason(probe, 'GET /v1/products'));
|
|
131
|
+
}
|
|
132
|
+
if (options.webhookOnly) {
|
|
133
|
+
const accountResult=await cli(['get','/v1/account',...(live?['--live']:[])]);
|
|
134
|
+
if(accountResult.code!==0 || json(accountResult.stdout)?.id!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
|
|
114
135
|
}
|
|
115
136
|
const site = siteUrl(root);
|
|
116
|
-
if (!site) return
|
|
137
|
+
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 });
|
|
117
138
|
let target;
|
|
118
|
-
try { target = new URL(site); } catch { return
|
|
119
|
-
if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return
|
|
139
|
+
try { target = new URL(site); } catch { return fail('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
|
|
140
|
+
if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return fail('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
|
|
120
141
|
const api = async (method, path, params, idempotency) => {
|
|
121
142
|
const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
|
|
122
143
|
for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
|
|
@@ -128,20 +149,20 @@ export async function shopConnect(root, options = {}) {
|
|
|
128
149
|
return body;
|
|
129
150
|
};
|
|
130
151
|
const reason = fullerReason(checked.shop);
|
|
131
|
-
if (reason) return
|
|
152
|
+
if (reason) return fail('fuller-checkout', reason, { mode, connected: true });
|
|
132
153
|
const studio = readStudio(root);
|
|
133
154
|
const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
134
155
|
const receiptFile = join(dir, `${mode}.json`);
|
|
135
156
|
const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
|
|
136
|
-
const result = await syncPaymentLinks({ root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
|
|
157
|
+
const result = await syncPaymentLinks({ catalog, root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
|
|
137
158
|
bookingSlots: options.bookingSlots ?? (existsSync(join(root,'.studio','booking-slots.json')) ? JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')) : []),
|
|
138
159
|
saveReceipt: (record) => {
|
|
139
160
|
writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
|
|
140
161
|
renameSync(`${receiptFile}.tmp`, receiptFile);
|
|
141
162
|
} });
|
|
142
|
-
return { ...result, profile };
|
|
163
|
+
return { ...result, command:options.webhookOnly?'shop webhook':result.command, profile };
|
|
143
164
|
} catch (e) {
|
|
144
165
|
const said = e?.stripeReason ?? '';
|
|
145
|
-
return
|
|
166
|
+
return fail('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.' + said);
|
|
146
167
|
} finally { lock.release(); }
|
|
147
168
|
}
|
|
@@ -3708,6 +3708,51 @@
|
|
|
3708
3708
|
}
|
|
3709
3709
|
}
|
|
3710
3710
|
}
|
|
3711
|
+
},
|
|
3712
|
+
"0.51.5": {
|
|
3713
|
+
"files": {
|
|
3714
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3715
|
+
"README.md": "684b3cca8001673c",
|
|
3716
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3717
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3718
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3719
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3720
|
+
"posts/README.md": "37841aae476bd540",
|
|
3721
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3722
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3723
|
+
},
|
|
3724
|
+
"sections": {
|
|
3725
|
+
"AGENTS.md": {
|
|
3726
|
+
"(intro)": "394cbbe934e80722",
|
|
3727
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3728
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3729
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3730
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3731
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3732
|
+
"## Making games": "bb715272fd1a615b",
|
|
3733
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3734
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3735
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3736
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3737
|
+
"## Selling things (the shop)": [
|
|
3738
|
+
"0df5a5fe1833c624",
|
|
3739
|
+
"43a3556be93d1235"
|
|
3740
|
+
],
|
|
3741
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3742
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3743
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3744
|
+
"## Beta": "9d02638207a26e73"
|
|
3745
|
+
}
|
|
3746
|
+
},
|
|
3747
|
+
"named": {
|
|
3748
|
+
"Homie Arcade": {
|
|
3749
|
+
"sections": {
|
|
3750
|
+
"AGENTS.md": {
|
|
3751
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3711
3756
|
}
|
|
3712
3757
|
}
|
|
3713
3758
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Retire the exact guidance shipped by Homie without opening credential files. */
|
|
2
|
+
export const RETIRED_MEDIA_GUIDANCE = "### Existing provider credentials\n\nThe tools read `FAL_KEY` or `ELEVENLABS_API_KEY` first, then the plain single-line\nkey in `~/.homie/keys/fal-key` or `~/.homie/keys/elevenlabs-key`. They also accept\n`~/.homie/workspace/fal-key` and `~/.homie/workspace/elevenlabs-key` from earlier\ninstalls. Checks name the source, never the key. Keep these files private (mode\n0600); never copy them into a studio. ElevenLabs also accepts its CLI browser\nsign-in and the existing Stripe Projects connection. Run the tool\u2019s `check`\nbefore declaring a provider disconnected.\n";
|
|
3
|
+
export const OFFICIAL_MEDIA_GUIDANCE = `### Official media connections
|
|
4
|
+
|
|
5
|
+
Use the official fal and ElevenLabs plugins in the AI client, with their browser sign-in.
|
|
6
|
+
Homie never reads provider credentials. Cloudflare deploys use Wrangler login; official
|
|
7
|
+
Cloudflare MCP tools inspect logs, Workers Issues and D1. Follow the matching toolkit's
|
|
8
|
+
guides/studio-setup/references/OFFICIAL-MEDIA.md. Retired Homie generation scripts have
|
|
9
|
+
no compatibility path. Keep existing assets and receipts; do not migrate credential files.
|
|
10
|
+
`;
|
|
11
|
+
export function upgradeMediaGuidance(text) {
|
|
12
|
+
return text.replace(RETIRED_MEDIA_GUIDANCE.trimEnd(), OFFICIAL_MEDIA_GUIDANCE.trimEnd());
|
|
13
|
+
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { upgradeMediaGuidance } from './upgrade-media.mjs';
|
|
1
2
|
import {appPresetChanges} from './upgrade-app-presets.mjs';
|
|
2
3
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
3
4
|
/**
|
|
@@ -199,8 +200,17 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
199
200
|
if (Object.keys(missingScripts).length) changes.push({ file: 'package.json', kind: 'scripts', what: `add scripts: ${Object.keys(missingScripts).join(', ')}`, scripts: missingScripts });
|
|
200
201
|
if (studio.homie?.studio !== STUDIO_VERSION) changes.push({ file: 'studio.json', kind: 'version', what: `homie.studio ${studio.homie?.studio ?? '(none)'} → ${STUDIO_VERSION}`, to: STUDIO_VERSION });
|
|
201
202
|
|
|
203
|
+
const mediaFiles = new Set();
|
|
204
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md', 'README.md']) {
|
|
205
|
+
const from = read(root, file);
|
|
206
|
+
if (from === null) continue;
|
|
207
|
+
const text = upgradeMediaGuidance(from);
|
|
208
|
+
if (text !== from) { changes.push({ file, kind: 'update-file', what: 'replace retired Homie credential guidance with official plugin connections', from, text }); mediaFiles.add(file); }
|
|
209
|
+
}
|
|
210
|
+
|
|
202
211
|
// AGENTS.md, section by section; the other template files whole.
|
|
203
212
|
for (const rel of TEMPLATE_FILES) {
|
|
213
|
+
if (mediaFiles.has(rel)) continue;
|
|
204
214
|
const mine = read(root, rel);
|
|
205
215
|
const want = tmpl[rel];
|
|
206
216
|
if (want === undefined) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.5",
|
|
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/parts/PARTS.md
CHANGED
|
@@ -240,7 +240,7 @@ Paid releases use a **separate private R2 bucket and `PURCHASE_MEDIA` binding**.
|
|
|
240
240
|
|
|
241
241
|
Stripe's [MPP guide](https://docs.stripe.com/payments/machine/mpp) initializes the Stripe client with a secret credential. Its [x402 guide](https://docs.stripe.com/payments/machine/x402) separates the on-chain facilitator settlement from authenticated PaymentIntent recording. **x402 itself does not require a Stripe API key**; this supported path records money into the seller's Stripe books as Stripe documents. The keyless Payment Link path uses [signed webhooks](https://docs.stripe.com/webhooks) instead of provider reads. CLI OAuth is not exported as a Worker REST credential; see [the setup research](../../../docs/stripe-connect-research.md).
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
Owner setup uses Stripe’s official OAuth plugin and Homie’s private webhook installation; read [SHOP.md](../shop/SHOP.md). Runtime-credential machine payment integrations are developer work, not an owner fallback. Never ask an owner to create or copy a key.
|
|
244
244
|
|
|
245
245
|
Turnstile is optional. If the studio configures it, the fallback verifies its hostname and action. The per-address Worker rate limiter remains configurable flood protection. Machine payment proofs are verified by the protocol libraries.
|
|
246
246
|
|
package/rules/view.ts
CHANGED
|
@@ -728,6 +728,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
728
728
|
while (stepped < t) {
|
|
729
729
|
stepped += 1;
|
|
730
730
|
step(stepped, stepped === t);
|
|
731
|
+
// fire() can read/cache the own entity before this pump advances it.
|
|
732
|
+
// A renderer later in the same callback must see the completed step,
|
|
733
|
+
// not hold that effect's old pose until the next (possibly early) frame.
|
|
734
|
+
presentedEntity = null;
|
|
731
735
|
if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
|
|
732
736
|
}
|
|
733
737
|
}
|