@homie-rocks/studio 0.24.0 → 0.24.3

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 CHANGED
@@ -17,9 +17,61 @@ 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.24.3] - 2026-10-03
21
+
22
+ **Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36)
23
+
24
+ Set up your shop with Stripe's own tools for AI: your AI makes the products, checks tax, makes the webhook and answers "how are sales?"; you make the account, approve Stripe's pages and paste one key.
25
+
26
+ ### Added
27
+
28
+ - Stripe's agent plugin is how your AI works with your Stripe (`npm install -g @stripe/cli@latest && stripe agent setup`: Stripe's own MCP server and skills for Claude Code and Codex, or Stripe's connector in the Claude app). It is set up when your studio starts selling, never before: `setup status` shows a Stripe row once `shop.json` exists. You sign in once on Stripe's page and give it a sandbox first.
29
+ - `homie-studio shop catalog`: your items as Products in Stripe, made by your AI through Stripe's MCP. It names the one read to make, then (`--have <Stripe's answer>`) exactly the writes still needed: a Product per item with a tax code eligible for Managed Payments and a default Price of `shop.json`'s amount. A new price becomes a new default Price; an item you removed is archived, never deleted; products you made yourself are never touched. In sync, `shop.json` records `"catalog": ["test"]` and checkouts name each item's Product, so Stripe's Dashboard and reports see sales by product. `shop.json`'s price is still what is charged.
30
+ - `homie-studio shop connect` now makes the webhook itself: you paste one restricted key (with Webhook Endpoints: Write added to its permissions), the page makes the endpoint with it, and Stripe's signing secret goes straight to your Worker without anyone seeing it. An older endpoint the kit made for the same address is turned off, not deleted. A key without that permission still works with a webhook secret you made yourself.
31
+ - With Managed Payments chosen in test mode, the connect page tries one test checkout with it (expired at once) and tells you whether Stripe has it on for your account.
32
+ - A refund Stripe holds for approval (when the shop's key is an Agent key) is shown as held, not failed: approve it in Stripe and the item leaves the player's account when Stripe refunds. Refunds made in Stripe's Dashboard or through Stripe's MCP take the item back the same way.
33
+ - `homie-studio setup --via stripe-projects [--with elevenlabs]` (a prototype): for a creator with no Cloudflare account, Stripe Projects makes or links one (and ElevenLabs) on their own Stripe sign-in, on free plans. It stops at each step that is yours (Stripe's sign-in, the providers' terms, Cloudflare's approval page), puts the account id in `studio.json`, keeps the token in Projects' vault and its git-ignored `.env`, and every Wrangler run in the studio uses it. `npx wrangler login` stays the default.
34
+ - The plugin's shop skill now walks a creator with a fresh Stripe account through it: what you click, what to tell your AI, and what stays yours. The studio-setup skill has the Stripe Projects option.
35
+
36
+ ### Changed
37
+
38
+ - The Homie mod refuses a write through Stripe's MCP that would make a webhook endpoint (or an event destination with its secret): Stripe answers the signing secret in that call, and it would land in the conversation. The connect page makes the webhook instead.
39
+ - The Homie mod now takes Stripe webhook signing secrets (`whsec_…`) out of tool output, as it does Stripe keys.
40
+ - `providers.json` lists Stripe: its agent plugin and MCP server for the shop skill, and Stripe Projects for studio-setup. The shop skill declares Stripe's MCP server for Codex. The Homie mod holds `stripe projects upgrade`, `billing add` / `billing update` and anything with `--confirm-paid-service` as paid calls whose cost cannot be read first.
41
+ - A new studio's `.gitignore` also keeps `.env.*`, `.projects/vault/` and `.projects/cache/` out of git; `upgrade` adds them to yours.
42
+
43
+ ### Upgrade notes
44
+
45
+ - Stripe's MCP accepts only its sign-in page (OAuth) or keys tagged "Agent" from 2026-10-31. If you gave an AI a plain Stripe key for the MCP, replace it with an Agent key or sign in again. The shop's own key in your Worker is not affected, and must stay a plain restricted key.
46
+ - To have the connect page make your webhook, add Webhook Endpoints: Write to the shop's restricted key (or make a new key) and run `homie-studio shop connect` again. Afterwards you can set that permission back to None.
47
+
48
+ ## [0.24.2] - 2026-10-03
49
+
50
+ **Plugin 0.25.2** · [#35](https://github.com/homie-rocks/homie/pull/35) · [release-2026-10-03-studio-0.24.2](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.2)
51
+
52
+ Homie's skills work through each provider's own CLI, plugin and MCP server, declared so they load only when a skill needs them, and the Homie mod holds the paid calls and Cloudflare changes those tools can make.
53
+
54
+ ### Added
55
+
56
+ - `providers.json` in the plugin: each provider's own CLI, plugin, MCP servers and skills, the skills that use them, and what stays Homie's (budgets and receipts, the kids rules, secrets never in the chat, the owner's one-tap asks, phone budgets, the rights notes). The plugin's `plugin.json` points at it (`extensions["rocks.homie"].providers`). Nothing installs by itself: a skill offers a provider's tool when the person wants what it unlocks, and the person approves the install and signs in on the provider's own page.
57
+ - Each skill that uses a provider names it in its own frontmatter (`metadata.providers`, and `compatibility` in words: the Agent Skills fields). The art, video, models, style, publish and servers skills declare their provider's MCP server for Codex in `agents/openai.yaml` (fal's MCP server; Cloudflare's docs server, which needs no sign-in), so Codex can wire it when the skill is used.
58
+ - The Homie mod holds a change to a studio's Cloudflare account made outside its deploy, inside the studio: Wrangler deleting something (a Worker, a D1 database, an R2 bucket or object, a KV namespace or key, a queue, a secret), a `secret put`, a version rolled out or back by hand, a migration or a writing query on the live database (`--remote`), and the same through Cloudflare's own MCP servers or a claude.ai Cloudflare connector (the API server's `execute` sending anything but a GET or a GraphQL read; a tool that deletes, updates or edits). The hold names the studio's own Worker, database or bucket when the change touches one. Creating something and reading are not held; `guardDeploys: false` turns it off.
59
+ - The mod's spending guard knows the providers' own CLIs and MCP servers: a generating command of `elevenlabs`, `fal api` or `fal run`, `genmedia run` and `tripo` is held as a call whose cost cannot be read first, and so is a run on fal's, ElevenLabs' or Tripo's MCP server. Their help, dry runs, sign-ins, prices, listings and ElevenLabs' `estimate_only` are free.
60
+
61
+ ### Changed
62
+
63
+ - The art and video skills find models and read prices on fal's own MCP server at its sign-in address (`https://mcp.fal.ai/mcp-relay`: the person signs in on fal's page, no key), with the commands that connect it in Claude Code and in Codex. Paid runs still go through the skills' scripts (priced, capped, receipted, resumable), never the MCP's `run_model`.
64
+ - The publish skill points at Cloudflare's own tools for questions beyond the deploy (its docs MCP server; its plugin's skills and API server, from `cloudflare/skills` or Claude's plugin directory; `wrangler login --device` where no browser can open) and says the studio's own Worker, database, storage and secrets change only through its deploy. The servers skill reads Cloudflare's model catalogue (`wrangler ai models list --json`) rather than memory.
65
+ - The music skill names ElevenLabs' plugin for Codex too, their skills on their own (`npx skills add elevenlabs/skills`) and their hosted MCP server, which has no music tools, and prefers the CLI to a raw API call.
66
+ - The models skill keeps Tripo on fal: Tripo's own CLI and MCP server bill a separate Tripo account outside the skill's budget and receipts.
67
+
68
+ ### Fixed
69
+
70
+ - The studio-setup and style skills' descriptions are plain YAML now. Each held a `: `, which a strict YAML reader refuses.
71
+
20
72
  ## [0.24.0] - 2026-10-03
