@homie-rocks/studio 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * THE CLAUDE CODE STATUS LINE: one quiet line under the prompt with the studio's current build, from its progress
3
+ * feed (lib/progress.mjs), for example
4
+ *
5
+ * ▶ Ember Run · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks · $0.40 of $2.00
6
+ *
7
+ * Optional, and only on the person's yes: `homie-studio statusline --install` writes the `statusLine` setting into
8
+ * this studio's `.claude/settings.local.json` (this person, this studio; Claude Code keeps that file out of git, and
9
+ * the studio's .gitignore does too). Claude Code runs the command after each message and, with `refreshInterval`,
10
+ * every few seconds while a long check runs; it sends the session as JSON on stdin (`workspace.current_dir`), and
11
+ * shows what the command prints. With no build running it shows only the studio's name, dimmed; a build that ended
12
+ * stays for a few minutes (passed, failed or stopped), then goes.
13
+ *
14
+ * Plugins cannot set a status line (a plugin's settings take only `agent` and `subagentStatusLine`), so this is a
15
+ * one-line project setting the person agrees to, never something the plugin switches on.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { currentFeedDoc, latestFeedFor, summarize } from './feed-summary.mjs';
21
+ import { PACKAGE_ROOT, findStudio } from './studio.mjs';
22
+
23
+ const ANSI = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m' };
24
+ /** How long an ended build stays on the line. */
25
+ export const RECENT_MS = 10 * 60_000;
26
+
27
+ function bar(percent, width) {
28
+ const full = Math.round((percent / 100) * width);
29
+ return { done: '▰'.repeat(full), left: '▱'.repeat(Math.max(0, width - full)) };
30
+ }
31
+
32
+ /**
33
+ * The line for the studio at or above `dir`, or '' outside a studio. Parts are dropped from the end (spend, then
34
+ * checks) until the line fits in `columns`.
35
+ */
36
+ export function statusLine({ dir = process.cwd(), now = Date.now(), columns = 100, color = true } = {}) {
37
+ const root = findStudio(dir);
38
+ if (!root) return '';
39
+ let name = 'Studio';
40
+ try { name = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name || name; } catch { /* keep */ }
41
+ const s = summarize(currentFeedDoc(root) ?? latestFeedFor(root, null));
42
+ const paint = (code, text) => (color && text ? `${code}${text}${ANSI.reset}` : text);
43
+ const fit = (parts) => {
44
+ // parts: [[plain, painted], …]; keep the first two whole, then as many as fit.
45
+ const width = Math.max(20, columns - 2);
46
+ let keep = parts.length;
47
+ const len = (n) => parts.slice(0, n).reduce((a, [plain]) => a + plain.length, 0) + (n - 1) * 3;
48
+ while (keep > 1 && len(keep) > width) keep -= 1;
49
+ const out = parts.slice(0, keep).map(([, painted]) => painted).join(paint(ANSI.dim, ' · '));
50
+ const plain = parts.slice(0, keep).map(([p]) => p).join(' · ');
51
+ return plain.length > width ? `${plain.slice(0, width - 1)}…` : out;
52
+ };
53
+ const ended = s && s.state !== 'running' ? Date.parse(s.endedAt ?? s.updatedAt ?? 0) : null;
54
+ if (!s || (ended !== null && !(now - ended < RECENT_MS))) return fit([[`◇ ${name}`, paint(ANSI.dim, `◇ ${name}`)]]);
55
+
56
+ const title = s.title.length > 40 ? `${s.title.slice(0, 39)}…` : s.title;
57
+ if (s.state === 'passed') {
58
+ return fit([[`✓ ${title} done`, paint(ANSI.green, `✓ ${title} done`)], ...(s.preview ? [[s.preview, paint(ANSI.dim, s.preview)]] : [])]);
59
+ }
60
+ if (s.state === 'failed') {
61
+ const why = `failed${s.stage ? ` at ${s.stage.label}` : ''}${s.error ? `: ${s.error}` : ''}`;
62
+ return fit([[`✗ ${title}`, paint(ANSI.red, `✗ ${title}`)], [why, paint(ANSI.red, why)]]);
63
+ }
64
+ if (s.state === 'stopped') return fit([[`■ ${title} stopped`, paint(ANSI.yellow, `■ ${title} stopped`)]]);
65
+
66
+ const width = columns < 70 ? 6 : 10;
67
+ const b = bar(s.percent, width);
68
+ const stage = s.stopping ? 'stopping…' : (s.stage?.label ?? 'Starting');
69
+ const progress = `${stage} ${b.done}${b.left} ${s.percent}%`;
70
+ const parts = [
71
+ [`▶ ${title}`, paint(ANSI.bold, `▶ ${title}`)],
72
+ [progress, `${paint(ANSI.cyan, stage)} ${paint(ANSI.green, b.done)}${paint(ANSI.dim, b.left)} ${s.percent}%`],
73
+ ];
74
+ if (s.checks.total) {
75
+ const text = `${s.checks.pass}/${s.checks.total} checks${s.checks.fail ? `, ${s.checks.fail} failing` : ''}`;
76
+ parts.push([text, paint(s.checks.fail ? ANSI.red : ANSI.dim, text)]);
77
+ }
78
+ if (s.spend.text) parts.push([s.spend.text, paint(ANSI.dim, s.spend.text)]);
79
+ return fit(parts);
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ the setting */
83
+
84
+ const LOCAL = join('.claude', 'settings.local.json');
85
+ const readJson = (path) => { if (!existsSync(path)) return {}; return JSON.parse(readFileSync(path, 'utf8')); };
86
+ /** Our own status line: a command that runs this toolkit's statusline. */
87
+ export const isOurs = (statusLine) => typeof statusLine?.command === 'string' && /homie-studio|@homie-rocks[\\/]studio/.test(statusLine.command) && /statusline/.test(statusLine.command);
88
+
89
+ /**
90
+ * The command the setting runs: this studio's pinned toolkit, by an absolute path (forward slashes, for Windows
91
+ * too). Set in another folder (the one Claude Code was started in, above the studio), it names the studio too.
92
+ */
93
+ export function statusLineCommand(root, { named = false } = {}) {
94
+ const pinned = join(root, 'node_modules', '@homie-rocks', 'studio', 'bin', 'statusline.mjs');
95
+ const script = existsSync(pinned) ? pinned : join(PACKAGE_ROOT, 'bin', 'statusline.mjs');
96
+ const slash = (p) => p.replace(/\\/g, '/');
97
+ return `node "${slash(script)}"${named ? ` --studio "${slash(root)}"` : ''}`;
98
+ }
99
+
100
+ /** Every settings file that may already hold a status line for this studio, most specific first. */
101
+ function settingsFiles(project, env) {
102
+ const user = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, 'settings.json') : join(env.HOME || homedir(), '.claude', 'settings.json');
103
+ return [
104
+ { where: 'this project, this person (.claude/settings.local.json)', path: join(project, LOCAL) },
105
+ { where: 'this project (.claude/settings.json)', path: join(project, '.claude', 'settings.json') },
106
+ { where: 'every project of this person (the user settings)', path: user },
107
+ ];
108
+ }
109
+
110
+ /**
111
+ * `homie-studio statusline --install`: the status line in this studio's .claude/settings.local.json. It never
112
+ * replaces a status line the person already has (anywhere Claude Code would read one) unless `replace`.
113
+ * `remove`: take ours out again.
114
+ */
115
+ export function installStatusLine(root, { replace = false, remove = false, project = null, env = process.env } = {}) {
116
+ // Claude Code reads project settings from the folder it was started in. A studio made as a subfolder of that
117
+ // folder is named in the command, and the setting goes where Claude Code reads it (`--project <that folder>`).
118
+ const here = project ? resolve(project) : root;
119
+ if (!existsSync(here)) return { ok: false, command: 'statusline install', why: `no folder ${here}` };
120
+ const files = settingsFiles(here, env);
121
+ const local = files[0].path;
122
+ let mine;
123
+ try { mine = readJson(local); } catch { return { ok: false, command: 'statusline install', why: `${LOCAL} is not valid JSON; fix it by hand first (nothing was changed)` }; }
124
+ if (remove) {
125
+ if (!isOurs(mine.statusLine)) return { ok: true, command: 'statusline remove', removed: false, message: 'No Homie status line in this studio; nothing changed.' };
126
+ delete mine.statusLine;
127
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
128
+ return { ok: true, command: 'statusline remove', removed: true, file: LOCAL, message: `Removed the Homie status line from ${LOCAL}.` };
129
+ }
130
+ const command = statusLineCommand(root, { named: here !== root });
131
+ if (isOurs(mine.statusLine) && mine.statusLine.command === command) return { ok: true, command: 'statusline install', already: true, file: LOCAL, statusLine: mine.statusLine, message: 'The status line is already on in this studio.' };
132
+ if (!replace) {
133
+ for (const f of files) {
134
+ let s = null;
135
+ try { s = readJson(f.path).statusLine ?? null; } catch { s = null; }
136
+ if (s && !isOurs(s)) {
137
+ return { ok: false, command: 'statusline install', exists: { where: f.where, command: String(s.command ?? s.type ?? '').slice(0, 120) }, why: `there is already a status line (${f.where}). Nothing was changed. With --replace, this studio shows the build's progress instead (only here; the other one stays everywhere else).` };
138
+ }
139
+ }
140
+ }
141
+ mine.statusLine = { type: 'command', command, padding: 0, refreshInterval: 5 };
142
+ mkdirSync(join(here, '.claude'), { recursive: true });
143
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
144
+ // Claude Code keeps settings.local.json out of git when it makes the file; this one we made, so say it here too.
145
+ const ignore = join(here, '.gitignore');
146
+ let ignored = false;
147
+ if (existsSync(ignore) || here === root) try {
148
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
149
+ if (!/^\.claude\/settings\.local\.json\s*$/m.test(text)) { writeFileSync(ignore, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This person's own Claude Code settings for this studio (the status line).\n.claude/settings.local.json\n`); ignored = true; }
150
+ } catch { /* no .gitignore to keep */ }
151
+ return {
152
+ ok: true, command: 'statusline install', file: here === root ? LOCAL : join(here, LOCAL), statusLine: mine.statusLine, gitignore: ignored,
153
+ message: `The status line is on ${here === root ? `in this studio (${LOCAL})` : `for Claude Code started in ${here} (${LOCAL} there)`}: one line under the prompt with the current build's stage, a progress bar, its checks and what it spent. Claude Code reloads settings by itself; it shows from the next message. Turn it off with: npx --no-install homie-studio statusline --remove`,
154
+ };
155
+ }
package/lib/studio.mjs CHANGED
@@ -26,6 +26,17 @@ export function requireStudio(from) {
26
26
  return root;
27
27
  }
28
28
 
29
+ /*
30
+ * WHERE THE WORKER'S CONFIG LIVES. From 0.10.0 a new studio keeps `wrangler.jsonc` at its root, which is what
31
+ * Cloudflare's Workers Builds and its "Deploy to Cloudflare" button read (they run the studio's own `npm run build`
32
+ * and `npm run deploy` at the repository's root). A studio made before 0.10.0 keeps `site/wrangler.jsonc`; every
33
+ * command runs Wrangler next to whichever the studio has, so both keep working.
34
+ */
35
+ export function workerDir(root) { return existsSync(join(root, 'wrangler.jsonc')) || !existsSync(join(root, 'site', 'wrangler.jsonc')) ? root : join(root, 'site'); }
36
+ export function configPath(root) { return join(workerDir(root), 'wrangler.jsonc'); }
37
+ /** 'root' (0.10.0 and later: Workers Builds ready) or 'site' (older studios). */
38
+ export function layoutOf(root) { return workerDir(root) === root ? 'root' : 'site'; }
39
+
29
40
  export function readStudio(root) { return JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')); }
30
41
  export function writeStudio(root, studio) { writeFileSync(join(root, 'studio.json'), `${JSON.stringify(studio, null, 2)}\n`); }
31
42
 
@@ -152,6 +152,53 @@
152
152
  "## Beta": "9d02638207a26e73"
153
153
  }
154
154
  }
155
+ },
156
+ "0.10.0": {
157
+ "files": {
158
+ "CLAUDE.md": "ddf2b2a0376202f6",
159
+ "README.md": "684b3cca8001673c",
160
+ "games/README.md": "5e5e942baa7e0372",
161
+ "music/README.md": "b8a828de9311bd28",
162
+ "videos/README.md": "70614388cbb203c7",
163
+ "posts/README.md": "37841aae476bd540",
164
+ "site/README.md": "ca131cd0472464fb",
165
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
166
+ },
167
+ "sections": {
168
+ "AGENTS.md": {
169
+ "(intro)": "214aa46eb3a895d5",
170
+ "## Layout": "f14d641159480e6c",
171
+ "## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
172
+ "## The site": "7d3525ba39b7f262",
173
+ "## Making games": "c204473d9b468f49",
174
+ "## Rules": "969318425c99f5a5",
175
+ "## Beta": "9d02638207a26e73"
176
+ }
177
+ }
178
+ },
179
+ "0.11.0": {
180
+ "files": {
181
+ "CLAUDE.md": "ddf2b2a0376202f6",
182
+ "README.md": "684b3cca8001673c",
183
+ "games/README.md": "5e5e942baa7e0372",
184
+ "music/README.md": "b8a828de9311bd28",
185
+ "videos/README.md": "70614388cbb203c7",
186
+ "posts/README.md": "37841aae476bd540",
187
+ "site/README.md": "ca131cd0472464fb",
188
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
189
+ },
190
+ "sections": {
191
+ "AGENTS.md": {
192
+ "(intro)": "214aa46eb3a895d5",
193
+ "## Layout": "f14d641159480e6c",
194
+ "## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
195
+ "## The site": "7d3525ba39b7f262",
196
+ "## Making games": "c204473d9b468f49",
197
+ "## The Game Codex and progress": "0738ef719cef3a4c",
198
+ "## Rules": "969318425c99f5a5",
199
+ "## Beta": "9d02638207a26e73"
200
+ }
201
+ }
155
202
  }
156
203
  }
