@homie-rocks/studio 0.52.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +9 -3
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +27 -11
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +4 -3
- package/lib/check.mjs +47 -15
- package/lib/dev.mjs +9 -1
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +27 -1
- package/lib/shop-links.mjs +39 -21
- package/lib/shop-mcp.mjs +13 -1
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +20 -6
- package/worker/app-shell.mjs +10 -5
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +2 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +74 -27
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +108 -6
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/records-page.mjs +2 -2
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +68 -8
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,63 @@ 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.54.0] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
**Plugin 0.54.0**
|
|
23
|
+
|
|
24
|
+
Studios answer faster, private apps stay private and usable, shops catch every payment, and the toolkit's CI costs a quarter of what it did.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Reduce pull-request CI work through shared studio fixtures, focused compatibility coverage, and nightly release checks.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **Long-running app screens:** renew role handoffs on records, live and tools responses, reconnect idle live streams before expiry, and set handoffs on records-only pages. Revoked sessions, grants and rotated links still stop access. Remove the shared role secret from the phone address bar and subsequent API URLs.
|
|
33
|
+
- **Tool catalogue:** load and notify once per Worker isolate and database binding, then refresh after studio mutations; static requests no longer repeatedly load tool modules and serialize schemas.
|
|
34
|
+
- **Test assets:** require a valid test session for the same app, role and scope whenever a test-kind asset ticket is used.
|
|
35
|
+
|
|
36
|
+
- **Request path:** remove referral cleanup and function registration from startup; clean retired referral keys in migration 0031. Reuse the deployment catalogue, reuse stable-binding metadata caches guarded by a trigger-maintained revision, avoid repeat tool-catalogue notifications, and dispatch background work after actual writes. Public static assets require no D1 reads; private app assets retain live authorization.
|
|
37
|
+
- **Payment Links:** menu items support `maxQuantity` (default 99), slot links accept groups within published remaining capacity, and confirmation uses the paid seat quantity atomically. Payments that cannot be fulfilled remain visible as review orders with a durable staff event and customer notice. Full connect saves public JSON link assets, retaining replaced links until deployment.
|
|
38
|
+
- **Stripe setup:** observe deterministic product IDs before creating missing search results; keep public checkout mode out of secret uploads and report Wrangler's redacted error text.
|
|
39
|
+
- **Payment review:** single-item links enforce quantity caps at Stripe and fulfillment; old menu snapshots retain their published maximum. Signed payments with missing/unknown references or a currency mismatch remain visible for staff review without granting entitlements.
|
|
40
|
+
- **Background delivery:** include nested background work, preserve D1 sessions and raw-write tracking, and refresh connected AIs’ tool lists on the first ordinary request after deployment.
|
|
41
|
+
- **App checks:** needs without assertions are pending placeholders, rather than failed checks. They remain unproven: check reports not done with a pending label while preserving a nonfailing exit for placeholders.
|
|
42
|
+
- **App navigation:** Big screen uses a five-minute HttpOnly same-origin role session instead of copying the shared access secret; app context URLs include only a validated public room ID.
|
|
43
|
+
- **App subscriptions and privacy:** tool-data revisions and app wakes are scoped to the namespaces each app uses. Private asset capabilities support invite passes, signed-in sessions and tool/test identities, conceal session hashes with encryption plus HMAC, and recheck revocation and grants in one statement.
|
|
44
|
+
- **Prediction:** synchronous `room.me` readers share a presentation even when CPU work crosses a simulation tick; the next microtask refreshes it. Fixes the 60 Hz browser prediction smoke flake without relaxing thresholds.
|
|
45
|
+
|
|
46
|
+
### Upgrade notes
|
|
47
|
+
|
|
48
|
+
- Apply migrations 0031–0033 with the normal studio deploy. Rebuild/deploy after full `shop connect` to publish its public link configuration. Review group availability and resync slot manifests after confirmations, moves or refunds; a Payment Link is not an exclusive unpaid hold.
|
|
49
|
+
|
|
50
|
+
## [0.53.0] - 2026-10-15
|
|
51
|
+
|
|
52
|
+
**Plugin 0.53.0**
|
|
53
|
+
|
|
54
|
+
Take bookings against real resources, keep private apps usable, collect deposits reliably and turn tested examples into your own place.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **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.
|
|
59
|
+
- **Keep upcoming slots ready to sell:** Declared `booking.slots_need_payment_links` events let your studio respond when rolling slot inventory needs payment links.
|
|
60
|
+
- **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.
|
|
61
|
+
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **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.
|
|
68
|
+
- **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.
|
|
69
|
+
- **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.
|
|
70
|
+
- **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.
|
|
71
|
+
|
|
72
|
+
### Upgrade notes
|
|
73
|
+
|
|
74
|
+
- Apply `0030_booking_resources.sql` after the existing booking migrations to use resource-aware bookings.
|
|
75
|
+
- 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.
|
|
76
|
+
|
|
20
77
|
## [0.52.0] - 2026-10-15
|
|
21
78
|
|
|
22
79
|
**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
|
+
}
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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');
|
|
@@ -1388,7 +1394,7 @@ try {
|
|
|
1388
1394
|
// A shared progress feed sends its last word before the command exits.
|
|
1389
1395
|
await flushProgress();
|
|
1390
1396
|
if (result && result.command !== 'help') print(result);
|
|
1391
|
-
if (result?.ok === false) process.exitCode = 1;
|
|
1397
|
+
if (result?.ok === false) process.exitCode = result.command==='check' && result.pending===true && result.exitCode===0 ? 0 : 1;
|
|
1392
1398
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
1393
1399
|
if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1394
1400
|
} catch (error) {
|
|
@@ -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.
|
package/functions/FUNCTIONS.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/guides/app/PLACES.md
CHANGED
|
@@ -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/guides/app/SKILL.md
CHANGED
|
@@ -101,9 +101,9 @@ The Codex's `needs` fenced block is a JSON array, one entry per need from the re
|
|
|
101
101
|
]
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it not
|
|
104
|
+
Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it pending (a placeholder, not a failure or proof). Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
|
|
105
105
|
|
|
106
|
-
Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
|
|
106
|
+
Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not/pending, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
|
|
107
107
|
|
|
108
108
|
For staff flows, sign in through the existing local owner link and prove the transition in real Chrome. Keep evidence in the Codex alongside the automated flows. Confirm unauthorized writes are refused and records remain after all screens close. Never call an untested privileged path passed. Before final handover, show the need table with evidence and explicit remaining gaps. If the starter or a board of boxes is still visible, return to the scene design and build it.
|
|
109
109
|
|
package/guides/shop/SKILL.md
CHANGED
|
@@ -210,3 +210,9 @@ and Stripe CLI `listen --all-snapshot --forward-to http://localhost:8787/api/sho
|
|
|
210
210
|
recipe is in the installed toolkit’s `apps/APPS.md`, “Real Stripe test checkout on
|
|
211
211
|
this computer”. It uses root `.dev.vars`, needs no public HTTPS or Cloudflare, and
|
|
212
212
|
keeps the existing approved Stripe CLI session. Do not write a stand-in checkout.
|
|
213
|
+
|
|
214
|
+
### Batch setup and reconciliation
|
|
215
|
+
|
|
216
|
+
Set each menu item's `maxQuantity` in `shop.json` when the studio has an order limit; the default is 99. Slot links support group quantities up to published remaining seats. Supply reviewed `confirmed` seat totals when resyncing: Stripe counts sessions, not people. The database confirms the entire paid group atomically. If a payment differs from the saved deposit/basket or a group no longer fits, inspect the `review` order and `payment.review` event, arrange fulfillment or refund, and show the customer the saved notice. Never discard a paid event.
|
|
217
|
+
|
|
218
|
+
Full `shop connect` and plugin verification both save public JSON assets; rebuild/deploy those assets before retiring replaced links. Checkout mode is a public Worker variable, not a secret. If a product search misses an existing deterministic ID, perform the planner's GET for that ID before creating it. Preserve Wrangler's diagnostic when setup fails.
|
package/lib/app-needs.mjs
CHANGED
|
@@ -22,25 +22,40 @@ 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
|
|
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); };
|
|
37
45
|
let waiting = 'validating assertions';
|
|
38
46
|
const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
|
|
39
47
|
try {
|
|
40
|
-
if (
|
|
48
|
+
if (need.assertions === undefined || Array.isArray(need.assertions) && !need.assertions.length) {
|
|
49
|
+
result.status = 'pending';
|
|
50
|
+
result.why = 'Placeholder: add assertions to prove this need';
|
|
51
|
+
report?.check?.(`need-${need.id}`, 'skip', { label: need.statement, note: result.why });
|
|
52
|
+
results.push(result);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(need.assertions)) throw new Error('assertions must be an array');
|
|
41
56
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
42
|
-
for
|
|
43
|
-
for
|
|
57
|
+
for(const item of [...needSteps(need.steps),...need.assertions])if(item.surface&&!popupNames.has(item.surface))surface(item.surface,item.role);
|
|
58
|
+
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
59
|
const perform = async rawStep => {
|
|
45
60
|
const step=resolveNeedResults(rawStep,saved);
|
|
46
61
|
if(step.concurrent){
|
|
@@ -57,7 +72,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
57
72
|
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
58
73
|
return;
|
|
59
74
|
}
|
|
60
|
-
const target=surface(step.surface);
|
|
75
|
+
const target=surface(step.surface,step.role);
|
|
61
76
|
if(!step.reload)await ready(target);
|
|
62
77
|
const { page } = target;
|
|
63
78
|
if(step.capabilities){
|
|
@@ -68,7 +83,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
68
83
|
if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
|
|
69
84
|
|
|
70
85
|
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; }
|
|
86
|
+
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface,step.role)); return; }
|
|
72
87
|
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
73
88
|
if (step.click && step.popup) {
|
|
74
89
|
let listener,timer;
|
|
@@ -118,7 +133,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
118
133
|
continue;
|
|
119
134
|
}
|
|
120
135
|
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);
|
|
136
|
+
const target=surface(assertion.surface,assertion.role);
|
|
122
137
|
await ready(target);
|
|
123
138
|
await waitInApp(target,waiting,async frame=>{
|
|
124
139
|
if (assertion.effect) {
|
|
@@ -134,9 +149,10 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
134
149
|
result.why = 'Assertions passed';
|
|
135
150
|
} catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
|
|
136
151
|
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
|
|
138
|
-
const
|
|
139
|
-
|
|
152
|
+
if (shots) for (const target of surfaces.filter(s=>names.includes(s.name))) {
|
|
153
|
+
const name=target.name, suffix=surfaces.filter(s=>s.name===name).length>1?'-'+target.role:'';
|
|
154
|
+
const file = join(shots, `${need.id}-${name}${suffix}.jpg`);
|
|
155
|
+
try { mkdirSync(shots,{recursive:true}); await ready(target); await target.page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
140
156
|
catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
|
|
141
157
|
}
|
|
142
158
|
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
|
|
@@ -689,7 +689,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
689
689
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
690
690
|
if(publicMcpSetup(root)) {
|
|
691
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.'}));
|
|
692
|
+
writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,purchases:existsSync(join(root,'.studio','stripe-purchase-links.json'))?JSON.parse(readFileSync(join(root,'.studio','stripe-purchase-links.json'),'utf8')):null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
|
|
693
693
|
}
|
|
694
694
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
695
695
|
const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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,33 @@ 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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
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
|
-
const
|
|
429
|
-
return {
|
|
452
|
+
const outcome = appNeedsOutcome(needs);
|
|
453
|
+
return { ...outcome, ...(needs ? { needs } : {}), 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 }); }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Pending is unfinished proof, not a failure of the baseline checks. */
|
|
459
|
+
export function appNeedsOutcome(needs) {
|
|
460
|
+
const failed=needs?.some(n=>!['met','pending'].includes(n.status));
|
|
461
|
+
if(failed)return {ok:false,exitCode:1,why:'Some needs from the person’s Codex are not yet met; see needs and captures'};
|
|
462
|
+
if(needs?.some(n=>n.status==='pending'))return {ok:false,pending:true,exitCode:0,why:'Pending needs are not yet proved.'};
|
|
463
|
+
return {ok:true,exitCode:0};
|
|
432
464
|
}
|
package/lib/dev.mjs
CHANGED
|
@@ -200,6 +200,7 @@ export function devConfig(root, remoteAi) {
|
|
|
200
200
|
// Always retain the exact local binding configuration for CLI commands after a deploy.
|
|
201
201
|
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
202
202
|
delete json.$schema;
|
|
203
|
+
json.vars={...json.vars,HOMIE_DEV:'1'};
|
|
203
204
|
json.main = entry ?? abs(json.main);
|
|
204
205
|
if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
|
|
205
206
|
json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
|
|
@@ -300,6 +301,7 @@ export function rulesStamp(g) {
|
|
|
300
301
|
const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
|
|
301
302
|
if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
|
|
302
303
|
const file=join(dir,entry.name);
|
|
304
|
+
if(file===join(g.dir,'CODEX.md'))continue; // check writes authoring evidence, never runtime inputs
|
|
303
305
|
if(entry.isDirectory())walk(file);
|
|
304
306
|
else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
|
|
305
307
|
}};
|
|
@@ -350,6 +352,12 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
350
352
|
m.on('close', done);
|
|
351
353
|
m.on('error', done);
|
|
352
354
|
});
|
|
355
|
+
const {functionDeploymentSQL}=await import('./functions-build.mjs');
|
|
356
|
+
const registerFunctions=()=>new Promise(resolve=>{
|
|
357
|
+
const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
|
|
358
|
+
child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
|
|
359
|
+
});
|
|
360
|
+
if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
|
|
353
361
|
const origin = `http://127.0.0.1:${port}`;
|
|
354
362
|
const printedOrigin = `http://localhost:${port}`;
|
|
355
363
|
log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
|
|
@@ -479,7 +487,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
479
487
|
if (rebuilding || ending) return;
|
|
480
488
|
const stamp = toolsStamp(); if (stamp === lastTools) return;
|
|
481
489
|
lastTools = stamp; rebuilding = true;
|
|
482
|
-
try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
|
|
490
|
+
try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); if(!await registerFunctions())throw new Error('Local function registration failed'); }
|
|
483
491
|
catch (error) { log(`Studio tools did not build: ${error.message}`); }
|
|
484
492
|
finally { rebuilding = false; }
|
|
485
493
|
};
|