@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.
- package/CHANGELOG.md +27 -0
- package/README.md +11 -0
- package/bin/homie-studio.mjs +8 -0
- package/functions/FUNCTIONS.md +99 -0
- package/functions/functions.ts +13 -0
- package/lib/build.mjs +1 -0
- package/lib/cloudflare.mjs +9 -9
- package/lib/dev.mjs +2 -1
- package/lib/functions-build.mjs +36 -0
- package/lib/functions-cli.mjs +23 -0
- package/lib/purchase-links.mjs +8 -0
- package/lib/scaffold.mjs +16 -3
- package/lib/shop.mjs +1 -1
- package/lib/template-history.json +44 -0
- package/lib/tools-build.mjs +2 -0
- package/lib/upgrade.mjs +2 -0
- package/lib/worker-build.mjs +8 -20
- package/lib/worker-config.mjs +28 -0
- package/package.json +7 -2
- package/shop/SHOP.md +4 -3
- package/tools/TOOLS.md +42 -2
- package/tools/tools.ts +3 -0
- package/worker/customer-mcp.mjs +77 -0
- package/worker/customer-resources.mjs +53 -0
- package/worker/customer-shop.mjs +54 -0
- package/worker/extensions.mjs +1 -0
- package/worker/functions-schema.mjs +55 -0
- package/worker/functions.mjs +93 -0
- package/worker/index.mjs +37 -2
- package/worker/machine-payments.mjs +1 -1
- package/worker/mcp-store.mjs +3 -2
- package/worker/mcp-tools.mjs +16 -4
- package/worker/mcp.mjs +31 -15
- package/worker/office-page.mjs +1 -1
- package/worker/players.mjs +5 -2
- package/worker/purchase-delivery.mjs +1 -0
- package/worker/purchase-hosted-readiness.mjs +0 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-machine.mjs +1 -0
- package/worker/purchase-office.mjs +1 -1
- package/worker/purchase-owner.mjs +0 -2
- package/worker/purchase-reconciliation.mjs +1 -0
- package/worker/purchase-routes.mjs +11 -17
- package/worker/purchase-store.mjs +1 -0
- package/worker/selling/runtime.mjs +4 -1
- package/worker/service-run.mjs +25 -0
- package/worker/service-terms.mjs +2 -0
- package/worker/shop-rules.mjs +9 -8
- package/worker/shop.mjs +22 -9
- package/worker/tool-webhook.mjs +1 -0
- 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
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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');
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/purchase-links.mjs
CHANGED
|
@@ -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 * * * *'] },
|
|
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
|
}
|
package/lib/tools-build.mjs
CHANGED
|
@@ -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,
|
package/lib/worker-build.mjs
CHANGED
|
@@ -1,34 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
+
}
|