@homie-rocks/studio 0.52.0 → 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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +8 -2
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/lib/app-needs.mjs +19 -10
  9. package/lib/app-ready.mjs +1 -1
  10. package/lib/build.mjs +3 -2
  11. package/lib/check.mjs +37 -13
  12. package/lib/dev.mjs +1 -0
  13. package/lib/fork.mjs +47 -0
  14. package/lib/jobs.mjs +1 -0
  15. package/lib/mcp-tools.mjs +1 -1
  16. package/lib/needs-headless.mjs +4 -3
  17. package/lib/scaffold.mjs +8 -1
  18. package/lib/shop-links.mjs +3 -3
  19. package/lib/shop-mcp.mjs +6 -0
  20. package/lib/stripe-connect.mjs +37 -2
  21. package/lib/template-history.json +42 -0
  22. package/lib/tools-cli.mjs +25 -3
  23. package/lib/worker-config.mjs +1 -0
  24. package/package.json +1 -1
  25. package/rules/rules.ts +1 -1
  26. package/shop/shop.ts +3 -3
  27. package/tools/tools.ts +3 -2
  28. package/worker/app-format.mjs +6 -3
  29. package/worker/app-records.mjs +16 -4
  30. package/worker/app-shell.mjs +1 -1
  31. package/worker/app-tools.mjs +1 -1
  32. package/worker/app-web.mjs +23 -0
  33. package/worker/booking-calendar.mjs +26 -0
  34. package/worker/booking-holds.mjs +18 -5
  35. package/worker/booking-options.mjs +23 -0
  36. package/worker/booking-resources.mjs +55 -0
  37. package/worker/build-id.mjs +1 -1
  38. package/worker/customer-mcp.mjs +1 -1
  39. package/worker/index.mjs +36 -6
  40. package/worker/local-test.mjs +10 -0
  41. package/worker/mcp-events.mjs +2 -2
  42. package/worker/mcp-tools.mjs +20 -2
  43. package/worker/office.mjs +27 -0
  44. package/worker/records-page.mjs +2 -2
  45. package/worker/shop.mjs +6 -1
  46. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,33 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.53.0] - 2026-10-15
21
+
22
+ **Plugin 0.53.0**
23
+
24
+ Take bookings against real resources, keep private apps usable, collect deposits reliably and turn tested examples into your own place.
25
+
26
+ ### Added
27
+
28
+ - **Bookings that fit your place:** Reserve shared resources alongside booking capacity, allocate packages, and use seasonal calendars in your place's time zone. Shared phone controls let customers choose booking options and resources; common booking argument aliases work across tools.
29
+ - **Keep upcoming slots ready to sell:** Declared `booking.slots_need_payment_links` events let your studio respond when rolling slot inventory needs payment links.
30
+ - **Apps customers can install:** Public apps can offer a direct install page with a web manifest and optional service worker. Add your own icons and offline behavior; installation support depends on the browser.
31
+ - **A quicker start from an example:** Fork editable source with new studio and app identities and fresh deployment settings. A single concept flag marks previews with a TEST / CONCEPT banner and keeps them out of the directory and search indexing.
32
+ - **Turn-based games without unnecessary physics:** Participants can have private roles and timed phases without needing physics bodies. Private fields remain visible only to their owning player.
33
+ - **Simpler Stripe setup:** `shop observe` accepts incremental public responses from Stripe's official plugin and returns the next setup plan without a hand-built observations file. New Stripe sign-ins show pairing instructions in the job's progress before approval completes.
34
+
35
+ ### Fixed
36
+
37
+ - **Deposits tied to the right booking:** Supporter and deposit links keep quantity at one. Variable deposits retain the app reference and require the saved subtotal before fulfillment; booking and AI checkout also preserve the reference in the saved order.
38
+ - **Private apps that load safely:** Authorized private app frames can load their modules through expiring asset links while anonymous modules and records stay closed. Missing record IDs explain exactly where to supply the ID.
39
+ - **Checks that reflect each screen's role:** Wall and phone permissions remain separate, each need starts in a fresh room after the baseline, and saving check evidence no longer restarts development. Brief Cloudflare startup failures retry with a visible notice.
40
+ - **Smoother shop upgrades:** Existing billing portals are discovered when older verifications lack their IDs. Webhook setup fills missing account IDs after checking link ownership with the saved Stripe login; reused logins no longer show unnecessary pairing instructions.
41
+
42
+ ### Upgrade notes
43
+
44
+ - Apply `0030_booking_resources.sql` after the existing booking migrations to use resource-aware bookings.
45
+ - Concept mode labels a preview; it does not isolate payment credentials or data. Installed public app surfaces run as same-origin studio code; private app shells keep their sandbox.
46
+
20
47
  ## [0.52.0] - 2026-10-15
