@homie-rocks/studio 0.50.5 → 0.50.7

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 (43) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/apps/APPS.md +45 -1
  3. package/bin/homie-studio.mjs +7 -4
  4. package/functions/FUNCTIONS.md +108 -15
  5. package/guides/app/SKILL.md +48 -4
  6. package/guides/functions/SKILL.md +30 -0
  7. package/guides/office/SKILL.md +37 -0
  8. package/guides/servers/SKILL.md +35 -0
  9. package/lib/app-needs.mjs +31 -11
  10. package/lib/app-ready.mjs +49 -0
  11. package/lib/build.mjs +1 -1
  12. package/lib/check.mjs +51 -35
  13. package/lib/cloudflare.mjs +8 -0
  14. package/lib/dev.mjs +19 -17
  15. package/lib/events-cli.mjs +5 -1
  16. package/lib/functions-build.mjs +10 -3
  17. package/lib/functions-cli.mjs +9 -3
  18. package/lib/local-test.mjs +18 -0
  19. package/lib/office.mjs +1 -1
  20. package/lib/scaffold.mjs +15 -2
  21. package/lib/template-history.json +84 -0
  22. package/lib/tools-cli.mjs +3 -0
  23. package/lib/worker-config.mjs +20 -3
  24. package/package.json +1 -1
  25. package/shop/SHOP.md +1 -1
  26. package/tools/tools.ts +8 -1
  27. package/worker/app-format.mjs +2 -1
  28. package/worker/app-frame.mjs +9 -0
  29. package/worker/app-records.mjs +16 -9
  30. package/worker/build-id.mjs +1 -1
  31. package/worker/event-resources.mjs +30 -3
  32. package/worker/function-schedule.mjs +68 -0
  33. package/worker/functions-schema.mjs +13 -0
  34. package/worker/functions.mjs +50 -11
  35. package/worker/index.mjs +13 -2
  36. package/worker/local-test.mjs +19 -0
  37. package/worker/mcp-tools.mjs +24 -9
  38. package/worker/mcp.mjs +23 -11
  39. package/worker/records-page.mjs +12 -0
  40. package/worker/shop-page.mjs +2 -2
  41. package/worker/shop.mjs +1 -0
  42. package/worker/tool-database.mjs +36 -0
  43. package/worker/version.mjs +1 -1
