@homie-rocks/studio 0.51.4 → 0.51.6
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 +49 -0
- package/README.md +10 -29
- package/apps/APPS.md +16 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +20 -4
- 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/app-needs.mjs +86 -18
- package/lib/app-ready.mjs +21 -0
- package/lib/build.mjs +12 -9
- package/lib/check.mjs +16 -7
- package/lib/cloudflare.mjs +6 -11
- package/lib/codex.mjs +7 -5
- 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/needs-headless.mjs +34 -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 +81 -19
- 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 +87 -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 +110 -34
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/index.mjs +5 -1
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +19 -1
- package/worker/mcp.mjs +1 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +58 -8
- 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/mcp-tools.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { providerKey } from './provider-key.mjs';
|
|
2
1
|
/**
|
|
3
2
|
* THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
|
|
4
3
|
* desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
|
|
@@ -200,14 +199,14 @@ function needsInstall(ctx, root) {
|
|
|
200
199
|
|
|
201
200
|
/* ------------------------------------------------------------------ the setup card */
|
|
202
201
|
|
|
203
|
-
async function statusOf(ctx, root, { fresh = false } = {}) {
|
|
202
|
+
async function statusOf(ctx, root, { fresh = false, integrations = {} } = {}) {
|
|
204
203
|
const key = root ?? '';
|
|
205
|
-
if (!fresh && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
|
|
204
|
+
if (!fresh && !Object.keys(integrations).length && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
|
|
206
205
|
const node = findNode({ fresh });
|
|
207
206
|
// Claude Code's own status line is not this host's: its row stays out (CLAUDECODE says the server runs inside it).
|
|
208
207
|
const env = toolEnv();
|
|
209
208
|
delete env.CLAUDECODE;
|
|
210
|
-
const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', node: node?.version ?? '0', env });
|
|
209
|
+
const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', integrations, node: node?.version ?? '0', env });
|
|
211
210
|
const row = value.rows.find((r) => r.id === 'node');
|
|
212
211
|
if (row) {
|
|
213
212
|
if (!node) { row.detail = 'not found on this computer (22 or newer is needed)'; row.state = 'missing'; }
|
|
@@ -240,8 +239,8 @@ function checklist(ctx, root) {
|
|
|
240
239
|
return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
|
|
241
240
|
}
|
|
242
241
|
|
|
243
|
-
async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null } = {}) {
|
|
244
|
-
const status = await statusOf(ctx, root, { fresh });
|
|
242
|
+
async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null, integrations = {} } = {}) {
|
|
243
|
+
const status = await statusOf(ctx, root, { fresh, integrations });
|
|
245
244
|
const studios = ctx.studios().map(({ root: r, ...s }) => s);
|
|
246
245
|
const current = root ? ctx.describe(root) : null;
|
|
247
246
|
const running = root ? runningJobs(root).find((j) => j.label.startsWith('npm install')) : null;
|
|
@@ -814,13 +813,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
814
813
|
{
|
|
815
814
|
name: 'setup_status', title: 'Setup status',
|
|
816
815
|
description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
|
|
817
|
-
inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' } } },
|
|
816
|
+
inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' }, integrations: { type: 'object', description: 'Session evidence from official MCP tools: connected only after a successful authenticated call; unavailable when absent, otherwise unknown. Never credentials.', properties: Object.fromEntries(['fal','elevenlabs','cloudflare'].map(id => [id, { type: 'string', enum: ['connected','unavailable','unknown'] }])) } } },
|
|
818
817
|
annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
|
|
819
818
|
run: async (a) => {
|
|
820
819
|
// A name with no studio of it yet is the studio about to be made: its own checklist, from step 1.
|
|
821
820
|
const named = String(a.studio ?? '').trim().slice(0, 60);
|
|
822
|
-
if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, wanted: named });
|
|
823
|
-
return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true });
|
|
821
|
+
if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, integrations: a.integrations, wanted: named });
|
|
822
|
+
return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true, integrations: a.integrations });
|
|
824
823
|
},
|
|
825
824
|
},
|
|
826
825
|
{
|
|
@@ -1031,13 +1030,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1031
1030
|
},
|
|
1032
1031
|
{
|
|
1033
1032
|
name: 'build', title: 'Build the site',
|
|
1034
|
-
description: 'Bundle every game or app and the site into site/dist. Rules builds
|
|
1035
|
-
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
|
|
1033
|
+
description: 'Bundle every game or app and the site into site/dist. Rules builds run strict types, the guard and deterministic generated play. quick:true skips generated play and snapshot smoke checks for local iteration; full build before release. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
|
|
1034
|
+
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), quick:{type:'boolean'}, ...STUDIO_ARG } },
|
|
1036
1035
|
annotations: { title: 'Build', ...RW },
|
|
1037
1036
|
run: async (a) => {
|
|
1038
1037
|
const root = ctx.root(a.studio);
|
|
1039
1038
|
const need = needsInstall(ctx, root); if (need) return need;
|
|
1040
|
-
const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : [])]);
|
|
1039
|
+
const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : []), ...(a.quick?['--quick']:[])]);
|
|
1041
1040
|
if (!r.ended) return stillRunning(r.job, 'The build');
|
|
1042
1041
|
if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
|
|
1043
1042
|
const g = r.result?.games ?? [];
|
|
@@ -1177,9 +1176,31 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1177
1176
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
|
1178
1177
|
},
|
|
1179
1178
|
},
|
|
1179
|
+
{
|
|
1180
|
+
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
|
+
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1183
|
+
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
|
+
run:async a=>{
|
|
1185
|
+
const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
|
|
1186
|
+
const result=await (a.verify?saveMcpVerification:shopMcpPlan)(ctx.root(a.studio),a);
|
|
1187
|
+
return ok(JSON.stringify(result),result);
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
name:'stripe_webhook',title:'Install Stripe webhook privately',
|
|
1192
|
+
description:'After stripe_shop_plan verify:true, create/install only the webhook and verified public links. Uses a separate official CLI browser approval if needed; never shows secrets. Follow studio_job. Never create a webhook through model-visible Stripe MCP.',
|
|
1193
|
+
inputSchema:{type:'object',properties:{...STUDIO_ARG,live:{type:'boolean'},renew:{type:'boolean'}}},
|
|
1194
|
+
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
|
|
1195
|
+
run:async a=>{
|
|
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
|
+
await waitJob(job,Math.min(ctx.waitMs,1000));
|
|
1198
|
+
return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
|
|
1199
|
+
},
|
|
1200
|
+
},
|
|
1180
1201
|
{
|
|
1181
1202
|
name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
|
|
1182
|
-
description:'
|
|
1203
|
+
description:'CLI alternative to stripe_shop_plan with Stripe official MCP: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
|
|
1183
1204
|
inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
|
|
1184
1205
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
|
|
1185
1206
|
run:async a=>{
|
|
@@ -1193,12 +1214,12 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1193
1214
|
},
|
|
1194
1215
|
{
|
|
1195
1216
|
name: 'stripe_login', title: 'Connect the studio to Stripe',
|
|
1196
|
-
description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job.
|
|
1197
|
-
inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' }
|
|
1217
|
+
description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. Alternative to the default Stripe official MCP plan and private stripe_webhook flow. live only when the owner says go live.',
|
|
1218
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' } } },
|
|
1198
1219
|
annotations: { title: 'Connect Stripe', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1199
1220
|
run: async (a) => {
|
|
1200
1221
|
const root = ctx.root(a.studio);
|
|
1201
|
-
const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])
|
|
1222
|
+
const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])];
|
|
1202
1223
|
const job = cliJob(root, 'Stripe connection', args, { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs'), keep: false });
|
|
1203
1224
|
const ended = await waitJob(job, Math.min(ctx.waitMs, 1000));
|
|
1204
1225
|
ctx.status = null;
|
|
@@ -1496,13 +1517,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1496
1517
|
},
|
|
1497
1518
|
});
|
|
1498
1519
|
}
|
|
1499
|
-
//
|
|
1500
|
-
for (const [name, title, needs] of [['music', 'Music
|
|
1520
|
+
// Local media processing; provider tools are discovered by the AI host.
|
|
1521
|
+
for (const [name, title, needs] of [['music', 'Music preparation', 'ffmpeg'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: import completed 3D assets', 'node']]) {
|
|
1501
1522
|
const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
|
|
1502
1523
|
if (!script || !existsSync(script) || !avail[needs]) continue;
|
|
1503
1524
|
tools.push({
|
|
1504
1525
|
name, title,
|
|
1505
|
-
description: `
|
|
1526
|
+
description: `Local ${name} preparation only; read studio_guide for the workflow. Generation, pricing and provider connections use the official provider MCP in the AI client.`,
|
|
1506
1527
|
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The script\'s words' }, ...STUDIO_ARG }, required: ['args'] },
|
|
1507
1528
|
annotations: { title, readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1508
1529
|
run: async (a) => {
|
|
@@ -1523,9 +1544,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1523
1544
|
return tools;
|
|
1524
1545
|
}
|
|
1525
1546
|
|
|
1526
|
-
/**
|
|
1547
|
+
/** Local processing availability only; provider connections belong to the AI host. */
|
|
1527
1548
|
export function availability(env = process.env) {
|
|
1528
|
-
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'))
|
|
1549
|
+
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')) };
|
|
1529
1550
|
}
|
|
1530
1551
|
|
|
1531
1552
|
/** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
|
package/lib/mcp.mjs
CHANGED
|
@@ -37,7 +37,7 @@ Carry the person's request through to a working result without a lesson or an in
|
|
|
37
37
|
Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
|
|
38
38
|
New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists; existing netplay games keep building and running with their original 32-seat limit; larger rooms require a rewrite. Repair the full build diagnostics and prove the requested behavior in two browsers. Set players.max to the chosen room size, including hundreds; public connections use Gates. Choose room.view radiusM, precisionM, nearM and farHz explicitly and measure at the requested size. Private per-seat state is not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
|
|
39
39
|
Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
|
|
40
|
-
For a shop,
|
|
40
|
+
For a shop, default to stripe_shop_plan with Stripe official MCP for catalog and links, then stripe_webhook for private webhook installation. stripe_login (shop connect) is the CLI alternative. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
|
|
41
41
|
For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
|
|
42
42
|
|
|
43
43
|
If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Place already downloaded official MCP outputs. No provider networking, credentials or generation. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
function inside(root, name) {
|
|
6
|
+
const path = resolve(root, name), rel = relative(root, path);
|
|
7
|
+
if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error('asset paths must stay inside the studio');
|
|
8
|
+
let ancestor = path;
|
|
9
|
+
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
|
|
10
|
+
const actual = relative(realpathSync(root), realpathSync(ancestor));
|
|
11
|
+
if (isAbsolute(actual) || actual === '..' || actual.startsWith(`..${sep}`)) throw new Error('symlink leaves studio');
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
export function importMedia(root, { file, receipt, out, usedBy }) {
|
|
15
|
+
if (!existsSync(resolve(root, 'studio.json'))) throw new Error('not a studio');
|
|
16
|
+
const r = typeof receipt === 'string' ? JSON.parse(readFileSync(receipt, 'utf8')) : receipt;
|
|
17
|
+
for (const key of ['provider','tool','model','requestId','prompt','styleRevision','rights','cost']) if (r?.[key] === undefined) throw new Error(`receipt needs ${key}`);
|
|
18
|
+
if (!['fal','elevenlabs'].includes(r.provider) || !r.requestId || !r.tool || !r.model || !r.prompt) throw new Error('incomplete official MCP provenance');
|
|
19
|
+
if (!r.rights?.source || !r.rights?.checkedAt || typeof r.rights?.commercial !== 'boolean') throw new Error('record rights source, date and commercial permission');
|
|
20
|
+
if (!['estimate','actual','unknown'].includes(r.cost?.status) || (r.cost.status !== 'unknown' && !(Number.isFinite(r.cost.amount) && r.cost.amount >= 0 && r.cost.unit))) throw new Error('cost must be actual, estimate or explicitly unknown');
|
|
21
|
+
const target = inside(root, out), use = inside(root, usedBy);
|
|
22
|
+
const bytes = readFileSync(file);
|
|
23
|
+
if (!bytes.length) throw new Error('empty asset');
|
|
24
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
25
|
+
const registry = inside(root, 'media/provenance.json');
|
|
26
|
+
const records = existsSync(registry) ? JSON.parse(readFileSync(registry,'utf8')) : [];
|
|
27
|
+
const previous = records.find(x => x.provider === r.provider && x.requestId === r.requestId && x.output === out);
|
|
28
|
+
if (previous && previous.sha256 !== hash) throw new Error('same output identity has different bytes');
|
|
29
|
+
if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') !== hash) throw new Error('destination already contains a different asset');
|
|
30
|
+
// Store only allowlisted provenance; raw tool responses may contain temporary signed URLs.
|
|
31
|
+
const record = { provider:r.provider, tool:r.tool, model:r.model, requestId:r.requestId, prompt:r.prompt, styleRevision:r.styleRevision,
|
|
32
|
+
rights:{ source:r.rights.source, checkedAt:r.rights.checkedAt, commercial:r.rights.commercial, note:r.rights.note ?? null },
|
|
33
|
+
cost:{ status:r.cost.status, amount:r.cost.amount ?? null, unit:r.cost.unit ?? null }, fixture:r.fixture === true,
|
|
34
|
+
output:out, usedBy, sha256:hash, bytes:bytes.length };
|
|
35
|
+
// Static reference evidence is not a runtime claim. Skills must also inspect the running scene.
|
|
36
|
+
const reference = relative(dirname(use), target).split(sep).join('/');
|
|
37
|
+
if (!readFileSync(use,'utf8').includes(reference)) throw new Error(`consumer does not reference ${reference}`);
|
|
38
|
+
mkdirSync(dirname(target),{recursive:true}); copyFileSync(file,target);
|
|
39
|
+
if (!previous) { records.push(record); mkdirSync(dirname(registry),{recursive:true}); writeFileSync(registry,JSON.stringify(records,null,2)+'\n'); }
|
|
40
|
+
return { ok:true, command:'media import', artifact:out, provenance:'media/provenance.json', sha256:hash, already:Boolean(previous), usage:'static reference verified; inspect the running scene' };
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Declarative tool references for Homie's media skills; host namespaces are resolved by the AI. */
|
|
2
|
+
export const MEDIA_WORKFLOWS = Object.freeze({
|
|
3
|
+
art: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result','upload_file'] },
|
|
4
|
+
models: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','submit_job','check_job','get_job_result'] },
|
|
5
|
+
video: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','submit_job','check_job','get_job_result'] },
|
|
6
|
+
style: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result'] },
|
|
7
|
+
music: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
|
|
8
|
+
sound: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
|
|
9
|
+
});
|
|
10
|
+
export function validateMediaTools(workflow, tools) {
|
|
11
|
+
const available=new Set(tools.map(t=>t.name));
|
|
12
|
+
return {ready:workflow.tools.every(t=>available.has(t)), missing:workflow.tools.filter(t=>!available.has(t))};
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {localCookie} from './local-test.mjs';
|
|
2
|
+
|
|
3
|
+
/** Each action has an explicit local identity, using the real MCP transport and permissions. */
|
|
4
|
+
export function needsHeadless({url,secret,app,meta,scope}) {
|
|
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');
|
|
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
|
+
const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
|
|
9
|
+
const client=new Client({name:'Homie needs check',version:'1.0.0'});
|
|
10
|
+
const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
|
|
11
|
+
try {
|
|
12
|
+
await client.connect(transport);
|
|
13
|
+
let call=item.mcp;
|
|
14
|
+
if(item.records)call={tool:'app_records',arguments:{...item.records,app,role,operation:'read'}};
|
|
15
|
+
if(item.schedule)call={tool:'studio_schedule_fire',arguments:item.schedule};
|
|
16
|
+
if(call?.method) {
|
|
17
|
+
if(call.method==='resources/read')return await client.readResource(call.params);
|
|
18
|
+
// The extension's JSON-RPC result is intentionally preserved for path assertions.
|
|
19
|
+
if(['events/list','events/poll'].includes(call.method)) {
|
|
20
|
+
const {z}=await import('zod');
|
|
21
|
+
return await client.request({method:call.method,params:call.params??{}},z.object({}).passthrough(),{timeout:10000});
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Unsupported MCP proof method ${call.method}; use tools, resources/read, events/list or events/poll`);
|
|
24
|
+
}
|
|
25
|
+
if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
|
|
26
|
+
const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
|
|
27
|
+
if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
|
|
28
|
+
if(result.structuredContent!==undefined)return result.structuredContent;
|
|
29
|
+
const content=result.content?.filter(c=>c.type==='text');
|
|
30
|
+
if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
|
|
31
|
+
return result;
|
|
32
|
+
} finally {await client.close();}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Provider connections belong to the AI host. Never inspect credentials or host configuration. */
|
|
2
|
+
export const INTEGRATIONS = Object.freeze({
|
|
3
|
+
fal: { label: 'fal', url: 'https://mcp.fal.ai/mcp-relay', connectUrl: 'https://fal.ai/docs/documentation/setting-up/mcp', unlocks: 'Images, 3D and generated video', connect: 'Install the official fal plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once.' },
|
|
4
|
+
elevenlabs: { label: 'ElevenLabs', url: 'https://api.elevenlabs.io/v1/mcp', connectUrl: 'https://elevenlabs.io/mcp', unlocks: 'Music, speech and sound where exposed by the connected MCP', connect: 'Install elevenlabs/plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once. Discover the workspace tools before promising a media capability.' },
|
|
5
|
+
cloudflare: { label: 'Cloudflare MCP', url: 'https://mcp.cloudflare.com/mcp', connectUrl: 'https://github.com/cloudflare/skills', unlocks: 'Inspect Workers Issues, logs and D1 through official tools', connect: 'Connect the official Cloudflare plugin or this MCP URL and approve account access in the browser. Deploys separately use Wrangler login (cloudflare_login).' },
|
|
6
|
+
});
|
|
7
|
+
export function officialConnections(evidence = {}) {
|
|
8
|
+
return Object.entries(INTEGRATIONS).map(([provider, p]) => {
|
|
9
|
+
const connected = evidence[provider] === 'connected';
|
|
10
|
+
return { id: provider === 'cloudflare' ? 'cloudflare-mcp' : provider, label: p.label, need: 'optional', state: connected ? 'ok' : 'optional',
|
|
11
|
+
connection: connected ? 'connected' : evidence[provider] === 'unavailable' ? 'unavailable' : 'unknown',
|
|
12
|
+
detail: connected ? 'Official MCP connected (authenticated call reported by this AI session)' : evidence[provider] === 'unavailable' ? 'Official MCP unavailable in this AI session' : 'Connection unknown: the AI host must discover tools and verify an authenticated read',
|
|
13
|
+
unlocks: p.unlocks, fix: connected ? null : { who: 'person', open: p.connectUrl, say: `${p.connect} MCP URL: ${p.url}` } };
|
|
14
|
+
});
|
|
15
|
+
}
|
package/lib/rules-build.mjs
CHANGED
|
@@ -141,7 +141,8 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
|
|
|
141
141
|
* Returns { code, tune, map, settings, seats, stateHash, schema, publicTune, build, files, rounds, units, tickUnits, check }.
|
|
142
142
|
* `longCheck`: eight times the generated play (`homie-studio build --long-check`).
|
|
143
143
|
*/
|
|
144
|
-
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
|
|
144
|
+
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false, quick = false } = {}) {
|
|
145
|
+
log(`${g.id}: checking rules and types${quick ? ' (quick rebuild; play checks skipped)' : ''}`);
|
|
145
146
|
const guarded = await guardRules(esbuild, root, g.dir);
|
|
146
147
|
if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
|
|
147
148
|
const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
|
|
@@ -157,8 +158,14 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
157
158
|
const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true, physics: Boolean(map.physics) });
|
|
158
159
|
const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
|
|
159
160
|
// The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
const declarations=await runRulesWorker(bundle,data,{declarationsOnly:true});
|
|
162
|
+
await typecheckRules(root,g,declarations,tune,{log});
|
|
163
|
+
log(`${g.id}: ${quick?'skipping generated play':'running generated rules play; use build --quick for an iteration without play checks'}`);
|
|
164
|
+
const heartbeat=setInterval(()=>log(`${g.id}: rules play still running (${Math.round((Date.now()-started)/1000)}s)`),15000);
|
|
165
|
+
const started=Date.now();
|
|
166
|
+
let checked;
|
|
167
|
+
try { checked=quick?{...declarations,information:[],plays:[],ticks:0,roundsPlayed:0,restores:0,units:0,maxUnits:0,maxTickUnits:0,largestSaveBytes:0}:await runRulesWorker(bundle,data,{allowance:longCheck?LONG_ALLOWANCE:ALLOWANCE}); }
|
|
168
|
+
finally {clearInterval(heartbeat);}
|
|
162
169
|
for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
|
|
163
170
|
for (const line of checked.information) log(`info: ${line}`);
|
|
164
171
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
@@ -168,7 +175,8 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
168
175
|
const { def, R, H, initializePhysics } = await loadRules(esbuild, root, g.id, guarded.code, {physics: Boolean(map.physics)});
|
|
169
176
|
if (initializePhysics) await initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
|
|
170
177
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
171
|
-
|
|
178
|
+
log(`${g.id}: ${quick?'skipping':'running'} rules snapshot smoke check`);
|
|
179
|
+
const stats = quick ? {snapshotBytes:null,checkpointBytes:null,frameBytes:{},frameRates:{}} : smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
172
180
|
const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
|
|
173
181
|
const exceeded = RULES_FRAMES.flatMap(t => {
|
|
174
182
|
const bytes = stats.frameBytes[t] ?? 0; const cap = capOf(t, seats);
|
|
@@ -187,7 +195,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
187
195
|
build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
|
|
188
196
|
snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
|
|
189
197
|
units: checked.maxUnits, tickUnits: checked.maxTickUnits,
|
|
190
|
-
check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
198
|
+
check: { skipped: quick, information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
191
199
|
};
|
|
192
200
|
}
|
|
193
201
|
|
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);
|
|
@@ -73,7 +78,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
73
78
|
const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
74
79
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
75
80
|
const groups=Map.groupBy(menuItems,i=>i.game??'');
|
|
76
|
-
for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
|
|
81
|
+
for(const group of (shop.basket ? [] : groups.values())) for (let offset = 0; offset < group.length; offset += 10) {
|
|
77
82
|
const items = group.slice(offset, offset + 10);
|
|
78
83
|
if (items.length < 2) continue;
|
|
79
84
|
const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
|
|
@@ -83,13 +88,40 @@ 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
|
}
|
|
96
|
+
// One aggregate payment represents a frozen local basket, including one-item baskets.
|
|
97
|
+
// Runtime uses prefilled_amount, never a shared Price mutation: concurrent
|
|
98
|
+
// guests must receive their own saved totals.
|
|
99
|
+
if(shop.basket) for(const [game,items] of groups) {
|
|
100
|
+
const id=await productIdOf(slug,`basket-${game||'shop'}`);
|
|
101
|
+
keepProducts.add(id);
|
|
102
|
+
const fields={name:shop.basket.name,description:shop.basket.description,metadata,active:true};
|
|
103
|
+
const product=products.find(p=>p.id===id);
|
|
104
|
+
if(!product)await api('POST','/v1/products',{id,...fields},`product-${hash(id)}`);
|
|
105
|
+
else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
|
|
106
|
+
const priceFields={product:id,currency:shop.currency,custom_unit_amount:{enabled:true},tax_behavior:'inclusive'};
|
|
107
|
+
const priceRevision=hash(priceFields);
|
|
108
|
+
let price=prices.find(p=>own(p)&&p.product===id&&p.metadata?.revision===priceRevision);
|
|
109
|
+
if(!price)price=await api('POST','/v1/prices',{...priceFields,metadata:{...metadata,revision:priceRevision}},`price-${hash({priceFields,metadata})}`);
|
|
110
|
+
if(!price.active)await api('POST',`/v1/prices/${price.id}`,{active:true});
|
|
111
|
+
keepPrices.add(price.id);
|
|
112
|
+
const params={line_items:[{price:price.id,quantity:1}],automatic_tax:{enabled:false},payment_intent_data:{metadata},
|
|
113
|
+
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
114
|
+
const revision=hash(params);
|
|
115
|
+
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
116
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`basket-${revision}`);
|
|
117
|
+
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
118
|
+
if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
|
|
119
|
+
keepLinks.add(link.id);
|
|
120
|
+
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)}))});
|
|
121
|
+
}
|
|
90
122
|
config.slots=[];
|
|
91
123
|
const pause=async link=>{
|
|
92
|
-
await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
124
|
+
if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
93
125
|
for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
|
|
94
126
|
await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
|
|
95
127
|
}
|
|
@@ -107,11 +139,13 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
107
139
|
let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
|
|
108
140
|
if(!link && (!slot.capacity||slot.active===false))continue;
|
|
109
141
|
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
|
-
|
|
142
|
+
const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
|
|
143
|
+
const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
|
|
144
|
+
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
145
|
const count=link?.restrictions?.completed_sessions?.count??0;
|
|
112
146
|
// Initial publication uses capacity exactly. After a reviewed move/refund the
|
|
113
147
|
// AI supplies current confirmed seats; keep the existing Stripe lifetime count.
|
|
114
|
-
const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
|
|
148
|
+
const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
|
|
115
149
|
const active=slot.active!==false && limit>count;
|
|
116
150
|
const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
|
|
117
151
|
restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
@@ -119,9 +153,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
119
153
|
else {
|
|
120
154
|
// Pause first whenever publishing a changed budget, so old sessions cannot
|
|
121
155
|
// 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});
|
|
156
|
+
if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
|
|
157
|
+
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
158
|
}
|
|
159
|
+
await verifyLink(link,params);
|
|
125
160
|
keepLinks.add(link.id);
|
|
126
161
|
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
162
|
}
|
|
@@ -132,11 +167,40 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
132
167
|
const portals=await list('/v1/billing_portal/configurations');
|
|
133
168
|
let portal=portals.find(p=>own(p));
|
|
134
169
|
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);
|
|
170
|
+
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
171
|
config.portal=portal.login_page?.url;
|
|
137
172
|
}
|
|
138
|
-
|
|
139
|
-
|
|
173
|
+
const retire = [
|
|
174
|
+
...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
|
|
175
|
+
...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
|
|
176
|
+
...products.filter(p=>own(p)&&p.active&&!keepProducts.has(p.id)).map(p=>({method:'POST',path:`/v1/products/${p.id}`,params:{active:false}})),
|
|
177
|
+
];
|
|
178
|
+
return {config, retire};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function syncPaymentLinks(options) {
|
|
182
|
+
const {root, api, shop, slug, site, mode, w, receipt, saveReceipt, local = false} = options;
|
|
183
|
+
if (local && mode !== 'test') throw new Error('Local checkout requires Stripe test mode');
|
|
184
|
+
const {config, retire} = options.catalog ?? await reconcilePaymentLinks(options);
|
|
185
|
+
if (local) return {ok:true, command:'shop connect', local:true, mode, config};
|
|
186
|
+
const list = async (path,params={}) => {
|
|
187
|
+
const all=[]; let cursor;
|
|
188
|
+
do {
|
|
189
|
+
const page=await api('GET',path,{...params,limit:100,...(cursor?{starting_after:cursor}:{})});
|
|
190
|
+
if (!Array.isArray(page.data)) throw new Error('Incomplete Stripe list');
|
|
191
|
+
all.push(...page.data);
|
|
192
|
+
const next=page.has_more?page.data.at(-1)?.id:null;
|
|
193
|
+
if(page.has_more && (!next || next===cursor))throw new Error('Incomplete Stripe pagination');
|
|
194
|
+
cursor=next;
|
|
195
|
+
}while(cursor);
|
|
196
|
+
return all;
|
|
197
|
+
};
|
|
198
|
+
const secrets=await w(['secret','list']);
|
|
199
|
+
if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
|
|
200
|
+
const names=JSON.parse(secrets.stdout??secrets.out);
|
|
201
|
+
const has=name=>names.some(s=>s.name===name);
|
|
202
|
+
|
|
203
|
+
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
204
|
const purchaseSecrets = {};
|
|
141
205
|
if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
|
|
142
206
|
const { generateKeyPair, exportJWK } = await import('jose');
|
|
@@ -166,9 +230,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
|
|
|
166
230
|
}
|
|
167
231
|
await purchases?.retire();
|
|
168
232
|
// 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 });
|
|
233
|
+
if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
|
|
172
234
|
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
235
|
return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
|
|
174
236
|
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
|
}
|