21
48
 
22
49
  **Plugin 0.52.0**
package/apps/apps.ts CHANGED
@@ -76,3 +76,20 @@ export function createAppContext() {
76
76
  dispose() {removeEventListener('message',receive);listeners.clear();}
77
77
  };
78
78
  }
79
+
80
+ /** Native accessible choices; submit value() to the same booking tool used by an AI. */
81
+ export function createBookingChoices({container,options=[],resources=[]}:{container:HTMLElement;options?:Array<{id:string;name:string}>;resources?:Array<{id:string;name:string}>}) {
82
+ const fields=new Map<string,HTMLSelectElement>();
83
+ const group=document.createElement('fieldset');
84
+ const legend=document.createElement('legend');legend.textContent='Choose an option';group.append(legend);
85
+ for(const [name,choices] of [['option',options],['resource',resources]] as const) {
86
+ if(!choices.length)continue;
87
+ const label=document.createElement('label');label.textContent=name==='option'?'Option':'Resource';
88
+ const select=document.createElement('select');select.name=name;
89
+ const empty=document.createElement('option');empty.value='';empty.textContent=name==='option'?'Choose an option':'Any available resource';select.append(empty);
90
+ for(const choice of choices){const option=document.createElement('option');option.value=choice.id;option.textContent=choice.name;select.append(option);}
91
+ label.append(select);group.append(label);fields.set(name,select);
92
+ }
93
+ container.append(group);
94
+ return {value:():{option?:string;resource?:string}=>Object.fromEntries([...fields].filter(([,select])=>select.value).map(([name,select])=>[name,select.value])),dispose:()=>group.remove()};
95
+ }
@@ -911,6 +911,7 @@ async function main() {
911
911
  }
912
912
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
913
913
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
914
+ if(cmd==='fork')return (await import('../lib/fork.mjs')).forkStudio(positional[1],positional[2],{name:flags.get('name'),slug:flags.get('slug'),ids:flags.get('ids')?JSON.parse(flags.get('ids')):{}});
914
915
  const root = requireStudio();
915
916
  // Keep guidance visible to AI callers using --json as well as human output.
916
917
  if (['build', 'check', 'upgrade'].includes(cmd)) {
@@ -1111,18 +1112,23 @@ async function main() {
1111
1112
  if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), giving: flags.has('giving'), currency: flags.get('currency') ?? null, price: flags.get('price') ?? 500, managed: flags.has('managed') });
1112
1113
  if(sub==='listen')return (await import('../lib/shop-listen.mjs')).shopListen(root,{profile:flags.get('profile'),port:flags.get('port')??8787,log});
1113
1114
  if (sub === 'check') return shopCheck(root);
1115
+ if(sub==='observe') {
1116
+ const {recordShopObservations}=await import('../lib/shop-mcp.mjs');
1117
+ const input=JSON.parse(readFileSync(0,'utf8'));
1118
+ return recordShopObservations(root,Array.isArray(input)?{observations:input}:input);
1119
+ }
1114
1120
  if (sub === 'plan' || sub === 'verify') {
1115
1121
  const {shopMcpPlan,saveMcpVerification}=await import('../lib/shop-mcp.mjs');
1116
1122
  const file=flags.get('have');
1117
1123
  const input=file?JSON.parse(readFileSync(file==='-'?0:file,'utf8')):{};
1118
1124
  return (sub==='verify'?saveMcpVerification:shopMcpPlan)(root,input);
1119
1125
  }
1120
- if (sub === 'webhook') return shopConnect(root,{webhookOnly:true,live:flags.has('live'),renew:flags.has('renew'),profile:flags.get('profile'),log:line=>process.stderr.write(`${line}\n`)});
1126
+ if (sub === 'webhook') return shopConnect(root,{onEvent:event=>process.stderr.write('HOMIE_EVENT '+JSON.stringify(event)+'\n'),webhookOnly:true,live:flags.has('live'),renew:flags.has('renew'),profile:flags.get('profile'),log:line=>process.stderr.write(`${line}\n`)});
1121
1127
  if (sub === 'connect' && flags.has('local')) {
1122
1128
  if(flags.has('live'))throw new Error('Local checkout requires test mode');
1123
1129
  return (await import('../lib/shop-local.mjs')).shopConnectLocal(root,{url:flags.get('url'),profile:flags.get('profile')});
1124
1130
  }
