@homie-rocks/studio 0.30.2 → 0.31.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 (90) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +349 -23
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +204 -124
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +203 -10
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/deploy-state.mjs +166 -0
  23. package/lib/dev.mjs +430 -0
  24. package/lib/directory.mjs +172 -9
  25. package/lib/doctor.mjs +12 -11
  26. package/lib/handoff.mjs +2 -6
  27. package/lib/library.mjs +1 -1
  28. package/lib/mcp-tools.mjs +42 -37
  29. package/lib/mcp.mjs +1 -1
  30. package/lib/net.mjs +81 -2
  31. package/lib/node-version.mjs +40 -0
  32. package/lib/office.mjs +6 -6
  33. package/lib/optimise.mjs +39 -4
  34. package/lib/parts-build.mjs +169 -0
  35. package/lib/parts-cli.mjs +104 -0
  36. package/lib/parts-store.mjs +342 -0
  37. package/lib/parts-tools.mjs +92 -0
  38. package/lib/parts.mjs +720 -0
  39. package/lib/perf.mjs +131 -31
  40. package/lib/port-check.mjs +40 -6
  41. package/lib/preview.mjs +77 -0
  42. package/lib/routes.mjs +240 -0
  43. package/lib/scaffold.mjs +83 -22
  44. package/lib/shoot.mjs +307 -0
  45. package/lib/site.mjs +1 -2
  46. package/lib/stage.mjs +118 -0
  47. package/lib/studio.mjs +0 -66
  48. package/lib/style-board.mjs +49 -6
  49. package/lib/template-history.json +101 -0
  50. package/lib/trailer.mjs +47 -0
  51. package/lib/typecheck.mjs +107 -0
  52. package/mcp/ui/lineup.js +3 -3
  53. package/mcp/ui/rights.js +1 -3
  54. package/netplay/NETPLAY.md +552 -16
  55. package/netplay/netplay.ts +950 -43
  56. package/package.json +10 -1
  57. package/parts/PARTS.md +169 -0
  58. package/port/index.ts +8 -2
  59. package/port/personas.ts +275 -0
  60. package/port/powerups.ts +357 -0
  61. package/port/probe.ts +38 -2
  62. package/port/room.ts +329 -40
  63. package/site/SITE.md +102 -20
  64. package/starters/ember-vale/index.html +1 -1
  65. package/starters/ember-vale/src/main.ts +7 -1
  66. package/starters/gem-rush/index.html +1 -1
  67. package/starters/gem-rush/src/main.ts +41 -12
  68. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  69. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  70. package/starters/gem-rush-3d/index.html +1 -1
  71. package/starters/gem-rush-3d/src/main.ts +50 -11
  72. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  73. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  74. package/starters/hero-rush-3d/index.html +1 -1
  75. package/starters/hero-rush-3d/src/main.ts +50 -11
  76. package/worker/arrival.mjs +4 -1
  77. package/worker/discover.mjs +11 -25
  78. package/worker/index.mjs +142 -62
  79. package/worker/license.mjs +45 -41
  80. package/worker/office-page.mjs +0 -4
  81. package/worker/office-schema.mjs +6 -0
  82. package/worker/office.mjs +19 -23
  83. package/worker/pages.mjs +156 -29
  84. package/worker/parts.mjs +275 -0
  85. package/worker/room.mjs +157 -14
  86. package/worker/schema.mjs +6 -8
  87. package/worker/seats.mjs +71 -0
  88. package/worker/site.mjs +51 -53
  89. package/worker/version.mjs +1 -1
  90. package/lib/remix-assets.mjs +0 -84
package/lib/routes.mjs ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * THE ROUTES IN A STUDIO'S WRANGLER CONFIG: WHICH ARE THE STUDIO'S, AND WHICH ARE NOT ITS TO TOUCH.
3
+ *
4
+ * A studio on a custom domain shares that domain's Cloudflare zone with whatever else its owner runs there, and a
5
+ * zone's Worker routes are one list for all of it. Three things went wrong with that:
6
+ *
7
+ * 1. `deploy` writes wrangler.jsonc again from studio.json, and it used to write it without `routes`. A studio whose
8
+ * owner had added the exact-host route its domain needed lost it at the next deploy, and the domain went back to
9
+ * whichever Worker the zone's wildcard route names.
10
+ * 2. A zone that already has a wildcard route (`*example.com/*`, the router of another site) answers the studio's
11
+ * hostname FIRST: a route is matched before a custom domain. The studio deployed fine and its own domain showed
12
+ * somebody else's "not found". Nothing said why, or that one exact-host route fixes it.
13
+ * 3. A config that itself names a catch-all (`*` + `/*`) hands every hostname of the zone to the studio's Worker
14
+ * the moment it deploys: another site on the zone stops answering.
15
+ *
16
+ * So: the routes the owner wrote for the studio's OWN hostnames (a custom domain, an exact host) are kept across
17
+ * every rewrite; a wildcard or catch-all route is never deployed by the studio unless studio.json says the zone is
18
+ * the studio's (`cloudflare.allowWildcardRoutes: true`); and a deploy whose domain answers as something else says
19
+ * which route is needed.
20
+ *
21
+ * 4. The zone ALREADY has another site's catch-all or wildcard route, and the studio's custom domain sits beside
22
+ * it. The studio's own config is clean, so nothing above speaks; the route answers the studio's hostname first,
23
+ * and the tempting repair (edit or remove that route) takes the other site down. So a deploy and its plan READ
24
+ * the zone's Worker routes when the studio has a custom domain (readZoneRoutes: two GETs with the sign-in
25
+ * Wrangler already has, never a write) and say, before anything is deployed, that the route is another site's,
26
+ * must not be edited or removed, and which one line makes the studio's hostname its own (zoneFinding). A list
27
+ * that cannot be read is said as unmeasured, never as fine.
28
+ *
29
+ * The studio never changes or removes a route it did not make: a zone's other routes belong to other Workers.
30
+ */
31
+ import { readFileSync } from 'node:fs';
32
+ import { configPath } from './studio.mjs';
33
+
34
+ /** wrangler.jsonc as an object (whole-line comments left out, as the toolkit writes it), or null. */
35
+ export function readConfig(root) {
36
+ try { return JSON.parse(readFileSync(configPath(root), 'utf8').replace(/^\s*\/\/.*$/gm, '')); } catch { return null; }
37
+ }
38
+
39
+ /** Wrangler takes a route as a string or an object, in `routes` or the older single `route`: one shape for all. */
40
+ export function routesOf(config) {
41
+ const raw = [...(Array.isArray(config?.routes) ? config.routes : []), ...(config?.route ? [config.route] : [])];
42
+ return raw.map((r) => (typeof r === 'string' ? { pattern: r } : r)).filter((r) => r && typeof r.pattern === 'string' && r.pattern.trim());
43
+ }
44
+
45
+ /** The hostname part of a route's pattern ("play.example.com/*" gives "play.example.com"; a scheme is left out). */
46
+ export function routeHost(route) {
47
+ return String(route?.pattern ?? '').trim().replace(/^[a-z*]+:\/\//i, '').split('/')[0].toLowerCase();
48
+ }
49
+
50
+ /**
51
+ * What kind of route it is:
52
+ * 'custom-domain' `custom_domain: true`: Cloudflare makes the DNS record and the certificate for that one host
53
+ * 'exact-host' one hostname, any path ("play.example.com/*"): only that host's requests
54
+ * 'catch-all' every hostname of the zone ("*" + "/*")
55
+ * 'wildcard' a `*` in the hostname ("*example.com/*", "*.example.com/*"): many hosts, maybe not the studio's
56
+ */
57
+ export function routeKind(route) {
58
+ if (route?.custom_domain === true) return 'custom-domain';
59
+ const host = routeHost(route);
60
+ if (host === '*' || host === '') return 'catch-all';
61
+ return host.includes('*') ? 'wildcard' : 'exact-host';
62
+ }
63
+
64
+ /** Whether a route's hostname pattern covers a hostname ("*example.com" covers "play.example.com" and "example.com"). */
65
+ export function routeCovers(route, hostname) {
66
+ const pattern = routeHost(route);
67
+ if (!pattern) return false;
68
+ const re = new RegExp(`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`);
69
+ return re.test(String(hostname ?? '').toLowerCase());
70
+ }
71
+
72
+ /**
73
+ * The config's routes, sorted into the studio's own (kept across every rewrite of wrangler.jsonc, exactly as written)
74
+ * and the wide ones (a wildcard or a catch-all).
75
+ */
76
+ export function sortRoutes(config) {
77
+ const own = [];
78
+ const wide = [];
79
+ for (const r of routesOf(config)) (['custom-domain', 'exact-host'].includes(routeKind(r)) ? own : wide).push(r);
80
+ return { own, wide };
81
+ }
82
+
83
+ /**
84
+ * The routes a rewrite of wrangler.jsonc carries over from the one on disk: the studio's own, and the wide ones
85
+ * only when studio.json says the whole zone is the studio's. Null when there are none, so a studio without routes
86
+ * gets byte for byte the config it always got.
87
+ */
88
+ export function keptRoutes(root, studio = null) {
89
+ const { own, wide } = sortRoutes(readConfig(root));
90
+ const kept = [...own, ...(studio?.cloudflare?.allowWildcardRoutes === true ? wide : [])];
91
+ return kept.length ? kept : null;
92
+ }
93
+
94
+ /**
95
+ * Why a deploy of this config would reach past the studio, or null. A wide route in the studio's own config makes
96
+ * its Worker answer for hostnames the studio does not have: said before anything is created or deployed.
97
+ */
98
+ export function wideRouteRefusal(config, studio = null) {
99
+ if (studio?.cloudflare?.allowWildcardRoutes === true) return null;
100
+ const { own, wide } = sortRoutes(config);
101
+ if (!wide.length) return null;
102
+ const names = wide.map((r) => `"${r.pattern}"`).join(', ');
103
+ const all = wide.some((r) => routeKind(r) === 'catch-all');
104
+ const keep = own.length ? ` Its own route${own.length === 1 ? '' : 's'} (${own.map((r) => `"${r.pattern}"`).join(', ')}) stay${own.length === 1 ? 's' : ''}, and every deploy keeps ${own.length === 1 ? 'it' : 'them'}.` : '';
105
+ return {
106
+ needs: 'cloudflare-routes', routes: wide.map((r) => r.pattern),
107
+ why: `wrangler.jsonc gives this studio's Worker the route${wide.length === 1 ? '' : 's'} ${names}: ${all ? 'a catch-all, which answers for EVERY hostname of the zone' : 'a wildcard, which answers for more hostnames than the studio\'s own'}. Deploying it would take those hostnames away from whatever serves them now (another site on the same domain stops answering), so nothing was deployed or changed on Cloudflare. Take ${wide.length === 1 ? 'that route' : 'those routes'} out of "routes" in wrangler.jsonc: a studio needs only its own hostname, as { "pattern": "<its hostname>", "custom_domain": true } or { "pattern": "<its hostname>/*", "zone_name": "<the domain>" }.${keep} If the whole zone really is this studio's, say so in studio.json ("cloudflare": { "allowWildcardRoutes": true }) and deploy again.`,
108
+ };
109
+ }
110
+
111
+ /** The apex a hostname most likely belongs to ("play.example.com" gives "example.com"): a suggestion, never a lookup. */
112
+ export function likelyZone(hostname) {
113
+ const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
114
+ return parts.slice(-2).join('.');
115
+ }
116
+
117
+ /** The exact-host route that makes a zone's Worker routes send one hostname to this studio's Worker. */
118
+ export function exactRouteFor(hostname, zone = likelyZone(hostname)) {
119
+ return { pattern: `${String(hostname).toLowerCase()}/*`, zone_name: zone };
120
+ }
121
+
122
+ /**
123
+ * The studio's custom domain answered, but not as this studio. With no exact-host route for that hostname in the
124
+ * config, the likeliest reason is another Worker's route on the zone that covers it (a wildcard, or a catch-all):
125
+ * a route is matched before a custom domain. Says which route is needed and that the other one is left alone; with
126
+ * the exact-host route already there, says to wait and what to look at instead.
127
+ */
128
+ export function shadowedDomain(config, hostname, answer = {}) {
129
+ const host = String(hostname ?? '').toLowerCase();
130
+ const own = sortRoutes(config).own;
131
+ const exact = own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host);
132
+ const said = answer.status ? `answered ${answer.status}${answer.said ? ` ("${answer.said}")` : ''}` : 'answered';
133
+ if (exact) {
134
+ return { shadowed: false, why: `https://${host} ${said}, not as this studio, although wrangler.jsonc has its exact-host route ("${exact.pattern}"). A new route can take a minute to apply; if it stays like this, look at the zone's Workers Routes in the Cloudflare dashboard for a more specific route on that hostname.` };
135
+ }
136
+ const route = exactRouteFor(host);
137
+ return {
138
+ shadowed: true, route,
139
+ why: `https://${host} ${said}, not as this studio: something else on the domain answers that hostname first. The usual reason is another Worker's route on the same zone that covers it (a wildcard like "*${route.zone_name}/*", or a catch-all): Cloudflare matches a route before a custom domain. The studio does not touch that route (it is not the studio's). Add the studio's own exact-host route to "routes" in wrangler.jsonc, which wins over a wildcard for this one hostname, and deploy again: ${JSON.stringify(route)} (zone_name is the domain as it is named in Cloudflare). Every deploy keeps it.`,
140
+ };
141
+ }
142
+
143
+ /* ------------------------------------------------------------------ the zone's routes, beside the studio's domain */
144
+
145
+ const CF_API = 'https://api.cloudflare.com/client/v4';
146
+
147
+ /** The zones a hostname may be in, likeliest first: the ones the config's routes name, then each parent of the hostname. */
148
+ export function zoneCandidates(hostname, config = null) {
149
+ const parts = String(hostname ?? '').toLowerCase().split('.').filter(Boolean);
150
+ const named = routesOf(config).map((r) => String(r.zone_name ?? '').toLowerCase()).filter(Boolean);
151
+ const parents = [];
152
+ for (let i = 0; i <= parts.length - 2; i += 1) parents.push(parts.slice(i).join('.'));
153
+ return [...new Set([...named, ...parents])].slice(0, 6);
154
+ }
155
+
156
+ /**
157
+ * The Worker routes of the zone a hostname is in, READ from Cloudflare (GET /zones?name=…, then GET
158
+ * /zones/<id>/workers/routes) with headers the toolkit already has (lib/brain-probe.mjs cloudflareAuth: Wrangler's
159
+ * own sign-in). { read: true, zone: { id, name }, routes: [{ pattern, script }] }, or { read: false, why } when it
160
+ * could not be read (no sign-in, a sign-in that may not read the zone, no such zone on it, no network). It never
161
+ * writes. `fetchFn` stands in for the network in tests.
162
+ */
163
+ export async function readZoneRoutes({ hostname, config = null, headers = null, fetchFn = globalThis.fetch, base = CF_API, ms = 10_000 } = {}) {
164
+ if (!headers) return { read: false, why: 'this computer has no Cloudflare sign-in to read them with' };
165
+ const root = String(base).replace(/\/+$/, '');
166
+ const get = async (path) => {
167
+ const res = await fetchFn(`${root}${path}`, { headers: { ...headers, accept: 'application/json', 'user-agent': 'homie-studio-deploy' }, signal: AbortSignal.timeout(ms) });
168
+ let body = null;
169
+ try { body = await res.json(); } catch { body = null; }
170
+ return { status: res.status, ok: res.ok && body?.success !== false, result: Array.isArray(body?.result) ? body.result : null };
171
+ };
172
+ const refused = (status) => (status === 401 || status === 403 ? 'this Cloudflare sign-in may not read the domain\'s routes' : `Cloudflare answered ${status}`);
173
+ try {
174
+ let zone = null;
175
+ for (const name of zoneCandidates(hostname, config)) {
176
+ const r = await get(`/zones?name=${encodeURIComponent(name)}`);
177
+ if (!r.ok || !r.result) return { read: false, why: refused(r.status) };
178
+ const hit = r.result.find((z) => String(z?.name ?? '').toLowerCase() === name && z?.id);
179
+ if (hit) { zone = { id: String(hit.id), name }; break; }
180
+ }
181
+ if (!zone) return { read: false, why: `no domain that ${String(hostname).toLowerCase()} belongs to is on the Cloudflare account this computer is signed in to` };
182
+ const r = await get(`/zones/${encodeURIComponent(zone.id)}/workers/routes`);
183
+ if (!r.ok || !r.result) return { read: false, zone, why: refused(r.status) };
184
+ return { read: true, zone, routes: r.result.filter((x) => typeof x?.pattern === 'string' && x.pattern.trim()).map((x) => ({ pattern: x.pattern.trim(), script: x.script ? String(x.script) : null })) };
185
+ } catch (error) {
186
+ return { read: false, why: error?.name === 'TimeoutError' ? `Cloudflare did not answer in ${Math.round(ms / 1000)} s` : `Cloudflare could not be reached (${String(error?.cause?.code ?? error?.code ?? error?.message ?? error).slice(0, 80)})` };
187
+ }
188
+ }
189
+
190
+ const NOT_OURS = 'Never edit or remove a route this studio did not make';
191
+
192
+ /**
193
+ * What the zone's routes mean for a studio on a custom domain, in words for the plan, the terminal and the tool
194
+ * result. `zone` is readZoneRoutes' answer; `worker` the studio's own Worker; `config` its wrangler.jsonc.
195
+ * state 'clear' no other route covers the studio's hostname: nothing to say (`why` is null)
196
+ * state 'foreign' another site's wildcard or catch-all covers it and the studio has no exact-host route of its
197
+ * own: `warning`, with the one line to add (`route`, `line`)
198
+ * state 'own-route' another site's route covers it, and the studio's own exact-host route already wins for its
199
+ * hostname: `why` says to leave the other route alone
200
+ * state 'taken' the studio's own hostname is routed to another Worker: only the person can decide that
201
+ * state 'unmeasured' the list could not be read: said as that, with the reason
202
+ */
203
+ export function zoneFinding({ hostname, worker = null, config = null, zone = null } = {}) {
204
+ const host = String(hostname ?? '').toLowerCase();
205
+ const guess = zone?.zone?.name ?? likelyZone(host);
206
+ const route = exactRouteFor(host, guess);
207
+ const line = JSON.stringify(route);
208
+ const ownInConfig = sortRoutes(config).own.find((r) => routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
209
+ if (!zone?.read) {
210
+ return {
211
+ state: 'unmeasured', host, zone: zone?.zone?.name ?? null, foreign: [], route, line,
212
+ why: `Not checked: whether another site's route on the domain covers ${host} (${zone?.why ?? 'not read'}). That is unmeasured, not a pass. If ${host} answers as something else after a deploy, the fix is the studio's own exact-host route in "routes" in wrangler.jsonc, ${line} (zone_name is the domain as Cloudflare names it), which every deploy keeps. ${NOT_OURS}.`,
213
+ };
214
+ }
215
+ const others = zone.routes.filter((r) => r.script !== worker && routeCovers(r, host)).map((r) => ({ pattern: r.pattern, script: r.script, kind: routeKind(r) }));
216
+ const taken = others.filter((r) => r.kind === 'exact-host');
217
+ const wide = others.filter((r) => r.kind !== 'exact-host');
218
+ const said = (list) => list.map((r) => `"${r.pattern}" (${r.script ? `Worker ${r.script}` : 'no Worker: it turns Workers off for what it covers'})`).join(', ');
219
+ const base = { host, zone: zone.zone.name, route, line };
220
+ if (taken.length) {
221
+ return {
222
+ ...base, state: 'taken', foreign: others,
223
+ warning: `${host} itself is routed to another Worker on ${zone.zone.name}: ${said(taken)}. That route is not this studio's, so the studio does not take, edit or remove it, and neither should you: whatever it serves would stop answering. Only the person can decide to hand the hostname over, in the domain's Workers Routes in the Cloudflare dashboard; or give the studio a hostname nothing else uses (studio.json cloudflare.domain).`,
224
+ };
225
+ }
226
+ if (!wide.length) return { ...base, state: 'clear', foreign: [], why: null };
227
+ const ownLive = zone.routes.find((r) => r.script === worker && routeKind(r) === 'exact-host' && routeHost(r) === host) ?? null;
228
+ const all = wide.some((r) => r.kind === 'catch-all');
229
+ const them = `${wide.length === 1 ? 'a route' : 'routes'} that cover${wide.length === 1 ? 's' : ''} ${host} and ${wide.length === 1 ? 'is' : 'are'} not this studio's: ${said(wide)}`;
230
+ if (ownInConfig || ownLive) {
231
+ return {
232
+ ...base, state: 'own-route', foreign: wide,
233
+ why: `${zone.zone.name} has ${them}. ${wide.length === 1 ? 'It belongs' : 'They belong'} to another site on this domain and must not be edited or removed. The studio's own exact-host route ("${(ownInConfig ?? ownLive).pattern}") wins over ${wide.length === 1 ? 'it' : 'them'} for this one hostname, and every deploy keeps it: nothing to change.`,
234
+ };
235
+ }
236
+ return {
237
+ ...base, state: 'foreign', foreign: wide,
238
+ warning: `${zone.zone.name} already has ${them}. ${wide.length === 1 ? 'That route belongs' : 'Those routes belong'} to another site on this domain: ${wide.length === 1 ? 'it' : 'they'} must not be edited or removed, here or in the Cloudflare dashboard, or that site stops answering. Cloudflare matches a route before a custom domain, so ${all ? 'the catch-all' : 'the wildcard'} answers ${host} first and the studio's own domain would show the other site. The one safe fix is the studio's own exact-host route, which wins over a wider route for this one hostname only and which every deploy keeps. Add this line to "routes" in wrangler.jsonc and deploy: ${line} (or \`homie-studio deploy --own-route\`, which adds exactly that line and nothing else).`,
239
+ };
240
+ }
package/lib/scaffold.mjs CHANGED
@@ -36,6 +36,11 @@ import { themeFile } from './site.mjs';
36
36
  export const COMPAT_DATE = '2026-06-01';
37
37
  /** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
38
38
  export const WRANGLER_VERSION = '4.145.0';
39
+ /**
40
+ * The TypeScript a new studio asks for, so `build --types` (and an editor) has a compiler: the studio's own
41
+ * devDependency, never one of the toolkit's (a studio that never checks types pays nothing for it at runtime).
42
+ */
43
+ export const TYPESCRIPT_VERSION = '5.9.3';
39
44
 
40
45
  export function slugify(name) {
41
46
  return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
@@ -58,7 +63,7 @@ export function slugify(name) {
58
63
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
59
64
  * the binding names and keep it linked.
60
65
  */
61
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false }) {
66
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
62
67
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
63
68
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
64
69
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -73,6 +78,10 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
73
78
  compatibility_flags: ['global_fetch_strictly_public'],
74
79
  workers_dev: true,
75
80
  preview_urls: true,
81
+ // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
82
+ // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
83
+ // (lib/routes.mjs). A studio without routes gets no key at all.
84
+ ...(routes?.length ? { routes } : {}),
76
85
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
77
86
  durable_objects: { bindings: rooms },
78
87
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
@@ -132,7 +141,23 @@ studio's pinned copy, never a registry lookup of the bare name.
132
141
  how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
133
142
  owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
134
143
  plugin's \`port\` skill does the whole job.
135
- - \`npm run build\` — bundle every game into \`site/dist\`.
144
+ - \`npm run build\` — bundle every game into \`site/dist\`. It builds in a folder of its own and puts the result
145
+ in place only when all of it is there: a game that does not build fails the command (a non-zero exit) and
146
+ leaves \`site/dist\` exactly as it was, and nothing running from \`site/dist\` loses its files. Each game's line
147
+ says \`changed\`, \`unchanged\` or \`new\` (against the build before) and its build hash;
148
+ \`site/dist/_site/build.json\` keeps the same, and the live site says each game's hash in
149
+ \`/.well-known/homie-studio.json\` (\`games[].build.hash\`), so "is the live game the one I built?" is one
150
+ comparison. A game's bundle is named by its content (\`assets/main-<HASH>.js\`; its built \`index.html\` names
151
+ it, so write \`./assets/main.js\` in the game's own \`index.html\` as always), and \`await import('./later')\` in
152
+ a game becomes a file of its own that loads when asked for: a 3D game can show its first screen before the
153
+ rest of its code arrives.
154
+ - \`npx --no-install homie-studio build --types\` — the same, after checking the games' TypeScript (the build
155
+ itself only strips types; a type error ships otherwise). It uses the studio's own \`typescript\`
156
+ (\`npm install --save-dev typescript\` if this studio has none), reads a game's own \`tsconfig.json\` when it
157
+ has one, and a type error in a game's files stops the build.
158
+ - \`npx --no-install homie-studio preview <id>\` — one built game's files at an address on this computer
159
+ (\`http://127.0.0.1:8788/\`), nothing else: no Wrangler, no rooms, no database. For a capture, a screenshot or
160
+ a perf script; the game plays offline with its bots. Ctrl-C stops it.
136
161
  - \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
137
162
  Durable Object, D1): open the printed address in two browsers and they share a room.
138
163
  Stop it with \`npx --no-install homie-studio dev --stop\` (this studio's dev server only;
@@ -199,14 +224,18 @@ studio's pinned copy, never a registry lookup of the bare name.
199
224
  page is not found.
200
225
  - **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
201
226
  (games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
202
- - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
203
- (\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
204
- with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
205
- live rooms, how to play, credits, and "Make a game like this". Give it words in game.json's \`landing\` block
227
+ - **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own files in
228
+ \`games/<id>/hero/\`: \`wide.jpg\` (16:9, 1280×720 or larger) and \`tall.jpg\` (9:16, 720×1280, what an upright
229
+ phone gets) for a still, and \`wide.mp4\` and \`tall.mp4\` in the same shapes for footage (a silent loop of 8 to
230
+ 15 s, under 3 MB each); else a trailer in \`videos/\` with \`for.game\`; else its cover with slow motion. Any
231
+ size is shown, cropped to fill; one file may be 25 MiB at most. Then the pitch, a big Play button into a public room, phone / computer / TV with the join code,
232
+ live rooms, how to play and credits. Give it words in game.json's \`landing\` block
206
233
  (\`pitch\`, \`about\`, \`controls\`, \`howToPlay\`, \`credits\`) and art with the plugin's \`art\` and \`video\` skills.
207
234
  - **The look** is \`site/theme.json\` (colours, fonts, corner radius, a logo). Anything in \`site/\` wins: a whole
208
235
  page in \`site/pages/\`, a piece of every page in \`site/partials/\`, files in \`site/public/\`, extra CSS in
209
- \`site/theme.css\` (\`site/README.md\`). A game whose picture is white or cream gets a light landing with
236
+ \`site/theme.css\` (\`site/README.md\`). **The order of the games** everywhere (Home, the Games page, the cards,
237
+ the directory's manifest) is studio.json \`"site": { "order": ["<id>", "<id>"] }\`: those first, as named, the
238
+ rest by id; \`"site": { "featured": "<id>" }\` picks Home's hero. A game whose picture is white or cream gets a light landing with
210
239
  game.json \`"landing": { "scheme": "light" }\` (a dark tint would turn it grey).
211
240
  - **Cards and the directory** show each game's landing still (\`hero/wide.jpg\`), else its cover. A song without
212
241
  a cover of its own shows the music manifest's \`cover\`, else its game's still.
@@ -243,14 +272,47 @@ studio's pinned copy, never a registry lookup of the bare name.
243
272
  Auto cuts to it (\`NETPLAY.md\` section 16). A game that never reads \`viewSeat\` is watched as its overview. A
244
273
  game with hidden hands or roles says game.json \`"watch": "overview"\` (the whole room only) or \`false\` (no
245
274
  watch door); a private or invite-only game is watched only by those it lets in.
275
+ - **A touch game guards its own page.** The play page refuses text selection, the long-press callout and page
276
+ gestures around the game's frame, but a long press INSIDE the game (a HUD label, a button's text, the canvas) is
277
+ the game's own: on a phone it raises copy/paste and the thumb's touch is lost. Call
278
+ \`guardGestures({ touch: 'canvas, [data-action]' })\` from \`@homie-rocks/studio/netplay\` once, early: no
279
+ selection, no callout, no pan or pinch on the gameplay surfaces; text fields, links and ordinary buttons keep the
280
+ browser's own behaviour (\`NETPLAY.md\` section 24). Try it with a real thumb on a real phone: emulated touch
281
+ does not raise the callout.
282
+ - **The frame has no storage of its own.** The game runs in a sandboxed frame without \`allow-same-origin\` (a
283
+ stranger's game must never read this site's storage or the owner's session), so \`localStorage\` throws inside it.
284
+ A setting or a personal best goes in \`net.prefs\` (\`net.prefs.string('quality', 'high')\`, \`net.prefs.number('volume', 0.8, { min: 0, max: 1 })\` after \`await net.prefs.ready\`: the typed readers give your fallback for a missing key, never a null that turns into 0,
285
+ \`net.prefs.set('quality', 'low')\`: the play page keeps 16 KB a game); progress that must last goes in saves.
286
+ The play page's \`?debug\` and \`?q=…\` reach the game as \`net.params\`, with any names game.json
287
+ \`"netplay": { "params": ["seed"] }\` declares (\`NETPLAY.md\` section 24).
288
+ - **game.json \`"netplay"\`** also takes \`"version"\` (the game's revision: bump it when a change makes an
289
+ already-open tab unable to play with a new one; rooms then run one build at a time and the old tab is told to
290
+ reload) and \`"stallMs"\` (1500 to 10000: how long a host may send no snapshot before the room is handed on, for
291
+ a heavy 3D game). \`NETPLAY.md\` sections 22 and 23.
246
292
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
247
293
  - A game's id is its URL (\`/<id>/\`); keep it once published.
248
- - **Remixing.** A public game shares its source at \`/games/<id>/source.json\`, with who made it (the studio, the
249
- game, its page) and the licence its owner picks in game.json \`"license"\`: \`"remix-with-credit"\` (the
250
- default), \`"remix-freely"\` or \`"no-remix"\`, and \`{ "kind": "…", "spdx": "MIT" }\` to name a licence too.
251
- \`npx --no-install homie-studio game remix <source.json> --id <new id>\` brings another studio's game in as a new
252
- game here: its game.json \`remixOf\` says "Remix of <game> by <studio>" with a link back, and its landing and
253
- credits show it; keep that credit. A game whose licence says no remix is refused.
294
+ - **A game's own licence.** game.json \`"license": "MIT"\` (any SPDX identifier) is said on the game's page; a game
295
+ that names none says nothing. It is a statement about the game, not an offer of its source: no game is handed
296
+ over whole. A game.json that still has \`"remix"\`, \`"share"\`, \`landing.make\` or one of the old licence words
297
+ builds as before with one note (remix was retired; delete them). A game that has \`"remixOf"\` was made from another
298
+ studio's game: keep it, it is the credit its landing and credits show ("Based on <game> by <studio>").
299
+ - **A 3D game's models.** \`createModels()\` warns in development about a model over 1,500 triangles or 300 KB,
300
+ which is a small prop's budget. A game whose models are bigger on purpose says so once in its game.json:
301
+ \`"assets": { "budgets": { "triangles": 8000, "bytes": 1500000 } }\` (per model; \`texturePx\` and \`materials\`
302
+ too). A game copied from a 3D starter has \`"assets": "library"\` there, which only mattered when it was copied:
303
+ replace it with the object.
304
+ - **Parts: how games build on each other.** A part is a piece of a game its studio chose to share (a creature, a
305
+ level generator, a camera, a bot brain, an audio pack), never the whole game. **Before writing a system from
306
+ scratch, look for one**: the \`@homie-rocks/*\` packages (npm) have the general mechanism (camera, input, audio,
307
+ effects), and \`parts_find\` searches the pieces other studios shared; packages are not parts. \`part_add\` with
308
+ \`"<studio site>/<part id>"\` fetches one exact version, checks every file, copies it into \`parts/_vendor/\` where
309
+ the studio owns and tunes it (its \`tuning.json\` survives a newer version), credits it in the game, and lets npm
310
+ install the packages it builds on; the game imports it in one line, \`import … from '@parts/<studio site>/<part
311
+ id>'\`. Write in the game's CODEX (Built from) what came from where. A reusable piece of this studio's own game
312
+ becomes a part with \`part_new\` (it is lifted out and the game keeps working). **A part is private until the
313
+ person asks to share it** (\`part_share\`, with an SPDX licence they picked and who to credit); it is live after
314
+ the next deploy. Without the chat tools, the same jobs are \`npx --no-install homie-studio parts
315
+ find|add|new|share\`. The design is \`node_modules/@homie-rocks/studio/parts/PARTS.md\`.
254
316
  - **Progress that lasts** (a character, unlocks, a collection, days of play) goes in **saves**, never in the room:
255
317
  a room forgets everything 60 s after its last player leaves. game.json \`"saves": true\` and
256
318
  \`createSaves\` from \`@homie-rocks/studio/saves\` (\`node_modules/@homie-rocks/studio/saves/SAVES.md\`): per
@@ -301,8 +363,7 @@ studio's pinned copy, never a registry lookup of the bare name.
301
363
  lean, look-at, crowds; every number a Game Lab tunable); \`game new <id> --from hero-rush-3d\` starts one. The
302
364
  plugin's \`animate\` skill has the rest.
303
365
  - **Licences**: a public game ships only assets whose licence allows it (CC0, CC BY with credit, the studio's own,
304
- generated); \`publish\` refuses an asset with no licence record. A remix gets each redistributable model,
305
- checked by SHA-256, and a grey placeholder for the rest.
366
+ generated); \`publish\` refuses an asset with no licence record.
306
367
 
307
368
  ## Running live games (the back office)
308
369
 
@@ -310,12 +371,12 @@ studio's pinned copy, never a registry lookup of the bare name.
310
371
  the owner a one-time sign-in link): every live room of every game, who is in it (handles; accounts once players
311
372
  sign in), bots, the round and uptime, refreshing by itself; Kick (that player cannot come back to that room for
312
373
  the minutes chosen), Mute (their chat and emotes reach nobody), Announce (one line every player sees), Close a
313
- room; and per game: launch state, Remixable, players per room and invites.
374
+ room; and per game: launch state, players per room and invites.
314
375
  - **Launch states:** \`private\` (only the owner, signed in; \`office link --to /<id>/play\` signs the owner's phone
315
376
  in), \`invite\` (an invite-only beta: \`office invite <id>\` makes invite links and codes, each for one browser or
316
377
  as many as \`--uses\` says), \`public\` (the default; listed). A game that is not public is in no list and not in
317
378
  the directory manifest, so the directory drops it the next time it reads the studio. A new game stays private
318
- from its first deploy with \`"launch": "private"\` in its game.json. **Remixable** publishes or withdraws its source.
379
+ from its first deploy with \`"launch": "private"\` in its game.json.
319
380
  - The owner is recognised in their own games: signed in, their play page has a small Owner button (tap a player:
320
381
  Mute, Kick; Announce). Nobody else's page has it.
321
382
  - **From the AI:** \`npx --no-install homie-studio office\` lists who is playing now; \`office announce "<text>"\` and
@@ -428,8 +489,8 @@ chat's card follows the work from then on) and, for a studio still being set up,
428
489
  \`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
429
490
  in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
430
491
  the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
431
- the same calls until the person's own \`proceed <code>\`, once the person has trusted them; in Grok Build nothing
432
- holds them yet. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
492
+ the same calls until the person's own \`proceed <code>\`, once the person has trusted them, and in Grok Build once
493
+ the plugin is trusted. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
433
494
  holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
434
495
  meant to do and why; do not retry it unless the person asks.
435
496
 
@@ -485,7 +546,7 @@ That line is all the session is given. The brief (what the person asked for, in
485
546
 
486
547
  1. \`npm install\` (once per session).
487
548
  2. \`npx --no-install homie-studio handoff hb_…\` prints the brief and the steps for this kind of build (a new
488
- studio's first session, a new game, a port, a remix or a change). It takes the build once, so the chat's card
549
+ studio's first session, a new game, a port or a change). It takes the build once, so the chat's card
489
550
  follows the work, and for a studio still being set up it checks in from this repository.
490
551
  3. If it says this session's network does not reach homie.rocks and the Homie connector's tools are in this session,
491
552
  call \`build_progress\` with \`{ "build": "hb_…" }\`: its answer carries the same brief. Otherwise tell the person
@@ -554,7 +615,7 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
554
615
  | \`theme.json\` | The look: \`bg\`, \`fg\`, \`accent\`, \`glow\` colours, \`display\` and \`text\` fonts (\`fonts\` loads a file from \`public/\`), \`radius\`, \`mark\` (a logo). Or \`palette\`: neon, dock, gold, acid, ember, orchid, tide, candy. |
555
616
  | \`theme.css\` | Extra CSS on every page (restyle anything, the "Made with Homie" footer included). |
556
617
  | \`partials/<name>.html\` | A piece of every page: \`head\`, \`header\`, \`footer\`, \`home\` (a band on Home), \`game\` (a band on every game's landing), \`game-<id>\` (on one game's), \`post\`. |
557
- | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). |
618
+ | \`pages/<path>/index.html\` | A whole page at \`/<path>/\`, instead of the generated one (\`pages/<id>/index.html\` replaces a game's landing) or beside them (\`pages/about/index.html\`). It may borrow \`<!-- homie:style -->\`, \`<!-- homie:header -->\`, \`<!-- homie:footer -->\`, \`<!-- homie:script -->\`, and \`<!-- homie:schema -->\` in its \`<head>\` (the page's structured data for search engines). A Home of your own (\`pages/index.html\`) takes the generated hero with \`<!-- homie:home-hero -->\` (the featured game) or \`<!-- homie:home-hero <id> -->\` (that game); a game's own hero files are served at \`/games/<id>/_landing/<file>\` (\`wide.jpg\`, \`tall.jpg\`, \`wide.mp4\`, \`tall.mp4\`) if a page of yours needs one by address (\`/api/games\` says each exactly, in \`landing.hero\`). |
558
619
  | \`public/\` | Files served as they are, at the same path (\`public/fonts/x.woff2\` is \`/fonts/x.woff2\`). A \`robots.txt\`, \`sitemap.xml\` or \`llms.txt\` here replaces the one the site makes. |
559
620
 
560
621
  \`src/worker.mjs\` and \`migrations/\` are the Worker (its config is \`wrangler.jsonc\`, at the studio's root); \`dist/\` is the build (not committed).
@@ -620,7 +681,7 @@ export function studioFiles({ name, slug, homie, template = false }) {
620
681
  private: true,
621
682
  type: 'module',
622
683
  scripts: { dev: 'homie-studio dev', build: 'homie-studio build', deploy: 'homie-studio deploy', check: 'homie-studio check', studio: 'homie-studio' },
623
- devDependencies: { '@homie-rocks/studio': packageSpec(homie), wrangler: WRANGLER_VERSION },
684
+ devDependencies: { '@homie-rocks/studio': packageSpec(homie), typescript: TYPESCRIPT_VERSION, wrangler: WRANGLER_VERSION },
624
685
  // What Cloudflare's "Deploy to Cloudflare" form says about each setting (package.json `cloudflare.bindings`).
625
686
  cloudflare: {
626
687
  label: 'Homie studio',