157
204
  }
package/lib/version.mjs CHANGED
@@ -8,10 +8,14 @@ import { readFileSync } from 'node:fs';
8
8
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
9
  export const STUDIO_VERSION = pkg.version;
10
10
 
11
- /** The pinned tarball of this version, on a Homie directory (homie.rocks by default). */
11
+ /** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
12
12
  export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
13
13
  return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
14
14
  }
15
15
 
16
- /** What a studio's package.json names: until @homie-rocks/studio is on the npm registry, its versioned tarball URL. */
17
- export function packageSpec(base) { return tarballUrl(base); }
16
+ /**
17
+ * What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
18
+ * integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
19
+ * their default network settings; a directory's /npm/ tarball is not.
20
+ */
21
+ export function packageSpec() { return STUDIO_VERSION; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The Game Codex page's one script (lib/codex.mjs draws the page): its tabs, "Updated 5 s ago", and a reload every
3
+ * few seconds while a build runs in the local copy. Constant text with no data in it, so the site's private codex
4
+ * page (worker/stats-page.mjs) allows exactly this script by its hash and nothing else.
5
+ */
6
+ export const CODEX_SCRIPT = `(function(){var d=document,t=[].slice.call(d.querySelectorAll('.tab')),n=[].slice.call(d.querySelectorAll('nav.tabs a'));if(!t.length)return;d.documentElement.classList.add('js');function show(id){var f=t.filter(function(s){return s.id===id})[0]||t.filter(function(s){return s.id===d.body.getAttribute('data-start')})[0]||t[0];t.forEach(function(s){s.classList.toggle('on',s===f)});n.forEach(function(a){a.classList.toggle('on',a.getAttribute('href')==='#'+f.id)});return f}var cur=show(location.hash.slice(1));var nav=d.querySelector('nav.tabs');window.addEventListener('hashchange',function(){cur=show(location.hash.slice(1));if(nav&&window.scrollY>nav.offsetTop)window.scrollTo(0,nav.offsetTop)});function ago(){[].forEach.call(d.querySelectorAll('[data-ago]'),function(e){var w=Date.parse(e.getAttribute('data-ago'));if(!w)return;var s=Math.max(0,Math.round((Date.now()-w)/1000));e.textContent='Updated '+(s<60?s+' s':s<3600?Math.round(s/60)+' min':Math.round(s/3600)+' h')+' ago'})}ago();setInterval(ago,1000);var st=d.getElementById('build-status');if(d.body.getAttribute('data-mode')==='file'&&st&&st.getAttribute('data-running')==='1')setTimeout(function(){if(cur===st)location.reload()},5000)})();`;
package/worker/index.mjs CHANGED
@@ -90,9 +90,71 @@ async function postsOf(env, origin) {
90
90
  } catch { return []; }
91
91
  }
