@homie-rocks/studio 0.40.0 → 0.42.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 (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +11 -0
  3. package/bin/homie-studio.mjs +8 -0
  4. package/functions/FUNCTIONS.md +99 -0
  5. package/functions/functions.ts +13 -0
  6. package/lib/build.mjs +1 -0
  7. package/lib/cloudflare.mjs +9 -9
  8. package/lib/dev.mjs +2 -1
  9. package/lib/functions-build.mjs +36 -0
  10. package/lib/functions-cli.mjs +23 -0
  11. package/lib/purchase-links.mjs +8 -0
  12. package/lib/scaffold.mjs +16 -3
  13. package/lib/shop.mjs +1 -1
  14. package/lib/template-history.json +44 -0
  15. package/lib/tools-build.mjs +2 -0
  16. package/lib/upgrade.mjs +2 -0
  17. package/lib/worker-build.mjs +8 -20
  18. package/lib/worker-config.mjs +28 -0
  19. package/package.json +7 -2
  20. package/shop/SHOP.md +4 -3
  21. package/tools/TOOLS.md +42 -2
  22. package/tools/tools.ts +3 -0
  23. package/worker/customer-mcp.mjs +77 -0
  24. package/worker/customer-resources.mjs +53 -0
  25. package/worker/customer-shop.mjs +54 -0
  26. package/worker/extensions.mjs +1 -0
  27. package/worker/functions-schema.mjs +55 -0
  28. package/worker/functions.mjs +93 -0
  29. package/worker/index.mjs +37 -2
  30. package/worker/machine-payments.mjs +1 -1
  31. package/worker/mcp-store.mjs +3 -2
  32. package/worker/mcp-tools.mjs +16 -4
  33. package/worker/mcp.mjs +31 -15
  34. package/worker/office-page.mjs +1 -1
  35. package/worker/players.mjs +5 -2
  36. package/worker/purchase-delivery.mjs +1 -0
  37. package/worker/purchase-hosted-readiness.mjs +0 -1
  38. package/worker/purchase-links.mjs +1 -1
  39. package/worker/purchase-machine.mjs +1 -0
  40. package/worker/purchase-office.mjs +1 -1
  41. package/worker/purchase-owner.mjs +0 -2
  42. package/worker/purchase-reconciliation.mjs +1 -0
  43. package/worker/purchase-routes.mjs +11 -17
  44. package/worker/purchase-store.mjs +1 -0
  45. package/worker/selling/runtime.mjs +4 -1
  46. package/worker/service-run.mjs +25 -0
  47. package/worker/service-terms.mjs +2 -0
  48. package/worker/shop-rules.mjs +9 -8
  49. package/worker/shop.mjs +22 -9
  50. package/worker/tool-webhook.mjs +1 -0
  51. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,33 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.42.0] - 2026-10-09
21
+
22
+ **Plugin 0.42.0**
23
+
24
+ Customers and other businesses can use a studio's tools, buy its goods and services, and trigger its own functions.
25
+
26
+ ### Added
27
+
28
+ - Customer catalogue, cart, checkout and agent purchases through the studio MCP server, using the existing payment standards and purchase lifecycle.
29
+ - Priced studio tools and configurable built-in audiences, with current permissions checked when listing and calling.
30
+ - Studio functions: plain-file event handlers, local firing, durable retries and office delivery status.
31
+
32
+ ### Changed
33
+
34
+ - MCP call rates and shop purchase/guest-buyer rates are opt-in.
35
+
36
+ ### Fixed
37
+
38
+ - Claim refunds use the same tip, used-item and window policy as the web shop. Guest carts create usable accounts before payment.
39
+ - Builds fully regenerate Worker configuration, remove stale generated bindings and the old default purchase limiter, and keep explicit additions in `wrangler.custom.json`.
40
+ - New functions skip history unless explicitly replayed; indexed subscriptions and cursors avoid unused events and prune completed delivery pages.
41
+ - Paid tools renew execution leases, return saved results on retries and automatically refund failed handlers. Unpaid quotes expire and are pruned.
42
+
43
+ ### Upgrade notes
44
+
45
+ - Run the toolkit upgrade and build, then commit the generated Worker configuration before deploying. Move hand-added Wrangler settings to root `wrangler.custom.json`; apply migrations 0018 and 0019 with the other studio migrations. Functions add a Cloudflare cron for retries only when declared.
46
+
20
47
  ## [0.40.0] - 2026-10-09
