@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +16 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/NEEDS.md +160 -0
  6. package/guides/app/SKILL.md +20 -4
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/parts/SKILL.md +1 -1
  18. package/guides/shop/SKILL.md +68 -77
  19. package/guides/sound/SKILL.md +70 -1
  20. package/guides/sound/scripts/player/sound.js +233 -0
  21. package/guides/studio-setup/SKILL.md +11 -27
  22. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  23. package/guides/style/SKILL.md +66 -3
  24. package/guides/video/SKILL.md +83 -0
  25. package/guides/video/references/DELIVERY.md +73 -0
  26. package/guides/video/references/HONESTY.md +40 -0
  27. package/guides/video/references/METHOD.md +144 -0
  28. package/guides/video/references/RECORD.md +137 -0
  29. package/guides/video/references/TRAILER.md +154 -0
  30. package/guides/video/references/examples/site-walkthrough.json +14 -0
  31. package/guides/video/references/examples/studio-play-phone.json +15 -0
  32. package/guides/video/references/examples/studio-play.json +17 -0
  33. package/guides/video/scripts/capture-game.mjs +175 -0
  34. package/guides/video/scripts/clock.js +136 -0
  35. package/guides/video/scripts/drawover/film.js +58 -0
  36. package/guides/video/scripts/drawover/index.html +9 -0
  37. package/guides/video/scripts/drawover/look.js +115 -0
  38. package/guides/video/scripts/drawover/shots.json +12 -0
  39. package/guides/video/scripts/lib/browser.mjs +87 -0
  40. package/guides/video/scripts/lib/frames.mjs +170 -0
  41. package/guides/video/scripts/lib/remix.mjs +159 -0
  42. package/guides/video/scripts/qa.mjs +127 -0
  43. package/guides/video/scripts/record-fixed.mjs +403 -0
  44. package/guides/video/scripts/record-page.mjs +477 -0
  45. package/guides/video/scripts/tap.js +108 -0
  46. package/guides/video/scripts/video.mjs +888 -0
  47. package/lib/app-needs.mjs +86 -18
  48. package/lib/app-ready.mjs +21 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/check.mjs +16 -7
  51. package/lib/cloudflare.mjs +6 -11
  52. package/lib/codex.mjs +7 -5
  53. package/lib/dev.mjs +1 -2
  54. package/lib/doctor.mjs +10 -54
  55. package/lib/mcp-tools.mjs +42 -21
  56. package/lib/mcp.mjs +1 -1
  57. package/lib/media-import.mjs +41 -0
  58. package/lib/media-workflows.mjs +13 -0
  59. package/lib/needs-headless.mjs +34 -0
  60. package/lib/official-media.mjs +15 -0
  61. package/lib/rules-build.mjs +13 -5
  62. package/lib/scaffold.mjs +2 -2
  63. package/lib/shop-links.mjs +81 -19
  64. package/lib/shop-listen.mjs +43 -0
  65. package/lib/shop-local.mjs +1 -1
  66. package/lib/shop-mcp.mjs +58 -0
  67. package/lib/stripe-connect.mjs +36 -15
  68. package/lib/template-history.json +87 -0
  69. package/lib/upgrade-media.mjs +13 -0
  70. package/lib/upgrade.mjs +10 -0
  71. package/package.json +1 -1
  72. package/parts/PARTS.md +1 -1
  73. package/rules/view.ts +4 -0
  74. package/shop/SHOP.md +110 -34
  75. package/worker/app-records.mjs +1 -0
  76. package/worker/build-id.mjs +1 -1
  77. package/worker/event-resources.mjs +9 -3
  78. package/worker/index.mjs +5 -1
  79. package/worker/local-test.mjs +4 -0
  80. package/worker/mcp-events.mjs +2 -2
  81. package/worker/mcp-tools.mjs +19 -1
  82. package/worker/mcp.mjs +1 -1
  83. package/worker/purchase-reconciliation.mjs +16 -1
  84. package/worker/shop-links.mjs +1 -1
  85. package/worker/shop-rules.mjs +3 -0
  86. package/worker/shop-subscriptions.mjs +2 -0
  87. package/worker/shop.mjs +58 -8
  88. package/worker/stripe-ownership.mjs +7 -0
  89. package/worker/version.mjs +1 -1
  90. package/guides/models/references/models.json +0 -175
  91. package/guides/music/scripts/lib/eleven.mjs +0 -260
  92. package/lib/projects-env.mjs +0 -68
  93. package/lib/projects.mjs +0 -311
  94. 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 assertions on its named surfaces');
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 step of need.steps ?? []) {
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
- await waitInApp(target,step.selector,f=>f.waitForSelector(step.selector, { visible: true, timeout:10000 }),{ready});
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 assertion of need.assertions) {
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 = shots ? 'met' : 'partly';
81
- result.why = shots ? 'UI assertions passed; inspect captures against the pictured scene' : 'Assertions passed but no captures saved';
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) { if (result.status === 'met') result.status = 'partly'; result.why += `; capture failed: ${error.message}`; }
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
- if (shots) mkdirSync(shots, { recursive: true });
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.frame.click(proof.action);
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
  }
@@ -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, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
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, ...projectsCloudflareEnv(root) }, bin, cwd: root,
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, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
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, ...projectsCloudflareEnv(root), CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
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, ...projectsCloudflareEnv(root), ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
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 on the wall and phone' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
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)}. Screen assertions are evidence; review the 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>`;
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 is observable on a real screen. Fill this executable needs block.
776
- Each need: id, statement, steps (surface wall/phone/phone-two; click/fill/select and selector/value),
777
- assertions (surface, selector, optional text). Include wall and phone assertions and real flow steps.
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, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
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 { providerKey } from './provider-key.mjs';
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`, `elevenlabs auth status`, one GET to the directory and, when a fal key
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, eleven, fal] = await Promise.all([
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
- // Through Stripe Projects (studio.json "auth": "stripe-projects"): Wrangler with the token Projects synced.
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
- // ElevenLabs.
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 ? `optional MCP is set up for ${mcp}` : 'optional MCP is not set up', 'CLI access is not proof that Worker credentials or a purchase work'].join('; '),
415
- unlocks: 'Connect the studio’s own Stripe with browser approval; shop connect syncs Payment Links and the webhook without a Worker API key. Verify a test purchase before calling the shop ready.',
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: version ? 'npx --no-install homie-studio shop connect' : 'npm install -g @stripe/cli@latest',
418
- say: 'Use stripe_login or shop connect. Never ask for a key in chat. Sync Products, Prices, Payment Links and the webhook through the approved CLI. The Worker needs only the signing secret; repeat connect after edits.',
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
  });