21
73
 
22
- **Plugin 0.25.0** · [#34](https://github.com/homie-rocks/homie/pull/34)
74
+ **Plugin 0.25.0** · [#34](https://github.com/homie-rocks/homie/pull/34) · [release-2026-10-03-studio-0.24.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.0)
23
75
 
24
76
  Sell things in your games with your own Stripe account: a supporter pack, cosmetics, a pass, an unlock or a tip, in real money, with refunds from your office and the kids rules built in; Homie never sees the money.
25
77
 
package/README.md CHANGED
@@ -325,7 +325,7 @@ npx homie-studio chat words # the bu
325
325
 
326
326
  ## Selling things: the shop
327
327
 
328
- A studio sells items for its games (0.24.0) with **its own Stripe account**: the studio is the seller, money goes
328
+ A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
329
329
  straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
330
330
  currency; not a Connect platform). Real-money prices only, nothing random, the kids rules built in (no shop on a kids
331
331
  server, spending off until a neutral age question says adult, nothing for under-13s, 13-17 through a parent's own
@@ -334,11 +334,20 @@ checkout, a monthly cap), and the television only shows a code to buy on a phone
334
334
  ```sh
335
335
  npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
336
336
  npx homie-studio shop check # the kit's rules; every build checks them too
337
- npx homie-studio shop connect # a page on THIS computer: the owner pastes a restricted key and the webhook secret
337
+ npx homie-studio shop catalog [--have <file>] # the items as Products in Stripe: the read, then the exact writes (Stripe's MCP)
338
+ npx homie-studio shop connect # a page on THIS computer: the owner pastes one restricted key; it makes the webhook
338
339
  npx homie-studio shop # open (test or live) or what is missing
339
340
  npx homie-studio shop refund ord_… # an ASK: the owner taps once (or Refund in /_studio/office/shop)
340
341
  ```
341
342
 
343
+ Since 0.24.3 the owner's AI sets it up with **Stripe's own agent tools** (`npm install -g @stripe/cli@latest && stripe
344
+ agent setup`: Stripe's MCP server and skills; the owner signs in once on Stripe's page, a sandbox first): it makes the
345
+ catalog through the MCP (`shop catalog` says exactly what to write), reads the tax settings, and answers "how are
346
+ sales?" read-only. The owner makes the account, approves Stripe's pages and pastes **one** restricted key on the
347
+ connect page, which makes the webhook with it, so its signing secret goes straight to the Worker and never through a
348
+ chat. A webhook is never made through Stripe's MCP (its answer carries the secret). From 2026-10-31 Stripe's MCP takes
349
+ only OAuth or Agent-tagged keys; the shop's own key is a plain restricted key and is not affected.
350
+
342
351
  In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`); a
343
352
  supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), Stripe Tax on, or Stripe Managed
344
353
  Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
@@ -356,7 +365,8 @@ One checklist of what this computer and the person's accounts have for a studio:
356
365
  connector (the AI says whether its Homie tools are there; the directory is reached), Cloudflare (signed in
357
366
  with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
358
367
  deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
359
- fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
368
+ fal (and, in Claude Code, the status line); in a studio that sells (shop.json), a Stripe row: whether Stripe's own
369
+ agent plugin (its MCP server) is set up for this AI, with `stripe agent setup` as the fix (0.24.3). Each row is green, missing or "do this now", says what it
360
370
  unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
361
371
  seconds, and never prints a key, a token or an account's name or id. A studio the Claude app's setup card made
362
372
  (`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
@@ -613,6 +623,27 @@ the home folder's `.cache/homie-studio` (from storage.googleapis.com, which a cl
613
623
  renders with SwiftShader. `check` reports how fast each browser drew the game and on what renderer; on SwiftShader
614
624
  it says the frame rate is not a person's, and judges only seats, rooms and rounds.
615
625
 
626
+ ## Another way to Cloudflare: Stripe Projects (a prototype, 0.24.3)
627
+
628
+ `npx wrangler login` stays the way. For a creator with no Cloudflare account (and no ElevenLabs), Stripe Projects
629
+ (Stripe's CLI plugin, docs.stripe.com/projects) can make or link both on the person's own Stripe sign-in, on free
630
+ plans, and hand the credentials to the studio folder:
631
+
632
+ ```sh
633
+ npx homie-studio setup --via stripe-projects [--with elevenlabs] --dry-run # what it would do; changes nothing
634
+ npx homie-studio setup --via stripe-projects [--with elevenlabs] --accept-tos # after the person accepted the providers' terms
635
+ ```
636
+
637
+ It stops with a `needs` step at each thing that is the person's (the Stripe CLI and its Projects plugin, Stripe's
638
+ sign-in, the providers' terms, Cloudflare's own approval page for an existing account); it never adds a paid plan or
639
+ a payment method. The Cloudflare account id goes in studio.json with `"auth": "stripe-projects"`; the token stays in
640
+ Projects' vault and the git-ignored `.env` it syncs, and every Wrangler run in the studio takes it from there
641
+ (`deploy`, `dev`, secrets, the office). It checks read-only what the token can do (Wrangler signed in, D1, Workers AI;
642
+ R2 is optional) and that `.gitignore` keeps `.env`, `.env.*`, `.projects/vault/` and `.projects/cache/` out of git.
643
+ With `--with elevenlabs` the music skill finds the ElevenLabs key there too (ElevenLabs' free plan has no commercial
644
+ licence). Unverified until a real run: the exact JSON of the Projects commands, and the Cloudflare token's permission
645
+ groups, which the checks measure instead of assuming.
646
+
616
647
  ## No payment method needed
617
648
 
618
649
  `deploy` creates only what Cloudflare's free Workers plan gives a new account with no
@@ -125,8 +125,12 @@
125
125
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
126
126
  * homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
127
127
  * homie-studio shop connect [--managed] [--live] a page on THIS computer where the owner pastes the studio's
128
- * restricted Stripe key and webhook secret: straight to the Worker secrets,
129
- * never a chat or a file (test keys only unless --live)
128
+ * restricted Stripe key; with it this makes the webhook (0.24.3), and the key
129
+ * and the webhook's secret go straight to the Worker secrets, never a chat or a
130
+ * file (test keys only unless --live)
131
+ * homie-studio shop catalog [--have <file>|-] [--mode test|live] the items as Products in the studio's Stripe,
132
+ * made by the AI through Stripe's own MCP: the read first, then (with what it
133
+ * answered) the exact writes; in sync, shop.json "catalog" records the mode
130
134
  * homie-studio shop disconnect both secrets gone: the shop closes
