@homie-rocks/studio 0.51.7 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/bin/homie-studio.mjs +9 -4
- package/docs/toolkit-place-patterns.md +68 -0
- package/functions/FUNCTIONS.md +27 -0
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +123 -0
- package/guides/app/SKILL.md +11 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-needs.mjs +41 -6
- package/lib/app-ready.mjs +31 -0
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +13 -10
- package/lib/cloudflare.mjs +2 -2
- package/lib/connector-token.mjs +30 -0
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +6 -1
- package/lib/functions-build.mjs +3 -0
- package/lib/mcp-tools.mjs +2 -2
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +4 -4
- package/lib/shop-mcp.mjs +82 -14
- package/lib/stripe-connect.mjs +9 -4
- package/lib/template-history.json +84 -0
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +6 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +61 -17
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/booking-holds.mjs +87 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/connector-token.mjs +11 -0
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +24 -6
- package/worker/join-area.mjs +35 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +17 -9
- package/worker/pages.mjs +10 -7
- package/worker/records-page.mjs +5 -4
- package/worker/records-prefs.mjs +24 -0
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/guides/shop/SKILL.md
CHANGED
|
@@ -49,27 +49,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
49
49
|
3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
|
|
50
50
|
for **writes as well as reads**. Keep this setup's observations across batches.
|
|
51
51
|
Product creation includes its default Price; link creation expands line items.
|
|
52
|
-
Product and Price Search use studio/origin metadata.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
Product and Price Search use studio/origin metadata. Payment Links use paginated
|
|
53
|
+
list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
|
|
54
|
+
Stripe has no Payment Link metadata search. There is no per-link read loop.
|
|
55
|
+
The planner saves public observations in `.studio/stripe-mcp-observations.json` and
|
|
56
|
+
reuses them on the next call; send only new responses. Keep that file and
|
|
57
|
+
`.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
|
|
58
|
+
mutable links while retaining product/price discovery. Account, mode and origin
|
|
59
|
+
changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
|
|
60
|
+
verification before webhook installation (CLI: include it in `--have` input).
|
|
61
|
+
Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
|
|
62
|
+
retry its exact idempotency key; never discard successful responses or invent IDs.
|
|
61
63
|
4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
|
|
62
64
|
`homie-studio shop verify --have plan-input.json`). Verification checks real fields,
|
|
63
65
|
line items, menus, slot limits and portal settings. It saves compact public Worker
|
|
64
66
|
configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
|
|
65
|
-
|
|
67
|
+
Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
|
|
68
|
+
that asset, with only its path in Wrangler vars. Large catalogues never use a text
|
|
69
|
+
binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
|
|
66
70
|
it cannot open until signed payment events can confirm orders. Changing shop.json,
|
|
67
71
|
the slot manifest or origin requires verification again. Local test configuration
|
|
68
72
|
goes to `.dev.vars`.
|
|
69
73
|
5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
|
|
70
74
|
Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
|
|
71
|
-
checks the same account as the MCP, and installs the signing secret
|
|
72
|
-
|
|
75
|
+
checks the same account as the MCP, and installs only the signing secret through
|
|
76
|
+
Wrangler stdin. If Stripe CLI is missing, the tool installs the official
|
|
77
|
+
`@stripe/cli@latest` npm package automatically, checks the executable, then continues.
|
|
78
|
+
An installation failure reports npm permissions/PATH and a retry command. The CLI
|
|
79
|
+
needs one separate browser approval if its
|
|
73
80
|
studio profile is not already connected. MCP approval cannot be reused or exported.
|
|
74
81
|
Cloudflare authorization and an existing deployment are prerequisites.
|
|
75
82
|
6. After installation, perform the returned `retire` operations through Stripe MCP to
|
|
@@ -80,6 +87,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
80
87
|
endpoint's delivery result and the app's verified records. Do not type a card into
|
|
81
88
|
a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
|
|
82
89
|
|
|
90
|
+
Upgrades accept 0.51.6 verifications without a source fingerprint only when their
|
|
91
|
+
saved content fingerprint still matches the current shop, slots, site and mode.
|
|
92
|
+
Actual changes name the affected settings and require verification. An unfinished
|
|
93
|
+
setup still builds and deploys with checkout closed and one next step: finish
|
|
94
|
+
`stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
|
|
95
|
+
old link secrets cannot reopen it. Cached observations never renew the verification
|
|
96
|
+
time; use fresh reads if the private webhook tool requests them.
|
|
97
|
+
|
|
83
98
|
Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
|
|
84
99
|
|
|
85
100
|
**Why the separate approval (checked 2026-10-14).** Stripe's
|
|
@@ -409,3 +409,7 @@ and slug. It fills missing generated files and retries installation, preserving
|
|
|
409
409
|
already there. A different studio or unrelated nonempty folder is still refused.
|
|
410
410
|
New studios do not request a directory listing by default; set `homie.directory: true`
|
|
411
411
|
or a directory URL only when the person chooses listing.
|
|
412
|
+
|
|
413
|
+
Cloudflare's official MCP request interface cannot authorize new static-asset uploads with an upload JWT; deploy with Wrangler, Cloudflare's own CLI using browser OAuth.
|
|
414
|
+
|
|
415
|
+
An owner's existing Wrangler login can connect another studio without a new browser approval: in that studio run `homie-studio connector --tools <exact-tool-names> --out <private-config.json>`, then load the file directly into the AI client's connector configuration. Never print the bearer or paste the file in chat. Revoke with `homie-studio connector --revoke <id>`. See the installed `docs/toolkit-place-patterns.md` for scope and expiry.
|
package/lib/app-needs.mjs
CHANGED
|
@@ -23,7 +23,7 @@ export function readAppNeeds(root, id) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
26
|
-
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
|
|
26
|
+
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless, restart }) {
|
|
27
27
|
const results = [];
|
|
28
28
|
const appBarrier=ready;
|
|
29
29
|
ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
|
|
@@ -31,7 +31,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
31
31
|
const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
|
|
32
32
|
for (const need of plan.needs) {
|
|
33
33
|
activeRole = need.role;
|
|
34
|
-
const names = [...new Set([...(need.steps
|
|
34
|
+
const names = [...new Set([...needSteps(need.steps), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
|
|
35
35
|
const saved = new Map();
|
|
36
36
|
const run = async item => { if (!headless) throw new Error('headless MCP proof requires local dev and its test credential'); return headless(item,item.role ?? need.role); };
|
|
37
37
|
let waiting = 'validating assertions';
|
|
@@ -41,19 +41,34 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
41
41
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
42
42
|
for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
|
|
43
43
|
for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
|
|
44
|
-
|
|
44
|
+
const perform = async rawStep => {
|
|
45
45
|
const step=resolveNeedResults(rawStep,saved);
|
|
46
|
+
if(step.concurrent){
|
|
47
|
+
if(!Array.isArray(step.concurrent)||step.concurrent.length<2)throw new Error('concurrent needs at least two steps');
|
|
48
|
+
if(needSteps(step.concurrent).some(s=>s.restart||s.capabilities))throw new Error('Restart and capabilities changes must be outside a race');
|
|
49
|
+
const results=await Promise.allSettled(step.concurrent.map(perform));
|
|
50
|
+
const failed=results.find(r=>r.status==='rejected');if(failed)throw failed.reason;return;
|
|
51
|
+
}
|
|
52
|
+
if(step.restart){if(!restart)throw new Error('Restart requires the local dev controller');await restart();for(const s of surfaces)await ready(s);return;}
|
|
53
|
+
|
|
46
54
|
if (step.mcp || step.schedule) {
|
|
47
55
|
waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
|
|
48
56
|
const value = await run(step);
|
|
49
57
|
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
50
|
-
|
|
58
|
+
return;
|
|
51
59
|
}
|
|
52
60
|
const target=surface(step.surface);
|
|
53
61
|
if(!step.reload)await ready(target);
|
|
54
62
|
const { page } = target;
|
|
63
|
+
if(step.capabilities){
|
|
64
|
+
if(typeof step.capabilities.webgl!=='boolean')throw new Error('capabilities.webgl must be boolean');
|
|
65
|
+
await page.evaluateOnNewDocument(configureWebGL,step.capabilities.webgl);
|
|
66
|
+
await page.reload({waitUntil:'domcontentloaded'});await ready(target);return;
|
|
67
|
+
}
|
|
68
|
+
if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
|
|
69
|
+
|
|
55
70
|
waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
|
|
56
|
-
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface));
|
|
71
|
+
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); return; }
|
|
57
72
|
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
58
73
|
if (step.click && step.popup) {
|
|
59
74
|
let listener,timer;
|
|
@@ -67,11 +82,15 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
67
82
|
await page.bringToFront();
|
|
68
83
|
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
69
84
|
}
|
|
85
|
+
else if(step.forceClick){await act(frame=>frame.$eval(step.selector,el=>{
|
|
86
|
+
const disabled=el.disabled;try{el.disabled=false;el.click();}finally{el.disabled=disabled;}
|
|
87
|
+
}));}
|
|
70
88
|
else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
|
|
71
89
|
else if (step.fill !== undefined) { await act(async frame=>{ await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }); }
|
|
72
90
|
else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
|
|
73
91
|
else throw new Error('step needs click, fill, select or reload');
|
|
74
|
-
}
|
|
92
|
+
};
|
|
93
|
+
for(const step of need.steps??[])await perform(step);
|
|
75
94
|
for (const rawAssertion of need.assertions) {
|
|
76
95
|
const assertion=resolveNeedResults(rawAssertion,saved);
|
|
77
96
|
if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
|
|
@@ -170,3 +189,19 @@ export function resolveNeedResults(value,saved) {
|
|
|
170
189
|
}
|
|
171
190
|
return value;
|
|
172
191
|
}
|
|
192
|
+
|
|
193
|
+
/** Flatten only action groups; used for browser/role discovery before a race starts. */
|
|
194
|
+
export function needSteps(steps = []) { return steps.flatMap(s => s.concurrent ? needSteps(s.concurrent) : [s]); }
|
|
195
|
+
export function configureWebGL(enabled) {
|
|
196
|
+
window.__homieWebGLDisabled = !enabled;
|
|
197
|
+
if(window.__homieWebGLWrapped)return;
|
|
198
|
+
window.__homieWebGLWrapped = true;
|
|
199
|
+
for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
|
|
200
|
+
if(!proto)continue;
|
|
201
|
+
const original = proto.getContext;
|
|
202
|
+
proto.getContext = function(kind, ...args) {
|
|
203
|
+
if(window.__homieWebGLDisabled && /^(webgl2?|experimental-webgl)$/.test(kind))return null;
|
|
204
|
+
return Reflect.apply(original,this,[kind,...args]);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
package/lib/app-ready.mjs
CHANGED
|
@@ -56,6 +56,28 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
|
|
|
56
56
|
for (;;) {
|
|
57
57
|
try {
|
|
58
58
|
await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
|
|
59
|
+
const handle=await surface.frame.$?.(selector);
|
|
60
|
+
if(handle)try {
|
|
61
|
+
await handle.scrollIntoView();
|
|
62
|
+
const point=await handle.clickablePoint();
|
|
63
|
+
const ancestors=[];
|
|
64
|
+
for(let frame=surface.frame;frame.parentFrame();frame=frame.parentFrame())ancestors.unshift(await frame.frameElement());
|
|
65
|
+
const describeHit=(el,p)=>{
|
|
66
|
+
const hit=document.elementFromPoint(p.x,p.y);
|
|
67
|
+
return hit&&!el.contains(hit)?hit.tagName.toLowerCase()+(hit.id?'#'+hit.id:'')+[...hit.classList].map(c=>'.'+c).join(''):null;
|
|
68
|
+
};
|
|
69
|
+
let local={...point};
|
|
70
|
+
try {
|
|
71
|
+
for(const ancestor of ancestors){
|
|
72
|
+
const covered=await ancestor.evaluate(describeHit,local);
|
|
73
|
+
if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
|
|
74
|
+
const offset=await ancestor.evaluate(el=>{const r=el.getBoundingClientRect();return {x:r.x+el.clientLeft,y:r.y+el.clientTop};});
|
|
75
|
+
local={x:local.x-offset.x,y:local.y-offset.y};
|
|
76
|
+
}
|
|
77
|
+
const covered=await handle.evaluate(describeHit,local);
|
|
78
|
+
if(covered)throw new Error(`${surface.name}: ${selector} is covered at the click point by ${covered}`);
|
|
79
|
+
}finally{await Promise.all(ancestors.map(h=>h.dispose()));}
|
|
80
|
+
}finally{await handle.dispose();}
|
|
59
81
|
return await action(surface.frame);
|
|
60
82
|
} catch(error) {
|
|
61
83
|
if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
|
|
@@ -69,3 +91,12 @@ export async function actInApp(surface, selector, action, {ready=appReady,timeou
|
|
|
69
91
|
}
|
|
70
92
|
}
|
|
71
93
|
}
|
|
94
|
+
|
|
95
|
+
/** Install before navigation: every app iframe observes a real context creation failure. */
|
|
96
|
+
export function forceWebglFailure() {
|
|
97
|
+
for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
|
|
98
|
+
if(!proto)continue;
|
|
99
|
+
const original=proto.getContext;
|
|
100
|
+
proto.getContext=function(kind,...args){return /^(webgl2?|experimental-webgl)$/.test(kind)?null:Reflect.apply(original,this,[kind,...args]);};
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
|
|
1
2
|
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
2
3
|
import { domainOrigin } from './studio.mjs';
|
|
3
4
|
import { appProblems } from '../worker/app-format.mjs';
|
|
@@ -686,6 +687,10 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
686
687
|
// GAME PARTS: every packed version of every SHARED part into dist/parts/, and its index; a private part is never copied.
|
|
687
688
|
const partsBuilt = buildParts(root, dist, { studio, log });
|
|
688
689
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
690
|
+
if(publicMcpSetup(root)) {
|
|
691
|
+
const verified=verifiedMcpConfig(root);
|
|
692
|
+
writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
|
|
693
|
+
}
|
|
689
694
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
690
695
|
const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
|
|
691
696
|
// The Lounge (0.29.0): studio.json "lounge", checked; off when a game already has /lounge/.
|
package/lib/check.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {needsHeadless} from './needs-headless.mjs';
|
|
2
2
|
import {randomBytes} from 'node:crypto';
|
|
3
|
-
import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
|
+
import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
4
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
5
|
-
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
|
+
import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps } from './app-needs.mjs';
|
|
6
6
|
/**
|
|
7
7
|
* `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
|
|
8
8
|
* when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
|
|
@@ -132,7 +132,7 @@ export function judgeRounds(browsers, missed = new Map()) {
|
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export async function check({ isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
135
|
+
export async function check({ webglFailure = false, isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
|
|
136
136
|
if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
|
|
137
137
|
const base = String(url).replace(/\/+$/, '');
|
|
138
138
|
const play = `${base}/${game}/play`;
|
|
@@ -152,14 +152,15 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
152
152
|
if(appMeta && !appMeta.check?.action) {
|
|
153
153
|
try {
|
|
154
154
|
const plan=readAppNeeds(root,game);
|
|
155
|
-
if(plan && !plan.needs.some(n=>[...(n.steps
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
156
|
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
157
|
}
|
|
158
158
|
const chrome = findChrome();
|
|
159
159
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
160
160
|
let puppeteer;
|
|
161
161
|
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
162
|
-
if (appMeta) return checkApp({ isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
162
|
+
if (appMeta) return checkApp({ webglFailure, isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
|
|
163
|
+
if(webglFailure)return {ok:false,command:'check',why:'--webgl-failure requires an app with UI checks'};
|
|
163
164
|
if (shots) mkdirSync(shots, { recursive: true });
|
|
164
165
|
const profiles = [];
|
|
165
166
|
const browsers = [];
|
|
@@ -354,7 +355,7 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
/** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
|
|
357
|
-
export async function checkApp({ isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
358
|
+
export async function checkApp({ webglFailure = false, isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
|
|
358
359
|
let plan;
|
|
359
360
|
try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
360
361
|
const secret=localCredential(root,url);
|
|
@@ -364,13 +365,14 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
364
365
|
const proof = meta.check??{};
|
|
365
366
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
366
367
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
368
|
+
if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
|
|
367
369
|
const captureWarnings=[];
|
|
368
370
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
369
371
|
let browser;
|
|
370
372
|
const surfaces = [];
|
|
371
373
|
const room = `check-${randomBytes(10).toString('hex')}`;
|
|
372
374
|
try {
|
|
373
|
-
if(baseline || plan?.needs.some(n=>[...(n.steps
|
|
375
|
+
if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
374
376
|
const openSurface = async (name, role) => {
|
|
375
377
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
376
378
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
@@ -382,6 +384,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
382
384
|
const page=await context.newPage();
|
|
383
385
|
answerDialogs(page,name,dialogs,report);
|
|
384
386
|
await page.evaluateOnNewDocument(observeEffects);
|
|
387
|
+
if(webglFailure)await page.evaluateOnNewDocument(forceWebglFailure);
|
|
385
388
|
const [width,height]=dimensions[name];
|
|
386
389
|
await page.setViewport({width,height,isMobile:name!=='wall',hasTouch:name!=='wall'});
|
|
387
390
|
const params=new URLSearchParams(proof.params??{});params.set('room',room);params.set('role',role);params.set('surface',name==='staff'?'tablet':name==='phone-two'?'phone':name);
|
|
@@ -414,16 +417,16 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
414
417
|
}
|
|
415
418
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
416
419
|
}
|
|
417
|
-
for(const need of plan?.needs??[])for(const item of [...(need.steps
|
|
420
|
+
for(const need of plan?.needs??[])for(const item of [...needSteps(need.steps),...(need.assertions??[])]) {
|
|
418
421
|
if(!item.surface)continue;
|
|
419
422
|
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
420
423
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
421
424
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
422
425
|
}
|
|
423
|
-
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
426
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)), headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
424
427
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
425
428
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
426
|
-
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
429
|
+
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
427
430
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
|
|
428
431
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
429
432
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -595,8 +595,8 @@ export function configNames(root) {
|
|
|
595
595
|
* nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
|
|
596
596
|
* creates the one the binding names while it deploys, and the migrations run right after.
|
|
597
597
|
*/
|
|
598
|
-
export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
599
|
-
const w = runner(root, {});
|
|
598
|
+
export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
599
|
+
const w = wrangler ?? runner(root, {});
|
|
600
600
|
refreshWorkerConfig(root);
|
|
601
601
|
const names = configNames(root);
|
|
602
602
|
const steps = [];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {randomBytes,createHash} from 'node:crypto';
|
|
2
|
+
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join,resolve} from 'node:path';
|
|
5
|
+
import {runner,configNames} from './cloudflare.mjs';
|
|
6
|
+
import {siteUrl} from './studio.mjs';
|
|
7
|
+
/** D1 administration uses the owner's existing Wrangler login. No bearer enters stdout or argv. */
|
|
8
|
+
export async function connectorToken(root,{url,tools,out,revoke,days=30,execute}={}) {
|
|
9
|
+
const run=execute??(async file=>{const names=configNames(root);if(!names.d1)throw new Error('Studio database is not configured');const result=runner(root)(['d1','execute',names.d1,'--remote','--file',file]);if(result.code!==0)throw new Error('Connector token database operation failed; check the existing Wrangler login and studio database');});
|
|
10
|
+
const scratch=mkdtempSync(join(tmpdir(),'homie-connector-'));
|
|
11
|
+
const sql=async text=>{const file=join(scratch,'token.sql');writeFileSync(file,text,{mode:0o600});await run(file);};
|
|
12
|
+
const quote=value=>"'"+value.replaceAll("'","''")+"'";
|
|
13
|
+
try {
|
|
14
|
+
if(revoke){if(!/^[a-f0-9]{64}$/.test(revoke))throw new Error('Invalid connector id');await sql(`DELETE FROM mcp_kv WHERE key='connector:${revoke}';`);return {ok:true,command:'connector',revoked:revoke};}
|
|
15
|
+
if(!Array.isArray(tools)||!tools.length||tools.length>100||tools.some(t=>! /^[a-zA-Z0-9_-]{1,128}$/.test(t)))throw new Error('Provide explicit tool names with --tools (no wildcard)');
|
|
16
|
+
if(!out)throw new Error('Provide --out for the private connector configuration');
|
|
17
|
+
if(!Number.isInteger(days)||days<1||days>90)throw new Error('Expiry must be 1–90 days');
|
|
18
|
+
const origin=new URL(url??siteUrl(root)).origin;if(!origin.startsWith('https://'))throw new Error('Connector requires HTTPS');
|
|
19
|
+
const token='homie_owner_'+randomBytes(32).toString('hex');const id=createHash('sha256').update(token).digest('hex');
|
|
20
|
+
const expires=Math.floor(Date.now()/1000)+days*86400;
|
|
21
|
+
const value=JSON.stringify({origin,tools:[...new Set(tools)],expires});
|
|
22
|
+
// Reserve the destination before minting; never overwrite a credential or follow a symlink.
|
|
23
|
+
const path=resolve(root,out);writeFileSync(path,'',{flag:'wx',mode:0o600});
|
|
24
|
+
try{
|
|
25
|
+
await sql(`INSERT INTO mcp_kv(key,value,expires) VALUES('connector:${id}',${quote(value)},${expires});`);
|
|
26
|
+
writeFileSync(path,JSON.stringify({mcpServers:{studio:{url:origin+'/mcp/auth',headers:{Authorization:'Bearer '+token}}}},null,2)+'\n',{mode:0o600});
|
|
27
|
+
}catch(error){rmSync(path,{force:true});throw error;}
|
|
28
|
+
return {ok:true,command:'connector',id,file:path,expires,tools:[...new Set(tools)]};
|
|
29
|
+
}finally{rmSync(scratch,{recursive:true,force:true});}
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {localCredential} from './local-test.mjs';
|
|
5
|
+
const read=file=>{try{return JSON.parse(readFileSync(file,'utf8'));}catch{return null;}};
|
|
6
|
+
export function takeDevRestart(root,secret){
|
|
7
|
+
const file=join(root,'.studio','restart-request.json'),value=read(file);
|
|
8
|
+
if(!value)return null;
|
|
9
|
+
rmSync(file,{force:true});
|
|
10
|
+
return value.secret===secret&&typeof value.id==='string'?value.id:null;
|
|
11
|
+
}
|
|
12
|
+
export function acknowledgeDevRestart(root,id){writeFileSync(join(root,'.studio','restart-result.json'),JSON.stringify({id}),{mode:0o600});}
|
|
13
|
+
export async function requestDevRestart(root,url,{timeout=60000}={}){
|
|
14
|
+
const secret=localCredential(root,url);if(!secret)throw new Error('Restart requires this studio’s local dev credential');
|
|
15
|
+
const id=randomUUID(),file=join(root,'.studio','restart-request.json');
|
|
16
|
+
writeFileSync(file,JSON.stringify({id,secret}),{mode:0o600});
|
|
17
|
+
try{
|
|
18
|
+
const end=Date.now()+timeout;
|
|
19
|
+
while(Date.now()<end){
|
|
20
|
+
if(read(join(root,'.studio','restart-result.json'))?.id===id)return;
|
|
21
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Dev did not acknowledge restart; run the current homie-studio dev');
|
|
24
|
+
}finally{if(read(file)?.id===id)rmSync(file,{force:true});}
|
|
25
|
+
}
|
package/lib/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {takeDevRestart,acknowledgeDevRestart} from './dev-restart.mjs';
|
|
1
2
|
import {createLocalCredential} from './local-test.mjs';
|
|
2
3
|
import {workerEntry} from './worker-build.mjs';
|
|
3
4
|
import { networkInterfaces } from 'node:os';
|
|
@@ -371,7 +372,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
371
372
|
|
|
372
373
|
let child = null;
|
|
373
374
|
let ending = false;
|
|
374
|
-
let restart = null;
|
|
375
|
+
let restart = null, proofRestart = null;
|
|
375
376
|
let failed = null;
|
|
376
377
|
const lineState = {};
|
|
377
378
|
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
@@ -395,6 +396,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
395
396
|
if (busy || ending || restart || !up) return;
|
|
396
397
|
busy = true;
|
|
397
398
|
try {
|
|
399
|
+
const requested=takeDevRestart(root,localSecret);
|
|
400
|
+
if(requested){proofRestart=requested;restart='proof';child?.kill('SIGTERM');return;}
|
|
401
|
+
|
|
398
402
|
const built = builtIds(root);
|
|
399
403
|
if (!built) return; // mid-build: site/dist is being written
|
|
400
404
|
for (const g of listGames(root)) {
|
|
@@ -490,6 +494,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
490
494
|
await new Promise((r) => setTimeout(r, 500));
|
|
491
495
|
}
|
|
492
496
|
if (!up) return;
|
|
497
|
+
if(proofRestart){acknowledgeDevRestart(root,proofRestart);proofRestart=null;}
|
|
493
498
|
const game = (builtIds(root) ?? [])[0];
|
|
494
499
|
if (!game) return;
|
|
495
500
|
const bad = await socketOriginProblem(origin, game);
|
package/lib/functions-build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
1
2
|
import {callbackUrl} from '../worker/mcp-events-wire.mjs';
|
|
2
3
|
import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
|
|
3
4
|
import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
|
|
@@ -12,6 +13,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
12
13
|
const feeds=readStudio(root).mcp?.events??{};
|
|
13
14
|
if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
|
|
14
15
|
for(const [name,feed] of Object.entries(feeds)) {
|
|
16
|
+
if(feed?.schema)new CfWorkerJsonSchemaValidator().getValidator(feed.schema);
|
|
15
17
|
if(feed?.webhook)validateWebhook(name,feed.webhook);
|
|
16
18
|
if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
|
|
17
19
|
if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
|
|
@@ -37,6 +39,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
37
39
|
try {if(nextDue(parseSchedule(fn.schedule))===null)throw new Error('no matching date');}
|
|
38
40
|
catch(error) {throw new Error(`Function ${fn.name} cannot run: invalid schedule ${JSON.stringify(fn.schedule)} (${error.message})`);}
|
|
39
41
|
}
|
|
42
|
+
for(const name of fn.publish??[])if(!feeds[name]?.schema)throw new Error(`Function ${fn.name}: publish feed ${name} needs a declared schema`);
|
|
40
43
|
names.add(fn.name);
|
|
41
44
|
}
|
|
42
45
|
const worker=join(root,'site/src/worker.mjs');
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1178,8 +1178,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1178
1178
|
},
|
|
1179
1179
|
{
|
|
1180
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 {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write,
|
|
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'}}},
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, supply new write responses as observations and replan until verified; earlier public observations are saved automatically. refresh:true checks external Stripe changes using paginated expanded link lists. verify:true saves compact public config for deploy and 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'},refresh:{type:'boolean',description:'Discard saved observations and read current Stripe state (after Dashboard edits or before private webhook installation).'}}},
|
|
1183
1183
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
1184
|
run:async a=>{
|
|
1185
1185
|
const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
|
|
2
|
+
import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
|
|
3
|
+
import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
|
|
1
4
|
import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
|
|
2
5
|
import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
|
|
3
6
|
import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
|
|
@@ -823,6 +826,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
823
826
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
824
827
|
[`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
|
|
825
828
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
829
|
+
[`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
|
|
830
|
+
[`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
|
|
831
|
+
[`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
|
|
826
832
|
[`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
|
|
827
833
|
[`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
|
|
828
834
|
[`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
|
|
@@ -1048,8 +1054,23 @@ function ensureToolDatabase(root) {
|
|
|
1048
1054
|
if(existsSync(file))return null;
|
|
1049
1055
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
|
|
1050
1056
|
}
|
|
1057
|
+
function ensureAppLive(root) {
|
|
1058
|
+
const file=join(root,'site','migrations',APP_LIVE_MIGRATION_FILE);
|
|
1059
|
+
if(existsSync(file))return null;
|
|
1060
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,APP_LIVE_MIGRATION);return `site/migrations/${APP_LIVE_MIGRATION_FILE}`;
|
|
1061
|
+
}
|
|
1062
|
+
function ensureBusinessEvents(root) {
|
|
1063
|
+
const file=join(root,'site','migrations',BUSINESS_EVENTS_MIGRATION_FILE);
|
|
1064
|
+
if(existsSync(file))return null;
|
|
1065
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BUSINESS_EVENTS_MIGRATION);return `site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`;
|
|
1066
|
+
}
|
|
1067
|
+
function ensureBookingHolds(root) {
|
|
1068
|
+
const file=join(root,'site','migrations',BOOKING_HOLDS_MIGRATION_FILE);
|
|
1069
|
+
if(existsSync(file))return null;
|
|
1070
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
|
|
1071
|
+
}
|
|
1051
1072
|
export function ensureMigrations(root) {
|
|
1052
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root)].filter(Boolean);
|
|
1073
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root)].filter(Boolean);
|
|
1053
1074
|
}
|
|
1054
1075
|
|
|
1055
1076
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
9
|
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
-
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false,
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false, knownPortal }) {
|
|
11
11
|
if(scoped) {
|
|
12
12
|
const transport=api;
|
|
13
13
|
api=(method,path,params={},idempotency)=>transport(method,path,method==='POST'&&path.startsWith('/v1/payment_links/')?{...params,expand:['line_items']}:params,idempotency);
|
|
@@ -44,7 +44,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
44
44
|
const [products,prices,links]=await parallel([
|
|
45
45
|
()=>scoped?search('products'):list('/v1/products'),
|
|
46
46
|
()=>scoped?search('prices'):list('/v1/prices'),
|
|
47
|
-
()=>
|
|
47
|
+
()=>list('/v1/payment_links',scoped?{expand:['data.line_items']}:{}),
|
|
48
48
|
]);
|
|
49
49
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
50
50
|
const verifyLink = async (link, params) => {
|
|
@@ -258,9 +258,9 @@ export async function syncPaymentLinks(options) {
|
|
|
258
258
|
secret = endpoint.secret;
|
|
259
259
|
}
|
|
260
260
|
const fingerprint = hash({config, purchases:purchases?.config});
|
|
261
|
-
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
|
|
261
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && (options.catalog || has('STRIPE_SHOP_LINKS')) && !has('STRIPE_KEY');
|
|
262
262
|
if (!unchanged) {
|
|
263
|
-
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
263
|
+
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', ...(options.catalog ? {} : {STRIPE_SHOP_LINKS: JSON.stringify(config)}), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
264
264
|
if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
|
|
265
265
|
// Explicitly running connect selects keyless; manual selects the existing keyed checkout.
|
|
266
266
|
if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
|