1125
- if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1131
+ if (sub === 'connect') return shopConnect(root, { onEvent:event=>process.stderr.write('HOMIE_EVENT '+JSON.stringify(event)+'\n'), managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1126
1132
  if (sub === 'disconnect') return shopDisconnect(root);
1127
1133
  if (sub === 'catalog') {
1128
1134
  const from = flags.get('have');
@@ -0,0 +1,9 @@
1
+ # Keyless shop setup and migration
2
+
3
+ Use `stripe_shop_plan` with Stripe's official plugin to perform the returned operations. Supply each new response as `{method,path,params,body}` in `observations`; earlier responses are saved automatically. Product and price discovery uses metadata searches. Payment Links use Stripe's paginated list API with expanded line items, then match studio, origin and revision metadata locally (Stripe has no Payment Link search endpoint). No creation is planned before discovery completes.
4
+
5
+ For CLI workflows, pipe an array of these responses, or `{observations:[...]}`, into `homie-studio shop observe`. It saves the responses and returns the next dependency batch. `shop plan` resumes without `--have`. Never include credentials or webhook creation responses.
6
+
7
+ Old verifications without a billing portal ID discover and reuse the existing portal before planning creation. Old verifications without an account ID adopt the official CLI's signed-in account only after reading and matching their saved Payment Links. A known different account is still refused. Verification freshness and configuration checks still apply.
8
+
9
+ `stripe_webhook` returns a job. Read `studio_job`: while browser approval is pending, `events` contains `{type:"stripe.pairing",code,url}`. Show that code and URL to the owner. A saved valid Stripe login emits no pairing event and needs no new approval. Secrets never enter the event.
@@ -0,0 +1,49 @@
1
+ # App references, private frames and checks
2
+
3
+ `shop.book(item, {collection, slot}, quantity, {reference})` accepts your app's booking reference. `studio_checkout` over MCP accepts `reference` alongside `lines`, `game` and optional `booking: {collection,slot}`. The saved order and `order.paid` event retain this value. It identifies your record; it is not proof of payment. Confirm the signed payment before fulfilling it.
4
+
5
+ HTTP record creation puts the ID in `/<app>/api/app/records/<collection>/<id>` and fields in `{data:{...}}`. The `app_records` MCP tool and `ctx.records` put `id` at the top level of their arguments, alongside `app` and `collection`. A missing HTTP ID now returns the exact URL format. IDs use 1–64 letters, digits, underscores or hyphens.
6
+
7
+ Private apps authorize the owner page first, then give its sandboxed frame a signed asset path valid for five minutes. Relative modules and chunks retain that path, without the owner's cookie. Asset capabilities cannot read records, call tools or authorize room sockets. Reload the owner page for a new path after expiry. Responses are private and uncached; unsigned private modules and records stay closed.
8
+
9
+ `check` gives the baseline action and every Codex need separate browser contexts and fresh rooms. Isolated record namespaces follow those rooms. `check --url https://studio.example` uses the saved owner MCP connection to mint 15-minute app-role sessions. These sessions cannot grant global owner access or access another app, and their record writes stay in the test namespace. Test slots never emit production payment-link work. With `--no-isolate`, records are explicitly shared. A need's `role` applies to its primary surface (`surface`, the first action’s surface, or the phone/matching role in observation-only needs); other surfaces retain their declared roles. Use an individual step's `role` for an explicit override. `--shots <directory>` captures passing and failing UI needs.
10
+
11
+ `dev` ignores changes to the authoring `CODEX.md` when detecting runtime edits, so saving needs evidence does not interrupt an active check. It reports when its requested port is busy and gives the actual URL. `check` without `--url` follows the running studio's registered dev URL. MCP connections retry a brief Cloudflare 1042 startup response up to three times and report each retry. Other errors return normally. Records-only apps do not inherit game-player cost estimates.
12
+
13
+ For turn-based games, a `player:true` entity can omit `body` and movement code. Declare its hidden role with `f.text(32, {visibility:'owner'})`; use `visibility:'server'` for secrets no client should receive. Keep hidden roles out of public shared fields and effects. The server projects snapshots for the receiving seat, including spectators. Use `room.start` and `room.on` with `world.after(ticks, event, data)` for phase deadlines; timers belong to the room and require no physics body. The normal server host remains required for secrecy.
14
+
15
+ Set one studio flag, `"concept":true`, and build for a concept preview. The site returns no directory manifest, disallows indexing and displays a fixed TEST / CONCEPT banner on HTML surfaces. Remove the flag and rebuild to leave concept mode. This labels a test site; it does not sandbox payment credentials or data.
16
+
17
+ A public records-only app can opt into a direct install surface:
18
+
19
+ ```json
20
+ {"web":{"manifest":{"name":"Guest app","short_name":"Guest","icons":[]},"serviceWorker":"sw.js"}}
21
+ ```
22
+
23
+ Place the service worker in the app's public files. `/app-id/install` serves the compiled app directly, with a manifest and optional service-worker registration. Records and app-tool helpers use the direct bridge. Add suitable icons and implement the offline behaviour in your service worker. The installed surface runs as same-origin studio code; signed-in/private app shells retain their sandbox. The manifest scope is the app's address, and installation support depends on the browser.
24
+
25
+ Fork editable example source in one command:
26
+
27
+ ```sh
28
+ homie-studio fork /path/to/example /path/to/new-place --name 'New Place' --slug new-place
29
+ ```
30
+
31
+ The command renames the studio, app/game IDs, record/slot IDs declared in JSON or JavaScript/TypeScript objects and matching source references. Use `--ids '{"old-id":"new-id"}'` for explicit overrides or long names. It excludes local state, credentials, dependencies, generated output and deployment identities, then generates a fresh Worker configuration. It refuses an existing destination and leaves the example unchanged. Install dependencies and run the returned check before publishing.
32
+
33
+ Supporter and deposit Payment Links have quantity fixed at one. For a variable deposit, declare `kind:"deposit", price:"choose"`, then call `shop.buy(id, 1, amount, {reference})`. Amounts use currency minor units. The link prefills the saved amount and carries the order ID, which maps back to the app reference. A signed payment with a different subtotal does not fulfill the order. Compute and validate the amount in the app's trusted booking operation and compare the payment event to its saved booking amount before confirmation; a buyer-supplied reference alone grants no authority.
34
+
35
+ Declare a function or AI event feed for `booking.slots_need_payment_links`. For example, in `studio.json`:
36
+
37
+ ```json
38
+ {"mcp":{"events":{"slot-links":{"event":"booking.slots_need_payment_links","audience":"owner"}}}}
39
+ ```
40
+
41
+ Creating or updating a booking's slot record without a matching capacity link inserts the event in the same database transaction. Its `slots` array contains `app`, booking `collection`, slot collection `slots`, `slot`, `item` and `capacity`. The owner's AI reads the full published slot inventory, calls `stripe_shop_plan`, performs its operations through Stripe's official plugin, records responses, verifies, then builds the updated public configuration. Never create webhooks through model-visible plugin calls. Unsubscribed events are not retained. Free items require no payment link.
42
+
43
+ Resource-aware bookings declare `booking: {slots:"slots", item:"ticket", resources:"resources"}`. Resource records hold `capacity`, optional `skills` arrays and `attributes` objects. Slots hold ISO `start`/`end`, candidate `resources`, and optional required `skills`/`attributes`. `ctx.bookings.hold` accepts `resource` (preferred resource), `option` and `addons`. An option can contain `components`, each naming candidate `resources`, optional `skills`/`attributes`, `units`, `offsetMinutes` and `durationMinutes`. Every component consumes capacity; overlapping allocations are checked atomically by the database. Release frees them. Editing an allocated resource or moving an allocated booking is refused: release and allocate the replacement deliberately. Resource checkout requires the allocated hold ID, so a raw checkout cannot bypass capacity. Paid exclusive holds still require session checkout; keyless inventory cannot share an exclusive unpaid hold.
44
+
45
+ Set the app's IANA `timeZone`, then use `ctx.bookings.calendar({app,startDate,endDate,time,days,capacity})` for an inclusive season of multi-day slots. `durationMinutes` handles shorter slots; `weekdays` uses Sunday=0 through Saturday=6. Persist the returned records through `ctx.records`. DST gaps and ambiguous wall times are refused; supply an explicit offset timestamp for such exceptional slots.
46
+
47
+ The public `studio_book` MCP tool uses the same hold operation. It accepts `time` as `slot`, `preferredResource`/`preferred_resource` as `resource`, and `addons`, `addOns` or `add-ons` as either an array of IDs or an object of quantities. Conflicting aliases are refused. Supply a stable request `id`, `expiresAt` within 15 minutes (or the booking declaration’s `holdMinutes`, up to one day), and a private random hex `token` for anonymous bookings. Keep that token private for later management.
48
+
49
+ The phone helper `createBookingChoices({container,options,resources})` from `@homie-rocks/studio/apps` renders accessible native selects. Submit its `value()` (`{option,resource}`) to the app's trusted booking tool. The phone and customer AI therefore use the same option/resource IDs and capacity rules. Call `dispose()` when the surface closes.
@@ -26,7 +26,21 @@ export default defineFunction<{ order: string }>({
26
26
  ```
27
27
 
28
28
  Build discovers and type-checks `functions/*.ts`. Handlers have the studio tool
29
- context and run with the studio's authority. They can change records, send a
29
+ context and run with the studio's authority. Handlers can call `ctx.records` (or
30
+ `context.records` when the parameter is named `context`) to read, create, update
31
+ and delete declared app records. Supply `app`, `collection`, and `id` at the top
32
+ level; put the record fields inside `data`. Updates and deletes also require
33
+ the current `version`. These calls use the same validation and permissions as
34
+ the app record API.
35
+
36
+ ```ts
37
+ await context.records({
38
+ app: 'calendar', collection: 'notes', id: event.id,
39
+ operation: 'create', data: { message: 'Ready' },
40
+ });
41
+ ```
42
+
43
+ Handlers They can change records, send a
30
44
  room event through declared capabilities, call an external service, or implement
31
45
  another business workflow. Treat event data as data, not instructions.
32
46
 
@@ -121,3 +121,26 @@ Document the exact commands and results; an empty assertion list is not proof.
121
121
  and verify a clean checkout can build it. Declare only packages actually used.
122
122
  - No invented reviews, impact figures or verification. Label estimates and test
123
123
  payments honestly; remove stale workaround claims when upgrading.
124
+
125
+ ## Booking toolkit and customer-AI arguments
126
+
127
+ Use declared booking slots and resources instead of duplicating capacity logic.
128
+ `studio_book` accepts `slot` or `time`, `resource`, `preferredResource` or
129
+ `preferred_resource`, and `addons`, `addOns` or `add-ons` as an array of IDs or an
130
+ object of quantities. Contradictory aliases are refused. Use the same option and
131
+ resource IDs in `createBookingChoices` on the phone. A booking may allocate
132
+ multiple resource components, with skills, attributes and capacity checked by
133
+ the server. Use the app's IANA `timeZone` and `ctx.bookings.calendar` for dated or
134
+ multi-day seasons. Public holds need a stable ID, bounded expiry and a private
135
+ management token for anonymous customers.
136
+
137
+ Pass the app's booking reference to `shop.book` or MCP `studio_checkout`. For
138
+ variable deposits use an amount-based link and validate the paid amount against
139
+ the saved booking. Declare `booking.slots_need_payment_links` so the owner's AI
140
+ can prepare links for newly rolled dates through Stripe's official plugin.
141
+ Use `shop observe` to record plugin responses, then verify and build.
142
+
143
+ The toolkit's `docs/toolkit-batch.md` documents resource declarations, install
144
+ surfaces, private frames, concept previews, example forks and isolated live-site
145
+ checks. Record IDs belong at the top level of `ctx.records`/`app_records`
146
+ arguments, alongside `app` and `collection`.
package/lib/app-needs.mjs CHANGED
@@ -22,15 +22,23 @@ 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
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;
41
+ primarySurface = primaryNeedSurface(need,name=>surfaces.find(s=>s.name===name)?.role);
34
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); };
@@ -39,8 +47,8 @@ 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 = []; });
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=[];});
44
52
  const perform = async rawStep => {
45
53
  const step=resolveNeedResults(rawStep,saved);
46
54
  if(step.concurrent){
@@ -57,7 +65,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
57
65
  if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
58
66
  return;
59
67
  }
60
- const target=surface(step.surface);
68
+ const target=surface(step.surface,step.role);
61
69
  if(!step.reload)await ready(target);
62
70
  const { page } = target;
63
71
  if(step.capabilities){
@@ -68,7 +76,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
68
76
  if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
69
77
 
70
78
  waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
71
- if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); return; }
79
+ if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface,step.role)); return; }
72
80
  const act = fn => actInApp(target,step.selector,fn,{ready});
73
81
  if (step.click && step.popup) {
74
82
  let listener,timer;
@@ -118,7 +126,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
118
126
  continue;
119
127
  }
120
128
  waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
121
- const target=surface(assertion.surface);
129
+ const target=surface(assertion.surface,assertion.role);
122
130
  await ready(target);
123
131
  await waitInApp(target,waiting,async frame=>{
124
132
  if (assertion.effect) {
@@ -134,9 +142,10 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
134
142
  result.why = 'Assertions passed';
135
143
  } catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
136
144
  if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
137
- if (shots) for (const name of names) {
138
- const file = join(shots, `${need.id}-${name}.jpg`);
139
- 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); }
140
149
  catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
141
150
  }
142
151
  report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
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
@@ -635,7 +635,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
635
635
  const bundle = bundleOf(dir);
636
636
  builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
637
637
  return {
638
- ...(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 } : {}),
639
639
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
640
640
  ...(ui ? { ui } : {}),
641
641
  // A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
@@ -698,6 +698,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
698
698
  const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
699
699
  const catalogue = {
700
700
  studio: {
701
+ ...(studio.concept===true?{concept:true}:{}),
701
702
  name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
702
703
  ...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
703
704
  theme,
@@ -716,7 +717,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
716
717
  ...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
717
718
  // The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
718
719
  // HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
719
- directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
720
+ directory: studio.concept===true?false:directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
720
721
  canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
721
722
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
722
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, needSteps } 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
@@ -359,7 +359,7 @@ 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??{};
@@ -368,19 +368,34 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
368
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
+ 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
+ };
375
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 [...needSteps(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, restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)), 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
  }
package/lib/dev.mjs CHANGED
@@ -300,6 +300,7 @@ export function rulesStamp(g) {
300
300
  const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
301
301
  if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
302
302
  const file=join(dir,entry.name);
303
+ if(file===join(g.dir,'CODEX.md'))continue; // check writes authoring evidence, never runtime inputs
303
304
  if(entry.isDirectory())walk(file);
304
305
  else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
305
306
  }};
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
+ }
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
@@ -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,4 @@
1
+ import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
1
2
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
2
3
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
3
4
  import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
@@ -827,6 +828,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
827
828
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
828
829
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
829
830
  [`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
831
+ [`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
830
832
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
831
833
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
832
834
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1069,8 +1071,13 @@ function ensureBookingHolds(root) {
1069
1071
  if(existsSync(file))return null;
1070
1072
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
1071
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
+ }
1072
1079
  export function ensureMigrations(root) {
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);
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);
1074
1081
  }
1075
1082
 
1076
1083
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -85,7 +85,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
85
85
  catalogPrices.set(item.id, price.id);
86
86
  if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
87
87
  if (item.price === 0) return; // Free orders never leave the studio.
88
- const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
88
+ const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' && !['supporter','deposit'].includes(item.kind) ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
89
89
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
90
90
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
91
91
  ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
@@ -103,7 +103,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
103
103
  // Stripe allows 20 Payment Link lines; invoice snapshots include only the first
104
104
  // 10. Split catalogues so keyless fulfillment needs no authenticated pagination.
105
105
  config.menus = [];
106
- const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
106
+ const menuItems = shop.items.filter(i => !['supporter','deposit'].includes(i.kind) && typeof i.price === 'number' && i.price > 0 && !i.interval);
107
107
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
108
108
  const tasks=[];
109
109
  const groups=Map.groupBy(menuItems,i=>i.game??'');
@@ -198,7 +198,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
198
198
  });
199
199
  }
200
200
  if(shop.items.some(i=>i.interval)) tasks.push(async()=>{
201
- const portals=scoped?(knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:[]):await list('/v1/billing_portal/configurations');
201
+ const portals=knownPortal?[await api('GET',`/v1/billing_portal/configurations/${knownPortal}`,{})]:await list('/v1/billing_portal/configurations');
202
202
  let portal=portals.find(p=>own(p));
203
203
  const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
204
204
  if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
package/lib/shop-mcp.mjs CHANGED
@@ -151,3 +151,9 @@ export function publicMcpSetup(root) {
151
151
  }
152
152
  return false;
153
153
  }
154
+
155
+ /** Record a batch from the official plugin and return the next dependency batch. */
156
+ export async function recordShopObservations(root, options = {}) {
157
+ if(!Array.isArray(options.observations) || !options.observations.length)throw new Error('Supply observations: [{method,path,params,body}] from Stripe’s official plugin.');
158
+ return shopMcpPlan(root, options);
159
+ }