131
135
  * homie-studio shop orders the latest orders (never a card or an email)
132
136
  * homie-studio shop refund <order> [--reason …] [--note "<why>"] ASKS the owner (a one-tap link)
@@ -162,6 +166,13 @@
162
166
  * the build so the chat's card follows it, and print the steps; HANDOFF.md)
163
167
  * homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
164
168
  * learn its live address, and give a template copy its real name)
169
+ * homie-studio setup --via stripe-projects [--with elevenlabs] [--accept-tos] [--dry-run]
170
+ * (a prototype, 0.24.3: Cloudflare, and ElevenLabs with --with, made or linked
171
+ * through Stripe Projects on their free plans; the person accepts the providers'
172
+ * terms (then --accept-tos) and signs in on Stripe's and the provider's own
173
+ * pages; the account id goes into studio.json, the token stays in Projects'
174
+ * vault and its git-ignored .env, and Wrangler runs with it here. The usual
175
+ * way, npx wrangler login, stays the default. lib/projects.mjs)
165
176
  * homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
166
177
  * what this computer and the person's accounts have for a studio: Node, the Homie
167
178
  * connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
@@ -237,6 +248,9 @@ import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords }
237
248
  import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
238
249
  import { AgentSeat } from '../lib/agent-seat.mjs';