21
48
 
22
49
  **Plugin 0.40.0**
package/README.md CHANGED
@@ -44,6 +44,17 @@ asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who las
44
44
  npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" # the toolkit as a local MCP server (stdio)
45
45
  ```
46
46
 
47
+ Every studio also has its own customer-facing `/mcp`. Customers can browse, quote a cart,
48
+ and pay through the same MPP/x402 purchase engine used for paid parts, or open the studio's
49
+ Stripe checkout for human approval. Studio tools choose `public`, `signed-in`, an app staff
50
+ role, or `owner`; a tool can sell its service with `price`. See [Studio tools](tools/TOOLS.md).
51
+
52
+ [Studio functions](functions/FUNCTIONS.md) run your code when an order is paid or refunded,
53
+ a player joins, a room closes, an app record changes, a tool is called, or a schedule fires.
54
+ Add one with `homie-studio function new paid-order --event order.paid`; fire an event locally
55
+ with `homie-studio function fire order.paid --input '{"order":"example"}'`. Deliveries persist,
56
+ retry with the same event ID, and show failures in the office. Policies and rate limits are opt-in.
57
+
47
58
  The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
48
59
  the live demo, make / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
49
60
  playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
@@ -155,6 +155,9 @@
155
155
  * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
156
156
  * [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
157
157
  * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
158
+ * homie-studio function new <name> [--event order.paid]
159
+ * homie-studio function replay <name> --replay [--url <studio-site>]
160
+ * homie-studio function fire <event> [--input <JSON>] [--id <event-id>] [--url <local-site>]
158
161
  * homie-studio tool new <name> [--app <id>]
159
162
  * homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
160
163
  * homie-studio servers set <game> <server> [the same flags]
@@ -489,9 +492,12 @@ function print(result) {
489
492
  lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
490
493
  break;
491
494
  }
495
+ case 'function new':
492
496
  case 'tool new':
493
497
  lines.push(result.file, result.next);
494
498
  break;
499
+ case 'function replay':
500
+ case 'function fire':
495
501
  case 'tool call':
496
502
  case 'tool list':
497
503
  lines.push(JSON.stringify(result.result, null, 2));
@@ -833,6 +839,7 @@ const OUTWARD_FLAGS = {
833
839
  'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
834
840
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
835
841
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
842
+ 'function replay': ['url','replay'], 'function new': ['event'], 'function fire': ['url','input','id'],
836
843
  'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
837
844
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
838
845
  'standalone run': ['for', 'device', 'site'],
@@ -894,6 +901,7 @@ async function main() {
894
901
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
895
902
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
896
903
  const root = requireStudio();
904
+ if (cmd === 'function') return (await import('../lib/functions-cli.mjs')).functionCommand(root, sub, positional, flags, { log });
897
905
  if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
898
906
  // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
899
907
  if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
@@ -0,0 +1,99 @@
1
+ # Studio functions: when this happens, run that
2
+
3
+ A function is your trusted code in your own Worker. Run:
4
+
5
+ ```sh
6
+ homie-studio function new prepare-order --event order.paid
7
+ homie-studio dev
8
+ homie-studio function fire order.paid --id local-order-1 --input '{"order":"example"}'
9
+ ```
10
+
11
+ The fire command connects to the local studio through the existing browser OAuth
12
+ approval. It is an owner tool and refuses remote hosts. It records an event in the
13
+ same durable dispatcher as real events; firing the same ID again is a duplicate.
14
+
15
+ ```ts
16
+ import { defineFunction } from '@homie-rocks/studio/functions';
17
+ export default defineFunction<{ order: string }>({
18
+ name: 'prepare-order',
19
+ event: 'order.paid',
20
+ async handler(event, context) {
21
+ // For an external API, send event.id as its idempotency key.
22
+ await context.database.put(event.id, { order: event.data.order });
23
+ },
24
+ });
25
+ ```
26
+
27
+ Build discovers and type-checks `functions/*.ts`. Handlers have the studio tool
28
+ context and run with the studio's authority. They can change records, send a
29
+ room event through declared capabilities, call an external service, or implement
30
+ another business workflow. Treat event data as data, not instructions.
31
+
32
+ | Event | Data |
33
+ | --- | --- |
34
+ | `order.paid` | `order`, `source`, `amount`, `currency`, `mode` |
35
+ | `payment.refunded` | `refund`, `payment`, `amount`, `currency` (each successful Stripe refund, including partial refunds) |
36
+ | `order.refunded` | `order`, `source`, `refund`, `mode` (full refund) |
37
+ | `player.joined` | `game`, `room`, `player` (nullable), `seat` |
38
+ | `room.closed` | `game`, `room`, closing/ending facts |
39
+ | `record.changed` | `app`, `collection`, `record`, `version`, `operation`, `data` |
40
+ | `tool.called` | `tool`, `person`, `client` (successful call, no arguments) |
41
+
42
+ For a scheduled function, add `schedule: '0 9 * * *'` and a chosen event name,
43
+ for example `event: 'morning.open'`. Schedules use Cloudflare cron syntax and UTC.
44
+ The event includes the function name, cron and scheduled time. Its ID remains the
45
+ same if Cloudflare delivers the same tick again. Only that scheduled function
46
+ receives its tick. Build reads custom crons from `wrangler.custom.json` and adds a minute tick
47
+ for pending delivery retries while functions exist. Commit build changes before
48
+ deploying. Cloudflare's own plan limits apply; Homie sets no attempt or event cap.
49
+
50
+ Order, refund, record and tool events use database triggers: the source change
51
+ and event either both commit or both roll back. Room events first enter the room's
52
+ durable SQLite outbox; its alarm retries transfer to D1. Delivery is **at least
53
+ once**, including after a worker dies between an external effect and its local
54
+ acknowledgement. `event.id` is the de-duplication key and `event.attempt` is the
55
+ attempt number. Completed deliveries do not run again. Failed/crashed deliveries
56
+ retry after a one-minute lease. Make effects idempotent; a database marker written
57
+ before an external request is not proof that the external request completed.
58
+
59
+ Open **Office → Functions** for event IDs, states, attempt counts and failures.
60
+ Use its Retry button, or an authenticated same-origin POST to `/_studio/office/functions` with
61
+ `{"retry":"event-id"}` retries a failed event immediately. Failure text is generic
62
+ so a thrown credential is never rendered in the office. Inspect the function's
63
+ code and the studio's own provider logs to diagnose its failure.
64
+
65
+ Existing signed inbound tool webhooks remain the external integration door. Their
66
+ successful calls produce `tool.called`; record changes they make also produce
67
+ `record.changed`. Use functions for durable downstream work instead of expecting
68
+ a webhook sender to orchestrate the studio's internal actions. New declarations start at deployment and skip past events. To replay retained
69
+ history, explicitly add `replay: true` to the declaration, then run
70
+ `homie-studio function replay <name> --replay --url https://your-studio.example`.
71
+ The owner OAuth connection is required. Merely adding `replay: true` does not run
72
+ history. Fully delivered events are pruned; replay cannot recover pruned events.
73
+ Only declared event types are recorded. Each function has an indexed sequence
74
+ cursor; dispatch and maintenance use pages of 100, with no event or retry cap.
75
+
76
+
77
+ Worker configuration is generated at build and upgrade time from `studio.json`,
78
+ shop and tool declarations, game files, and function declarations. Commit the
79
+ build before deploy. Put custom bindings, variables, aliases, routes, crons or
80
+ observability settings in root `wrangler.custom.json` (plain JSON); objects merge
81
+ recursively and arrays replace generated arrays. Do not hand-edit the generated
82
+ `wrangler.jsonc`. Move existing manual additions into that file before upgrading.
83
+ `studio.json.cloudflare.ai` explicitly enables/disables AI; otherwise game
84
+ chat/decision declarations determine it. Set `cloudflare.routes` for routes and
85
+ `cloudflare.purchaseRateLimit` for an explicitly chosen Wrangler rate-limit
86
+ binding. An old generated purchase limiter is removed unless explicitly set.
87
+
88
+ Machine cart quotes bind an account before payment. Anonymous buyers receive
89
+ `account.player` and private `account.cookies`; retain the cookie and send its
90
+ name/value as the Cookie header to this studio to use the grants. These credentials
91
+ are returned only to the quoting caller, never embedded in the public offer.
92
+ Guest limits are checked at quote time using the incoming request address.
93
+ Quotes expire after 24 hours; paid orders retain their independent snapshots.
94
+
95
+ Paid handlers renew their execution lease while running. Retries share the first
96
+ result and never rerun an abandoned handler. A failed or abandoned run requests an
97
+ automatic Stripe refund; retries report completion or continue refund recovery.
98
+ Paid services require the studio's refundable Stripe connection; chain settlement
99
+ is not offered for services. Handler errors never expose their private contents.
@@ -0,0 +1,13 @@
1
+ import type { ToolContext, StudioTool } from '../tools/tools';
2
+ export interface StudioEvent<Data = unknown> { readonly id: string; readonly type: string; readonly at: number; readonly data: Data; readonly attempt: number }
3
+ export interface StudioFunction<Data = unknown> {
4
+ name: string;
5
+ event: string;
6
+ /** Cloudflare cron expression; scheduled events are named by this function. */
7
+ schedule?: string;
8
+ /** Permit an explicit owner replay command to consume retained history. */
9
+ replay?: boolean;
10
+ events?: StudioTool['events'];
11
+ handler(event: StudioEvent<Data>, context: ToolContext): unknown | Promise<unknown>;
12
+ }
13
+ export function defineFunction<Data = unknown>(definition: StudioFunction<Data>): StudioFunction<Data> { return definition; }
package/lib/build.mjs CHANGED
@@ -492,6 +492,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
492
492
  const esbuild = await studioEsbuild(root);
