@homie-rocks/studio 0.37.0 → 0.37.2
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 +46 -0
- package/bin/homie-studio.mjs +10 -3
- package/lib/cloudflare.mjs +40 -6
- package/lib/dev.mjs +1 -1
- package/lib/doctor.mjs +7 -7
- package/lib/domain.mjs +43 -0
- package/lib/jobs.mjs +17 -7
- package/lib/mcp-tools.mjs +49 -18
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/prepare.mjs +47 -0
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/scaffold.mjs +35 -5
- package/lib/shoot.mjs +1 -1
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +82 -0
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,52 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.37.2] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
|
|
23
|
+
|
|
24
|
+
Sell game, app, music or video parts on your own studio's site, with your price and licence; another studio's AI can discover, pay for and install them.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Your own part shop.** Tell your AI which files to sell, their price and their licence. On a studio already deployed with Stripe, connection takes one browser approval when needed (zero if already connected); the AI creates Payment Links and the webhook in your account. It generates and installs the purchase signing key too. Nobody finds or pastes a key for this setup.
|
|
29
|
+
- **An AI can pay without a checkout page.** MPP with Stripe shared payment tokens, x402 on Base, and MPP over MCP return verified files and a signed proof. The buyer approves the exact price or gives its wallet standing authority. Wallet-required approvals still apply. Stripe's hosted checkout is the fallback.
|
|
30
|
+
- **Machine payments are an optional fuller connection.** Stripe's shared-token charge and stablecoin PaymentIntent recording APIs need a Worker credential; the MCP binding uses the same charge. Your AI can operate Stripe's key-creation page and transfer its Copy result privately with `shop connect --from-clipboard`; you only handle Stripe's sign-in, verification and approval prompts. Live Base also needs your CDP facilitator credentials. The guide distinguishes this setup from the one-approval keyless default.
|
|
31
|
+
- Receipts, retries, private storage, refunds and payment recovery belong to your studio. **homie.rocks is not in the money path**, takes no fee, and can be unavailable while a buyer buys directly from your site.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Part kinds are labels you choose; app, music and video are suggestions alongside existing game kinds. Existing manifests and game imports still work. App pieces use the same extraction, imports and credits as games; music and video retain their provenance and intended uses.
|
|
36
|
+
- Sellers choose licence scopes, refund windows, approval-intent lifetimes and recurring intervals supported by Stripe. No new price ceiling, seller kind list or spending cap. Turnstile on the hosted fallback is optional; request flood protection remains configurable.
|
|
37
|
+
- Paid resources use the shop's webhook verification and refund-list reader, including keyless signed refund snapshots. Each purchase keeps its frozen terms and private claim. A second purchase has a fresh claim; retries keep the first claim and never request a second charge.
|
|
38
|
+
- Selling code is loaded only by the selling entry. A studio that sells no parts keeps the ordinary Worker and its existing compatibility flags.
|
|
39
|
+
|
|
40
|
+
### Upgrade notes
|
|
41
|
+
|
|
42
|
+
- Follows [#74](https://github.com/homie-rocks/homie/pull/74), studio 0.37.1 / plugin 0.38.1. Main’s apps, keyless shop and visible Shop control remain available.
|
|
43
|
+
|
|
44
|
+
- The AI applies `0015_purchases.sql` and `0016_purchase_facts.sql`, configures private storage, and uploads and verifies paid files when you deploy. Existing shop migrations stay unchanged. Keep paid files and the seller's signing key backed up.
|
|
45
|
+
- Payment Links need no Stripe API key in the Worker. A keyless refund is made in Stripe or by the owner's connected AI; signed events update the studio. Missing events wait for Stripe redelivery. Dynamic quantities and provider-read recovery use the fuller connection.
|
|
46
|
+
- Verification uses stand-in payment services in the local Workers runtime and Chrome. No real payment account was signed into and nothing was deployed. Actual account eligibility, settlement and provider permissions still need verification on the seller's account.
|
|
47
|
+
|
|
48
|
+
## [0.37.1] - 2026-10-09
|
|
49
|
+
|
|
50
|
+
**Plugin 0.38.1** · [#74](https://github.com/homie-rocks/homie/pull/74)
|
|
51
|
+
|
|
52
|
+
Your AI carries first setup through with defaults you can change by asking.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Setup no longer makes you play a demo, request a practice change, name the studio or answer a design interview before your AI builds your request.
|
|
57
|
+
- Deploy registers an initial workers.dev address through the existing Cloudflare sign-in, keeping an existing address unchanged.
|
|
58
|
+
- The Desktop tools can install a private Node and npm. Your AI can prepare a custom hostname for Cloudflare to create its DNS and certificate during deploy.
|
|
59
|
+
- Public descriptions now describe games, apps, engine packages and media skills, your own Cloudflare and your own Stripe with one approval.
|
|
60
|
+
- App requests go directly to the app build and shared-action check. The large shop-read regression measures CPU work, so other concurrent test processes cannot exhaust its unchanged limit.
|
|
61
|
+
|
|
62
|
+
### Upgrade notes
|
|
63
|
+
|
|
64
|
+
- Ask your AI to upgrade; it shows the changes and applies them after one yes, keeping your edited files.
|
|
65
|
+
|
|
20
66
|
## [0.37.0] - 2026-10-09
|
|
21
67
|
|
|
22
68
|
**Plugin 0.38.0** · [#72](https://github.com/homie-rocks/homie/pull/72)
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -226,6 +226,8 @@
|
|
|
226
226
|
* pages; the account id goes into studio.json, the token stays in Projects'
|
|
227
227
|
* vault and its git-ignored .env, and Wrangler runs with it here. The usual
|
|
228
228
|
* way, npx wrangler login, stays the default. lib/projects.mjs)
|
|
229
|
+
* homie-studio domain <hostname> AI configures DNS/TLS for the next deploy
|
|
230
|
+
* homie-studio setup prepare AI installs a private Node/npm if missing
|
|
229
231
|
* homie-studio setup status [--connector yes|no] [--client claude|codex|grok] (also: homie-studio doctor)
|
|
230
232
|
* what this computer and the person's accounts have for a studio: Node, the Homie
|
|
231
233
|
* connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
|
|
@@ -325,6 +327,8 @@ import { build } from '../lib/build.mjs';
|
|
|
325
327
|
import { PREVIEW_PORT, previewServer } from '../lib/preview.mjs';
|
|
326
328
|
import { check } from '../lib/check.mjs';
|
|
327
329
|
import { ciDeploy, deploy, deployPlan, mediaMove, storageAdd, whoami, wranglerBin, zonePlan } from '../lib/cloudflare.mjs';
|
|
330
|
+
import { studioDomain } from '../lib/domain.mjs';
|
|
331
|
+
import { prepareNode } from '../lib/prepare.mjs';
|
|
328
332
|
import { setupAttach } from '../lib/setup.mjs';
|
|
329
333
|
import { chromeArgs, findChrome, installChrome, noChrome } from '../lib/chrome.mjs';
|
|
330
334
|
import { deployWords } from '../lib/deploy-state.mjs';
|
|
@@ -815,7 +819,8 @@ const OUTWARD_FLAGS = {
|
|
|
815
819
|
'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
|
|
816
820
|
'chat remove': ['url', 'all'], 'chat budget': ['url'],
|
|
817
821
|
'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
|
|
818
|
-
'
|
|
822
|
+
'parts reissue': ['claim-hash', 'receipt-verified', 'buyer'], 'parts retire': [], 'parts keys': [], 'parts refund': ['game'],
|
|
823
|
+
'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
|
|
819
824
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
820
825
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
821
826
|
// With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
|
|
@@ -863,6 +868,7 @@ async function main() {
|
|
|
863
868
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
864
869
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
865
870
|
if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
|
|
871
|
+
if (cmd === 'setup' && sub === 'prepare') return prepareNode();
|
|
866
872
|
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null, client: flags.get('client') ?? null });
|
|
867
873
|
if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
|
|
868
874
|
const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
|
|
@@ -891,6 +897,7 @@ async function main() {
|
|
|
891
897
|
if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
|
|
892
898
|
if (cmd === 'codex') return codexCommand(root, sub);
|
|
893
899
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
900
|
+
if (cmd === 'domain') return studioDomain(root, sub);
|
|
894
901
|
if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie'), client: flags.get('client') });
|
|
895
902
|
if (cmd === 'setup' && flags.has('via')) {
|
|
896
903
|
if (flags.get('via') !== 'stripe-projects') return { ok: false, command: 'setup', why: '--via stripe-projects is the one other way this version knows (the default is npx wrangler login)' };
|
|
@@ -1051,7 +1058,7 @@ async function main() {
|
|
|
1051
1058
|
if (!sub) return shopStatus(root, { url });
|
|
1052
1059
|
if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), currency: flags.get('currency') ?? 'usd', price: flags.get('price') ?? 500, managed: flags.has('managed') });
|
|
1053
1060
|
if (sub === 'check') return shopCheck(root);
|
|
1054
|
-
if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
|
|
1061
|
+
if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
|
|
1055
1062
|
if (sub === 'disconnect') return shopDisconnect(root);
|
|
1056
1063
|
if (sub === 'catalog') {
|
|
1057
1064
|
const from = flags.get('have');
|
|
@@ -1066,7 +1073,7 @@ async function main() {
|
|
|
1066
1073
|
return catalogPlan(root, { have, mode: mode ?? null });
|
|
1067
1074
|
}
|
|
1068
1075
|
if (sub === 'orders') return shopOrders(root, { url });
|
|
1069
|
-
if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
|
|
1076
|
+
if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note'), manualTransaction: flags.get('manual-transaction') });
|
|
1070
1077
|
if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), cursor: flags.get('cursor') ?? '', send: flags.has('send') });
|
|
1071
1078
|
return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
|
|
1072
1079
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
|
|
3
4
|
* account, through Cloudflare's own tool (Wrangler, pinned in the studio).
|
|
@@ -31,6 +32,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
31
32
|
import { createHash } from 'node:crypto';
|
|
32
33
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
34
|
import { join } from 'node:path';
|
|
35
|
+
import { registerWorkersAddress } from './domain.mjs';
|
|
34
36
|
import { cloudflareAuth } from './brain-probe.mjs';
|
|
35
37
|
import { build } from './build.mjs';
|
|
36
38
|
import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
|
|
@@ -93,10 +95,10 @@ export function explainCloudflare(out, accountId = null) {
|
|
|
93
95
|
const text = String(out ?? '');
|
|
94
96
|
const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
|
|
95
97
|
if (/\b10034\b|verify your email/i.test(text)) {
|
|
96
|
-
return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it
|
|
98
|
+
return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it. The AI resumes deployment after verification.` };
|
|
97
99
|
}
|
|
98
100
|
if (/register a workers\.dev subdomain|workers\/onboarding/i.test(text)) {
|
|
99
|
-
return { needs: 'workers-dev-subdomain', why: `This Cloudflare account has no workers.dev address yet.
|
|
101
|
+
return { needs: 'workers-dev-subdomain', why: `This Cloudflare account has no workers.dev address yet. AI: retry deploy to register it automatically through the existing sign-in. If Cloudflare refuses, open ${dash}/workers/onboarding and handle the setting in the browser; the person only handles provider authorization.` };
|
|
100
102
|
}
|
|
101
103
|
if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
|
|
102
104
|
return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
|
|
@@ -348,12 +350,25 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
348
350
|
step(`created D1 ${cf.d1}`);
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
// Paid objects use a separate private bucket, so even old Worker versions cannot serve them.
|
|
354
|
+
if (paidReleases(root).length && !storage) return refuse('Paid releases need private storage. Run homie-studio storage add before deploying.');
|
|
355
|
+
const purchaseBucket = storage && `${storage.slice(0, 52)}-purchases`;
|
|
356
|
+
if (paidReleases(root).length && !created.has(`r2:${purchaseBucket}`)) {
|
|
357
|
+
const listed = w(['r2', 'bucket', 'list']);
|
|
358
|
+
if (listed.code !== 0) return refuse('Could not inspect private purchase storage', listed.out);
|
|
359
|
+
const buckets = parseJson(listed.out);
|
|
360
|
+
if (!Array.isArray(buckets)) return refuse('Could not read the private storage list', listed.out);
|
|
361
|
+
if (buckets.some((bucket) => bucket.name === purchaseBucket)) return refuse('The private purchase bucket already exists and is not owned by this studio');
|
|
362
|
+
const made = w(['r2', 'bucket', 'create', purchaseBucket]);
|
|
363
|
+
if (made.code !== 0) return refuse('Could not create private purchase storage', made.out);
|
|
364
|
+
remember(`r2:${purchaseBucket}`);
|
|
365
|
+
}
|
|
351
366
|
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
352
367
|
// free account with no payment method deploys the whole studio.
|
|
353
368
|
const r2 = storage;
|
|
354
369
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
355
370
|
|
|
356
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
371
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
357
372
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
358
373
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
359
374
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -361,13 +376,26 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
361
376
|
step('D1 migrations applied');
|
|
362
377
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
363
378
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
364
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
379
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
365
380
|
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
366
381
|
}
|
|
367
382
|
|
|
368
383
|
const started = Date.now();
|
|
369
|
-
|
|
384
|
+
let dep = w(['deploy', ...repoVar(root)]);
|
|
385
|
+
if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
|
|
386
|
+
const headers = await cloudflareAuth({ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
|
|
387
|
+
exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
|
|
388
|
+
const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
|
|
389
|
+
if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
|
|
390
|
+
step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
|
|
391
|
+
dep = w(['deploy', ...repoVar(root)]);
|
|
392
|
+
}
|
|
370
393
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
394
|
+
if (paidReleases(root).length) {
|
|
395
|
+
const bin = wranglerBin(root);
|
|
396
|
+
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
397
|
+
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) });
|
|
398
|
+
}
|
|
371
399
|
remember(`worker:${cf.worker}`);
|
|
372
400
|
// The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
|
|
373
401
|
// .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
|
|
@@ -385,6 +413,12 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
385
413
|
|
|
386
414
|
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
387
415
|
// reading it once now is all it takes; nothing is stored by hand.
|
|
416
|
+
if (paidReleases(root).length) {
|
|
417
|
+
const { withKey } = await import('./office.mjs');
|
|
418
|
+
const synced = await withKey(root, url, (call) => call('/_studio/api/shop/sync-offers', {}));
|
|
419
|
+
if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
|
|
420
|
+
step(`closed ${synced.expired} obsolete part Checkouts`);
|
|
421
|
+
}
|
|
388
422
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
389
423
|
recordDeploy(root, builtNow);
|
|
390
424
|
let claim = null;
|
|
@@ -646,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
646
680
|
writeStudio(root, next);
|
|
647
681
|
if (next.cloudflare.d1Id) {
|
|
648
682
|
// The studio's own routes stay through this rewrite too (lib/routes.mjs).
|
|
649
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
|
|
683
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
|
|
650
684
|
}
|
|
651
685
|
log(`created R2 ${bucket}`);
|
|
652
686
|
return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
|
package/lib/dev.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function runsFromStudio(pid, root) {
|
|
|
81
81
|
export function processOfStudio(pid, root, kind) {
|
|
82
82
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !isAlive(pid)) return false;
|
|
83
83
|
const cmd = commandOf(pid);
|
|
84
|
-
if (cmd === null) return process.platform === 'win32'; // no ps: the registration file is all there is
|
|
84
|
+
if (cmd === null) return process.platform === 'win32' || runsFromStudio(pid, root); // no ps: the registration file is all there is
|
|
85
85
|
if (kind === 'dev' ? /homie-studio/.test(cmd) : /wrangler|workerd/.test(cmd)) return true;
|
|
86
86
|
return runsFromStudio(pid, root);
|
|
87
87
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -192,7 +192,7 @@ export async function setupStatus({
|
|
|
192
192
|
rows.push({
|
|
193
193
|
id: 'node', label: 'Node.js', need: 'required', state: major >= 22 ? 'ok' : 'missing', detail: `v${node}${major >= 22 ? '' : ' (22 or newer is needed)'}`,
|
|
194
194
|
unlocks: 'everything: the studio\'s toolkit runs on it',
|
|
195
|
-
fix: major >= 22 ? null : { who: '
|
|
195
|
+
fix: major >= 22 ? null : { who: 'ai', say: 'Call setup_prepare in the Desktop extension; with a shell, install Node.js 22 or newer for this computer and continue setup. The person never runs a command.' },
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
// The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
|
|
@@ -318,15 +318,15 @@ export async function setupStatus({
|
|
|
318
318
|
{ label: 'email verified', state: verified, detail: deployed ? 'a deploy went through' : verified === 'act' ? 'the last deploy was refused until it is' : 'Cloudflare checks it at the first deploy' },
|
|
319
319
|
];
|
|
320
320
|
const fix = attached ? null : signedIn === 'act'
|
|
321
|
-
? { who: 'ai', run: 'npx wrangler login', say: 'Cloudflare opens in your browser: approve once.
|
|
321
|
+
? { who: 'ai', run: 'npx wrangler login', say: 'Cloudflare opens in your browser: approve once.', open: 'https://dash.cloudflare.com/sign-up' }
|
|
322
322
|
: verified === 'act'
|
|
323
|
-
? { who: 'person', open: 'https://dash.cloudflare.com/profile', say: 'Open the email Cloudflare sent and click its link (or Profile, "Send verification email");
|
|
323
|
+
? { who: 'person', open: 'https://dash.cloudflare.com/profile', say: 'Open the email Cloudflare sent and click its link (or Profile, "Send verification email"); your AI resumes deployment.' }
|
|
324
324
|
: signedIn === 'later' || verified === 'later'
|
|
325
|
-
? { who: '
|
|
325
|
+
? { who: 'ai', say: 'Continue making the studio. When it is ready to go live, call cloudflare_login; the person signs in or creates an account in that browser flow.' }
|
|
326
326
|
: null;
|
|
327
327
|
rows.push({
|
|
328
328
|
id: 'cloudflare', label: 'Cloudflare', need: 'to go online', state: worst(parts.map((p) => p.state)), detail: parts.map((p) => `${p.label}: ${p.detail}`).join('; '), parts,
|
|
329
|
-
unlocks: 'putting the studio\'s site and public rooms online, on its own
|
|
329
|
+
unlocks: 'putting the studio\'s site and public rooms online, on its own Cloudflare account', fix,
|
|
330
330
|
});
|
|
331
331
|
}
|
|
332
332
|
|
|
@@ -357,7 +357,7 @@ export async function setupStatus({
|
|
|
357
357
|
unlocks: 'sound effects and a synthesized theme (free, on this computer), trailers, and footage in a landing page\'s hero',
|
|
358
358
|
fix: ok ? null : mac ? { who: 'ai', run: 'brew install ffmpeg', say: 'about a minute with Homebrew; you approve the install.' }
|
|
359
359
|
: win ? { who: 'ai', run: 'winget install --id Gyan.FFmpeg -e', say: 'you approve the install.' }
|
|
360
|
-
: { who: '
|
|
360
|
+
: { who: 'ai', run: 'sudo apt-get install -y ffmpeg', say: 'Install ffmpeg with this system’s package manager; only an OS authorization prompt belongs to the person.' },
|
|
361
361
|
});
|
|
362
362
|
}
|
|
363
363
|
|
|
@@ -398,7 +398,7 @@ export async function setupStatus({
|
|
|
398
398
|
id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
|
|
399
399
|
detail: !fal.key ? 'no FAL_KEY in this environment' : fal.valid === true ? 'FAL_KEY is set and fal accepts it' : fal.valid === false ? 'fal refused the FAL_KEY that is set (wrong or revoked)' : 'FAL_KEY is set (fal did not answer just now)',
|
|
400
400
|
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',
|
|
401
|
-
fix: fal.key && fal.valid !== false ? null : { who: '
|
|
401
|
+
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' },
|
|
402
402
|
});
|
|
403
403
|
|
|
404
404
|
// Stripe, only in a studio that sells. Installing agent tooling is not proof of Worker credentials.
|
package/lib/domain.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Configure one requested hostname. Wrangler creates its DNS and TLS on deploy using OAuth.
|
|
2
|
+
* No zone ids or DNS values pass through the person. Existing unrelated routes stay intact.
|
|
3
|
+
*/
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { readConfig, routesOf } from './routes.mjs';
|
|
6
|
+
import { configPath, readStudio, writeStudio } from './studio.mjs';
|
|
7
|
+
|
|
8
|
+
export function studioDomain(root, hostname) {
|
|
9
|
+
const host = String(hostname ?? '').trim().toLowerCase();
|
|
10
|
+
if (host.length > 253 || !host.includes('.') || !host.split('.').every((p) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(p)) || host.endsWith('.workers.dev')) {
|
|
11
|
+
return { ok: false, next: 'AI: use the requested domain hostname alone, for example play.example.com.' };
|
|
12
|
+
}
|
|
13
|
+
const config = readConfig(root);
|
|
14
|
+
if (!config) return { ok: false, next: 'AI: repair the studio’s Wrangler configuration, then retry studio_domain.' };
|
|
15
|
+
const routes = routesOf(config);
|
|
16
|
+
if (!routes.some((r) => r.pattern === host && r.custom_domain === true)) routes.push({ pattern: host, custom_domain: true });
|
|
17
|
+
const studio = readStudio(root);
|
|
18
|
+
writeFileSync(configPath(root), `${JSON.stringify({ ...config, routes }, null, 2)}\n`);
|
|
19
|
+
writeStudio(root, { ...studio, cloudflare: { ...studio.cloudflare, domain: host } });
|
|
20
|
+
return { ok: true, host, next: 'AI: run studio_deploy with plan: true, then deploy within the person’s request. Cloudflare creates DNS and TLS with the existing sign-in. If the zone is not active on this account, keep the current live address and help connect the domain in the browser.', open: 'https://dash.cloudflare.com/?to=/:account/domains' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A first deploy may need the account's workers.dev name. Never rename an existing one. */
|
|
24
|
+
export async function registerWorkersAddress({ accountId, slug = 'studio', headers, fetchFn = fetch } = {}) {
|
|
25
|
+
const next = 'AI: retry Cloudflare sign-in with cloudflare_login, then deploy again.';
|
|
26
|
+
if (!headers || !accountId) return { ok: false, next };
|
|
27
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/workers/subdomain`;
|
|
28
|
+
try {
|
|
29
|
+
const old = await fetchFn(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
30
|
+
const body = await old.json();
|
|
31
|
+
if (old.ok && body.success !== false && body.result?.subdomain) return { ok: true, created: false };
|
|
32
|
+
if (old.status !== 404 && !(old.ok && body.success !== false && !body.result?.subdomain)) return { ok: false, next, status: old.status };
|
|
33
|
+
// Stable, unguessable from the account name, and no choice of a technical setting for the person.
|
|
34
|
+
const { createHash } = await import('node:crypto');
|
|
35
|
+
const stem = String(slug).toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/^-+|-+$/g, '').slice(0, 40) || 'studio';
|
|
36
|
+
const suffix = createHash('sha256').update(accountId).digest('hex').slice(0, 10);
|
|
37
|
+
const res = await fetchFn(url, { method: 'PUT', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ subdomain: `${stem}-${suffix}` }), signal: AbortSignal.timeout(15_000) });
|
|
38
|
+
const made = await res.json();
|
|
39
|
+
return res.ok && made.success !== false && made.result?.subdomain
|
|
40
|
+
? { ok: true, created: true }
|
|
41
|
+
: { ok: false, status: res.status, next: 'AI: open https://dash.cloudflare.com/?to=/:account/workers-and-pages and resolve the account address with the existing sign-in, then retry deploy. Do not ask the person for a setting or id.' };
|
|
42
|
+
} catch { return { ok: false, next: 'AI: retry deploy when Cloudflare is reachable; no account setting was confirmed.' }; }
|
|
43
|
+
}
|
package/lib/jobs.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { delimiter, dirname, join } from 'node:path';
|
|
19
19
|
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { nodeHome } from './prepare.mjs';
|
|
20
21
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
21
22
|
|
|
22
23
|
const WIN = process.platform === 'win32';
|
|
@@ -38,7 +39,7 @@ export function toolPath(env = process.env, platform = process.platform, home =
|
|
|
38
39
|
: ['/opt/homebrew/bin', '/usr/local/bin', join(home, '.volta', 'bin'), join(home, '.asdf', 'shims'), join(home, '.local', 'bin'), join(home, '.fnm', 'aliases', 'default', 'bin'), join(home, '.bun', 'bin'), ...nvm, '/usr/bin', '/bin'];
|
|
39
40
|
const seen = new Set();
|
|
40
41
|
const out = [];
|
|
41
|
-
for (const p of [...String(env.PATH ?? env.Path ?? '').split(delimiter), ...extra]) if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
42
|
+
for (const p of [...String(env.PATH ?? env.Path ?? '').split(delimiter), join(nodeHome(home), platform === 'win32' ? '' : 'bin'), ...extra]) if (p && !seen.has(p)) { seen.add(p); out.push(p); }
|
|
42
43
|
return out.join(delimiter);
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -52,6 +53,7 @@ export function findNode({ fresh = false } = {}) {
|
|
|
52
53
|
if (!process.versions.electron) candidates.push(process.execPath);
|
|
53
54
|
for (const dir of toolPath().split(delimiter)) candidates.push(join(dir, exe));
|
|
54
55
|
const tried = new Set();
|
|
56
|
+
let withoutNpm = null;
|
|
55
57
|
for (const bin of candidates) {
|
|
56
58
|
if (tried.has(bin) || !existsSync(bin)) continue;
|
|
57
59
|
tried.add(bin);
|
|
@@ -61,11 +63,12 @@ export function findNode({ fresh = false } = {}) {
|
|
|
61
63
|
const version = String(r.stdout ?? '').trim().replace(/^v/, '');
|
|
62
64
|
if (r.status === 0 && Number(version.split('.')[0]) >= 22) {
|
|
63
65
|
const npmNext = join(dirname(bin), WIN ? 'npm.cmd' : 'npm');
|
|
64
|
-
|
|
65
|
-
return nodeCache;
|
|
66
|
+
const found = { bin, version, npm: existsSync(npmNext) ? npmNext : which(WIN ? 'npm.cmd' : 'npm') };
|
|
67
|
+
if (found.npm) { nodeCache = found; return nodeCache; }
|
|
68
|
+
withoutNpm ??= found;
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
|
-
return
|
|
71
|
+
return withoutNpm;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export function which(name) {
|
|
@@ -161,16 +164,23 @@ export function jobView(job, { lines = 12 } = {}) {
|
|
|
161
164
|
/** Run the studio's pinned CLI as a job: `homie-studio <args> --json`. */
|
|
162
165
|
export function cliJob(root, label, args, { cli = null, ...opts } = {}) {
|
|
163
166
|
const node = findNode();
|
|
164
|
-
if (!node) throw new Error('
|
|
167
|
+
if (!node) throw new Error('Node.js is missing. AI: call setup_prepare, then retry this tool.');
|
|
165
168
|
// `cli`: another homie-studio than the studio's pinned one (this package's, for an upgrade to it).
|
|
166
169
|
return startJob({ root, label, cmd: node.bin, args: [cli ?? cliOf(root), ...args, '--json'], json: true, ...opts });
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
/** Windows command shims need a shell; run npm's JavaScript with Node instead. */
|
|
173
|
+
export function npmInvocation(node, platform = process.platform) {
|
|
174
|
+
return platform === 'win32'
|
|
175
|
+
? { cmd: node.bin, args: [join(dirname(node.npm), 'node_modules', 'npm', 'bin', 'npm-cli.js'), 'install', '--no-audit', '--no-fund'] }
|
|
176
|
+
: { cmd: node.npm, args: ['install', '--no-audit', '--no-fund'] };
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
/** `npm install` in a studio (its pinned toolkit and Wrangler). */
|
|
170
180
|
export function installJob(root, opts = {}) {
|
|
171
181
|
const node = findNode();
|
|
172
|
-
if (!node?.npm) throw new Error('npm
|
|
173
|
-
return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)',
|
|
182
|
+
if (!node?.npm) throw new Error('npm is missing. AI: call setup_prepare, then retry studio_install.');
|
|
183
|
+
return startJob({ root, label: 'npm install (the studio\'s pinned toolkit and Wrangler)', ...npmInvocation(node), ...opts });
|
|
174
184
|
}
|
|
175
185
|
|
|
176
186
|
/** When the server ends (the app quit), nothing it started keeps running, except what asked to (none today). */
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* AI in a game's guide seat, marked " · AI". This process holds the socket between turns; the game's own bot code
|
|
17
17
|
* moves the body while the AI thinks; the AI chooses only the game's own goals and lines (agents.json).
|
|
18
18
|
*/
|
|
19
|
+
import { prepareNode } from './prepare.mjs';
|
|
20
|
+
import { studioDomain } from './domain.mjs';
|
|
19
21
|
import { spawnSync } from 'node:child_process';
|
|
20
22
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
21
23
|
import { homedir } from 'node:os';
|
|
@@ -192,7 +194,7 @@ function needsInstall(ctx, root) {
|
|
|
192
194
|
if (installed(root) || !ctx.install) return null;
|
|
193
195
|
const running = runningJobs(root).find((j) => j.label.startsWith('npm install'));
|
|
194
196
|
const job = running ?? installJob(root);
|
|
195
|
-
return ok(`The studio's toolkit is still being installed (npm install, job ${job.id}; usually 20 to 60 s).
|
|
197
|
+
return ok(`The studio's toolkit is still being installed (npm install, job ${job.id}; usually 20 to 60 s). AI: follow it with studio_job { "job": "${job.id}" }.`, { kind: 'job', ...jobView(job) });
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
/* ------------------------------------------------------------------ the setup card */
|
|
@@ -217,7 +219,7 @@ async function statusOf(ctx, root, { fresh = false } = {}) {
|
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
function checklist(ctx, root) {
|
|
220
|
-
const steps = ['Setup status', 'The studio', '
|
|
222
|
+
const steps = ['Setup status', 'The studio', 'Plan from your request', 'Build and check it', 'Put it online'];
|
|
221
223
|
const done = [true, Boolean(root)];
|
|
222
224
|
if (root) {
|
|
223
225
|
const games = listGames(root);
|
|
@@ -228,7 +230,7 @@ function checklist(ctx, root) {
|
|
|
228
230
|
const checked = note.checked || (() => {
|
|
229
231
|
try { return readdirSync(join(root, '.studio', 'progress')).some((f) => f.endsWith('.json') && readFeed(root, f.slice(0, -5))?.stages?.some((x) => x.id === 'checks' && x.state === 'done')); } catch { return false; }
|
|
230
232
|
})();
|
|
231
|
-
done.push(Boolean(
|
|
233
|
+
done.push(Boolean(codex || games.length || checked || local.deployedAt), Boolean(checked || local.deployedAt), Boolean(local.deployedAt || local.connectedAt));
|
|
232
234
|
}
|
|
233
235
|
// In order: a step counts as done only when every step before it is (an early file edit is not "one small change").
|
|
234
236
|
for (let i = 1; i < done.length; i++) done[i] = done[i] && done[i - 1];
|
|
@@ -618,9 +620,18 @@ async function openFromGitHub(ctx, a) {
|
|
|
618
620
|
}
|
|
619
621
|
|
|
620
622
|
/** github_login: GitHub's device sign-in through the GitHub CLI; the person types the one-time code in the browser. */
|
|
621
|
-
async function githubLogin(ctx) {
|
|
622
|
-
const { gh, signedIn } =
|
|
623
|
-
if (!gh)
|
|
623
|
+
async function githubLogin(ctx, { state = ghState, find = which, start = startJob, activeJobs = runningJobs, view = jobView } = {}) {
|
|
624
|
+
const { gh, signedIn } = state();
|
|
625
|
+
if (!gh) {
|
|
626
|
+
const brew = find('brew');
|
|
627
|
+
if (brew) {
|
|
628
|
+
const root = ctx.current ?? ctx.studiosDir ?? homedir();
|
|
629
|
+
const active = activeJobs(root).find((j) => j.label === 'Install GitHub CLI');
|
|
630
|
+
const job = active ?? start({ root, label: 'Install GitHub CLI', cmd: brew, args: ['install', 'gh'] });
|
|
631
|
+
return ok(`AI: follow studio_job { "job": "${job.id}" }, then retry github_login.`, { kind: 'job', ...view(job) });
|
|
632
|
+
}
|
|
633
|
+
return fail('AI: install the GitHub CLI from https://cli.github.com, then retry github_login. The person only approves an OS installer prompt; never ask them for a command or token.');
|
|
634
|
+
}
|
|
624
635
|
if (signedIn) return ok('This computer is signed in to GitHub already.');
|
|
625
636
|
const job = startJob({ root: ctx.current ?? ctx.studiosDir ?? homedir(), label: 'GitHub sign-in (gh auth login)', cmd: gh, args: ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'], env: toolEnv() });
|
|
626
637
|
let code = null;
|
|
@@ -758,6 +769,26 @@ const RW = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
|
|
|
758
769
|
|
|
759
770
|
export function toolDefs(ctx, avail = {}) {
|
|
760
771
|
const tools = [
|
|
772
|
+
{
|
|
773
|
+
name: 'setup_prepare', title: 'Prepare this computer',
|
|
774
|
+
description: 'AI setup: install a private Node.js and npm when missing, without a terminal or administrator rights. Then resume studio_scaffold; install Chrome with studio_run ["chrome","install"] when checks need it. No provider account is needed.',
|
|
775
|
+
inputSchema: { type: 'object', properties: {} },
|
|
776
|
+
annotations: { title: 'Prepare this computer', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
777
|
+
run: async () => {
|
|
778
|
+
if (findNode({ fresh: true })?.npm) return ok('Node and npm are ready. AI: continue studio_scaffold.');
|
|
779
|
+
const r = await prepareNode();
|
|
780
|
+
ctx.status = null;
|
|
781
|
+
findNode({ fresh: true });
|
|
782
|
+
return r.ok ? ok('Node and npm are ready. AI: continue studio_scaffold.', r) : fail(r.next, r);
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: 'studio_domain', title: 'Set the studio domain',
|
|
787
|
+
description: 'Prepare a requested custom hostname for deployment. Cloudflare creates DNS and TLS using its existing browser sign-in. The AI handles configuration; never ask for an account id, zone id or DNS value. Does not deploy or change the domain registrar.',
|
|
788
|
+
inputSchema: { type: 'object', properties: { hostname: str('Requested hostname, e.g. play.example.com'), ...STUDIO_ARG }, required: ['hostname'] },
|
|
789
|
+
annotations: { title: 'Set the studio domain', ...RW },
|
|
790
|
+
run: async (a) => { const r = studioDomain(ctx.root(a.studio), a.hostname); return r.ok ? ok(r.next, r) : fail(r.next, r); },
|
|
791
|
+
},
|
|
761
792
|
{
|
|
762
793
|
name: 'setup_status', title: 'Setup status',
|
|
763
794
|
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.',
|
|
@@ -772,12 +803,11 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
772
803
|
},
|
|
773
804
|
{
|
|
774
805
|
name: 'studio_scaffold', title: 'Make a studio',
|
|
775
|
-
description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (
|
|
776
|
-
inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') }
|
|
806
|
+
description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (their account). It has NO game: its home page says "First game coming soon". Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
|
|
807
|
+
inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') } },
|
|
777
808
|
annotations: { title: 'Make a studio', ...RW }, _meta: ui(UI.setup),
|
|
778
809
|
run: async (a) => {
|
|
779
|
-
const name = String(a.name ?? '').trim().slice(0, 60);
|
|
780
|
-
if (!name) return fail('name the studio, e.g. "Night Owls"');
|
|
810
|
+
const name = String(a.name ?? '').trim().slice(0, 60) || 'My Studio';
|
|
781
811
|
if (!ctx.studiosDir) return fail('no studios folder is set: in Claude, Settings > Extensions > Homie, choose the folder your studios live in');
|
|
782
812
|
mkdirSync(ctx.studiosDir, { recursive: true });
|
|
783
813
|
const folder = a.folder ? String(a.folder).trim() : slugify(name);
|
|
@@ -797,6 +827,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
797
827
|
ctx.note(made.dir).earlier = earlier.map((e) => e.path);
|
|
798
828
|
let install = null;
|
|
799
829
|
if (ctx.install) {
|
|
830
|
+
if (!findNode()?.npm) { const ready = await prepareNode(); if (!ready.ok) return fail(ready.next, ready); findNode({ fresh: true }); }
|
|
800
831
|
try { install = installJob(made.dir); } catch (error) { return fail(`${name} is made at ${made.dir}, but ${error.message}`); }
|
|
801
832
|
await waitJob(install, Math.min(ctx.waitMs, 35_000));
|
|
802
833
|
}
|
|
@@ -937,8 +968,8 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
937
968
|
},
|
|
938
969
|
{
|
|
939
970
|
name: 'game_plan', title: 'Plan a game (its Game Codex)',
|
|
940
|
-
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and
|
|
941
|
-
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
|
|
971
|
+
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and record sensible defaults from the request for genre, style, devices, rooms, art, sound and scope. Never route engineering or design questions to the person. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their request and your defaults in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it. While planning, run `parts find` (the parts_find tool) for each system the game needs, so the plan starts from pieces of existing games and the @homie-rocks/* packages rather than from nothing; the codex\'s Built from section records what was found.',
|
|
972
|
+
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), interview: { type: 'boolean', description: 'Only when the person explicitly requests a planning conversation' }, ...STUDIO_ARG }, required: ['id'] },
|
|
942
973
|
annotations: { title: 'Plan a game', ...RW },
|
|
943
974
|
run: async (a) => {
|
|
944
975
|
const root = ctx.root(a.studio);
|
|
@@ -948,12 +979,12 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
948
979
|
made = newCodex(root, String(a.id), { name: a.name ?? null });
|
|
949
980
|
if (!made.ok) return fail(made.why);
|
|
950
981
|
}
|
|
951
|
-
const guide = ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
982
|
+
const guide = a.interview === true && ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
952
983
|
return ok([
|
|
953
984
|
made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
|
|
954
|
-
'
|
|
985
|
+
'From the request and sensible defaults, fill CODEX.md in one call, then show it with game_codex and continue building. Choose phone and computer controls, a small first version, local sound and CC0 art. Infer persistent progress from the premise. No interview unless the person explicitly asks to plan together; no engineering or design questions.',
|
|
955
986
|
'', PARTS_FIRST,
|
|
956
|
-
...(guide ? ['', guide] : []),
|
|
987
|
+
...(guide ? ['', 'Optional reference for a person who explicitly requests an interview:', guide] : []),
|
|
957
988
|
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
958
989
|
},
|
|
959
990
|
},
|
|
@@ -1105,7 +1136,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1105
1136
|
},
|
|
1106
1137
|
{
|
|
1107
1138
|
name: 'studio_deploy', title: 'Put the studio online',
|
|
1108
|
-
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects,
|
|
1139
|
+
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, with its own resources. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
|
|
1109
1140
|
inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ownRoute: { type: 'boolean', description: 'Only after the plan or a deploy warned that another site\'s route on the studio\'s custom domain covers its hostname, and the person agreed: add the studio\'s own exact-host route (the one line the warning gives) and deploy. It never edits or removes any other route' }, ...STUDIO_ARG } },
|
|
1110
1141
|
annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
|
|
1111
1142
|
run: async (a) => {
|
|
@@ -1139,7 +1170,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1139
1170
|
},
|
|
1140
1171
|
{
|
|
1141
1172
|
name: 'cloudflare_login', title: 'Sign in to Cloudflare',
|
|
1142
|
-
description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in;
|
|
1173
|
+
description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in; no key pasted anywhere). Say in one line that Cloudflare opened and they should approve it.',
|
|
1143
1174
|
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
1144
1175
|
annotations: { title: 'Sign in to Cloudflare', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1145
1176
|
run: async (a) => {
|
|
@@ -1464,4 +1495,4 @@ export function slimCodex(html, { max = 140_000, pictures = true } = {}) {
|
|
|
1464
1495
|
return h.length <= max ? h : null;
|
|
1465
1496
|
}
|
|
1466
1497
|
|
|
1467
|
-
export const _test = { checklist, feedFor, slimCodex, expandHome, feedbackFacts };
|
|
1498
|
+
export const _test = { githubLogin, checklist, feedFor, slimCodex, expandHome, feedbackFacts };
|