239
250
  import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
251
+ import { catalogLines, catalogPlan } from '../lib/shop-catalog.mjs';
252
+ import { projectsLines, setupViaProjects } from '../lib/projects.mjs';
253
+ import { projectsCloudflareEnv } from '../lib/projects-env.mjs';
240
254
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
241
255
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
242
256
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -249,7 +263,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
249
263
 
250
264
  const argv = process.argv.slice(2);
251
265
  const flags = new Map();
252
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send'];
266
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos'];
253
267
  const positional = [];
254
268
  for (let i = 0; i < argv.length; i++) {
255
269
  const a = argv[i];
@@ -461,6 +475,12 @@ function print(result) {
461
475
  case 'shop disconnect':
462
476
  lines.push(...shopLines(result));
463
477
  break;
478
+ case 'shop catalog':
479
+ lines.push(...catalogLines(result));
480
+ break;
481
+ case 'setup':
482
+ lines.push(...projectsLines(result));
483
+ break;
464
484
  case 'chat rules':
465
485
  case 'chat budget':
466
486
  case 'shop refund':
@@ -662,6 +682,12 @@ async function main() {
662
682
  if (cmd === 'codex') return codexCommand(root, sub);
663
683
  if (cmd === 'progress') return progressCommand(root, sub);
664
684
  if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
685
+ if (cmd === 'setup' && flags.has('via')) {
686
+ if (flags.get('via') !== 'stripe-projects') return { ok: false, command: 'setup', why: '--via stripe-projects is the one other way this version knows (the default is npx wrangler login)' };
687
+ const extra = String(flags.get('with') ?? '').split(',').map((x) => x.trim()).filter(Boolean);
688
+ if (extra.some((x) => x !== 'elevenlabs')) return { ok: false, command: 'setup', why: '--with takes elevenlabs (Cloudflare is always set up)' };
689
+ return setupViaProjects(root, { withElevenlabs: extra.includes('elevenlabs'), acceptTos: flags.has('accept-tos'), dryRun: flags.has('dry-run'), log });
690
+ }
665
691
  if (cmd === 'handoff') return handoff(root, sub, { homie: flags.get('homie') });
666
692
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
667
693
  if (cmd === 'port' && sub === 'check') {
@@ -775,10 +801,22 @@ async function main() {
775
801
  if (sub === 'check') return shopCheck(root);
776
802
  if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), log });
777
803
  if (sub === 'disconnect') return shopDisconnect(root);
804
+ if (sub === 'catalog') {
805
+ const from = flags.get('have');
806
+ let have = null;
807
+ if (from === '-' || from === true) have = readFileSync(0, 'utf8');
808
+ else if (typeof from === 'string') {
809
+ if (!existsSync(from)) return { ok: false, command: 'shop catalog', why: `no such file: ${from}` };
810
+ have = readFileSync(from, 'utf8');
811
+ }
812
+ const mode = flags.get('mode');
813
+ if (mode !== undefined && mode !== 'test' && mode !== 'live') return { ok: false, command: 'shop catalog', why: '--mode is test or live' };
814
+ return catalogPlan(root, { have, mode: mode ?? null });
815
+ }
778
816
  if (sub === 'orders') return shopOrders(root, { url });
779
817
  if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
780
818
  if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), send: flags.has('send') });
