@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.
- package/CHANGELOG.md +40 -0
- package/apps/APPS.md +45 -1
- package/bin/homie-studio.mjs +7 -4
- package/functions/FUNCTIONS.md +108 -15
- package/guides/app/SKILL.md +48 -4
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +37 -0
- package/guides/servers/SKILL.md +35 -0
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/build.mjs +1 -1
- package/lib/check.mjs +51 -35
- package/lib/cloudflare.mjs +8 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/functions-build.mjs +10 -3
- package/lib/functions-cli.mjs +9 -3
- package/lib/local-test.mjs +18 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +15 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/lib/worker-config.mjs +20 -3
- package/package.json +1 -1
- package/shop/SHOP.md +1 -1
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +2 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +16 -9
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/function-schedule.mjs +68 -0
- package/worker/functions-schema.mjs +13 -0
- package/worker/functions.mjs +50 -11
- package/worker/index.mjs +13 -2
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +24 -9
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +12 -0
- package/worker/shop-page.mjs +2 -2
- package/worker/shop.mjs +1 -0
- package/worker/tool-database.mjs +36 -0
- 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
|
|
351
|
-
if
|
|
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-${
|
|
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
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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.
|
|
375
|
-
|
|
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.
|
|
378
|
-
|
|
379
|
-
for (const s of surfaces) await s.
|
|
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
|
|
383
|
-
actor.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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://
|
|
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
|
|
296
|
-
for
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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://
|
|
320
|
-
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://
|
|
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://
|
|
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
|
-
|
|
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
|
|
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).
|
|
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)
|
|
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;
|
package/lib/events-cli.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/functions-build.mjs
CHANGED
|
@@ -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
|
|
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. */
|
package/lib/functions-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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://
|
|
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 {
|
|
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.
|