@homie-rocks/studio 0.36.0 → 0.37.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -2
  3. package/app-starters/welcome/app.json +106 -0
  4. package/app-starters/welcome/index.html +4 -0
  5. package/app-starters/welcome/src/main.ts +107 -0
  6. package/apps/APPS.md +61 -0
  7. package/apps/apps.ts +28 -0
  8. package/bin/homie-studio.mjs +19 -4
  9. package/lib/art-cli.mjs +2 -2
  10. package/lib/art-tools.mjs +6 -6
  11. package/lib/asset-check.mjs +3 -2
  12. package/lib/asset-import.mjs +3 -2
  13. package/lib/asset-manifest.mjs +8 -8
  14. package/lib/build.mjs +6 -3
  15. package/lib/characters.mjs +7 -7
  16. package/lib/check.mjs +49 -0
  17. package/lib/cloudflare.mjs +13 -4
  18. package/lib/codex.mjs +2 -2
  19. package/lib/collision.mjs +1 -1
  20. package/lib/decisions.mjs +7 -7
  21. package/lib/dev.mjs +26 -9
  22. package/lib/directory.mjs +1 -1
  23. package/lib/doctor.mjs +8 -8
  24. package/lib/domain.mjs +43 -0
  25. package/lib/handoff.mjs +1 -1
  26. package/lib/jobs.mjs +17 -7
  27. package/lib/lab-check.mjs +1 -1
  28. package/lib/lab.mjs +8 -8
  29. package/lib/library.mjs +2 -2
  30. package/lib/mcp-tools.mjs +71 -21
  31. package/lib/mcp.mjs +6 -11
  32. package/lib/parts-build.mjs +4 -3
  33. package/lib/parts-cli.mjs +3 -3
  34. package/lib/parts-store.mjs +8 -7
  35. package/lib/parts-tools.mjs +7 -7
  36. package/lib/parts.mjs +19 -14
  37. package/lib/perf.mjs +2 -1
  38. package/lib/player-image.mjs +2 -2
  39. package/lib/prepare.mjs +47 -0
  40. package/lib/scaffold.mjs +31 -2
  41. package/lib/shop.mjs +1 -1
  42. package/lib/standalone-files.mjs +2 -2
  43. package/lib/standalone.mjs +7 -2
  44. package/lib/stats.mjs +2 -1
  45. package/lib/studio.mjs +32 -6
  46. package/lib/style-board.mjs +10 -9
  47. package/lib/template-history.json +82 -0
  48. package/links/links.d.ts +3 -0
  49. package/links/links.mjs +16 -0
  50. package/package.json +14 -3
  51. package/parts/PARTS.md +10 -5
  52. package/shop/SHOP.md +13 -0
  53. package/site/SITE.md +1 -1
  54. package/worker/account-page.mjs +4 -3
  55. package/worker/app-format.mjs +42 -0
  56. package/worker/app-records.mjs +100 -0
  57. package/worker/app-shell.mjs +29 -0
  58. package/worker/embed.mjs +4 -2
  59. package/worker/index.mjs +33 -9
  60. package/worker/pages.mjs +33 -27
  61. package/worker/parts.mjs +11 -9
  62. package/worker/schema.mjs +6 -5
  63. package/worker/seats.mjs +2 -2
  64. package/worker/shop-page.mjs +4 -3
  65. package/worker/site.mjs +49 -40
  66. package/worker/standalone.mjs +2 -2
  67. package/worker/version.mjs +1 -1
@@ -22,7 +22,7 @@ import { createHash } from 'node:crypto';
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
23
23
  import { dirname, join, relative } from 'node:path';
24
24
  import { pinByUse, readDecisions, revisionsFor } from './decisions.mjs';
25
- import { GAME_ID } from './studio.mjs';
25
+ import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
26
26
 
27
27
  export const MANIFEST_FILE = join('assets', 'manifest.json');
28
28
  export const RIGHTS_FILE = join('assets', 'RIGHTS.md');
@@ -81,7 +81,7 @@ export function underFor(kind, route) {
81
81
  return [...(UNDER[kind] ?? UNDER.prop), ...(route === 'library' ? ['cast.family'] : []), ...(route === 'generated' ? ['derived.prompt'] : [])];
82
82
  }
83
83
 
84
- const fileOf = (root, id) => join(root, 'games', id, MANIFEST_FILE);
84
+ const fileOf = (root, id) => join(experienceDir(root, id), MANIFEST_FILE);
85
85
  const now = () => new Date().toISOString();
86
86
  const clean = (s, n = 300) => (typeof s === 'string' ? s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : null);