781
- return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, orders, refund, statements)` };
819
+ return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
782
820
  }
783
821
  if (cmd === 'agents') {
784
822
  const url = flags.get('url');
@@ -983,7 +1021,7 @@ async function dev(root) {
983
1021
  const bin = wranglerBin(root);
984
1022
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
985
1023
  const studio = readStudio(root);
986
- const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
1024
+ const env = { ...process.env, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1' };
987
1025
  for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
988
1026
  await new Promise((done) => {
989
1027
  const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
@@ -1065,7 +1103,7 @@ function mediaPut(root, file, as) {
1065
1103
  const key = as ?? (/^(music|videos)\//.test(rel) && !rel.includes('..') ? rel : `${folder}/${basename(file)}`);
1066
1104
  const bin = wranglerBin(root);
1067
1105
  return new Promise((done) => {
1068
- const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: workerDir(root), env: { ...process.env, CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
1106
+ const p = spawn(bin, ['r2', 'object', 'put', `${r2}/${key}`, '--file', resolve(file), '--content-type', typeOf(file), '--remote'], { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), CI: '1' }, stdio: ['ignore', 'ignore', 'inherit'] });
1069
1107
  p.on('close', (code) => {
1070
1108
  if (code !== 0) return done({ ok: false, command: 'media put', why: 'wrangler r2 object put failed' });
1071
1109
  // The manifest is committed: it names the file by its path on the site, never the workers.dev address.
@@ -30,6 +30,7 @@ import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File,
30
30
  import { repoOf } from './repo.mjs';
31
31
  import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
32
32
  import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
33
+ import { projectsCloudflareEnv } from './projects-env.mjs';
33
34
 
34
35
  const ANSI = /\u001b\[[0-9;]*m/g;
35
36
 
@@ -38,13 +39,17 @@ export function wranglerBin(root) {
38
39
  return existsSync(local) ? local : null;
39
40
  }
40
41
 
41
- /** Wrangler from the studio's own node_modules, run in site/ with the given environment. */
42
+ /**
43
+ * Wrangler from the studio's own node_modules, run in site/ with the given environment. A studio whose Cloudflare came
44
+ * through Stripe Projects (studio.json `cloudflare.auth: "stripe-projects"`, 0.24.3) runs it with the token and account
45
+ * Projects synced to its git-ignored output file; every other studio with Wrangler's own sign-in.
46
+ */
42
47
  export function runner(root, env) {
43
48
  const bin = wranglerBin(root);
44
49
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
45
50
  return (args, { cwd = workerDir(root), input } = {}) => {
46
51
  const res = spawnSync(bin, args, {
47
- cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
52
+ cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
48
53
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
49
54
  });
50
55
  const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
@@ -128,6 +133,9 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
128
133
  const created = new Set(cf.created ?? []);
129
134
  const who = whoami(root);
130
135
  if (!who) {
136
+ if (cf.auth === 'stripe-projects') {
137
+ return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
138
+ }
131
139
  return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
132
140
  }
133
141
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
@@ -490,7 +498,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
490
498
  const bin = wranglerBin(root);
491
499
  if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
492
500
  const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
493
- const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
501
+ const opts = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
494
502
  const moved = [];
495
503
  const failed = [];
496
504
  const verified = [];
package/lib/doctor.mjs CHANGED
@@ -16,6 +16,9 @@
16
16
  * GitHub optional a private backup, publishing by pull request, building from the Claude app
17
17
  * ElevenLabs optional songs and game scores
18
18
  * fal optional painted art and generated video
19
+ * Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
20
+ * and skills) for this AI, set up when the studio starts selling; the shop's key itself
21
+ * goes in through `homie-studio shop connect`, never here
19
22
  * status line optional (Claude Code only) the build's progress under the prompt
20
23
  *
21
24
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
@@ -32,6 +35,7 @@ import { findChrome } from './chrome.mjs';
32
35
  import { whyFailed } from './net.mjs';
33
36
  import { isOurs } from './statusline.mjs';
34
37
  import { findStudio, readLocal, readStudio } from './studio.mjs';
38
+ import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
35
39
  import { STUDIO_VERSION } from './version.mjs';
36
40
 
37
41
  /** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
@@ -101,7 +105,12 @@ export async function setupStatus({
101
105
  if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
102
106
  const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
103
107
  if (!bin || !existsSync(bin)) return { signedIn: null };
104
- const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
108
+ // Through Stripe Projects (studio.json "auth": "stripe-projects"): Wrangler with the token Projects synced.
109
+ const projects = projectsCloudflareEnv(root);
110
+ const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, ...projects, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
111
+ if (projects.CLOUDFLARE_API_TOKEN && r.code === 0) {
112
+ try { if (JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))).loggedIn) return { signedIn: true, how: 'through Stripe Projects (its token, in the studio\'s git-ignored .env; never shown)', accounts: 1, accountIds: [projects.CLOUDFLARE_ACCOUNT_ID].filter(Boolean) }; } catch { /* read below */ }
113
+ }
105
114
  if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
106
115
  // The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
107
116
  try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
@@ -124,7 +133,7 @@ export async function setupStatus({
124
133
  const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
125
134
  try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
126
135
  }
127
- return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY) };
136
+ return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY), projects: root ? projectsElevenLabs(root) : false };
128
137
  })(),
129
138
  (async () => {
130
139
  const key = String(env.FAL_KEY ?? '').trim();
@@ -231,10 +240,10 @@ export async function setupStatus({
231
240
 
232
241
  // ElevenLabs.
233
242
  rows.push({
234
- id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key ? 'ok' : 'optional',
235
- detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
243
+ id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
244
+ detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
236
245
  unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
237
- fix: eleven.signedIn || eleven.key ? null : eleven.cli
246
+ fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
238
247
  ? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
239
248
  : { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
240
249
  });
@@ -247,6 +256,24 @@ export async function setupStatus({
247
256
  fix: fal.key && fal.valid !== false ? null : { who: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
248
257
  });
249
258
 
259
+ // Stripe, only in a studio that sells (shop.json): Stripe's own agent plugin, set up when selling starts (lazy, like
260
+ // every provider here). The shop's key never comes through here: `homie-studio shop connect` takes it on a page.
261
+ if (root && existsSync(join(root, 'shop.json'))) {
262
+ const v = await exec('stripe', ['--version']);
263
+ const version = v.code === 0 ? /(\d+\.\d+\.\d+)/.exec(v.stdout)?.[1] ?? null : null;
264
+ const mcp = stripeAgentConfigured({ root, env });
265
+ rows.push({
266
+ id: 'stripe', label: 'Stripe', need: 'for selling', state: mcp ? 'ok' : 'act',
267
+ detail: [mcp ? `Stripe's MCP is set up for ${mcp}` : 'Stripe\'s MCP is not set up for this AI', version ? `the Stripe CLI ${version}` : 'no Stripe CLI'].join('; '),
268
+ unlocks: 'the shop: your AI makes the catalog, checks tax and Managed Payments and answers "how are sales?" with Stripe\'s own tools, on your own Stripe account; you sign in on Stripe\'s page',
269
+ fix: mcp ? null : {
270
+ who: 'ai', run: 'npm install -g @stripe/cli@latest && stripe agent setup',
271
+ say: 'Stripe\'s own agent plugin for Claude Code and Codex (its MCP server and skills, kept up to date); you approve the install. Then sign in once on Stripe\'s page and give access to your Stripe sandbox first.',
272
+ open: 'https://dashboard.stripe.com/register',
273
+ },
274
+ });
275
+ }
276
+
250
277
  // The Claude Code status line (only in Claude Code, only in a studio).
