@homie-rocks/studio 0.51.8 → 0.53.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 (69) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +68 -1
  4. package/bin/homie-studio.mjs +8 -2
  5. package/docs/shop-migration.md +9 -0
  6. package/docs/toolkit-batch.md +49 -0
  7. package/docs/toolkit-place-patterns.md +6 -0
  8. package/functions/FUNCTIONS.md +42 -1
  9. package/functions/functions.ts +4 -0
  10. package/guides/app/PLACES.md +146 -0
  11. package/guides/app/SKILL.md +3 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/lib/app-needs.mjs +59 -15
  15. package/lib/app-ready.mjs +1 -1
  16. package/lib/build.mjs +8 -2
  17. package/lib/check.mjs +40 -16
  18. package/lib/cloudflare.mjs +2 -2
  19. package/lib/dev-restart.mjs +25 -0
  20. package/lib/dev.mjs +7 -1
  21. package/lib/fork.mjs +47 -0
  22. package/lib/functions-build.mjs +3 -0
  23. package/lib/jobs.mjs +1 -0
  24. package/lib/mcp-tools.mjs +3 -3
  25. package/lib/needs-headless.mjs +4 -3
  26. package/lib/scaffold.mjs +29 -1
  27. package/lib/shop-links.mjs +7 -7
  28. package/lib/shop-mcp.mjs +88 -14
  29. package/lib/stripe-connect.mjs +46 -6
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +25 -3
  32. package/lib/upgrade-places.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/lib/worker-config.mjs +7 -3
  35. package/links/links.d.mts +5 -0
  36. package/links/links.d.ts +2 -0
  37. package/links/links.mjs +2 -0
  38. package/links/time.mjs +20 -0
  39. package/package.json +1 -1
  40. package/rules/rules.ts +1 -1
  41. package/shop/SHOP.md +27 -12
  42. package/shop/shop.ts +3 -3
  43. package/tools/tools.ts +13 -0
  44. package/worker/app-format.mjs +6 -3
  45. package/worker/app-records.mjs +21 -5
  46. package/worker/app-shell.mjs +28 -5
  47. package/worker/app-streams.mjs +57 -0
  48. package/worker/app-tools.mjs +44 -0
  49. package/worker/app-web.mjs +23 -0
  50. package/worker/booking-calendar.mjs +26 -0
  51. package/worker/booking-holds.mjs +100 -0
  52. package/worker/booking-options.mjs +23 -0
  53. package/worker/booking-resources.mjs +55 -0
  54. package/worker/build-id.mjs +1 -1
  55. package/worker/business-events.mjs +31 -0
  56. package/worker/customer-mcp.mjs +1 -1
  57. package/worker/event-resources.mjs +1 -1
  58. package/worker/functions.mjs +5 -0
  59. package/worker/index.mjs +54 -9
  60. package/worker/local-test.mjs +10 -0
  61. package/worker/mcp-events.mjs +3 -3
  62. package/worker/mcp-tools.mjs +23 -2
  63. package/worker/mcp.mjs +3 -3
  64. package/worker/office.mjs +27 -0
  65. package/worker/pages.mjs +4 -4
  66. package/worker/records-page.mjs +3 -3
  67. package/worker/shop-links.mjs +12 -0
  68. package/worker/shop.mjs +20 -5
  69. 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
package/lib/app-needs.mjs CHANGED
@@ -22,16 +22,24 @@ export function readAppNeeds(root, id) {
22
22
  return { needs, hash: createHash('sha256').update(source).digest('hex') };
23
23
  }
24
24
 