92
92
 
93
- async function claimOf(env) {
93
+ /*
94
+ * THE SITE CLAIMS ITSELF IN THE DIRECTORY (0.10.0). The homie.rocks directory lists a studio only when its site
95
+ * serves the claim the directory handed out for that exact address, which proves the studio controls the site.
96
+ * Before 0.10.0 `homie-studio deploy` fetched the claim and stored it in D1 from the person's computer; a site built
97
+ * by Cloudflare's Workers Builds has no such step. So the site asks for its own claim the first time its manifest
98
+ * is read (by the directory publishing it, or `deploy` reading it once), for the address it is being read at, and
99
+ * keeps it in its D1 (`meta`, `homie_claim:<origin>`).
100
+ *
101
+ * It never asks from a Preview (HOMIE_PREVIEW: a Preview is a branch under review, not the studio), from a studio
102
+ * whose studio.json says `homie.directory: false`, or without its D1. A directory that does not answer is asked
103
+ * again at most once a minute per address; the page answers without a claim meanwhile.
104
+ */
105
+ const CLAIM = /^[a-f0-9]{16,128}$/;
106
+ const claimed = new Map();
107
+ const claimMissed = new Map();
108
+
109
+ async function storedClaim(env, origin) {
94
110
  if (!env.DB) return null;
95
- try { return (await env.DB.prepare('SELECT value FROM meta WHERE key = ?').bind('homie_claim').first())?.value ?? null; } catch { return null; }
111
+ try {
112
+ const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
113
+ return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
114
+ } catch { return null; }
115
+ }
116
+
117
+ export function directoryOf(cat) {
118
+ const d = cat?.studio?.directory;
119
+ if (d === null || d === false) return null;
120
+ const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
121
+ try {
122
+ const u = new URL(at);
123
+ return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
124
+ } catch { return null; }
125
+ }
126
+
127
+ /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
128
+ async function claimOf(env, cat, url) {
129
+ const origin = url.origin;
130
+ if (claimed.has(origin)) return claimed.get(origin);
131
+ const kept = await storedClaim(env, origin);
132
+ if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
133
+ const directory = directoryOf(cat);
134
+ const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
135
+ const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
136
+ && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
137
+ if (may) {
138
+ try {
139
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
140
+ headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
141
+ });
142
+ const body = await res.json().catch(() => null);
143
+ if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
144
+ await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
145
+ claimed.set(origin, body.claim);
146
+ return body.claim;
147
+ }
148
+ } catch { /* the directory is optional for a live site */ }
149
+ claimMissed.set(origin, Date.now());
150
+ }
151
+ return kept?.older ?? null;
152
+ }
153
+
154
+ /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
155
+ function named(cat, env) {
156
+ if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
157
+ return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
96
158
  }