@@ -0,0 +1,49 @@
1
+ /** One arrival barrier for baseline proof, needs reloads and rebuilt frames. */
2
+ export async function appReady(surface, {timeout=60000}={}) {
3
+ const end=Date.now()+timeout;
4
+ let error;
5
+ do {
6
+ try {
7
+ await surface.page.waitForFunction(() => {
8
+ const cover=document.querySelector('[data-arrive]');
9
+ return window.__shell?.link?.state==='online' && window.__shell?.arrival?.phase==='done' && (!cover || (getComputedStyle(cover).opacity==='0' && getComputedStyle(cover).visibility==='hidden'));
10
+ },{timeout:Math.max(1,end-Date.now())});
11
+ const frame=surface.page.frames().find(f=>/\/__game\//.test(f.url()));
12
+ if(!frame)throw new Error('app frame missing');
13
+ await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
14
+ surface.frame=frame;return;
15
+ } catch(e) {error=e;if(!/context|detach|navigat|frame missing/i.test(e.message))break;}
16
+ } while(Date.now()<end);
17
+ throw new Error(`${surface.name}: waiting for online app, arrival phase done, hidden [data-arrive], and two frames: ${error?.message}`);
18
+ }
19
+ /** Installed before any app script. Observes actual platform calls, without faking their outcome. */
20
+ export function observeEffects() {
21
+ window.__homieNeedsEffects=[];
22
+ const record=(effect,name)=>window.__homieNeedsEffects.push({effect,name});
23
+ addEventListener('homie-effect',e=>{if(e.detail?.effect)record(e.detail.effect,e.detail.name);});
24
+ const wrap=(obj,key,effect,name)=>{const original=obj?.[key];if(typeof original!=='function')return;try{obj[key]=function(...args){record(effect,typeof name==='function'?name(args):name);return Reflect.apply(original,this,args);};}catch{}};
25
+ wrap(globalThis.AudioScheduledSourceNode?.prototype,'start','sound','audio');
26
+ wrap(globalThis.HTMLMediaElement?.prototype,'play','sound','media');
27
+ wrap(navigator,'vibrate','haptic','vibrate');
28
+ if(globalThis.Notification) {const Original=Notification;globalThis.Notification=new Proxy(Original,{construct(target,args){record('notification',String(args[0]));return Reflect.construct(target,args);}});}
29
+ wrap(globalThis.ServiceWorkerRegistration?.prototype,'showNotification','notification',args=>String(args[0]));
30
+ }
31
+
32
+ /** Retry reads after navigation; never repeat a dispatched click or record mutation. */
33
+ export async function waitInApp(surface,label,read,{ready=appReady,timeout=60000}={}) {
34
+ const end=Date.now()+timeout;
35
+ for(;;) {
36
+ try {return await read(surface.frame);}
37
+ catch(error) {
38
+ if(Date.now()>=end||!/context.*destroy|detach|navigat|cannot find context/i.test(error.message))throw new Error(`${surface.name}: waiting for ${label}: ${error.message}`);
39
+ await ready(surface,{timeout:Math.max(1,end-Date.now())});
40
+ }
41
+ }
42
+ }
43
+ export function answerDialogs(page,surface,dialogs,report) {
44
+ page.on('dialog',async dialog=>{
45
+ const item={surface,type:dialog.type(),message:dialog.message(),answer:'accepted'};dialogs.push(item);
46
+ report?.check?.('dialog-'+dialogs.length,'pass',{note:`${surface}: automatically accepted ${item.type}: ${item.message}`});
47
+ await dialog.accept().catch(()=>{});
48
+ });
49
+ }
package/lib/build.mjs CHANGED
@@ -513,7 +513,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
513
513
  if (Object.hasOwn(studio, 'referrals')) log('studio.json referrals: ignored; Homie has no referral system.');
514
514
  if (listGames(root).some(g => isRulesGame(g) && g.room?.host !== 'browser' && g.room?.host !== 'offline')) await (await import('./rules-wiring.mjs')).ensureRulesWiring(root);
515
515
  await (await import('./tools-build.mjs')).buildTools(root, esbuild);
516
- await (await import('./functions-build.mjs')).buildFunctions(root, esbuild);
516
+ await (await import('./functions-build.mjs')).buildFunctions(root, esbuild, { log });
517
517
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
518
518
  const shop = shopForBuild(root, { log });
519
519
  const live = join(root, 'site', 'dist');
package/lib/check.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import {randomBytes} from 'node:crypto';
2
+ import {appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
3
+ import {localCredential,localCookie} from './local-test.mjs';
1
4
  import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
2
5
  /**
3
6
  * `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
@@ -128,7 +131,7 @@ export function judgeRounds(browsers, missed = new Map()) {
128
131
  return null;
129
132
  }
130
133
 
131
- export async function check({ root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
134
+ export async function check({ isolate = true, root = null, url, game, roundTimeoutMs = 150_000, rounds = 3, shots = null, userAgentTag = 'homie-studio-check', log = () => {}, report = null }) {
132
135
  if (!url || !game) throw new Error('usage: homie-studio check <game> --url <site url>');
133
136
  const base = String(url).replace(/\/+$/, '');
134
137
  const play = `${base}/${game}/play`;
@@ -149,7 +152,7 @@ export async function check({ root = null, url, game, roundTimeoutMs = 150_000,
149
152
  if (!chrome) return { ok: false, command: 'check', why: noChrome() };
150
153
  let puppeteer;
151
154
  try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'check', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
152
- if (appMeta) return checkApp({ root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
155
+ if (appMeta) return checkApp({ isolate, root, url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
153
156
  if (shots) mkdirSync(shots, { recursive: true });
154
157
  const profiles = [];
155
158
  const browsers = [];
@@ -344,59 +347,72 @@ export async function check({ root = null, url, game, roundTimeoutMs = 150_000,
344
347
  }
345
348
 
346
349
  /** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
347
- export async function checkApp({ root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
350
+ export async function checkApp({ isolate = true, root = null, url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
348
351
  let plan;
349
352
  try { plan = readAppNeeds(root, game); } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
350
- const proof = meta.check;
351
- if (!proof || typeof proof.action !== 'string' || typeof proof.observe !== 'string') return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
353
+ const secret=localCredential(root,url);
354
+ if(isolate && !secret)return {ok:false,command:'check',why:'Fresh isolated records are on by default. Run dev in this studio and check its localhost URL, or explicitly use --no-isolate.'};
355
+ log(isolate ? 'Fresh isolated records: on (this check only).' : 'Fresh isolated records: off; using existing records.');
356
+ const dialogs=[];
357
+ const proof = meta.check??{};
358
+ const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
359
+ 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' };
352
360
  if (shots) mkdirSync(shots, { recursive: true });
353
361
  const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
354
362
  let browser;
355
363
  const surfaces = [];
356
- const room = `check-${Date.now().toString(36)}`;
364
+ const room = `check-${randomBytes(10).toString('hex')}`;
357
365
  try {
358
366
  browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
359
- for (const [name, width, height, path] of [['wall', 1280, 720, 'tv'], ['phone', 390, 844, 'open'], ['phone-two', 390, 844, 'open']]) {
360
- const context = await browser.createBrowserContext();
361
- const page = await context.newPage();
362
- await page.setViewport({ width, height, isMobile: name !== 'wall', hasTouch: name !== 'wall' });
363
- const params = new URLSearchParams(proof.params ?? {}); params.set('room', room);
364
- await page.goto(`${url}/${game}/${path}?${params}`, { waitUntil: 'domcontentloaded', timeout: 90_000 });
365
- await page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
366
- const frame = page.frames().find((f) => /\/__game\//.test(f.url()));
367
- if (!frame) throw new Error(`${name} never loaded the app`);
368
- await frame.waitForSelector(proof.observe, { timeout: 30_000 });
369
- surfaces.push({ name, page, frame });
370
- log(`${name} connected to ${room}`);
371
- }
367
+ const openSurface = async (name, role) => {
368
+ const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
369
+ if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
370
+ role ??= meta.surfaces?.[name==='phone-two'?'phone':name] ?? (name==='staff'?'staff':undefined);
371
+ if(!meta.roles?.[role])throw new Error(`${name}: unknown role ${role}`);
372
+ if(meta.roles[role].signIn && !secret)throw new Error(`${name}: local staff proof needs dev's test session`);
373
+ const context=await browser.createBrowserContext();
374
+ if(secret)await context.setCookie({name:'homie_test',value:localCookie(secret,{app:game,role,scope:isolate?room:null}),url,httpOnly:true,sameSite:'Strict'});
375
+ const page=await context.newPage();
376
+ answerDialogs(page,name,dialogs,report);
377
+ await page.evaluateOnNewDocument(observeEffects);
378
+ const [width,height]=dimensions[name];
379
+ await page.setViewport({width,height,isMobile:name!=='wall',hasTouch:name!=='wall'});
380
+ const params=new URLSearchParams(proof.params??{});params.set('room',room);params.set('role',role);params.set('surface',name==='staff'?'tablet':name==='phone-two'?'phone':name);
381
+ await page.goto(`${url}/${game}/${name==='wall'?'tv':'open'}?${params}`,{waitUntil:'domcontentloaded',timeout:90000});
382
+ const surface={name,role,page};await appReady(surface);
383
+ surfaces.push(surface);log(`${name} connected as ${role} to ${room}`);return surface;
384
+ };
385
+ let before=null,after=null;
386
+ if(baseline) {
387
+ for(const name of ['wall','phone','phone-two'])await openSurface(name);
372
388
  const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
373
389
  const actor = surfaces[1];
374
- await actor.frame.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action);
375
- const before = await textOf(actor.frame);
390
+ await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
391
+ before = await waitInApp(actor,proof.observe,textOf);
376
392
  await actor.frame.click(proof.action);
377
- await actor.frame.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before);
378
- const after = await textOf(actor.frame);
379
- for (const s of surfaces) await s.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
393
+ await waitInApp(actor,proof.observe+' changes after action',f=>f.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before));
394
+ after = await waitInApp(actor,proof.observe,textOf);
395
+ 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));
380
396
  // The actor reloads with its ticket and room. The other screens remain live.
381
397
  await actor.page.reload({ waitUntil: 'domcontentloaded' });
382
- await actor.page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
383
- actor.frame = actor.page.frames().find((f) => /\/__game\//.test(f.url()));
384
- await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
398
+ await appReady(actor);
399
+ await waitInApp(actor,proof.observe+' retained after reload',f=>f.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after));
385
400
  const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
386
401
  if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
387
402
  for (const s of surfaces) {
388
- await s.page.waitForFunction(() => {
389
- const cover = document.querySelector('[data-arrive]');
390
- return window.__shell?.arrival?.phase === 'done' && (!cover || (getComputedStyle(cover).opacity === '0' && getComputedStyle(cover).visibility === 'hidden'));
391
- }, { timeout: READY_TIMEOUT_MS });
392
- await s.frame.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
403
+ await appReady(s);
393
404
  if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
394
405
  }
395
406
  report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
396
- const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report }) : null;
407
+ }
408
+ for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
409
+ const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
410
+ if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
411
+ }
412
+ const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady }) : null;
397
413
  if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
398
414
  const ok = !needs || needs.every(n => n.status === 'met');
399
- 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', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
400
- } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
415
+ 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 };
416
+ } catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
401
417
  finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
402
418
  }
@@ -201,6 +201,7 @@ export function deployPlan(root) {
201
201
  return {
202
202
  ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
203
203
  cloudflare: [
204
+ {kind:'Cron triggers',name:(readConfig(root)?.triggers?.crons??[]).join('; ')||'none',what:'Installs these UTC triggers; the Worker dispatches due functions and retries. Studio custom triggers are retained.',state:'installed on deploy'},
204
205
  { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`) },
205
206
  { kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`) },
206
207
  { kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
@@ -376,6 +377,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
376
377
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
377
378
 
378
379
  refreshWorkerConfig(root, {d1Id: db.uuid});
380
+ step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join("; ")||"none (remove toolkit triggers)"}`);
379
381
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
380
382
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
381
383
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -591,6 +593,7 @@ export function configNames(root) {
591
593
  */
592
594
  export async function ciDeploy(root, { log = () => {} } = {}) {
593
595
  const w = runner(root, {});
596
+ refreshWorkerConfig(root);
594
597
  const names = configNames(root);
595
598
  const steps = [];
596
599
  const step = (what) => { steps.push({ what }); log(what); };
@@ -602,6 +605,10 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
602
605
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
603
606
  if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
604
607
  if (!first) step('D1 migrations applied');
608
+ const register=()=>w(['d1','execute',names.binding,'--remote','--command',functionDeploymentSQL(root)]);
609
+ const {functionDeploymentSQL}=await import('./functions-build.mjs');
610
+ if(!first) {const registered=register();if(registered.code!==0)return refuse('Could not register studio functions',registered.out);}
611
+ step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join('; ')||'none (remove toolkit triggers)'}`);
605
612
  // Workers AI (0.17.0): bound only when a server's AI guides think with it. The checkout is thrown away after.
606
613
  if (!first && needsWorkersAi(root, w, names.binding)) {
607
614
  try {
@@ -620,6 +627,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
620
627
  migrate = apply();
621
628
  if (migrate.code !== 0) return refuse(`D1 migrations failed after the first deploy: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
622
629
  step('D1 migrations applied (the database was created with this first deploy)');
630
+ const registered=register();if(registered.code!==0)return refuse('Could not register studio functions',registered.out);
623
631
  }
624
632
  // The live address, from what Wrangler printed (a custom domain in studio.json wins).
625
633
  const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
package/lib/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {createLocalCredential} from './local-test.mjs';
1
2
  import {workerEntry} from './worker-build.mjs';
2
3
  import { networkInterfaces } from 'node:os';
3
4
  /**
@@ -132,7 +133,7 @@ export function devState(root) {
132
133
  export function runningDev(root) {
133
134
  const st = devState(root);
134
135
  if (st.state !== 'running' || !st.port) return null;
135
- return { url: `http://127.0.0.1:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
136
+ return { url: `http://localhost:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
136
137
  }
137
138
 
138
139
  async function endAll(pids, ms = 8000) {
@@ -292,15 +293,14 @@ function passLines(stream, to, opts) {
292
293
  * saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
293
294
  */
294
295
  export function rulesStamp(g) {
295
- const files = [join(g.dir, g.kind === 'app' ? 'app.json' : 'game.json'), join(g.dir, 'tunables.json')];
296
- for (const sub of ['src', 'map']) {
297
- const dir = join(g.dir, sub);
298
- if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
299
- }
300
- let newest = 0;
301
- let n = 0;
302
- for (const f of files) { try { const st = statSync(f); if (st.isFile()) { n += 1; newest = Math.max(newest, st.mtimeMs); } } catch { /* gone */ } }
303
- return `${n}:${Math.round(newest)}`;
296
+ const rows=[];
297
+ const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
298
+ if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
299
+ const file=join(dir,entry.name);
300
+ if(entry.isDirectory())walk(file);
301
+ else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
302
+ }};
303
+ walk(g.dir);return rows.sort().join('|');
304
304
  }