25
+ /** An action names its actor; an observation-only need prefers its phone or a matching role. */
26
+ export function primaryNeedSurface(need,roleFor=()=>undefined) {
27
+ const action=needSteps(need.steps).find(s=>s.surface)?.surface;
28
+ const assertions=(need.assertions??[]).filter(s=>s.surface);
29
+ return need.surface??action??assertions.find(s=>['phone','phone-two'].includes(s.surface))?.surface??assertions.find(s=>!need.role||roleFor(s.surface)===need.role)?.surface;
30
+ }
31
+
25
32
  /** 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 }) {
33
+ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless, restart }) {
27
34
  const results = [];
28
35
  const appBarrier=ready;
29
36
  ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
30
- let activeRole;
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; };
37
+ let activeRole, primarySurface;
38
+ const surface = (name, explicitRole) => { const role=explicitRole??(name===primarySurface?activeRole:undefined); const s=surfaces.find(s=>s.name===name&&(!role||s.role===role)); if(!s)throw new Error(role?`${name} requires role ${role}`:`unknown surface ${name}`);return s; };
32
39
  for (const need of plan.needs) {
33
40
  activeRole = need.role;
34
- const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
41
+ primarySurface = primaryNeedSurface(need,name=>surfaces.find(s=>s.name===name)?.role);
42
+ const names = [...new Set([...needSteps(need.steps), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
35
43
  const saved = new Map();
36
44
  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
45
  let waiting = 'validating assertions';
@@ -39,21 +47,36 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
39
47
  try {
40
48
  if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
41
49
  const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
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
- 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 ?? []) {
50
+ for(const item of [...needSteps(need.steps),...need.assertions])if(item.surface&&!popupNames.has(item.surface))surface(item.surface,item.role);
51
+ for(const target of new Set(need.assertions.filter(a=>a.effect).map(a=>surface(a.surface,a.role))))await target.frame.evaluate(()=>{window.__homieNeedsEffects=[];});
52
+ const perform = async rawStep => {
45
53
  const step=resolveNeedResults(rawStep,saved);
54
+ if(step.concurrent){
55
+ if(!Array.isArray(step.concurrent)||step.concurrent.length<2)throw new Error('concurrent needs at least two steps');
56
+ if(needSteps(step.concurrent).some(s=>s.restart||s.capabilities))throw new Error('Restart and capabilities changes must be outside a race');
57
+ const results=await Promise.allSettled(step.concurrent.map(perform));
58
+ const failed=results.find(r=>r.status==='rejected');if(failed)throw failed.reason;return;
59
+ }
60
+ 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;}
61
+
46
62
  if (step.mcp || step.schedule) {
47
63
  waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
48
64
  const value = await run(step);
49
65
  if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
50
- continue;
66
+ return;
51
67
  }
52
- const target=surface(step.surface);
68
+ const target=surface(step.surface,step.role);
53
69
  if(!step.reload)await ready(target);
54
70
  const { page } = target;
71
+ if(step.capabilities){
72
+ if(typeof step.capabilities.webgl!=='boolean')throw new Error('capabilities.webgl must be boolean');
73
+ await page.evaluateOnNewDocument(configureWebGL,step.capabilities.webgl);
74
+ await page.reload({waitUntil:'domcontentloaded'});await ready(target);return;
75
+ }
76
+ if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
77
+
55
78
  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; }
79
+ if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface,step.role)); return; }
57
80
  const act = fn => actInApp(target,step.selector,fn,{ready});
58
81
  if (step.click && step.popup) {
59
82
  let listener,timer;
@@ -67,11 +90,15 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
67
90
  await page.bringToFront();
68
91
  } finally {clearTimeout(timer);page.off('popup',listener);}
69
92
  }
93
+ else if(step.forceClick){await act(frame=>frame.$eval(step.selector,el=>{
94
+ const disabled=el.disabled;try{el.disabled=false;el.click();}finally{el.disabled=disabled;}
95
+ }));}
70
96
  else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
71
97
  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
98
  else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
73
99
  else throw new Error('step needs click, fill, select or reload');
74
- }
100
+ };
101
+ for(const step of need.steps??[])await perform(step);
75
102
  for (const rawAssertion of need.assertions) {
76
103
  const assertion=resolveNeedResults(rawAssertion,saved);
77
104
  if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
@@ -99,7 +126,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
99
126
  continue;
100
127
  }
101
128
  waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
102
- const target=surface(assertion.surface);
129
+ const target=surface(assertion.surface,assertion.role);
103
130
  await ready(target);
104
131
  await waitInApp(target,waiting,async frame=>{
105
132
  if (assertion.effect) {
@@ -115,9 +142,10 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
115
142
  result.why = 'Assertions passed';
116
143
  } catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
117
144
  if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
118
- if (shots) for (const name of names) {
119
- const file = join(shots, `${need.id}-${name}.jpg`);
120
- try { mkdirSync(shots,{recursive:true}); await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
145
+ if (shots) for (const target of surfaces.filter(s=>names.includes(s.name))) {
146
+ const name=target.name, suffix=surfaces.filter(s=>s.name===name).length>1?'-'+target.role:'';
147
+ const file = join(shots, `${need.id}-${name}${suffix}.jpg`);
148
+ try { mkdirSync(shots,{recursive:true}); await ready(target); await target.page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
121
149
  catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
122
150
  }
123
151
  report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
@@ -170,3 +198,19 @@ export function resolveNeedResults(value,saved) {
170
198
  }
171
199
  return value;
172
200
  }
201
+
202
+ /** Flatten only action groups; used for browser/role discovery before a race starts. */
203
+ export function needSteps(steps = []) { return steps.flatMap(s => s.concurrent ? needSteps(s.concurrent) : [s]); }
204
+ export function configureWebGL(enabled) {
205
+ window.__homieWebGLDisabled = !enabled;
206
+ if(window.__homieWebGLWrapped)return;
207
+ window.__homieWebGLWrapped = true;
208
+ for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
209
+ if(!proto)continue;
210
+ const original = proto.getContext;
211
+ proto.getContext = function(kind, ...args) {
212
+ if(window.__homieWebGLDisabled && /^(webgl2?|experimental-webgl)$/.test(kind))return null;
213
+ return Reflect.apply(original,this,[kind,...args]);
214
+ };
215
+ }
216
+ }
package/lib/app-ready.mjs CHANGED
@@ -9,7 +9,7 @@ export async function appReady(surface, {timeout=60000}={}) {
9
9
  const cover=document.querySelector('[data-arrive]');
10
10
  return window.__shell?.link?.state==='online' && window.__shell?.arrival?.phase==='done' && (!cover || (getComputedStyle(cover).opacity==='0' && getComputedStyle(cover).visibility==='hidden'));
11
11
  },{timeout:Math.max(1,end-Date.now())});
