@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
package/lib/build.mjs
CHANGED
|
@@ -384,7 +384,7 @@ function pointAtBundle(html, bundle) {
|
|
|
384
384
|
* game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
|
|
385
385
|
* one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
|
|
386
386
|
*/
|
|
387
|
-
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
|
|
387
|
+
export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false, quick = false } = {}) {
|
|
388
388
|
rmSync(out, { recursive: true, force: true });
|
|
389
389
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
390
390
|
const mode = g.build?.mode ?? 'bundle';
|
|
@@ -411,7 +411,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
|
|
414
|
-
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
|
|
414
|
+
const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck, quick }) : null;
|
|
415
415
|
const bundle = async (entryRel) => {
|
|
416
416
|
const entry = join(g.dir, entryRel);
|
|
417
417
|
if (!existsSync(entry)) throw new Error(`${label}: entry ${entryRel} not found`);
|
|
@@ -509,7 +509,8 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
509
509
|
return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
|
|
512
|
+
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false, quick = false } = {}) {
|
|
513
|
+
log(`Building studio${quick?' (quick: rules play checks skipped)':''}…`);
|
|
513
514
|
const esbuild = await studioEsbuild(root);
|
|
514
515
|
const studio = readStudio(root);
|
|
515
516
|
if (Object.hasOwn(studio, 'referrals')) log('studio.json referrals: ignored; Homie has no referral system.');
|
|
@@ -535,13 +536,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
|
|
|
535
536
|
// from the site as it is.
|
|
536
537
|
const dist = openStage(root, { from: only ? live : null });
|
|
537
538
|
try {
|
|
538
|
-
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
|
|
539
|
+
return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick });
|
|
539
540
|
} finally {
|
|
540
541
|
rmSync(dist, { recursive: true, force: true });
|
|
541
542
|
}
|
|
542
543
|
}
|
|
543
544
|
|
|
544
|
-
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
|
|
545
|
+
async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick }) {
|
|
545
546
|
const built = [];
|
|
546
547
|
const retired = [];
|
|
547
548
|
const cache = {};
|
|
@@ -549,8 +550,9 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
549
550
|
const browserHosted = [];
|
|
550
551
|
for (const g of games) {
|
|
551
552
|
const out = join(dist, 'games', g.id);
|
|
553
|
+
log(`Building ${g.id}…`);
|
|
552
554
|
const started = Date.now();
|
|
553
|
-
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
|
|
555
|
+
const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck, quick });
|
|
554
556
|
if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
|
|
555
557
|
// The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
|
|
556
558
|
vocabFor(g, out);
|
|
@@ -571,14 +573,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
571
573
|
if (seats.asked > SEAT_MAX && !rules) log(`games/${g.id} asks for ${seats.asked} players; browser-hosted netplay rooms retain their ${SEAT_MAX}-player limit, so this build uses ${SEAT_MAX} seats. For larger rooms, rewrite as rules plus view: node_modules/@homie-rocks/studio/guides/game/REWRITE.md`);
|
|
572
574
|
built.push({
|
|
573
575
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
574
|
-
...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
|
|
576
|
+
...(rules ? { capacityTrial: rules.check.capacityTrial, rulesChecksSkipped: rules.check.skipped === true } : {}),
|
|
575
577
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
576
578
|
});
|
|
577
579
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
|
578
580
|
if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this ${g.kind === 'app' ? 'app' : 'game'}'s rules and tunables are sent to players' devices so it can be played offline`);
|
|
579
|
-
if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
581
|
+
if (rules && !quick) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
|
|
580
582
|
if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
|
|
581
|
-
if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
583
|
+
if (rules && !quick) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
|
|
582
584
|
}
|
|
583
585
|
// A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
|
|
584
586
|
if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
|
|
@@ -752,6 +754,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
752
754
|
// site, never before: a game that did not build has left both as they were.
|
|
753
755
|
await beforePublish();
|
|
754
756
|
const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
|
|
757
|
+
log('Building site Worker…');
|
|
755
758
|
await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
|
|
756
759
|
// All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
|
|
757
760
|
const swapped = swapIn(dist, live);
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -48,7 +48,6 @@ import { repoOf } from './repo.mjs';
|
|
|
48
48
|
import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
|
|
49
49
|
import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
50
50
|
import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
51
|
-
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
52
51
|
|
|
53
52
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
54
53
|
|
|
@@ -62,11 +61,7 @@ export function wranglerBin(root) {
|
|
|
62
61
|
return existsSync(local) ? local : null;
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
/**
|
|
66
|
-
* Wrangler from the studio's own node_modules, run in site/ with the given environment. A studio whose Cloudflare came
|
|
67
|
-
* through Stripe Projects (studio.json `cloudflare.auth: "stripe-projects"`, 0.24.3) runs it with the token and account
|
|
68
|
-
* Projects synced to its git-ignored output file; every other studio with Wrangler's own sign-in.
|
|
69
|
-
*/
|
|
64
|
+
/** Wrangler from the studio's own node_modules, using Cloudflare's own sign-in. */
|
|
70
65
|
export function runner(root, env) {
|
|
71
66
|
const bin = wranglerBin(root);
|
|
72
67
|
if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
|
|
@@ -74,7 +69,7 @@ export function runner(root, env) {
|
|
|
74
69
|
const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
|
|
75
70
|
if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
|
|
76
71
|
const res = spawnSync(bin, args, {
|
|
77
|
-
cwd, env: { ...process.env, ...
|
|
72
|
+
cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
|
|
78
73
|
input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
|
|
79
74
|
});
|
|
80
75
|
const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
|
|
@@ -152,7 +147,7 @@ export async function zoneCheck(root, { studio = readStudio(root), config = read
|
|
|
152
147
|
try {
|
|
153
148
|
const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
|
|
154
149
|
headers = await cloudflareAuth({
|
|
155
|
-
env: { ...process.env
|
|
150
|
+
env: { ...process.env }, bin, cwd: root,
|
|
156
151
|
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
|
|
157
152
|
});
|
|
158
153
|
} catch { headers = null; }
|
|
@@ -398,7 +393,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
398
393
|
const started = Date.now();
|
|
399
394
|
let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
400
395
|
if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
|
|
401
|
-
const headers = await cloudflareAuth({ env: { ...process.env
|
|
396
|
+
const headers = await cloudflareAuth({ env: { ...process.env }, bin: wranglerBin(root), cwd: root,
|
|
402
397
|
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
|
|
403
398
|
const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
|
|
404
399
|
if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
|
|
@@ -408,7 +403,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
408
403
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
409
404
|
if (paidReleases(root).length) {
|
|
410
405
|
const bin = wranglerBin(root);
|
|
411
|
-
const uploadOptions = { cwd: workerDir(root), env: { ...process.env,
|
|
406
|
+
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
412
407
|
await uploadPaidParts(root, { bucket: purchaseBucket, log, put: (key, file) => wranglerRun(bin, ['r2', 'object', 'put', key, '--file', file, '--remote'], uploadOptions), hash: (key) => hashRemote(bin, key, uploadOptions) });
|
|
413
408
|
}
|
|
414
409
|
remember(`worker:${cf.worker}`);
|
|
@@ -772,7 +767,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
|
|
|
772
767
|
const bin = wranglerBin(root);
|
|
773
768
|
if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
|
|
774
769
|
const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
|
|
775
|
-
const opts = { cwd: workerDir(root), env: { ...process.env, ...
|
|
770
|
+
const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
776
771
|
const moved = [];
|
|
777
772
|
const failed = [];
|
|
778
773
|
const verified = [];
|
package/lib/dev.mjs
CHANGED
|
@@ -32,7 +32,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpat
|
|
|
32
32
|
import { connect } from 'node:net';
|
|
33
33
|
import { dirname, join, sep } from 'node:path';
|
|
34
34
|
import { detectLocalAi, localAiVars } from './local-ai.mjs';
|
|
35
|
-
import { projectsCloudflareEnv } from './projects-env.mjs';
|
|
36
35
|
import { routesOf } from './routes.mjs';
|
|
37
36
|
import { ensureMigrations, migrationWord } from './scaffold.mjs';
|
|
38
37
|
import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
|
|
@@ -343,7 +342,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
343
342
|
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
344
343
|
const bin = wranglerBin(root);
|
|
345
344
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
346
|
-
const env = { ...process.env,
|
|
345
|
+
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
|
|
347
346
|
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
348
347
|
await new Promise((done) => {
|
|
349
348
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { officialConnections } from './official-media.mjs';
|
|
2
2
|
/**
|
|
3
3
|
* `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
|
|
4
4
|
* a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
|
|
@@ -35,8 +35,7 @@ import { providerKey } from './provider-key.mjs';
|
|
|
35
35
|
* status line optional (Claude Code only) the build's progress under the prompt
|
|
36
36
|
*
|
|
37
37
|
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
38
|
-
* tools, `wrangler whoami`, `gh auth status`,
|
|
39
|
-
* is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
|
|
38
|
+
* tools, `wrangler whoami`, `gh auth status`, one GET to the directory and explicit AI-host connection evidence; when a server's AI guides use Workers AI, one read of the live database and one
|
|
40
39
|
* one-word call to the model; in Codex or Grok, the `[mcp_servers.homie]` table of that app's config.toml, of which
|
|
41
40
|
* it keeps only whether it is an address or a command, and the hooks' mark in this user's cache), never changes
|
|
42
41
|
* anything, and never prints a key, a token, a command line or an account's name or id.
|
|
@@ -53,7 +52,6 @@ import { whyFailed } from './net.mjs';
|
|
|
53
52
|
import { isOurs } from './statusline.mjs';
|
|
54
53
|
import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
|
|
55
54
|
import { standaloneRows } from './standalone.mjs';
|
|
56
|
-
import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
|
|
57
55
|
import { STUDIO_VERSION } from './version.mjs';
|
|
58
56
|
|
|
59
57
|
/** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
|
|
@@ -175,7 +173,7 @@ const ago = (ms) => (ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : m
|
|
|
175
173
|
* replaceable for tests.
|
|
176
174
|
*/
|
|
177
175
|
export async function setupStatus({
|
|
178
|
-
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null, client = null,
|
|
176
|
+
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, integrations = {}, homie = null, client = null,
|
|
179
177
|
exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node, nowMs = Date.now(),
|
|
180
178
|
} = {}) {
|
|
181
179
|
const root = findStudio(cwd);
|
|
@@ -198,18 +196,12 @@ export async function setupStatus({
|
|
|
198
196
|
|
|
199
197
|
// The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
|
|
200
198
|
const said = connector === true || connector === 'yes' ? 'yes' : connector === false || connector === 'no' ? 'no' : null;
|
|
201
|
-
const [net, who, gh
|
|
199
|
+
const [net, who, gh] = await Promise.all([
|
|
202
200
|
reach(`${directory}/mcp`, fetchFn),
|
|
203
201
|
(async () => {
|
|
204
|
-
if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
|
|
205
202
|
const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
|
|
206
203
|
if (!bin || !existsSync(bin)) return { signedIn: null };
|
|
207
|
-
|
|
208
|
-
const projects = projectsCloudflareEnv(root);
|
|
209
|
-
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, ...projects, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
210
|
-
if (projects.CLOUDFLARE_API_TOKEN && r.code === 0) {
|
|
211
|
-
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 */ }
|
|
212
|
-
}
|
|
204
|
+
const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
|
|
213
205
|
if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
|
|
214
206
|
// The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
|
|
215
207
|
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 }; }
|
|
@@ -224,26 +216,6 @@ export async function setupStatus({
|
|
|
224
216
|
if (v.code !== 0) return { git: git.code === 0, gh: false, signedIn: false, onGitHub };
|
|
225
217
|
return { git: git.code === 0, gh: true, signedIn: (await exec('gh', ['auth', 'status'], { timeout: 10_000 })).code === 0, onGitHub };
|
|
226
218
|
})(),
|
|
227
|
-
(async () => {
|
|
228
|
-
const cliBin = env.ELEVENLABS_CLI || 'elevenlabs';
|
|
229
|
-
const v = await exec(cliBin, ['--version']);
|
|
230
|
-
let signedIn = false;
|
|
231
|
-
if (v.code === 0) {
|
|
232
|
-
const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
|
|
233
|
-
try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
|
|
234
|
-
}
|
|
235
|
-
const credential = providerKey('elevenlabs', env);
|
|
236
|
-
return { cli: v.code === 0, signedIn, key: Boolean(credential.key), source: credential.source, projects: root ? projectsElevenLabs(root) : false };
|
|
237
|
-
})(),
|
|
238
|
-
(async () => {
|
|
239
|
-
const { key, source } = providerKey('fal', env);
|
|
240
|
-
if (!key) return { key: false };
|
|
241
|
-
const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
|
|
242
|
-
try {
|
|
243
|
-
const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
|
|
244
|
-
return { key: true, source, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
|
|
245
|
-
} catch { return { key: true, source, valid: null }; }
|
|
246
|
-
})(),
|
|
247
219
|
]);
|
|
248
220
|
|
|
249
221
|
// The Homie connector. A session with a shell makes, deploys and lists a studio without it, so it never blocks.
|
|
@@ -385,23 +357,7 @@ export async function setupStatus({
|
|
|
385
357
|
: { who: 'person', open: 'https://github.com/signup', say: `Make a free GitHub account; your AI installs the GitHub CLI${mac ? ' (brew install gh)' : ''} when you want the backup.` },
|
|
386
358
|
});
|
|
387
359
|
|
|
388
|
-
|
|
389
|
-
rows.push({
|
|
390
|
-
id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
|
|
391
|
-
detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? `key from ${eleven.source}` : 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',
|
|
392
|
-
unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
|
|
393
|
-
fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
|
|
394
|
-
? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
|
|
395
|
-
: { 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.' },
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// fal.
|
|
399
|
-
rows.push({
|
|
400
|
-
id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
|
|
401
|
-
detail: !fal.key ? 'no fal key in the environment or Homie key files' : `key from ${fal.source}; ${fal.valid === true ? 'fal accepts it' : fal.valid === false ? 'fal refused it (wrong or revoked)' : 'fal did not answer just now'}`,
|
|
402
|
-
unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
|
|
403
|
-
fix: fal.key && fal.valid !== false ? null : { who: 'ai', say: 'Use local art and captured footage. Paid fal scripts need an already connected account; never ask the person to find or set a key. Offer the provider browser connection only when generated media is requested.', open: 'https://fal.ai/dashboard' },
|
|
404
|
-
});
|
|
360
|
+
rows.push(...officialConnections(integrations));
|
|
405
361
|
|
|
406
362
|
// Stripe, only in a studio that sells. Installing agent tooling is not proof of Worker credentials.
|
|
407
363
|
// shop connect runs browser pairing and reports the remaining steps; this read-only status never reads keys.
|
|
@@ -411,11 +367,11 @@ export async function setupStatus({
|
|
|
411
367
|
const mcp = stripeAgentConfigured({ root, env });
|
|
412
368
|
rows.push({
|
|
413
369
|
id: 'stripe', label: 'Stripe', need: 'for selling', state: 'act',
|
|
414
|
-
detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `
|
|
415
|
-
unlocks: '
|
|
370
|
+
detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `official MCP is set up for ${mcp}` : 'official MCP is not set up', 'Provider sign-in is not proof that the shop and signed events work'].join('; '),
|
|
371
|
+
unlocks: 'Use Stripe’s official plugin for shop planning and verification, then install the private webhook. Verify a test purchase before calling the shop ready.',
|
|
416
372
|
fix: {
|
|
417
|
-
who: 'ai', run:
|
|
418
|
-
say: 'Use
|
|
373
|
+
who: 'ai', run: mcp ? 'npx --no-install homie-studio shop plan' : version ? 'stripe agent setup' : 'npm install -g @stripe/cli@latest',
|
|
374
|
+
say: 'Use stripe_shop_plan with Stripe’s official MCP for catalog and links, then stripe_webhook for private signing-secret installation. Never ask for a key. Repeat plan and verify after edits.',
|
|
419
375
|
open: 'https://dashboard.stripe.com/register',
|
|
420
376
|
},
|
|
421
377
|
});
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { providerKey } from './provider-key.mjs';
|
|
2
1
|
/**
|
|
3
2
|
* THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
|
|
4
3
|
* desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
|
|
@@ -200,14 +199,14 @@ function needsInstall(ctx, root) {
|
|
|
200
199
|
|
|
201
200
|
/* ------------------------------------------------------------------ the setup card */
|
|
202
201
|
|
|
203
|
-
async function statusOf(ctx, root, { fresh = false } = {}) {
|
|
202
|
+
async function statusOf(ctx, root, { fresh = false, integrations = {} } = {}) {
|
|
204
203
|
const key = root ?? '';
|
|
205
|
-
if (!fresh && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
|
|
204
|
+
if (!fresh && !Object.keys(integrations).length && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
|
|
206
205
|
const node = findNode({ fresh });
|
|
207
206
|
// Claude Code's own status line is not this host's: its row stays out (CLAUDECODE says the server runs inside it).
|
|
208
207
|
const env = toolEnv();
|
|
209
208
|
delete env.CLAUDECODE;
|
|
210
|
-
const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', node: node?.version ?? '0', env });
|
|
209
|
+
const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', integrations, node: node?.version ?? '0', env });
|
|
211
210
|
const row = value.rows.find((r) => r.id === 'node');
|
|
212
211
|
if (row) {
|
|
213
212
|
if (!node) { row.detail = 'not found on this computer (22 or newer is needed)'; row.state = 'missing'; }
|
|
@@ -240,8 +239,8 @@ function checklist(ctx, root) {
|
|
|
240
239
|
return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
|
|
241
240
|
}
|
|
242
241
|
|
|
243
|
-
async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null } = {}) {
|
|
244
|
-
const status = await statusOf(ctx, root, { fresh });
|
|
242
|
+
async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null, integrations = {} } = {}) {
|
|
243
|
+
const status = await statusOf(ctx, root, { fresh, integrations });
|
|
245
244
|
const studios = ctx.studios().map(({ root: r, ...s }) => s);
|
|
246
245
|
const current = root ? ctx.describe(root) : null;
|
|
247
246
|
const running = root ? runningJobs(root).find((j) => j.label.startsWith('npm install')) : null;
|
|
@@ -814,13 +813,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
814
813
|
{
|
|
815
814
|
name: 'setup_status', title: 'Setup status',
|
|
816
815
|
description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
|
|
817
|
-
inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' } } },
|
|
816
|
+
inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' }, integrations: { type: 'object', description: 'Session evidence from official MCP tools: connected only after a successful authenticated call; unavailable when absent, otherwise unknown. Never credentials.', properties: Object.fromEntries(['fal','elevenlabs','cloudflare'].map(id => [id, { type: 'string', enum: ['connected','unavailable','unknown'] }])) } } },
|
|
818
817
|
annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
|
|
819
818
|
run: async (a) => {
|
|
820
819
|
// A name with no studio of it yet is the studio about to be made: its own checklist, from step 1.
|
|
821
820
|
const named = String(a.studio ?? '').trim().slice(0, 60);
|
|
822
|
-
if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, wanted: named });
|
|
823
|
-
return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true });
|
|
821
|
+
if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, integrations: a.integrations, wanted: named });
|
|
822
|
+
return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true, integrations: a.integrations });
|
|
824
823
|
},
|
|
825
824
|
},
|
|
826
825
|
{
|
|
@@ -1031,13 +1030,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1031
1030
|
},
|
|
1032
1031
|
{
|
|
1033
1032
|
name: 'build', title: 'Build the site',
|
|
1034
|
-
description: 'Bundle every game or app and the site into site/dist. Rules builds
|
|
1035
|
-
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
|
|
1033
|
+
description: 'Bundle every game or app and the site into site/dist. Rules builds run strict types, the guard and deterministic generated play. quick:true skips generated play and snapshot smoke checks for local iteration; full build before release. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
|
|
1034
|
+
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), quick:{type:'boolean'}, ...STUDIO_ARG } },
|
|
1036
1035
|
annotations: { title: 'Build', ...RW },
|
|
1037
1036
|
run: async (a) => {
|
|
1038
1037
|
const root = ctx.root(a.studio);
|
|
1039
1038
|
const need = needsInstall(ctx, root); if (need) return need;
|
|
1040
|
-
const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : [])]);
|
|
1039
|
+
const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : []), ...(a.quick?['--quick']:[])]);
|
|
1041
1040
|
if (!r.ended) return stillRunning(r.job, 'The build');
|
|
1042
1041
|
if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
|
|
1043
1042
|
const g = r.result?.games ?? [];
|
|
@@ -1177,9 +1176,31 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1177
1176
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
|
|
1178
1177
|
},
|
|
1179
1178
|
},
|
|
1179
|
+
{
|
|
1180
|
+
name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
|
|
1181
|
+
description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
|
|
1182
|
+
inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
|
|
1183
|
+
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
|
|
1184
|
+
run:async a=>{
|
|
1185
|
+
const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
|
|
1186
|
+
const result=await (a.verify?saveMcpVerification:shopMcpPlan)(ctx.root(a.studio),a);
|
|
1187
|
+
return ok(JSON.stringify(result),result);
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
name:'stripe_webhook',title:'Install Stripe webhook privately',
|
|
1192
|
+
description:'After stripe_shop_plan verify:true, create/install only the webhook and verified public links. Uses a separate official CLI browser approval if needed; never shows secrets. Follow studio_job. Never create a webhook through model-visible Stripe MCP.',
|
|
1193
|
+
inputSchema:{type:'object',properties:{...STUDIO_ARG,live:{type:'boolean'},renew:{type:'boolean'}}},
|
|
1194
|
+
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
|
|
1195
|
+
run:async a=>{
|
|
1196
|
+
const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
|
|
1197
|
+
await waitJob(job,Math.min(ctx.waitMs,1000));
|
|
1198
|
+
return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
|
|
1199
|
+
},
|
|
1200
|
+
},
|
|
1180
1201
|
{
|
|
1181
1202
|
name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
|
|
1182
|
-
description:'
|
|
1203
|
+
description:'CLI alternative to stripe_shop_plan with Stripe official MCP: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
|
|
1183
1204
|
inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
|
|
1184
1205
|
annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
|
|
1185
1206
|
run:async a=>{
|
|
@@ -1193,12 +1214,12 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1193
1214
|
},
|
|
1194
1215
|
{
|
|
1195
1216
|
name: 'stripe_login', title: 'Connect the studio to Stripe',
|
|
1196
|
-
description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job.
|
|
1197
|
-
inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' }
|
|
1217
|
+
description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. Alternative to the default Stripe official MCP plan and private stripe_webhook flow. live only when the owner says go live.',
|
|
1218
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' } } },
|
|
1198
1219
|
annotations: { title: 'Connect Stripe', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1199
1220
|
run: async (a) => {
|
|
1200
1221
|
const root = ctx.root(a.studio);
|
|
1201
|
-
const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])
|
|
1222
|
+
const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])];
|
|
1202
1223
|
const job = cliJob(root, 'Stripe connection', args, { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs'), keep: false });
|
|
1203
1224
|
const ended = await waitJob(job, Math.min(ctx.waitMs, 1000));
|
|
1204
1225
|
ctx.status = null;
|
|
@@ -1496,13 +1517,13 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1496
1517
|
},
|
|
1497
1518
|
});
|
|
1498
1519
|
}
|
|
1499
|
-
//
|
|
1500
|
-
for (const [name, title, needs] of [['music', 'Music
|
|
1520
|
+
// Local media processing; provider tools are discovered by the AI host.
|
|
1521
|
+
for (const [name, title, needs] of [['music', 'Music preparation', 'ffmpeg'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: import completed 3D assets', 'node']]) {
|
|
1501
1522
|
const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
|
|
1502
1523
|
if (!script || !existsSync(script) || !avail[needs]) continue;
|
|
1503
1524
|
tools.push({
|
|
1504
1525
|
name, title,
|
|
1505
|
-
description: `
|
|
1526
|
+
description: `Local ${name} preparation only; read studio_guide for the workflow. Generation, pricing and provider connections use the official provider MCP in the AI client.`,
|
|
1506
1527
|
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The script\'s words' }, ...STUDIO_ARG }, required: ['args'] },
|
|
1507
1528
|
annotations: { title, readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1508
1529
|
run: async (a) => {
|
|
@@ -1523,9 +1544,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1523
1544
|
return tools;
|
|
1524
1545
|
}
|
|
1525
1546
|
|
|
1526
|
-
/**
|
|
1547
|
+
/** Local processing availability only; provider connections belong to the AI host. */
|
|
1527
1548
|
export function availability(env = process.env) {
|
|
1528
|
-
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'))
|
|
1549
|
+
return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')) };
|
|
1529
1550
|
}
|
|
1530
1551
|
|
|
1531
1552
|
/** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
|
package/lib/mcp.mjs
CHANGED
|
@@ -37,7 +37,7 @@ Carry the person's request through to a working result without a lesson or an in
|
|
|
37
37
|
Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
|
|
38
38
|
New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists; existing netplay games keep building and running with their original 32-seat limit; larger rooms require a rewrite. Repair the full build diagnostics and prove the requested behavior in two browsers. Set players.max to the chosen room size, including hundreds; public connections use Gates. Choose room.view radiusM, precisionM, nearM and farHz explicitly and measure at the requested size. Private per-seat state is not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
|
|
39
39
|
Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
|
|
40
|
-
For a shop,
|
|
40
|
+
For a shop, default to stripe_shop_plan with Stripe official MCP for catalog and links, then stripe_webhook for private webhook installation. stripe_login (shop connect) is the CLI alternative. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
|
|
41
41
|
For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
|
|
42
42
|
|
|
43
43
|
If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Place already downloaded official MCP outputs. No provider networking, credentials or generation. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
function inside(root, name) {
|
|
6
|
+
const path = resolve(root, name), rel = relative(root, path);
|
|
7
|
+
if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error('asset paths must stay inside the studio');
|
|
8
|
+
let ancestor = path;
|
|
9
|
+
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
|
|
10
|
+
const actual = relative(realpathSync(root), realpathSync(ancestor));
|
|
11
|
+
if (isAbsolute(actual) || actual === '..' || actual.startsWith(`..${sep}`)) throw new Error('symlink leaves studio');
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
export function importMedia(root, { file, receipt, out, usedBy }) {
|
|
15
|
+
if (!existsSync(resolve(root, 'studio.json'))) throw new Error('not a studio');
|
|
16
|
+
const r = typeof receipt === 'string' ? JSON.parse(readFileSync(receipt, 'utf8')) : receipt;
|
|
17
|
+
for (const key of ['provider','tool','model','requestId','prompt','styleRevision','rights','cost']) if (r?.[key] === undefined) throw new Error(`receipt needs ${key}`);
|
|
18
|
+
if (!['fal','elevenlabs'].includes(r.provider) || !r.requestId || !r.tool || !r.model || !r.prompt) throw new Error('incomplete official MCP provenance');
|
|
19
|
+
if (!r.rights?.source || !r.rights?.checkedAt || typeof r.rights?.commercial !== 'boolean') throw new Error('record rights source, date and commercial permission');
|
|
20
|
+
if (!['estimate','actual','unknown'].includes(r.cost?.status) || (r.cost.status !== 'unknown' && !(Number.isFinite(r.cost.amount) && r.cost.amount >= 0 && r.cost.unit))) throw new Error('cost must be actual, estimate or explicitly unknown');
|
|
21
|
+
const target = inside(root, out), use = inside(root, usedBy);
|
|
22
|
+
const bytes = readFileSync(file);
|
|
23
|
+
if (!bytes.length) throw new Error('empty asset');
|
|
24
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
25
|
+
const registry = inside(root, 'media/provenance.json');
|
|
26
|
+
const records = existsSync(registry) ? JSON.parse(readFileSync(registry,'utf8')) : [];
|
|
27
|
+
const previous = records.find(x => x.provider === r.provider && x.requestId === r.requestId && x.output === out);
|
|
28
|
+
if (previous && previous.sha256 !== hash) throw new Error('same output identity has different bytes');
|
|
29
|
+
if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') !== hash) throw new Error('destination already contains a different asset');
|
|
30
|
+
// Store only allowlisted provenance; raw tool responses may contain temporary signed URLs.
|
|
31
|
+
const record = { provider:r.provider, tool:r.tool, model:r.model, requestId:r.requestId, prompt:r.prompt, styleRevision:r.styleRevision,
|
|
32
|
+
rights:{ source:r.rights.source, checkedAt:r.rights.checkedAt, commercial:r.rights.commercial, note:r.rights.note ?? null },
|
|
33
|
+
cost:{ status:r.cost.status, amount:r.cost.amount ?? null, unit:r.cost.unit ?? null }, fixture:r.fixture === true,
|
|
34
|
+
output:out, usedBy, sha256:hash, bytes:bytes.length };
|
|
35
|
+
// Static reference evidence is not a runtime claim. Skills must also inspect the running scene.
|
|
36
|
+
const reference = relative(dirname(use), target).split(sep).join('/');
|
|
37
|
+
if (!readFileSync(use,'utf8').includes(reference)) throw new Error(`consumer does not reference ${reference}`);
|
|
38
|
+
mkdirSync(dirname(target),{recursive:true}); copyFileSync(file,target);
|
|
39
|
+
if (!previous) { records.push(record); mkdirSync(dirname(registry),{recursive:true}); writeFileSync(registry,JSON.stringify(records,null,2)+'\n'); }
|
|
40
|
+
return { ok:true, command:'media import', artifact:out, provenance:'media/provenance.json', sha256:hash, already:Boolean(previous), usage:'static reference verified; inspect the running scene' };
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Declarative tool references for Homie's media skills; host namespaces are resolved by the AI. */
|
|
2
|
+
export const MEDIA_WORKFLOWS = Object.freeze({
|
|
3
|
+
art: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result','upload_file'] },
|
|
4
|
+
models: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','submit_job','check_job','get_job_result'] },
|
|
5
|
+
video: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','submit_job','check_job','get_job_result'] },
|
|
6
|
+
style: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result'] },
|
|
7
|
+
music: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
|
|
8
|
+
sound: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
|
|
9
|
+
});
|
|
10
|
+
export function validateMediaTools(workflow, tools) {
|
|
11
|
+
const available=new Set(tools.map(t=>t.name));
|
|
12
|
+
return {ready:workflow.tools.every(t=>available.has(t)), missing:workflow.tools.filter(t=>!available.has(t))};
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Provider connections belong to the AI host. Never inspect credentials or host configuration. */
|
|
2
|
+
export const INTEGRATIONS = Object.freeze({
|
|
3
|
+
fal: { label: 'fal', url: 'https://mcp.fal.ai/mcp-relay', connectUrl: 'https://fal.ai/docs/documentation/setting-up/mcp', unlocks: 'Images, 3D and generated video', connect: 'Install the official fal plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once.' },
|
|
4
|
+
elevenlabs: { label: 'ElevenLabs', url: 'https://api.elevenlabs.io/v1/mcp', connectUrl: 'https://elevenlabs.io/mcp', unlocks: 'Music, speech and sound where exposed by the connected MCP', connect: 'Install elevenlabs/plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once. Discover the workspace tools before promising a media capability.' },
|
|
5
|
+
cloudflare: { label: 'Cloudflare MCP', url: 'https://mcp.cloudflare.com/mcp', connectUrl: 'https://github.com/cloudflare/skills', unlocks: 'Inspect Workers Issues, logs and D1 through official tools', connect: 'Connect the official Cloudflare plugin or this MCP URL and approve account access in the browser. Deploys separately use Wrangler login (cloudflare_login).' },
|
|
6
|
+
});
|
|
7
|
+
export function officialConnections(evidence = {}) {
|
|
8
|
+
return Object.entries(INTEGRATIONS).map(([provider, p]) => {
|
|
9
|
+
const connected = evidence[provider] === 'connected';
|
|
10
|
+
return { id: provider === 'cloudflare' ? 'cloudflare-mcp' : provider, label: p.label, need: 'optional', state: connected ? 'ok' : 'optional',
|
|
11
|
+
connection: connected ? 'connected' : evidence[provider] === 'unavailable' ? 'unavailable' : 'unknown',
|
|
12
|
+
detail: connected ? 'Official MCP connected (authenticated call reported by this AI session)' : evidence[provider] === 'unavailable' ? 'Official MCP unavailable in this AI session' : 'Connection unknown: the AI host must discover tools and verify an authenticated read',
|
|
13
|
+
unlocks: p.unlocks, fix: connected ? null : { who: 'person', open: p.connectUrl, say: `${p.connect} MCP URL: ${p.url}` } };
|
|
14
|
+
});
|
|
15
|
+
}
|
package/lib/rules-build.mjs
CHANGED
|
@@ -141,7 +141,8 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
|
|
|
141
141
|
* Returns { code, tune, map, settings, seats, stateHash, schema, publicTune, build, files, rounds, units, tickUnits, check }.
|
|
142
142
|
* `longCheck`: eight times the generated play (`homie-studio build --long-check`).
|
|
143
143
|
*/
|
|
144
|
-
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
|
|
144
|
+
export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false, quick = false } = {}) {
|
|
145
|
+
log(`${g.id}: checking rules and types${quick ? ' (quick rebuild; play checks skipped)' : ''}`);
|
|
145
146
|
const guarded = await guardRules(esbuild, root, g.dir);
|
|
146
147
|
if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
|
|
147
148
|
const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
|
|
@@ -157,8 +158,14 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
157
158
|
const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true, physics: Boolean(map.physics) });
|
|
158
159
|
const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
|
|
159
160
|
// The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
const declarations=await runRulesWorker(bundle,data,{declarationsOnly:true});
|
|
162
|
+
await typecheckRules(root,g,declarations,tune,{log});
|
|
163
|
+
log(`${g.id}: ${quick?'skipping generated play':'running generated rules play; use build --quick for an iteration without play checks'}`);
|
|
164
|
+
const heartbeat=setInterval(()=>log(`${g.id}: rules play still running (${Math.round((Date.now()-started)/1000)}s)`),15000);
|
|
165
|
+
const started=Date.now();
|
|
166
|
+
let checked;
|
|
167
|
+
try { checked=quick?{...declarations,information:[],plays:[],ticks:0,roundsPlayed:0,restores:0,units:0,maxUnits:0,maxTickUnits:0,largestSaveBytes:0}:await runRulesWorker(bundle,data,{allowance:longCheck?LONG_ALLOWANCE:ALLOWANCE}); }
|
|
168
|
+
finally {clearInterval(heartbeat);}
|
|
162
169
|
for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
|
|
163
170
|
for (const line of checked.information) log(`info: ${line}`);
|
|
164
171
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
@@ -168,7 +175,8 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
168
175
|
const { def, R, H, initializePhysics } = await loadRules(esbuild, root, g.id, guarded.code, {physics: Boolean(map.physics)});
|
|
169
176
|
if (initializePhysics) await initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
|
|
170
177
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
171
|
-
|
|
178
|
+
log(`${g.id}: ${quick?'skipping':'running'} rules snapshot smoke check`);
|
|
179
|
+
const stats = quick ? {snapshotBytes:null,checkpointBytes:null,frameBytes:{},frameRates:{}} : smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
172
180
|
const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
|
|
173
181
|
const exceeded = RULES_FRAMES.flatMap(t => {
|
|
174
182
|
const bytes = stats.frameBytes[t] ?? 0; const cap = capOf(t, seats);
|
|
@@ -187,7 +195,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
187
195
|
build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
|
|
188
196
|
snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
|
|
189
197
|
units: checked.maxUnits, tickUnits: checked.maxTickUnits,
|
|
190
|
-
check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
198
|
+
check: { skipped: quick, information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
191
199
|
};
|
|
192
200
|
}
|
|
193
201
|
|