@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/bin/homie-studio.mjs +9 -4
  5. package/docs/toolkit-place-patterns.md +68 -0
  6. package/functions/FUNCTIONS.md +27 -0
  7. package/functions/functions.ts +4 -0
  8. package/guides/app/PLACES.md +123 -0
  9. package/guides/app/SKILL.md +11 -0
  10. package/guides/app/examples/reserve.mjs +13 -0
  11. package/guides/functions/SKILL.md +2 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/guides/studio-setup/SKILL.md +4 -0
  15. package/lib/app-needs.mjs +41 -6
  16. package/lib/app-ready.mjs +31 -0
  17. package/lib/build.mjs +5 -0
  18. package/lib/check.mjs +13 -10
  19. package/lib/cloudflare.mjs +2 -2
  20. package/lib/connector-token.mjs +30 -0
  21. package/lib/dev-restart.mjs +25 -0
  22. package/lib/dev.mjs +6 -1
  23. package/lib/functions-build.mjs +3 -0
  24. package/lib/mcp-tools.mjs +2 -2
  25. package/lib/scaffold.mjs +22 -1
  26. package/lib/shop-links.mjs +4 -4
  27. package/lib/shop-mcp.mjs +82 -14
  28. package/lib/stripe-connect.mjs +9 -4
  29. package/lib/template-history.json +84 -0
  30. package/lib/upgrade-places.mjs +49 -0
  31. package/lib/upgrade.mjs +2 -1
  32. package/lib/worker-config.mjs +6 -3
  33. package/links/links.d.mts +5 -0
  34. package/links/links.d.ts +2 -0
  35. package/links/links.mjs +2 -0
  36. package/links/time.mjs +20 -0
  37. package/netplay/netplay.ts +3 -0
  38. package/package.json +1 -1
  39. package/rules/collision.ts +4 -2
  40. package/rules/terrain.ts +2 -1
  41. package/rules/tiles.ts +8 -4
  42. package/shop/SHOP.md +27 -12
  43. package/shop/shop.ts +1 -1
  44. package/tools/tools.ts +12 -0
  45. package/worker/app-graphics.mjs +8 -0
  46. package/worker/app-records.mjs +5 -1
  47. package/worker/app-shell.mjs +61 -17
  48. package/worker/app-streams.mjs +57 -0
  49. package/worker/app-tools.mjs +44 -0
  50. package/worker/booking-holds.mjs +87 -0
  51. package/worker/build-id.mjs +1 -1
  52. package/worker/business-events.mjs +31 -0
  53. package/worker/connector-token.mjs +11 -0
  54. package/worker/event-resources.mjs +1 -1
  55. package/worker/functions.mjs +5 -0
  56. package/worker/index.mjs +24 -6
  57. package/worker/join-area.mjs +35 -0
  58. package/worker/mcp-events.mjs +2 -2
  59. package/worker/mcp-tools.mjs +3 -0
  60. package/worker/mcp.mjs +17 -9
  61. package/worker/pages.mjs +10 -7
  62. package/worker/records-page.mjs +5 -4
  63. package/worker/records-prefs.mjs +24 -0
  64. package/worker/shop-links.mjs +12 -0
  65. package/worker/shop.mjs +14 -4
  66. package/worker/version.mjs +1 -1
@@ -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. Existing links are fetched by
53
- IDs from the verified configuration; Stripe has no Payment Link metadata search.
54
- Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
55
- it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
56
- window, recover the studio's existing link IDs before setup to avoid duplicates.
57
- Follow search `next_page` cursors. Search is eventually consistent: write responses
58
- are the immediate evidence. On an uncertain write, retry its exact idempotency key;
59
- never discard successful responses or invent IDs. Start a new read set when checking
60
- later external edits, changing accounts, or changing mode.
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
- Deploy installs those links. Checkout then reports only `webhook-secret` missing;
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 and verified
72
- links through Wrangler stdin. The CLI needs one separate browser approval if its
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 ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
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
- for (const rawStep of need.steps ?? []) {
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
- continue;
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)); continue; }
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??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
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??[]),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
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??[]),...(need.assertions??[])]) {
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
  }
@@ -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);
@@ -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, retain write responses as observations and replan until verified. 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'}}},
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). */
@@ -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, knownLinks = [], knownPortal }) {
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
- ()=>scoped?parallel([...new Set(knownLinks)].map(id=>()=>api('GET',`/v1/payment_links/${id}`,{expand:['line_items']}))):list('/v1/payment_links'),
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.' };