12
- const frame=surface.page.frames().find(f=>/\/__game\//.test(f.url()));
12
+ const frame=surface.page.frames().find(f=>/\/(?:__game|__assets)\//.test(f.url()));
13
13
  if(!frame)throw new Error('app frame missing');
14
14
  await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
15
15
  surface.frame=frame;return;
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';
@@ -634,7 +635,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
634
635
  const bundle = bundleOf(dir);
635
636
  builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
636
637
  return {
637
- ...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
638
+ ...(g.kind === 'app' ? { kind: 'app', ...(g.web?{web:g.web}:{}), ...(g.timeZone?{timeZone:g.timeZone}:{}), roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
638
639
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
639
640
  ...(ui ? { ui } : {}),
640
641
  // A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
@@ -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/.
@@ -693,6 +698,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
693
698
  const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
694
699
  const catalogue = {
695
700
  studio: {
701
+ ...(studio.concept===true?{concept:true}:{}),
696
702
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
697
703
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
698
704
  theme,
@@ -711,7 +717,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
711
717
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
712
718
  // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
713
719
  // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
714
- directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
720
+ directory: studio.concept===true?false:directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
715
721
  canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
716
722
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
717
723
  ...(studio.template === true ? { template: true } : {}),
package/lib/check.mjs CHANGED
@@ -2,7 +2,7 @@ import {needsHeadless} from './needs-headless.mjs';
2
2
  import {randomBytes} from 'node:crypto';
3
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, primaryNeedSurface } 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
@@ -152,7 +152,7 @@ export async function check({ webglFailure = false, isolate = true, root = null,
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({webglFailure,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();
@@ -359,28 +359,43 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
359
359
  let plan;
360
360
  try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
361
361
  const secret=localCredential(root,url);
362
- if(isolate && !secret)return {ok:false,command:'check',why:'Fresh isolated records are on by default. Run dev in this studio and check its localhost URL, or explicitly use --no-isolate.'};
362
+
363
363
  log(isolate ? 'Fresh isolated records: on (this check only).' : 'Fresh isolated records: off; using existing records.');
364
364
  const dialogs=[];
365
365
  const proof = meta.check??{};
366
366
  const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
367
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=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
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'};
369
369
  const captureWarnings=[];
370
370
  const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
371
- let browser;
371
+ let browser, ownerClient;
372
372
  const surfaces = [];
373
- const room = `check-${randomBytes(10).toString('hex')}`;
373
+ let room = `check-${randomBytes(10).toString('hex')}`;
374
374
  try {
375
- 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
+ const remoteCookies=new Map();
376
+ const sessionFor=async role=>{
377
+ if(secret)return {name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null})};
378
+ if(!isolate)return null;
379
+ ownerClient??=await (await import('./tools-cli.mjs')).connectToolClient(root,url,{log});
380
+ const key=room+':'+role;
381
+ if(!remoteCookies.has(key)) {
382
+ const result=await ownerClient.callTool({name:'studio_check_session',arguments:{app:game,role,scope:room}});
383
+ if(result.isError)throw new Error(result.content?.[0]?.text??'Cannot create isolated check session');
384
+ const parsed=result.structuredContent??JSON.parse(result.content.find(c=>c.type==='text').text),data=parsed.data??parsed;
385
+ if(!data.token)throw new Error('Studio did not return its test-session token');
386
+ remoteCookies.set(key,{name:'homie_check',value:data.token});
387
+ }
388
+ return remoteCookies.get(key);
389
+ };
390
+ 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() });
376
391
  const openSurface = async (name, role) => {
377
392
  const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
378
393
  if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
379
394
  role ??= meta.surfaces?.[name==='phone-two'?'phone':name] ?? (name==='staff'?'staff':undefined);
380
395
  if(!meta.roles?.[role])throw new Error(`${name}: unknown role ${role}`);
381
- if(meta.roles[role].signIn && !secret)throw new Error(`${name}: local staff proof needs dev's test session`);
396
+ if(meta.roles[role].signIn && !secret && !isolate)throw new Error(`${name}: local staff proof needs dev's test session`);
382
397
  const context=await browser.createBrowserContext();
383
- if(secret)await context.setCookie({name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null}),url,httpOnly:true,sameSite:'Strict'});
398
+ const cookie=await sessionFor(role);if(cookie)await context.setCookie({...cookie,url,httpOnly:true,sameSite:'Strict',secure:new URL(url).protocol==='https:'});
384
399
  const page=await context.newPage();
385
400
  answerDialogs(page,name,dialogs,report);
386
401
  await page.evaluateOnNewDocument(observeEffects);
@@ -417,16 +432,25 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
417
432
  }
418
433
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
419
434
  }
420
- for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
421
- if(!item.surface)continue;
422
- if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
423
- const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
424
- if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
435
+ const needs=plan?[]:null;
436
+ for(const need of plan?.needs??[]) {
437
+ // Baseline and every need get separate contexts, rooms and record namespaces.
438
+ for(const context of new Set(surfaces.map(surface=>surface.page.browserContext())))await context.close();
439
+ surfaces.length=0;
440
+ room=`check-${randomBytes(10).toString('hex')}`;
441
+ const declaredRole=name=>meta.surfaces?.[name==='phone-two'?'phone':name]??(name==='staff'?'staff':undefined);
442
+ const primary=primaryNeedSurface(need,declaredRole);
443
+ log(`Need ${need.id}: fresh room ${room}; records ${isolate?'isolated':'shared (--no-isolate)'}.`);
444
+ for(const item of [...needSteps(need.steps),...(need.assertions??[])]) {
445
+ if(!item.surface || (need.steps??[]).some(s=>s.popup===item.surface))continue;
446
+ const role=item.role??(item.surface===primary?need.role:undefined)??declaredRole(item.surface);
447
+ if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
448
+ }
449
+ needs.push(...await proveAppNeeds({plan:{...plan,needs:[need]},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,sessionFor})}));
425
450
  }
426
- const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
427
451
  if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
428
452
  const ok = !needs || needs.every(n => n.status === 'met');
429
453
  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 };
430
454
  } catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
