@homie-rocks/studio 0.16.1 → 0.18.0
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/README.md +27 -15
- package/agents/GUIDES.md +99 -0
- package/agents/agents.ts +395 -0
- package/bin/homie-studio.mjs +123 -16
- package/lib/agent-seat.mjs +185 -0
- package/lib/build.mjs +32 -5
- package/lib/cloudflare.mjs +163 -9
- package/lib/mcp-tools.mjs +56 -0
- package/lib/media.mjs +185 -16
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +29 -10
- package/lib/servers.mjs +66 -4
- package/lib/template-history.json +56 -0
- package/lib/upgrade.mjs +27 -0
- package/media/MEDIA.md +68 -17
- package/netplay/NETPLAY.md +157 -6
- package/netplay/netplay.ts +10 -4
- package/package.json +8 -2
- package/site/SITE.md +6 -0
- package/starters/ember-vale/agents.json +34 -0
- package/starters/ember-vale/index.html +8 -0
- package/starters/ember-vale/src/main.ts +252 -5
- package/worker/agents.mjs +359 -4
- package/worker/brain.mjs +417 -0
- package/worker/index.mjs +195 -21
- package/worker/office-page.mjs +13 -0
- package/worker/office.mjs +62 -8
- package/worker/room.mjs +115 -6
- package/worker/stats.mjs +4 -2
- package/worker/version.mjs +1 -1
package/lib/cloudflare.mjs
CHANGED
|
@@ -9,19 +9,24 @@
|
|
|
9
9
|
* brand-new account with no payment method, by the names in studio.json:
|
|
10
10
|
* the Worker (pages + rooms), a D1 database, and the Table/Lobby Durable
|
|
11
11
|
* Objects (SQLite-backed, declared by the Worker).
|
|
12
|
-
* It never creates
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* It never creates R2: Cloudflare asks for a payment method before R2 works,
|
|
13
|
+
* even inside its free tier, so storage for large media is its own later step
|
|
14
|
+
* (`homie-studio storage add`) that the person agrees to. Once a studio has it,
|
|
15
|
+
* deploy binds it and moves the big songs and videos there first (mediaMove:
|
|
16
|
+
* uploaded, read back, checked by SHA-256), so the site stops carrying them and
|
|
17
|
+
* serves them from R2 at the same addresses.
|
|
15
18
|
*
|
|
16
19
|
* It REFUSES to touch a Worker, database or bucket of that name that this
|
|
17
20
|
* studio did not create (studio.json `cloudflare.created` is the record), so it
|
|
18
21
|
* can never overwrite someone's existing site. `deploy --plan` says all of this
|
|
19
22
|
* before anything happens and calls nothing.
|
|
20
23
|
*/
|
|
21
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
22
26
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
27
|
import { join } from 'node:path';
|
|
24
28
|
import { build } from './build.mjs';
|
|
29
|
+
import { MAX_ASSET_BYTES, R2_COST, mediaPlan, r2OverOf, recordMove, sha256File, sizeOf } from './media.mjs';
|
|
25
30
|
import { repoOf } from './repo.mjs';
|
|
26
31
|
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
27
32
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
@@ -101,11 +106,11 @@ export function deployPlan(root) {
|
|
|
101
106
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
102
107
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
103
108
|
storage
|
|
104
|
-
? { kind: 'R2 bucket', name: storage, what: '
|
|
109
|
+
? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
|
|
105
110
|
: { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
|
|
106
111
|
],
|
|
107
112
|
cost: storage
|
|
108
|
-
?
|
|
113
|
+
? `Free on Cloudflare's Workers Free plan. ${R2_COST} Beyond the free tier Cloudflare bills the account directly.`
|
|
109
114
|
: 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
|
|
110
115
|
login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
|
|
111
116
|
address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
|
|
@@ -158,9 +163,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
158
163
|
for (const line of announced) log(line);
|
|
159
164
|
}
|
|
160
165
|
|
|
161
|
-
|
|
166
|
+
// Big media into the studio's R2 before the build (0.18.0): only with storage this studio made, so a free account
|
|
167
|
+
// with no payment method is never asked anything about R2. Each file is read back and its SHA-256 compared before
|
|
168
|
+
// the build stops carrying it; a file that did not make it stays on the site (when it fits) and is named here.
|
|
169
|
+
const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
170
|
+
let media = null;
|
|
171
|
+
if (storage) {
|
|
172
|
+
media = await mediaMove(root, { auto: true, accountId, log });
|
|
173
|
+
for (const m of media.moved ?? []) step(`moved ${m.path} (${sizeOf(m.bytes)}) to R2 and checked it by SHA-256; the site serves it at the same address, ${m.url}`);
|
|
174
|
+
for (const m of media.failed ?? []) step(`not moved to R2: ${m.path}: ${m.why}`);
|
|
175
|
+
if (media.why && !media.ok) step(`big media stays on the site this time: ${media.why}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const b = await build(root, { log, deploy: true });
|
|
162
179
|
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
163
180
|
step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
|
|
181
|
+
for (const n of b.mediaNotes ?? []) step(`note: ${n.file}: ${n.why}`);
|
|
164
182
|
|
|
165
183
|
// The Worker: never one this studio did not make.
|
|
166
184
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -189,7 +207,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
189
207
|
|
|
190
208
|
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
191
209
|
// free account with no payment method deploys the whole studio.
|
|
192
|
-
const r2 =
|
|
210
|
+
const r2 = storage;
|
|
193
211
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
194
212
|
|
|
195
213
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
@@ -197,6 +215,11 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
197
215
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
198
216
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
199
217
|
step('D1 migrations applied');
|
|
218
|
+
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
219
|
+
if (needsWorkersAi(root, w, cf.d1)) {
|
|
220
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true }));
|
|
221
|
+
step('Workers AI bound (AI): a server\'s AI guides think with it, within the day\'s budget (free allocation: 10,000 neurons a day)');
|
|
222
|
+
}
|
|
200
223
|
|
|
201
224
|
const started = Date.now();
|
|
202
225
|
const dep = w(['deploy', ...repoVar(root)]);
|
|
@@ -232,6 +255,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
232
255
|
writeStudio(root, { ...readStudio(root), cloudflare: next });
|
|
233
256
|
return {
|
|
234
257
|
ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
|
|
258
|
+
...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
|
|
235
259
|
games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
|
|
236
260
|
songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
|
|
237
261
|
videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
|
|
@@ -239,6 +263,21 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
239
263
|
};
|
|
240
264
|
}
|
|
241
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Whether any server of this studio's games thinks with Workers AI (a D1 row with brain 'workers-ai', or a game.json
|
|
268
|
+
* seed): deploy binds AI only then (DESIGN section 6). A database without the servers table yet says no.
|
|
269
|
+
*/
|
|
270
|
+
export function needsWorkersAi(root, w, db) {
|
|
271
|
+
try {
|
|
272
|
+
const cat = JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8'));
|
|
273
|
+
if ((cat.games ?? []).some((g) => (g.servers ?? []).some((s) => s.brain === 'workers-ai'))) return true;
|
|
274
|
+
} catch { /* not built */ }
|
|
275
|
+
const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
|
|
276
|
+
if (r.code !== 0) return false;
|
|
277
|
+
const parsed = parseJson(r.stdout ?? r.out);
|
|
278
|
+
return Number((Array.isArray(parsed) ? parsed[0] : parsed)?.results?.[0]?.n) > 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
242
281
|
/*
|
|
243
282
|
* THE STUDIO'S REPOSITORY, FOR ITS WORKER (lib/repo.mjs). The Claude app's hand-off opens Claude Code on the studio's
|
|
244
283
|
* own GitHub repository, and only the studio knows which it is: the deploy hands it to the Worker as a variable
|
|
@@ -299,6 +338,14 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
299
338
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
|
300
339
|
if (migrate.code !== 0 && !first) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
301
340
|
if (!first) step('D1 migrations applied');
|
|
341
|
+
// Workers AI (0.17.0): bound only when a server's AI guides think with it. The checkout is thrown away after.
|
|
342
|
+
if (!first && needsWorkersAi(root, w, names.binding)) {
|
|
343
|
+
try {
|
|
344
|
+
const json = JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, ''));
|
|
345
|
+
if (!json.ai) { json.ai = { binding: 'AI' }; writeFileSync(configPath(root), `${JSON.stringify(json, null, 2)}\n`); }
|
|
346
|
+
step('Workers AI bound (AI) for the AI guides');
|
|
347
|
+
} catch { step('could not add the Workers AI binding to wrangler.jsonc: the guides answer from the game\'s script'); }
|
|
348
|
+
}
|
|
302
349
|
const started = Date.now();
|
|
303
350
|
const dep = w(['deploy', ...repoVar(root)]);
|
|
304
351
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
@@ -372,5 +419,112 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
372
419
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root) }));
|
|
373
420
|
}
|
|
374
421
|
log(`created R2 ${bucket}`);
|
|
375
|
-
return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA
|
|
422
|
+
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)'] };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One Wrangler command, run without blocking (an upload of a music video takes a while); the last of its words kept. */
|
|
426
|
+
function wranglerRun(bin, args, { cwd, env }) {
|
|
427
|
+
return new Promise((done) => {
|
|
428
|
+
const p = spawn(bin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
429
|
+
let out = '';
|
|
430
|
+
const keep = (d) => { out = `${out}${d}`.slice(-8000); };
|
|
431
|
+
p.stdout.on('data', keep);
|
|
432
|
+
p.stderr.on('data', keep);
|
|
433
|
+
p.on('error', (e) => done({ code: 1, out: String(e.message) }));
|
|
434
|
+
p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, '') }));
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Read an object back from R2 (`wrangler r2 object get --pipe`) and hash it as it streams: nothing is written to disk. */
|
|
439
|
+
function hashRemote(bin, path, { cwd, env }) {
|
|
440
|
+
return new Promise((done) => {
|
|
441
|
+
const p = spawn(bin, ['r2', 'object', 'get', path, '--remote', '--pipe'], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
442
|
+
const h = createHash('sha256');
|
|
443
|
+
let bytes = 0;
|
|
444
|
+
let err = '';
|
|
445
|
+
p.stdout.on('data', (d) => { h.update(d); bytes += d.length; });
|
|
446
|
+
p.stderr.on('data', (d) => { err = `${err}${d}`.slice(-4000); });
|
|
447
|
+
p.on('error', (e) => done({ code: 1, err: String(e.message) }));
|
|
448
|
+
p.on('close', (code) => done({ code: code ?? 1, sha256: h.digest('hex'), bytes, err: err.replace(ANSI, '') }));
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/*
|
|
453
|
+
* `homie-studio media move [<file>...] [--dry-run] [--verify]` (and every `deploy` of a studio with storage): the big
|
|
454
|
+
* public files of the studio's published songs and videos go into its own R2, at the key that is their path, so the
|
|
455
|
+
* site keeps serving them at the same address.
|
|
456
|
+
*
|
|
457
|
+
* 1. hash the file on this computer (SHA-256);
|
|
458
|
+
* 2. `wrangler r2 object put <bucket>/<path>`;
|
|
459
|
+
* 3. read it back (`wrangler r2 object get --pipe`) and hash what R2 returns;
|
|
460
|
+
* 4. only when size and hash match, record `r2: { key, sha256, bytes, at }` on the manifest's file.
|
|
461
|
+
*
|
|
462
|
+
* The next deploy's build leaves a recorded file out of the site's static assets (and serves it from R2). The file
|
|
463
|
+
* on this computer is never deleted, moved or changed. A file that fails any step is named, and stays on the site.
|
|
464
|
+
* `--verify` reads every file already recorded back from R2 and checks its hash again (it changes nothing).
|
|
465
|
+
*/
|
|
466
|
+
export async function mediaMove(root, { paths = null, dryRun = false, verify = false, auto = false, accountId = null, log = () => {} } = {}) {
|
|
467
|
+
const studio = readStudio(root);
|
|
468
|
+
const cf = { created: [], ...studio.cloudflare };
|
|
469
|
+
const bucket = cf.r2 && (cf.created ?? []).includes(`r2:${cf.r2}`) ? cf.r2 : null;
|
|
470
|
+
const over = r2OverOf(studio);
|
|
471
|
+
const named = Array.isArray(paths) && paths.length ? paths : null;
|
|
472
|
+
const rows = mediaPlan(root, { over, paths: named });
|
|
473
|
+
const view = (r) => ({ path: r.path, kind: r.kind, slug: r.slug, role: r.role, state: r.state, bytes: r.bytes ?? null, ...(r.reason ? { reason: r.reason } : {}), ...(r.why ? { why: r.why } : {}) });
|
|
474
|
+
const held = rows.filter((r) => r.state === 'r2');
|
|
475
|
+
const todo = rows.filter((r) => r.state === 'move');
|
|
476
|
+
const inR2 = [...held, ...todo].reduce((n, r) => n + (r.bytes ?? 0), 0);
|
|
477
|
+
const base = {
|
|
478
|
+
command: 'media move', bucket, over, rows: rows.map(view), inR2, cost: R2_COST,
|
|
479
|
+
// Over the free 10 GB-month, Cloudflare bills the account: say so with the number.
|
|
480
|
+
...(inR2 > 10e9 ? { warning: `R2 would hold ${sizeOf(inR2)} of this studio's media, over the 10 GB-month free tier: about US$${(((inR2 - 10e9) / 1e9) * 0.015).toFixed(2)} a month more, billed by Cloudflare to the account` } : {}),
|
|
481
|
+
};
|
|
482
|
+
if (!bucket) {
|
|
483
|
+
return { ok: false, ...base, needs: 'storage', why: 'this studio has no storage yet, so its media stays on the site (files up to 25 MiB each). `npx --no-install homie-studio storage add` adds an R2 bucket; Cloudflare asks for a payment method on the account before R2 works, so ask the person first. Games never need it.' };
|
|
484
|
+
}
|
|
485
|
+
if (auto && over === null && !named) return { ok: true, ...base, off: true, moved: [], failed: [], why: 'studio.json media.r2Over is false: nothing moves on its own' };
|
|
486
|
+
if (dryRun || (!todo.length && !verify)) return { ok: true, ...base, dryRun, moved: [], failed: [], next: todo.length ? 'npx --no-install homie-studio media move (then npm run deploy)' : null };
|
|
487
|
+
const bin = wranglerBin(root);
|
|
488
|
+
if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
|
|
489
|
+
const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
|
|
490
|
+
const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
491
|
+
const moved = [];
|
|
492
|
+
const failed = [];
|
|
493
|
+
const verified = [];
|
|
494
|
+
if (verify) {
|
|
495
|
+
for (const r of held) {
|
|
496
|
+
const key = r.held?.key ?? r.path;
|
|
497
|
+
const got = await hashRemote(bin, `${bucket}/${key}`, opts);
|
|
498
|
+
const ok = got.code === 0 && got.sha256 === r.held?.sha256 && got.bytes === Number(r.held?.bytes);
|
|
499
|
+
verified.push({ path: r.path, key, ok, bytes: got.bytes, ...(ok ? {} : { why: got.code !== 0 ? `could not read it back: ${got.err.trim().split('\n').slice(-2).join(' ')}` : 'R2 returned different bytes than were recorded' }) });
|
|
500
|
+
log(`${ok ? 'verified' : 'MISMATCH'} ${r.path} in R2 ${bucket}/${key}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
for (const r of todo) {
|
|
504
|
+
const key = r.path;
|
|
505
|
+
const url = `/${key.split('/').map(encodeURIComponent).join('/')}`;
|
|
506
|
+
const sha256 = sha256File(r.abs);
|
|
507
|
+
log(`uploading ${r.path} (${sizeOf(r.bytes)}) to R2 ${bucket}/${key}`);
|
|
508
|
+
const put = await wranglerRun(bin, ['r2', 'object', 'put', `${bucket}/${key}`, '--file', r.abs, '--content-type', r.type, '--remote'], opts);
|
|
509
|
+
// Where the file is meanwhile: on the site when it fits; a bigger one keeps an older R2 copy, or waits.
|
|
510
|
+
const meanwhile = r.bytes <= MAX_ASSET_BYTES ? ' It stays on the site.' : r.held ? ' Its page keeps playing the older copy in R2.' : ' At this size the site cannot carry it, so its page waits until it is in R2.';
|
|
511
|
+
if (put.code !== 0) {
|
|
512
|
+
failed.push({ path: r.path, bytes: r.bytes, why: `${explainCloudflare(put.out, account)?.why ?? `the upload failed: ${put.out.trim().split('\n').slice(-3).join(' ')}`}${meanwhile}` });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const got = await hashRemote(bin, `${bucket}/${key}`, opts);
|
|
516
|
+
if (got.code !== 0 || got.sha256 !== sha256 || got.bytes !== r.bytes) {
|
|
517
|
+
failed.push({ path: r.path, bytes: r.bytes, why: `${got.code !== 0 ? `uploaded, but it could not be read back to check it (${got.err.trim().split('\n').slice(-2).join(' ')}); nothing recorded.` : `R2 returned ${got.bytes} bytes with a different SHA-256 from this file's ${r.bytes}; nothing recorded.`}${meanwhile}` });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const at = new Date().toISOString();
|
|
521
|
+
const manifests = recordMove(root, r.path, { key, sha256, bytes: r.bytes, at });
|
|
522
|
+
moved.push({ path: r.path, key, bytes: r.bytes, sha256, url, manifests });
|
|
523
|
+
log(`checked ${r.path} in R2 by SHA-256 (${sha256.slice(0, 12)}…); its address stays ${url}`);
|
|
524
|
+
}
|
|
525
|
+
const bad = verified.filter((v) => !v.ok);
|
|
526
|
+
return {
|
|
527
|
+
ok: !failed.length && !bad.length, ...base, moved, failed, ...(verify ? { verified } : {}),
|
|
528
|
+
...(moved.length ? { next: 'npm run deploy: the site stops carrying these files and serves them from R2 at the same addresses. The files stay in this folder; commit the manifests (they name each file\'s R2 copy), never the media.' } : {}),
|
|
529
|
+
};
|
|
376
530
|
}
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Every tool works in ONE studio folder: one the person named, else the one in use, else the only one there is. The
|
|
13
13
|
* file tools never leave it (lib/files.mjs).
|
|
14
|
+
*
|
|
15
|
+
* AN AI IN A SEAT (0.17.0, lib/agent-seat.mjs): agent_sit, agent_look, agent_do and agent_stand put the person's own
|
|
16
|
+
* AI in a game's guide seat, marked " · AI". This process holds the socket between turns; the game's own bot code
|
|
17
|
+
* moves the body while the AI thinks; the AI chooses only the game's own goals and lines (agents.json).
|
|
14
18
|
*/
|
|
15
19
|
import { spawnSync } from 'node:child_process';
|
|
16
20
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
@@ -26,6 +30,7 @@ import { newStudio, slugify } from './scaffold.mjs';
|
|
|
26
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
27
31
|
import { GAME_ID, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
28
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
33
|
+
import { AgentSeat } from './agent-seat.mjs';
|
|
29
34
|
|
|
30
35
|
export const UI = Object.freeze({
|
|
31
36
|
setup: 'ui://homie-studio/setup',
|
|
@@ -960,6 +965,57 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
960
965
|
return r.job.code === 0 ? ok(text, { kind: 'run', args, result: r.result }) : fail(`homie-studio ${args.join(' ')}: ${whyOf(r.job)}`, { kind: 'run', args, result: r.result });
|
|
961
966
|
},
|
|
962
967
|
},
|
|
968
|
+
{
|
|
969
|
+
name: 'agent_sit', title: 'Sit in a game as an AI guide',
|
|
970
|
+
description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
|
|
971
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
|
|
972
|
+
annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
973
|
+
run: async (a) => {
|
|
974
|
+
const root = ctx.root(a.studio);
|
|
975
|
+
if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
|
|
976
|
+
const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
|
|
977
|
+
if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
|
|
978
|
+
const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : 'Claude', root });
|
|
979
|
+
const r = await seat.sit();
|
|
980
|
+
if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
|
|
981
|
+
ctx.seat = seat;
|
|
982
|
+
return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
name: 'agent_look', title: 'Look as the AI guide',
|
|
987
|
+
description: 'What your AI guide sees now: the view the game showed you (game state, seats, never names), the asks players made of you (answer these first), the party\'s lines, your last goal, and your choices.',
|
|
988
|
+
inputSchema: { type: 'object', properties: {} },
|
|
989
|
+
annotations: { title: 'Look as the AI guide', ...RO },
|
|
990
|
+
run: async () => {
|
|
991
|
+
if (!ctx.seat) return fail('not seated: agent_sit first');
|
|
992
|
+
const l = ctx.seat.look();
|
|
993
|
+
return ok(`${l.closed ? `The seat closed (${l.closed}); agent_sit again.\n` : ''}${JSON.stringify(l, null, 1).slice(0, 8000)}`, { kind: 'agent', ...l });
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
name: 'agent_do', title: 'Act as the AI guide',
|
|
998
|
+
description: 'Choose your guide\'s goal (one of look.choices.goals, with argument values the view offers; a player argument is a seat number) and, if the server lets its AI talk, at most one line (look.choices.lines; never free text). The game\'s bot code carries it out at frame rate until you choose again. At most one goal every 3 s and one line every 8 s.',
|
|
999
|
+
inputSchema: { type: 'object', properties: { goal: str('A goal id'), args: { type: 'object', description: 'The goal\'s arguments' }, say: str('Optional: a line id'), sayArgs: { type: 'object', description: 'The line\'s arguments' } }, required: ['goal'] },
|
|
1000
|
+
annotations: { title: 'Act as the AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1001
|
+
run: async (a) => {
|
|
1002
|
+
if (!ctx.seat) return fail('not seated: agent_sit first');
|
|
1003
|
+
const r = await ctx.seat.do({ goal: a.goal, args: a.args ?? {}, say: a.say ?? null, sayArgs: a.sayArgs ?? {} });
|
|
1004
|
+
return r.ok ? ok(`Done: ${r.sent.goal} ${JSON.stringify(r.sent.args)}${r.text ? `, saying "${r.text}"` : ''}.`, { kind: 'agent', ...r }) : fail(r.why, { kind: 'agent', ok: false });
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: 'agent_stand', title: 'Leave the seat',
|
|
1009
|
+
description: 'Your AI guide leaves the room (its seat becomes a guide the game\'s own script plays); a pass made for this sitting is revoked.',
|
|
1010
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1011
|
+
annotations: { title: 'Leave the seat', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1012
|
+
run: async () => {
|
|
1013
|
+
if (!ctx.seat) return ok('Not seated.');
|
|
1014
|
+
const r = await ctx.seat.stand();
|
|
1015
|
+
ctx.seat = null;
|
|
1016
|
+
return ok(`Left ${r.room}.`, { kind: 'agent', ...r });
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
963
1019
|
{
|
|
964
1020
|
name: 'studio_job', title: 'A job, so far',
|
|
965
1021
|
description: 'Where a job a tool handed back is (an npm install, a check, a deploy, a render): running or done, its last lines and its result. Waits up to 30 s for it to finish.',
|
package/lib/media.mjs
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A studio's music/ and videos/ manifests (media/MEDIA.md): what the site shows,
|
|
3
|
-
* and where each file's bytes come from (R2
|
|
4
|
-
* into the site, or an absolute https address).
|
|
3
|
+
* and where each file's bytes come from (the studio's R2, a file in the repository
|
|
4
|
+
* copied into the site, or an absolute https address).
|
|
5
|
+
*
|
|
6
|
+
* BIG MEDIA LIVES IN R2 (0.18.0). Once a studio has storage (`homie-studio storage add`), every public file of a
|
|
7
|
+
* published song or video that is over `R2_OVER` bytes (studio.json `media.r2Over`), or that git leaves out of the
|
|
8
|
+
* repository, goes to the studio's own bucket: `homie-studio media move` (and every `deploy`) uploads it, reads it
|
|
9
|
+
* back and compares SHA-256, and only then records `r2: { key, sha256, bytes, at }` on the manifest's file. A deploy
|
|
10
|
+
* build then stops carrying that file in the site's static assets, and the Worker serves the very same address
|
|
11
|
+
* (`/<path>`) from R2, with byte ranges and the same cache headers. The local file is never deleted.
|
|
5
12
|
*/
|
|
6
|
-
import {
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
7
16
|
import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
|
|
8
17
|
|
|
9
18
|
export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
@@ -13,6 +22,15 @@ export const KINDS = {
|
|
|
13
22
|
};
|
|
14
23
|
/** Cloudflare Workers static assets: one file may be at most 25 MiB. */
|
|
15
24
|
export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Over this many bytes a public file of a published entry is kept in the studio's R2 once it has storage (the
|
|
27
|
+
* default for studio.json `media.r2Over`; `false` there keeps every file on the site unless `media move` names it).
|
|
28
|
+
*/
|
|
29
|
+
export const R2_OVER = 1024 * 1024;
|
|
30
|
+
/** Wrangler uploads one object of at most 300 MiB (`wrangler r2 object put`). */
|
|
31
|
+
export const MAX_PUT_BYTES = 300 * 1024 * 1024;
|
|
32
|
+
/** What R2 costs, said the same way everywhere (Cloudflare's R2 pricing page, read 2026-10-01). */
|
|
33
|
+
export const R2_COST = 'R2 is on the studio\'s own Cloudflare account. Serving its files costs nothing in bandwidth (R2 has no egress fees). Storage is free up to 10 GB-month, then US$0.015 per GB-month; uploads (Class A) are free up to 1 million a month and reads (Class B) up to 10 million a month. Cloudflare asks for a payment method on the account before R2 works, even inside the free tier.';
|
|
16
34
|
const TYPES = {
|
|
17
35
|
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
|
|
18
36
|
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
|
|
@@ -40,15 +58,64 @@ function inside(root, rel) {
|
|
|
40
58
|
const abs = resolve(root, normalize(rel));
|
|
41
59
|
return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
|
|
42
60
|
}
|
|
61
|
+
/** A file's address on the site: its path in the studio, each part encoded (the same before and after it moves to R2). */
|
|
62
|
+
const siteUrlOf = (root, abs) => `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`;
|
|
63
|
+
const relOf = (root, abs) => relative(root, abs).split(sep).join('/');
|
|
64
|
+
const fileOf = (abs) => { try { const st = statSync(abs); return st.isFile() ? st.size : null; } catch { return null; } };
|
|
65
|
+
|
|
66
|
+
/** studio.json `media.r2Over`: bytes over which a public file goes to R2, or null (`false`: only `media move <file>`). */
|
|
67
|
+
export function r2OverOf(studio) {
|
|
68
|
+
const v = studio?.media?.r2Over;
|
|
69
|
+
if (v === false || v === 'off') return null;
|
|
70
|
+
return Number.isFinite(v) && v >= 0 ? v : R2_OVER;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sizes for people: "45.4 MB", "1.20 GB". */
|
|
74
|
+
export const sizeOf = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`);
|
|
75
|
+
/** A size line as it was set: "1 MiB" for 1048576, else as sizeOf says it. */
|
|
76
|
+
export const lineOf = (n) => (n > 0 && n % 1048576 === 0 ? `${n / 1048576} MiB` : n > 0 && n % 1024 === 0 && n < 1048576 ? `${n / 1024} KiB` : sizeOf(n));
|
|
77
|
+
|
|
78
|
+
const hashes = new Map();
|
|
79
|
+
/** SHA-256 of a file, read in 8 MiB pieces; remembered per path, size and modified time for the life of the command. */
|
|
80
|
+
export function sha256File(abs) {
|
|
81
|
+
const st = statSync(abs);
|
|
82
|
+
const memo = `${abs}\0${st.size}\0${st.mtimeMs}`;
|
|
83
|
+
if (hashes.has(memo)) return hashes.get(memo);
|
|
84
|
+
const h = createHash('sha256');
|
|
85
|
+
const fd = openSync(abs, 'r');
|
|
86
|
+
const buf = Buffer.allocUnsafe(8 << 20);
|
|
87
|
+
try {
|
|
88
|
+
for (;;) { const n = readSync(fd, buf, 0, buf.length, null); if (!n) break; h.update(buf.subarray(0, n)); }
|
|
89
|
+
} finally { closeSync(fd); }
|
|
90
|
+
const hex = h.digest('hex');
|
|
91
|
+
hashes.set(memo, hex);
|
|
92
|
+
return hex;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The R2 record `media move` wrote on a manifest file (its key and the SHA-256 it read back from R2), or null. */
|
|
96
|
+
export function heldOf(f) {
|
|
97
|
+
const r = f?.r2;
|
|
98
|
+
return r && typeof r === 'object' && typeof r.key === 'string' && r.key && !r.key.startsWith('players/') && /^[0-9a-f]{64}$/.test(String(r.sha256 ?? '')) ? r : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether R2 has exactly this file: the size and the SHA-256 that `media move` read back from it. */
|
|
102
|
+
export function sameAsHeld(abs, held) {
|
|
103
|
+
const bytes = fileOf(abs);
|
|
104
|
+
return bytes !== null && held && Number(held.bytes) === bytes && sha256File(abs) === held.sha256;
|
|
105
|
+
}
|
|
43
106
|
|
|
44
107
|
/**
|
|
45
108
|
* Every entry of one manifest with its files resolved, and why anything is left out.
|
|
46
|
-
* `r2`: the studio has its own bucket (so a `key` is served at /media/<key
|
|
109
|
+
* `r2`: the studio has its own bucket (so a `key` is served at /media/<key>, and a file `media move` put there at its
|
|
110
|
+
* own address). `deploy`: a build that goes live (`homie-studio deploy`, or Workers Builds): a file whose exact bytes
|
|
111
|
+
* are in R2 is not copied into the site. Without it (a local `build`, `dev`), a file that is also on this computer is
|
|
112
|
+
* still copied when it fits, because `wrangler dev`'s own R2 is empty.
|
|
47
113
|
*/
|
|
48
|
-
export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
114
|
+
export function resolveMedia(root, kind, { r2 = false, deploy = false } = {}) {
|
|
49
115
|
const manifest = readManifest(root, kind);
|
|
50
116
|
const entries = [];
|
|
51
117
|
const skipped = [];
|
|
118
|
+
const notes = [];
|
|
52
119
|
const seen = new Set();
|
|
53
120
|
// The manifest's own picture (music: `cover`, videos: `poster`): an album cover for every song that has none of
|
|
54
121
|
// its own. A path in the studio (copied into the site) or an https:// address, like any file.
|
|
@@ -68,20 +135,45 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
68
135
|
for (const f of list) {
|
|
69
136
|
if (!f || f.public === false) continue;
|
|
70
137
|
let src = null;
|
|
138
|
+
// A key from `media put` (before 0.18.0): served at /media/<key>, as it always was.
|
|
71
139
|
if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
|
|
140
|
+
// A file `media move` put in R2: served at its own address, from R2 once the site no longer carries it.
|
|
141
|
+
const held = heldOf(f);
|
|
142
|
+
if (!src && held && r2 && f.path) {
|
|
143
|
+
const abs = inside(root, f.path);
|
|
144
|
+
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
145
|
+
const url = siteUrlOf(root, abs);
|
|
146
|
+
const local = fileOf(abs);
|
|
147
|
+
const fromR2 = { from: 'r2', url, key: held.key, bytes: Number(held.bytes) || null };
|
|
148
|
+
if (local === null) src = fromR2;
|
|
149
|
+
else if (!deploy) src = local <= MAX_ASSET_BYTES ? { from: 'site', url, abs, rel: relative(root, abs), bytes: local } : fromR2;
|
|
150
|
+
else if (sameAsHeld(abs, held)) src = { ...fromR2, bytes: local };
|
|
151
|
+
else if (local <= MAX_ASSET_BYTES) {
|
|
152
|
+
src = { from: 'site', url, abs, rel: relative(root, abs), bytes: local };
|
|
153
|
+
notes.push({ kind, item: item.slug, file: f.path, why: 'changed since it went to R2: the site carries this copy until `homie-studio media move` uploads it again (deploy does it when the studio has storage)' });
|
|
154
|
+
} else {
|
|
155
|
+
src = fromR2;
|
|
156
|
+
notes.push({ kind, item: item.slug, file: f.path, why: `changed since it went to R2 (${held.at ?? 'earlier'}), and at ${sizeOf(local)} it is too big for the site: the page plays the copy in R2 until \`homie-studio media move ${f.path}\` uploads this one` });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
72
159
|
if (!src && f.path) {
|
|
73
160
|
const abs = inside(root, f.path);
|
|
74
161
|
if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url:
|
|
78
|
-
else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself:
|
|
79
|
-
} else if (f.key && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its
|
|
80
|
-
else if (!f.key && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2
|
|
162
|
+
const bytes = fileOf(abs);
|
|
163
|
+
if (bytes !== null) {
|
|
164
|
+
if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: siteUrlOf(root, abs), abs, rel: relative(root, abs), bytes };
|
|
165
|
+
else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself: give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and the next deploy moves it to R2 (or homie-studio media move ${f.path} now), or make it smaller` });
|
|
166
|
+
} else if ((f.key || held) && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its copy in R2 ("${f.key ?? held.key}") needs the studio's storage (homie-studio storage add)` });
|
|
167
|
+
else if (!f.key && !held && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2 copy or url' });
|
|
81
168
|
}
|
|
82
169
|
if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
|
|
83
170
|
if (!src) continue;
|
|
84
|
-
files.push({
|
|
171
|
+
files.push({
|
|
172
|
+
role: String(f.role ?? 'file'), name: f.name ?? null, type: f.type ?? typeOf(f.path ?? f.key ?? f.url), bytes: f.bytes ?? src.bytes ?? null, bars: f.bars ?? null, url: src.url, from: src.from,
|
|
173
|
+
// The Worker serves this address from R2 (the site does not carry it): the catalogue says which key.
|
|
174
|
+
...(src.from === 'r2' && src.key ? { r2: src.key } : {}),
|
|
175
|
+
...(src.abs ? { abs: src.abs, rel: src.rel } : {}),
|
|
176
|
+
});
|
|
85
177
|
}
|
|
86
178
|
const main = kind === 'music' ? 'audio' : 'video';
|
|
87
179
|
if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
|
|
@@ -94,27 +186,104 @@ export function resolveMedia(root, kind, { r2 = false } = {}) {
|
|
|
94
186
|
files,
|
|
95
187
|
});
|
|
96
188
|
}
|
|
97
|
-
return { entries, skipped };
|
|
189
|
+
return { entries, skipped, notes };
|
|
98
190
|
}
|
|
99
191
|
|
|
100
192
|
/** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
|
|
101
|
-
export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
|
|
193
|
+
export function buildMedia(root, dist, { r2 = false, deploy = false, log = () => {} } = {}) {
|
|
102
194
|
const out = {};
|
|
103
195
|
const skipped = [];
|
|
196
|
+
const notes = [];
|
|
197
|
+
let inR2 = 0;
|
|
104
198
|
for (const kind of ['music', 'videos']) {
|
|
105
|
-
const r = resolveMedia(root, kind, { r2 });
|
|
199
|
+
const r = resolveMedia(root, kind, { r2, deploy });
|
|
106
200
|
skipped.push(...r.skipped);
|
|
201
|
+
notes.push(...r.notes);
|
|
107
202
|
out[kind] = r.entries.map((e) => ({
|
|
108
203
|
...e,
|
|
109
204
|
files: e.files.map(({ abs, rel, from, ...f }) => {
|
|
110
205
|
if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
|
|
206
|
+
if (from === 'r2' && f.r2) inR2++;
|
|
111
207
|
return f;
|
|
112
208
|
}),
|
|
113
209
|
}));
|
|
114
210
|
if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
|
|
115
211
|
}
|
|
212
|
+
if (inR2) log(`${inR2} file(s) served from the studio's R2 at their own addresses (not in the site's files)`);
|
|
116
213
|
for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
|
|
117
|
-
|
|
214
|
+
for (const s of notes) log(`note: ${s.kind}/${s.item} ${s.file}: ${s.why}`);
|
|
215
|
+
return { songs: out.music, videos: out.videos, skipped, notes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The files git leaves out of the repository (its .gitignore): a deploy from another computer, or Workers Builds, would not have them. */
|
|
219
|
+
export function gitIgnored(root, rels) {
|
|
220
|
+
if (!rels.length) return new Set();
|
|
221
|
+
const r = spawnSync('git', ['check-ignore', '--stdin'], { cwd: root, input: `${rels.join('\n')}\n`, encoding: 'utf8' });
|
|
222
|
+
if (r.status !== 0 && r.status !== 1) return new Set();
|
|
223
|
+
return new Set(String(r.stdout ?? '').split('\n').map((l) => l.trim()).filter(Boolean));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What `media move` would do, file by file: every public file of a published song or video (or exactly the files
|
|
228
|
+
* named), with its state:
|
|
229
|
+
* move it goes to R2 (over the size, git leaves it out, or it was named; or it changed since it went there)
|
|
230
|
+
* r2 R2 already has these exact bytes (size and SHA-256 match the record)
|
|
231
|
+
* site it stays on the site (small enough, and in the repository)
|
|
232
|
+
* media it has a /media/<key> address from `media put` (left as it is)
|
|
233
|
+
* too-big over the 300 MiB one Wrangler upload takes
|
|
234
|
+
* missing not on this computer
|
|
235
|
+
* unlisted named, but in no manifest
|
|
236
|
+
*/
|
|
237
|
+
export function mediaPlan(root, { over = R2_OVER, paths = null } = {}) {
|
|
238
|
+
const want = paths ? new Set(paths.map((p) => { const abs = inside(root, relative(root, resolve(String(p)))); return abs ? relOf(root, abs) : String(p); })) : null;
|
|
239
|
+
const found = [];
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const kind of ['music', 'videos']) {
|
|
242
|
+
for (const item of readManifest(root, kind).items) {
|
|
243
|
+
if (!item || typeof item !== 'object') continue;
|
|
244
|
+
for (const f of Array.isArray(item.files) ? item.files : []) {
|
|
245
|
+
if (!f || f.public === false || typeof f.path !== 'string') continue;
|
|
246
|
+
const abs = inside(root, f.path);
|
|
247
|
+
if (!abs) continue;
|
|
248
|
+
const rel = relOf(root, abs);
|
|
249
|
+
if (seen.has(rel) || (want ? !want.has(rel) : item.published !== true)) continue;
|
|
250
|
+
seen.add(rel);
|
|
251
|
+
found.push({ kind, slug: item.slug ?? null, role: String(f.role ?? 'file'), path: rel, abs, type: f.type ?? typeOf(rel), f });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const ignored = gitIgnored(root, found.map((r) => r.path));
|
|
256
|
+
const rows = found.map(({ f, ...row }) => {
|
|
257
|
+
const held = heldOf(f);
|
|
258
|
+
if (typeof f.key === 'string' && f.key) return { ...row, state: 'media', why: `in R2 at /media/${f.key} since \`media put\`; left as it is` };
|
|
259
|
+
if (row.path.startsWith('players/')) return { ...row, state: 'site', why: 'players/ is the players\' own part of the bucket' };
|
|
260
|
+
const bytes = fileOf(row.abs);
|
|
261
|
+
if (bytes === null) return { ...row, state: held ? 'r2' : 'missing', bytes: held ? Number(held.bytes) : null, ...(held ? { held } : { why: 'not on this computer' }) };
|
|
262
|
+
const reason = want ? 'named' : over !== null && bytes > over ? `over ${lineOf(over)}` : ignored.has(row.path) ? 'git leaves it out of the repository' : null;
|
|
263
|
+
if (held && sameAsHeld(row.abs, held)) return { ...row, bytes, state: 'r2', held };
|
|
264
|
+
if (!reason && !held) return { ...row, bytes, state: 'site' };
|
|
265
|
+
if (bytes > MAX_PUT_BYTES) return { ...row, bytes, state: 'too-big', why: `${sizeOf(bytes)} is over the 300 MiB one Wrangler upload takes: make a smaller delivery (the video skill's cuts are far smaller), or upload it with an S3 tool and record it by hand` };
|
|
266
|
+
return { ...row, bytes, state: 'move', reason: held ? 'changed since it went to R2' : reason, ...(held ? { held } : {}) };
|
|
267
|
+
});
|
|
268
|
+
if (want) for (const p of want) if (!seen.has(p)) rows.push({ path: p, state: 'unlisted', why: 'not a public file of any entry in music/manifest.json or videos/manifest.json: add it to an entry first' });
|
|
269
|
+
return rows;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** After R2 has the file and its SHA-256 matched: record it on every manifest file with that path. */
|
|
273
|
+
export function recordMove(root, rel, { key, sha256, bytes, at = new Date().toISOString() }) {
|
|
274
|
+
const touched = [];
|
|
275
|
+
for (const kind of ['music', 'videos']) {
|
|
276
|
+
const m = readManifest(root, kind);
|
|
277
|
+
let hit = false;
|
|
278
|
+
for (const item of m.items) {
|
|
279
|
+
for (const f of Array.isArray(item?.files) ? item.files : []) {
|
|
280
|
+
const abs = typeof f?.path === 'string' ? inside(root, f.path) : null;
|
|
281
|
+
if (abs && relOf(root, abs) === rel) { f.bytes = bytes; f.r2 = { key, sha256, bytes, at }; hit = true; }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (hit) { writeManifest(root, kind, m); touched.push(relative(root, manifestPath(root, kind))); }
|
|
285
|
+
}
|
|
286
|
+
return touched;
|
|
118
287
|
}
|
|
119
288
|
|
|
120
289
|
/** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
|
package/lib/office.mjs
CHANGED
|
@@ -59,7 +59,7 @@ function signinTo(root, url, to) {
|
|
|
59
59
|
|
|
60
60
|
export const askedFor = (root, url, r, command) => {
|
|
61
61
|
if (!r.ok) return { ok: false, command, why: r.message ?? r.why ?? `the studio said ${r.error ?? r.status}` };
|
|
62
|
-
if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: r.what ? `Done: ${r.what}` : 'Done.' };
|
|
62
|
+
if (r.needs !== 'owner' || !r.ask) return { ok: true, command, done: true, result: r, message: `${r.what ? `Done: ${r.what}` : 'Done.'}${r.note ? ` ${r.note}` : ''}` };
|
|
63
63
|
const link = signinTo(root, url, `/_studio/confirm/${r.ask.id}`);
|
|
64
64
|
return {
|
|
65
65
|
ok: true, command, asked: true, ask: r.ask, what: r.ask.what,
|