493
493
  const studio = readStudio(root);
494
494
  await (await import('./tools-build.mjs')).buildTools(root, esbuild);
495
+ await (await import('./functions-build.mjs')).buildFunctions(root, esbuild);
495
496
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
496
497
  const shop = shopForBuild(root, { log });
497
498
  const live = join(root, 'site', 'dist');
@@ -1,3 +1,4 @@
1
+ import { refreshWorkerConfig } from './worker-config.mjs';
1
2
  import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
2
3
  /**
3
4
  * `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
@@ -41,7 +42,7 @@ import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File,
41
42
  import { whyFailed } from './net.mjs';
42
43
  import { repoOf } from './repo.mjs';
43
44
  import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
44
- import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
45
+ import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
45
46
  import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
46
47
  import { projectsCloudflareEnv } from './projects-env.mjs';
47
48
 
@@ -240,7 +241,6 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
240
241
  const wide = wideRouteRefusal(readConfig(root), studio);
241
242
  if (wide) return { ok: false, command: 'deploy', ...wide };
242
243
  let routes = keptRoutes(root, studio);
243
- const triggers = readConfig(root)?.triggers;
244
244
  const who = whoami(root);
245
245
  if (!who) {
246
246
  if (cf.auth === 'stripe-projects') {
@@ -277,6 +277,9 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
277
277
  if (zone) {
278
278
  if (ownRoute && zone.state === 'foreign') {
279
279
  routes = [...(routes ?? []), zone.route];
280
+ const customFile=join(root,'wrangler.custom.json');
281
+ const custom=existsSync(customFile)?JSON.parse(readFileSync(customFile,'utf8')):{};
282
+ writeFileSync(customFile,JSON.stringify({...custom,routes},null,2)+'\n');
280
283
  step(`added the studio's own exact-host route to "routes" in wrangler.jsonc: ${zone.line}. It covers ${zone.host} only, and every deploy keeps it; the other site's route was not touched.`, { route: zone.route });
281
284
  const config = readConfig(root) ?? {};
282
285
  zone = await zoneCheck(root, { studio, accountId, fetchFn, config: { ...config, routes: [...routesOf(config), zone.route] } });
@@ -368,17 +371,14 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
368
371
  const r2 = storage;
369
372
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
370
373
 
371
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
374
+ refreshWorkerConfig(root, {d1Id: db.uuid});
372
375
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
373
376
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
374
377
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
375
378
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
376
379
  step('D1 migrations applied');
377
- // Workers AI (0.17.0): bound only when a server's AI guides think with it.
378
- if (needsWorkersAi(root, w, cf.d1)) {
379
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
380
- step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
381
- }
380
+ const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
381
+ if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
382
382
 
383
383
  const started = Date.now();
384
384
  let dep = w(['deploy', ...repoVar(root)]);
@@ -680,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
680
680
  writeStudio(root, next);
681
681
  if (next.cloudflare.d1Id) {
682
682
  // The studio's own routes stay through this rewrite too (lib/routes.mjs).
683
- writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
683
+ refreshWorkerConfig(root);
684
684
  }
685
685
  log(`created R2 ${bucket}`);
686
686
  return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
package/lib/dev.mjs CHANGED
@@ -459,8 +459,9 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
459
459
  return;
460
460
  }
461
461
  };
462
+ const { functionFiles } = await import('./functions-build.mjs');
462
463
  const { toolFiles } = await import('./tools-build.mjs');
463
- const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
+ const toolsStamp = () => [...toolFiles(root),...functionFiles(root)].map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
465
  let lastTools = toolsStamp();
465
466
  const toolsLook = async () => {
466
467
  if (rebuilding || ending) return;
@@ -0,0 +1,36 @@
1
+ import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {pathToFileURL} from 'node:url';
4
+ import {createRequire} from 'node:module';
5
+ const require=createRequire(import.meta.url);
6
+ export function functionFiles(root) { const dir=join(root,'functions');return existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]; }
7
+ export async function buildFunctions(root,esbuild) {
8
+ const files=functionFiles(root),dir=join(root,'site/src/functions');mkdirSync(dir,{recursive:true});
9
+ if(files.length) {
10
+ const ts=require('typescript'),options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true};
11
+ const errors=ts.getPreEmitDiagnostics(ts.createProgram(files,options));
12
+ if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
13
+ }
14
+ const contents=files.map((f,i)=>`import f${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'f'+i).join(',')}];`;
15
+ const built=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',write:false,logLevel:'silent'});
16
+ const file=join(dir,'index.mjs');writeFileSync(file,built.outputFiles[0].text);
17
+ const definitions=(await import(pathToFileURL(file).href+'?build='+Date.now())).default;
18
+ const names=new Set();
19
+ for(const fn of definitions) {
20
+ if(!/^[a-z][a-z0-9-]*$/.test(fn.name)||names.has(fn.name)||typeof fn.handler!=='function'||typeof fn.event!=='string'||!fn.event.trim())throw new Error('Function needs a unique name, event and handler');
21
+ if(fn.schedule && (typeof fn.schedule!=='string'||fn.schedule.trim().split(/\s+/).length!==5))throw new Error('Function schedule needs a Cloudflare cron expression');
22
+ names.add(fn.name);
23
+ }
24
+ const worker=join(root,'site/src/worker.mjs');
25
+ if(existsSync(worker)) {let text=readFileSync(worker,'utf8');if(!text.includes('useFunctions(')){text+="\nimport { useFunctions } from '@homie-rocks/studio/worker';\nuseFunctions(async () => (await import('./functions/index.mjs')).default);\n";writeFileSync(worker,text);}}
26
+ writeFileSync(join(dir,'declarations.json'),JSON.stringify(definitions.map(({name,event,schedule,replay})=>({name,event,schedule,replay})),null,2)+'\n');
27
+ (await import('./worker-config.mjs')).refreshWorkerConfig(root);
28
+ return definitions;
29
+ }
30
+ /** Install subscriptions before the new Worker can produce its first event. */
31
+ export function functionDeploymentSQL(root) {
32
+ const file=join(root,'site/src/functions/declarations.json');
33
+ const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
34
+ const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
35
+ return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
36
+ }
@@ -0,0 +1,23 @@
1
+ import {existsSync,mkdirSync,writeFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {connectToolClient} from './tools-cli.mjs';
4
+ export async function functionCommand(root,sub,positional,flags,{log=()=>{}}={}) {
5
+ if(sub==='new') {
6
+ const name=positional[2],event=flags.get('event')??'order.paid';
7
+ if(!/^[a-z][a-z0-9-]*$/.test(name??''))throw new Error('Use a lowercase function name');
8
+ const file=join(root,'functions',name+'.ts');if(existsSync(file))throw new Error('Function already exists');
9
+ mkdirSync(join(root,'functions'),{recursive:true});
10
+ writeFileSync(file,`import { defineFunction } from '@homie-rocks/studio/functions';\nexport default defineFunction({\n name: ${JSON.stringify(name)}, event: ${JSON.stringify(event)},\n async handler(event, context) {\n // Deduplicate external side effects with event.id (for example an API idempotency key).\n await context.database.put(event.id, event.data);\n },\n});\n`);
11
+ return {ok:true,command:'function new',file,next:'Edit your function, then run homie-studio dev.'};
12
+ }
13
+ if(sub==='replay') {
14
+ if(!flags.has('replay'))throw new Error('Pass --replay to explicitly request retained history');
15
+ const client=await connectToolClient(root,new URL(flags.get('url')??'http://localhost:8787'),{log});
16
+ try {const result=await client.callTool({name:'studio_function_replay',arguments:{name:positional[2]}});return {ok:!result.isError,command:'function replay',result};}finally{await client.close();}
17
+ }
18
+ if(sub!=='fire')throw new Error('Use function new <name> --event <event>, or function fire <event> --input <JSON>');
19
+ const url=new URL(flags.get('url')??'http://localhost:8787');
20
+ if(!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Fire is local testing only');
21
+ const client=await connectToolClient(root,url,{log});
22
+ try {const result=await client.callTool({name:'studio_function_fire',arguments:{event:positional[2],data:JSON.parse(flags.get('input')??'{}'),...(flags.has('id')?{id:flags.get('id')}: {})}});return {ok:!result.isError,command:'function fire',result};}finally{await client.close();}
23
+ }
@@ -1,3 +1,5 @@
1
+ import {pathToFileURL} from 'node:url';
2
+ import {serviceTerms} from '../worker/service-terms.mjs';
1
3
  /** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
2
4
  import { createHash } from 'node:crypto';
3
5
  import { existsSync, readFileSync } from 'node:fs';
@@ -13,6 +15,12 @@ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, til
13
15
  const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
14
16
  return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
15
17
  }));
18
+ const {toolFiles,buildTools}=await import('./tools-build.mjs');
19
+ if(toolFiles(root).length){
20
+ await buildTools(root,await import('esbuild'));
21
+ const definitions=(await import(pathToFileURL(join(root,'site/src/tools/index.mjs')).href+'?links='+Date.now())).default;
22
+ offers.push(...definitions.filter(t=>t.price).map(t=>({kind:'service',resource:t.name,version:'1.0.0',name:t.description,sale:serviceTerms(t.price)})));
23
+ }
16
24
  const configured = offers.flatMap(o => {
17
25
  const quantities = settings[o.resource]?.quantities ?? [1];
18
26
  if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
1
2
  import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
2
3
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
3
4
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
@@ -83,7 +84,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
83
84
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
84
85
  no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
85
86
  compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
86
- ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
87
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
87
88
  workers_dev: true,
88
89
  preview_urls: true,
89
90
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
@@ -154,7 +155,7 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
154
155
 
155
156
  Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
156
157
 
157
- Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
158
+ Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Customers can browse and buy through this same endpoint, and tools may declare a price using the studio's existing payments. Add event handlers with \`homie-studio function new <name> --event order.paid\`; read \`node_modules/@homie-rocks/studio/functions/FUNCTIONS.md\`. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
158
159
 
159
160
  Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
160
161
 
@@ -763,6 +764,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
763
764
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
764
765
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
765
766
  [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
767
+ [`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
768
+ [`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
766
769
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
767
770
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
768
771
  '.claude/skills/.gitkeep': '',
@@ -934,13 +937,23 @@ function ensurePartsStateMigration(root) {
934
937
  return `site/migrations/${PURCHASE_STATE_FILE}`;
935
938
  }
936
939
 
940
+ function ensureFunctionCursors(root) {
941
+ const file=join(root,'site','migrations',FUNCTIONS_CURSOR_MIGRATION_FILE);
942
+ if(existsSync(file))return null;
943
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_CURSOR_MIGRATION);return `site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`;
944
+ }
945
+ function ensureFunctionsMigration(root) {
946
+ const file=join(root,'site','migrations',FUNCTIONS_MIGRATION_FILE);
947
+ if(existsSync(file))return null;
948
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,FUNCTIONS_MIGRATION);return `site/migrations/${FUNCTIONS_MIGRATION_FILE}`;
949
+ }
937
950
  function ensureMcpMigration(root) {
938
951
  const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
939
952
  if(existsSync(file))return null;
940
953
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
941
954
  }
942
955
  export function ensureMigrations(root) {
943
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root)].filter(Boolean);
956
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root)].filter(Boolean);
944
957
  }
945
958
 
946
959
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shop.mjs CHANGED
@@ -287,7 +287,7 @@ export async function shopConnectManual(root, { fromClipboard = false, clipboard
287
287
  const shopNow = readShop(root);
288
288
  const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
289
289
  const nonce = randomBytes(16).toString('hex');
290
- const paidParts = paidReleases(root).length > 0;
290
+ const paidParts = paidReleases(root).length > 0 || (shopNow.shop?.items?.length??0)>0 || (await import('./tools-build.mjs')).toolFiles(root).length>0;
291
291
  const page = connectPage({ paidParts, nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
292
292
  return await new Promise((done) => {
293
293
  let finished = false;
@@ -2238,6 +2238,50 @@
2238
2238
  }
2239
2239
  }
2240
2240
  }
2241
+ },
2242
+ "0.42.0": {
2243
+ "files": {
2244
+ "CLAUDE.md": "ddf2b2a0376202f6",
2245
+ "README.md": "684b3cca8001673c",
2246
+ "HANDOFF.md": "29337e2ace95b2e7",
2247
+ "games/README.md": "5ad32fe80c61f117",
2248
+ "music/README.md": "a3dc042dadd39df8",
2249
+ "videos/README.md": "16babc3437d5545b",
2250
+ "posts/README.md": "37841aae476bd540",
2251
+ "site/README.md": "732894273a32dc3f",
2252
+ "site/src/worker.mjs": "34444a6ca4b14437"
2253
+ },
2254
+ "sections": {
2255
+ "AGENTS.md": {
2256
+ "(intro)": "214aa46eb3a895d5",
2257
+ "## Layout": "c18362ed28975d81",
2258
+ "## Apps: one screen, roles and parts": [
2259
+ "8596f2b25edda3e2",
2260
+ "be6f8fa45f93211a"
2261
+ ],
2262
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2263
+ "## The site": "0cb8dd9a6f8a0096",
2264
+ "## Making games": "2da7fa74c8884834",
2265
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2266
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2267
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2268
+ "## Servers and AI seats": "98d08705087a33ec",
2269
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2270
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2271
+ "## Rules": "2e6b29afb85dcf7e",
2272
+ "## Telling Homie": "80b46fdd9e578db8",
2273
+ "## Beta": "9d02638207a26e73"
2274
+ }
2275
+ },
2276
+ "named": {
2277
+ "Homie Arcade": {
2278
+ "sections": {
2279
+ "AGENTS.md": {
2280
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2281
+ }
2282
+ }
2283
+ }
2284
+ }
2241
2285
  }
2242
2286
  }
2243
2287
  }
@@ -38,6 +38,7 @@ export async function buildTools(root,esbuild) {
38
38
  if(!['owner','signed-in','public'].includes(t.audience)&&!(t.audience&&typeof t.audience.app==='string'&&typeof t.audience.role==='string'))throw new Error(`tool ${t.name} needs an audience`);
39
39
  if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
40
40
  validator.getValidator(t.inputSchema);
41
+ if(t.price && (t.kind==='prompt'||!Number.isSafeInteger(t.price.amount)||t.price.amount<=0||! /^[a-z]{3}$/.test(t.price.currency)))throw new Error(`tool ${t.name}: price needs a positive minor-unit amount and currency`);
41
42
  if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
42
43
  if(t.webhook&&(!/^[A-Z][A-Z0-9_]{0,63}$/.test(t.webhook.secret)||typeof t.webhook.person!=='string'||!t.webhook.person))throw new Error(`tool ${t.name}: webhook needs a named secret and account`);
43
44
  for(const [name,event] of Object.entries(t.events??{})){
@@ -51,5 +52,6 @@ export async function buildTools(root,esbuild) {
51
52
  let text=readFileSync(worker,'utf8');
52
53
  if(!text.includes('useTools(')){text += "\nimport { useTools } from '@homie-rocks/studio/worker';\nuseTools(async () => (await import('./tools/index.mjs')).default);\n";writeFileSync(worker,text);}
53
54
  }
55
+ writeFileSync(join(dir,'declarations.json'),JSON.stringify({paid:defs.some(t=>t.price)})+'\n');
54
56
  return defs.map(t=>t.name);
55
57
  }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
1
2
  /**
2
3
  * `homie-studio upgrade [--apply] [--diff]` — an existing studio takes what this version's template adds, and
3
4
  * nothing it made its own is ever overwritten.
@@ -346,6 +347,7 @@ export function upgradeApply(root, plan) {
346
347
  }
347
348
  if (text !== null && (!existsSync(path) || readFileSync(path, 'utf8') !== text)) writeAtomic(path, text);
348
349
  }
350
+ refreshWorkerConfig(root);
349
351
  const pinned = done.some((c) => c.kind === 'pin');
350
352
  return {
351
353
  ...plan, applied: true, done, skipped,
@@ -1,34 +1,22 @@
1
- import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
2
- import {join,relative,dirname} from 'node:path';
1
+ import {refreshWorkerConfig} from './worker-config.mjs';
2
+ import {pathToFileURL} from 'node:url';
3
+ import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
+ import {join,relative} from 'node:path';
3
5
  import {readConfig} from './routes.mjs';
4
6
  import {paidReleases} from './parts-upload.mjs';
5
- import {configPath} from './studio.mjs';
6
7
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
7
8
  export async function buildWorker(root,esbuild) {
8
9
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
9
10
  const out=join(root,'site/src/runtime');
11
+ refreshWorkerConfig(root);
10
12
  const alias={...(readConfig(root)?.alias??{})};
11
- if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
13
+ const toolModule=join(root,'site/src/tools/index.mjs');
14
+ const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
+ if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
12
16
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
13
17
  rmSync(out,{recursive:true,force:true});
14
18
  mkdirSync(out,{recursive:true});
15
19
  for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
16
- const config=configPath(root);
17
- if(existsSync(config)){
18
- const original=readFileSync(config,'utf8');
19
- const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
20
- const before=JSON.stringify(json);
21
- json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
22
- json.no_bundle=true;json.find_additional_modules=true;
23
- json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
24
- // No duplicate rule on rebuild.
25
- json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
26
- json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
27
- if(JSON.stringify(json)!==before) {
28
- const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
29
- writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
30
- }
31
- }
32
20
  const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
33
21
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
34
22
  return sizes;
@@ -0,0 +1,28 @@
1
+ import {readFileSync,writeFileSync,existsSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
+ import {wranglerConfig} from './scaffold.mjs';
5
+ import {paidReleases} from './parts-upload.mjs';
6
+ const parse=t=>JSON.parse(t.replace(/^\s*\/\/.*$/gm,''));
7
+ const read=file=>existsSync(file)?parse(readFileSync(file,'utf8')):{};
8
+ function merge(base,custom) {
9
+ if(Array.isArray(custom))return custom;
10
+ if(custom && typeof custom==='object')return Object.fromEntries([...new Set([...Object.keys(base??{}),...Object.keys(custom)])].sort().map(k=>[k,k in custom?merge(base?.[k],custom[k]):base[k]]));
11
+ return custom;
12
+ }
13
+ /** Generated output is never an input. Studio additions belong in wrangler.custom.json. */
14
+ export function updateWorkerConfig(root,generated) {
15
+ const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
+ const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
18
+ }
19
+ export function refreshWorkerConfig(root,overrides={}) {
20
+ const studio=readStudio(root),cf=studio.cloudflare;
21
+ if(!cf?.worker)return;
22
+ const metadata=read(join(root,'site/src/functions/declarations.json'));
23
+ const functions=Array.isArray(metadata)?metadata:[];
24
+ const tools=read(join(root,'site/src/tools/declarations.json'));
25
+ const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
26
+ const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
27
+ updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,triggers:functions.length?{crons:[...new Set(['* * * * *',...functions.map(f=>f.schedule).filter(Boolean)])].sort()}:null,...overrides}));
28
+ }