@homie-rocks/studio 0.51.8 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +68 -1
- package/bin/homie-studio.mjs +8 -2
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/docs/toolkit-place-patterns.md +6 -0
- package/functions/FUNCTIONS.md +42 -1
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +146 -0
- package/guides/app/SKILL.md +3 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/lib/app-needs.mjs +59 -15
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +8 -2
- package/lib/check.mjs +40 -16
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +7 -1
- package/lib/fork.mjs +47 -0
- package/lib/functions-build.mjs +3 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +29 -1
- package/lib/shop-links.mjs +7 -7
- package/lib/shop-mcp.mjs +88 -14
- package/lib/stripe-connect.mjs +46 -6
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +7 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +13 -0
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +21 -5
- package/worker/app-shell.mjs +28 -5
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +100 -0
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +54 -9
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +3 -3
- package/worker/mcp-tools.mjs +23 -2
- package/worker/mcp.mjs +3 -3
- package/worker/office.mjs +27 -0
- package/worker/pages.mjs +4 -4
- package/worker/records-page.mjs +3 -3
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +20 -5
- package/worker/version.mjs +1 -1
package/guides/shop/SKILL.md
CHANGED
|
@@ -49,27 +49,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
49
49
|
3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
|
|
50
50
|
for **writes as well as reads**. Keep this setup's observations across batches.
|
|
51
51
|
Product creation includes its default Price; link creation expands line items.
|
|
52
|
-
Product and Price Search use studio/origin metadata.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
Product and Price Search use studio/origin metadata. Payment Links use paginated
|
|
53
|
+
list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
|
|
54
|
+
Stripe has no Payment Link metadata search. There is no per-link read loop.
|
|
55
|
+
The planner saves public observations in `.studio/stripe-mcp-observations.json` and
|
|
56
|
+
reuses them on the next call; send only new responses. Keep that file and
|
|
57
|
+
`.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
|
|
58
|
+
mutable links while retaining product/price discovery. Account, mode and origin
|
|
59
|
+
changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
|
|
60
|
+
verification before webhook installation (CLI: include it in `--have` input).
|
|
61
|
+
Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
|
|
62
|
+
retry its exact idempotency key; never discard successful responses or invent IDs.
|
|
61
63
|
4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
|
|
62
64
|
`homie-studio shop verify --have plan-input.json`). Verification checks real fields,
|
|
63
65
|
line items, menus, slot limits and portal settings. It saves compact public Worker
|
|
64
66
|
configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
|
|
65
|
-
|
|
67
|
+
Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
|
|
68
|
+
that asset, with only its path in Wrangler vars. Large catalogues never use a text
|
|
69
|
+
binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
|
|
66
70
|
it cannot open until signed payment events can confirm orders. Changing shop.json,
|
|
67
71
|
the slot manifest or origin requires verification again. Local test configuration
|
|
68
72
|
goes to `.dev.vars`.
|
|
69
73
|
5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
|
|
70
74
|
Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
|
|
71
|
-
checks the same account as the MCP, and installs the signing secret
|
|
72
|
-
|
|
75
|
+
checks the same account as the MCP, and installs only the signing secret through
|
|
76
|
+
Wrangler stdin. If Stripe CLI is missing, the tool installs the official
|
|
77
|
+
`@stripe/cli@latest` npm package automatically, checks the executable, then continues.
|
|
78
|
+
An installation failure reports npm permissions/PATH and a retry command. The CLI
|
|
79
|
+
needs one separate browser approval if its
|
|
73
80
|
studio profile is not already connected. MCP approval cannot be reused or exported.
|
|
74
81
|
Cloudflare authorization and an existing deployment are prerequisites.
|
|
75
82
|
6. After installation, perform the returned `retire` operations through Stripe MCP to
|
|
@@ -80,6 +87,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
|
80
87
|
endpoint's delivery result and the app's verified records. Do not type a card into
|
|
81
88
|
a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
|
|
82
89
|
|
|
90
|
+
Upgrades accept 0.51.6 verifications without a source fingerprint only when their
|
|
91
|
+
saved content fingerprint still matches the current shop, slots, site and mode.
|
|
92
|
+
Actual changes name the affected settings and require verification. An unfinished
|
|
93
|
+
setup still builds and deploys with checkout closed and one next step: finish
|
|
94
|
+
`stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
|
|
95
|
+
old link secrets cannot reopen it. Cached observations never renew the verification
|
|
96
|
+
time; use fresh reads if the private webhook tool requests them.
|
|
97
|
+
|
|
83
98
|
Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
|
|
84
99
|
|
|
85
100
|
**Why the separate approval (checked 2026-10-14).** Stripe's
|
package/lib/app-needs.mjs
CHANGED
|
@@ -22,16 +22,24 @@ export function readAppNeeds(root, id) {
|
|
|
22
22
|
return { needs, hash: createHash('sha256').update(source).digest('hex') };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** An action names its actor; an observation-only need prefers its phone or a matching role. */
|
|
26
|
+
export function primaryNeedSurface(need,roleFor=()=>undefined) {
|
|
27
|
+
const action=needSteps(need.steps).find(s=>s.surface)?.surface;
|
|
28
|
+
const assertions=(need.assertions??[]).filter(s=>s.surface);
|
|
29
|
+
return need.surface??action??assertions.find(s=>['phone','phone-two'].includes(s.surface))?.surface??assertions.find(s=>!need.role||roleFor(s.surface)===need.role)?.surface;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
26
|
-
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
|
|
33
|
+
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless, restart }) {
|
|
27
34
|
const results = [];
|
|
28
35
|
const appBarrier=ready;
|
|
29
36
|
ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
|
|
30
|
-
let activeRole;
|
|
31
|
-
const surface = name => { const s
|
|
37
|
+
let activeRole, primarySurface;
|
|
38
|
+
const surface = (name, explicitRole) => { const role=explicitRole??(name===primarySurface?activeRole:undefined); const s=surfaces.find(s=>s.name===name&&(!role||s.role===role)); if(!s)throw new Error(role?`${name} requires role ${role}`:`unknown surface ${name}`);return s; };
|
|
32
39
|
for (const need of plan.needs) {
|
|
33
40
|
activeRole = need.role;
|
|
34
|
-
|
|
41
|
+
primarySurface = primaryNeedSurface(need,name=>surfaces.find(s=>s.name===name)?.role);
|
|
42
|
+
const names = [...new Set([...needSteps(need.steps), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
|
|
35
43
|
const saved = new Map();
|
|
36
44
|
const run = async item => { if (!headless) throw new Error('headless MCP proof requires local dev and its test credential'); return headless(item,item.role ?? need.role); };
|
|
37
45
|
let waiting = 'validating assertions';
|
|
@@ -39,21 +47,36 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
39
47
|
try {
|
|
40
48
|
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
|
|
41
49
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
42
|
-
for
|
|
43
|
-
for
|
|
44
|
-
|
|
50
|
+
for(const item of [...needSteps(need.steps),...need.assertions])if(item.surface&&!popupNames.has(item.surface))surface(item.surface,item.role);
|
|
51
|
+
for(const target of new Set(need.assertions.filter(a=>a.effect).map(a=>surface(a.surface,a.role))))await target.frame.evaluate(()=>{window.__homieNeedsEffects=[];});
|
|
52
|
+
const perform = async rawStep => {
|
|
45
53
|
const step=resolveNeedResults(rawStep,saved);
|
|
54
|
+
if(step.concurrent){
|
|
55
|
+
if(!Array.isArray(step.concurrent)||step.concurrent.length<2)throw new Error('concurrent needs at least two steps');
|
|
56
|
+
if(needSteps(step.concurrent).some(s=>s.restart||s.capabilities))throw new Error('Restart and capabilities changes must be outside a race');
|
|
57
|
+
const results=await Promise.allSettled(step.concurrent.map(perform));
|
|
58
|
+
const failed=results.find(r=>r.status==='rejected');if(failed)throw failed.reason;return;
|
|
59
|
+
}
|
|
60
|
+
if(step.restart){if(!restart)throw new Error('Restart requires the local dev controller');await restart();for(const s of surfaces)await ready(s);return;}
|
|
61
|
+
|
|
46
62
|
if (step.mcp || step.schedule) {
|
|
47
63
|
waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
|
|
48
64
|
const value = await run(step);
|
|
49
65
|
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
50
|
-
|
|
66
|
+
return;
|
|
51
67
|
}
|
|
52
|
-
const target=surface(step.surface);
|
|
68
|
+
const target=surface(step.surface,step.role);
|
|
53
69
|
if(!step.reload)await ready(target);
|
|
54
70
|
const { page } = target;
|
|
71
|
+
if(step.capabilities){
|
|
72
|
+
if(typeof step.capabilities.webgl!=='boolean')throw new Error('capabilities.webgl must be boolean');
|
|
73
|
+
await page.evaluateOnNewDocument(configureWebGL,step.capabilities.webgl);
|
|
74
|
+
await page.reload({waitUntil:'domcontentloaded'});await ready(target);return;
|
|
75
|
+
}
|
|
76
|
+
if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
|
|
77
|
+
|
|
55
78
|
waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
|
|
56
|
-
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface));
|
|
79
|
+
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface,step.role)); return; }
|
|
57
80
|
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
58
81
|
if (step.click && step.popup) {
|
|
59
82
|
let listener,timer;
|
|
@@ -67,11 +90,15 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
67
90
|
await page.bringToFront();
|
|
68
91
|
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
69
92
|
}
|
|
93
|
+
else if(step.forceClick){await act(frame=>frame.$eval(step.selector,el=>{
|
|
94
|
+
const disabled=el.disabled;try{el.disabled=false;el.click();}finally{el.disabled=disabled;}
|
|
95
|
+
}));}
|
|
70
96
|
else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
|
|
71
97
|
else if (step.fill !== undefined) { await act(async frame=>{ await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }); }
|
|
72
98
|
else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
|
|
73
99
|
else throw new Error('step needs click, fill, select or reload');
|
|
74
|
-
}
|
|
100
|
+
};
|
|
101
|
+
for(const step of need.steps??[])await perform(step);
|
|
75
102
|
for (const rawAssertion of need.assertions) {
|
|
76
103
|
const assertion=resolveNeedResults(rawAssertion,saved);
|
|
77
104
|
if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
|
|
@@ -99,7 +126,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
99
126
|
continue;
|
|
100
127
|
}
|
|
101
128
|
waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
|
|
102
|
-
const target=surface(assertion.surface);
|
|
129
|
+
const target=surface(assertion.surface,assertion.role);
|
|
103
130
|
await ready(target);
|
|
104
131
|
await waitInApp(target,waiting,async frame=>{
|
|
105
132
|
if (assertion.effect) {
|
|
@@ -115,9 +142,10 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
115
142
|
result.why = 'Assertions passed';
|
|
116
143
|
} catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
|
|
117
144
|
if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
|
|
118
|
-
if (shots) for (const
|
|
119
|
-
const
|
|
120
|
-
|
|
145
|
+
if (shots) for (const target of surfaces.filter(s=>names.includes(s.name))) {
|
|
146
|
+
const name=target.name, suffix=surfaces.filter(s=>s.name===name).length>1?'-'+target.role:'';
|
|
147
|
+
const file = join(shots, `${need.id}-${name}${suffix}.jpg`);
|
|
148
|
+
try { mkdirSync(shots,{recursive:true}); await ready(target); await target.page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
121
149
|
catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
|
|
122
150
|
}
|
|
123
151
|
report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
|
|
@@ -170,3 +198,19 @@ export function resolveNeedResults(value,saved) {
|
|
|
170
198
|
}
|
|
171
199
|
return value;
|
|
172
200
|
}
|
|
201
|
+
|
|
202
|
+
/** Flatten only action groups; used for browser/role discovery before a race starts. */
|
|
203
|
+
export function needSteps(steps = []) { return steps.flatMap(s => s.concurrent ? needSteps(s.concurrent) : [s]); }
|
|
204
|
+
export function configureWebGL(enabled) {
|
|
205
|
+
window.__homieWebGLDisabled = !enabled;
|
|
206
|
+
if(window.__homieWebGLWrapped)return;
|
|
207
|
+
window.__homieWebGLWrapped = true;
|
|
208
|
+
for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
|
|
209
|
+
if(!proto)continue;
|
|
210
|
+
const original = proto.getContext;
|
|
211
|
+
proto.getContext = function(kind, ...args) {
|
|
212
|
+
if(window.__homieWebGLDisabled && /^(webgl2?|experimental-webgl)$/.test(kind))return null;
|
|
213
|
+
return Reflect.apply(original,this,[kind,...args]);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
package/lib/app-ready.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export async function appReady(surface, {timeout=60000}={}) {
|
|
|
9
9
|
const cover=document.querySelector('[data-arrive]');
|
|
10
10
|
return window.__shell?.link?.state==='online' && window.__shell?.arrival?.phase==='done' && (!cover || (getComputedStyle(cover).opacity==='0' && getComputedStyle(cover).visibility==='hidden'));
|
|
11
11
|
},{timeout:Math.max(1,end-Date.now())});
|
|
12
|
-
const frame=surface.page.frames().find(f=>/\/__game\//.test(f.url()));
|
|
12
|
+
const frame=surface.page.frames().find(f=>/\/(?:__game|__assets)\//.test(f.url()));
|
|
13
13
|
if(!frame)throw new Error('app frame missing');
|
|
14
14
|
await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
|
15
15
|
surface.frame=frame;return;
|
package/lib/build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
|
|
1
2
|
import { directoryUrl } from '../worker/directory-choice.mjs';
|
|
2
3
|
import { domainOrigin } from './studio.mjs';
|
|
3
4
|
import { appProblems } from '../worker/app-format.mjs';
|
|
@@ -634,7 +635,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
634
635
|
const bundle = bundleOf(dir);
|
|
635
636
|
builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
|
|
636
637
|
return {
|
|
637
|
-
...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
|
|
638
|
+
...(g.kind === 'app' ? { kind: 'app', ...(g.web?{web:g.web}:{}), ...(g.timeZone?{timeZone:g.timeZone}:{}), roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
|
|
638
639
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
639
640
|
...(ui ? { ui } : {}),
|
|
640
641
|
// A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
|
|
@@ -686,6 +687,10 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
686
687
|
// GAME PARTS: every packed version of every SHARED part into dist/parts/, and its index; a private part is never copied.
|
|
687
688
|
const partsBuilt = buildParts(root, dist, { studio, log });
|
|
688
689
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
690
|
+
if(publicMcpSetup(root)) {
|
|
691
|
+
const verified=verifiedMcpConfig(root);
|
|
692
|
+
writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
|
|
693
|
+
}
|
|
689
694
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
690
695
|
const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
|
|
691
696
|
// The Lounge (0.29.0): studio.json "lounge", checked; off when a game already has /lounge/.
|
|
@@ -693,6 +698,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
693
698
|
const lounge = loungeConfig(studio, rows, { audience: audienceOf(studio) });
|
|
694
699
|
const catalogue = {
|
|
695
700
|
studio: {
|
|
701
|
+
...(studio.concept===true?{concept:true}:{}),
|
|
696
702
|
name: studio.name, slug: studio.slug, version: STUDIO_VERSION, ...(studio.kind ? { kind: studio.kind } : {}),
|
|
697
703
|
...(typeof studio.tagline === 'string' && studio.tagline.trim() ? { tagline: studio.tagline.trim().slice(0, 140) } : {}),
|
|
698
704
|
theme,
|
|
@@ -711,7 +717,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
711
717
|
...(studio.rooms?.share === false ? { rooms: { share: false } } : {}),
|
|
712
718
|
// The directory the live site claims itself in (0.10.0); studio.json `homie.directory: false` keeps it out.
|
|
713
719
|
// HOMIE_DIRECTORY (a build variable) points one build at another directory, such as a staging one.
|
|
714
|
-
directory: directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
|
|
720
|
+
directory: studio.concept===true?false:directoryUrl(process.env.HOMIE_DIRECTORY ?? studio.homie?.directory),
|
|
715
721
|
canonical: domainOrigin(studio.cloudflare?.domain) ?? domainOrigin(studio.cloudflare?.url),
|
|
716
722
|
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
717
723
|
...(studio.template === true ? { template: true } : {}),
|
package/lib/check.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import {needsHeadless} from './needs-headless.mjs';
|
|
|
2
2
|
import {randomBytes} from 'node:crypto';
|
|
3
3
|
import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
4
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
5
|
-
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
|
+
import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps, primaryNeedSurface } from './app-needs.mjs';
|
|
6
6
|
/**
|
|
7
7
|
* `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
|
|
8
8
|
* when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
|
|
@@ -152,7 +152,7 @@ export async function check({ webglFailure = false, isolate = true, root = null,
|
|
|
152
152
|
if(appMeta && !appMeta.check?.action) {
|
|
153
153
|
try {
|
|
154
154
|
const plan=readAppNeeds(root,game);
|
|
155
|
-
if(plan && !plan.needs.some(n=>[...(n.steps
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
156
|
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
157
|
}
|
|
158
158
|
const chrome = findChrome();
|
|
@@ -359,28 +359,43 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
|
|
|
359
359
|
let plan;
|
|
360
360
|
try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
|
|
361
361
|
const secret=localCredential(root,url);
|
|
362
|
-
|
|
362
|
+
|
|
363
363
|
log(isolate ? 'Fresh isolated records: on (this check only).' : 'Fresh isolated records: off; using existing records.');
|
|
364
364
|
const dialogs=[];
|
|
365
365
|
const proof = meta.check??{};
|
|
366
366
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
367
367
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
368
|
-
if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...(n.steps
|
|
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
|
-
|
|
375
|
+
const remoteCookies=new Map();
|
|
376
|
+
const sessionFor=async role=>{
|
|
377
|
+
if(secret)return {name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null})};
|
|
378
|
+
if(!isolate)return null;
|
|
379
|
+
ownerClient??=await (await import('./tools-cli.mjs')).connectToolClient(root,url,{log});
|
|
380
|
+
const key=room+':'+role;
|
|
381
|
+
if(!remoteCookies.has(key)) {
|
|
382
|
+
const result=await ownerClient.callTool({name:'studio_check_session',arguments:{app:game,role,scope:room}});
|
|
383
|
+
if(result.isError)throw new Error(result.content?.[0]?.text??'Cannot create isolated check session');
|
|
384
|
+
const parsed=result.structuredContent??JSON.parse(result.content.find(c=>c.type==='text').text),data=parsed.data??parsed;
|
|
385
|
+
if(!data.token)throw new Error('Studio did not return its test-session token');
|
|
386
|
+
remoteCookies.set(key,{name:'homie_check',value:data.token});
|
|
387
|
+
}
|
|
388
|
+
return remoteCookies.get(key);
|
|
389
|
+
};
|
|
390
|
+
if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
376
391
|
const openSurface = async (name, role) => {
|
|
377
392
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
378
393
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
379
394
|
role ??= meta.surfaces?.[name==='phone-two'?'phone':name] ?? (name==='staff'?'staff':undefined);
|
|
380
395
|
if(!meta.roles?.[role])throw new Error(`${name}: unknown role ${role}`);
|
|
381
|
-
if(meta.roles[role].signIn && !secret)throw new Error(`${name}: local staff proof needs dev's test session`);
|
|
396
|
+
if(meta.roles[role].signIn && !secret && !isolate)throw new Error(`${name}: local staff proof needs dev's test session`);
|
|
382
397
|
const context=await browser.createBrowserContext();
|
|
383
|
-
if(
|
|
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, 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/cloudflare.mjs
CHANGED
|
@@ -595,8 +595,8 @@ export function configNames(root) {
|
|
|
595
595
|
* nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
|
|
596
596
|
* creates the one the binding names while it deploys, and the migrations run right after.
|
|
597
597
|
*/
|
|
598
|
-
export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
599
|
-
const w = runner(root, {});
|
|
598
|
+
export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
599
|
+
const w = wrangler ?? runner(root, {});
|
|
600
600
|
refreshWorkerConfig(root);
|
|
601
601
|
const names = configNames(root);
|
|
602
602
|
const steps = [];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {localCredential} from './local-test.mjs';
|
|
5
|
+
const read=file=>{try{return JSON.parse(readFileSync(file,'utf8'));}catch{return null;}};
|
|
6
|
+
export function takeDevRestart(root,secret){
|
|
7
|
+
const file=join(root,'.studio','restart-request.json'),value=read(file);
|
|
8
|
+
if(!value)return null;
|
|
9
|
+
rmSync(file,{force:true});
|
|
10
|
+
return value.secret===secret&&typeof value.id==='string'?value.id:null;
|
|
11
|
+
}
|
|
12
|
+
export function acknowledgeDevRestart(root,id){writeFileSync(join(root,'.studio','restart-result.json'),JSON.stringify({id}),{mode:0o600});}
|
|
13
|
+
export async function requestDevRestart(root,url,{timeout=60000}={}){
|
|
14
|
+
const secret=localCredential(root,url);if(!secret)throw new Error('Restart requires this studio’s local dev credential');
|
|
15
|
+
const id=randomUUID(),file=join(root,'.studio','restart-request.json');
|
|
16
|
+
writeFileSync(file,JSON.stringify({id,secret}),{mode:0o600});
|
|
17
|
+
try{
|
|
18
|
+
const end=Date.now()+timeout;
|
|
19
|
+
while(Date.now()<end){
|
|
20
|
+
if(read(join(root,'.studio','restart-result.json'))?.id===id)return;
|
|
21
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Dev did not acknowledge restart; run the current homie-studio dev');
|
|
24
|
+
}finally{if(read(file)?.id===id)rmSync(file,{force:true});}
|
|
25
|
+
}
|
package/lib/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {takeDevRestart,acknowledgeDevRestart} from './dev-restart.mjs';
|
|
1
2
|
import {createLocalCredential} from './local-test.mjs';
|
|
2
3
|
import {workerEntry} from './worker-build.mjs';
|
|
3
4
|
import { networkInterfaces } from 'node:os';
|
|
@@ -299,6 +300,7 @@ export function rulesStamp(g) {
|
|
|
299
300
|
const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
|
|
300
301
|
if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
|
|
301
302
|
const file=join(dir,entry.name);
|
|
303
|
+
if(file===join(g.dir,'CODEX.md'))continue; // check writes authoring evidence, never runtime inputs
|
|
302
304
|
if(entry.isDirectory())walk(file);
|
|
303
305
|
else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
|
|
304
306
|
}};
|
|
@@ -371,7 +373,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
371
373
|
|
|
372
374
|
let child = null;
|
|
373
375
|
let ending = false;
|
|
374
|
-
let restart = null;
|
|
376
|
+
let restart = null, proofRestart = null;
|
|
375
377
|
let failed = null;
|
|
376
378
|
const lineState = {};
|
|
377
379
|
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
@@ -395,6 +397,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
395
397
|
if (busy || ending || restart || !up) return;
|
|
396
398
|
busy = true;
|
|
397
399
|
try {
|
|
400
|
+
const requested=takeDevRestart(root,localSecret);
|
|
401
|
+
if(requested){proofRestart=requested;restart='proof';child?.kill('SIGTERM');return;}
|
|
402
|
+
|
|
398
403
|
const built = builtIds(root);
|
|
399
404
|
if (!built) return; // mid-build: site/dist is being written
|
|
400
405
|
for (const g of listGames(root)) {
|
|
@@ -490,6 +495,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
490
495
|
await new Promise((r) => setTimeout(r, 500));
|
|
491
496
|
}
|
|
492
497
|
if (!up) return;
|
|
498
|
+
if(proofRestart){acknowledgeDevRestart(root,proofRestart);proofRestart=null;}
|
|
493
499
|
const game = (builtIds(root) ?? [])[0];
|
|
494
500
|
if (!game) return;
|
|
495
501
|
const bad = await socketOriginProblem(origin, game);
|
package/lib/fork.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,readdirSync,readFileSync,writeFileSync,copyFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {resolve,join,dirname,extname,relative} from 'node:path';
|
|
3
|
+
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
4
|
+
/** Copy editable source, never account state, installed code or a deployment. */
|
|
5
|
+
export async function forkStudio(source,target,{name,slug,ids={}}={}) {
|
|
6
|
+
source=resolve(source);target=resolve(target);
|
|
7
|
+
if(existsSync(target))throw new Error('Fork target already exists');
|
|
8
|
+
if(!/^[a-z][a-z0-9-]{0,39}$/.test(slug??'')||typeof name!=='string'||!name.trim())throw new Error('Supply name and a lowercase slug (1–40 letters, digits and hyphens)');
|
|
9
|
+
if(!relative(source,target).startsWith('..'))throw new Error('Fork target must be outside the example');
|
|
10
|
+
const studio=JSON.parse(readFileSync(join(source,'studio.json'),'utf8'));
|
|
11
|
+
const excluded=new Set(['node_modules','.git','.studio','dist','.wrangler','.DS_Store','wrangler.json','wrangler.jsonc','wrangler.toml','wrangler.custom.json','package-lock.json']);
|
|
12
|
+
const files=[];
|
|
13
|
+
function walk(dir){for(const entry of readdirSync(join(source,dir),{withFileTypes:true})) {
|
|
14
|
+
if(excluded.has(entry.name)||entry.name.startsWith('.')&&entry.name!=='.gitignore'||entry.name.startsWith('.dev.vars')||entry.name.endsWith('.sqlite')||entry.name.endsWith('.db')||entry.name.endsWith('.pem')||entry.name.endsWith('.key')||entry.isSymbolicLink())continue;
|
|
15
|
+
const path=join(dir,entry.name);if(entry.isDirectory())walk(path);else if(entry.isFile())files.push(path);
|
|
16
|
+
}}walk('');
|
|
17
|
+
const mapping=new Map([[studio.slug,slug],[studio.name,name]]);
|
|
18
|
+
const collect=value=>{if(!value||typeof value!=='object')return;if(typeof value.id==='string'&&/^[a-z][a-z0-9-]*$/.test(value.id))mapping.set(value.id,ids[value.id]??`${slug}-${value.id}`);for(const child of Object.values(value))collect(child);};
|
|
19
|
+
for(const file of files.filter(p=>p.endsWith('.json')&&!['studio.json','package.json'].includes(p)))try{collect(JSON.parse(readFileSync(join(source,file),'utf8')));}catch{}
|
|
20
|
+
const ts=(await import('typescript')).default;
|
|
21
|
+
for(const file of files.filter(p=>/\.(?:[cm]?js|tsx?)$/.test(p))) {
|
|
22
|
+
const tree=ts.createSourceFile(file,readFileSync(join(source,file),'utf8'),ts.ScriptTarget.Latest,true);
|
|
23
|
+
const visit=node=>{
|
|
24
|
+
if(ts.isPropertyAssignment(node)&&(ts.isIdentifier(node.name)||ts.isStringLiteral(node.name))&&node.name.text==='id'&&ts.isStringLiteral(node.initializer))collect({id:node.initializer.text});
|
|
25
|
+
ts.forEachChild(node,visit);
|
|
26
|
+
};visit(tree);
|
|
27
|
+
}
|
|
28
|
+
for(const [old,next] of Object.entries(ids))mapping.set(old,next);
|
|
29
|
+
for(const [old,next] of mapping)if(typeof next!=='string'||!next.length)throw new Error(`Invalid replacement for ${old}`);
|
|
30
|
+
for(const [old,next] of mapping)if(old!==studio.name&&(!/^[a-z][a-z0-9-]{0,39}$/.test(next)))throw new Error(`Renamed id ${next} exceeds 40 characters; supply ids.${old}`);
|
|
31
|
+
const escape=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
32
|
+
const pattern=new RegExp(`(?<![A-Za-z0-9_-])(?:${[...mapping.keys()].filter(Boolean).sort((a,b)=>b.length-a.length).map(escape).join('|')})(?![A-Za-z0-9_-])`,'g');
|
|
33
|
+
const rewrite=s=>s.replace(pattern,value=>mapping.get(value));
|
|
34
|
+
mkdirSync(target);
|
|
35
|
+
try {
|
|
36
|
+
for(const file of files) {
|
|
37
|
+
const to=join(target,...file.split('/').map(part=>mapping.get(part)??part));mkdirSync(dirname(to),{recursive:true});
|
|
38
|
+
if(['.json','.ts','.js','.mjs','.html','.css','.md','.sql','.txt','.svg','.yaml','.yml'].includes(extname(file)))writeFileSync(to,rewrite(readFileSync(join(source,file),'utf8')));else copyFileSync(join(source,file),to);
|
|
39
|
+
}
|
|
40
|
+
const fresh=JSON.parse(readFileSync(join(target,'studio.json'),'utf8'));fresh.name=name;fresh.slug=slug;delete fresh.github;delete fresh.template;
|
|
41
|
+
fresh.cloudflare={worker:slug,d1:slug+'-db',created:[]};fresh.homie={...fresh.homie,directory:false};
|
|
42
|
+
writeFileSync(join(target,'studio.json'),JSON.stringify(fresh,null,2)+'\n');
|
|
43
|
+
const pkg=JSON.parse(readFileSync(join(target,'package.json'),'utf8'));pkg.name=slug;delete pkg.repository;writeFileSync(join(target,'package.json'),JSON.stringify(pkg,null,2)+'\n');
|
|
44
|
+
refreshWorkerConfig(target);
|
|
45
|
+
return {ok:true,command:'fork',root:target,renamed:Object.fromEntries(mapping),next:['npm install','homie-studio check']};
|
|
46
|
+
}catch(error){rmSync(target,{recursive:true,force:true});throw error;}
|
|
47
|
+
}
|
package/lib/functions-build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
1
2
|
import {callbackUrl} from '../worker/mcp-events-wire.mjs';
|
|
2
3
|
import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
|
|
3
4
|
import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
|
|
@@ -12,6 +13,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
12
13
|
const feeds=readStudio(root).mcp?.events??{};
|
|
13
14
|
if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
|
|
14
15
|
for(const [name,feed] of Object.entries(feeds)) {
|
|
16
|
+
if(feed?.schema)new CfWorkerJsonSchemaValidator().getValidator(feed.schema);
|
|
15
17
|
if(feed?.webhook)validateWebhook(name,feed.webhook);
|
|
16
18
|
if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
|
|
17
19
|
if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
|
|
@@ -37,6 +39,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
37
39
|
try {if(nextDue(parseSchedule(fn.schedule))===null)throw new Error('no matching date');}
|
|
38
40
|
catch(error) {throw new Error(`Function ${fn.name} cannot run: invalid schedule ${JSON.stringify(fn.schedule)} (${error.message})`);}
|
|
39
41
|
}
|
|
42
|
+
for(const name of fn.publish??[])if(!feeds[name]?.schema)throw new Error(`Function ${fn.name}: publish feed ${name} needs a declared schema`);
|
|
40
43
|
names.add(fn.name);
|
|
41
44
|
}
|
|
42
45
|
const worker=join(root,'site/src/worker.mjs');
|
package/lib/jobs.mjs
CHANGED
|
@@ -158,6 +158,7 @@ export function jobView(job, { lines = 12 } = {}) {
|
|
|
158
158
|
startedAt: job.startedAt, endedAt: job.endedAt,
|
|
159
159
|
seconds: Math.round(((job.endedAt ? Date.parse(job.endedAt) : Date.now()) - Date.parse(job.startedAt)) / 1000),
|
|
160
160
|
tail: text.slice(-lines), log: job.log, result: job.result,
|
|
161
|
+
events: job.err.split('\n').filter(line=>line.startsWith('HOMIE_EVENT ')).flatMap(line=>{try{return [JSON.parse(line.slice(12))];}catch{return [];}}),
|
|
161
162
|
};
|
|
162
163
|
}
|
|
163
164
|
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1178,8 +1178,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1178
1178
|
},
|
|
1179
1179
|
{
|
|
1180
1180
|
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
-
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write,
|
|
1182
|
-
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, supply new write responses as observations and replan until verified; earlier public observations are saved automatically. refresh:true checks external Stripe changes using paginated expanded link lists. verify:true saves compact public config for deploy and stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
|
+
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'},refresh:{type:'boolean',description:'Discard saved observations and read current Stripe state (after Dashboard edits or before private webhook installation).'}}},
|
|
1183
1183
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
1184
|
run:async a=>{
|
|
1185
1185
|
const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
|
|
@@ -1195,7 +1195,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1195
1195
|
run:async a=>{
|
|
1196
1196
|
const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
|
|
1197
1197
|
await waitJob(job,Math.min(ctx.waitMs,1000));
|
|
1198
|
-
return ok('Follow studio_job
|
|
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,7 @@
|
|
|
1
|
+
import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
|
|
2
|
+
import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
|
|
3
|
+
import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
|
|
4
|
+
import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
|
|
1
5
|
import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
|
|
2
6
|
import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
|
|
3
7
|
import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
|
|
@@ -823,6 +827,10 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
823
827
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
824
828
|
[`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
|
|
825
829
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
830
|
+
[`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
|
|
831
|
+
[`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
|
|
832
|
+
[`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
|
|
833
|
+
[`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
|
|
826
834
|
[`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
|
|
827
835
|
[`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
|
|
828
836
|
[`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
|
|
@@ -1048,8 +1056,28 @@ function ensureToolDatabase(root) {
|
|
|
1048
1056
|
if(existsSync(file))return null;
|
|
1049
1057
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
|
|
1050
1058
|
}
|
|
1059
|
+
function ensureAppLive(root) {
|
|
1060
|
+
const file=join(root,'site','migrations',APP_LIVE_MIGRATION_FILE);
|
|
1061
|
+
if(existsSync(file))return null;
|
|
1062
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,APP_LIVE_MIGRATION);return `site/migrations/${APP_LIVE_MIGRATION_FILE}`;
|
|
1063
|
+
}
|
|
1064
|
+
function ensureBusinessEvents(root) {
|
|
1065
|
+
const file=join(root,'site','migrations',BUSINESS_EVENTS_MIGRATION_FILE);
|
|
1066
|
+
if(existsSync(file))return null;
|
|
1067
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BUSINESS_EVENTS_MIGRATION);return `site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`;
|
|
1068
|
+
}
|
|
1069
|
+
function ensureBookingHolds(root) {
|
|
1070
|
+
const file=join(root,'site','migrations',BOOKING_HOLDS_MIGRATION_FILE);
|
|
1071
|
+
if(existsSync(file))return null;
|
|
1072
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
|
|
1073
|
+
}
|
|
1074
|
+
function ensureBookingResources(root) {
|
|
1075
|
+
const file=join(root,'site','migrations',BOOKING_RESOURCES_MIGRATION_FILE);
|
|
1076
|
+
if(existsSync(file))return null;
|
|
1077
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
|
|
1078
|
+
}
|
|
1051
1079
|
export function ensureMigrations(root) {
|
|
1052
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root)].filter(Boolean);
|
|
1080
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root)].filter(Boolean);
|
|
1053
1081
|
}
|
|
1054
1082
|
|
|
1055
1083
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|