@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/docs/toolkit-place-patterns.md +6 -0
  5. package/functions/FUNCTIONS.md +27 -0
  6. package/functions/functions.ts +4 -0
  7. package/guides/app/PLACES.md +123 -0
  8. package/guides/app/SKILL.md +3 -0
  9. package/guides/game/SKILL.md +3 -0
  10. package/guides/shop/SKILL.md +27 -12
  11. package/lib/app-needs.mjs +41 -6
  12. package/lib/build.mjs +5 -0
  13. package/lib/check.mjs +6 -6
  14. package/lib/cloudflare.mjs +2 -2
  15. package/lib/dev-restart.mjs +25 -0
  16. package/lib/dev.mjs +6 -1
  17. package/lib/functions-build.mjs +3 -0
  18. package/lib/mcp-tools.mjs +2 -2
  19. package/lib/scaffold.mjs +22 -1
  20. package/lib/shop-links.mjs +4 -4
  21. package/lib/shop-mcp.mjs +82 -14
  22. package/lib/stripe-connect.mjs +9 -4
  23. package/lib/template-history.json +42 -0
  24. package/lib/upgrade-places.mjs +49 -0
  25. package/lib/upgrade.mjs +2 -1
  26. package/lib/worker-config.mjs +6 -3
  27. package/links/links.d.mts +5 -0
  28. package/links/links.d.ts +2 -0
  29. package/links/links.mjs +2 -0
  30. package/links/time.mjs +20 -0
  31. package/package.json +1 -1
  32. package/shop/SHOP.md +27 -12
  33. package/shop/shop.ts +1 -1
  34. package/tools/tools.ts +12 -0
  35. package/worker/app-records.mjs +5 -1
  36. package/worker/app-shell.mjs +27 -4
  37. package/worker/app-streams.mjs +57 -0
  38. package/worker/app-tools.mjs +44 -0
  39. package/worker/booking-holds.mjs +87 -0
  40. package/worker/build-id.mjs +1 -1
  41. package/worker/business-events.mjs +31 -0
  42. package/worker/event-resources.mjs +1 -1
  43. package/worker/functions.mjs +5 -0
  44. package/worker/index.mjs +19 -4
  45. package/worker/mcp-events.mjs +2 -2
  46. package/worker/mcp-tools.mjs +3 -0
  47. package/worker/mcp.mjs +3 -3
  48. package/worker/pages.mjs +4 -4
  49. package/worker/records-page.mjs +2 -2
  50. package/worker/shop-links.mjs +12 -0
  51. package/worker/shop.mjs +14 -4
  52. 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??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({webglFailure,isolate,root,url:base,game,meta:appMeta,shots,log,report});
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??[]),...(n.assertions??[])].some(i=>i.surface)))return {ok:false,command:'check',why:'--webgl-failure requires UI steps or assertions'};
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??[]),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
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??[]),...(need.assertions??[])]) {
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 };
@@ -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);
@@ -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, retain write responses as observations and replan until verified. 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'}}},
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). */
@@ -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, knownLinks = [], knownPortal }) {
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
- ()=>scoped?parallel([...new Set(knownLinks)].map(id=>()=>api('GET',`/v1/payment_links/${id}`,{expand:['line_items']}))):list('/v1/payment_links'),
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
- const body=found.body;
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,knownLinks,knownPortal:saved?.result?.site===site&&saved.result.mode===mode?saved.result.config.portalId:undefined});
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:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
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
- if(!saved.result?.verified || saved.source!==hash({shop:readShop(root).shop,slots}) || saved.result.site!==new URL(siteUrl(root)).origin)throw new Error('Shop configuration changed; verify through stripe_shop_plan before deploying or installing its webhook.');
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
+ }
@@ -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('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
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.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).');
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) return fail('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
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.
@@ -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
- const verified=verifiedMcpConfig(root);
31
- if(verified)next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:JSON.stringify(verified.result.config)};
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
@@ -14,3 +14,5 @@ export function appLink(address, params = {}, base = globalThis.location?.href)
14
14
  }
15
15
  return url.href;
16
16
  }
17
+
18
+ export {placeTime} from './time.mjs';
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.51.8",
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",