431
- finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
455
+ finally { await ownerClient?.close().catch(()=>{}); await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
432
456
  }
@@ -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,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';
@@ -299,6 +300,7 @@ export function rulesStamp(g) {
299
300
  const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
300
301
  if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
301
302
  const file=join(dir,entry.name);
303
+ if(file===join(g.dir,'CODEX.md'))continue; // check writes authoring evidence, never runtime inputs
302
304
  if(entry.isDirectory())walk(file);
303
305
  else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
304
306
  }};
@@ -371,7 +373,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
371
373
 
372
374
  let child = null;
373
375
  let ending = false;
374
- let restart = null;
376
+ let restart = null, proofRestart = null;
375
377
  let failed = null;
376
378
  const lineState = {};
377
379
  // Stopping this process stops Wrangler with it, so no dev server is left running on its own.
@@ -395,6 +397,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
395
397
  if (busy || ending || restart || !up) return;
396
398
  busy = true;
397
399
  try {
400
+ const requested=takeDevRestart(root,localSecret);
401
+ if(requested){proofRestart=requested;restart='proof';child?.kill('SIGTERM');return;}
402
+
398
403
  const built = builtIds(root);
399
404
  if (!built) return; // mid-build: site/dist is being written
400
405
  for (const g of listGames(root)) {
@@ -490,6 +495,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
490
495
  await new Promise((r) => setTimeout(r, 500));
491
496
  }
492
497
  if (!up) return;
498
+ if(proofRestart){acknowledgeDevRestart(root,proofRestart);proofRestart=null;}
493
499
  const game = (builtIds(root) ?? [])[0];
494
500
  if (!game) return;
495
501
  const bad = await socketOriginProblem(origin, game);
package/lib/fork.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import {existsSync,mkdirSync,readdirSync,readFileSync,writeFileSync,copyFileSync,rmSync} from 'node:fs';
2
+ import {resolve,join,dirname,extname,relative} from 'node:path';
3
+ import {refreshWorkerConfig} from './worker-config.mjs';
4
+ /** Copy editable source, never account state, installed code or a deployment. */
5
+ export async function forkStudio(source,target,{name,slug,ids={}}={}) {
6
+ source=resolve(source);target=resolve(target);
7
+ if(existsSync(target))throw new Error('Fork target already exists');
8
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(slug??'')||typeof name!=='string'||!name.trim())throw new Error('Supply name and a lowercase slug (1–40 letters, digits and hyphens)');
9
+ if(!relative(source,target).startsWith('..'))throw new Error('Fork target must be outside the example');
10
+ const studio=JSON.parse(readFileSync(join(source,'studio.json'),'utf8'));
11
+ const excluded=new Set(['node_modules','.git','.studio','dist','.wrangler','.DS_Store','wrangler.json','wrangler.jsonc','wrangler.toml','wrangler.custom.json','package-lock.json']);
12
+ const files=[];
13
+ function walk(dir){for(const entry of readdirSync(join(source,dir),{withFileTypes:true})) {
14
+ if(excluded.has(entry.name)||entry.name.startsWith('.')&&entry.name!=='.gitignore'||entry.name.startsWith('.dev.vars')||entry.name.endsWith('.sqlite')||entry.name.endsWith('.db')||entry.name.endsWith('.pem')||entry.name.endsWith('.key')||entry.isSymbolicLink())continue;
15
+ const path=join(dir,entry.name);if(entry.isDirectory())walk(path);else if(entry.isFile())files.push(path);
16
+ }}walk('');
17
+ const mapping=new Map([[studio.slug,slug],[studio.name,name]]);
18
+ const collect=value=>{if(!value||typeof value!=='object')return;if(typeof value.id==='string'&&/^[a-z][a-z0-9-]*$/.test(value.id))mapping.set(value.id,ids[value.id]??`${slug}-${value.id}`);for(const child of Object.values(value))collect(child);};
19
+ for(const file of files.filter(p=>p.endsWith('.json')&&!['studio.json','package.json'].includes(p)))try{collect(JSON.parse(readFileSync(join(source,file),'utf8')));}catch{}
20
+ const ts=(await import('typescript')).default;
21
+ for(const file of files.filter(p=>/\.(?:[cm]?js|tsx?)$/.test(p))) {
22
+ const tree=ts.createSourceFile(file,readFileSync(join(source,file),'utf8'),ts.ScriptTarget.Latest,true);
23
+ const visit=node=>{
24
+ if(ts.isPropertyAssignment(node)&&(ts.isIdentifier(node.name)||ts.isStringLiteral(node.name))&&node.name.text==='id'&&ts.isStringLiteral(node.initializer))collect({id:node.initializer.text});
25
+ ts.forEachChild(node,visit);
26
+ };visit(tree);
27
+ }
28
+ for(const [old,next] of Object.entries(ids))mapping.set(old,next);
29
+ for(const [old,next] of mapping)if(typeof next!=='string'||!next.length)throw new Error(`Invalid replacement for ${old}`);
30
+ for(const [old,next] of mapping)if(old!==studio.name&&(!/^[a-z][a-z0-9-]{0,39}$/.test(next)))throw new Error(`Renamed id ${next} exceeds 40 characters; supply ids.${old}`);
31
+ const escape=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
32
+ const pattern=new RegExp(`(?<![A-Za-z0-9_-])(?:${[...mapping.keys()].filter(Boolean).sort((a,b)=>b.length-a.length).map(escape).join('|')})(?![A-Za-z0-9_-])`,'g');
33
+ const rewrite=s=>s.replace(pattern,value=>mapping.get(value));
34
+ mkdirSync(target);
35
+ try {
36
+ for(const file of files) {
37
+ const to=join(target,...file.split('/').map(part=>mapping.get(part)??part));mkdirSync(dirname(to),{recursive:true});
38
+ if(['.json','.ts','.js','.mjs','.html','.css','.md','.sql','.txt','.svg','.yaml','.yml'].includes(extname(file)))writeFileSync(to,rewrite(readFileSync(join(source,file),'utf8')));else copyFileSync(join(source,file),to);
39
+ }
40
+ const fresh=JSON.parse(readFileSync(join(target,'studio.json'),'utf8'));fresh.name=name;fresh.slug=slug;delete fresh.github;delete fresh.template;
41
+ fresh.cloudflare={worker:slug,d1:slug+'-db',created:[]};fresh.homie={...fresh.homie,directory:false};
42
+ writeFileSync(join(target,'studio.json'),JSON.stringify(fresh,null,2)+'\n');
43
+ const pkg=JSON.parse(readFileSync(join(target,'package.json'),'utf8'));pkg.name=slug;delete pkg.repository;writeFileSync(join(target,'package.json'),JSON.stringify(pkg,null,2)+'\n');
44
+ refreshWorkerConfig(target);
45
+ return {ok:true,command:'fork',root:target,renamed:Object.fromEntries(mapping),next:['npm install','homie-studio check']};
46
+ }catch(error){rmSync(target,{recursive:true,force:true});throw error;}
47
+ }
@@ -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/jobs.mjs CHANGED
@@ -158,6 +158,7 @@ export function jobView(job, { lines = 12 } = {}) {
158
158
  startedAt: job.startedAt, endedAt: job.endedAt,
159
159
  seconds: Math.round(((job.endedAt ? Date.parse(job.endedAt) : Date.now()) - Date.parse(job.startedAt)) / 1000),
160
160
  tail: text.slice(-lines), log: job.log, result: job.result,
161
+ events: job.err.split('\n').filter(line=>line.startsWith('HOMIE_EVENT ')).flatMap(line=>{try{return [JSON.parse(line.slice(12))];}catch{return [];}}),
161
162
  };
