@homie-rocks/studio 0.51.8 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/docs/toolkit-place-patterns.md +6 -0
- package/functions/FUNCTIONS.md +27 -0
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +123 -0
- package/guides/app/SKILL.md +3 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/lib/app-needs.mjs +41 -6
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +6 -6
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +6 -1
- package/lib/functions-build.mjs +3 -0
- package/lib/mcp-tools.mjs +2 -2
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +4 -4
- package/lib/shop-mcp.mjs +82 -14
- package/lib/stripe-connect.mjs +9 -4
- package/lib/template-history.json +42 -0
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +6 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/package.json +1 -1
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +27 -4
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/booking-holds.mjs +87 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +19 -4
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +3 -3
- package/worker/pages.mjs +4 -4
- package/worker/records-page.mjs +2 -2
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/lib/check.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import {needsHeadless} from './needs-headless.mjs';
|
|
|
2
2
|
import {randomBytes} from 'node:crypto';
|
|
3
3
|
import {actInApp,appReady,observeEffects,forceWebglFailure,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
4
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
5
|
-
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
|
+
import { readAppNeeds, proveAppNeeds, saveAppNeeds, needSteps } from './app-needs.mjs';
|
|
6
6
|
/**
|
|
7
7
|
* `homie-studio check [<game>] [--url <site> | --live]` — the promise, measured (where it runs and on which games
|
|
8
8
|
* when nobody says: lib/check-site.mjs; never the live site unless asked): two fresh
|
|
@@ -152,7 +152,7 @@ export async function check({ webglFailure = false, isolate = true, root = null,
|
|
|
152
152
|
if(appMeta && !appMeta.check?.action) {
|
|
153
153
|
try {
|
|
154
154
|
const plan=readAppNeeds(root,game);
|
|
155
|
-
if(plan && !plan.needs.some(n=>[...(n.steps
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
156
|
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
157
|
}
|
|
158
158
|
const chrome = findChrome();
|
|
@@ -365,14 +365,14 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
|
|
|
365
365
|
const proof = meta.check??{};
|
|
366
366
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
367
367
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
368
|
-
if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...(n.steps
|
|
368
|
+
if(webglFailure&&!baseline&&!plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
|
|
369
369
|
const captureWarnings=[];
|
|
370
370
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
371
371
|
let browser;
|
|
372
372
|
const surfaces = [];
|
|
373
373
|
const room = `check-${randomBytes(10).toString('hex')}`;
|
|
374
374
|
try {
|
|
375
|
-
if(baseline || plan?.needs.some(n=>[...(n.steps
|
|
375
|
+
if(baseline || plan?.needs.some(n=>[...needSteps(n.steps),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
376
376
|
const openSurface = async (name, role) => {
|
|
377
377
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
378
378
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
@@ -417,13 +417,13 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
|
|
|
417
417
|
}
|
|
418
418
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
419
419
|
}
|
|
420
|
-
for(const need of plan?.needs??[])for(const item of [...(need.steps
|
|
420
|
+
for(const need of plan?.needs??[])for(const item of [...needSteps(need.steps),...(need.assertions??[])]) {
|
|
421
421
|
if(!item.surface)continue;
|
|
422
422
|
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
423
423
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
424
424
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
425
425
|
}
|
|
426
|
-
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
426
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)), headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
427
427
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
428
428
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
429
429
|
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -595,8 +595,8 @@ export function configNames(root) {
|
|
|
595
595
|
* nothing back (a CI checkout is thrown away). On the very first deploy the database may not exist yet: Wrangler
|
|
596
596
|
* creates the one the binding names while it deploys, and the migrations run right after.
|
|
597
597
|
*/
|
|
598
|
-
export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
599
|
-
const w = runner(root, {});
|
|
598
|
+
export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
599
|
+
const w = wrangler ?? runner(root, {});
|
|
600
600
|
refreshWorkerConfig(root);
|
|
601
601
|
const names = configNames(root);
|
|
602
602
|
const steps = [];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {localCredential} from './local-test.mjs';
|
|
5
|
+
const read=file=>{try{return JSON.parse(readFileSync(file,'utf8'));}catch{return null;}};
|
|
6
|
+
export function takeDevRestart(root,secret){
|
|
7
|
+
const file=join(root,'.studio','restart-request.json'),value=read(file);
|
|
8
|
+
if(!value)return null;
|
|
9
|
+
rmSync(file,{force:true});
|
|
10
|
+
return value.secret===secret&&typeof value.id==='string'?value.id:null;
|
|
11
|
+
}
|
|
12
|
+
export function acknowledgeDevRestart(root,id){writeFileSync(join(root,'.studio','restart-result.json'),JSON.stringify({id}),{mode:0o600});}
|
|
13
|
+
export async function requestDevRestart(root,url,{timeout=60000}={}){
|
|
14
|
+
const secret=localCredential(root,url);if(!secret)throw new Error('Restart requires this studio’s local dev credential');
|
|
15
|
+
const id=randomUUID(),file=join(root,'.studio','restart-request.json');
|
|
16
|
+
writeFileSync(file,JSON.stringify({id,secret}),{mode:0o600});
|
|
17
|
+
try{
|
|
18
|
+
const end=Date.now()+timeout;
|
|
19
|
+
while(Date.now()<end){
|
|
20
|
+
if(read(join(root,'.studio','restart-result.json'))?.id===id)return;
|
|
21
|
+
await new Promise(resolve=>setTimeout(resolve,100));
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Dev did not acknowledge restart; run the current homie-studio dev');
|
|
24
|
+
}finally{if(read(file)?.id===id)rmSync(file,{force:true});}
|
|
25
|
+
}
|
package/lib/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {takeDevRestart,acknowledgeDevRestart} from './dev-restart.mjs';
|
|
1
2
|
import {createLocalCredential} from './local-test.mjs';
|
|
2
3
|
import {workerEntry} from './worker-build.mjs';
|
|
3
4
|
import { networkInterfaces } from 'node:os';
|
|
@@ -371,7 +372,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
371
372
|
|
|
372
373
|
let child = null;
|
|
373
374
|
let ending = false;
|
|
374
|
-
let restart = null;
|
|
375
|
+
let restart = null, proofRestart = null;
|
|
375
376
|
let failed = null;
|
|
376
377
|
const lineState = {};
|
|
377
378
|
// Stopping this process stops Wrangler with it, so no dev server is left running on its own.
|
|
@@ -395,6 +396,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
395
396
|
if (busy || ending || restart || !up) return;
|
|
396
397
|
busy = true;
|
|
397
398
|
try {
|
|
399
|
+
const requested=takeDevRestart(root,localSecret);
|
|
400
|
+
if(requested){proofRestart=requested;restart='proof';child?.kill('SIGTERM');return;}
|
|
401
|
+
|
|
398
402
|
const built = builtIds(root);
|
|
399
403
|
if (!built) return; // mid-build: site/dist is being written
|
|
400
404
|
for (const g of listGames(root)) {
|
|
@@ -490,6 +494,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
490
494
|
await new Promise((r) => setTimeout(r, 500));
|
|
491
495
|
}
|
|
492
496
|
if (!up) return;
|
|
497
|
+
if(proofRestart){acknowledgeDevRestart(root,proofRestart);proofRestart=null;}
|
|
493
498
|
const game = (builtIds(root) ?? [])[0];
|
|
494
499
|
if (!game) return;
|
|
495
500
|
const bad = await socketOriginProblem(origin, game);
|
package/lib/functions-build.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
1
2
|
import {callbackUrl} from '../worker/mcp-events-wire.mjs';
|
|
2
3
|
import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
|
|
3
4
|
import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
|
|
@@ -12,6 +13,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
12
13
|
const feeds=readStudio(root).mcp?.events??{};
|
|
13
14
|
if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
|
|
14
15
|
for(const [name,feed] of Object.entries(feeds)) {
|
|
16
|
+
if(feed?.schema)new CfWorkerJsonSchemaValidator().getValidator(feed.schema);
|
|
15
17
|
if(feed?.webhook)validateWebhook(name,feed.webhook);
|
|
16
18
|
if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
|
|
17
19
|
if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
|
|
@@ -37,6 +39,7 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
|
|
|
37
39
|
try {if(nextDue(parseSchedule(fn.schedule))===null)throw new Error('no matching date');}
|
|
38
40
|
catch(error) {throw new Error(`Function ${fn.name} cannot run: invalid schedule ${JSON.stringify(fn.schedule)} (${error.message})`);}
|
|
39
41
|
}
|
|
42
|
+
for(const name of fn.publish??[])if(!feeds[name]?.schema)throw new Error(`Function ${fn.name}: publish feed ${name} needs a declared schema`);
|
|
40
43
|
names.add(fn.name);
|
|
41
44
|
}
|
|
42
45
|
const worker=join(root,'site/src/worker.mjs');
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1178,8 +1178,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1178
1178
|
},
|
|
1179
1179
|
{
|
|
1180
1180
|
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
-
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write,
|
|
1182
|
-
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {method,path,params,body}, account ID, mode, and complete booking slots. Returns every independent operation in a dependency-ordered batch, with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, supply new write responses as observations and replan until verified; earlier public observations are saved automatically. refresh:true checks external Stripe changes using paginated expanded link lists. verify:true saves compact public config for deploy and stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
|
+
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'},refresh:{type:'boolean',description:'Discard saved observations and read current Stripe state (after Dashboard edits or before private webhook installation).'}}},
|
|
1183
1183
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
1184
|
run:async a=>{
|
|
1185
1185
|
const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
|
|
2
|
+
import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
|
|
3
|
+
import {APP_LIVE_MIGRATION,APP_LIVE_MIGRATION_FILE} from '../worker/app-streams.mjs';
|
|
1
4
|
import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
|
|
2
5
|
import { SUBSCRIPTIONS_MIGRATION, SUBSCRIPTIONS_MIGRATION_FILE } from '../worker/shop-subscriptions.mjs';
|
|
3
6
|
import { BOOKINGS_MIGRATION, BOOKINGS_MIGRATION_FILE } from '../worker/booking-store.mjs';
|
|
@@ -823,6 +826,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
823
826
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
824
827
|
[`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
|
|
825
828
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
829
|
+
[`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
|
|
830
|
+
[`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
|
|
831
|
+
[`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
|
|
826
832
|
[`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
|
|
827
833
|
[`site/migrations/${BOOKINGS_MIGRATION_FILE}`]: BOOKINGS_MIGRATION,
|
|
828
834
|
[`site/migrations/${SUBSCRIPTIONS_MIGRATION_FILE}`]: SUBSCRIPTIONS_MIGRATION,
|
|
@@ -1048,8 +1054,23 @@ function ensureToolDatabase(root) {
|
|
|
1048
1054
|
if(existsSync(file))return null;
|
|
1049
1055
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
|
|
1050
1056
|
}
|
|
1057
|
+
function ensureAppLive(root) {
|
|
1058
|
+
const file=join(root,'site','migrations',APP_LIVE_MIGRATION_FILE);
|
|
1059
|
+
if(existsSync(file))return null;
|
|
1060
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,APP_LIVE_MIGRATION);return `site/migrations/${APP_LIVE_MIGRATION_FILE}`;
|
|
1061
|
+
}
|
|
1062
|
+
function ensureBusinessEvents(root) {
|
|
1063
|
+
const file=join(root,'site','migrations',BUSINESS_EVENTS_MIGRATION_FILE);
|
|
1064
|
+
if(existsSync(file))return null;
|
|
1065
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BUSINESS_EVENTS_MIGRATION);return `site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`;
|
|
1066
|
+
}
|
|
1067
|
+
function ensureBookingHolds(root) {
|
|
1068
|
+
const file=join(root,'site','migrations',BOOKING_HOLDS_MIGRATION_FILE);
|
|
1069
|
+
if(existsSync(file))return null;
|
|
1070
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_HOLDS_MIGRATION);return `site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`;
|
|
1071
|
+
}
|
|
1051
1072
|
export function ensureMigrations(root) {
|
|
1052
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root)].filter(Boolean);
|
|
1073
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root)].filter(Boolean);
|
|
1053
1074
|
}
|
|
1054
1075
|
|
|
1055
1076
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
|
|
|
7
7
|
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
8
|
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
9
|
export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
|
|
10
|
-
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false,
|
|
10
|
+
export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false, scoped = false, knownPortal }) {
|
|
11
11
|
if(scoped) {
|
|
12
12
|
const transport=api;
|
|
13
13
|
api=(method,path,params={},idempotency)=>transport(method,path,method==='POST'&&path.startsWith('/v1/payment_links/')?{...params,expand:['line_items']}:params,idempotency);
|
|
@@ -44,7 +44,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
44
44
|
const [products,prices,links]=await parallel([
|
|
45
45
|
()=>scoped?search('products'):list('/v1/products'),
|
|
46
46
|
()=>scoped?search('prices'):list('/v1/prices'),
|
|
47
|
-
()=>
|
|
47
|
+
()=>list('/v1/payment_links',scoped?{expand:['data.line_items']}:{}),
|
|
48
48
|
]);
|
|
49
49
|
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
50
50
|
const verifyLink = async (link, params) => {
|
|
@@ -258,9 +258,9 @@ export async function syncPaymentLinks(options) {
|
|
|
258
258
|
secret = endpoint.secret;
|
|
259
259
|
}
|
|
260
260
|
const fingerprint = hash({config, purchases:purchases?.config});
|
|
261
|
-
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
|
|
261
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && (options.catalog || has('STRIPE_SHOP_LINKS')) && !has('STRIPE_KEY');
|
|
262
262
|
if (!unchanged) {
|
|
263
|
-
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
263
|
+
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', ...(options.catalog ? {} : {STRIPE_SHOP_LINKS: JSON.stringify(config)}), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
264
264
|
if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
|
|
265
265
|
// Explicitly running connect selects keyless; manual selects the existing keyed checkout.
|
|
266
266
|
if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
|
package/lib/shop-mcp.mjs
CHANGED
|
@@ -6,11 +6,11 @@ import {join} from 'node:path';
|
|
|
6
6
|
import {readShop, SHOP_HOOK_EVENTS} from './shop.mjs';
|
|
7
7
|
import {readStudio, siteUrl} from './studio.mjs';
|
|
8
8
|
import {reconcilePaymentLinks} from './shop-links.mjs';
|
|
9
|
-
import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
9
|
+
import {fullerReason, linkSettings, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
|
|
10
10
|
const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
|
|
11
11
|
export const readKey = (path, params) => canonicalJson([path,params]);
|
|
12
12
|
const slotsOf=(root,slots)=>slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
|
|
13
|
-
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
|
|
13
|
+
export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false, refresh = false, account} = {}) {
|
|
14
14
|
const checked=readShop(root);
|
|
15
15
|
if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
|
|
16
16
|
if(!['test','live'].includes(mode))throw new Error('Choose test or live mode');
|
|
@@ -26,8 +26,38 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
26
26
|
const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
|
|
27
27
|
// Reject secrets before persisting observations. Webhook creation is never part of this plan.
|
|
28
28
|
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
29
|
+
let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
|
|
30
|
+
const cacheFile=join(root,'.studio','stripe-mcp-observations.json');
|
|
31
|
+
let cache;try{cache=JSON.parse(readFileSync(cacheFile,'utf8'));}catch{}
|
|
32
|
+
const source=hash({shop:checked.shop,slots:bookingSlots});
|
|
33
|
+
const reusable=!refresh && cache?.site===site && cache.mode===mode && (!account || cache.account===account);
|
|
34
|
+
const legacy=!refresh && saved?.result?.site===site && saved.result.mode===mode && (!account || saved.options?.account===account);
|
|
35
|
+
let prior=reusable?cache.observations:legacy?saved.options?.observations??[]:[];
|
|
36
|
+
// Catalogue edits retain product/price discovery, but refresh mutable slot counts and writes.
|
|
37
|
+
const legacySourceMatches=legacy && (saved.source===source || saved.result.fingerprint===hash({shop:checked.shop,bookingSlots,site,mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}}));
|
|
38
|
+
if(reusable && cache.source!==source || !reusable && legacy && !legacySourceMatches)prior=prior.filter(o=>/\/v1\/(products|prices)(\/|$)/.test(o.path));
|
|
39
|
+
observations=[...prior,...observations];
|
|
40
|
+
if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
|
|
41
|
+
// Latest response per operation, bounded by operations rather than replanning attempts.
|
|
42
|
+
const latest=new Map();
|
|
43
|
+
for(const o of observations){const key=canonicalJson([o.method??'GET',o.path,o.params??{}]);latest.delete(key);latest.set(key,o);}
|
|
44
|
+
observations=[...latest.values()];
|
|
45
|
+
const observedAt=refresh?new Date().toISOString():reusable?cache.observedAt:legacy?saved.result.verifiedAt??new Date().toISOString():new Date().toISOString();
|
|
46
|
+
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
47
|
+
writeFileSync(cacheFile,JSON.stringify({site,mode,account:account??cache?.account??saved?.options?.account,source,observedAt,observations}));
|
|
29
48
|
const api=async(method,path,params={},idempotency)=>{
|
|
30
49
|
let found=observations.findLast(o=>(o.method??'GET')===method && readKey(o.path,o.params??{})===readKey(path,params));
|
|
50
|
+
// Upgrade old list reads to the scoped searches, and hydrate old link lists
|
|
51
|
+
// from the expanded reads already saved by 0.51.6.
|
|
52
|
+
if(!found && method==='GET' && /\/v1\/(products|prices)\/search$/.test(path) && !params.page)
|
|
53
|
+
found=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path.replace('/search','') && o.body?.has_more===false);
|
|
54
|
+
if(!found && method==='GET' && path==='/v1/payment_links' && !params.starting_after) {
|
|
55
|
+
const old=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===path && o.body?.has_more===false);
|
|
56
|
+
if(old) {
|
|
57
|
+
const data=old.body.data.map(l=>observations.findLast(o=>o.path===`${path}/${l.id}`)?.body??l);
|
|
58
|
+
if(data.every(l=>l.line_items?.data))found={...old,body:{...old.body,data}};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
31
61
|
if(method==='GET'&&path.split('/').length>=4&&!path.endsWith('/search')) {
|
|
32
62
|
const written=observations.findLast(o=>o.method==='POST'&&o.body?.id===path.split('/').at(-1));
|
|
33
63
|
if(written&&observations.indexOf(written)>observations.indexOf(found)) {
|
|
@@ -36,22 +66,24 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
36
66
|
}
|
|
37
67
|
}
|
|
38
68
|
if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
|
|
39
|
-
|
|
69
|
+
let body=found.body;
|
|
70
|
+
if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
|
|
71
|
+
const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
|
|
72
|
+
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
|
|
73
|
+
if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
|
|
74
|
+
if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
|
|
75
|
+
}
|
|
76
|
+
body={...body,data:[...rows.values()]};
|
|
77
|
+
}
|
|
40
78
|
if(!body || body.error || body.livemode !== undefined && body.livemode !== (mode==='live') || body.data?.some(o=>o.livemode !== undefined && o.livemode !== (mode==='live')))throw new Error('Stripe read has an error or wrong mode');
|
|
41
79
|
return body;
|
|
42
80
|
};
|
|
43
81
|
const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
|
|
44
82
|
webhook:{method:'POST',path:'/v1/webhook_endpoints',private:true,params:{url:`${site}/api/shop/hook`,enabled_events:SHOP_HOOK_EVENTS,api_version:LINKS_STRIPE_VERSION,metadata:{homie:'shop-v1'},description:'Studio shop'}}};
|
|
45
|
-
let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
|
|
46
|
-
const knownLinks=saved?.result?.site===site&&saved.result.mode===mode?[...(saved.result.config.items??[]),...(saved.result.config.menus??[]),...(saved.result.config.slots??[])].map(l=>l.id):[];
|
|
47
|
-
for(const observation of observations) {
|
|
48
|
-
const body=observation.body;
|
|
49
|
-
if(/^plink_[A-Za-z0-9]+$/.test(body?.id??'')&&body.metadata?.homie==='shop-links-v1'&&body.metadata.studio===slug&&body.metadata.origin===site)knownLinks.push(body.id);
|
|
50
|
-
}
|
|
51
83
|
try {
|
|
52
|
-
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,
|
|
84
|
+
const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,knownPortal:saved?.result?.site===site&&saved.result.mode===mode&&(!account||saved.options?.account===account)?saved.result.config?.portalId:undefined});
|
|
53
85
|
if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
|
|
54
|
-
const result={...base,ok:true,verified:true,verifiedAt:
|
|
86
|
+
const result={...base,ok:true,verified:true,verifiedAt:observedAt,...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
|
|
55
87
|
return result;
|
|
56
88
|
} catch(e) {
|
|
57
89
|
if(!e.next&&!e.operations)throw e;
|
|
@@ -69,7 +101,7 @@ export async function saveMcpVerification(root, options) {
|
|
|
69
101
|
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
70
102
|
const slots=slotsOf(root,options.slots);
|
|
71
103
|
if(options.slots!==undefined)writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(slots));
|
|
72
|
-
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
|
|
104
|
+
writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account??JSON.parse(readFileSync(join(root,'.studio','stripe-mcp-observations.json'),'utf8')).account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),sourceConfig:{shop:readShop(root).shop,slots},result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
|
|
73
105
|
}
|
|
74
106
|
return result;
|
|
75
107
|
}
|
|
@@ -78,8 +110,44 @@ export async function saveMcpVerification(root, options) {
|
|
|
78
110
|
export function verifiedMcpConfig(root) {
|
|
79
111
|
const file=join(root,'.studio','stripe-mcp.json');if(!existsSync(file))return null;
|
|
80
112
|
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
81
|
-
if(saved.result?.local)return null; // Local builds read .dev.vars, never production bindings.
|
|
113
|
+
if(!saved.result?.verified || saved.result.local)return null; // Local builds read .dev.vars, never production bindings.
|
|
82
114
|
const slots=existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):saved.options?.slots??[];
|
|
83
|
-
|
|
115
|
+
const shop=readShop(root).shop, source=hash({shop,slots}), site=new URL(siteUrl(root)).origin;
|
|
116
|
+
const legacyFingerprint=hash({shop,bookingSlots:slots,site,mode:saved.result.mode,catalog:{config:saved.result.config,retire:saved.result.retire??[]}});
|
|
117
|
+
if(!saved.source && saved.result.fingerprint===legacyFingerprint) {
|
|
118
|
+
saved.source=source;saved.sourceConfig={shop,slots};
|
|
119
|
+
writeFileSync(file,JSON.stringify(saved,null,2));
|
|
120
|
+
}
|
|
121
|
+
if(saved.source!==source || saved.result.site!==site) {
|
|
122
|
+
const changes=[];
|
|
123
|
+
if(saved.result.site!==site)changes.push('site URL');
|
|
124
|
+
const previous=saved.sourceConfig?.shop;
|
|
125
|
+
if(previous)for(const key of new Set([...Object.keys(previous),...Object.keys(shop??{})])) {
|
|
126
|
+
if(canonicalJson(previous[key]??null)!==canonicalJson(shop?.[key]??null))changes.push(`shop.${key}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
let before;try{before=JSON.parse(saved.result.config?.settings);}catch{}
|
|
130
|
+
const after=JSON.parse(linkSettings(shop));
|
|
131
|
+
if(before)for(const key of new Set([...Object.keys(before),...Object.keys(after)])) {
|
|
132
|
+
if(canonicalJson(before[key]??null)!==canonicalJson(after[key]??null))changes.push(`shop.${key}`);
|
|
133
|
+
}
|
|
134
|
+
else changes.push('shop settings');
|
|
135
|
+
const receipt=saved.options?.observations?.find(o=>o.body?.custom_text?.submit?.message)?.body.custom_text.submit.message;
|
|
136
|
+
if(receipt!==(shop.giving?.receiptText||undefined))changes.push('shop.giving.receiptText');
|
|
137
|
+
}
|
|
138
|
+
if(canonicalJson(saved.sourceConfig?.slots??saved.options?.slots??[])!==canonicalJson(slots))changes.push('booking slots');
|
|
139
|
+
throw new Error(`Shop configuration changed: ${changes.join(', ')||'shop settings (saved verification differs)'}. Run stripe_shop_plan with verify:true before deploying or installing its webhook.`);
|
|
140
|
+
}
|
|
84
141
|
return saved;
|
|
85
142
|
}
|
|
143
|
+
|
|
144
|
+
/** Unfinished public plans also publish a closed setup, even before first verification. */
|
|
145
|
+
export function publicMcpSetup(root) {
|
|
146
|
+
for(const name of ['stripe-mcp.json','stripe-mcp-observations.json']) {
|
|
147
|
+
const file=join(root,'.studio',name);
|
|
148
|
+
if(!existsSync(file))continue;
|
|
149
|
+
const saved=JSON.parse(readFileSync(file,'utf8'));
|
|
150
|
+
return !saved.result?.local && !(saved.site??saved.result?.site??saved.options?.url??'').startsWith('http:');
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
package/lib/stripe-connect.mjs
CHANGED
|
@@ -37,11 +37,11 @@ const stripeReason = (result, call) => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
// This output stays private, including CLI config, completion, errors and API responses.
|
|
40
|
-
export function stripeProcess(args, { cwd, env = process.env } = {}) {
|
|
40
|
+
export function stripeProcess(args, { cwd, env = process.env, command = 'stripe' } = {}) {
|
|
41
41
|
const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
|
|
42
42
|
for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
|
|
43
43
|
return new Promise((resolve) => {
|
|
44
|
-
const p = spawn(
|
|
44
|
+
const p = spawn(command, args, { cwd, env: childEnv, shell: process.platform==='win32' && command==='npm.cmd', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
45
45
|
let stdout = '', stderr = '', ended = false;
|
|
46
46
|
const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
|
|
47
47
|
const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
|
|
@@ -92,7 +92,7 @@ export async function shopConnect(root, options = {}) {
|
|
|
92
92
|
try {
|
|
93
93
|
const {verifiedMcpConfig} = await import('./shop-mcp.mjs');
|
|
94
94
|
const saved=verifiedMcpConfig(root);
|
|
95
|
-
if(!saved
|
|
95
|
+
if(!saved?.result?.verifiedAt || Date.now()-Date.parse(saved.result.verifiedAt)>15*60_000) return fail('verify','Run shop verify with fresh Stripe MCP reads before installing the webhook (verification is valid for 15 minutes).');
|
|
96
96
|
const verified=saved.result;
|
|
97
97
|
if(!verified.verified || verified.local || verified.mode!==mode || verified.site!==new URL(siteUrl(root)).origin) return fail('verify','Run shop verify with fresh Stripe MCP reads for this site and mode first.');
|
|
98
98
|
mcpAccount=saved.options.account;
|
|
@@ -110,7 +110,12 @@ export async function shopConnect(root, options = {}) {
|
|
|
110
110
|
try {
|
|
111
111
|
const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
|
|
112
112
|
const installed = await cli(['--version']);
|
|
113
|
-
if (installed.code !== 0)
|
|
113
|
+
if (installed.code !== 0) {
|
|
114
|
+
log('Installing Stripe’s official CLI with npm…');
|
|
115
|
+
const install=options.installExec??((command,args)=>stripeProcess(args,{cwd:root,command}));
|
|
116
|
+
const result=await install(process.platform==='win32'?'npm.cmd':'npm',['install','--global','@stripe/cli@latest']);
|
|
117
|
+
if(result.code!==0 || (await cli(['--version'])).code!==0)return fail('stripe-cli','Automatic Stripe CLI installation failed. Check npm install permissions and PATH, then run shop connect again.',{next:['npm install --global @stripe/cli@latest']});
|
|
118
|
+
}
|
|
114
119
|
|
|
115
120
|
// A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
|
|
116
121
|
let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
@@ -3879,6 +3879,48 @@
|
|
|
3879
3879
|
}
|
|
3880
3880
|
}
|
|
3881
3881
|
}
|
|
3882
|
+
},
|
|
3883
|
+
"0.52.0": {
|
|
3884
|
+
"files": {
|
|
3885
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3886
|
+
"README.md": "684b3cca8001673c",
|
|
3887
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3888
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3889
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3890
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3891
|
+
"posts/README.md": "37841aae476bd540",
|
|
3892
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3893
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3894
|
+
},
|
|
3895
|
+
"sections": {
|
|
3896
|
+
"AGENTS.md": {
|
|
3897
|
+
"(intro)": "394cbbe934e80722",
|
|
3898
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3899
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3900
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3901
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3902
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3903
|
+
"## Making games": "bb715272fd1a615b",
|
|
3904
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3905
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3906
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3907
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3908
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3909
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3910
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3911
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3912
|
+
"## Beta": "9d02638207a26e73"
|
|
3913
|
+
}
|
|
3914
|
+
},
|
|
3915
|
+
"named": {
|
|
3916
|
+
"Homie Arcade": {
|
|
3917
|
+
"sections": {
|
|
3918
|
+
"AGENTS.md": {
|
|
3919
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3882
3924
|
}
|
|
3883
3925
|
}
|
|
3884
3926
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {createRequire} from 'node:module';
|
|
2
|
+
import {existsSync,readdirSync,readFileSync} from 'node:fs';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
/** Only a standalone, named refresh timer over known record clients is mechanical. */
|
|
5
|
+
export function upgradePlaceView(source,root){
|
|
6
|
+
// Desktop ships no compiler; use the studio’s installed compiler when available.
|
|
7
|
+
let ts;try{ts=createRequire(root?join(root,'package.json'):import.meta.url)('typescript');}catch{return source;}
|
|
8
|
+
const file=ts.createSourceFile('view.ts',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TS);
|
|
9
|
+
const factories=new Set(),clients=[];
|
|
10
|
+
for(const statement of file.statements){
|
|
11
|
+
if(ts.isImportDeclaration(statement)&&statement.moduleSpecifier.text==='@homie-rocks/studio/apps'){
|
|
12
|
+
for(const name of statement.importClause?.namedBindings?.elements??[])if((name.propertyName?.text??name.name.text)==='createAppRecords')factories.add(name.name.text);
|
|
13
|
+
}
|
|
14
|
+
if(ts.isVariableStatement(statement))for(const d of statement.declarationList.declarations){
|
|
15
|
+
if(ts.isIdentifier(d.name)&&d.initializer&&ts.isCallExpression(d.initializer)&&ts.isIdentifier(d.initializer.expression)&&factories.has(d.initializer.expression.text))clients.push(d.name.text);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if(!clients.length)return source;
|
|
19
|
+
const edits=[];
|
|
20
|
+
for(const statement of file.statements){
|
|
21
|
+
if(!ts.isExpressionStatement(statement)||!ts.isCallExpression(statement.expression))continue;
|
|
22
|
+
const call=statement.expression;
|
|
23
|
+
if(!ts.isIdentifier(call.expression)||call.expression.text!=='setInterval'||call.arguments.length!==2)continue;
|
|
24
|
+
const [callback,delay]=call.arguments;
|
|
25
|
+
if(!ts.isIdentifier(callback)||!/^refresh/i.test(callback.text)||!ts.isNumericLiteral(delay)||+delay.text<2000||+delay.text>3000)continue;
|
|
26
|
+
edits.push({start:statement.getStart(file),end:statement.end,text:clients.map(name=>`${name}.subscribe(${callback.text});`).join('\n')});
|
|
27
|
+
}
|
|
28
|
+
for(const edit of edits.reverse())source=source.slice(0,edit.start)+edit.text+source.slice(edit.end);
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
/** Exact obsolete notes only. Never rewrite owner's words, evidence, dates or unresolved needs. */
|
|
32
|
+
export function upgradePlaceCodex(source){
|
|
33
|
+
const replacements=new Map([
|
|
34
|
+
['The runner has no staff surface or MCP step.','The toolkit supports staff surfaces and MCP needs. Rerun the needs; earlier manual evidence is not a passing proof.'],
|
|
35
|
+
['Real Homie gap: no push for studio events, so the AI polls.','Declared studio event feeds support push subscriptions; reconnect from the last cursor.'],
|
|
36
|
+
['Staff device runs the jobs Homie has no scheduler for.','Run these jobs in scheduled server functions; remove the duplicate staff-device job after proving its replacement.'],
|
|
37
|
+
['ctx.database has only get/put (no atomic append).','Use versioned database writes and named event feeds; do not maintain an event log with sleep-and-retry.']
|
|
38
|
+
]);
|
|
39
|
+
return source.split('\n').map(line=>replacements.get(line)??line).join('\n');
|
|
40
|
+
}
|
|
41
|
+
export function placeUpgradeChanges(root){
|
|
42
|
+
const dir=join(root,'apps');if(!existsSync(dir))return [];
|
|
43
|
+
const changes=[];
|
|
44
|
+
for(const app of readdirSync(dir,{withFileTypes:true}).filter(d=>d.isDirectory()))for(const [suffix,rewrite] of [['CODEX.md',upgradePlaceCodex],['src/view.ts',upgradePlaceView]]){
|
|
45
|
+
const file=`apps/${app.name}/${suffix}`,path=join(root,file);if(!existsSync(path))continue;
|
|
46
|
+
const from=readFileSync(path,'utf8'),text=rewrite(from,root);if(from!==text)changes.push({file,kind:'update-file',from,text,what:'replace a recognized obsolete place workaround with the supported toolkit contract; rerun needs'});
|
|
47
|
+
}
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {placeUpgradeChanges} from './upgrade-places.mjs';
|
|
1
2
|
import { upgradeMediaGuidance } from './upgrade-media.mjs';
|
|
2
3
|
import {appPresetChanges} from './upgrade-app-presets.mjs';
|
|
3
4
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
@@ -176,7 +177,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
176
177
|
const who = { name, slug };
|
|
177
178
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
178
179
|
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
179
|
-
const changes = appPresetChanges(root);
|
|
180
|
+
const changes = [...appPresetChanges(root),...placeUpgradeChanges(root)];
|
|
180
181
|
const kept = [];
|
|
181
182
|
|
|
182
183
|
// package.json: the pin, and the scripts the template's studios have.
|
package/lib/worker-config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {verifiedMcpConfig} from './shop-mcp.mjs';
|
|
1
|
+
import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
|
|
2
2
|
import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
|
|
3
3
|
import {join} from 'node:path';
|
|
4
4
|
import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
|
|
@@ -27,8 +27,11 @@ export function updateWorkerConfig(root,generated) {
|
|
|
27
27
|
writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
|
|
28
28
|
}
|
|
29
29
|
const next=merge(base,custom);
|
|
30
|
-
|
|
31
|
-
if(
|
|
30
|
+
verifiedMcpConfig(root);
|
|
31
|
+
if(publicMcpSetup(root)) {
|
|
32
|
+
next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
|
|
33
|
+
delete next.vars.STRIPE_SHOP_LINKS;
|
|
34
|
+
}
|
|
32
35
|
// Crons are additive: studio-owned entries cannot erase required toolkit ticks.
|
|
33
36
|
const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
|
|
34
37
|
next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function randomId(): string;
|
|
2
|
+
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
|
+
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
|
4
|
+
/** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
|
|
5
|
+
export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
|
package/links/links.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export function randomId(): string;
|
|
2
2
|
export function appLink(address: string, params?: Record<string, string | number | boolean | null | undefined>, base?: string): string;
|
|
3
3
|
export function qrSvg(text: string, options?: { quiet?: number; dark?: string; light?: string; title?: string; minLevel?: 'L' | 'M' | 'Q' | 'H' }): string;
|
|
4
|
+
/** Epoch milliseconds for local date/time in an IANA zone; gaps and repeats reject by default. */
|
|
5
|
+
export function placeTime(date: string, time: string, timeZone: string, options?: {disambiguation?: 'reject' | 'earlier' | 'later'}): number;
|
package/links/links.mjs
CHANGED
package/links/time.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Interpret a place's calendar date and wall time using the runtime's IANA zone data. */
|
|
2
|
+
export function placeTime(date,time,timeZone,{disambiguation='reject'}={}){
|
|
3
|
+
if(!/^\d{4}-\d{2}-\d{2}$/.test(date)||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time))throw new Error('Use a calendar date YYYY-MM-DD and time HH:MM');
|
|
4
|
+
if(typeof timeZone!=='string'||/^[+-]/.test(timeZone))throw new Error('Use an IANA time zone');
|
|
5
|
+
if(!['reject','earlier','later'].includes(disambiguation))throw new Error('Unknown time disambiguation');
|
|
6
|
+
const naive=Date.parse(date+'T'+time+':00Z');
|
|
7
|
+
if(!Number.isFinite(naive)||new Date(naive).toISOString().slice(0,10)!==date)throw new Error('Invalid calendar date');
|
|
8
|
+
let format;try{format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});}catch{throw new Error('Use a valid IANA time zone');}
|
|
9
|
+
const wall=epoch=>{
|
|
10
|
+
const p=Object.fromEntries(format.formatToParts(epoch).map(p=>[p.type,p.value]));
|
|
11
|
+
return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`);
|
|
12
|
+
};
|
|
13
|
+
const offsets=new Set();
|
|
14
|
+
// Sample both sides of even a whole-day civil transition; do not assume an hour-sized DST shift.
|
|
15
|
+
for(let h=-48;h<=48;h+=6){const sample=naive+h*3600000;offsets.add(wall(sample)-sample);}
|
|
16
|
+
const choices=[...offsets].map(offset=>naive-offset).filter(epoch=>wall(epoch)===naive).sort((a,b)=>a-b);
|
|
17
|
+
if(!choices.length)throw new Error('This local time does not exist in the place’s time zone');
|
|
18
|
+
if(choices.length>1&&disambiguation==='reject')throw new Error('This local time is ambiguous; choose earlier or later explicitly');
|
|
19
|
+
return disambiguation==='later'?choices.at(-1):choices[0];
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|