@homie-rocks/studio 0.51.4 → 0.51.5
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 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/SKILL.md +1 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +45 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +5 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { providerKey } from '../../../../hooks/lib/provider-key.mjs';
|
|
2
|
-
/**
|
|
3
|
-
* ElevenLabs, through the creator's OWN account, by one of two roads:
|
|
4
|
-
*
|
|
5
|
-
* cli the official `elevenlabs` CLI (github.com/elevenlabs/cli), signed in with
|
|
6
|
-
* its own browser sign-in (`elevenlabs auth login`); the sign-in stays in the
|
|
7
|
-
* OS keychain and nothing here ever sees it;
|
|
8
|
-
* key ELEVENLABS_API_KEY in the environment (the person's own key; never
|
|
9
|
-
* written into the studio, never printed), or, for a studio set up through
|
|
10
|
-
* Stripe Projects (`homie-studio setup --via stripe-projects --with elevenlabs`;
|
|
11
|
-
* studio.json providers.elevenlabs), the key Projects synced to the studio's
|
|
12
|
-
* git-ignored .env: read here, in this process only, never printed or passed on.
|
|
13
|
-
*
|
|
14
|
-
* ELEVENLABS_BASE_URL points either road at another address (a test double).
|
|
15
|
-
*/
|
|
16
|
-
import { spawnSync } from 'node:child_process';
|
|
17
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
-
import { basename, dirname, isAbsolute, join, normalize, sep } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const BASE = () => (process.env.ELEVENLABS_BASE_URL || 'https://api.elevenlabs.io').replace(/\/+$/, '');
|
|
21
|
-
const CLI = () => process.env.ELEVENLABS_CLI || 'elevenlabs';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Measured on a Creator-plan account, 2026-09-28: music_v2 composition-plan renders
|
|
25
|
-
* billed 1,015 credits for 37 s and 5,204 credits for 189.2 s, about 27.5 credits a
|
|
26
|
-
* second. The quote uses this; every render then records what it really cost.
|
|
27
|
-
*/
|
|
28
|
-
export const MUSIC_CREDITS_PER_SECOND = 27.5;
|
|
29
|
-
/** Speech-to-text (the lyric check) is small next to a render; measured per run and recorded. */
|
|
30
|
-
export const STT_CREDITS_PER_SECOND_ESTIMATE = 0.5;
|
|
31
|
-
|
|
32
|
-
function cli(args, { input, timeout = 15 * 60_000 } = {}) {
|
|
33
|
-
const base = process.env.ELEVENLABS_BASE_URL ? ['--base-url', process.env.ELEVENLABS_BASE_URL] : [];
|
|
34
|
-
const r = spawnSync(CLI(), [...args, ...base], { input: typeof input === 'string' ? Buffer.from(input) : input, maxBuffer: 512 * 1024 * 1024, timeout });
|
|
35
|
-
if (r.error) return { code: 127, stdout: Buffer.alloc(0), stderr: r.error.message };
|
|
36
|
-
return { code: r.status ?? 1, stdout: r.stdout ?? Buffer.alloc(0), stderr: (r.stderr ?? Buffer.alloc(0)).toString('utf8') };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const parse = (buf) => { try { return JSON.parse(buf.toString('utf8')); } catch { return null; } };
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The ElevenLabs key Stripe Projects keeps for this studio, or null: studio.json (found from the working folder up)
|
|
43
|
-
* says `providers.elevenlabs.via: "stripe-projects"` and which variable of which output file holds it.
|
|
44
|
-
*/
|
|
45
|
-
export function projectsKey(from = process.cwd()) {
|
|
46
|
-
let dir = from;
|
|
47
|
-
for (let i = 0; i < 12; i += 1) {
|
|
48
|
-
if (existsSync(join(dir, 'studio.json'))) break;
|
|
49
|
-
const up = dirname(dir);
|
|
50
|
-
if (up === dir) return null;
|
|
51
|
-
dir = up;
|
|
52
|
-
}
|
|
53
|
-
let p = null;
|
|
54
|
-
try { p = JSON.parse(readFileSync(join(dir, 'studio.json'), 'utf8'))?.providers?.elevenlabs ?? null; } catch { return null; }
|
|
55
|
-
if (p?.via !== 'stripe-projects') return null;
|
|
56
|
-
const name = /^[A-Z][A-Z0-9_]{2,}$/.test(String(p.envKey ?? '')) ? p.envKey : 'ELEVENLABS_API_KEY';
|
|
57
|
-
const file = normalize(String(p.envFile || '.env'));
|
|
58
|
-
if (isAbsolute(file) || file === '..' || file.startsWith(`..${sep}`)) return null;
|
|
59
|
-
let text = '';
|
|
60
|
-
try { text = readFileSync(join(dir, file), 'utf8'); } catch { return null; }
|
|
61
|
-
for (const line of text.split(/\r?\n/)) {
|
|
62
|
-
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
63
|
-
if (!m || m[1] !== name) continue;
|
|
64
|
-
const v = m[2].trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
65
|
-
return v || null;
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
const keyOf = () => providerKey('elevenlabs').key || projectsKey();
|
|
70
|
-
|
|
71
|
-
/** Which road is open, without spending anything and without printing a secret. */
|
|
72
|
-
export function road() {
|
|
73
|
-
const probe = cli(['--version']);
|
|
74
|
-
const hasCli = probe.code === 0;
|
|
75
|
-
let signedIn = false;
|
|
76
|
-
if (hasCli) {
|
|
77
|
-
const s = parse(cli(['auth', 'status', '--format', 'json']).stdout);
|
|
78
|
-
signedIn = Boolean(s?.schemes?.some((x) => x.logged_in));
|
|
79
|
-
}
|
|
80
|
-
const hasKey = Boolean(keyOf());
|
|
81
|
-
if (hasCli && signedIn) return { road: 'cli', cli: probe.stdout.toString('utf8').trim() };
|
|
82
|
-
if (hasKey) return { road: 'key', via: providerKey('elevenlabs').source === 'ELEVENLABS_API_KEY' ? 'environment' : providerKey('elevenlabs').key ? 'key-file' : 'stripe-projects', source: providerKey('elevenlabs').source ?? 'stripe-projects', cli: hasCli ? probe.stdout.toString('utf8').trim() : null, cliSignedIn: false };
|
|
83
|
-
return {
|
|
84
|
-
road: null, cli: hasCli ? probe.stdout.toString('utf8').trim() : null, cliSignedIn: false,
|
|
85
|
-
why: hasCli
|
|
86
|
-
? 'the elevenlabs CLI is here but not signed in: run `elevenlabs auth login` (it opens ElevenLabs in the browser; the person signs in once)'
|
|
87
|
-
: 'no ElevenLabs connection: install the official CLI (`brew install elevenlabs/tap/elevenlabs`, then `elevenlabs auth login`), or set ELEVENLABS_API_KEY from https://elevenlabs.io/app/developers/api-keys in the environment',
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function api(path, { method = 'GET', json, form, query } = {}) {
|
|
92
|
-
const key = keyOf();
|
|
93
|
-
if (!key) throw new Error('ELEVENLABS_API_KEY is not set (and no Stripe Projects key for this studio)');
|
|
94
|
-
const url = new URL(`${BASE()}${path}`);
|
|
95
|
-
for (const [k, v] of Object.entries(query ?? {})) if (v != null) url.searchParams.set(k, String(v));
|
|
96
|
-
const headers = { 'xi-api-key': key };
|
|
97
|
-
let body;
|
|
98
|
-
if (json) { headers['content-type'] = 'application/json'; body = JSON.stringify(json); }
|
|
99
|
-
if (form) body = form;
|
|
100
|
-
const res = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(15 * 60_000) });
|
|
101
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
102
|
-
if (!res.ok) throw new Error(`ElevenLabs ${method} ${path} answered ${res.status}: ${buf.toString('utf8').slice(0, 300)}`);
|
|
103
|
-
return { headers: res.headers, body: buf };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Plan and credit facts. `used`/`limit` are ElevenLabs' character_count/character_limit (its credits). */
|
|
107
|
-
export async function subscription(r = road()) {
|
|
108
|
-
let s;
|
|
109
|
-
if (r.road === 'cli') {
|
|
110
|
-
const out = cli(['user', 'subscription', 'get', '--format', 'json']);
|
|
111
|
-
s = parse(out.stdout);
|
|
112
|
-
if (out.code !== 0 || !s) throw new Error(`could not read the ElevenLabs subscription: ${out.stderr.trim().split('\n').pop() || out.stdout.toString('utf8').slice(0, 200)}`);
|
|
113
|
-
} else if (r.road === 'key') s = parse((await api('/v1/user/subscription')).body);
|
|
114
|
-
else throw new Error(r.why);
|
|
115
|
-
const used = Number(s.character_count); const limit = Number(s.character_limit);
|
|
116
|
-
return {
|
|
117
|
-
tier: s.tier ?? null, status: s.status ?? null, used, limit, remaining: limit - used,
|
|
118
|
-
resetAt: s.next_character_count_reset_unix ? new Date(s.next_character_count_reset_unix * 1000).toISOString() : null,
|
|
119
|
-
canGoOver: Boolean(s.can_extend_character_limit || s.allowed_to_extend_character_limit),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* What the plan means for rights, in plain words (ElevenLabs help centre, "Can I
|
|
125
|
-
* publish the content I generate on the platform?", read 2026-09-29): the free plan
|
|
126
|
-
* has no commercial licence and asks for "Eleven Music" attribution; paid plans
|
|
127
|
-
* include a commercial licence (not for Beta services), under the Terms of Service
|
|
128
|
-
* and the Prohibited Use Policy. The tier is recorded with every render because it
|
|
129
|
-
* is the tier AT RENDER TIME that counts: upgrading later does not relicense a
|
|
130
|
-
* render made on the free plan.
|
|
131
|
-
*/
|
|
132
|
-
export function rightsFor(tier) {
|
|
133
|
-
const t = String(tier ?? '').toLowerCase();
|
|
134
|
-
if (!t) return { plan: null, commercial: null, attribution: null, words: 'The plan could not be read, so the rights are unknown: do not publish commercially until it is.' };
|
|
135
|
-
if (t === 'free') {
|
|
136
|
-
return { plan: t, commercial: false, attribution: 'Made with Eleven Music.', words: 'Free plan: no commercial use (no ads, no sales, no monetised channels), and published copies credit "Eleven Music". A paid plan is needed for commercial use, and a render made now stays under the free-plan terms.' };
|
|
137
|
-
}
|
|
138
|
-
return { plan: t, commercial: true, attribution: null, words: `Paid plan (${t}): ElevenLabs includes a commercial licence for music made on it (not for Beta features), within their Terms of Service and Prohibited Use Policy. It is recorded with the render, because the plan at render time is what counts.` };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export const quoteCredits = (seconds, { vocals = false } = {}) => {
|
|
142
|
-
const music = Math.ceil(seconds * MUSIC_CREDITS_PER_SECOND);
|
|
143
|
-
const stt = vocals ? Math.ceil(seconds * STT_CREDITS_PER_SECOND_ESTIMATE) : 0;
|
|
144
|
-
return { music, lyricCheck: stt, total: music + stt, basis: `about ${MUSIC_CREDITS_PER_SECOND} credits per second of music_v2 (measured on a Creator-plan account); the lyric check is an estimate` };
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
/** The free check: the CLI validates the body locally and sends nothing. */
|
|
148
|
-
export function dryRun(body, { outputFormat }) {
|
|
149
|
-
const r = road();
|
|
150
|
-
if (r.road !== 'cli') return { ok: true, sent: false, road: r.road, body, note: 'no CLI to validate with: the body is shown as it would be sent' };
|
|
151
|
-
const out = cli(['music', 'compose_detailed', '--dry-run', '--format', 'json', '--output-format', outputFormat, '--json', '-'], { input: JSON.stringify(body) });
|
|
152
|
-
const j = parse(out.stdout);
|
|
153
|
-
if (out.code !== 0 || !j || j.error) return { ok: false, why: j?.error?.message ?? out.stderr.trim() };
|
|
154
|
-
return { ok: true, sent: false, road: 'cli', request: { url: j.url, query: j.query_params, body: j.body } };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Split a multipart/mixed answer (JSON + audio) into its parts. */
|
|
158
|
-
export function splitMultipart(buf, contentType) {
|
|
159
|
-
const m = /boundary="?([^";]+)"?/i.exec(contentType ?? '');
|
|
160
|
-
let boundary = m ? m[1] : null;
|
|
161
|
-
if (!boundary) {
|
|
162
|
-
const first = buf.indexOf('\r\n');
|
|
163
|
-
const line = buf.subarray(0, first).toString('latin1');
|
|
164
|
-
if (line.startsWith('--')) boundary = line.slice(2);
|
|
165
|
-
}
|
|
166
|
-
if (!boundary) throw new Error('the answer is not multipart');
|
|
167
|
-
const sep = Buffer.from(`--${boundary}`);
|
|
168
|
-
const parts = [];
|
|
169
|
-
let at = buf.indexOf(sep);
|
|
170
|
-
while (at >= 0) {
|
|
171
|
-
const next = buf.indexOf(sep, at + sep.length);
|
|
172
|
-
if (next < 0) break;
|
|
173
|
-
let part = buf.subarray(at + sep.length, next);
|
|
174
|
-
if (part.subarray(0, 2).toString() === '\r\n') part = part.subarray(2);
|
|
175
|
-
if (part.subarray(part.length - 2).toString() === '\r\n') part = part.subarray(0, part.length - 2);
|
|
176
|
-
const headEnd = part.indexOf('\r\n\r\n');
|
|
177
|
-
const head = part.subarray(0, headEnd).toString('latin1').toLowerCase();
|
|
178
|
-
parts.push({ head, body: part.subarray(headEnd + 4) });
|
|
179
|
-
at = next;
|
|
180
|
-
}
|
|
181
|
-
const json = parts.find((p) => p.head.includes('application/json'));
|
|
182
|
-
const audio = parts.find((p) => /audio|octet-stream/.test(p.head));
|
|
183
|
-
return { json: json ? JSON.parse(json.body.toString('utf8')) : null, audio: audio?.body ?? null };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Split `--format http` output (status line, headers, body). */
|
|
187
|
-
function splitHttp(buf) {
|
|
188
|
-
const end = buf.indexOf('\r\n\r\n') >= 0 ? buf.indexOf('\r\n\r\n') : buf.indexOf('\n\n');
|
|
189
|
-
const sepLen = buf.indexOf('\r\n\r\n') >= 0 ? 4 : 2;
|
|
190
|
-
const head = buf.subarray(0, end).toString('latin1');
|
|
191
|
-
const [status, ...lines] = head.split(/\r?\n/);
|
|
192
|
-
const headers = {};
|
|
193
|
-
for (const l of lines) { const i = l.indexOf(':'); if (i > 0) headers[l.slice(0, i).trim().toLowerCase()] = l.slice(i + 1).trim(); }
|
|
194
|
-
let body = buf.subarray(end + sepLen);
|
|
195
|
-
if (/chunked/i.test(headers['transfer-encoding'] ?? '') && !/^--/.test(body.subarray(0, 2).toString())) {
|
|
196
|
-
// De-chunk (the CLI normally hands the body over already joined).
|
|
197
|
-
const out = []; let i = 0;
|
|
198
|
-
while (i < body.length) { const nl = body.indexOf('\r\n', i); const n = parseInt(body.subarray(i, nl).toString(), 16); if (!n) break; out.push(body.subarray(nl + 2, nl + 2 + n)); i = nl + 2 + n + 2; }
|
|
199
|
-
body = Buffer.concat(out);
|
|
200
|
-
}
|
|
201
|
-
return { status: Number(status.split(' ')[1]), headers, body };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** One paid render: audio, the plan it used, word timestamps, and the song id (for inpainting). */
|
|
205
|
-
export async function composeDetailed(body, { outputFormat }) {
|
|
206
|
-
const r = road();
|
|
207
|
-
if (r.road === 'cli') {
|
|
208
|
-
const out = cli(['music', 'compose_detailed', '--format', 'http', '--output-format', outputFormat, '--json', '-'], { input: JSON.stringify(body) });
|
|
209
|
-
if (out.code !== 0) throw new Error(`the render failed: ${out.stderr.trim().split('\n').slice(-3).join(' ') || out.stdout.toString('utf8').slice(0, 300)}`);
|
|
210
|
-
const h = splitHttp(out.stdout);
|
|
211
|
-
if (h.status >= 400) throw new Error(`ElevenLabs answered ${h.status}: ${h.body.toString('utf8').slice(0, 300)}`);
|
|
212
|
-
const parts = splitMultipart(h.body, h.headers['content-type']);
|
|
213
|
-
return { ...parts, songId: h.headers['song-id'] ?? null };
|
|
214
|
-
}
|
|
215
|
-
if (r.road === 'key') {
|
|
216
|
-
const res = await api('/v1/music/detailed', { method: 'POST', json: body, query: { output_format: outputFormat } });
|
|
217
|
-
return { ...splitMultipart(res.body, res.headers.get('content-type')), songId: res.headers.get('song-id') };
|
|
218
|
-
}
|
|
219
|
-
throw new Error(r.why);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Scribe (speech-to-text) with word timings. */
|
|
223
|
-
export async function transcribe(file, { model = 'scribe_v2', language = null } = {}) {
|
|
224
|
-
const r = road();
|
|
225
|
-
if (r.road === 'cli') {
|
|
226
|
-
const args = ['speech-to-text', 'convert', '--format', 'json', '--model-id', model, '--file', file];
|
|
227
|
-
if (language) args.push('--language-code', language);
|
|
228
|
-
const out = cli(args);
|
|
229
|
-
const j = parse(out.stdout);
|
|
230
|
-
if (out.code !== 0 || !j || j.error) throw new Error(`the transcription failed: ${j?.error?.message ?? out.stderr.trim().split('\n').pop()}`);
|
|
231
|
-
return j;
|
|
232
|
-
}
|
|
233
|
-
if (r.road === 'key') {
|
|
234
|
-
const form = new FormData();
|
|
235
|
-
form.set('model_id', model);
|
|
236
|
-
if (language) form.set('language_code', language);
|
|
237
|
-
form.set('file', new Blob([readFileSync(file)]), basename(file));
|
|
238
|
-
return JSON.parse((await api('/v1/speech-to-text', { method: 'POST', form })).body.toString('utf8'));
|
|
239
|
-
}
|
|
240
|
-
throw new Error(r.why);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Stem separation: a zip of stems. Priced by measurement (the account's credits before and after). */
|
|
244
|
-
export async function separateStems(file, outZip) {
|
|
245
|
-
const r = road();
|
|
246
|
-
if (r.road === 'cli') {
|
|
247
|
-
const out = cli(['music', 'separate_stems', '--file', file, '-o', outZip]);
|
|
248
|
-
if (out.code !== 0) throw new Error(`stem separation failed: ${out.stderr.trim().split('\n').pop()}`);
|
|
249
|
-
return outZip;
|
|
250
|
-
}
|
|
251
|
-
if (r.road === 'key') {
|
|
252
|
-
const form = new FormData();
|
|
253
|
-
form.set('file', new Blob([readFileSync(file)]), basename(file));
|
|
254
|
-
const res = await api('/v1/music/stem-separation', { method: 'POST', form });
|
|
255
|
-
const { writeFileSync } = await import('node:fs');
|
|
256
|
-
writeFileSync(outZip, res.body);
|
|
257
|
-
return outZip;
|
|
258
|
-
}
|
|
259
|
-
throw new Error(r.why);
|
|
260
|
-
}
|
package/lib/projects-env.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/projects.mjs
DELETED
|
@@ -1,311 +0,0 @@
|
|
|
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
|
-
}
|