162
163
  }
163
164
 
package/lib/mcp-tools.mjs CHANGED
@@ -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');
@@ -1195,7 +1195,7 @@ export function toolDefs(ctx, avail = {}) {
1195
1195
  run:async a=>{
1196
1196
  const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1197
1197
  await waitJob(job,Math.min(ctx.waitMs,1000));
1198
- return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
1198
+ return ok('Follow studio_job. Its events contain any required Stripe pairing; a saved login needs no new approval.',{kind:'job',...jobView(job)});
1199
1199
  },
1200
1200
  },
1201
1201
  {
@@ -1,13 +1,14 @@
1
1
  import {localCookie} from './local-test.mjs';
2
2
 
3
3
  /** Each action has an explicit local identity, using the real MCP transport and permissions. */
4
- export function needsHeadless({url,secret,app,meta,scope}) {
4
+ export function needsHeadless({url,secret,app,meta,scope,sessionFor}) {
5
5
  return async (item,role) => {
6
- if(!secret)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
6
+ if(!secret&&!sessionFor)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
7
7
  if(!role || (role!=='owner'&&!Object.hasOwn(meta.roles??{},role)))throw new Error(`Name a declared role (or owner) for headless proof; got ${role??'none'}`);
8
8
  const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
9
9
  const client=new Client({name:'Homie needs check',version:'1.0.0'});
10
- const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
10
+ const cookie=sessionFor?await sessionFor(role):{name:'homie_test',value:localCookie(secret,{app,role,scope})};
11
+ const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{...(secret?{authorization:`Bearer ${secret}`} : {}),...(cookie?{cookie:`${cookie.name}=${cookie.value}`}:{})}}});
11
12
  try {
12
13
  await client.connect(transport);
13
14
  let call=item.mcp;
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
2
+ import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
3
+ import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
4
+ import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
1
5
  import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
2
6
  import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
3
7
  import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
@@ -823,6 +827,10 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
823
827
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
824
828
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
825
829
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
830
+ [`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
831
+ [`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
832
+ [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
833
+ [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
826
834
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
827
835
  [`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
828
836
  [`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
@@ -1048,8 +1056,28 @@ function ensureToolDatabase(root) {
1048
1056
  if(existsSync(file))return null;
1049
1057
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
1050
1058
  }
1059
+ function ensureAppLive(root) {
1060
+ const file=join(root,'site','migrations',APP_LIVE_MIGRATION_FILE);
1061
+ if(existsSync(file))return null;
1062
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,APP_LIVE_MIGRATION);return `site/migrations/${APP_LIVE_MIGRATION_FILE}`;
1063
+ }
1064
+ function ensureBusinessEvents(root) {
1065
+ const file=join(root,'site','migrations',BUSINESS_EVENTS_MIGRATION_FILE);
1066
+ if(existsSync(file))return null;
1067
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BUSINESS_EVENTS_MIGRATION);return `site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`;
1068
+ }
1069
+ function ensureBookingHolds(root) {
1070
+ const file=join(root,'site','migrations',BOOKING_HOLDS_MIGRATION_FILE);
1071
+ if(existsSync(file))return null;
1072
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
1073
+ }
1074
+ function ensureBookingResources(root) {
1075
+ const file=join(root,'site','migrations',BOOKING_RESOURCES_MIGRATION_FILE);
1076
+ if(existsSync(file))return null;
1077
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
1078
+ }
1051
1079
  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);
1080
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root)].filter(Boolean);
1053
1081
  }
1054
1082
 
1055
1083
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */