@homie-rocks/studio 0.10.0 → 0.12.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 +86 -5
- package/bin/homie-studio.mjs +91 -4
- package/bin/statusline.mjs +41 -0
- package/lib/build.mjs +11 -3
- package/lib/cloudflare.mjs +11 -7
- package/lib/codex.mjs +665 -0
- package/lib/doctor.mjs +279 -0
- package/lib/feed-summary.mjs +88 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +11 -3
- package/lib/scaffold.mjs +51 -1
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/statusline.mjs +155 -0
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/codex-script.mjs +6 -0
- package/worker/index.mjs +11 -1
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +35 -2
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
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.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
75
|
+
npx -y @homie-rocks/studio@0.12.0 upgrade # the plan; changes nothing
|
|
76
|
+
npx -y @homie-rocks/studio@0.12.0 upgrade --apply # after the person agrees
|
|
77
77
|
npm install && npm run build
|
|
78
78
|
```
|
|
79
79
|
|
|
@@ -94,6 +94,32 @@ Bots, a late joiner taking a bot's body and a new host after the old one leaves
|
|
|
94
94
|
the same at 32 as at 4. One address may hold every seat plus four sockets, so a party
|
|
95
95
|
on one Wi-Fi (or strangers behind one carrier's address) fills a room with a TV beside it.
|
|
96
96
|
|
|
97
|
+
## Player accounts and cloud saves
|
|
98
|
+
|
|
99
|
+
A game whose progress must last (a character that levels up for days, unlocks, a collection, a hardcore mode)
|
|
100
|
+
keeps it in **saves**, not in its room: a room forgets everything 60 s after its last player leaves.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "id": "my-rpg", "saves": true }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createSaves } from '@homie-rocks/studio/saves';
|
|
108
|
+
const saves = createSaves({ game: 'my-rpg' });
|
|
109
|
+
const hero = (await saves.get('hero')) ?? newHero();
|
|
110
|
+
await saves.set('hero', hero); // on this device at once, in the studio's D1 when online
|
|
111
|
+
saves.stats.add({ kills: 1 }); // lifetime numbers that outlive any hero
|
|
112
|
+
await saves.fall({ character: hero.name, summary: { level: hero.level }, wipe: true }); // hardcore
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pressing Play needs no account. A player who saves something is a guest on that browser; with a **passkey**
|
|
116
|
+
(Face ID, a fingerprint, a PIN, no password, no email) it becomes an account on this studio only, and the same
|
|
117
|
+
saves follow them to every device they sign in on. Saves are per player and game, versioned (a stale offline
|
|
118
|
+
copy is a conflict, never an overwrite), capped in size, and offline-tolerant. Players see, download and delete
|
|
119
|
+
everything at `/account/`; the owner sees counts (`homie-studio players`) and, through the back office, names,
|
|
120
|
+
never a passkey or an email. The `ember-vale` starter is a small persistent-character game on the same public
|
|
121
|
+
rooms. Everything, including what is stored and the back office's API: [saves/SAVES.md](saves/SAVES.md).
|
|
122
|
+
|
|
97
123
|
## The studio's own stats
|
|
98
124
|
|
|
99
125
|
The site counts, and never tracks: pages opened, Play presses, rooms opened, the most
|
|
@@ -113,6 +139,60 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
113
139
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
114
140
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
115
141
|
|
|
142
|
+
## Setup status
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
npx -y @homie-rocks/studio@0.12.0 setup status --connector yes # before a studio exists
|
|
146
|
+
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
One checklist of what this computer and the person's accounts have for a studio: Node.js, the Homie
|
|
150
|
+
connector (the AI says whether its Homie tools are there; the directory is reached), Cloudflare (signed in
|
|
151
|
+
with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
|
|
152
|
+
deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
|
|
153
|
+
fal (and, in Claude Code, the status line). Each row is green, missing or "do this now", says what it
|
|
154
|
+
unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
|
|
155
|
+
seconds, and never prints a key, a token or an account's name. A studio the Claude app's setup card made
|
|
156
|
+
(`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
|
|
157
|
+
|
|
158
|
+
## The Game Codex
|
|
159
|
+
|
|
160
|
+
`games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
|
|
161
|
+
cover, pixel art) and a few conventions that become cards (`### Name` with a picture, `` `M-01` `` id and tag
|
|
162
|
+
chips, an italic subtitle and `**Key:** value` stats), tables, checklists, a dated decision log and open
|
|
163
|
+
questions (`lib/codex.mjs` has the whole format).
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npx homie-studio codex new crown-thief # every section, in the studio's colours
|
|
167
|
+
npx homie-studio codex crown-thief --open # .studio/codex/crown-thief.html, in the browser
|
|
168
|
+
npx homie-studio codex crown-thief --artifact # one self-contained page to publish as a Claude artifact
|
|
169
|
+
npx homie-studio codex link crown-thief # a one-time link to the site's private copy
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The page has a tab per section and a **Build status** tab from the game's progress feed; the local copy
|
|
173
|
+
redraws itself whenever the feed changes and refreshes in the browser while a build runs. `npm run build`
|
|
174
|
+
puts each game's codex on the site at `/_studio/codex/<id>/`, served only to the studio's owner (the same
|
|
175
|
+
one-time sign-in as the stats page), never listed or indexed. Text is escaped and the page runs only its own
|
|
176
|
+
script (the site allows it by its hash); pictures and fonts come only from inside the studio and are
|
|
177
|
+
embedded, so it loads nothing but Google Fonts. CODEX.md is never in a game's remix source or among a
|
|
178
|
+
static game's served files.
|
|
179
|
+
|
|
180
|
+
## The Claude Code status line
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
npx homie-studio statusline --install # .claude/settings.local.json: this person, this studio
|
|
184
|
+
npx homie-studio statusline --install --project .. # Claude Code started in the folder above the studio
|
|
185
|
+
npx homie-studio statusline --remove
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
One line under the prompt, from the current build's feed: `▶ Crown Thief · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks
|
|
189
|
+
· $0.40 of $2.00`; the studio's name, dimmed, when nothing runs; a finished build's result for ten minutes.
|
|
190
|
+
Claude Code runs `bin/statusline.mjs` with the session as JSON on stdin (it reads only the studio's progress
|
|
191
|
+
files, in about 40 ms) and, with the 5 s `refreshInterval` it sets, keeps it moving while a long check runs.
|
|
192
|
+
It never replaces a status line the person already has (`--replace` puts this one in for this studio only).
|
|
193
|
+
Plugins cannot set a status line, so it is always the person's yes. Codex CLI's status line takes only its
|
|
194
|
+
own built-in items, so there the codex page is the progress view.
|
|
195
|
+
|
|
116
196
|
## A build the person can watch (the progress feed)
|
|
117
197
|
|
|
118
198
|
A build of a game, song or video can keep a small progress feed, so the person follows it
|
|
@@ -151,9 +231,10 @@ Workers Builds deploys the branch as a Preview; the card's **Publish** opens the
|
|
|
151
231
|
person's merge is the approval; Workers Builds deploys `main`, and the card says **Live** when the live site's
|
|
152
232
|
manifest lists the change's mark (`homie-studio build` lists the newest marks from `changes/`).
|
|
153
233
|
|
|
154
|
-
A Claude Code cloud session's default network ("Trusted")
|
|
155
|
-
|
|
156
|
-
|
|
234
|
+
A Claude Code cloud session's default network ("Trusted") reaches homie.rocks. If a session's network blocks it
|
|
235
|
+
(its proxy answers 403 `host_not_allowed`, and the progress card stops updating), the toolkit says so and names the
|
|
236
|
+
setting (the environment's Network access: Custom, add homie.rocks), instead of "did not answer". The build goes on
|
|
237
|
+
with its local feed.
|
|
157
238
|
|
|
158
239
|
The feed is `.studio/progress/<build>.json` (git-ignored). With `--share` the studio's
|
|
159
240
|
directory (studio.json `homie.directory`, homie.rocks by default) keeps a copy for 24
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
38
38
|
* homie-studio stats revoke (every stats key and page session ends)
|
|
39
39
|
* homie-studio stats share on|off (tell the homie.rocks directory "played this week", or stop)
|
|
40
|
+
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
|
+
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
40
42
|
*
|
|
41
43
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
42
44
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
@@ -62,6 +64,24 @@
|
|
|
62
64
|
*
|
|
63
65
|
* homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
|
|
64
66
|
* learn its live address, and give a template copy its real name)
|
|
67
|
+
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
68
|
+
* what this computer and the person's accounts have for a studio: Node, the Homie
|
|
69
|
+
* connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
|
|
70
|
+
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
71
|
+
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
72
|
+
*
|
|
73
|
+
* homie-studio codex new <id> games/<id>/CODEX.md: the Game Codex, every section, in the game's colours
|
|
74
|
+
* homie-studio codex <id> [--artifact] [--open]
|
|
75
|
+
* the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
|
|
76
|
+
* itself as the build's progress changes); --artifact: a copy to publish as a
|
|
77
|
+
* Claude artifact; --open: open it in this computer's browser
|
|
78
|
+
* homie-studio codex link <id> a one-time link to the codex on the live site (a private page for the owner)
|
|
79
|
+
*
|
|
80
|
+
* homie-studio statusline the current build in one line (what Claude Code's status line shows)
|
|
81
|
+
* homie-studio statusline --install [--project <folder>]
|
|
82
|
+
* turn it on in Claude Code for this studio (.claude/settings.local.json);
|
|
83
|
+
* --project: the folder Claude Code was started in, when that is above the
|
|
84
|
+
* studio; --remove turns it off; --replace when another status line is set
|
|
65
85
|
*
|
|
66
86
|
* Every command prints a few lines for a person; --json prints the result.
|
|
67
87
|
*/
|
|
@@ -78,16 +98,20 @@ import { look } from '../lib/look.mjs';
|
|
|
78
98
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
79
99
|
import { portCheck } from '../lib/port-check.mjs';
|
|
80
100
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
81
|
-
import {
|
|
101
|
+
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
82
102
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
83
103
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
84
104
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
85
105
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
|
+
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
86
107
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
108
|
+
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
109
|
+
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
110
|
+
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
87
111
|
|
|
88
112
|
const argv = process.argv.slice(2);
|
|
89
113
|
const flags = new Map();
|
|
90
|
-
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh'];
|
|
114
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
91
115
|
const positional = [];
|
|
92
116
|
for (let i = 0; i < argv.length; i++) {
|
|
93
117
|
const a = argv[i];
|
|
@@ -128,6 +152,7 @@ function print(result) {
|
|
|
128
152
|
for (const m of result.mediaSkipped ?? []) lines.push(` left out: ${m.kind}/${m.item}${m.file ? ` ${m.file}` : ''}: ${m.why}`);
|
|
129
153
|
for (const m of result.postsSkipped ?? []) lines.push(` left out: posts/${m.post}: ${m.why}`);
|
|
130
154
|
for (const m of result.siteSkipped ?? []) lines.push(` left out: ${m.what}: ${m.why}`);
|
|
155
|
+
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
156
|
break;
|
|
132
157
|
case 'media list':
|
|
133
158
|
for (const kind of ['music', 'videos']) {
|
|
@@ -185,8 +210,17 @@ function print(result) {
|
|
|
185
210
|
for (const e of result.songs) lines.push(` song ${e.slug}: ${n(e.visits)} page visits, played ${n(e.plays)}`);
|
|
186
211
|
for (const e of result.videos) lines.push(` video ${e.slug}: ${n(e.visits)} page visits, watched ${n(e.views)}`);
|
|
187
212
|
for (const r of result.referrers.slice(0, 10)) lines.push(` from ${r.from} (${r.kind}): ${n(r.visits)} visits, ${n(r.plays)} plays`);
|
|
213
|
+
if (result.players) lines.push(` players: ${n(result.players.accounts)} accounts (${n(result.players.newAccounts7d)} new this week), ${n(result.players.guests)} guests with saves, ${n(result.players.active7d)} played this week`);
|
|
188
214
|
break;
|
|
189
215
|
}
|
|
216
|
+
case 'players': {
|
|
217
|
+
const p = result.players;
|
|
218
|
+
lines.push(`${p.accounts} player accounts (${p.newAccounts7d} new this week), ${p.guests} guests with saves, ${p.active7d} played this week.`, result.note);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'players owner':
|
|
222
|
+
lines.push(result.revoked ? result.message : `One-time link (until ${result.expiresAt}): ${result.link}`, ...(result.revoked ? [] : [result.use]));
|
|
223
|
+
break;
|
|
190
224
|
case 'stats key':
|
|
191
225
|
lines.push(`Read key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
192
226
|
break;
|
|
@@ -197,6 +231,27 @@ function print(result) {
|
|
|
197
231
|
case 'stats share':
|
|
198
232
|
lines.push(result.message);
|
|
199
233
|
break;
|
|
234
|
+
case 'setup status':
|
|
235
|
+
lines.push(formatStatus(result));
|
|
236
|
+
break;
|
|
237
|
+
case 'codex':
|
|
238
|
+
lines.push(`The Game Codex for ${result.title}: ${result.file}${result.opened ? ' (opened in the browser)' : ''}`,
|
|
239
|
+
` tabs: ${result.sections.map((x) => x.title).join(' · ')} · Build status${result.build ? ` (${result.build.state}, ${result.build.percent}%)` : ''}`,
|
|
240
|
+
...(result.missing.length ? [` not decided yet: ${result.missing.join(', ')}`] : [' every section has something in it']),
|
|
241
|
+
` open questions: ${result.openQuestions}`,
|
|
242
|
+
...result.warnings.map((w) => ` warning: ${w}`),
|
|
243
|
+
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);
|
|
244
|
+
break;
|
|
245
|
+
case 'codex new':
|
|
246
|
+
lines.push(`Wrote ${result.file}.`, ...result.next.map((n) => ` next: ${n}`));
|
|
247
|
+
break;
|
|
248
|
+
case 'codex link':
|
|
249
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
250
|
+
break;
|
|
251
|
+
case 'statusline install':
|
|
252
|
+
case 'statusline remove':
|
|
253
|
+
lines.push(result.message);
|
|
254
|
+
break;
|
|
200
255
|
case 'setup attach':
|
|
201
256
|
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
257
|
break;
|
|
@@ -282,7 +337,16 @@ async function main() {
|
|
|
282
337
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
283
338
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
284
339
|
if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
|
|
340
|
+
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null });
|
|
341
|
+
if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
|
|
342
|
+
const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
|
|
343
|
+
if (asJson) return { ok: true, command: 'statusline', line };
|
|
344
|
+
if (line) process.stdout.write(`${line}\n`);
|
|
345
|
+
return { ok: true, command: 'help' };
|
|
346
|
+
}
|
|
285
347
|
const root = requireStudio();
|
|
348
|
+
if (cmd === 'statusline') return installStatusLine(root, { remove: flags.has('remove'), replace: flags.has('replace'), project: flags.get('project') ?? null });
|
|
349
|
+
if (cmd === 'codex') return codexCommand(root, sub);
|
|
286
350
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
287
351
|
if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
|
|
288
352
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
@@ -336,6 +400,8 @@ async function main() {
|
|
|
336
400
|
return { ok: true, command: 'storage', storage: has ? { kind: 'r2', bucket: cf.r2 } : null, why: has ? undefined : 'no storage yet: the studio runs without it; `homie-studio storage add` adds an R2 bucket for large media (Cloudflare asks for a payment method before R2 works)' };
|
|
337
401
|
}
|
|
338
402
|
if (cmd === 'publish') return publish(root, { homie: flags.get('homie'), site: flags.get('site') });
|
|
403
|
+
if (cmd === 'players' && sub === 'owner') return playersOwner(root, { url: flags.get('url'), revoke: flags.has('revoke') });
|
|
404
|
+
if (cmd === 'players' && !sub) return playersShow(root, { url: flags.get('url') });
|
|
339
405
|
if (cmd === 'stats' && sub === 'key') return statsKey(root, { url: flags.get('url'), hours: flags.get('hours') });
|
|
340
406
|
if (cmd === 'stats' && sub === 'link') return statsLink(root, { url: flags.get('url') });
|
|
341
407
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
@@ -351,6 +417,28 @@ async function main() {
|
|
|
351
417
|
return { ok: false, command: cmd, why: `unknown command "${[cmd, sub].filter(Boolean).join(' ')}" (homie-studio help)` };
|
|
352
418
|
}
|
|
353
419
|
|
|
420
|
+
/** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
|
|
421
|
+
function codexCommand(root, sub) {
|
|
422
|
+
const games = listGames(root);
|
|
423
|
+
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined));
|
|
424
|
+
if (sub === 'link') {
|
|
425
|
+
const id = positional[2] ?? (games.length === 1 ? games[0].id : null);
|
|
426
|
+
if (!id || !games.some((g) => g.id === id)) return { ok: false, command: 'codex link', why: 'name the game: homie-studio codex link <id>' };
|
|
427
|
+
const r = statsLink(root, { url: flags.get('url') });
|
|
428
|
+
if (!r.ok) return { ...r, command: 'codex link' };
|
|
429
|
+
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.' };
|
|
430
|
+
}
|
|
431
|
+
const id = sub ?? (games.length === 1 ? games[0].id : null);
|
|
432
|
+
if (!id) return { ok: false, command: 'codex', why: `name the game: homie-studio codex <id> (${games.map((g) => g.id).join(', ') || 'no games yet'})` };
|
|
433
|
+
let r;
|
|
434
|
+
try { r = writeCodexPage(root, id, { mode: flags.has('artifact') ? 'artifact' : 'file' }); } catch (error) { return { ok: false, command: 'codex', why: error.message }; }
|
|
435
|
+
if (flags.has('open')) {
|
|
436
|
+
const opener = process.platform === 'darwin' ? ['open', [r.path]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', r.path]] : ['xdg-open', [r.path]];
|
|
437
|
+
r.opened = spawnSync(opener[0], opener[1], { stdio: 'ignore', timeout: 10_000 }).status === 0;
|
|
438
|
+
}
|
|
439
|
+
return r;
|
|
440
|
+
}
|
|
441
|
+
|
|
354
442
|
/*
|
|
355
443
|
* THE PROGRESS FEED (lib/progress.mjs). A command that is a stage of the open build (build, check, port check,
|
|
356
444
|
* deploy) says so: the stage runs, then is done or failed with one line; a check's steps go green as they pass; a
|
|
@@ -488,8 +576,7 @@ async function dev(root) {
|
|
|
488
576
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
489
577
|
const studio = readStudio(root);
|
|
490
578
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
491
|
-
const added
|
|
492
|
-
if (added) log(`added ${added} (the studio's own stats)`);
|
|
579
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats'})`);
|
|
493
580
|
await new Promise((done) => {
|
|
494
581
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
495
582
|
m.on('close', done);
|
|
@@ -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)) {
|
|
@@ -217,11 +220,16 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
217
220
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: { min, max },
|
|
218
221
|
roundSeconds: g.roundSeconds ?? net.roundSeconds ?? null, movement: net.movement ?? null, cover: g.cover ?? null,
|
|
219
222
|
...(g.screen ? { screen: g.screen } : {}),
|
|
223
|
+
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
|
+
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
|
+
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
220
226
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
221
227
|
};
|
|
222
228
|
});
|
|
223
229
|
const { posts, skipped: postsSkipped } = readPosts(root, { games: rows, songs: media.songs, videos: media.videos, log });
|
|
224
230
|
const site = buildSiteFiles(root, dist, { gameIds: rows.map((g) => g.id), log });
|
|
231
|
+
// Each game's Game Codex (games/<id>/CODEX.md), as the owner's private page at /_studio/codex/<id>/ (never listed).
|
|
232
|
+
const codexes = buildCodexPages(root, dist, games.map((g) => g.id), { log });
|
|
225
233
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
226
234
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
227
235
|
const s = studio.site && typeof studio.site === 'object' ? studio.site : {};
|
|
@@ -257,7 +265,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
257
265
|
return {
|
|
258
266
|
ok: true, command: 'build', dist, games: built, catalogue: catalogue.games.map((g) => g.id),
|
|
259
267
|
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,
|
|
268
|
+
posts: posts.map((p) => p.slug), postsSkipped, codexes, pages: site.pages, partials: Object.keys(site.partials), public: site.public.length, siteSkipped: site.skipped,
|
|
261
269
|
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
270
|
};
|
|
263
271
|
}
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
|
-
import { ensureLocalIgnored,
|
|
25
|
+
import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
|
|
26
26
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
27
27
|
|
|
28
28
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -96,7 +96,7 @@ export function deployPlan(root) {
|
|
|
96
96
|
ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
|
|
97
97
|
cloudflare: [
|
|
98
98
|
{ kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
|
|
99
|
-
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round,
|
|
99
|
+
{ kind: 'D1 database', name: cf.d1, what: 'the directory claim, every finished round, the studio\'s own stats (daily counters of visits, plays, rooms, rounds and songs, for the owner only; nothing about a visitor), and, for games that keep saves, player accounts (a passkey\'s public key, never a password) and their saves', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
|
|
100
100
|
{ kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
101
101
|
{ kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
|
|
102
102
|
storage
|
|
@@ -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) =>
|
|
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) => {
|
|
@@ -187,8 +192,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
187
192
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
188
193
|
|
|
189
194
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
190
|
-
const added
|
|
191
|
-
if (added) step(`added ${added} (the studio's own stats: counts, never tracks)`);
|
|
195
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own stats: counts, never tracks'})`);
|
|
192
196
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
193
197
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
194
198
|
step('D1 migrations applied');
|
|
@@ -201,7 +205,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
201
205
|
// .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
|
|
202
206
|
if (ensureLocalIgnored(root)) step(`added .studio/ to .gitignore (${LOCAL_STATE} keeps this computer's own state)`);
|
|
203
207
|
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() });
|
|
208
|
+
if (workersDev) writeLocal(root, { url: workersDev, deployedAt: new Date().toISOString(), needs: null, needsAt: null });
|
|
205
209
|
const movedOut = isWorkersDev(cf.url);
|
|
206
210
|
if (movedOut) {
|
|
207
211
|
cf.url = null;
|
|
@@ -278,7 +282,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
278
282
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
279
283
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
280
284
|
if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
|
|
281
|
-
|
|
285
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${/players/.test(added) ? 'player accounts and cloud saves' : 'the studio\'s own counters'})`);
|
|
282
286
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
283
287
|
let migrate = apply();
|
|
284
288
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|