305
305
 
306
306
  const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { return null; } };
@@ -316,8 +316,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
316
316
  // What is really there, before anything is built or started (the header: stale state heals itself).
317
317
  const st = devState(root);
318
318
  if (st.state === 'running') {
319
- const at = st.port ? `http://127.0.0.1:${st.port}/` : 'an unknown port';
320
- return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://127.0.0.1:${st.port}` } : {}), why: `this studio's dev server is already running at ${at} (process ${st.pids.join(', ')}). Use that one, or stop it first: ${stop}` };
319
+ const at = st.port ? `http://localhost:${st.port}/` : 'an unknown port';
320
+ return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://localhost:${st.port}` } : {}), why: `this studio's dev server is already running at ${at} (process ${st.pids.join(', ')}). Use that one, or stop it first: ${stop}` };
321
321
  }
322
322
  if (st.state === 'stale') {
323
323
  rmSync(st.file, { force: true });
@@ -329,7 +329,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
329
329
  if (p === Number(port) && left.open) {
330
330
  const busy = port;
331
331
  do { port = String(Number(port) + 1); } while (Number(port) < 65535 && await portOpen(Number(port)));
332
- log(`Port ${busy} is busy; using http://127.0.0.1:${port}/`);
332
+ log(`Port ${busy} is busy; using http://localhost:${port}/`);
333
333
  }
334
334
  }
