@homie-rocks/studio 0.51.4 → 0.51.6
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 +49 -0
- package/README.md +10 -29
- package/apps/APPS.md +16 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +20 -4
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/app-needs.mjs +86 -18
- package/lib/app-ready.mjs +21 -0
- package/lib/build.mjs +12 -9
- package/lib/check.mjs +16 -7
- package/lib/cloudflare.mjs +6 -11
- package/lib/codex.mjs +7 -5
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/needs-headless.mjs +34 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +81 -19
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +87 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +110 -34
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/index.mjs +5 -1
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +19 -1
- package/worker/mcp.mjs +1 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +58 -8
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
package/lib/app-needs.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {waitInApp} from './app-ready.mjs';
|
|
1
|
+
import {actInApp,waitInApp} from './app-ready.mjs';
|
|
2
2
|
/** Opt-in acceptance evidence, sourced from the person's Codex, not starter labels. */
|
|
3
3
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
export function readAppNeeds(root, id) {
|
|
@@ -22,30 +23,38 @@ export function readAppNeeds(root, id) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
25
|
-
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {} }) {
|
|
26
|
+
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
|
|
26
27
|
const results = [];
|
|
27
28
|
const appBarrier=ready;
|
|
28
|
-
ready=async s=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s);};
|
|
29
|
+
ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
|
|
29
30
|
let activeRole;
|
|
30
31
|
const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
|
|
31
32
|
for (const need of plan.needs) {
|
|
32
33
|
activeRole = need.role;
|
|
33
|
-
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface))];
|
|
34
|
+
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
|
|
35
|
+
const saved = new Map();
|
|
36
|
+
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); };
|
|
34
37
|
let waiting = 'validating assertions';
|
|
35
38
|
const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
|
|
36
39
|
try {
|
|
37
|
-
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires
|
|
40
|
+
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
|
|
38
41
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
39
42
|
for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
|
|
40
43
|
for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
|
|
41
|
-
for (const
|
|
44
|
+
for (const rawStep of need.steps ?? []) {
|
|
45
|
+
const step=resolveNeedResults(rawStep,saved);
|
|
46
|
+
if (step.mcp || step.schedule) {
|
|
47
|
+
waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
|
|
48
|
+
const value = await run(step);
|
|
49
|
+
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
42
52
|
const target=surface(step.surface);
|
|
43
53
|
if(!step.reload)await ready(target);
|
|
44
54
|
const { page } = target;
|
|
45
55
|
waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
|
|
46
56
|
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
|
|
47
|
-
|
|
48
|
-
const frame=target.frame;
|
|
57
|
+
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
49
58
|
if (step.click && step.popup) {
|
|
50
59
|
let listener,timer;
|
|
51
60
|
const opened=new Promise((resolve,reject)=>{
|
|
@@ -53,17 +62,42 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
53
62
|
timer=setTimeout(()=>reject(new Error('payment popup did not open')),10000);
|
|
54
63
|
});
|
|
55
64
|
try {
|
|
56
|
-
const [popup]=await Promise.all([opened,frame.click(step.selector)]);
|
|
65
|
+
const [popup]=await Promise.all([opened,act(frame=>frame.click(step.selector))]);
|
|
57
66
|
surfaces.push({name:step.popup,page:popup,frame:popup.mainFrame(),external:true,role:target.role});
|
|
58
67
|
await page.bringToFront();
|
|
59
68
|
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
60
69
|
}
|
|
61
|
-
else if (step.click) {await frame.click(step.selector);await page.bringToFront?.();}
|
|
62
|
-
else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
|
|
63
|
-
else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
|
|
70
|
+
else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
|
|
71
|
+
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
|
+
else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
|
|
64
73
|
else throw new Error('step needs click, fill, select or reload');
|
|
65
74
|
}
|
|
66
|
-
for (const
|
|
75
|
+
for (const rawAssertion of need.assertions) {
|
|
76
|
+
const assertion=resolveNeedResults(rawAssertion,saved);
|
|
77
|
+
if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
|
|
78
|
+
waiting = assertion.file ? `file ${assertion.file}` : assertion.result ? `result ${assertion.result}${assertion.path ? '.'+assertion.path : ''}` : assertion.records ? `records ${assertion.records.collection}` : `MCP ${assertion.mcp.tool ?? assertion.mcp.method}`;
|
|
79
|
+
let value;
|
|
80
|
+
if (assertion.file) {
|
|
81
|
+
if (!root) throw new Error('file proof requires the studio root');
|
|
82
|
+
const file=resolve(root,assertion.file), rel=relative(resolve(root),file);
|
|
83
|
+
if(rel.startsWith('..') || isAbsolute(rel))throw new Error('handover file must be inside the studio');
|
|
84
|
+
value=readFileSync(file,'utf8');
|
|
85
|
+
} else if (assertion.result) {
|
|
86
|
+
if(!saved.has(assertion.result))throw new Error(`no saved result ${assertion.result}; add save to its step`);
|
|
87
|
+
value=saved.get(assertion.result);
|
|
88
|
+
} else {
|
|
89
|
+
const waitMs=assertion.waitMs??0;
|
|
90
|
+
if(!Number.isInteger(waitMs)||waitMs<0||waitMs>10000)throw new Error('waitMs must be an integer from 0 to 10000');
|
|
91
|
+
const end=Date.now()+waitMs;
|
|
92
|
+
for(;;) {
|
|
93
|
+
value=await run(assertion);
|
|
94
|
+
try {assertNeedValue(value,assertion);break;}
|
|
95
|
+
catch(error){if(Date.now()>=end)throw error;await new Promise(resolve=>setTimeout(resolve,100));}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
assertNeedValue(value,assertion);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
67
101
|
waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
|
|
68
102
|
const target=surface(assertion.surface);
|
|
69
103
|
await ready(target);
|
|
@@ -77,13 +111,14 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
77
111
|
if (assertion.text !== undefined) await frame.waitForFunction((selector, text) => document.querySelector(selector)?.textContent.includes(text), { timeout: 10000 }, assertion.selector, String(assertion.text));
|
|
78
112
|
},{ready});
|
|
79
113
|
}
|
|
80
|
-
result.status =
|
|
81
|
-
result.why =
|
|
114
|
+
result.status = 'met';
|
|
115
|
+
result.why = 'Assertions passed';
|
|
82
116
|
} catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
|
|
117
|
+
if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
|
|
83
118
|
if (shots) for (const name of names) {
|
|
84
119
|
const file = join(shots, `${need.id}-${name}.jpg`);
|
|
85
|
-
try { await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
86
|
-
catch (error) {
|
|
120
|
+
try { mkdirSync(shots,{recursive:true}); await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
121
|
+
catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
|
|
87
122
|
}
|
|
88
123
|
report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
|
|
89
124
|
results.push(result);
|
|
@@ -96,3 +131,36 @@ export function saveAppNeeds(root, id, plan, needs) {
|
|
|
96
131
|
const dir = join(root, '.studio', 'checks'); mkdirSync(dir, { recursive: true });
|
|
97
132
|
writeFileSync(join(dir, `${id}-needs.json`), JSON.stringify({ at: new Date().toISOString(), codexHash: plan.hash, needs }, null, 2));
|
|
98
133
|
}
|
|
134
|
+
|
|
135
|
+
/** Partial structured matching: object keys must match; each expected array entry must occur. */
|
|
136
|
+
export function containsValue(actual, expected) {
|
|
137
|
+
if (typeof expected === 'string') return typeof actual === 'string' && actual.includes(expected);
|
|
138
|
+
if (Array.isArray(expected)) return Array.isArray(actual) && expected.every(e=>actual.some(a=>containsValue(a,e)));
|
|
139
|
+
if (expected && typeof expected === 'object') return actual !== null && typeof actual === 'object' && Object.entries(expected).every(([k,v])=>Object.hasOwn(actual,k)&&containsValue(actual[k],v));
|
|
140
|
+
return isDeepStrictEqual(actual,expected);
|
|
141
|
+
}
|
|
142
|
+
export function assertNeedValue(value, assertion) {
|
|
143
|
+
if(assertion.path) for(const key of assertion.path.split('.')) value=value!=null&&Object.hasOwn(Object(value),key)?value[key]:undefined;
|
|
144
|
+
if(value === undefined)throw new Error('value does not exist');
|
|
145
|
+
if(Object.hasOwn(assertion,'equals')&&!isDeepStrictEqual(value,assertion.equals))throw new Error(`expected equals ${JSON.stringify(assertion.equals)}; got ${JSON.stringify(value)}`);
|
|
146
|
+
if(Object.hasOwn(assertion,'contains')&&!containsValue(value,assertion.contains))throw new Error(`expected contains ${JSON.stringify(assertion.contains)}; got ${JSON.stringify(value)}`);
|
|
147
|
+
if(assertion.text!==undefined)for(const text of [].concat(assertion.text))if(typeof value!=='string'||!value.includes(String(text)))throw new Error(`expected text ${JSON.stringify(text)}`);
|
|
148
|
+
if(!assertion.file&&!Object.hasOwn(assertion,'equals')&&!Object.hasOwn(assertion,'contains')&&assertion.text===undefined)throw new Error('result/records assertion requires equals, contains or text');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Explicit result references never evaluate code or interpolate arbitrary strings. */
|
|
152
|
+
export function resolveNeedResults(value,saved) {
|
|
153
|
+
if(Array.isArray(value))return value.map(v=>resolveNeedResults(v,saved));
|
|
154
|
+
if(value && typeof value==='object') {
|
|
155
|
+
if(Object.keys(value).length===1 && typeof value.$result==='string') {
|
|
156
|
+
const [name,...path]=value.$result.split('.');
|
|
157
|
+
if(!saved.has(name))throw new Error(`no saved result ${name}`);
|
|
158
|
+
let result=saved.get(name);
|
|
159
|
+
for(const key of path)result=result!=null&&Object.hasOwn(Object(result),key)?result[key]:undefined;
|
|
160
|
+
if(result===undefined)throw new Error(`saved result path ${value.$result} does not exist`);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
return Object.fromEntries(Object.entries(value).map(([key,v])=>[key,resolveNeedResults(v,saved)]));
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
package/lib/app-ready.mjs
CHANGED
|
@@ -48,3 +48,24 @@ export function answerDialogs(page,surface,dialogs,report) {
|
|
|
48
48
|
await dialog.accept().catch(()=>{});
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/** Retry only errors identifying a detached element, never an ambiguous navigation after dispatch. */
|
|
53
|
+
export async function actInApp(surface, selector, action, {ready=appReady,timeout=10000}={}) {
|
|
54
|
+
const end=Date.now()+timeout;
|
|
55
|
+
let stale;
|
|
56
|
+
for (;;) {
|
|
57
|
+
try {
|
|
58
|
+
await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
|
|
59
|
+
return await action(surface.frame);
|
|
60
|
+
} catch(error) {
|
|
61
|
+
if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
|
|
62
|
+
if(stale && Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
stale=error;
|
|
66
|
+
if(Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
67
|
+
await new Promise(resolve=>setTimeout(resolve,Math.min(50,Math.max(1,end-Date.now()))));
|
|
68
|
+
await ready(surface,{timeout:Math.max(1,end-Date.now())});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/build.mjs
CHANGED
|
@@ -384,7 +384,7 @@ function pointAtBundle(html, bundle) {
|
|
|
384
384
|
* game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
|
|
385
385
|
* one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
|
|
386
386
|
*/
|
|
387
|
-
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
|
|
387
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false, quick = false } = {}) {
|
|
388
388
|
rmSync(out, { recursive: true, force: true });
|
|
389
389
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
390
390
|
const mode = g.build?.mode ?? 'bundle';
|
|
@@ -411,7 +411,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
414
|
-
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
|
|
414
|
+
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck, quick }) : null;
|
|
415
415
|
const bundle = async (entryRel) => {
|
|
416
416
|
const entry = join(g.dir, entryRel);
|
|
417
417
|
if (!existsSync(entry)) throw new Error(`${label}: entry ${entryRel} not found`);
|
|
@@ -509,7 +509,8 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
509
509
|
return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
|
|
512
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false, quick = false } = {}) {
|
|
513
|
+
log(`Building studio${quick?' (quick: rules play checks skipped)':''}…`);
|
|
513
514
|
const esbuild = await studioEsbuild(root);
|
|
514
515
|
const studio = readStudio(root);
|
|
515
516
|
if (Object.hasOwn(studio, 'referrals')) log('studio.json referrals: ignored; Homie has no referral system.');
|
|
@@ -535,13 +536,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
535
536
|
// from the site as it is.
|
|
536
537
|
const dist = openStage(root, { from: only ? live : null });
|
|
537
538
|
try {
|
|
538
|
-
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
|
|
539
|
+
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick });
|
|
539
540
|
} finally {
|
|
540
541
|
rmSync(dist, { recursive: true, force: true });
|
|
541
542
|
}
|
|
542
543
|
}
|
|
543
544
|
|
|
544
|
-
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
|
|
545
|
+
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick }) {
|
|
545
546
|
const built = [];
|
|
546
547
|
const retired = [];
|
|
547
548
|
const cache = {};
|
|
@@ -549,8 +550,9 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
549
550
|
const browserHosted = [];
|
|
550
551
|
for (const g of games) {
|
|
551
552
|
const out = join(dist, 'games', g.id);
|
|
553
|
+
log(`Building ${g.id}…`);
|
|
552
554
|
const started = Date.now();
|
|
553
|
-
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
|
|
555
|
+
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck, quick });
|
|
554
556
|
if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
|
|
555
557
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
556
558
|
vocabFor(g, out);
|
|
@@ -571,14 +573,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
571
573
|
if (seats.asked > SEAT_MAX && !rules) log(`games/${g.id} asks for ${seats.asked} players; browser-hosted netplay rooms retain their ${SEAT_MAX}-player limit, so this build uses ${SEAT_MAX} seats. For larger rooms, rewrite as rules plus view: node_modules/@homie-rocks/studio/guides/game/REWRITE.md`);
|
|
572
574
|
built.push({
|
|
573
575
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
574
|
-
...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
|
|
576
|
+
...(rules ? { capacityTrial: rules.check.capacityTrial, rulesChecksSkipped: rules.check.skipped === true } : {}),
|
|
575
577
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
576
578
|
});
|
|
577
579
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
578
580
|
if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this ${g.kind === 'app' ? 'app' : 'game'}'s rules and tunables are sent to players' devices so it can be played offline`);
|
|
579
|
-
if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
581
|
+
if (rules && !quick) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
580
582
|
if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
|
|
581
|
-
if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
583
|
+
if (rules && !quick) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
582
584
|
}
|
|
583
585
|
// A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
|
|
584
586
|
if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
|
|
@@ -752,6 +754,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
752
754
|
// site, never before: a game that did not build has left both as they were.
|
|
753
755
|
await beforePublish();
|
|
754
756
|
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
757
|
+
log('Building site Worker…');
|
|
755
758
|
await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
|
|
756
759
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
757
760
|
const swapped = swapIn(dist, live);
|
package/lib/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {needsHeadless} from './needs-headless.mjs';
|
|
1
2
|
import {randomBytes} from 'node:crypto';
|
|
2
|
-
import {appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
|
+
import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
4
5
|
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
6
|
/**
|
|
@@ -148,6 +149,12 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
148
149
|
if (meta?.rulesError) return { ok: false, command: 'check', why: meta.rulesError };
|
|
149
150
|
if (meta?.kind === 'app') appMeta = meta;
|
|
150
151
|
} catch { /* browser preflight below still names failures */ }
|
|
152
|
+
if(appMeta && !appMeta.check?.action) {
|
|
153
|
+
try {
|
|
154
|
+
const plan=readAppNeeds(root,game);
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
|
+
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
|
+
}
|
|
151
158
|
const chrome = findChrome();
|
|
152
159
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
153
160
|
let puppeteer;
|
|
@@ -357,13 +364,13 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
357
364
|
const proof = meta.check??{};
|
|
358
365
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
359
366
|
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' };
|
|
360
|
-
|
|
367
|
+
const captureWarnings=[];
|
|
361
368
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
362
369
|
let browser;
|
|
363
370
|
const surfaces = [];
|
|
364
371
|
const room = `check-${randomBytes(10).toString('hex')}`;
|
|
365
372
|
try {
|
|
366
|
-
browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
373
|
+
if(baseline || plan?.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
367
374
|
const openSurface = async (name, role) => {
|
|
368
375
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
369
376
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
@@ -390,7 +397,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
390
397
|
await appReady(actor);
|
|
391
398
|
await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
|
|
392
399
|
before = await waitInApp(actor,proof.observe,textOf);
|
|
393
|
-
await actor.
|
|
400
|
+
await actInApp(actor,proof.action,f=>f.click(proof.action));
|
|
394
401
|
await waitInApp(actor,proof.observe+' changes after action',f=>f.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before));
|
|
395
402
|
after = await waitInApp(actor,proof.observe,textOf);
|
|
396
403
|
for (const s of surfaces) await waitInApp(s,proof.observe+' equals '+after,f=>f.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after));
|
|
@@ -402,19 +409,21 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
402
409
|
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
403
410
|
for (const s of surfaces) {
|
|
404
411
|
await appReady(s);
|
|
405
|
-
if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
|
|
412
|
+
if (shots) try {mkdirSync(shots,{recursive:true});await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });}
|
|
413
|
+
catch(error){const warning=`${s.name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`;captureWarnings.push(warning);log(warning);}
|
|
406
414
|
}
|
|
407
415
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
408
416
|
}
|
|
409
417
|
for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
|
|
418
|
+
if(!item.surface)continue;
|
|
410
419
|
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
411
420
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
412
421
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
413
422
|
}
|
|
414
|
-
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady }) : null;
|
|
423
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
415
424
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
416
425
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
417
|
-
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', isolatedRecords:isolate, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
426
|
+
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', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
418
427
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
|
|
419
428
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
420
429
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -48,7 +48,6 @@ import { repoOf } from './repo.mjs';
|
|
|
48
48
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
49
49
|
import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
50
50
|
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
51
|
-
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
52
51
|
|
|
53
52
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
54
53
|
|
|
@@ -62,11 +61,7 @@ export function wranglerBin(root) {
|
|
|
62
61
|
return existsSync(local) ? local : null;
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/**
|
|
66
|
-
* Wrangler from the studio's own node_modules, run in site/ with the given environment. A studio whose Cloudflare came
|
|
67
|
-
* through Stripe Projects (studio.json `cloudflare.auth: "stripe-projects"`, 0.24.3) runs it with the token and account
|
|
68
|
-
* Projects synced to its git-ignored output file; every other studio with Wrangler's own sign-in.
|
|
69
|
-
*/
|
|
64
|
+
/** Wrangler from the studio's own node_modules, using Cloudflare's own sign-in. */
|
|
70
65
|
export function runner(root, env) {
|
|
71
66
|
const bin = wranglerBin(root);
|
|
72
67
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
@@ -74,7 +69,7 @@ export function runner(root, env) {
|
|
|
74
69
|
const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
|
|
75
70
|
if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
|
|
76
71
|
const res = spawnSync(bin, args, {
|
|
77
|
-
cwd, env: { ...process.env, ...
|
|
72
|
+
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
78
73
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
79
74
|
});
|
|
80
75
|
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
|
|
@@ -152,7 +147,7 @@ export async function zoneCheck(root, { studio = readStudio(root), config = read
|
|
|
152
147
|
try {
|
|
153
148
|
const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
|
|
154
149
|
headers = await cloudflareAuth({
|
|
155
|
-
env: { ...process.env
|
|
150
|
+
env: { ...process.env }, bin, cwd: root,
|
|
156
151
|
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
|
|
157
152
|
});
|
|
158
153
|
} catch { headers = null; }
|
|
@@ -398,7 +393,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
398
393
|
const started = Date.now();
|
|
399
394
|
let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
400
395
|
if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
|
|
401
|
-
const headers = await cloudflareAuth({ env: { ...process.env
|
|
396
|
+
const headers = await cloudflareAuth({ env: { ...process.env }, bin: wranglerBin(root), cwd: root,
|
|
402
397
|
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
|
|
403
398
|
const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
|
|
404
399
|
if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
|
|
@@ -408,7 +403,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
408
403
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
409
404
|
if (paidReleases(root).length) {
|
|
410
405
|
const bin = wranglerBin(root);
|
|
411
|
-
const uploadOptions = { cwd: workerDir(root), env: { ...process.env,
|
|
406
|
+
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
412
407
|
await uploadPaidParts(root, { bucket: purchaseBucket, log, put: (key, file) => wranglerRun(bin, ['r2', 'object', 'put', key, '--file', file, '--remote'], uploadOptions), hash: (key) => hashRemote(bin, key, uploadOptions) });
|
|
413
408
|
}
|
|
414
409
|
remember(`worker:${cf.worker}`);
|
|
@@ -772,7 +767,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
|
|
|
772
767
|
const bin = wranglerBin(root);
|
|
773
768
|
if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
|
|
774
769
|
const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
|
|
775
|
-
const opts = { cwd: workerDir(root), env: { ...process.env, ...
|
|
770
|
+
const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
776
771
|
const moved = [];
|
|
777
772
|
const failed = [];
|
|
778
773
|
const verified = [];
|
package/lib/codex.mjs
CHANGED
|
@@ -318,7 +318,7 @@ function sectionHtml(section, emb, ctx) {
|
|
|
318
318
|
if (block) {
|
|
319
319
|
try {
|
|
320
320
|
const needs = JSON.parse(block[1]);
|
|
321
|
-
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate
|
|
321
|
+
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate with check' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
|
|
322
322
|
} catch { /* Keep malformed source visible for correction. */ }
|
|
323
323
|
}
|
|
324
324
|
}
|
|
@@ -582,7 +582,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
582
582
|
const r = JSON.parse(readFileSync(needReportFile, 'utf8'));
|
|
583
583
|
const stale = r.codexHash !== createHash('sha256').update(readFileSync(file, 'utf8')).digest('hex');
|
|
584
584
|
tabs.push('<a href="#needs-status">Needs status</a>');
|
|
585
|
-
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}.
|
|
585
|
+
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}. Assertions are evidence; review available captures against Their words.</p>${r.needs.map(n => `<article><h3>${esc(n.statement)}</h3><p><strong>${stale ? 'not yet rechecked' : esc(n.status)}</strong> — ${esc(n.why ?? '')}</p>${(n.captures ?? []).map(p => figure(emb, n.statement, relative(root, resolve(root, p)), p)).join('')}</article>`).join('')}</section>`;
|
|
586
586
|
} catch { /* The next check repairs an incomplete receipt. */ }
|
|
587
587
|
}
|
|
588
588
|
const body = needStatus + sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
|
|
@@ -772,9 +772,11 @@ try: "Open the wall and phone. Follow a real request through the scene."
|
|
|
772
772
|
<!-- What is sold or given, price and currency, once/monthly, and what payment fulfils. -->
|
|
773
773
|
|
|
774
774
|
## Needs
|
|
775
|
-
<!-- Each statement
|
|
776
|
-
Each need: id, statement, steps
|
|
777
|
-
|
|
775
|
+
<!-- Each owner statement belongs in this executable needs block.
|
|
776
|
+
Each need: id, statement, optional role, steps and assertions. UI: surface/selector with
|
|
777
|
+
click/fill/select/reload; headless: mcp tool/arguments + save, or schedule name/at.
|
|
778
|
+
Assert UI selectors, saved result/path/equals/contains, records, or file/text.
|
|
779
|
+
See node_modules/@homie-rocks/studio/guides/app/NEEDS.md for complete examples.
|
|
778
780
|
A missing flow is reported not met. Do not replace an unmet need with an easier one. -->
|
|
779
781
|
\`\`\`needs
|
|
780
782
|
[]
|
package/lib/dev.mjs
CHANGED
|
@@ -32,7 +32,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpat
|
|
|
32
32
|
import { connect } from 'node:net';
|
|
33
33
|
import { dirname, join, sep } from 'node:path';
|
|
34
34
|
import { detectLocalAi, localAiVars } from './local-ai.mjs';
|
|
35
|
-
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
36
35
|
import { routesOf } from './routes.mjs';
|
|
37
36
|
import { ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
38
37
|
import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
|
|
@@ -343,7 +342,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
343
342
|
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
344
343
|
const bin = wranglerBin(root);
|
|
345
344
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
346
|
-
const env = { ...process.env,
|
|
345
|
+
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
|
|
347
346
|
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
348
347
|
await new Promise((done) => {
|
|
349
348
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { officialConnections } from './official-media.mjs';
|
|
2
2
|
/**
|
|
3
3
|
* `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
|
|
4
4
|
* a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
|
|
@@ -35,8 +35,7 @@ import { providerKey } from './provider-key.mjs';
|
|
|
35
35
|
* status line optional (Claude Code only) the build's progress under the prompt
|
|
36
36
|
*
|
|
37
37
|
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
38
|
-
* tools, `wrangler whoami`, `gh auth status`,
|
|
39
|
-
* is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
|
|
38
|
+
* tools, `wrangler whoami`, `gh auth status`, one GET to the directory and explicit AI-host connection evidence; when a server's AI guides use Workers AI, one read of the live database and one
|
|
40
39
|
* one-word call to the model; in Codex or Grok, the `[mcp_servers.homie]` table of that app's config.toml, of which
|
|
41
40
|
* it keeps only whether it is an address or a command, and the hooks' mark in this user's cache), never changes
|
|
42
41
|
* anything, and never prints a key, a token, a command line or an account's name or id.
|
|
@@ -53,7 +52,6 @@ import { whyFailed } from './net.mjs';
|
|
|
53
52
|
import { isOurs } from './statusline.mjs';
|
|
54
53
|
import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
|
|
55
54
|
import { standaloneRows } from './standalone.mjs';
|
|
56
|
-
import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
|
|
57
55
|
import { STUDIO_VERSION } from './version.mjs';
|
|
58
56
|
|
|
59
57
|
/** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
|
|
@@ -175,7 +173,7 @@ const ago = (ms) => (ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : m
|
|
|
175
173
|
* replaceable for tests.
|
|
176
174
|
*/
|
|
177
175
|
export async function setupStatus({
|
|
178
|
-
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null, client = null,
|
|
176
|
+
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, integrations = {}, homie = null, client = null,
|
|
179
177
|
exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node, nowMs = Date.now(),
|
|
180
178
|
} = {}) {
|
|
181
179
|
const root = findStudio(cwd);
|
|
@@ -198,18 +196,12 @@ export async function setupStatus({
|
|
|
198
196
|
|
|
199
197
|
// The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
|
|
200
198
|
const said = connector === true || connector === 'yes' ? 'yes' : connector === false || connector === 'no' ? 'no' : null;
|
|
201
|
-
const [net, who, gh
|
|
199
|
+
const [net, who, gh] = await Promise.all([
|
|
202
200
|
reach(`${directory}/mcp`, fetchFn),
|
|
203
201
|
(async () => {
|
|
204
|
-
if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
|
|
205
202
|
const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
|
|
206
203
|
if (!bin || !existsSync(bin)) return { signedIn: null };
|
|
207
|
-
|
|
208
|
-
const projects = projectsCloudflareEnv(root);
|
|
209
|
-
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, ...projects, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
210
|
-
if (projects.CLOUDFLARE_API_TOKEN && r.code === 0) {
|
|
211
|
-
try { if (JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))).loggedIn) return { signedIn: true, how: 'through Stripe Projects (its token, in the studio\'s git-ignored .env; never shown)', accounts: 1, accountIds: [projects.CLOUDFLARE_ACCOUNT_ID].filter(Boolean) }; } catch { /* read below */ }
|
|
212
|
-
}
|
|
204
|
+
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
213
205
|
if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
|
|
214
206
|
// The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
|
|
215
207
|
try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
|
|
@@ -224,26 +216,6 @@ export async function setupStatus({
|
|
|
224
216
|
if (v.code !== 0) return { git: git.code === 0, gh: false, signedIn: false, onGitHub };
|
|
225
217
|
return { git: git.code === 0, gh: true, signedIn: (await exec('gh', ['auth', 'status'], { timeout: 10_000 })).code === 0, onGitHub };
|
|
226
218
|
})(),
|
|
227
|
-
(async () => {
|
|
228
|
-
const cliBin = env.ELEVENLABS_CLI || 'elevenlabs';
|
|
229
|
-
const v = await exec(cliBin, ['--version']);
|
|
230
|
-
let signedIn = false;
|
|
231
|
-
if (v.code === 0) {
|
|
232
|
-
const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
|
|
233
|
-
try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
|
|
234
|
-
}
|
|
235
|
-
const credential = providerKey('elevenlabs', env);
|
|
236
|
-
return { cli: v.code === 0, signedIn, key: Boolean(credential.key), source: credential.source, projects: root ? projectsElevenLabs(root) : false };
|
|
237
|
-
})(),
|
|
238
|
-
(async () => {
|
|
239
|
-
const { key, source } = providerKey('fal', env);
|
|
240
|
-
if (!key) return { key: false };
|
|
241
|
-
const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
|
|
242
|
-
try {
|
|
243
|
-
const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
|
|
244
|
-
return { key: true, source, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
|
|
245
|
-
} catch { return { key: true, source, valid: null }; }
|
|
246
|
-
})(),
|
|
247
219
|
]);
|
|
248
220
|
|
|
249
221
|
// The Homie connector. A session with a shell makes, deploys and lists a studio without it, so it never blocks.
|
|
@@ -385,23 +357,7 @@ export async function setupStatus({
|
|
|
385
357
|
: { who: 'person', open: 'https://github.com/signup', say: `Make a free GitHub account; your AI installs the GitHub CLI${mac ? ' (brew install gh)' : ''} when you want the backup.` },
|
|
386
358
|
});
|
|
387
359
|
|
|
388
|
-
|
|
389
|
-
rows.push({
|
|
390
|
-
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
|
|
391
|
-
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? `key from ${eleven.source}` : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
|
|
392
|
-
unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
|
|
393
|
-
fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
|
|
394
|
-
? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
|
|
395
|
-
: { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// fal.
|
|
399
|
-
rows.push({
|
|
400
|
-
id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
|
|
401
|
-
detail: !fal.key ? 'no fal key in the environment or Homie key files' : `key from ${fal.source}; ${fal.valid === true ? 'fal accepts it' : fal.valid === false ? 'fal refused it (wrong or revoked)' : 'fal did not answer just now'}`,
|
|
402
|
-
unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
|
|
403
|
-
fix: fal.key && fal.valid !== false ? null : { who: 'ai', say: 'Use local art and captured footage. Paid fal scripts need an already connected account; never ask the person to find or set a key. Offer the provider browser connection only when generated media is requested.', open: 'https://fal.ai/dashboard' },
|
|
404
|
-
});
|
|
360
|
+
rows.push(...officialConnections(integrations));
|
|
405
361
|
|
|
406
362
|
// Stripe, only in a studio that sells. Installing agent tooling is not proof of Worker credentials.
|
|
407
363
|
// shop connect runs browser pairing and reports the remaining steps; this read-only status never reads keys.
|
|
@@ -411,11 +367,11 @@ export async function setupStatus({
|
|
|
411
367
|
const mcp = stripeAgentConfigured({ root, env });
|
|
412
368
|
rows.push({
|
|
413
369
|
id: 'stripe', label: 'Stripe', need: 'for selling', state: 'act',
|
|
414
|
-
detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `
|
|
415
|
-
unlocks: '
|
|
370
|
+
detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `official MCP is set up for ${mcp}` : 'official MCP is not set up', 'Provider sign-in is not proof that the shop and signed events work'].join('; '),
|
|
371
|
+
unlocks: 'Use Stripe’s official plugin for shop planning and verification, then install the private webhook. Verify a test purchase before calling the shop ready.',
|
|
416
372
|
fix: {
|
|
417
|
-
who: 'ai', run:
|
|
418
|
-
say: 'Use
|
|
373
|
+
who: 'ai', run: mcp ? 'npx --no-install homie-studio shop plan' : version ? 'stripe agent setup' : 'npm install -g @stripe/cli@latest',
|
|
374
|
+
say: 'Use stripe_shop_plan with Stripe’s official MCP for catalog and links, then stripe_webhook for private signing-secret installation. Never ask for a key. Repeat plan and verify after edits.',
|
|
419
375
|
open: 'https://dashboard.stripe.com/register',
|
|
420
376
|
},
|
|
421
377
|
});
|