@homie-rocks/studio 0.24.2 → 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 +29 -1
- package/README.md +34 -3
- package/bin/homie-studio.mjs +44 -6
- package/lib/cloudflare.mjs +11 -3
- package/lib/doctor.mjs +50 -5
- package/lib/projects-env.mjs +68 -0
- package/lib/projects.mjs +311 -0
- package/lib/scaffold.mjs +13 -2
- package/lib/shop-catalog.mjs +163 -0
- package/lib/shop.mjs +151 -60
- package/lib/template-history.json +39 -0
- package/package.json +1 -1
- package/shop/SELLING.md +16 -0
- package/shop/SHOP.md +65 -16
- package/worker/office.mjs +2 -1
- package/worker/shop-rules.mjs +17 -1
- package/worker/shop.mjs +32 -9
- package/worker/stripe.mjs +30 -0
- package/worker/version.mjs +1 -1
package/lib/projects.mjs
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio setup --via stripe-projects [--with elevenlabs] [--accept-tos] [--dry-run]` (@homie-rocks/studio
|
|
3
|
+
* 0.24.3, a PROTOTYPE): a creator with no Cloudflare account (and no ElevenLabs) gets both through Stripe Projects
|
|
4
|
+
* (docs.stripe.com/projects), Stripe's CLI plugin that makes or links provider accounts and hands their credentials to
|
|
5
|
+
* the project. Homie's own paths (`npx wrangler login`, an ElevenLabs sign-in) stay the default; this is an option.
|
|
6
|
+
*
|
|
7
|
+
* What it does, in order, and where it stops to hand a step to the person (`needs`):
|
|
8
|
+
* 1. the Stripe CLI (1.40.0 or later) and its Projects plugin are here;
|
|
9
|
+
* 2. this folder is a Stripe project, signed in to the PERSON'S OWN Stripe account (`stripe projects init` opens
|
|
10
|
+
* Stripe's sign-in in the browser: the person signs in, then this runs again);
|
|
11
|
+
* 3. the catalog has Cloudflare's Workers service (and ElevenLabs' with --with elevenlabs);
|
|
12
|
+
* 4. the person said yes to the provider's terms (only then --accept-tos is passed on); the services are added on
|
|
13
|
+
* their free plans: this never passes --confirm-paid-service, and stops if Stripe reports anything paid;
|
|
14
|
+
* an existing Cloudflare account is linked through Cloudflare's own approval page (`stripe projects link`);
|
|
15
|
+
* a Stripe email with no Cloudflare account gets a new one from Cloudflare;
|
|
16
|
+
* 5. the credentials are where Projects put them (`stripe projects env --json` lists NAMES only): the account id
|
|
17
|
+
* goes into studio.json, the token stays in Projects' vault and its git-ignored output file, and studio.json says
|
|
18
|
+
* `cloudflare.auth: "stripe-projects"` so every Wrangler run here takes it from there (lib/projects-env.mjs);
|
|
19
|
+
* 6. read-only checks with that token: Wrangler is signed in, D1 and Workers AI answer (R2 is optional and needs a
|
|
20
|
+
* payment method on Cloudflare, so it is only reported);
|
|
21
|
+
* 7. .gitignore keeps `.env`, `.env.*`, `.projects/vault/` and `.projects/cache/` out of git.
|
|
22
|
+
* Then `npm run deploy` works as always.
|
|
23
|
+
*
|
|
24
|
+
* Never: a value of any credential in output, a file of ours, an argument or a log (Projects' own rule too: names
|
|
25
|
+
* only); a Stripe login that is not the person's; a paid plan; a payment method (the free plans need none).
|
|
26
|
+
*
|
|
27
|
+
* Unverified until a real run (the docs do not say): the exact JSON shapes of `status`, `search` and `env --json`
|
|
28
|
+
* (read tolerantly here), the ElevenLabs service's slug and env name, and the Cloudflare token's permission groups
|
|
29
|
+
* (step 6 measures what it can do instead of assuming).
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { runner } from './cloudflare.mjs';
|
|
35
|
+
import { envFileOf, readDotenv } from './projects-env.mjs';
|
|
36
|
+
import { readStudio, writeStudio } from './studio.mjs';
|
|
37
|
+
|
|
38
|
+
export const MIN_STRIPE_CLI = '1.40.0';
|
|
39
|
+
export const TERMS = Object.freeze({
|
|
40
|
+
cloudflare: 'https://www.cloudflare.com/terms/',
|
|
41
|
+
elevenlabs: 'https://elevenlabs.io/terms-of-use',
|
|
42
|
+
});
|
|
43
|
+
/** What .gitignore must hold before any credential lands in the folder. */
|
|
44
|
+
export const IGNORED = Object.freeze(['.env', '.env.*', '.projects/vault/', '.projects/cache/']);
|
|
45
|
+
|
|
46
|
+
function defaultExec(cmd, args, { cwd, env, timeout = 180_000 } = {}) {
|
|
47
|
+
const r = spawnSync(cmd, args, { cwd, env: env ?? process.env, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024 });
|
|
48
|
+
if (r.error) return { code: r.error.code === 'ENOENT' ? 127 : r.error.code === 'ETIMEDOUT' ? 124 : 1, stdout: '', stderr: String(r.error.message) };
|
|
49
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The first JSON value in a command's output (CLIs print a line or two before it), or null. */
|
|
53
|
+
export function jsonOf(text) {
|
|
54
|
+
const t = String(text ?? '');
|
|
55
|
+
let i = t.search(/[[{]/);
|
|
56
|
+
while (i >= 0) {
|
|
57
|
+
const end = Math.max(t.lastIndexOf('}'), t.lastIndexOf(']'));
|
|
58
|
+
for (const piece of [t.slice(i), end > i ? t.slice(i, end + 1) : null]) {
|
|
59
|
+
if (piece === null) continue;
|
|
60
|
+
try { return JSON.parse(piece); } catch { /* the next try */ }
|
|
61
|
+
}
|
|
62
|
+
const next = t.slice(i + 1).search(/[[{]/);
|
|
63
|
+
i = next < 0 ? -1 : i + 1 + next;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every string in a JSON value (keys too), for reading shapes the docs do not pin down. */
|
|
69
|
+
function stringsIn(value, out = [], depth = 0) {
|
|
70
|
+
if (depth > 12 || value === null || value === undefined) return out;
|
|
71
|
+
if (typeof value === 'string') out.push(value);
|
|
72
|
+
else if (Array.isArray(value)) for (const v of value) stringsIn(v, out, depth + 1);
|
|
73
|
+
else if (typeof value === 'object') for (const [k, v] of Object.entries(value)) { out.push(k); stringsIn(v, out, depth + 1); }
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** provider/service slugs named anywhere in a JSON answer ("cloudflare/workers"), and provider + service pairs. */
|
|
78
|
+
export function servicesIn(value) {
|
|
79
|
+
const found = new Set();
|
|
80
|
+
for (const s of stringsIn(value)) if (/^[a-z0-9][a-z0-9_.-]*\/[a-z0-9][a-z0-9_:.-]*$/i.test(s)) found.add(s.toLowerCase());
|
|
81
|
+
const walk = (v, depth = 0) => {
|
|
82
|
+
if (depth > 12 || !v || typeof v !== 'object') return;
|
|
83
|
+
if (Array.isArray(v)) { v.forEach((x) => walk(x, depth + 1)); return; }
|
|
84
|
+
const p = typeof v.provider === 'string' ? v.provider : typeof v.provider?.id === 'string' ? v.provider.id : null;
|
|
85
|
+
const s = typeof v.service === 'string' ? v.service : typeof v.service?.id === 'string' ? v.service.id : null;
|
|
86
|
+
if (p && s) found.add((s.includes('/') ? s : `${p}/${s}`).toLowerCase());
|
|
87
|
+
Object.values(v).forEach((x) => walk(x, depth + 1));
|
|
88
|
+
};
|
|
89
|
+
walk(value);
|
|
90
|
+
return [...found];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Environment variable NAMES in `stripe projects env --json` (values are never there, and never read from it). */
|
|
94
|
+
export function envNamesIn(value) {
|
|
95
|
+
return [...new Set(stringsIn(value).filter((s) => /^[A-Z][A-Z0-9_]{2,}$/.test(s)))];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether an answer says something costs money: a price or amount above 0, or a plan named paid. */
|
|
99
|
+
export function paidIn(value) {
|
|
100
|
+
let paid = false;
|
|
101
|
+
const walk = (v, key = '', depth = 0) => {
|
|
102
|
+
if (paid || depth > 12 || v === null || v === undefined) return;
|
|
103
|
+
if (typeof v === 'number') { if (/price|amount|cost|monthly/i.test(key) && v > 0) paid = true; return; }
|
|
104
|
+
if (typeof v === 'string') { if (/^(tier|plan|plan_id|tier_id)$/i.test(key) && /paid|\bpro\b|scale|business|enterprise/i.test(v) && !/free/i.test(v)) paid = true; return; }
|
|
105
|
+
if (Array.isArray(v)) { v.forEach((x) => walk(x, key, depth + 1)); return; }
|
|
106
|
+
if (typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
|
|
107
|
+
};
|
|
108
|
+
walk(value);
|
|
109
|
+
return paid;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CODES = ['PROVIDER_NOT_LINKED', 'BROWSER_AUTH_REQUIRED', 'NOT_INITIALIZED', 'PROJECT_NOT_FOUND', 'TOS_NOT_ACCEPTED', 'PAYMENT_METHOD_REQUIRED', 'PAID_SERVICE_CONFIRMATION_REQUIRED'];
|
|
113
|
+
/** The error code a Projects command answered with (from its JSON or its words). */
|
|
114
|
+
export function codeOf(r) {
|
|
115
|
+
const text = `${r?.stdout ?? ''}\n${r?.stderr ?? ''}`;
|
|
116
|
+
const j = jsonOf(text);
|
|
117
|
+
const failed = j && typeof j === 'object' && (j.error || j.ok === false || j.success === false || j.status === 'error');
|
|
118
|
+
const fromJson = failed ? (j.error?.code ?? j.code ?? null) : null;
|
|
119
|
+
if (typeof fromJson === 'string' && fromJson) return fromJson.toUpperCase();
|
|
120
|
+
for (const c of CODES) if (text.includes(c)) return c;
|
|
121
|
+
if (/not (?:been )?initiali[sz]ed|no project found|run `?stripe projects init/i.test(text)) return 'NOT_INITIALIZED';
|
|
122
|
+
if (/log ?in|sign ?in|authenticat/i.test(text) && /browser/i.test(text)) return 'BROWSER_AUTH_REQUIRED';
|
|
123
|
+
if (/terms of service|accept.*tos|--accept-tos/i.test(text)) return 'TOS_NOT_ACCEPTED';
|
|
124
|
+
if (/paid|payment method/i.test(text) && /confirm|required/i.test(text)) return 'PAID_SERVICE_CONFIRMATION_REQUIRED';
|
|
125
|
+
return r?.code === 0 ? null : 'UNKNOWN_ERROR';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const versionAtLeast = (v, min) => {
|
|
129
|
+
const a = String(v).split('.').map(Number); const b = min.split('.').map(Number);
|
|
130
|
+
for (let i = 0; i < 3; i += 1) { if ((a[i] || 0) > b[i]) return true; if ((a[i] || 0) < b[i]) return false; }
|
|
131
|
+
return true;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** .gitignore holds every line in IGNORED (appended, with a comment, when one is missing). */
|
|
135
|
+
function ensureIgnored(root, { dryRun }) {
|
|
136
|
+
const file = join(root, '.gitignore');
|
|
137
|
+
const now = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
138
|
+
const have = new Set(now.split(/\r?\n/).map((l) => l.trim()));
|
|
139
|
+
const missing = IGNORED.filter((l) => !have.has(l) && !have.has(`/${l}`));
|
|
140
|
+
if (missing.length && !dryRun) appendFileSync(file, `${now && !now.endsWith('\n') ? '\n' : ''}# Stripe Projects: credentials stay on this computer (its vault and the .env it syncs), never in git.\n${missing.join('\n')}\n`);
|
|
141
|
+
return missing;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The prototype's whole run. `exec(cmd, args, { cwd, env })` and `wrangler(args)` are replaceable (tests use
|
|
146
|
+
* stand-ins that answer like the real CLIs' --json); `stripe` names the Stripe CLI.
|
|
147
|
+
*/
|
|
148
|
+
export async function setupViaProjects(root, {
|
|
149
|
+
withElevenlabs = false, acceptTos = false, dryRun = false, log = () => {},
|
|
150
|
+
exec = defaultExec, stripe = process.env.HOMIE_STRIPE_CLI || 'stripe', wrangler = null,
|
|
151
|
+
} = {}) {
|
|
152
|
+
const steps = [];
|
|
153
|
+
const step = (id, state, words) => { steps.push({ id, state, words }); log(`${state === 'ok' || state === 'done' ? '✓' : state === 'needs' ? '→' : '·'} ${words}`); };
|
|
154
|
+
const stop = (id, needs, why, extra = {}) => ({ ok: false, command: 'setup', via: 'stripe-projects', dryRun, steps, needs: { id, ...needs }, why, ...extra });
|
|
155
|
+
const run = (args) => exec(stripe, args, { cwd: root });
|
|
156
|
+
|
|
157
|
+
// 1. The Stripe CLI and the Projects plugin.
|
|
158
|
+
const v = run(['--version']);
|
|
159
|
+
const version = /(\d+\.\d+\.\d+)/.exec(`${v.stdout} ${v.stderr}`)?.[1] ?? null;
|
|
160
|
+
if (v.code === 127 || !version) {
|
|
161
|
+
return stop('stripe-cli', { who: 'ai', run: 'npm install -g @stripe/cli@latest', say: 'Stripe\'s own CLI (or on a Mac: brew install stripe/stripe-cli/stripe); the person approves the install.' }, 'the Stripe CLI is not on this computer');
|
|
162
|
+
}
|
|
163
|
+
if (!versionAtLeast(version, MIN_STRIPE_CLI)) {
|
|
164
|
+
return stop('stripe-cli', { who: 'ai', run: 'npm install -g @stripe/cli@latest', say: `Stripe Projects needs the Stripe CLI ${MIN_STRIPE_CLI} or later (this one is ${version}); on a Mac with Homebrew: brew upgrade stripe/stripe-cli/stripe.` }, `the Stripe CLI is ${version}; Projects needs ${MIN_STRIPE_CLI} or later`);
|
|
165
|
+
}
|
|
166
|
+
step('stripe-cli', 'ok', `the Stripe CLI ${version}`);
|
|
167
|
+
const plugin = run(['projects', '--version']);
|
|
168
|
+
if (plugin.code !== 0) return stop('projects-plugin', { who: 'ai', run: 'stripe plugin install projects', say: 'Stripe\'s Projects plugin for the CLI (no sign-in yet).' }, 'the Stripe Projects plugin is not installed');
|
|
169
|
+
step('projects-plugin', 'ok', 'the Stripe Projects plugin');
|
|
170
|
+
|
|
171
|
+
// 2. A project here, signed in to the person's own Stripe.
|
|
172
|
+
const status = run(['projects', 'status', '--json']);
|
|
173
|
+
const statusCode = codeOf(status);
|
|
174
|
+
if (status.code !== 0 || (statusCode && statusCode !== 'UNKNOWN_ERROR')) {
|
|
175
|
+
if (['NOT_INITIALIZED', 'PROJECT_NOT_FOUND', 'BROWSER_AUTH_REQUIRED', 'UNKNOWN_ERROR'].includes(statusCode ?? 'UNKNOWN_ERROR')) {
|
|
176
|
+
return stop('projects-init', {
|
|
177
|
+
who: 'ai', run: 'stripe projects init --yes',
|
|
178
|
+
say: 'Stripe opens its sign-in in the browser: the person signs in to THEIR OWN Stripe account (a new free one is fine; never a work or client account that happens to be signed in here). Wait until they say it is done, then run this again.',
|
|
179
|
+
}, statusCode === 'BROWSER_AUTH_REQUIRED' ? 'Stripe needs the person to sign in first' : 'this studio folder is not a Stripe project yet');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const have = servicesIn(jsonOf(status.stdout));
|
|
183
|
+
step('project', 'ok', 'this folder is a Stripe project');
|
|
184
|
+
|
|
185
|
+
// 3. The catalog: Cloudflare's Workers service (and ElevenLabs').
|
|
186
|
+
const find = (provider, prefer) => {
|
|
187
|
+
const r = run(['projects', 'search', provider, '--json']);
|
|
188
|
+
const j = jsonOf(r.stdout);
|
|
189
|
+
if (r.code !== 0 || !j) return { slug: null, why: `stripe projects search ${provider} did not answer (${codeOf(r) ?? 'no JSON'})` };
|
|
190
|
+
if (Number(j.result_count) === 0) return { slug: null, why: `${provider} is not in the Stripe Projects catalog` };
|
|
191
|
+
const all = servicesIn(j).filter((s) => s.startsWith(`${provider}/`));
|
|
192
|
+
const slug = prefer.map((p) => all.find((s) => p.test(s))).find(Boolean) ?? null;
|
|
193
|
+
return slug ? { slug, all } : { slug: null, why: `the catalog has no ${provider} service this kit knows (${all.join(', ') || 'none listed'})` };
|
|
194
|
+
};
|
|
195
|
+
const wants = [['cloudflare', [/^cloudflare\/workers$/, /^cloudflare\/worker$/, /^cloudflare\/workers?[:/]/]]];
|
|
196
|
+
if (withElevenlabs) wants.push(['elevenlabs', [/^elevenlabs\/(?:tts|api|key|audio|music)$/, /^elevenlabs\//]]);
|
|
197
|
+
const slugs = {};
|
|
198
|
+
for (const [provider, prefer] of wants) {
|
|
199
|
+
const f = find(provider, prefer);
|
|
200
|
+
if (!f.slug) return stop('not-in-catalog', { who: 'person', say: `Use Homie's usual way for ${provider === 'cloudflare' ? 'Cloudflare (npx wrangler login)' : 'ElevenLabs (elevenlabs auth login)'} instead.` }, f.why);
|
|
201
|
+
slugs[provider] = f.slug;
|
|
202
|
+
step(`catalog-${provider}`, 'ok', `${f.slug} is in the Stripe Projects catalog`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 4. The services, on their free plans, after the person said yes to the providers' terms.
|
|
206
|
+
const missing = Object.entries(slugs).filter(([, slug]) => !have.includes(slug));
|
|
207
|
+
if (missing.length && !acceptTos && !dryRun) {
|
|
208
|
+
const names = missing.map(([p]) => (p === 'cloudflare' ? 'Cloudflare' : 'ElevenLabs'));
|
|
209
|
+
return stop('accept-terms', {
|
|
210
|
+
who: 'person',
|
|
211
|
+
say: `Stripe Projects will make (or link) ${names.join(' and ')} for this studio on the free plan${names.length > 1 ? 's' : ''}, with no payment method. The person accepts ${missing.map(([p]) => `${p === 'cloudflare' ? 'Cloudflare' : 'ElevenLabs'}'s terms (${TERMS[p]})`).join(' and ')}. When they say yes, run this again with --accept-tos.${missing.some(([p]) => p === 'elevenlabs') ? ' ElevenLabs\' free plan has no commercial licence: songs made on it cannot be sold or monetised.' : ''}`,
|
|
212
|
+
}, `the person has not accepted ${names.join(' and ')}'s terms yet`, { plan: missing.map(([, slug]) => `stripe projects add ${slug} --json --no-interactive --accept-tos`) });
|
|
213
|
+
}
|
|
214
|
+
for (const [provider, slug] of missing) {
|
|
215
|
+
if (dryRun) { step(`add-${provider}`, 'skipped', `would run: stripe projects add ${slug} --json --no-interactive --accept-tos`); continue; }
|
|
216
|
+
const add = run(['projects', 'add', slug, '--json', '--no-interactive', '--accept-tos']);
|
|
217
|
+
const code = codeOf(add);
|
|
218
|
+
if (code === 'PROVIDER_NOT_LINKED') {
|
|
219
|
+
return stop(`link-${provider}`, {
|
|
220
|
+
who: 'ai', run: `stripe projects link ${provider}`,
|
|
221
|
+
say: provider === 'cloudflare'
|
|
222
|
+
? 'If the person\'s Stripe email already has a Cloudflare account, Cloudflare\'s own approval page opens: they approve once. If not, Cloudflare makes a free account for them. Then run this again.'
|
|
223
|
+
: 'ElevenLabs\' own approval page may open: the person approves once. Then run this again.',
|
|
224
|
+
}, `${provider} is not linked to this Stripe account yet`);
|
|
225
|
+
}
|
|
226
|
+
if (code === 'BROWSER_AUTH_REQUIRED') return stop('projects-init', { who: 'ai', run: 'stripe projects init --yes', say: 'Stripe needs the person to sign in again in the browser. Then run this again.' }, 'Stripe asked for a sign-in');
|
|
227
|
+
if (code === 'PAID_SERVICE_CONFIRMATION_REQUIRED' || code === 'PAYMENT_METHOD_REQUIRED') {
|
|
228
|
+
return stop('paid', { who: 'person', say: `Stripe says ${slug} would cost money here. This kit adds only free plans: nothing was added. Use Homie's usual way (npx wrangler login) instead, or decide about a paid plan yourself in Stripe Projects.` }, `${slug} is not free here`);
|
|
229
|
+
}
|
|
230
|
+
if (add.code !== 0) return stop('projects-error', { who: 'ai', run: `stripe projects add ${slug} --json --no-interactive --accept-tos --debug`, say: 'Show the person Stripe\'s error as it is (names of variables only, never values).' }, `stripe projects add ${slug} failed (${code ?? 'no code'})`);
|
|
231
|
+
if (paidIn(jsonOf(add.stdout))) {
|
|
232
|
+
return stop('paid', { who: 'person', say: `Stripe reported a price for ${slug}. Check it with stripe projects status, and downgrade it to the free plan (stripe projects downgrade ${slug}) or remove it, before anything else.` }, `Stripe reported a paid plan for ${slug}`);
|
|
233
|
+
}
|
|
234
|
+
step(`add-${provider}`, 'done', `${slug} added on its free plan`);
|
|
235
|
+
}
|
|
236
|
+
for (const [provider, slug] of Object.entries(slugs)) if (have.includes(slug)) step(`add-${provider}`, 'ok', `${slug} was already in this project`);
|
|
237
|
+
|
|
238
|
+
// 5. The credentials: names from Projects, values only from its output file, only into Wrangler's environment.
|
|
239
|
+
const envList = run(['projects', 'env', '--json']);
|
|
240
|
+
const names = envNamesIn(jsonOf(envList.stdout));
|
|
241
|
+
const shown = run(['projects', 'env', 'show', '--json']);
|
|
242
|
+
const outName = (() => {
|
|
243
|
+
const j = jsonOf(shown.stdout);
|
|
244
|
+
const pick = (o) => (o && typeof o === 'object' ? (o.output ?? o.output_file ?? o.outputFile ?? o.environment?.output ?? null) : null);
|
|
245
|
+
const o = pick(j) ?? (Array.isArray(j) ? j.map(pick).find(Boolean) : null);
|
|
246
|
+
return typeof o === 'string' && o ? o : '.env';
|
|
247
|
+
})();
|
|
248
|
+
const outFile = envFileOf(root, outName);
|
|
249
|
+
if (!outFile) return stop('env-file', { who: 'ai', say: 'Set the Projects environment\'s output file inside the studio (stripe projects env update --output .env).' }, `the Projects output file (${outName}) is outside the studio`);
|
|
250
|
+
const missingIgnores = ensureIgnored(root, { dryRun });
|
|
251
|
+
if (missingIgnores.length) step('gitignore', dryRun ? 'skipped' : 'done', `${dryRun ? 'would add' : 'added'} ${missingIgnores.join(', ')} to .gitignore`);
|
|
252
|
+
else step('gitignore', 'ok', '.gitignore keeps the credentials out of git');
|
|
253
|
+
if (dryRun) {
|
|
254
|
+
return { ok: true, command: 'setup', via: 'stripe-projects', dryRun: true, steps, services: slugs, envNames: names, envFile: outName, next: ['Run it without --dry-run (after the person accepted the terms: --accept-tos).'] };
|
|
255
|
+
}
|
|
256
|
+
if (!names.includes('CLOUDFLARE_API_TOKEN') || !names.includes('CLOUDFLARE_ACCOUNT_ID')) {
|
|
257
|
+
const pull = run(['projects', 'env', '--pull']);
|
|
258
|
+
if (pull.code !== 0) return stop('credentials', { who: 'ai', run: 'stripe projects env --pull', say: 'Projects did not sync the credentials; show its error (names only).' }, 'Stripe Projects listed no CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID');
|
|
259
|
+
}
|
|
260
|
+
const vars = readDotenv(outFile);
|
|
261
|
+
if (!vars.CLOUDFLARE_API_TOKEN || !vars.CLOUDFLARE_ACCOUNT_ID) {
|
|
262
|
+
return stop('credentials', { who: 'ai', run: 'stripe projects env --pull', say: `Projects has not written CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to ${outName} yet: pull them, then run this again.` }, `no Cloudflare credentials in ${outName}`);
|
|
263
|
+
}
|
|
264
|
+
if (!/^[a-f0-9]{32}$/i.test(vars.CLOUDFLARE_ACCOUNT_ID)) return stop('credentials', { who: 'ai', say: 'The account id Projects wrote does not look like a Cloudflare account id; check stripe projects status.' }, 'CLOUDFLARE_ACCOUNT_ID is not a Cloudflare account id');
|
|
265
|
+
const studio = readStudio(root);
|
|
266
|
+
const before = studio.cloudflare?.accountId;
|
|
267
|
+
if (before && before !== vars.CLOUDFLARE_ACCOUNT_ID && (studio.cloudflare?.created ?? []).length) {
|
|
268
|
+
return stop('other-account', { who: 'person', say: 'This studio already lives on another Cloudflare account. Keep that one (npx wrangler login), or move the studio deliberately; nothing was changed.' }, 'studio.json names another Cloudflare account that this studio already deployed to');
|
|
269
|
+
}
|
|
270
|
+
const eleven = withElevenlabs ? names.find((n) => /ELEVEN/.test(n) && /KEY/.test(n)) ?? (Object.keys(vars).find((n) => /ELEVEN/.test(n) && /KEY/.test(n)) ?? null) : null;
|
|
271
|
+
writeStudio(root, {
|
|
272
|
+
...studio,
|
|
273
|
+
cloudflare: { ...studio.cloudflare, accountId: vars.CLOUDFLARE_ACCOUNT_ID, auth: 'stripe-projects', envFile: outName },
|
|
274
|
+
...(withElevenlabs && eleven ? { providers: { ...(studio.providers ?? {}), elevenlabs: { via: 'stripe-projects', envKey: eleven, envFile: outName } } } : {}),
|
|
275
|
+
});
|
|
276
|
+
step('studio.json', 'done', `studio.json: the Cloudflare account Projects gave (its id, not a secret), and "auth": "stripe-projects" (the token stays in ${outName} and Projects' vault)${eleven ? `; ElevenLabs' key is ${eleven} there` : ''}`);
|
|
277
|
+
|
|
278
|
+
// 6. What the token can do, read-only.
|
|
279
|
+
const w = wrangler ?? ((args) => runner(root, {})(args));
|
|
280
|
+
const checks = {};
|
|
281
|
+
try {
|
|
282
|
+
const who = w(['whoami', '--json']);
|
|
283
|
+
let signed = false;
|
|
284
|
+
try { signed = Boolean(jsonOf(who.stdout ?? who.out)?.loggedIn); } catch { signed = false; }
|
|
285
|
+
checks.signedIn = who.code === 0 && signed;
|
|
286
|
+
checks.d1 = w(['d1', 'list', '--json']).code === 0;
|
|
287
|
+
checks.workersAi = w(['ai', 'models', '--json']).code === 0;
|
|
288
|
+
checks.r2 = w(['r2', 'bucket', 'list']).code === 0;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
checks.error = String(error?.message ?? error).slice(0, 200);
|
|
291
|
+
}
|
|
292
|
+
const short = [checks.signedIn === false ? 'Wrangler does not accept the token' : null, checks.d1 === false ? 'D1 (the studio\'s database)' : null].filter(Boolean);
|
|
293
|
+
step('checks', checks.error ? 'skipped' : short.length ? 'needs' : 'ok', checks.error ? `not checked (${checks.error})` : `Wrangler with the Projects token: signed in ${checks.signedIn ? 'yes' : 'no'}, D1 ${checks.d1 ? 'yes' : 'no'}, Workers AI ${checks.workersAi ? 'yes' : 'no'}, R2 ${checks.r2 ? 'yes' : 'no (optional: storage asks Cloudflare for a payment method)'}`);
|
|
294
|
+
if (short.length) {
|
|
295
|
+
return stop('token-scope', { who: 'ai', run: 'stripe projects search cloudflare --json', say: `The token Projects gave cannot do: ${short.join(', ')}. Add the missing Cloudflare service through Projects (search the catalog: stripe projects search cloudflare --json), or use npx wrangler login instead.` }, `the Cloudflare token cannot do: ${short.join(', ')}`, { checks });
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
ok: true, command: 'setup', via: 'stripe-projects', steps, services: slugs, checks, envFile: outName,
|
|
299
|
+
wrote: ['studio.json', ...(missingIgnores.length ? ['.gitignore'] : [])],
|
|
300
|
+
...(eleven ? { elevenlabs: { envKey: eleven, note: 'ElevenLabs\' free plan has no commercial licence: the music skill says the plan before every render.' } } : {}),
|
|
301
|
+
next: ['Commit studio.json and .gitignore (never .env or .projects/vault/).', 'npm run deploy (Wrangler deploys with the Projects token; no wrangler login needed)'],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function projectsLines(r) {
|
|
306
|
+
const lines = [];
|
|
307
|
+
for (const s of r.steps ?? []) lines.push(` ${s.state === 'ok' || s.state === 'done' ? '✓' : s.state === 'needs' ? '→' : '·'} ${s.words}`);
|
|
308
|
+
if (r.ok === false && r.needs) lines.push(`Next (${r.needs.who === 'person' ? 'the person' : 'your AI'}): ${r.needs.run ? `${r.needs.run} ` : ''}${r.needs.say}`);
|
|
309
|
+
for (const n of r.next ?? []) lines.push(n);
|
|
310
|
+
return lines;
|
|
311
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -364,9 +364,16 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
364
364
|
\`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
|
|
365
365
|
play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
|
|
366
366
|
(\`peer.badge\`, set by the Worker, never by a hello). The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
|
|
367
|
+
- **Stripe's own tools** (set up when the studio starts selling): \`npm install -g @stripe/cli@latest && stripe agent
|
|
368
|
+
setup\` installs Stripe's agent plugin (its MCP server and skills); the owner signs in once on Stripe's page and
|
|
369
|
+
gives access to a sandbox first. With it you make the catalog (\`npx --no-install homie-studio shop catalog\` says
|
|
370
|
+
the read, then the exact \`stripe_api_write\` calls), check tax settings, and answer "how are sales?" read-only.
|
|
371
|
+
Never make a webhook or an API key through the MCP (a secret would land in the chat), and never retry a write
|
|
372
|
+
Stripe sent to the owner for confirmation until they approved it.
|
|
367
373
|
- **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
|
|
368
|
-
prints a 127.0.0.1 link; the owner pastes
|
|
369
|
-
the Worker secrets. Test keys only unless \`--live\`. Never ask for a
|
|
374
|
+
prints a 127.0.0.1 link; the owner pastes one restricted key there, the page makes the webhook with it, and the
|
|
375
|
+
key and the webhook's secret go straight to the Worker secrets. Test keys only unless \`--live\`. Never ask for a
|
|
376
|
+
key in the chat; never write one anywhere.
|
|
370
377
|
- **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
|
|
371
378
|
\`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
|
|
372
379
|
account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
|
|
@@ -522,6 +529,10 @@ site/.wrangler/
|
|
|
522
529
|
.wrangler/
|
|
523
530
|
.dev.vars
|
|
524
531
|
.env
|
|
532
|
+
# Stripe Projects (setup --via stripe-projects): credentials stay in its vault and the .env files it syncs, never in git.
|
|
533
|
+
.env.*
|
|
534
|
+
.projects/vault/
|
|
535
|
+
.projects/cache/
|
|
525
536
|
*.log
|
|
526
537
|
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
527
538
|
games/*/.port/
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio shop catalog` (@homie-rocks/studio 0.24.3): the shop's items as Products, each with its Price, in the
|
|
3
|
+
* studio's OWN Stripe, made by the owner's AI through Stripe's own MCP server (`stripe_api_read`, `stripe_api_write`)
|
|
4
|
+
* after the owner connected it with Stripe's sign-in page. No key on this computer makes them.
|
|
5
|
+
*
|
|
6
|
+
* homie-studio shop catalog what the catalog should hold, and the one read to make first
|
|
7
|
+
* homie-studio shop catalog --have <file> Stripe's answer to that read (a file, or - for standard input): the
|
|
8
|
+
* exact writes still needed, or "in sync" (then shop.json `catalog`
|
|
9
|
+
* records the mode, test or live, so the Worker's checkouts name them)
|
|
10
|
+
*
|
|
11
|
+
* Each item is one Product with an id of our choosing, the same in a sandbox and in live mode:
|
|
12
|
+
* `homie_<studio slug>_<item id>` (worker/stripe.mjs productIdOf), its name, blurb, a tax code (video games,
|
|
13
|
+
* downloaded: what Managed Payments needs, and what Stripe Tax should know), metadata naming the studio and the item,
|
|
14
|
+
* and a default Price of exactly shop.json's amount (a tip has no fixed price: its Product only). A changed price is a
|
|
15
|
+
* new Price made the default; an item gone from shop.json is archived (`active: false`), never deleted. Products this
|
|
16
|
+
* studio did not make are never named.
|
|
17
|
+
*
|
|
18
|
+
* shop.json stays the truth for money: the Worker charges shop.json's checked price and names the Product only so that
|
|
19
|
+
* Stripe's Dashboard, its reports and its MCP see sales by product.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { readStudio } from './studio.mjs';
|
|
23
|
+
import { readShop } from './shop.mjs';
|
|
24
|
+
import { defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
25
|
+
import { productIdOf } from '../worker/stripe.mjs';
|
|
26
|
+
|
|
27
|
+
const MARK = 'shop-v1';
|
|
28
|
+
|
|
29
|
+
/** The Products the catalog should hold, from the checked shop.json. */
|
|
30
|
+
export function wantedProducts(shop, slug) {
|
|
31
|
+
return (shop?.items ?? []).map((it) => {
|
|
32
|
+
const id = productIdOf(slug, it.id);
|
|
33
|
+
return {
|
|
34
|
+
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
|
|
35
|
+
metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id },
|
|
36
|
+
price: it.kind === 'tip' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
|
|
37
|
+
shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)} to ${money(it.max, shop.currency)}` : money(it.price, shop.currency),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every Product in whatever Stripe answered: a list object ({ object: 'list', data }), an array, an MCP result
|
|
44
|
+
* ({ content: [{ type: 'text', text }] }), or several of these. Anything else is ignored.
|
|
45
|
+
*/
|
|
46
|
+
export function productsIn(value, out = [], depth = 0) {
|
|
47
|
+
if (depth > 8 || value === null || value === undefined) return out;
|
|
48
|
+
if (typeof value === 'string') {
|
|
49
|
+
const t = value.trim();
|
|
50
|
+
if (t.startsWith('{') || t.startsWith('[')) { try { productsIn(JSON.parse(t), out, depth + 1); } catch { /* not JSON */ } }
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value)) { for (const v of value) productsIn(v, out, depth + 1); return out; }
|
|
54
|
+
if (typeof value !== 'object') return out;
|
|
55
|
+
if (value.object === 'product' && typeof value.id === 'string') { out.push(value); return out; }
|
|
56
|
+
for (const k of ['data', 'content', 'text', 'result', 'results', 'structuredContent']) if (k in value) productsIn(value[k], out, depth + 1);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
|
|
61
|
+
|
|
62
|
+
/** The plan: the read first, then (with what Stripe answered) the writes still needed. */
|
|
63
|
+
export function catalogPlan(root, { have = null, mode = null, record = true } = {}) {
|
|
64
|
+
const r = readShop(root);
|
|
65
|
+
if (r.absent) return { ok: false, command: 'shop catalog', why: 'no shop.json: homie-studio shop init writes one first' };
|
|
66
|
+
if (!r.ok) return { ok: false, command: 'shop catalog', why: `shop.json breaks the rules (homie-studio shop check): ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
67
|
+
const studio = readStudio(root);
|
|
68
|
+
const slug = studio.slug;
|
|
69
|
+
if (!slug) return { ok: false, command: 'shop catalog', why: 'studio.json has no slug: the catalog\'s Product ids are made from it' };
|
|
70
|
+
const wanted = wantedProducts(r.shop, slug);
|
|
71
|
+
const read = {
|
|
72
|
+
tool: 'stripe_api_read', method: 'GET', path: '/v1/products', params: { limit: 100, expand: ['data.default_price'] },
|
|
73
|
+
then: 'Save exactly what it answers (every page, while has_more) to a file in the scratch folder, then run: npx --no-install homie-studio shop catalog --have <that file>',
|
|
74
|
+
};
|
|
75
|
+
const summary = wanted.map((w) => ({ id: w.id, item: w.item, name: w.name, price: w.shown, tax_code: w.tax_code }));
|
|
76
|
+
if (have === null || have === undefined) {
|
|
77
|
+
return {
|
|
78
|
+
ok: true, command: 'shop catalog', step: 'read', products: summary, read,
|
|
79
|
+
next: [
|
|
80
|
+
'With Stripe\'s MCP connected to the studio\'s SANDBOX (never live until the owner says so), make the read above with stripe_api_read.',
|
|
81
|
+
'Then: npx --no-install homie-studio shop catalog --have <the saved answer>: it says exactly which writes are still needed.',
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const products = productsIn(have);
|
|
86
|
+
const ours = products.filter((p) => p.metadata?.homie_studio === String(slug) || wanted.some((w) => w.id === p.id));
|
|
87
|
+
const modes = [...new Set(ours.map((p) => (p.livemode ? 'live' : 'test')))];
|
|
88
|
+
if (modes.length > 1) return { ok: false, command: 'shop catalog', why: 'that answer mixes test and live Products: read one environment at a time' };
|
|
89
|
+
const where = mode ?? modes[0] ?? (products[0] ? (products[0].livemode ? 'live' : 'test') : null);
|
|
90
|
+
const writes = [];
|
|
91
|
+
let unknownPrices = 0;
|
|
92
|
+
for (const w of wanted) {
|
|
93
|
+
const p = products.find((x) => x.id === w.id);
|
|
94
|
+
const meta = Object.fromEntries(Object.entries(w.metadata));
|
|
95
|
+
if (!p) {
|
|
96
|
+
writes.push({
|
|
97
|
+
tool: 'stripe_api_write', method: 'POST', path: '/v1/products', why: `make ${w.name} (${w.shown})`,
|
|
98
|
+
params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), tax_code: w.tax_code, metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const change = {};
|
|
103
|
+
if (p.name !== w.name) change.name = w.name;
|
|
104
|
+
if ((p.description ?? null) !== (w.description ?? null) && w.description) change.description = w.description;
|
|
105
|
+
if (taxCodeOf(p) !== w.tax_code) change.tax_code = w.tax_code;
|
|
106
|
+
if (p.active === false) change.active = true;
|
|
107
|
+
if (Object.entries(meta).some(([k, v]) => p.metadata?.[k] !== v)) change.metadata = meta;
|
|
108
|
+
if (Object.keys(change).length) writes.push({ tool: 'stripe_api_write', method: 'POST', path: `/v1/products/${w.id}`, why: `bring ${w.name} back in line with shop.json (${Object.keys(change).join(', ')})`, params: change });
|
|
109
|
+
if (!w.price) continue;
|
|
110
|
+
const dp = p.default_price;
|
|
111
|
+
if (typeof dp === 'string') { unknownPrices += 1; continue; }
|
|
112
|
+
const same = dp && dp.unit_amount === w.price.unit_amount && dp.currency === w.price.currency && dp.active !== false && (!dp.tax_behavior || dp.tax_behavior === 'exclusive' || dp.tax_behavior === 'unspecified');
|
|
113
|
+
if (!same) {
|
|
114
|
+
writes.push({
|
|
115
|
+
tool: 'stripe_api_write', method: 'POST', path: '/v1/prices', why: `${w.name}'s price as shop.json says: ${w.shown}`,
|
|
116
|
+
params: { product: w.id, ...w.price, lookup_key: `${w.id}_${w.price.currency}`, transfer_lookup_key: true, metadata: meta },
|
|
117
|
+
});
|
|
118
|
+
writes.push({
|
|
119
|
+
tool: 'stripe_api_write', method: 'POST', path: `/v1/products/${w.id}`, why: `make that price ${w.name}'s default`,
|
|
120
|
+
params: { default_price: '<the id (price_…) of the Price the call before made>' },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const p of ours) {
|
|
125
|
+
if (wanted.some((w) => w.id === p.id) || p.active === false) continue;
|
|
126
|
+
writes.push({ tool: 'stripe_api_write', method: 'POST', path: `/v1/products/${p.id}`, why: `${p.name ?? p.id} is no longer in shop.json: archive it (never delete; past sales keep their product)`, params: { active: false } });
|
|
127
|
+
}
|
|
128
|
+
const inSync = writes.length === 0 && unknownPrices === 0;
|
|
129
|
+
let recorded = false;
|
|
130
|
+
if (inSync && where && record && wanted.length) {
|
|
131
|
+
const raw = JSON.parse(readFileSync(r.file, 'utf8'));
|
|
132
|
+
const now = Array.isArray(raw.catalog) ? raw.catalog : [];
|
|
133
|
+
if (!now.includes(where)) {
|
|
134
|
+
raw.catalog = [...new Set([...now, where])].sort();
|
|
135
|
+
writeFileSync(r.file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
136
|
+
recorded = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
ok: true, command: 'shop catalog', step: inSync ? 'done' : 'write', mode: where, inSync, products: summary, writes,
|
|
141
|
+
...(unknownPrices ? { read: { ...read, why: `${unknownPrices} Product(s) came back without their default price expanded: read again with expand data.default_price` } } : {}),
|
|
142
|
+
...(recorded ? { recorded: `shop.json "catalog" now includes "${where}": commit it, deploy, and the checkouts name these Products` } : {}),
|
|
143
|
+
next: inSync
|
|
144
|
+
? [where === 'live' ? 'The live catalog matches shop.json.' : 'The sandbox catalog matches shop.json.', ...(recorded ? ['Commit shop.json and deploy (npm run deploy).'] : [])]
|
|
145
|
+
: ['Make each write in order with stripe_api_write (the human-confirmation link Stripe may give is the owner\'s to open).', 'Then read again and run --have again: it says "in sync" when nothing is left.'],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function catalogLines(r) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
if (r.step === 'read') {
|
|
152
|
+
lines.push('The catalog shop.json asks for (one Product an item, the same ids in the sandbox and live):');
|
|
153
|
+
for (const p of r.products) lines.push(` ${p.id} ${p.name} ${p.price} (tax code ${p.tax_code})`);
|
|
154
|
+
lines.push(`First read what Stripe holds: ${r.read.tool} ${r.read.method} ${r.read.path} ${JSON.stringify(r.read.params)}`, ...r.next.map((n) => ` ${n}`));
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
lines.push(r.inSync ? `In sync: the ${r.mode ?? ''} catalog in Stripe matches shop.json.`.replace(' ', ' ') : `${r.writes.length} write(s) still needed${r.mode ? ` (${r.mode})` : ''}:`);
|
|
158
|
+
for (const w of r.writes) lines.push(` ${w.tool} ${w.method} ${w.path} ${JSON.stringify(w.params)} # ${w.why}`);
|
|
159
|
+
if (r.read) lines.push(` ${r.read.why}`);
|
|
160
|
+
if (r.recorded) lines.push(r.recorded);
|
|
161
|
+
lines.push(...r.next);
|
|
162
|
+
return lines;
|
|
163
|
+
}
|