335
335
 
@@ -348,7 +348,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
348
348
  m.on('error', done);
349
349
  });
350
350
  const origin = `http://127.0.0.1:${port}`;
351
- log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
351
+ const printedOrigin = `http://localhost:${port}`;
352
+ log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
352
353
  if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
353
354
  const ai = devConfig(root, remoteAi);
354
355
  if (ai.note) log(ai.note);
@@ -362,7 +363,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
362
363
  }
363
364
  // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
364
365
  // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
365
- const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
366
+ const localSecret = createLocalCredential(root);
367
+ const args = ['dev', '--var', `HOMIE_LOCAL_TEST_SECRET:${localSecret}`, ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
366
368
  log(`Stop it with: ${stop} (this studio's dev server only)`);
367
369
 
368
370
  let child = null;
@@ -417,13 +419,13 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
417
419
  } finally { busy = false; }
418
420
  };
419
421
  // A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
420
- const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
422
+ const stamps = new Map(listGames(root).map((g) => [g.id, rulesStamp(g)]));
421
423
  const timingRetries = new Map();
422
424
  let rebuilding = false;
423
425
  let published = Promise.resolve();
424
426
  const rulesLook = async () => {
425
427
  if (rebuilding || ending) return;
426
- for (const g of listGames(root).filter(isRulesGame)) {
428
+ for (const g of listGames(root)) {
427
429
  const now = rulesStamp(g);
428
430
  const retry = timingRetries.get(g.id);
429
431
  if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
@@ -31,7 +31,11 @@ export async function eventsWebhook(root,action,name,url,{run,connect=connectToo
31
31
  writeStudio(root,studio);
32
32
  return {ok:true,command:'events webhook '+action,feed:name,...(secret?{secret}:{}),next:action==='rotate'?'Key replaced in the Worker. Configure the receiver with this key; it is shown only here.':'Build, commit and deploy the updated feed declaration through your usual authorized workflow.'};
33
33
  }
34
- export function eventsCommand(root,sub,positional,flags,options={}) {
34
+ export async function eventsCommand(root,sub,positional,flags,options={}) {
35
+ if(sub==='clear'||sub==='read') {
36
+ const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',options);
37
+ try {const result=await client.callTool({name:'studio_events_'+sub,arguments:sub==='clear'?{through:Number(flags.get('through'))}:{feed:positional[2],after:Number(flags.get('after')??0)}});return {ok:!result.isError,result};} finally {await client.close();}
38
+ }
35
39
  if(sub!=='webhook')throw new Error('Use events webhook <add|remove|rotate|status|test>');
36
40
  return eventsWebhook(root,positional[2],positional[3],positional[4]??flags.get('url'),{...options,after:flags.get('after')??0});
37
41
  }
@@ -1,12 +1,13 @@
1
1
  import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
2
+ import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
2
3
  import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
3
4
  import {join} from 'node:path';
4
- import {readStudio} from './studio.mjs';
5
+ import {readStudio,configPath} from './studio.mjs';
5
6
  import {pathToFileURL} from 'node:url';
6
7
  import {createRequire} from 'node:module';
7
8
  const require=createRequire(import.meta.url);
8
9
  export function functionFiles(root) { const dir=join(root,'functions');return existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]; }
9
- export async function buildFunctions(root,esbuild) {
10
+ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
10
11
  const feeds=readStudio(root).mcp?.events??{};
11
12
  if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
12
13
  for(const [name,feed] of Object.entries(feeds)) {
@@ -29,13 +30,19 @@ export async function buildFunctions(root,esbuild) {
29
30
  const names=new Set();
30
31
  for(const fn of definitions) {
31
32
  if(!/^[a-z][a-z0-9-]*$/.test(fn.name)||names.has(fn.name)||typeof fn.handler!=='function'||typeof fn.event!=='string'||!fn.event.trim())throw new Error('Function needs a unique name, event and handler');
32
- if(fn.schedule && (typeof fn.schedule!=='string'||fn.schedule.trim().split(/\s+/).length!==5))throw new Error('Function schedule needs a Cloudflare cron expression');
33
+ if(fn.schedule!==undefined) {
34
+ try {if(nextDue(parseSchedule(fn.schedule))===null)throw new Error('no matching date');}
35
+ catch(error) {throw new Error(`Function ${fn.name} cannot run: invalid schedule ${JSON.stringify(fn.schedule)} (${error.message})`);}
36
+ }
33
37
  names.add(fn.name);
34
38
  }
35
39
  const worker=join(root,'site/src/worker.mjs');
36
40
  if(existsSync(worker)) {let text=readFileSync(worker,'utf8');if(!text.includes('useFunctions(')){text+="\nimport { useFunctions } from '@homie-rocks/studio/worker';\nuseFunctions(async () => (await import('./functions/index.mjs')).default);\n";writeFileSync(worker,text);}}
37
41
  writeFileSync(join(dir,'declarations.json'),JSON.stringify(definitions.map(({name,event,schedule,replay})=>({name,event,schedule,replay})),null,2)+'\n');
38
42
  (await import('./worker-config.mjs')).refreshWorkerConfig(root);
43
+ const config=existsSync(configPath(root))?JSON.parse(readFileSync(configPath(root),'utf8').replace(/^\s*\/\/.*$/gm,'')):{};
44
+ for(const status of scheduleStatus(definitions,config.triggers?.crons??[]))log(`Function ${status.function}: ${status.schedule} UTC; ${status.status}; next due ${status.nextDue??'none'}`);
45
+ if(definitions.length)log(`Function cron triggers to install on deploy: ${(config.triggers?.crons??[]).join('; ')||'none'}`);
39
46
  return definitions;
40
47
  }
41
48
  /** Install subscriptions before the new Worker can produce its first event. */
@@ -1,7 +1,13 @@
1
1
  import {existsSync,mkdirSync,writeFileSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
+ import {siteUrl} from './studio.mjs';
3
4
  import {connectToolClient} from './tools-cli.mjs';
4
- export async function functionCommand(root,sub,positional,flags,{log=()=>{}}={}) {
5
+ export async function functionCommand(root,sub,positional,flags,{log=()=>{},connect=connectToolClient}={}) {
6
+ if(sub==='runs') {
7
+ const url=flags.get('url')??siteUrl(root)??'http://localhost:8787';
8
+ const client=await connect(root,new URL(url),{log});
9
+ try {const result=await client.callTool({name:'studio_function_runs',arguments:{...(positional[2]?{name:positional[2]}:{}),errors:flags.has('errors'),...(flags.has('before')?{before:Number(flags.get('before'))}:{})}});return {ok:!result.isError,command:'functions runs',result};}finally{await client.close();}
10
+ }
5
11
  if(sub==='new') {
6
12
  const name=positional[2],event=flags.get('event')??'order.paid';
7
13
  if(!/^[a-z][a-z0-9-]*$/.test(name??''))throw new Error('Use a lowercase function name');
@@ -12,12 +18,12 @@ export async function functionCommand(root,sub,positional,flags,{log=()=>{}}={})
12
18
  }
13
19
  if(sub==='replay') {
14
20
  if(!flags.has('replay'))throw new Error('Pass --replay to explicitly request retained history');
15
- const client=await connectToolClient(root,new URL(flags.get('url')??'http://localhost:8787'),{log});
21
+ const client=await connect(root,new URL(flags.get('url')??'http://localhost:8787'),{log});
16
22
  try {const result=await client.callTool({name:'studio_function_replay',arguments:{name:positional[2]}});return {ok:!result.isError,command:'function replay',result};}finally{await client.close();}
17
23
  }
18
24
  if(sub!=='fire')throw new Error('Use function new <name> --event <event>, or function fire <event> --input <JSON>');
19
25
  const url=new URL(flags.get('url')??'http://localhost:8787');
20
26
  if(!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Fire is local testing only');
21
- const client=await connectToolClient(root,url,{log});
27
+ const client=await connect(root,url,{log});
22
28
  try {const result=await client.callTool({name:'studio_function_fire',arguments:{event:positional[2],data:JSON.parse(flags.get('input')??'{}'),...(flags.has('id')?{id:flags.get('id')}: {})}});return {ok:!result.isError,command:'function fire',result};}finally{await client.close();}
23
29
  }
@@ -0,0 +1,18 @@
1
+ import {readFileSync,mkdirSync,writeFileSync,chmodSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {randomBytes,createHmac} from 'node:crypto';
4
+ export function createLocalCredential(root) {
5
+ const secret=randomBytes(32).toString('hex');
6
+ mkdirSync(join(root,'.studio'),{recursive:true});
7
+ const file=join(root,'.studio','local-test.json');
8
+ writeFileSync(file,JSON.stringify({secret}),{mode:0o600});chmodSync(file,0o600);
9
+ return secret;
10
+ }
11
+ export function localCredential(root,site) {
12
+ if(!root||!['localhost','127.0.0.1','[::1]'].includes(new URL(site).hostname))return null;
13
+ try{return JSON.parse(readFileSync(join(root,'.studio','local-test.json'),'utf8')).secret;}catch{return null;}
14
+ }
15
+ export function localCookie(secret,session) {
16
+ const data=Buffer.from(JSON.stringify(session)).toString('base64url');
17
+ return data+'.'+createHmac('sha256',secret).update(data).digest('hex');
18
+ }
package/lib/office.mjs CHANGED
@@ -29,7 +29,7 @@ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
29
29
  /** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
30
30
  export async function withKey(root, url, fn) {
31
31
  const { studio, site } = siteOf(root, url);
32
- if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://127.0.0.1:8787 for `npm run dev`' };
32
+ if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://localhost:8787 for `npm run dev`' };
33
33
  const local = loopback(site);
34
34
  const k = mint(root, studio, 'office', 10 * 60_000, { local });
35
35
  let expiresAt = Date.now() + 10 * 60_000;
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
+ import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
2
+ import { FUNCTIONS_RUNS_MIGRATION, FUNCTIONS_RUNS_MIGRATION_FILE, FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
2
3
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
3
4
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
4
5
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -820,11 +821,13 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
820
821
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
821
822
  [`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
822
823
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
824
+ [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
823
825
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
824
826
  [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
825
827
  [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
826
828
  [`site/migrations/${FUNCTIONS_META_MIGRATION_FILE}`]: FUNCTIONS_META_MIGRATION,
827
829
  [`site/migrations/${FUNCTIONS_REFERENCE_MIGRATION_FILE}`]: FUNCTIONS_REFERENCE_MIGRATION,
830
+ [`site/migrations/${FUNCTIONS_RUNS_MIGRATION_FILE}`]: FUNCTIONS_RUNS_MIGRATION,
828
831
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
829
832
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
830
833
  '.claude/skills/.gitkeep': '',
@@ -996,6 +999,11 @@ function ensurePartsStateMigration(root) {
996
999
  return `site/migrations/${PURCHASE_STATE_FILE}`;
997
1000
  }
998
1001
 
1002
+ function ensureFunctionRuns(root) {
1003
+ const file=join(root,'site','migrations',FUNCTIONS_RUNS_MIGRATION_FILE);
1004
+ if(existsSync(file))return null;
1005
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_RUNS_MIGRATION);return `site/migrations/${FUNCTIONS_RUNS_MIGRATION_FILE}`;
1006
+ }
999
1007
  function ensureFunctionReference(root) {
1000
1008
  const file=join(root,'site','migrations',FUNCTIONS_REFERENCE_MIGRATION_FILE);
1001
1009
  if(existsSync(file))return null;
@@ -1021,8 +1029,13 @@ function ensureMcpMigration(root) {
1021
1029
  if(existsSync(file))return null;
1022
1030
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
1023
1031
  }
1032
+ function ensureToolDatabase(root) {
1033
+ const file=join(root,'site','migrations',TOOL_DATABASE_MIGRATION_FILE);
1034
+ if(existsSync(file))return null;
1035
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
1036
+ }
1024
1037
  export function ensureMigrations(root) {
1025
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root)].filter(Boolean);
1038
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root)].filter(Boolean);
1026
1039
  }
1027
1040
 
1028
1041
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -3408,6 +3408,90 @@
3408
3408
  }
3409
3409
  }
3410
3410
  }
3411
+ },
3412
+ "0.50.6": {
3413
+ "files": {
3414
+ "CLAUDE.md": "ddf2b2a0376202f6",
3415
+ "README.md": "684b3cca8001673c",
3416
+ "HANDOFF.md": "29337e2ace95b2e7",
3417
+ "games/README.md": "5ad32fe80c61f117",
3418
+ "music/README.md": "a3dc042dadd39df8",
3419
+ "videos/README.md": "16babc3437d5545b",
3420
+ "posts/README.md": "37841aae476bd540",
3421
+ "site/README.md": "1dbdf22c03a1e505",
3422
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3423
+ },
3424
+ "sections": {
3425
+ "AGENTS.md": {
3426
+ "(intro)": "394cbbe934e80722",
3427
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3428
+ "## Layout": "f3a280b8c96cd068",
3429
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3430
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3431
+ "## The site": "0cb8dd9a6f8a0096",
3432
+ "## Making games": "bb715272fd1a615b",
3433
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3434
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3435
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3436
+ "## Servers and AI seats": "1ccfd549d2369a60",
3437
+ "## Selling things (the shop)": "43a3556be93d1235",
3438
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3439
+ "## Rules": "b2234d80f6cbe71d",
3440
+ "## Telling Homie": "80b46fdd9e578db8",
3441
+ "## Beta": "9d02638207a26e73"
3442
+ }
3443
+ },
3444
+ "named": {
3445
+ "Homie Arcade": {
3446
+ "sections": {
3447
+ "AGENTS.md": {
3448
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3449
+ }
3450
+ }
3451
+ }
3452
+ }
3453
+ },
3454
+ "0.50.7": {
3455
+ "files": {
3456
+ "CLAUDE.md": "ddf2b2a0376202f6",
3457
+ "README.md": "684b3cca8001673c",
3458
+ "HANDOFF.md": "29337e2ace95b2e7",
3459
+ "games/README.md": "5ad32fe80c61f117",
3460
+ "music/README.md": "a3dc042dadd39df8",
3461
+ "videos/README.md": "16babc3437d5545b",
3462
+ "posts/README.md": "37841aae476bd540",
3463
+ "site/README.md": "1dbdf22c03a1e505",
3464
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3465
+ },
3466
+ "sections": {
3467
+ "AGENTS.md": {
3468
+ "(intro)": "394cbbe934e80722",
3469
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3470
+ "## Layout": "f3a280b8c96cd068",
3471
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3472
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3473
+ "## The site": "0cb8dd9a6f8a0096",
3474
+ "## Making games": "bb715272fd1a615b",
3475
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3476
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3477
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3478
+ "## Servers and AI seats": "1ccfd549d2369a60",
3479
+ "## Selling things (the shop)": "43a3556be93d1235",
3480
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3481
+ "## Rules": "b2234d80f6cbe71d",
3482
+ "## Telling Homie": "80b46fdd9e578db8",
3483
+ "## Beta": "9d02638207a26e73"
3484
+ }
3485
+ },
3486
+ "named": {
3487
+ "Homie Arcade": {
3488
+ "sections": {
3489
+ "AGENTS.md": {
3490
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3491
+ }
3492
+ }
3493
+ }
3494
+ }
3411
3495
  }
3412
3496
  }
3413
3497
  }
package/lib/tools-cli.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {localCredential} from './local-test.mjs';
1
2
  import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
2
3
  import {join,dirname} from 'node:path';
3
4
  import {createServer} from 'node:http';
@@ -16,6 +17,8 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
16
17
  const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
17
18
  const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
18
19
  if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
20
+ const secret=localCredential(root,url);
21
+ if(secret){await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
19
22
  const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
20
23
  let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
21
24
  // Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.