@homie-rocks/studio 0.10.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.
package/README.md CHANGED
@@ -72,8 +72,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
72
72
  A studio pins one version, so nothing changes until it asks. To take a newer one:
73
73
 
74
74
  ```sh
75
- npx -y @homie-rocks/studio@0.10.0 upgrade # the plan; changes nothing
76
- npx -y @homie-rocks/studio@0.10.0 upgrade --apply # after the person agrees
75
+ npx -y @homie-rocks/studio@0.11.0 upgrade # the plan; changes nothing
76
+ npx -y @homie-rocks/studio@0.11.0 upgrade --apply # after the person agrees
77
77
  npm install && npm run build
78
78
  ```
79
79
 
@@ -113,6 +113,60 @@ npx homie-studio stats share on # tell the directory "pl
113
113
  Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
114
114
  studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
115
115
 
116
+ ## Setup status
117
+
118
+ ```sh
119
+ npx -y @homie-rocks/studio@0.11.0 setup status --connector yes # before a studio exists
120
+ npx homie-studio setup status # in a studio (also: homie-studio doctor)
121
+ ```
122
+
123
+ One checklist of what this computer and the person's accounts have for a studio: Node.js, the Homie
124
+ connector (the AI says whether its Homie tools are there; the directory is reached), Cloudflare (signed in
125
+ with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
126
+ deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
127
+ fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
128
+ unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
129
+ seconds, and never prints a key, a token or an account's name. A studio the Claude app's setup card made
130
+ (`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
131
+
132
+ ## The Game Codex
133
+
134
+ `games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
135
+ cover, pixel art) and a few conventions that become cards (`### Name` with a picture, `` `M-01` `` id and tag
136
+ chips, an italic subtitle and `**Key:** value` stats), tables, checklists, a dated decision log and open
137
+ questions (`lib/codex.mjs` has the whole format).
138
+
139
+ ```sh
140
+ npx homie-studio codex new crown-thief # every section, in the studio's colours
141
+ npx homie-studio codex crown-thief --open # .studio/codex/crown-thief.html, in the browser
142
+ npx homie-studio codex crown-thief --artifact # one self-contained page to publish as a Claude artifact
143
+ npx homie-studio codex link crown-thief # a one-time link to the site's private copy
144
+ ```
145
+
146
+ The page has a tab per section and a **Build status** tab from the game's progress feed; the local copy
147
+ redraws itself whenever the feed changes and refreshes in the browser while a build runs. `npm run build`
148
+ puts each game's codex on the site at `/_studio/codex/<id>/`, served only to the studio's owner (the same
149
+ one-time sign-in as the stats page), never listed or indexed. Text is escaped and the page runs only its own
150
+ script (the site allows it by its hash); pictures and fonts come only from inside the studio and are
151
+ embedded, so it loads nothing but Google Fonts. CODEX.md is never in a game's remix source or among a
152
+ static game's served files.
153
+
154
+ ## The Claude Code status line
155
+
156
+ ```sh
157
+ npx homie-studio statusline --install # .claude/settings.local.json: this person, this studio
158
+ npx homie-studio statusline --install --project .. # Claude Code started in the folder above the studio
159
+ npx homie-studio statusline --remove
160
+ ```
161
+
162
+ One line under the prompt, from the current build's feed: `▶ Crown Thief · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks
163
+ · $0.40 of $2.00`; the studio's name, dimmed, when nothing runs; a finished build's result for ten minutes.
164
+ Claude Code runs `bin/statusline.mjs` with the session as JSON on stdin (it reads only the studio's progress
165
+ files, in about 40 ms) and, with the 5 s `refreshInterval` it sets, keeps it moving while a long check runs.
166
+ It never replaces a status line the person already has (`--replace` puts this one in for this studio only).
167
+ Plugins cannot set a status line, so it is always the person's yes. Codex CLI's status line takes only its
168
+ own built-in items, so there the codex page is the progress view.
169
+
116
170
  ## A build the person can watch (the progress feed)
117
171
 
118
172
  A build of a game, song or video can keep a small progress feed, so the person follows it
@@ -62,6 +62,24 @@
62
62
  *
63
63
  * homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
64
64
  * learn its live address, and give a template copy its real name)
65
+ * homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
66
+ * what this computer and the person's accounts have for a studio: Node, the Homie
67
+ * connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
68
+ * ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
69
+ * exact fix. Read-only and safe any time, inside a studio or before one exists.
70
+ *
71
+ * homie-studio codex new <id> games/<id>/CODEX.md: the Game Codex, every section, in the game's colours
72
+ * homie-studio codex <id> [--artifact] [--open]
73
+ * the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
74
+ * itself as the build's progress changes); --artifact: a copy to publish as a
75
+ * Claude artifact; --open: open it in this computer's browser
76
+ * homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
77
+ *
78
+ * homie-studio statusline the current build in one line (what Claude Code's status line shows)
79
+ * homie-studio statusline --install [--project <folder>]
80
+ * turn it on in Claude Code for this studio (.claude/settings.local.json);
81
+ * --project: the folder Claude Code was started in, when that is above the
82
+ * studio; --remove turns it off; --replace when another status line is set
65
83
  *
66
84
  * Every command prints a few lines for a person; --json prints the result.
67
85
  */
@@ -84,10 +102,13 @@ import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, star
84
102
  import { STUDIO_VERSION } from '../lib/version.mjs';
85
103
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
86
104
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
105
+ import { formatStatus, setupStatus } from '../lib/doctor.mjs';
106
+ import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
107
+ import { installStatusLine, statusLine } from '../lib/statusline.mjs';
87
108
 
88
109
  const argv = process.argv.slice(2);
89
110
  const flags = new Map();
90
- const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh'];
111
+ const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
91
112
  const positional = [];
92
113
  for (let i = 0; i < argv.length; i++) {
93
114
  const a = argv[i];
@@ -128,6 +149,7 @@ function print(result) {
128
149
  for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
129
150
  for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
130
151
  for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
152
+ if (result.codexes?.length) lines.push(`Game Codex (the owner's private pages; codex link <id> opens one): ${result.codexes.map((x) => `/_studio/codex/${x}/`).join(', ')}`);
131
153
  break;
132
154
  case 'media list':
133
155
  for (const kind of ['music', 'videos']) {
@@ -197,6 +219,27 @@ function print(result) {
197
219
  case 'stats share':
198
220
  lines.push(result.message);
199
221
  break;
222
+ case 'setup status':
223
+ lines.push(formatStatus(result));
224
+ break;
225
+ case 'codex':
226
+ lines.push(`The Game Codex for ${result.title}: ${result.file}${result.opened ? ' (opened in the browser)' : ''}`,
227
+ ` tabs: ${result.sections.map((x) => x.title).join(' · ')} · Build status${result.build ? ` (${result.build.state}, ${result.build.percent}%)` : ''}`,
228
+ ...(result.missing.length ? [` not decided yet: ${result.missing.join(', ')}`] : [' every section has something in it']),
229
+ ` open questions: ${result.openQuestions}`,
230
+ ...result.warnings.map((w) => ` warning: ${w}`),
231
+ result.mode === 'artifact' ? ' publish this file as an artifact where your app can (Claude: the Artifact tool); it is one self-contained page' : ' on the site, for the owner only, after the next deploy: npx --no-install homie-studio codex link ' + result.id);
232
+ break;
233
+ case 'codex new':
234
+ lines.push(`Wrote ${result.file}.`, ...result.next.map((n) => ` next: ${n}`));
235
+ break;
236
+ case 'codex link':
237
+ lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
238
+ break;
239
+ case 'statusline install':
240
+ case 'statusline remove':
241
+ lines.push(result.message);
242
+ break;
200
243
  case 'setup attach':
201
244
  lines.push(result.message, ...(result.site ? [` live site: ${result.site}`] : []), ...(result.renamed?.length ? [' changed (commit these on a branch):', ...result.renamed.map((f) => ` ${f}`)] : []), ...(result.next ?? []).map((n) => ` next: ${n}`));
202
245
  break;
@@ -282,7 +325,16 @@ async function main() {
282
325
  if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
283
326
  if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
284
327
  if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
328
+ if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null });
329
+ if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
330
+ const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
331
+ if (asJson) return { ok: true, command: 'statusline', line };
332
+ if (line) process.stdout.write(`${line}\n`);
333
+ return { ok: true, command: 'help' };
334
+ }
285
335
  const root = requireStudio();
336
+ if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
337
+ if (cmd === 'codex') return codexCommand(root, sub);
286
338
  if (cmd === 'progress') return progressCommand(root, sub);
287
339
  if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
288
340
  if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
@@ -351,6 +403,28 @@ async function main() {
351
403
  return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
352
404
  }
353
405
 
406
+ /** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
407
+ function codexCommand(root, sub) {
408
+ const games = listGames(root);
409
+ if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined));
410
+ if (sub === 'link') {
411
+ const id = positional[2] ?? (games.length === 1 ? games[0].id : null);
412
+ if (!id || !games.some((g) => g.id === id)) return { ok: false, command: 'codex link', why: 'name the game: homie-studio codex link <id>' };
413
+ const r = statsLink(root, { url: flags.get('url') });
414
+ if (!r.ok) return { ...r, command: 'codex link' };
415
+ return { ...r, command: 'codex link', link: `${r.link}&to=${encodeURIComponent(codexTarget(id))}`, use: 'Give this link to the studio\'s owner to open in their own browser: it works once, within 30 minutes, opens the codex, and keeps that browser signed in to the studio\'s private pages (the codex and the stats) for 30 days. Do not post it anywhere.' };
416
+ }
417
+ const id = sub ?? (games.length === 1 ? games[0].id : null);
418
+ if (!id) return { ok: false, command: 'codex', why: `name the game: homie-studio codex <id> (${games.map((g) => g.id).join(', ') || 'no games yet'})` };
419
+ let r;
420
+ try { r = writeCodexPage(root, id, { mode: flags.has('artifact') ? 'artifact' : 'file' }); } catch (error) { return { ok: false, command: 'codex', why: error.message }; }
421
+ if (flags.has('open')) {
422
+ const opener = process.platform === 'darwin' ? ['open', [r.path]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', r.path]] : ['xdg-open', [r.path]];
423
+ r.opened = spawnSync(opener[0], opener[1], { stdio: 'ignore', timeout: 10_000 }).status === 0;
424
+ }
425
+ return r;
426
+ }
427
+
354
428
  /*
355
429
  * THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
356
430
  * deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Claude Code status line for a Homie studio (lib/statusline.mjs). Claude Code runs it with the session as
4
+ * JSON on stdin and shows what it prints; it must be quick and must never fail loudly, so it reads only the
5
+ * studio's own progress files and prints nothing at all when anything goes wrong.
6
+ *
7
+ * echo '{"workspace":{"current_dir":"."}}' | node node_modules/@homie-rocks/studio/bin/statusline.mjs
8
+ */
9
+ import { statusLine } from '../lib/statusline.mjs';
10
+
11
+ let input = '';
12
+ let printed = false;
13
+ function print() {
14
+ if (printed) return;
15
+ printed = true;
16
+ let dirs = [process.cwd()];
17
+ try {
18
+ const session = JSON.parse(input || '{}');
19
+ dirs = [session?.workspace?.current_dir, session?.workspace?.project_dir, session?.cwd, process.cwd()].filter((d) => typeof d === 'string' && d);
20
+ } catch { /* no session: this folder */ }
21
+ // `--studio <folder>`: set up in the folder above the studio, the setting names the studio.
22
+ const named = process.argv.indexOf('--studio');
23
+ if (named > 0 && process.argv[named + 1]) dirs.push(process.argv[named + 1]);
24
+ try {
25
+ const columns = Number(process.env.COLUMNS) || 100;
26
+ const color = !process.env.NO_COLOR;
27
+ for (const dir of dirs) {
28
+ const line = statusLine({ dir, columns, color });
29
+ if (line) { process.stdout.write(`${line}\n`); break; }
30
+ }
31
+ } catch { /* quiet: a status line never shows a stack */ }
32
+ // A caller that never closes stdin must not keep this process alive.
33
+ if (!process.stdin.isTTY) process.stdin.destroy();
34
+ }
35
+ if (process.stdin.isTTY) print();
36
+ else {
37
+ const timer = setTimeout(print, 400);
38
+ process.stdin.on('data', (d) => { input += d; });
39
+ process.stdin.on('end', () => { clearTimeout(timer); print(); });
40
+ process.stdin.on('error', () => { clearTimeout(timer); print(); });
41
+ }
package/lib/build.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  * posts/*.md and the studio's site/ folder (site/SITE.md)
11
11
  * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
12
12
  * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
13
+ * site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
14
+ * the studio's owner (worker/stats-page.mjs), never listed or indexed
13
15
  * site/dist/<file> site/public, as it is (fonts, a logo, hero footage)
14
16
  * site/dist/music/..., site/dist/videos/... media files the site serves itself (R2 keys are served from R2)
15
17
  *
@@ -25,6 +27,7 @@ import { spawnSync } from 'node:child_process';
25
27
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
26
28
  import { createRequire } from 'node:module';
27
29
  import { join, relative } from 'node:path';
30
+ import { buildCodexPages } from './codex.mjs';
28
31
  import { buildMedia } from './media.mjs';
29
32
  import { buildSiteFiles, landingOf, readPosts, readTheme } from './site.mjs';
30
33
  import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
@@ -33,7 +36,7 @@ import { STUDIO_VERSION } from './version.mjs';
33
36
 
34
37
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
35
38
  /** Never copied into a static game's served folder. */
36
- const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md']);
39
+ const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md', 'CODEX.md']);
37
40
  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' };
38
41
 
39
42
  /** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
@@ -67,7 +70,7 @@ export function sourceOf(dir, id) {
67
70
  let total = 0;
68
71
  const walk = (rel) => {
69
72
  for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
70
- if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name)) continue;
73
+ if (entry.name.startsWith('.') || SOURCE_SKIP.has(entry.name) || (!rel && entry.name === 'CODEX.md')) continue;
71
74
  const path = rel ? `${rel}/${entry.name}` : entry.name;
72
75
  if (entry.isDirectory()) walk(path);
73
76
  else if (TEXT.test(entry.name)) {
@@ -222,6 +225,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
222
225
  });
223
226
  const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
224
227
  const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
228
+ // Each game's Game Codex (games/<id>/CODEX.md), as the owner's private page at /_studio/codex/<id>/ (never listed).
229
+ const codexes = buildCodexPages(root, dist, games.map((g) => g.id), { log });
225
230
  mkdirSync(join(dist, '_site'), { recursive: true });
226
231
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
227
232
  const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
@@ -257,7 +262,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
257
262
  return {
258
263
  ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
259
264
  songs: catalogue.songs.map((e) => e.slug), videos: catalogue.videos.map((e) => e.slug), mediaSkipped: media.skipped,
260
- posts: posts.map((p) => p.slug), postsSkipped, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
265
+ posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
261
266
  landings: rows.map((g) => ({ id: g.id, hero: g.landing.hero.wide || g.landing.hero.tall ? 'footage' : g.landing.hero.wideImage ? 'art' : 'colours', credits: Boolean(g.landing.credits.original || g.landing.credits.people.length), source: g.landing.source })),
262
267
  };
263
268
  }
@@ -129,7 +129,12 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
129
129
  return { ok: false, command: 'deploy', needs: 'cloudflare-account', why: `This Cloudflare login can reach ${who.accounts.length} accounts; ask the person which one this studio uses and put its id in studio.json (cloudflare.accountId).`, accounts: who.accounts };
130
130
  }
131
131
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
132
- const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
132
+ const refuse = (why, out) => {
133
+ const r = { ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) };
134
+ // What the account asked for (an email to verify…) stays on this computer, for `setup status`.
135
+ if (r.needs) { try { writeLocal(root, { needs: r.needs, needsAt: new Date().toISOString() }); } catch { /* not recorded */ } }
136
+ return r;
137
+ };
133
138
  // Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
134
139
  // instead of refusing its own database as "someone else's" next time.
135
140
  const remember = (key) => {
@@ -201,7 +206,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
201
206
  // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
202
207
  if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
203
208
  const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? readLocal(root).url ?? (isWorkersDev(cf.url) ? cf.url : null);
204
- if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString() });
209
+ if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString(), needs: null, needsAt: null });
205
210
  const movedOut = isWorkersDev(cf.url);
206
211
  if (movedOut) {
207
212
  cf.url = null;