@homie-rocks/studio 0.12.1 → 0.14.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 +92 -4
- package/bin/homie-studio.mjs +83 -8
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +5 -5
- package/lib/codex.mjs +28 -9
- package/lib/demo.mjs +85 -0
- package/lib/files.mjs +154 -0
- package/lib/handoff.mjs +128 -0
- package/lib/jobs.mjs +181 -0
- package/lib/mcp-tools.mjs +1000 -0
- package/lib/mcp.mjs +180 -0
- package/lib/office.mjs +172 -0
- package/lib/scaffold.mjs +98 -25
- package/lib/stats.mjs +1 -1
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +52 -0
- package/lib/upgrade.mjs +6 -3
- package/mcp/ui/bridge.js +148 -0
- package/mcp/ui/build.js +63 -0
- package/mcp/ui/card.css +73 -0
- package/mcp/ui/codex.js +23 -0
- package/mcp/ui/setup.js +72 -0
- package/mcp/ui/studio.js +41 -0
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +2 -1
- package/worker/index.mjs +172 -33
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/site.mjs +24 -2
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -19,9 +19,36 @@ npx homie-studio deploy --plan # what deploy will create
|
|
|
19
19
|
npx homie-studio deploy # the studio's own Cloudflare
|
|
20
20
|
npx homie-studio publish # the homie.rocks directory
|
|
21
21
|
npx homie-studio stats # the studio's own numbers, for its owner
|
|
22
|
+
npx homie-studio office # who is playing now, in every live room (the back office)
|
|
22
23
|
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
23
24
|
```
|
|
24
25
|
|
|
26
|
+
A new studio has no game: its home page says "First game coming soon" until the first one is made, and
|
|
27
|
+
`homie-studio demo` names a live game on Homie Arcade to try meanwhile. A starter is copied in only when the person
|
|
28
|
+
asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who lasts, with cloud saves).
|
|
29
|
+
|
|
30
|
+
## In one chat: `homie-studio mcp`
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" # the toolkit as a local MCP server (stdio)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
|
|
37
|
+
the live demo, make / remix / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
|
|
38
|
+
playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
|
|
39
|
+
their provider is set up. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and
|
|
40
|
+
input shape. Long work (npm install, a check, a deploy) runs in the background and reports through the progress
|
|
41
|
+
feed; MCP Apps cards (setup, build progress, studio, codex) are served as `ui://homie-studio/*`, and every tool also
|
|
42
|
+
answers in plain text. Homie for Claude Desktop (`desktop/` in this repository) is this server as a Desktop
|
|
43
|
+
Extension: the plain Claude app gets it with one install, and no terminal.
|
|
44
|
+
|
|
45
|
+
## From a phone: a one-line hand-off
|
|
46
|
+
|
|
47
|
+
The Claude app on a phone hands a build to a Claude Code cloud session with one line, `Continue building <Studio>:
|
|
48
|
+
build hb_…`. In the session, `homie-studio handoff hb_…` fetches the brief from the directory by the build id, checks
|
|
49
|
+
in for a studio still being set up, takes the build so the chat's card follows it, and prints the steps (every
|
|
50
|
+
studio's `HANDOFF.md` says so).
|
|
51
|
+
|
|
25
52
|
## From GitHub, with Cloudflare's own CI (Workers Builds, Previews, Deploy to Cloudflare)
|
|
26
53
|
|
|
27
54
|
A studio keeps `wrangler.jsonc` at its root (a studio made before 0.10.0 keeps `site/wrangler.jsonc`, and every
|
|
@@ -38,7 +65,8 @@ computer involved:
|
|
|
38
65
|
|
|
39
66
|
The button copies the public template (`template/` in this repository, exactly what `homie-studio new --template`
|
|
40
67
|
writes) into the person's GitHub, makes the Worker, the D1 database and the rooms on their Cloudflare account,
|
|
41
|
-
and connects Workers Builds. The site
|
|
68
|
+
and connects Workers Builds. The site goes live with its own home page ("First game coming soon": no starter game
|
|
69
|
+
is put in a studio unless the person asks for one), and its Home has a
|
|
42
70
|
**Connect to Claude** band: one tap links it to the Claude chat that set it up (the Homie MCP tool `studio_setup`).
|
|
43
71
|
In the Claude Code session that works in the repository, `homie-studio setup attach <hs_…>` then gives the studio
|
|
44
72
|
the name chosen in the chat and removes the band.
|
|
@@ -72,8 +100,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
72
100
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
73
101
|
|
|
74
102
|
```sh
|
|
75
|
-
npx -y @homie-rocks/studio@0.
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
103
|
+
npx -y @homie-rocks/studio@0.14.0 upgrade # the plan; changes nothing
|
|
104
|
+
npx -y @homie-rocks/studio@0.14.0 upgrade --apply # after the person agrees
|
|
77
105
|
npm install && npm run build
|
|
78
106
|
```
|
|
79
107
|
|
|
@@ -139,10 +167,70 @@ npx homie-studio stats share on # tell the directory "pl
|
|
|
139
167
|
Each key is minted on the owner's computer; only its SHA-256 goes into D1, through the
|
|
140
168
|
studio's own Cloudflare login. `worker/stats.mjs` says exactly what is counted.
|
|
141
169
|
|
|
170
|
+
## The back office: run your live games
|
|
171
|
+
|
|
172
|
+
The owner sees and runs the studio's live games from `/_studio/office`, a private page
|
|
173
|
+
that refreshes itself: every live room of every game (players, bots, the round, how long
|
|
174
|
+
it has been up) and who is in it (each seat's handle, its device, whether it hosts, the
|
|
175
|
+
browser it came from; signed-in players once a studio has player accounts). From there,
|
|
176
|
+
and from a small Owner button in the owner's own play page (nobody else's page carries
|
|
177
|
+
it):
|
|
178
|
+
|
|
179
|
+
- **Kick** a player: they get a polite notice and cannot come back to that room for the
|
|
180
|
+
minutes the owner chose (their seat, their browser, their account; their network only
|
|
181
|
+
when asked, since a household shares one).
|
|
182
|
+
- **Mute** a player: their chat and emotes reach nobody (the `say`, `chat` and `emote`
|
|
183
|
+
events; a game hides the rest with `net.isMuted(seat)`).
|
|
184
|
+
- **Announce** one line to a room, a game or the whole studio: every player sees it as a
|
|
185
|
+
banner, and a game can show it its own way (`net.on('announce')`).
|
|
186
|
+
- **Close** a room: everyone is sent out with a thank-you; nobody gets in until it opens.
|
|
187
|
+
- Per game: **players per room**, the **launch state** (`private`: only the owner;
|
|
188
|
+
`invite`: an invite-only beta, where each invite link or code lets a browser in;
|
|
189
|
+
`public`: anyone, listed), and **remixable** (publish or withdraw the game's source).
|
|
190
|
+
A game that is not public is in no list and not in the directory manifest, so the
|
|
191
|
+
directory drops it the next time it reads the studio. A new game is private from its
|
|
192
|
+
first deploy with `"launch": "private"` in its game.json.
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
npx homie-studio office link # a one-time link that signs the owner's browser in
|
|
196
|
+
npx homie-studio office link --to /crown-thief/play # ... onto a private game, on the owner's phone
|
|
197
|
+
npx homie-studio office # every live room and who is in it, now
|
|
198
|
+
npx homie-studio office announce "Double gems this round!" --game crown-thief
|
|
199
|
+
npx homie-studio office invite crown-thief --label "Sam" --uses 1
|
|
200
|
+
npx homie-studio office launch crown-thief invite # asks: the owner confirms with one tap
|
|
201
|
+
npx homie-studio office kick crown-thief pub-3 2 # asks: seat 2 of Room 3
|
|
202
|
+
npx homie-studio office key # a key for the Homie MCP's owner tools
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Going private or invite-only never cuts a round short: each live room finishes its current round with a notice to
|
|
206
|
+
the players, then everyone the new state leaves out is sent out with a thank-you (the owner, and in a beta the
|
|
207
|
+
invited, play on). Mute drops a player's `say…`, `chat…` and `emote…` events, so a game that sends its chat and
|
|
208
|
+
emotes under those kinds needs nothing more (`netplay/NETPLAY.md` section 15). Kicking a whole network address is in
|
|
209
|
+
the studio's API (`address: true`) and deliberately not in the office. A Preview (a branch's own unlisted address, with
|
|
210
|
+
no D1) enforces no launch state and has no office.
|
|
211
|
+
|
|
212
|
+
**Privacy.** The site still sets no cookie on a visitor to count or follow them. Two things are kept for the back
|
|
213
|
+
office, both functional: a random room key in the play page's own storage (what a kick holds; it says nothing about
|
|
214
|
+
who the player is), and, for an invited player of an invite-only beta only, their pass to that game: an HttpOnly
|
|
215
|
+
cookie for that game's pages alone, for 90 days. D1 keeps only a hash of each pass.
|
|
216
|
+
|
|
217
|
+
**Only the owner.** The owner's browser is signed in with the same one-time link as the
|
|
218
|
+
stats (an HttpOnly session no page or game can read; games run in a sandboxed, opaque
|
|
219
|
+
frame). An office key (minted with the studio's own Cloudflare login) lets the owner's AI
|
|
220
|
+
look, announce and invite at once; a kick, a mute, a closed room or a launch change is only
|
|
221
|
+
**asked** for, and the owner confirms it with one tap in their own browser. No key can
|
|
222
|
+
confirm. The Worker signs each control with the studio's own secret and the room verifies
|
|
223
|
+
it before it acts (`netplay/NETPLAY.md` section 15). Everything is in the studio's own
|
|
224
|
+
Worker and D1 (migration `0003_studio_office.sql`); homie.rocks stores none of it.
|
|
225
|
+
|
|
226
|
+
With player accounts (0.12.0), a signed-in player's play page names their account in its room ticket (an invited
|
|
227
|
+
player's names the invite and the account), so the office shows them by name and a kick holds the account on every
|
|
228
|
+
device; the owner's own passkey account (`homie-studio players owner`) counts as the owner.
|
|
229
|
+
|
|
142
230
|
## Setup status
|
|
143
231
|
|
|
144
232
|
```sh
|
|
145
|
-
npx -y @homie-rocks/studio@0.
|
|
233
|
+
npx -y @homie-rocks/studio@0.14.0 setup status --connector yes # before a studio exists
|
|
146
234
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
147
235
|
```
|
|
148
236
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
* list its games in the homie.rocks directory.
|
|
6
6
|
*
|
|
7
7
|
* homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install] [--template]
|
|
8
|
-
* (--template: the public "Deploy to Cloudflare" template
|
|
8
|
+
* (--template: the public "Deploy to Cloudflare" template, with a Connect band;
|
|
9
|
+
* a new studio has no game: its home says "First game coming soon")
|
|
9
10
|
* homie-studio starters
|
|
11
|
+
* homie-studio demo a live multiplayer game to try now (Homie Arcade), nothing copied into the studio
|
|
12
|
+
* homie-studio mcp [--studios <folder>] [--skills <folder>]
|
|
13
|
+
* this toolkit as a local MCP server (stdio): the Claude desktop app's Homie
|
|
14
|
+
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
|
+
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
10
16
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
11
17
|
* homie-studio game remix <source.json url> --id <new id>
|
|
12
18
|
* homie-studio games
|
|
@@ -40,6 +46,19 @@
|
|
|
40
46
|
* homie-studio players (player accounts and guests with saves: counts, never a passkey or an email)
|
|
41
47
|
* homie-studio players owner [--revoke] (a one-time link that marks the owner's own player account as the owner's)
|
|
42
48
|
*
|
|
49
|
+
* homie-studio office [--url <site>] the back office: every live room of every game and who is in it, now
|
|
50
|
+
* homie-studio office link [--to <path>] a one-time link that signs the owner's browser in (the office; or
|
|
51
|
+
* --to /<game>/play: a private game on the owner's own phone)
|
|
52
|
+
* homie-studio office key [--hours 1] a key for the Homie MCP's owner tools (studio_office, room_kick, ...)
|
|
53
|
+
* homie-studio office announce "<text>" [--game <id>] [--room <code>] [--seconds 30]
|
|
54
|
+
* homie-studio office invite <game> [--label "<who>"] [--uses 1|<n>|any] [--count 1] [--days <n>]
|
|
55
|
+
* homie-studio office launch <game> private|invite|public [--remixable on|off] [--max <n>|game]
|
|
56
|
+
* homie-studio office kick <game> <room> <seat number | name> [--minutes 10]
|
|
57
|
+
* homie-studio office close <game> <room> [--minutes 10] [--reopen]
|
|
58
|
+
* (kick, close and launch are ASKED for: the owner confirms each with one
|
|
59
|
+
* tap in their own browser, from the link this prints)
|
|
60
|
+
* homie-studio office revoke (every office key, play ticket and pending ask ends)
|
|
61
|
+
*
|
|
43
62
|
* homie-studio progress start [<id>] [--what game|song|video] [--title "<what this build does>"]
|
|
44
63
|
* [--budget <dollars>] [--unit usd|credits] [--share]
|
|
45
64
|
* (a progress feed for one build; --share shows it in the Claude app
|
|
@@ -62,6 +81,9 @@
|
|
|
62
81
|
* into it (stage, each check going green, a preview picture) and stop
|
|
63
82
|
* when asked. Without one, nothing changes.
|
|
64
83
|
*
|
|
84
|
+
* homie-studio handoff <hb_…> (a Claude Code session started from the Claude app with one line, "Continue building
|
|
85
|
+
* <Studio>: build hb_…": fetch the person's brief, check in for a new studio, take
|
|
86
|
+
* the build so the chat's card follows it, and print the steps; HANDOFF.md)
|
|
65
87
|
* homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
|
|
66
88
|
* learn its live address, and give a template copy its real name)
|
|
67
89
|
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
@@ -70,7 +92,9 @@
|
|
|
70
92
|
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
71
93
|
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
72
94
|
*
|
|
73
|
-
* homie-studio codex new <id>
|
|
95
|
+
* homie-studio codex new <id> [--name "<Name>"]
|
|
96
|
+
* games/<id>/CODEX.md: the Game Codex, every section, in the game's colours (a game
|
|
97
|
+
* is planned before it is made: with no game <id> yet, it starts its folder)
|
|
74
98
|
* homie-studio codex <id> [--artifact] [--open]
|
|
75
99
|
* the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
|
|
76
100
|
* itself as the build's progress changes); --artifact: a copy to publish as a
|
|
@@ -98,21 +122,25 @@ import { look } from '../lib/look.mjs';
|
|
|
98
122
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
99
123
|
import { portCheck } from '../lib/port-check.mjs';
|
|
100
124
|
import { recordUpload, resolveMedia, typeOf } from '../lib/media.mjs';
|
|
101
|
-
import { ensureMigrations, newStudio } from '../lib/scaffold.mjs';
|
|
125
|
+
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
102
126
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
103
127
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
104
128
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
105
129
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
106
130
|
import { playersOwner, playersShow } from '../lib/players.mjs';
|
|
131
|
+
import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeRevoke, officeShow } from '../lib/office.mjs';
|
|
107
132
|
import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
|
|
108
133
|
import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
109
134
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
110
135
|
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
111
136
|
import { restartWithProxy } from '../lib/net.mjs';
|
|
137
|
+
import { demoGames, formatDemo } from '../lib/demo.mjs';
|
|
138
|
+
import { serveMcp } from '../lib/mcp.mjs';
|
|
139
|
+
import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
112
140
|
|
|
113
141
|
const argv = process.argv.slice(2);
|
|
114
142
|
const flags = new Map();
|
|
115
|
-
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open'];
|
|
143
|
+
const BOOL_FLAGS = ['revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen'];
|
|
116
144
|
const positional = [];
|
|
117
145
|
for (let i = 0; i < argv.length; i++) {
|
|
118
146
|
const a = argv[i];
|
|
@@ -128,7 +156,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
128
156
|
|
|
129
157
|
function print(result) {
|
|
130
158
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
131
|
-
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
159
|
+
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
|
|
132
160
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
133
161
|
const lines = [];
|
|
134
162
|
switch (result.command) {
|
|
@@ -232,9 +260,37 @@ function print(result) {
|
|
|
232
260
|
case 'stats share':
|
|
233
261
|
lines.push(result.message);
|
|
234
262
|
break;
|
|
263
|
+
case 'office':
|
|
264
|
+
lines.push(...officeLines(result));
|
|
265
|
+
break;
|
|
266
|
+
case 'office link':
|
|
267
|
+
lines.push(`One-time link (until ${result.expiresAt}): ${result.link}`, result.use);
|
|
268
|
+
break;
|
|
269
|
+
case 'office key':
|
|
270
|
+
lines.push(`Office key (until ${result.expiresAt}): ${result.key}`, result.use);
|
|
271
|
+
break;
|
|
272
|
+
case 'office announce':
|
|
273
|
+
case 'office revoke':
|
|
274
|
+
lines.push(result.message);
|
|
275
|
+
break;
|
|
276
|
+
case 'office invite':
|
|
277
|
+
for (const i of result.invites) lines.push(`Invite ${i.code}${i.label ? ` (${i.label})` : ''}, ${i.maxUses ? `${i.maxUses} use${i.maxUses === 1 ? '' : 's'}` : 'any number of uses'}: ${i.link}`);
|
|
278
|
+
if (result.note) lines.push(result.note);
|
|
279
|
+
break;
|
|
280
|
+
case 'office launch':
|
|
281
|
+
case 'office kick':
|
|
282
|
+
case 'office close':
|
|
283
|
+
lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
|
|
284
|
+
break;
|
|
235
285
|
case 'setup status':
|
|
236
286
|
lines.push(formatStatus(result));
|
|
237
287
|
break;
|
|
288
|
+
case 'demo':
|
|
289
|
+
lines.push(formatDemo(result));
|
|
290
|
+
break;
|
|
291
|
+
case 'handoff':
|
|
292
|
+
lines.push(formatHandoff(result));
|
|
293
|
+
break;
|
|
238
294
|
case 'codex':
|
|
239
295
|
lines.push(`The Game Codex for ${result.title}: ${result.file}${result.opened ? ' (opened in the browser)' : ''}`,
|
|
240
296
|
` tabs: ${result.sections.map((x) => x.title).join(' · ')} · Build status${result.build ? ` (${result.build.state}, ${result.build.percent}%)` : ''}`,
|
|
@@ -334,6 +390,12 @@ async function main() {
|
|
|
334
390
|
if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
|
|
335
391
|
if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
|
|
336
392
|
if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
|
|
393
|
+
if (cmd === 'demo') return demoGames();
|
|
394
|
+
if (cmd === 'mcp') {
|
|
395
|
+
// stdout carries MCP messages only from here on (lib/mcp.mjs); every word for a person goes to stderr.
|
|
396
|
+
await serveMcp({ studios: flags.get('studios') ?? process.env.HOMIE_STUDIOS ?? null, skills: flags.get('skills') ?? process.env.HOMIE_SKILLS ?? null, install: !flags.has('no-install'), directory: flags.get('homie') ?? null });
|
|
397
|
+
return { ok: true, command: 'help' };
|
|
398
|
+
}
|
|
337
399
|
|
|
338
400
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
339
401
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
@@ -350,6 +412,7 @@ async function main() {
|
|
|
350
412
|
if (cmd === 'codex') return codexCommand(root, sub);
|
|
351
413
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
352
414
|
if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
|
|
415
|
+
if (cmd === 'handoff') return handoff(root, sub, { homie: flags.get('homie') });
|
|
353
416
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
354
417
|
if (cmd === 'port' && sub === 'check') {
|
|
355
418
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
@@ -408,6 +471,18 @@ async function main() {
|
|
|
408
471
|
if (cmd === 'stats' && sub === 'revoke') return statsRevoke(root, { url: flags.get('url') });
|
|
409
472
|
if (cmd === 'stats' && sub === 'share') return statsShare(root, positional[2]);
|
|
410
473
|
if (cmd === 'stats' && !sub) return statsShow(root, { url: flags.get('url'), range: flags.get('range'), game: flags.get('game'), song: flags.get('song'), video: flags.get('video') });
|
|
474
|
+
if (cmd === 'office') {
|
|
475
|
+
const url = flags.get('url');
|
|
476
|
+
if (!sub) return officeShow(root, { url });
|
|
477
|
+
if (sub === 'link') return officeLink(root, { url, to: flags.get('to') });
|
|
478
|
+
if (sub === 'key') return officeKey(root, { url, hours: flags.get('hours') });
|
|
479
|
+
if (sub === 'announce') return officeAnnounce(root, positional.slice(2).join(' '), { url, game: flags.get('game'), room: flags.get('room'), seconds: flags.get('seconds') });
|
|
480
|
+
if (sub === 'invite') return officeInvite(root, positional[2], { url, label: flags.get('label'), uses: flags.get('uses'), count: flags.get('count'), days: flags.get('days') });
|
|
481
|
+
if (sub === 'launch') return officeLaunch(root, positional[2], positional[3], { url, remixable: flags.get('remixable'), max: flags.get('max') });
|
|
482
|
+
if (sub === 'kick') return officeKick(root, positional[2], positional[3], positional.slice(4).join(' ') || undefined, { url, minutes: flags.get('minutes') });
|
|
483
|
+
if (sub === 'close') return officeClose(root, positional[2], positional[3], { url, minutes: flags.get('minutes'), reopen: flags.has('reopen') });
|
|
484
|
+
if (sub === 'revoke') return officeRevoke(root, { url });
|
|
485
|
+
}
|
|
411
486
|
if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
|
|
412
487
|
if (cmd === 'media' && sub === 'list') {
|
|
413
488
|
const studio = readStudio(root);
|
|
@@ -421,7 +496,7 @@ async function main() {
|
|
|
421
496
|
/** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
|
|
422
497
|
function codexCommand(root, sub) {
|
|
423
498
|
const games = listGames(root);
|
|
424
|
-
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined));
|
|
499
|
+
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined), { name: flags.get('name') ?? null });
|
|
425
500
|
if (sub === 'link') {
|
|
426
501
|
const id = positional[2] ?? (games.length === 1 ? games[0].id : null);
|
|
427
502
|
if (!id || !games.some((g) => g.id === id)) return { ok: false, command: 'codex link', why: 'name the game: homie-studio codex link <id>' };
|
|
@@ -572,12 +647,12 @@ async function stopDev(root) {
|
|
|
572
647
|
async function dev(root) {
|
|
573
648
|
const port = String(flags.get('port') ?? 8787);
|
|
574
649
|
const b = await build(root, { log });
|
|
575
|
-
if (!b.catalogue.length && !b.songs.length && !b.videos.length)
|
|
650
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
576
651
|
const bin = wranglerBin(root);
|
|
577
652
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
578
653
|
const studio = readStudio(root);
|
|
579
654
|
const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' };
|
|
580
|
-
for (const added of ensureMigrations(root)) log(`added ${added} (${
|
|
655
|
+
for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
|
|
581
656
|
await new Promise((done) => {
|
|
582
657
|
const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
583
658
|
m.on('close', done);
|
package/lib/build.mjs
CHANGED
|
@@ -223,6 +223,8 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
223
223
|
// game.json "saves": true — player accounts and cloud saves (saves/SAVES.md): the play shell answers the game's
|
|
224
224
|
// saves calls, and the site's nav and the game's landing offer a player account.
|
|
225
225
|
...(g.saves === true || (g.saves && typeof g.saves === 'object') ? { saves: true } : {}),
|
|
226
|
+
// game.json `"launch": "private" | "invite"`: the game's launch state until its owner sets one live (the office).
|
|
227
|
+
...(g.launch === 'private' || g.launch === 'invite' ? { launch: g.launch } : {}),
|
|
226
228
|
landing: landingOf(g, join(dist, 'games', g.id), { videos: media.videos, songs: media.songs, log }),
|
|
227
229
|
};
|
|
228
230
|
});
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { build } from './build.mjs';
|
|
25
25
|
import { repoOf } from './repo.mjs';
|
|
26
|
-
import { ensureLocalIgnored, ensureMigrations, wranglerConfig } from './scaffold.mjs';
|
|
26
|
+
import { ensureLocalIgnored, ensureMigrations, migrationWord, wranglerConfig } from './scaffold.mjs';
|
|
27
27
|
import { LOCAL_STATE, configPath, isWorkersDev, layoutOf, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
|
|
28
28
|
|
|
29
29
|
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
@@ -159,8 +159,8 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const b = await build(root, { log });
|
|
162
|
-
|
|
163
|
-
step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
|
|
162
|
+
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
163
|
+
step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
|
|
164
164
|
|
|
165
165
|
// The Worker: never one this studio did not make.
|
|
166
166
|
if (!created.has(`worker:${cf.worker}`)) {
|
|
@@ -193,7 +193,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
193
193
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
194
194
|
|
|
195
195
|
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root) }));
|
|
196
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
196
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
197
197
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
198
198
|
if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
|
|
199
199
|
step('D1 migrations applied');
|
|
@@ -293,7 +293,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
|
|
|
293
293
|
const step = (what) => { steps.push({ what }); log(what); };
|
|
294
294
|
const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
|
|
295
295
|
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 };
|
|
296
|
-
for (const added of ensureMigrations(root)) step(`added ${added} (${
|
|
296
|
+
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
297
297
|
const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
|
|
298
298
|
let migrate = apply();
|
|
299
299
|
const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
|
package/lib/codex.mjs
CHANGED
|
@@ -471,6 +471,20 @@ function lookOf(root, game, meta, emb) {
|
|
|
471
471
|
return { palette, fonts: { display: fam.display ?? stack.display, body: fam.body ?? stack.body, mono: fam.mono ?? stack.mono }, links: [...new Set(links)], faces };
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* The game a codex belongs to. A new studio has no game, and the plan comes before the game is made, so a codex may
|
|
476
|
+
* sit in games/<id>/ before its game.json does: then the "game" is that planned folder. `create`: a codex is being
|
|
477
|
+
* started for an id with no folder yet.
|
|
478
|
+
*/
|
|
479
|
+
function codexGame(root, id, { create = false } = {}) {
|
|
480
|
+
const game = listGames(root).find((g) => g.id === id);
|
|
481
|
+
if (game) return game;
|
|
482
|
+
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
483
|
+
const dir = join(root, 'games', id);
|
|
484
|
+
if (existsSync(join(dir, 'game.json'))) return null;
|
|
485
|
+
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
474
488
|
/**
|
|
475
489
|
* The page for one game. `mode`: 'file' (this computer; reloads while a build runs), 'artifact' (one file to publish
|
|
476
490
|
* as a Claude artifact), 'site' (the studio site's private page). `feed`: a progress feed document, else the game's
|
|
@@ -478,8 +492,8 @@ function lookOf(root, game, meta, emb) {
|
|
|
478
492
|
*/
|
|
479
493
|
export function renderCodex(root, id, { mode = 'file', feed, now = new Date() } = {}) {
|
|
480
494
|
if (!GAME_ID.test(String(id ?? ''))) throw new Error(`no game id ${JSON.stringify(id)}`);
|
|
481
|
-
const game =
|
|
482
|
-
if (!game) throw new Error(`no game "${id}" in games
|
|
495
|
+
const game = codexGame(root, id);
|
|
496
|
+
if (!game) throw new Error(`no game "${id}" in games/, and no codex planning it`);
|
|
483
497
|
const file = join(game.dir, CODEX_FILE);
|
|
484
498
|
if (!existsSync(file)) throw new Error(`games/${id}/${CODEX_FILE} does not exist yet: npx --no-install homie-studio codex new ${id}`);
|
|
485
499
|
if (statSync(file).size > LIMITS.source) throw new Error(`games/${id}/${CODEX_FILE} is over ${LIMITS.source / 1024} KB`);
|
|
@@ -576,9 +590,12 @@ export function buildCodexPages(root, dist, ids, { log = () => {} } = {}) {
|
|
|
576
590
|
/* ------------------------------------------------------------------ a new codex */
|
|
577
591
|
|
|
578
592
|
/** `homie-studio codex new <id>`: CODEX.md with every section, its look taken from the game and the studio. */
|
|
579
|
-
export function newCodex(root, id) {
|
|
580
|
-
|
|
581
|
-
|
|
593
|
+
export function newCodex(root, id, { name = null } = {}) {
|
|
594
|
+
// A game is planned before it is made: with no game of this id, the codex starts its folder (games/<id>/CODEX.md),
|
|
595
|
+
// and `game new <id>` later makes the game around it.
|
|
596
|
+
const game = codexGame(root, id, { create: true });
|
|
597
|
+
if (!game) return { ok: false, command: 'codex new', why: `"${id}" is not a game id (lowercase letters, digits and hyphens, up to 40)` };
|
|
598
|
+
if (game.planned) { game.name = name ? String(name).slice(0, 60) : id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); mkdirSync(game.dir, { recursive: true }); }
|
|
582
599
|
const file = join(game.dir, CODEX_FILE);
|
|
583
600
|
if (existsSync(file)) return { ok: false, command: 'codex new', why: `games/${id}/${CODEX_FILE} exists already: change it, never replace it` };
|
|
584
601
|
const theme = readTheme(root);
|
|
@@ -608,7 +625,7 @@ ${game.blurb ?? ''}
|
|
|
608
625
|
|
|
609
626
|
## Latest
|
|
610
627
|
|
|
611
|
-
- ${today}: The codex starts. The game is a copy of the ${game.from?.starter ?? 'starter'} starter for now
|
|
628
|
+
- ${today}: The codex starts. ${game.planned ? 'The game is planned here first; it is made once the plan is agreed.' : `The game is a copy of the ${game.from?.starter ?? 'starter'} starter for now.`}
|
|
612
629
|
|
|
613
630
|
## Concept
|
|
614
631
|
|
|
@@ -645,18 +662,20 @@ ${game.blurb ?? ''}
|
|
|
645
662
|
|
|
646
663
|
## Milestones
|
|
647
664
|
|
|
648
|
-
|
|
665
|
+
${game.planned ? `- [ ] The plan (this codex)
|
|
666
|
+
- [ ] The first playable version of the plan, checked with two browsers
|
|
667
|
+
- [ ] Art, sound and its landing page; online` : `- [x] Step 1: a working copy of the starter plays in two browsers
|
|
649
668
|
- [ ] Step 2: one small change from one sentence
|
|
650
669
|
- [ ] Step 3: the plan (this codex)
|
|
651
670
|
- [ ] Step 4: the first playable version of the plan, checked with two browsers
|
|
652
|
-
- [ ] Step 5: art, sound and its landing page; online
|
|
671
|
+
- [ ] Step 5: art, sound and its landing page; online`}
|
|
653
672
|
|
|
654
673
|
## Open questions
|
|
655
674
|
|
|
656
675
|
-
|
|
657
676
|
`;
|
|
658
677
|
writeFileSync(file, text);
|
|
659
|
-
return { ok: true, command: 'codex new', id, file: relative(root, file), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`] };
|
|
678
|
+
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`once the plan is agreed: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
|
|
660
679
|
}
|
|
661
680
|
|
|
662
681
|
/** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
|
package/lib/demo.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio demo` — "see a working game" with nothing copied into the studio: a live multiplayer game on Homie
|
|
3
|
+
* Arcade (arcade.homie.rocks), the house's own studio of small public games, made with this same toolkit. Press Play
|
|
4
|
+
* and you are in a live public room with whoever is playing, bots in the empty seats; open it in two tabs (or a
|
|
5
|
+
* phone and a computer) and you are two players in the same room.
|
|
6
|
+
*
|
|
7
|
+
* A new studio starts with no game (its home page says "First game coming soon"). A starter is copied INTO the
|
|
8
|
+
* studio only when the person asks for one: `homie-studio game new <id> --from gem-rush`.
|
|
9
|
+
*
|
|
10
|
+
* It reads the arcade's own public manifest (/.well-known/homie-studio.json) and its live rooms (/api/rooms), so the
|
|
11
|
+
* links are the ones live right now; when the arcade does not answer, it names its usual first pick. Read-only.
|
|
12
|
+
*/
|
|
13
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
14
|
+
|
|
15
|
+
export const DEMO_STUDIO = 'https://arcade.homie.rocks';
|
|
16
|
+
/** The arcade's games that show a stranger the most in a minute, in order. */
|
|
17
|
+
export const DEMO_PICKS = ['asteroids-arena', 'bone-burglar', 'tiny-platformer', '2048-race', 'octree-arena'];
|
|
18
|
+
export const DEMO_FALLBACK = Object.freeze({
|
|
19
|
+
id: 'asteroids-arena', name: 'Asteroids Arena', blurb: 'Up to six ships on one wrap-around rock field. Top score in two minutes wins.',
|
|
20
|
+
page: `${DEMO_STUDIO}/asteroids-arena/`, play: `${DEMO_STUDIO}/asteroids-arena/play`, cover: null, players: { min: 1, max: 6 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const HOW = 'Open Play in two browser tabs, or on a phone and a computer: you are two players in the same live public room, and bots fill the empty seats. Keys on a computer, touch on a phone.';
|
|
24
|
+
const COPY = 'Want a working game inside your own studio instead? Only when you ask: `npx --no-install homie-studio game new <id> --from gem-rush` copies the Gem Rush starter (an arena: rounds, bots, one readable file), or `--from ember-vale` the Ember Vale starter (a hero who lasts for days, with cloud saves), into games/<id>/.';
|
|
25
|
+
|
|
26
|
+
const text = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
27
|
+
const onSite = (url, site) => (typeof url === 'string' && url.startsWith(`${site}/`) ? url : null);
|
|
28
|
+
|
|
29
|
+
async function getJson(fetchFn, url, ms) {
|
|
30
|
+
const res = await fetchFn(url, { headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION} (demo)` }, signal: AbortSignal.timeout(ms) });
|
|
31
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUDIO, timeoutMs = 6000 } = {}) {
|
|
36
|
+
const site = String(studio).replace(/\/+$/, '');
|
|
37
|
+
let manifest = null; let rooms = null; let why = null;
|
|
38
|
+
try {
|
|
39
|
+
[manifest, rooms] = await Promise.all([
|
|
40
|
+
getJson(fetchFn, `${site}/.well-known/homie-studio.json`, timeoutMs),
|
|
41
|
+
getJson(fetchFn, `${site}/api/rooms`, timeoutMs).catch(() => null),
|
|
42
|
+
]);
|
|
43
|
+
} catch (error) { why = error?.message ?? String(error); }
|
|
44
|
+
const live = new Map();
|
|
45
|
+
for (const r of Array.isArray(rooms?.rooms) ? rooms.rooms : []) {
|
|
46
|
+
const id = text(r?.game, 40);
|
|
47
|
+
if (id) live.set(id, (live.get(id) ?? 0) + (Number(r?.players) || 0));
|
|
48
|
+
}
|
|
49
|
+
const listed = (Array.isArray(manifest?.games) ? manifest.games : [])
|
|
50
|
+
.map((g) => ({
|
|
51
|
+
id: text(g?.id, 40), name: text(g?.name, 60), blurb: text(g?.blurb, 200),
|
|
52
|
+
page: onSite(g?.page, site), play: onSite(g?.play, site), cover: onSite(g?.cover, site),
|
|
53
|
+
players: g?.players && Number.isFinite(g.players.max) ? { min: Number(g.players.min) || 1, max: Number(g.players.max) } : null,
|
|
54
|
+
}))
|
|
55
|
+
.filter((g) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(g.id) && g.play);
|
|
56
|
+
const rank = (g) => { const i = DEMO_PICKS.indexOf(g.id); return i < 0 ? DEMO_PICKS.length : i; };
|
|
57
|
+
// A game people are playing right now goes first: that is the best demo of all.
|
|
58
|
+
const games = listed.map((g) => ({ ...g, playingNow: live.get(g.id) ?? 0 }))
|
|
59
|
+
.sort((a, b) => (b.playingNow > 0) - (a.playingNow > 0) || rank(a) - rank(b) || a.name.localeCompare(b.name))
|
|
60
|
+
.slice(0, 5);
|
|
61
|
+
const pick = games[0] ?? { ...DEMO_FALLBACK, playingNow: 0 };
|
|
62
|
+
return {
|
|
63
|
+
ok: true, command: 'demo',
|
|
64
|
+
studio: { name: text(manifest?.name, 60) || 'Homie Arcade', site },
|
|
65
|
+
pick, games: games.length ? games : [pick],
|
|
66
|
+
...(why ? { note: `the arcade did not answer just now (${why}); this is its usual first pick` } : {}),
|
|
67
|
+
how: HOW, copy: COPY,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The demo as a person reads it in a terminal. */
|
|
72
|
+
export function formatDemo(r) {
|
|
73
|
+
const p = r.pick;
|
|
74
|
+
return [
|
|
75
|
+
`See a working game: ${p.name} on ${r.studio.name}${p.playingNow ? ` (${p.playingNow} playing right now)` : ''}`,
|
|
76
|
+
` Play: ${p.play}`,
|
|
77
|
+
...(p.blurb ? [` ${p.blurb}`] : []),
|
|
78
|
+
'',
|
|
79
|
+
r.how,
|
|
80
|
+
...(r.games.length > 1 ? ['', 'More to try:', ...r.games.slice(1).map((g) => ` ${g.name}: ${g.play}`)] : []),
|
|
81
|
+
...(r.note ? ['', `(${r.note})`] : []),
|
|
82
|
+
'',
|
|
83
|
+
r.copy,
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|