@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.
- package/CHANGELOG.md +27 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +8 -2
- 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/lib/app-needs.mjs +19 -10
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +3 -2
- package/lib/check.mjs +37 -13
- package/lib/dev.mjs +1 -0
- 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 +8 -1
- package/lib/shop-links.mjs +3 -3
- package/lib/shop-mcp.mjs +6 -0
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +42 -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/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +16 -4
- package/worker/app-shell.mjs +1 -1
- package/worker/app-tools.mjs +1 -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/customer-mcp.mjs +1 -1
- package/worker/index.mjs +36 -6
- 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 +27 -0
- package/worker/records-page.mjs +2 -2
- package/worker/shop.mjs +6 -1
- 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
|
+
}
|
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');
|
|
@@ -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/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
|
|
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
|
|
43
|
-
for
|
|
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
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
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
|
|
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
|
{
|
package/lib/needs-headless.mjs
CHANGED
|
@@ -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
|
|
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). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -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=
|
|
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
|
+
}
|