251
278
  if (root && env.CLAUDECODE === '1') {
252
279
  let on = false;
@@ -270,6 +297,7 @@ export async function setupStatus({
270
297
  { feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
271
298
  { feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
272
299
  ...(by['workers-ai'] ? [{ feature: 'AI guides that think (Workers AI)', state: ready(by['workers-ai'].state), needs: ['workers-ai'] }] : []),
300
+ ...(by.stripe ? [{ feature: 'Sell in the games (the shop, with Stripe\'s own tools)', state: ready(by.stripe.state), needs: ['stripe'] }] : []),
273
301
  ];
274
302
  const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
275
303
  const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
@@ -284,6 +312,23 @@ export async function setupStatus({
284
312
  };
285
313
  }
286
314
 
315
+ /**
316
+ * Where Stripe's MCP server (https://mcp.stripe.com) is set up for this person's AI: Claude Code (the Stripe plugin,
317
+ * or an MCP entry in .claude.json in the home folder, or the studio's .mcp.json) or Codex (config.toml in the Codex
318
+ * home folder). It only looks for the address or the plugin's name in those files; it never reads a key and never
319
+ * prints what it found. Null when nowhere.
320
+ */
321
+ export function stripeAgentConfigured({ root, env = process.env } = {}) {
322
+ const home = env.HOME || env.USERPROFILE || '';
323
+ const look = (file, re) => {
324
+ try { if (!file || !existsSync(file)) return false; const t = readFileSync(file, 'utf8'); return t.length < 64 * 1024 * 1024 && re.test(t); } catch { return false; }
325
+ };
326
+ const mcp = /mcp\.stripe\.com/;
327
+ if (look(home && join(home, '.claude', 'plugins', 'installed_plugins.json'), /"stripe@/) || look(home && join(home, '.claude.json'), mcp) || look(root && join(root, '.mcp.json'), mcp)) return 'Claude Code';
328
+ if (look(join(env.CODEX_HOME || (home && join(home, '.codex')) || '', 'config.toml'), /mcp\.stripe\.com|\[mcp_servers\.stripe\]|stripe@openai-curated/)) return 'Codex';
329
+ return null;
330
+ }
331
+
287
332
  /** The checklist as a person reads it in a terminal. */
288
333
  export function formatStatus(r) {
289
334
  const lines = [`Setup status${r.studio?.name ? ` for ${r.studio.name}` : ' (before the studio exists)'}`, ''];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The credentials Stripe Projects keeps for a studio (`homie-studio setup --via stripe-projects`, 0.24.3), read only
3
+ * where they are used and never printed, returned to a person, or written anywhere else.
4
+ *
5
+ * Stripe Projects keeps them encrypted in its vault and syncs them to the project's output file (`.env` unless the
6
+ * environment names another), which it creates readable by this user only and git-ignores. Wrangler then deploys with
7
+ * the Cloudflare token from that file instead of a `wrangler login`; the music skill reads the ElevenLabs key the same
8
+ * way. studio.json says which (never a value): `cloudflare.auth: "stripe-projects"`, `providers.elevenlabs.via`.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { isAbsolute, join, normalize, sep } from 'node:path';
12
+
13
+ /** The output file inside the studio (`.env` by default); null for anything outside it. */
14
+ export function envFileOf(root, name = '.env') {
15
+ const n = normalize(String(name || '.env'));
16
+ if (isAbsolute(n) || n === '..' || n.startsWith(`..${sep}`)) return null;
17
+ return join(root, n);
18
+ }
19
+
20
+ const unquote = (v) => {
21
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v); } catch { return v.slice(1, -1); } }
22
+ if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
23
+ return v;
24
+ };
25
+
26
+ /** A dotenv file as { NAME: value } (KEY=value lines; `export ` and comments allowed). {} when absent. */
27
+ export function readDotenv(file) {
28
+ const out = {};
29
+ if (!file || !existsSync(file)) return out;
30
+ let text = '';
31
+ try { text = readFileSync(file, 'utf8'); } catch { return out; }
32
+ for (const line of text.split(/\r?\n/)) {
33
+ const t = line.trim().replace(/^export\s+/, '');
34
+ if (!t || t.startsWith('#')) continue;
35
+ const i = t.indexOf('=');
36
+ if (i < 1) continue;
37
+ const k = t.slice(0, i).trim();
38
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
39
+ out[k] = unquote(t.slice(i + 1).trim());
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function studioOf(root) {
45
+ try { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); } catch { return null; }
46
+ }
47
+
48
+ /**
49
+ * The environment Wrangler gets for a studio whose Cloudflare came through Stripe Projects: CLOUDFLARE_API_TOKEN and
50
+ * CLOUDFLARE_ACCOUNT_ID from the Projects output file. {} for every other studio (the default: `wrangler login`).
51
+ */
52
+ export function projectsCloudflareEnv(root, studio = studioOf(root)) {
53
+ const cf = studio?.cloudflare;
54
+ if (!root || cf?.auth !== 'stripe-projects') return {};
55
+ const vars = readDotenv(envFileOf(root, cf.envFile));
56
+ return {
57
+ ...(vars.CLOUDFLARE_API_TOKEN ? { CLOUDFLARE_API_TOKEN: vars.CLOUDFLARE_API_TOKEN } : {}),
58
+ ...(vars.CLOUDFLARE_ACCOUNT_ID ? { CLOUDFLARE_ACCOUNT_ID: vars.CLOUDFLARE_ACCOUNT_ID } : {}),
59
+ };
60
+ }
61
+
62
+ /** Whether Stripe Projects holds this studio's ElevenLabs key (studio.json `providers.elevenlabs`); never the key. */
63
+ export function projectsElevenLabs(root, studio = studioOf(root)) {
64
+ const p = studio?.providers?.elevenlabs;
65
+ if (!root || p?.via !== 'stripe-projects') return false;
66
+ const name = /^[A-Z][A-Z0-9_]{2,}$/.test(String(p.envKey ?? '')) ? p.envKey : 'ELEVENLABS_API_KEY';
67
+ return Boolean(readDotenv(envFileOf(root, p.envFile))[name]);
68
+ }