87
87
 
@@ -167,7 +167,7 @@ export function removeAsset(root, id, assetId) {
167
167
  writeManifest(root, id, manifest);
168
168
  writeRights(root, id, manifest);
169
169
  syncCredits(root, id, manifest);
170
- const gdir = join(root, 'games', id);
170
+ const gdir = join(experienceDir(root, id));
171
171
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
172
172
  const deleted = []; const kept = [];
173
173
  for (const f of gone.files ?? []) {
@@ -216,7 +216,7 @@ export function licenceProblems(root, id, manifest = readManifest(root, id)) {
216
216
 
217
217
  /** Model files a game ships (.glb, .gltf, .fbx, .obj, .vrm under games/<id>/) that no manifest entry names. */
218
218
  export function unrecordedModels(root, id, manifest = readManifest(root, id)) {
219
- const dir = join(root, 'games', id);
219
+ const dir = join(experienceDir(root, id));
220
220
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
221
221
  const out = [];
222
222
  const walk = (rel, depth) => {
@@ -245,7 +245,7 @@ const PICTURE = /\.(png|jpe?g|webp|avif|gif|hdr|exr|ktx2)$/i;
245
245
  * The codex (private, the style board's own swatches) is not walked at all.
246
246
  */
247
247
  export function unrecordedPictures(root, id, manifest = readManifest(root, id), { cover = null } = {}) {
248
- const dir = join(root, 'games', id);
248
+ const dir = join(experienceDir(root, id));
249
249
  const named = new Set(manifest.assets.flatMap((a) => (a.files ?? []).map((f) => f.path)));
250
250
  const coverPaths = new Set(cover ? [String(cover).replace(/^\.\//, ''), `public/${String(cover).replace(/^\.\//, '')}`] : []);
251
251
  const shipped = []; const notShipped = [];
@@ -296,7 +296,7 @@ function routeWords(a) {
296
296
  /** RIGHTS.md for one game, in plain words. */
297
297
  export function rightsMarkdown(root, id, manifest = readManifest(root, id)) {
298
298
  let game = {};
299
- try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
299
+ try { game = JSON.parse(readFileSync(experienceFile(root, id), 'utf8')); } catch { game = {}; }
300
300
  const lines = [
301
301
  `# Rights: ${game.name ?? id}`,
302
302
  '',
@@ -333,7 +333,7 @@ function termsApply(t, a) {
333
333
  }
334
334
 
335
335
  export function writeRights(root, id, manifest = readManifest(root, id)) {
336
- const file = join(root, 'games', id, RIGHTS_FILE);
336
+ const file = join(experienceDir(root, id), RIGHTS_FILE);
337
337
  mkdirSync(dirname(file), { recursive: true });
338
338
  const text = rightsMarkdown(root, id, manifest);
339
339
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
@@ -347,7 +347,7 @@ export function writeRights(root, id, manifest = readManifest(root, id)) {
347
347
  * and one per attribution-required file. Lines this function wrote carry `"asset": true`; every other part stays.
348
348
  */
349
349
  export function syncCredits(root, id, manifest = readManifest(root, id)) {
350
- const file = join(root, 'games', id, 'credits.json');
350
+ const file = join(experienceDir(root, id), 'credits.json');
351
351
  let credits = {};
352
352
  try { credits = JSON.parse(readFileSync(file, 'utf8')); } catch { credits = {}; }
353
353
  const before = Array.isArray(credits.parts) ? credits.parts : [];
package/lib/build.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { appProblems } from '../worker/app-format.mjs';
1
2
  /**
2
3
  * `homie-studio build` — every game in games/ bundled into site/dist, plus the
3
4
  * catalogue the Worker serves (games.json).
@@ -55,7 +56,7 @@ import { buildMedia } from './media.mjs';
55
56
  import { buildSiteFiles, isoDate, landingOf, readPosts, readTheme } from './site.mjs';
56
57
  import { checkJsonLd } from './schema-check.mjs';
57
58
  import { SCHEMA_REFUSED } from '../worker/schema.mjs';
58
- import { PACKAGE_ROOT, isRulesGame, listGames, readStudio } from './studio.mjs';
59
+ import { PACKAGE_ROOT, isRulesGame, listExperiences as listGames, readStudio } from './studio.mjs';
59
60
 
60
61
  // The rules build brings a JavaScript parser with it. It is loaded when a build first needs it, never when this
61
62
  // module is: Homie for Claude Desktop starts the toolkit with no node_modules beside it (scripts/desktop.mjs).
@@ -78,7 +79,7 @@ import { buildParts, partsPlugin } from './parts-build.mjs';
78
79
  // RULES ON THE SERVER (NETPLAY.md section 29): a game declaring a room object and src/rules.ts builds as a view bundle plus a rules module.
79
80
 
80
81
  /** Never copied into a static game's served folder. */
81
- const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
82
+ const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'app.json', 'PORT.md', 'CODEX.md', 'lab.json', 'codex']);
82
83
  const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
83
84
 
84
85
  /** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
@@ -269,7 +270,7 @@ export function netplayOf(g, out = null) {
269
270
  */
270
271
  export function seatsFor(g, net = netplayOf(g)) {
271
272
  const named = [net.maxPlayers, net.players?.max, g.players?.max].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
272
- const asked = named ?? 8;
273
+ const asked = named ?? (g.kind === 'app' ? 32 : 8);
273
274
  const max = Math.min(SEAT_MAX, asked);
274
275
  const minNamed = [net.minPlayers, net.players?.min, g.players?.min].map((n) => Math.floor(Number(n))).find((n) => Number.isFinite(n) && n >= 1);
275
276
  return { min: Math.min(max, minNamed ?? 1), max, asked };
@@ -495,6 +496,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
495
496
  const live = join(root, 'site', 'dist');
496
497
  const games = listGames(root).filter((g) => !only || g.id === only);
497
498
  if (only && !games.length) throw new Error(`no game "${only}" in games/`);
499
+ for (const g of games) if (g.kind === 'app') { const bad = appProblems(g); if (bad.length) throw new Error(`apps/${g.id}/app.json: ${bad.join('; ')}`); }
498
500
  // `--types`: the games' TypeScript is checked first (esbuild only strips types, it never reads them), and a type
499
501
  // error stops the build before anything is built.
500
502
  const typed = types ? typecheck(root, games, { log }) : null;
@@ -601,6 +603,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
601
603
  const bundle = bundleOf(dir);
602
604
  builds[g.id] = { hash: gameDigest(dir), ...(bundle ? { bundle } : {}) };
603
605
  return {
606
+ ...(g.kind === 'app' ? { kind: 'app', roles: g.roles, surfaces: g.surfaces, words: g.words, records: g.records, parts: g.parts ?? [], check: g.check } : {}),
604
607
  id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
605
608
  ...(ui ? { ui } : {}),
606
609
  // A rules game's round is its rules' own (`room.rounds`), and its movement a per-kind choice there: game.json's
@@ -28,7 +28,7 @@ import { readManifest, recordAsset, writeManifest } from './asset-manifest.mjs';
28
28
  import { VERBS, VERB_IDS, bakeLibrary, findClip, gaitOf, rigOf, writeLibrary } from './clips.mjs';
29
29
  import { readDecisions } from './decisions.mjs';
30
30
  import { FAMILIES, normaliseRig, skeletonOf, socketBone } from './rig.mjs';
31
- import { GAME_ID } from './studio.mjs';
31
+ import { experienceDir, experienceFile, GAME_ID } from './studio.mjs';
32
32
 
33
33
  /** Where humanoid clips come from when a character has none of its own: KayKit's adventurers (CC0, 76 clips). */
34
34
  export const CLIP_SOURCES = Object.freeze({
@@ -146,7 +146,7 @@ function appendClips(into, from) {
146
146
  * `own`: its own clips (a clip source) or null. Returns { path, skeleton, verbs, clips, missing, bytes, entry }.
147
147
  */
148
148
  async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, cache = new Map(), license = null } = {}) {
149
- const gdir = join(root, 'games', game);
149
+ const gdir = join(experienceDir(root, game));
150
150
  const skel = rig.skeleton;
151
151
  const fallback = from ?? CLIP_SOURCES[skel.family] ?? null;
152
152
  const existing = await existingLibrary(gdir, skel.id);
@@ -206,7 +206,7 @@ async function bakeFor(root, game, rig, own, verbs, { from = null, lib = null, c
206
206
  */
207
207
  export async function addCharacter(root, game, opts = {}) {
208
208
  if (!GAME_ID.test(String(game ?? ''))) throw new Error(`"${game}" is not a game id`);
209
- const gdir = join(root, 'games', game);
209
+ const gdir = join(experienceDir(root, game));
210
210
  if (!existsSync(join(gdir, 'game.json')) && !existsSync(join(gdir, 'CODEX.md'))) throw new Error(`no game "${game}"`);
211
211
  const t = await tools();
212
212
  const { optimiseModel, measureDoc } = await import('./optimise.mjs');
@@ -297,7 +297,7 @@ export async function bakeClips(root, game, asset, { verbs = [], from = null, li
297
297
  if (!a?.rig) throw new Error(`${asset} is not a rigged character in ${game} (assets add brings one in)`);
298
298
  const t = await tools();
299
299
  const model = a.files.find((f) => f.role === 'model');
300
- const doc = await t.io.readBinary(readFileSync(join(root, 'games', game, model.path)));
300
+ const doc = await t.io.readBinary(readFileSync(join(experienceDir(root, game), model.path)));
301
301
  doc.setLogger(t.logger);
302
302
  const rig = rigOf(doc);
303
303
  if (rig.skeleton.id !== a.rig.skeleton) rig.skeleton.id = a.rig.skeleton;
@@ -368,7 +368,7 @@ export async function measureGait(root, game, assetId) {
368
368
  const a = m.assets.find((x) => x.id === assetId);
369
369
  const model = (a?.files ?? []).find((f) => f.role === 'model');
370
370
  if (!a?.rig || !model) return null;
371
- const gdir = join(root, 'games', game);
371
+ const gdir = join(experienceDir(root, game));
372
372
  const t = await tools();
373
373
  const read = async (rel) => { const d = await t.io.readBinary(readFileSync(join(gdir, rel))); d.setLogger(t.logger); return d; };
374
374
  const rig = rigOf(await read(model.path));
@@ -420,7 +420,7 @@ export function animPlan(root, game) {
420
420
  return {
421
421
  ok: true, command: 'anim plan', game, verbs: need, rows, unrigged,
422
422
  decisions: Object.fromEntries(['anim.style', 'anim.clips', 'anim.source', 'anim.motion', 'anim.blend', 'anim.procedural', 'rig.skeleton', 'rig.source', 'rig.bones', 'game.feel'].map((k) => [k, d[k] ? { label: d[k].label, state: d[k].state } : null])),
423
- lab: existsSync(join(root, 'games', game, 'lab.json')) ? JSON.parse(readFileSync(join(root, 'games', game, 'lab.json'), 'utf8')) : null,
423
+ lab: existsSync(join(experienceDir(root, game), 'lab.json')) ? JSON.parse(readFileSync(join(experienceDir(root, game), 'lab.json'), 'utf8')) : null,
424
424
  };
425
425
  }
426
426
 
@@ -434,7 +434,7 @@ export async function animPreview(root, game, { asset = null, verbs = null, size
434
434
  const rows = plan.rows.filter((r) => !asset || r.id === asset);
435
435
  if (!rows.length) return { ok: false, command: 'anim preview', game, why: asset ? `${asset} is not a rigged character in ${game}` : `${game} has no rigged characters yet (assets add <game> <character item>)` };
436
436
  const m = readManifest(root, game);
437
- const gdir = join(root, 'games', game);
437
+ const gdir = join(experienceDir(root, game));
438
438
  let tokens = null;
439
439
  try { tokens = JSON.parse(readFileSync(join(gdir, 'style.json'), 'utf8')); } catch { tokens = null; }
440
440
  tokens = tokens?.palette ? tokens : { palette: { bg: '#e9eef2', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a' } };
package/lib/check.mjs CHANGED
@@ -141,6 +141,9 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
141
141
  // it either, that refusal (for a lookup: BLOCKED, this computer's network, local testing offered) is the report.
142
142
  const cannotAsk = siteRefusal(await reachSite(base, { path: `/${game}/play`, timeout: 15_000 }), { command: 'check', play });
143
143
  if (cannotAsk?.preflight === 'local') return cannotAsk;
144
+ let appMeta = null;
145
+ try { appMeta = (await (await fetch(`${base}/api/games`)).json()).games?.find((g) => g.id === game && g.kind === 'app'); } catch { /* browser preflight below still names failures */ }
146
+ if (appMeta) return checkApp({ url: base, game, meta: appMeta, shots, log, report, puppeteer, chrome });
144
147
  if (shots) mkdirSync(shots, { recursive: true });
145
148
  const profiles = [];
146
149
  const browsers = [];
@@ -333,3 +336,49 @@ export async function check({ url, game, roundTimeoutMs = 150_000, rounds = 3, s
333
336
  for (const p of profiles) rmSync(p, { recursive: true, force: true });
334
337
  }
335
338
  }
339
+
340
+ /** Apps prove a real action crossing surfaces and a reconnect, without inventing a round. */
341
+ export async function checkApp({ url, game, meta, shots = null, log = () => {}, report = null, puppeteer, chrome }) {
342
+ const proof = meta.check;
343
+ if (!proof || typeof proof.action !== 'string' || typeof proof.observe !== 'string') return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
344
+ if (shots) mkdirSync(shots, { recursive: true });
345
+ const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
346
+ let browser;
347
+ const surfaces = [];
348
+ const room = `check-${Date.now().toString(36)}`;
349
+ try {
350
+ browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
351
+ for (const [name, width, height, path] of [['wall', 1280, 720, 'tv'], ['phone', 390, 844, 'open'], ['phone-two', 390, 844, 'open']]) {
352
+ const context = await browser.createBrowserContext();
353
+ const page = await context.newPage();
354
+ await page.setViewport({ width, height, isMobile: name !== 'wall', hasTouch: name !== 'wall' });
355
+ const params = new URLSearchParams(proof.params ?? {}); params.set('room', room);
356
+ await page.goto(`${url}/${game}/${path}?${params}`, { waitUntil: 'domcontentloaded', timeout: 90_000 });
357
+ await page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
358
+ const frame = page.frames().find((f) => /\/__game\//.test(f.url()));
359
+ if (!frame) throw new Error(`${name} never loaded the app`);
360
+ await frame.waitForSelector(proof.observe, { timeout: 30_000 });
361
+ surfaces.push({ name, page, frame });
362
+ log(`${name} connected to ${room}`);
363
+ }
364
+ const textOf = (f) => f.$eval(proof.observe, (e) => e.textContent);
365
+ const actor = surfaces[1];
366
+ await actor.frame.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action);
367
+ const before = await textOf(actor.frame);
368
+ await actor.frame.click(proof.action);
369
+ await actor.frame.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before);
370
+ const after = await textOf(actor.frame);
371
+ for (const s of surfaces) await s.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
372
+ // The actor reloads with its ticket and room. The other screens remain live.
373
+ await actor.page.reload({ waitUntil: 'domcontentloaded' });
374
+ await actor.page.waitForFunction(() => window.__shell?.link?.state === 'online', { timeout: 90_000 });
375
+ actor.frame = actor.page.frames().find((f) => /\/__game\//.test(f.url()));
376
+ await actor.frame.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after);
377
+ const rooms = await Promise.all(surfaces.map((s) => s.page.evaluate(() => window.__shell.room)));
378
+ if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
379
+ if (shots) for (const s of surfaces) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 65 });
380
+ report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
381
+ return { ok: true, command: 'check', kind: 'app', room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: true };
382
+ } catch (error) { return { ok: false, command: 'check', kind: 'app', why: error.message }; }
383
+ finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
384
+ }
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from 'node:child_process';
31
31
  import { createHash } from 'node:crypto';
32
32
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
+ import { registerWorkersAddress } from './domain.mjs';
34
35
  import { cloudflareAuth } from './brain-probe.mjs';
35
36
  import { build } from './build.mjs';
36
37
  import { builtGames, compareDeploy, deployWords, lastDeploy, lastDeployRecord, lockDeploy, recordDeploy } from './deploy-state.mjs';
@@ -40,7 +41,7 @@ import { whyFailed } from './net.mjs';
40
41
  import { repoOf } from './repo.mjs';
41
42
  import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
42
43
  import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
43
- import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
44
+ import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
44
45
  import { projectsCloudflareEnv } from './projects-env.mjs';
45
46
 
46
47
  const ANSI = /\u001b\[[0-9;]*m/g;
@@ -93,10 +94,10 @@ export function explainCloudflare(out, accountId = null) {
93
94
  const text = String(out ?? '');
94
95
  const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
95
96
  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, then run \`npm run deploy\` again. No payment method is needed.` };
97
+ 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
98
  }
98
99
  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. Ask the person to open ${dash}/workers/onboarding once and pick one (it is free), then run \`npm run deploy\` again.` };
100
+ 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
101
  }
101
102
  if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
102
103
  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.' };
@@ -366,7 +367,15 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
366
367
  }
367
368
 
368
369
  const started = Date.now();
369
- const dep = w(['deploy', ...repoVar(root)]);
370
+ let dep = w(['deploy', ...repoVar(root)]);
371
+ if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
372
+ const headers = await cloudflareAuth({ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
373
+ exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
374
+ const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
375
+ if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
376
+ step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
377
+ dep = w(['deploy', ...repoVar(root)]);
378
+ }
370
379
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
371
380
  remember(`worker:${cf.worker}`);
372
381
  // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
package/lib/codex.mjs CHANGED
@@ -41,7 +41,7 @@ import { extname, join, relative, resolve, sep } from 'node:path';
41
41
  import { latestFeedFor, summarize } from './feed-summary.mjs';
42
42
  import { escapeHtml as esc, renderMarkdown } from './markdown.mjs';
43
43
  import { readTheme } from './site.mjs';
44
- import { GAME_ID, listGames, readStudio } from './studio.mjs';
44
+ import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames, readStudio } from './studio.mjs';
45
45
  import { decisionRows, readDecisions } from './decisions.mjs';
46
46
 
47
47
  export const CODEX_FILE = 'CODEX.md';
@@ -514,7 +514,7 @@ function codexGame(root, id, { create = false } = {}) {
514
514
  const game = listGames(root).find((g) => g.id === id);
515
515
  if (game) return game;
516
516
  if (!GAME_ID.test(String(id ?? ''))) return null;
517
- const dir = join(root, 'games', id);
517
+ const dir = join(experienceDir(root, id));
518
518
  if (existsSync(join(dir, 'game.json'))) return null;
519
519
  return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
520
520
  }
package/lib/collision.mjs CHANGED
@@ -32,7 +32,7 @@ import { createRequire } from 'node:module';
32
32
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { modelTools } from './optimise.mjs';
35
- import { listGames } from './studio.mjs';
35
+ import { listExperiences as listGames } from './studio.mjs';
36
36
 
37
37
  /** The body that must fit when a file names none: a person, in metres. */
38
38
  export const DEFAULT_MOVER = Object.freeze({ radius: 0.35, height: 1.8, step: 0.3, maxDrop: 0.5, arrival: 1, speed: 4 });
package/lib/decisions.mjs CHANGED
@@ -20,7 +20,7 @@ import { dirname, join } from 'node:path';
20
20
  import { HEX, deltaHex, hexToRgb, luminance, mix, nudge } from './colour.mjs';
21
21
  import { CAMERAS, FAMILIES, FONT_SETS, GENRES, LIGHTS, MOODS, PALETTES, PALETTE_KEYS, RENDERS, SHAPES } from './style-presets.mjs';
22
22
  import { readTheme } from './site.mjs';
23
- import { GAME_ID, listGames } from './studio.mjs';
23
+ import { experienceDir, experienceFile, GAME_ID, listExperiences as listGames } from './studio.mjs';
24
24
 
25
25
  export const DECISIONS_FILE = join('codex', 'decisions.json');
26
26
  export const STATES = Object.freeze(['auto', 'steered', 'pinned', 'locked']);
@@ -35,7 +35,7 @@ const clean = (s, n = 200) => String(s ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').
35
35
 
36
36
  /** What the automatic picks read: the person's words, the codex (its concept, art text and cards), the game. */
37
37
  export function contextOf(root, id, { prompt = '' } = {}) {
38
- const dir = join(root, 'games', id);
38
+ const dir = join(experienceDir(root, id));
39
39
  let codex = '';
40
40
  try { codex = readFileSync(join(dir, 'CODEX.md'), 'utf8').replace(/<!--[\s\S]*?-->/g, ''); } catch { codex = ''; }
41
41
  let game = {};
@@ -282,12 +282,12 @@ function castFrom(ctx) {
282
282
 
283
283
  /* ------------------------------------------------------------------ the file */
284
284
 
285
- const fileOf = (root, id) => join(root, 'games', id, DECISIONS_FILE);
285
+ const fileOf = (root, id) => join(experienceDir(root, id), DECISIONS_FILE);
286
286
 
287
287
  function gameDirOf(root, id) {
288
288
  if (!GAME_ID.test(String(id ?? ''))) throw new Error(`"${id}" is not a game id`);
289
- const dir = join(root, 'games', id);
290
- if (!existsSync(join(dir, 'game.json')) && !existsSync(join(dir, 'CODEX.md'))) throw new Error(`no game "${id}" (no games/${id}/game.json or CODEX.md)${listGames(root).length ? `; games: ${listGames(root).map((g) => g.id).join(', ')}` : ''}`);
289
+ const dir = join(experienceDir(root, id));
290
+ if (!existsSync(experienceFile(root, id)) && !existsSync(join(dir, 'CODEX.md'))) throw new Error(`no game "${id}" (no games/${id}/game.json or CODEX.md)${listGames(root).length ? `; games: ${listGames(root).map((g) => g.id).join(', ')}` : ''}`);
291
291
  return dir;
292
292
  }
293
293
 
@@ -332,7 +332,7 @@ export function styleTokens(doc) {
332
332
  function writeStyleTokens(root, id, doc) {
333
333
  const t = styleTokens(doc);
334
334
  if (!t) return;
335
- const file = join(root, 'games', id, 'style.json');
335
+ const file = join(experienceDir(root, id), 'style.json');
336
336
  const text = `${JSON.stringify(t, null, 2)}\n`;
337
337
  if (!existsSync(file) || readFileSync(file, 'utf8') !== text) writeFileSync(file, text);
338
338
  }
@@ -790,7 +790,7 @@ export function pickDirection(root, id, choice, { mixes = {}, manifest = null }
790
790
 
791
791
  /** One dated line under the codex's Latest, newest first ("- 2026-10-02: …"). */
792
792
  export function addLatestLine(root, id, text) {
793
- const file = join(root, 'games', id, 'CODEX.md');
793
+ const file = join(experienceDir(root, id), 'CODEX.md');
794
794
  if (!existsSync(file)) return false;
795
795
  const src = readFileSync(file, 'utf8');
796
796
  const line = `- ${now().slice(0, 10)}: ${clean(text, 300)}`;
package/lib/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { networkInterfaces } from 'node:os';
1
2
  /**
2
3
  * `homie-studio dev` and `dev --stop`: the whole site on this computer (pages, rooms in a local Durable Object, D1),
3
4
  * and exactly this studio's dev server stopped, never another project's.
@@ -32,7 +33,7 @@ import { detectLocalAi, localAiVars } from './local-ai.mjs';
32
33
  import { projectsCloudflareEnv } from './projects-env.mjs';
33
34
  import { routesOf } from './routes.mjs';
34
35
  import { ensureMigrations, migrationWord } from './scaffold.mjs';
35
- import { isRulesGame, listGames, workerDir } from './studio.mjs';
36
+ import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
36
37
 
37
38
  export const devFile = (root) => join(workerDir(root), '.wrangler', 'homie-dev.json');
38
39
 
@@ -289,7 +290,7 @@ function passLines(stream, to, opts) {
289
290
  * saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
290
291
  */
291
292
  export function rulesStamp(g) {
292
- const files = [join(g.dir, 'game.json'), join(g.dir, 'tunables.json')];
293
+ const files = [join(g.dir, g.kind === 'app' ? 'app.json' : 'game.json'), join(g.dir, 'tunables.json')];
293
294
  for (const sub of ['src', 'map']) {
294
295
  const dir = join(g.dir, sub);
295
296
  if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
@@ -306,7 +307,7 @@ const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'si
306
307
  * `homie-studio dev`: build once, then Wrangler's local runtime until it is stopped. Returns the command's result.
307
308
  * options: port, remoteAi, localAi (false: --no-local-ai), timestamps (every line of Wrangler's gets a time), log.
308
309
  */
309
- export async function dev(root, { port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
310
+ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi = false, localAi = true, timestamps = false, log = () => {}, watchMs = 2000 } = {}) {
310
311
  const port = String(askedPort ?? 8787);
311
312
  const stop = 'npx --no-install homie-studio dev --stop';
312
313
 
@@ -344,6 +345,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
344
345
  });
345
346
  const origin = `http://127.0.0.1:${port}`;
346
347
  log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
348
+ if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
347
349
  const ai = devConfig(root, remoteAi);
348
350
  if (ai.note) log(ai.note);
349
351
  // The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
@@ -356,7 +358,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
356
358
  }
357
359
  // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
358
360
  // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
359
- const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
361
+ const args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
360
362
  log(`Stop it with: ${stop} (this studio's dev server only)`);
361
363
 
362
364
  let child = null;
@@ -390,7 +392,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
390
392
  for (const g of listGames(root)) {
391
393
  if (built.includes(g.id) || told.has(g.id)) continue;
392
394
  told.add(g.id);
393
- log(`games/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
395
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is new and not built yet, so ${origin}/${g.id}/play is a 404 for now. Build it (npx --no-install homie-studio build): this dev server serves what build wrote and picks the new game up by itself.`);
394
396
  }
395
397
  for (const id of built) {
396
398
  if (startedWith.has(id)) continue;
@@ -412,17 +414,20 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
412
414
  };
413
415
  // A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
414
416
  const stamps = new Map(listGames(root).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
417
+ const timingRetries = new Map();
415
418
  let rebuilding = false;
416
419
  let published = Promise.resolve();
417
420
  const rulesLook = async () => {
418
421
  if (rebuilding || ending) return;
419
422
  for (const g of listGames(root).filter(isRulesGame)) {
420
423
  const now = rulesStamp(g);
421
- if (stamps.get(g.id) === now) continue;
424
+ const retry = timingRetries.get(g.id);
425
+ if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
426
+ if (retry?.stamp !== now) timingRetries.delete(g.id);
422
427
  stamps.set(g.id, now);
423
428
  rebuilding = true;
424
429
  try {
425
- log(`games/${g.id} changed: checking its rules and building it again…`);
430
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} changed: checking its rules and building it again…`);
426
431
  const hash = () => { try { return JSON.parse(readFileSync(join(root, 'site/dist/games.json'), 'utf8')).games.find((x) => x.id === g.id)?.room?.stateHash; } catch { return null; } };
427
432
  const before = hash();
428
433
  let release;
@@ -439,9 +444,17 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
439
444
  await stopped;
440
445
  } });
441
446
  } finally { release(); }
442
- log(`games/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
447
+ timingRetries.delete(g.id);
448
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} is rebuilt. ${before && before === hash() ? 'Local rooms resume their saved match; pages reconnect.' : 'The stored shape changed: local rooms reset to a fresh match; pages reconnect.'}`);
443
449
  } catch (error) {
444
- log(`games/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
450
+ log(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id} did not build, so the local site still runs what it had:\n${String(error?.message ?? error)}`);
451
+ // A busy machine can fail the wall-clock tick guard once. Keep the guard, resample at most three times,
452
+ // and never retry syntax, contract or deterministic budget failures until the author edits them.
453
+ const attempts = timingRetries.get(g.id)?.attempts ?? 0;
454
+ if (/rules ran for .*seconds with bots and were too slow/.test(String(error?.message ?? error)) && attempts < 3) {
455
+ timingRetries.set(g.id, { stamp: now, attempts: attempts + 1, at: Date.now() + 10000 });
456
+ log(`${g.id}: retrying the timing check in 10 seconds (${attempts + 1}/3); the last good build stays live.`);
457
+ } else timingRetries.delete(g.id);
445
458
  } finally { rebuilding = false; }
446
459
  return;
447
460
  }
@@ -484,3 +497,7 @@ export async function dev(root, { port: askedPort = 8787, remoteAi = false, loca
484
497
  if (failed) return { ok: false, command: 'dev', needs: 'local-rooms', socket: failed.socket, expected: failed.expected, why: failed.why };
485
498
  return { ok: true, command: 'dev', stopped: true };
486
499
  }
500
+
501
+ export function lanAddresses(interfaces = networkInterfaces()) {
502
+ return [...new Set(Object.values(interfaces).flat().filter((n) => n && !n.internal && n.family === 'IPv4' && !n.address.startsWith('169.254.')).map((n) => n.address))].sort();
503
+ }
package/lib/directory.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * nobody can list games under someone else's site.
6
6
  */
7
7
  import { request } from './net.mjs';
8
- import { listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
8
+ import { listExperiences as listGames, readLocal, readStudio, siteUrl, writeLocal } from './studio.mjs';
9
9
  import { licenceProblems, readManifest } from './asset-manifest.mjs';
10
10
  import { partsPlanLines, partsPublishReport } from './parts-build.mjs';
11
11
 
package/lib/doctor.mjs CHANGED
@@ -50,7 +50,7 @@ import { detectLocalAi } from './local-ai.mjs';
50
50
  import { findChrome } from './chrome.mjs';
51
51
  import { whyFailed } from './net.mjs';
52
52
  import { isOurs } from './statusline.mjs';
53
- import { findStudio, listGames, readLocal, readStudio } from './studio.mjs';
53
+ import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
54
54
  import { standaloneRows } from './standalone.mjs';
55
55
  import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
56
56
  import { STUDIO_VERSION } from './version.mjs';
@@ -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: 'person', open: 'https://nodejs.org/en/download', say: 'Install Node.js 22 or newer (the LTS download), then start a new session.' },
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. A free account works; no payment method.', open: 'https://dash.cloudflare.com/sign-up' }
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"); then deploy again.' }
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: 'person', open: 'https://dash.cloudflare.com/sign-up', say: 'No Cloudflare account yet? Make a free one now (no payment method) and click the link in the email it sends. Your AI signs in for you later; you approve once.' }
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 free Cloudflare account', fix,
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: 'person', say: 'Install your system\'s ffmpeg package (for example: sudo apt-get install -y ffmpeg).' },
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: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
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/handoff.mjs CHANGED
@@ -26,7 +26,7 @@ function originRepo(root) {
26
26
  return m ? `${m[1]}/${m[2]}` : null;
27
27
  }
28
28
  import { setupAttach } from './setup.mjs';
29
- import { listGames, readStudio } from './studio.mjs';
29
+ import { listExperiences as listGames, readStudio } from './studio.mjs';
30
30
  import { STUDIO_VERSION } from './version.mjs';
31
31
 
32
32
  const BUILD = /^hb_[a-f0-9]{32}$/;