97
159
 
98
160
  /** Every public room with people in it, per game (the Lobby's list), and the number playing per game. */
@@ -303,8 +365,14 @@ async function route(request, env, ctx) {
303
365
  const parts = path.split('/').filter(Boolean);
304
366
  const read = request.method === 'GET' || request.method === 'HEAD';
305
367
  let catP = null;
306
- const getCat = () => (catP ??= catalogue(env, url.origin));
368
+ const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
307
369
 
370
+ // "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
371
+ if (path === '/_studio/connect' && read) {
372
+ const directory = directoryOf(await getCat());
373
+ if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
374
+ return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
375
+ }
308
376
  if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
309
377
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
310
378
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
@@ -336,7 +404,10 @@ async function route(request, env, ctx) {
336
404
  ...(cat.studio?.tagline ? { tagline: cat.studio.tagline } : {}),
337
405
  site: url.origin,
338
406
  studio: cat.studio?.version ?? null,
339
- claim: await claimOf(env),
407
+ claim: await claimOf(env, cat, url),
408
+ // What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
409
+ // its mark is here). A Preview says it is one.
410
+ ...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
340
411
  games: (cat.games ?? []).map((g) => {
341
412
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
342
413
  const cover = gameCover(g);
@@ -8,6 +8,19 @@
8
8
  * never cached, never indexed.
9
9
  */
10
10
  import { OWNER_COOKIE, endSession, ownerAllowed, rangeOf, readStats, spendSignin, onlyOf } from './stats.mjs';
11
+ import { CODEX_SCRIPT } from './codex-script.mjs';
12
+
13
+ /** Where a sign-in may send the owner next: the stats, or one game's codex. */
14
+ const NEXT = /^\/_studio\/(?:stats|codex\/[a-z0-9][a-z0-9-]{0,39}\/)$/;
15
+ const nextOf = (url) => { const to = url.searchParams.get('to') ?? ''; return NEXT.test(to) ? to : '/_studio/stats'; };
16
+ let scriptHash = null;
17
+ async function codexScriptHash() {
18
+ if (!scriptHash) {
19
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(CODEX_SCRIPT));
20
+ scriptHash = `sha256-${btoa(String.fromCharCode(...new Uint8Array(digest)))}`;
21
+ }
22
+ return scriptHash;
23
+ }
11
24
 
12
25
  const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
13
26
  const fmt = (n) => Number(n || 0).toLocaleString('en-US');
@@ -119,12 +132,12 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
119
132
  // A GET spends nothing: a chat app's link preview must not use up the owner's one-time link.
120
133
  return page('Sign in · stats', `<h1>${esc(cat.studio?.name ?? 'Studio')} stats</h1>
121
134
  <p class="dim">Open this studio's private stats in this browser. The link works once.</p>
122
- <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}"><button type="submit">Open the stats</button></form>`);
135
+ <form method="post" action="/_studio/signin?k=${esc(encodeURIComponent(key))}${nextOf(url) !== '/_studio/stats' ? `&amp;to=${esc(encodeURIComponent(nextOf(url)))}` : ''}"><button type="submit">${nextOf(url).startsWith('/_studio/codex/') ? 'Open the codex' : 'Open the stats'}</button></form>`);
123
136
  }
124
137
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
125
138
  const session = await spendSignin(env, key);
126
139
  if (!session) return page('Link used or expired', `<h1>That link has been used or has expired</h1><p class="note">A sign-in link works once, for 30 minutes. Ask your AI for a new one: <code>npx --no-install homie-studio stats link</code>.</p>`, 403);
127
- return new Response(null, { status: 303, headers: { location: '/_studio/stats', 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
140
+ return new Response(null, { status: 303, headers: { location: nextOf(url), 'cache-control': 'no-store', 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/_studio/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } });
128
141
  }
129
142
  if (path === '/_studio/signout') {
130
143
  if (request.method !== 'POST' || !sameOrigin(request, url)) return page('Not allowed', '<h1>Not allowed</h1>', 403);
@@ -140,5 +153,24 @@ export async function ownerRoutes(request, env, url, { catalogueOf }) {
140
153
  }
141
154
  return page(`Stats · ${cat.studio?.name ?? 'Studio'}`, statsBody(cat, stats, url));
142
155
  }
156
+ // A game's Game Codex (lib/codex.mjs, built into site/dist/_studio/codex/<id>/): the owner's only, never listed.
157
+ const codex = /^\/_studio\/codex\/([a-z0-9][a-z0-9-]{0,39})(\/|\/index\.html)?$/.exec(path);
158
+ if (codex) {
159
+ if (request.method !== 'GET' && request.method !== 'HEAD') return page('Not allowed', '<h1>Not allowed</h1>', 405);
160
+ if (!codex[2]) return new Response(null, { status: 301, headers: { location: `/_studio/codex/${codex[1]}/`, 'cache-control': 'no-store' } });
161
+ if ((await ownerAllowed(request, env, { kinds: ['session'] })) !== 'session') {
162
+ return page('Codex · private', `<h1>${esc(cat.studio?.name ?? 'Studio')}: Game Codex</h1>
163
+ <p class="dim">A game's codex is for the studio's owner only.</p>
164
+ <p class="note">To open it, the owner asks their AI in the studio's folder to run <code>npx --no-install homie-studio codex link ${esc(codex[1])}</code>. That uses the studio's own Cloudflare login, and gives a one-time link for this browser.</p>`, 401);
165
+ }
166
+ const res = env.ASSETS ? await env.ASSETS.fetch(new Request(`${url.origin}/_studio/codex/${codex[1]}/index.html`)) : null;
167
+ if (!res || !res.ok) return page('No codex yet', `<h1>No codex for this game yet</h1><p class="note">Its plan is <code>games/${esc(codex[1])}/CODEX.md</code> in the studio; the next <code>npm run deploy</code> puts it here.</p>`, 404);
168
+ return new Response(request.method === 'HEAD' ? null : await res.text(), {
169
+ headers: {
170
+ ...PRIVATE_HEADERS,
171
+ 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data:; script-src '${await codexScriptHash()}'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`,
172
+ },
173
+ });
174
+ }
143
175
  return page('Not found', '<h1>Not found</h1>', 404);
144
176
  }
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.9.0';
2
+ export const STUDIO_VERSION_TAG = '0.11.0';