@homie-rocks/studio 0.13.0 → 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 +31 -4
- package/bin/homie-studio.mjs +32 -5
- package/lib/cloudflare.mjs +2 -2
- 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/scaffold.mjs +56 -24
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +27 -0
- package/lib/upgrade.mjs +1 -1
- 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/package.json +2 -1
- package/worker/site.mjs +24 -2
- package/worker/version.mjs +1 -1
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio mcp` — the studio toolkit as a LOCAL MCP server (stdio), so the chat that shows Homie's cards also
|
|
3
|
+
* does the building: in the Claude desktop app through the Homie extension (.mcpb, desktop/ in this repository), in
|
|
4
|
+
* Claude Code, Codex or any MCP client. The remote Homie MCP (homie.rocks/mcp) coordinates (directory, grants, hub,
|
|
5
|
+
* progress relay for phones); this one does the work on this computer: studios, games, builds, checks with real
|
|
6
|
+
* browsers, deploys. Tools that overlap the remote ones keep their names and input shapes (lib/mcp-tools.mjs).
|
|
7
|
+
*
|
|
8
|
+
* homie-studio mcp [--studios <folder>] [--skills <folder>]
|
|
9
|
+
* --studios the folder the person's studios live in (the extension's setting; `${HOME}/Studios` is expanded);
|
|
10
|
+
* without it, the studio the server was started in (the working directory, or one above it)
|
|
11
|
+
* --skills Homie's guides (the plugin's skills folder): studio_guide and the media tools read them
|
|
12
|
+
* --no-install never run npm install in a studio (tests, and a studio whose node_modules are linked by hand)
|
|
13
|
+
* --homie the directory a new studio names (default https://homie.rocks)
|
|
14
|
+
*
|
|
15
|
+
* The transport is the MCP stdio transport: one JSON-RPC 2.0 message per line on stdin and stdout; logs go to
|
|
16
|
+
* stderr. No SDK. MCP Apps cards (spec 2026-01-26, `text/html;profile=mcp-app`) are served as ui:// resources from
|
|
17
|
+
* mcp/ui/ (real files, read as they are: a card's script is never built from a function's source), and every tool
|
|
18
|
+
* also answers in plain text for a host without cards.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { PACKAGE_ROOT } from './studio.mjs';
|
|
23
|
+
import { StudioContext, UI, availability, toolDefs } from './mcp-tools.mjs';
|
|
24
|
+
import { stopAllJobs, toolPath } from './jobs.mjs';
|
|
25
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
26
|
+
|
|
27
|
+
export const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
28
|
+
export const MCP_APP_MIME = 'text/html;profile=mcp-app';
|
|
29
|
+
|
|
30
|
+
export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
|
|
31
|
+
|
|
32
|
+
A new studio follows one checklist, in order and never ahead. Show it in your first reply and again, ticked, as each step ends:
|
|
33
|
+
0. Setup status (setup_status): what this computer and their accounts have; optional rows never block.
|
|
34
|
+
1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
|
|
35
|
+
2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
|
|
36
|
+
3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
|
|
37
|
+
4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex).
|
|
38
|
+
5. Build it: build_open, then game_make under the planned id (the codex stays), file edits, build, preview_run, check. The card follows every build.
|
|
39
|
+
6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
|
|
40
|
+
If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
|
|
41
|
+
|
|
42
|
+
A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on GitHub): studio_open with its repo clones it into the studios folder with this computer's own GitHub sign-in; github_login signs the computer in with GitHub's one-time code. Never ask for a token.
|
|
43
|
+
|
|
44
|
+
Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
|
|
45
|
+
|
|
46
|
+
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js' };
|
|
47
|
+
const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex' };
|
|
48
|
+
const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
|
|
49
|
+
|
|
50
|
+
/** One card's whole document: the shared look and bridge, and the card's own script, as the files are. */
|
|
51
|
+
export function cardHtml(uri) {
|
|
52
|
+
const own = CARD_FILES[uri];
|
|
53
|
+
if (!own) return null;
|
|
54
|
+
const css = readFileSync(join(UI_DIR, 'card.css'), 'utf8');
|
|
55
|
+
const bridge = readFileSync(join(UI_DIR, 'bridge.js'), 'utf8');
|
|
56
|
+
const script = readFileSync(join(UI_DIR, own), 'utf8');
|
|
57
|
+
const safe = (s) => s.replace(/<\/(script|style)/gi, '<\\/$1');
|
|
58
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${CARD_TITLES[uri]} · Homie</title><style>${safe(css)}</style></head><body><main id="root" class="card" aria-live="polite"><div class="skel"></div><div class="skel"></div></main><script>${safe(bridge)}\n${safe(script)}</script></body></html>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a card may load: nothing from the network, except the codex page's Google Fonts. */
|
|
62
|
+
export function cardCsp(uri) {
|
|
63
|
+
if (uri === UI.codex) return { connectDomains: [], resourceDomains: ['https://fonts.googleapis.com', 'https://fonts.gstatic.com'] };
|
|
64
|
+
return { connectDomains: [], resourceDomains: [] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const PROMPTS = [
|
|
68
|
+
{ name: 'new-studio', title: 'Set up a game studio', description: 'Make a game studio on this computer and go through Homie\'s new-studio checklist.', arguments: [{ name: 'name', description: 'The studio\'s name', required: false }] },
|
|
69
|
+
{ name: 'make-multiplayer', title: 'Make my game multiplayer', description: 'Port an existing single-player web game on this computer into a studio.', arguments: [{ name: 'folder', description: 'The game\'s folder', required: true }] },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function promptText(name, args = {}) {
|
|
73
|
+
if (name === 'new-studio') return `Set up a game studio${args.name ? ` called ${String(args.name).slice(0, 60)}` : ''} with Homie, step by step.`;
|
|
74
|
+
if (name === 'make-multiplayer') return `Make the game in ${String(args.folder ?? '').slice(0, 200)} multiplayer on my Homie studio.`;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The server. `input`/`output` are streams (stdin/stdout by default). Returns a promise that ends with the input.
|
|
80
|
+
*/
|
|
81
|
+
export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
|
|
82
|
+
// A GUI app starts this with a short PATH: everything it runs sees the usual places Node, npm and ffmpeg live.
|
|
83
|
+
process.env.PATH = toolPath();
|
|
84
|
+
const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
|
|
85
|
+
const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install });
|
|
86
|
+
let avail = availability();
|
|
87
|
+
let names = '';
|
|
88
|
+
const send = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
89
|
+
const tools = () => {
|
|
90
|
+
const list = toolDefs(ctx, avail);
|
|
91
|
+
const now = list.map((t) => t.name).join(',');
|
|
92
|
+
names = now;
|
|
93
|
+
return list;
|
|
94
|
+
};
|
|
95
|
+
tools();
|
|
96
|
+
log(`ready: toolkit ${STUDIO_VERSION}, studios ${ctx.studiosDir ?? '(none set)'}${ctx.cwdStudio ? `, started in ${ctx.cwdStudio}` : ''}${skillsDir ? ', guides on' : ''}`);
|
|
97
|
+
|
|
98
|
+
async function handle(message) {
|
|
99
|
+
const { id, method, params } = message ?? {};
|
|
100
|
+
const isRequest = message && Object.hasOwn(message, 'id') && id !== null && id !== undefined;
|
|
101
|
+
const reply = (result) => { if (isRequest) send({ jsonrpc: '2.0', id, result }); };
|
|
102
|
+
const error = (code, text) => { if (isRequest) send({ jsonrpc: '2.0', id, error: { code, message: text } }); };
|
|
103
|
+
try {
|
|
104
|
+
switch (method) {
|
|
105
|
+
case 'initialize': {
|
|
106
|
+
const asked = params?.protocolVersion;
|
|
107
|
+
return reply({
|
|
108
|
+
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
109
|
+
capabilities: { tools: { listChanged: true }, resources: { listChanged: false }, prompts: { listChanged: false } },
|
|
110
|
+
serverInfo: { name: 'homie-studio', title: 'Homie Studio', version: STUDIO_VERSION },
|
|
111
|
+
instructions: INSTRUCTIONS,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
case 'ping': return reply({});
|
|
115
|
+
case 'tools/list':
|
|
116
|
+
return reply({ tools: tools().map(({ run, ...t }) => t) });
|
|
117
|
+
case 'tools/call': {
|
|
118
|
+
const name = params?.name;
|
|
119
|
+
const tool = tools().find((t) => t.name === name);
|
|
120
|
+
if (!tool) return reply({ content: [{ type: 'text', text: `No tool ${name} here.` }], isError: true });
|
|
121
|
+
const t0 = Date.now();
|
|
122
|
+
let result;
|
|
123
|
+
try { result = await tool.run(params?.arguments ?? {}); } catch (e) { result = { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true }; }
|
|
124
|
+
log(`${name} ${result?.isError ? 'failed' : 'ok'} in ${Date.now() - t0} ms`);
|
|
125
|
+
reply(result);
|
|
126
|
+
// A media provider set up meanwhile adds its tool.
|
|
127
|
+
const before = names;
|
|
128
|
+
avail = availability();
|
|
129
|
+
tools();
|
|
130
|
+
if (names !== before) send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
case 'resources/list':
|
|
134
|
+
return reply({ resources: Object.values(UI).map((uri) => ({ uri, name: uri.split('/').pop(), title: CARD_TITLES[uri], mimeType: MCP_APP_MIME })) });
|
|
135
|
+
case 'resources/templates/list': return reply({ resourceTemplates: [] });
|
|
136
|
+
case 'resources/read': {
|
|
137
|
+
const uri = String(params?.uri ?? '');
|
|
138
|
+
const html = cardHtml(uri);
|
|
139
|
+
if (!html) return error(-32002, `no resource ${uri}`);
|
|
140
|
+
return reply({ contents: [{ uri, mimeType: MCP_APP_MIME, text: html, _meta: { ui: { csp: cardCsp(uri), prefersBorder: false } } }] });
|
|
141
|
+
}
|
|
142
|
+
case 'prompts/list': return reply({ prompts: PROMPTS });
|
|
143
|
+
case 'prompts/get': {
|
|
144
|
+
const p = PROMPTS.find((x) => x.name === params?.name);
|
|
145
|
+
if (!p) return error(-32602, `no prompt ${params?.name}`);
|
|
146
|
+
return reply({ description: p.description, messages: [{ role: 'user', content: { type: 'text', text: promptText(p.name, params?.arguments) } }] });
|
|
147
|
+
}
|
|
148
|
+
case 'logging/setLevel': return reply({});
|
|
149
|
+
default:
|
|
150
|
+
if (!isRequest) return undefined; // notifications/initialized, notifications/cancelled, …
|
|
151
|
+
return error(-32601, `method not found: ${method}`);
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return error(-32603, e instanceof Error ? e.message : String(e));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let buffer = '';
|
|
159
|
+
const pending = new Set();
|
|
160
|
+
await new Promise((done) => {
|
|
161
|
+
input.setEncoding?.('utf8');
|
|
162
|
+
input.on('data', (chunk) => {
|
|
163
|
+
buffer += chunk;
|
|
164
|
+
let nl;
|
|
165
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
166
|
+
const line = buffer.slice(0, nl).trim();
|
|
167
|
+
buffer = buffer.slice(nl + 1);
|
|
168
|
+
if (!line) continue;
|
|
169
|
+
let msg;
|
|
170
|
+
try { msg = JSON.parse(line); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); continue; }
|
|
171
|
+
const p = handle(msg).finally(() => pending.delete(p));
|
|
172
|
+
pending.add(p);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
input.on('end', done);
|
|
176
|
+
input.on('close', done);
|
|
177
|
+
});
|
|
178
|
+
await Promise.allSettled([...pending]);
|
|
179
|
+
stopAllJobs();
|
|
180
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* AGENTS.md what this studio is and how to work in it (both apps)
|
|
6
6
|
* CLAUDE.md imports AGENTS.md (Claude Code's documented way to share it)
|
|
7
|
+
* HANDOFF.md what a Claude Code session started from the Claude app does with its one-line prompt
|
|
7
8
|
* studio.json the studio's name, slug, and its Cloudflare resources
|
|
8
9
|
* package.json @homie-rocks/studio and wrangler, pinned
|
|
9
10
|
* games/<id>/ one folder per game (game.json, index.html, src/)
|
|
@@ -27,7 +28,6 @@ import { STATS_MIGRATION, STATS_MIGRATION_FILE } from '../worker/stats.mjs';
|
|
|
27
28
|
import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs';
|
|
28
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
29
30
|
import { themeFile } from './site.mjs';
|
|
30
|
-
import { PACKAGE_ROOT } from './studio.mjs';
|
|
31
31
|
|
|
32
32
|
export const COMPAT_DATE = '2026-06-01';
|
|
33
33
|
/** 4.135.0 or later: Worker Previews (`wrangler preview`, a Durable Object namespace per Preview). */
|
|
@@ -114,8 +114,11 @@ directory lists its games; homie.rocks does not host them.
|
|
|
114
114
|
Use \`npm run <script>\` or \`npx --no-install homie-studio <command>\`: \`--no-install\` makes sure it is this
|
|
115
115
|
studio's pinned copy, never a registry lookup of the bare name.
|
|
116
116
|
|
|
117
|
+
- \`npx --no-install homie-studio demo\` — a live multiplayer game to try right now (on Homie Arcade, with
|
|
118
|
+
whoever is playing and bots in the empty seats). Nothing is copied into this studio.
|
|
117
119
|
- \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
|
|
118
|
-
multiplayer starter (one live public room from its first build, bots fill seats).
|
|
120
|
+
multiplayer starter (one live public room from its first build, bots fill seats). A new studio starts with no
|
|
121
|
+
game: copy a starter only when the person asks for one, or once their game is planned.
|
|
119
122
|
- \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
|
|
120
123
|
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
121
124
|
owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
|
|
@@ -168,6 +171,8 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
168
171
|
- **Sections, like homie.rocks:** Home (the featured game, live rooms, latest posts), Games, Music, Videos,
|
|
169
172
|
Rooms (every public room playing now, joinable) and Posts. A section with nothing in it has no tab, and its
|
|
170
173
|
page is not found.
|
|
174
|
+
- **A new studio goes live with its Home only:** its name and "First game coming soon", with what is on the way
|
|
175
|
+
(games, posts, music and videos), until its first game, song or video is published. A post shows there at once.
|
|
171
176
|
- **Every game gets a landing** at \`/<id>/\`: a full-bleed hero from the game's own footage
|
|
172
177
|
(\`games/<id>/hero/wide.mp4\` and \`tall.mp4\`, or a trailer in \`videos/\` with \`for.game\`), else its cover
|
|
173
178
|
with slow motion; the pitch, a big Play button into a public room, phone / computer / TV with the join code,
|
|
@@ -245,6 +250,13 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
245
250
|
- A game can listen (\`NETPLAY.md\` section 15): \`net.on('announce', …)\`, \`net.isMuted(seat)\` to hide a muted
|
|
246
251
|
player's chat, and \`net.pickPlayer(seat)\` when a player is clicked (the owner's page opens their card).
|
|
247
252
|
|
|
253
|
+
## Continuing a build from the Claude app
|
|
254
|
+
|
|
255
|
+
A Claude Code session started from the Claude app's card gets one short line, like
|
|
256
|
+
\`Continue building Night Owls: build hb_…\`. \`HANDOFF.md\` says what to do with it:
|
|
257
|
+
\`npx --no-install homie-studio handoff hb_…\` fetches the brief the person gave in the chat, takes the build (the
|
|
258
|
+
chat's card follows the work from then on) and, for a studio still being set up, checks in from this repository.
|
|
259
|
+
|
|
248
260
|
## Rules
|
|
249
261
|
|
|
250
262
|
- Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
|
|
@@ -280,6 +292,39 @@ and ask for a game; \`AGENTS.md\` says how everything here works.
|
|
|
280
292
|
`;
|
|
281
293
|
}
|
|
282
294
|
|
|
295
|
+
/**
|
|
296
|
+
* HANDOFF.md: what a Claude Code session started from the Claude app does with the one line it is given
|
|
297
|
+
* ("Continue building <Studio>: build hb_…"). The brief stays with the build in the directory; the session fetches it
|
|
298
|
+
* (`homie-studio handoff`, lib/handoff.mjs), so no wall of text ever goes through a prompt.
|
|
299
|
+
*/
|
|
300
|
+
export const HANDOFF_MD = `# Continuing a build from the Claude app
|
|
301
|
+
|
|
302
|
+
The Claude app (claude.ai on a phone or the web) hands a build to a Claude Code session in this repository with
|
|
303
|
+
one short line:
|
|
304
|
+
|
|
305
|
+
Continue building <Studio>: build hb_<32 hex digits>
|
|
306
|
+
|
|
307
|
+
That line is all the session is given. The brief (what the person asked for, in their words) stays with the build:
|
|
308
|
+
|
|
309
|
+
1. \`npm install\` (once per session).
|
|
310
|
+
2. \`npx --no-install homie-studio handoff hb_…\` prints the brief and the steps for this kind of build (a new
|
|
311
|
+
studio's first session, a new game, a port, a remix or a change). It takes the build once, so the chat's card
|
|
312
|
+
follows the work, and for a studio still being set up it checks in from this repository.
|
|
313
|
+
3. If it says this session's network does not reach homie.rocks and the Homie connector's tools are in this session,
|
|
314
|
+
call \`build_progress\` with \`{ "build": "hb_…" }\`: its answer carries the same brief. Otherwise tell the person
|
|
315
|
+
in one line and ask what to build; the card in their chat shows the brief.
|
|
316
|
+
4. Do the work the brief asks for, as AGENTS.md says: \`npm run build\`, \`npm run dev\` in the background, and
|
|
317
|
+
\`npx --no-install homie-studio check <id> --url http://127.0.0.1:8787\` (on Linux without Chrome, first
|
|
318
|
+
\`npx --no-install homie-studio chrome install\`).
|
|
319
|
+
5. \`npx --no-install homie-studio progress change "<what it does, one line>"\`; commit on a new branch, push,
|
|
320
|
+
\`gh pr create\`; then \`npx --no-install homie-studio progress pr --url <the pull request>\`.
|
|
321
|
+
|
|
322
|
+
Never merge the pull request: the person publishes it from the chat's card, in GitHub. A new studio has no game
|
|
323
|
+
yet: copy a starter only when the brief or the person asks for one.
|
|
324
|
+
|
|
325
|
+
On a computer, the Claude desktop app with the Homie extension builds in the same chat, with no hand-off at all.
|
|
326
|
+
`;
|
|
327
|
+
|
|
283
328
|
const MIGRATION = `-- A studio's own D1: the directory claim, and every finished round of every public room.
|
|
284
329
|
CREATE TABLE IF NOT EXISTS meta (
|
|
285
330
|
key TEXT PRIMARY KEY,
|
|
@@ -402,10 +447,11 @@ export function studioFiles({ name, slug, homie, template = false }) {
|
|
|
402
447
|
'AGENTS.md': agentsMd({ name, slug }),
|
|
403
448
|
'CLAUDE.md': claudeMd(),
|
|
404
449
|
'README.md': template ? templateReadme() : readme({ name }),
|
|
450
|
+
'HANDOFF.md': HANDOFF_MD,
|
|
405
451
|
'studio.json': `${JSON.stringify(studio, null, 2)}\n`,
|
|
406
452
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
407
453
|
'.gitignore': GITIGNORE,
|
|
408
|
-
'games/README.md': 'One folder per game. Start one with `npx --no-install homie-studio game new <id> --from gem-rush
|
|
454
|
+
'games/README.md': 'One folder per game. A new studio has none (its home page says "First game coming soon"). Start one with `npx --no-install homie-studio game new <id> --from gem-rush` when the person asks for a copy of the starter, or once their game is planned.\n',
|
|
409
455
|
'music/README.md': 'Songs, game scores, loops and stems, one folder each (`music/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/music/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
|
|
410
456
|
'music/manifest.json': '{ "v": 1, "items": [] }\n',
|
|
411
457
|
'videos/README.md': 'Trailers, music videos and cutscenes, one folder each (`videos/<slug>/`). `manifest.json` lists them (`node_modules/@homie-rocks/studio/media/MEDIA.md`); a published entry gets a page at `/videos/<slug>/`. The site serves files up to 25 MiB itself; larger ones go to the studio\'s storage with `npx --no-install homie-studio media put` (after `npx --no-install homie-studio storage add`). Never into git.\n',
|
|
@@ -425,29 +471,14 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
425
471
|
'.claude/skills/.gitkeep': '',
|
|
426
472
|
};
|
|
427
473
|
if (template) {
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
474
|
+
// No game: a new studio goes live with its own Home ("first game coming soon"), and a band there that connects
|
|
475
|
+
// it to the Claude chat that set it up (`setup attach` removes the band once Claude works in the studio). A
|
|
476
|
+
// starter is copied in only when the person asks for one (`homie-studio game new`).
|
|
431
477
|
files['site/partials/home.html'] = CONNECT_BAND;
|
|
432
478
|
}
|
|
433
479
|
return files;
|
|
434
480
|
}
|
|
435
481
|
|
|
436
|
-
/** A starter's files, as text, relative to its folder. */
|
|
437
|
-
function starterFiles(id) {
|
|
438
|
-
const dir = join(PACKAGE_ROOT, 'starters', id);
|
|
439
|
-
const out = [];
|
|
440
|
-
const walk = (rel) => {
|
|
441
|
-
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
442
|
-
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
443
|
-
if (entry.isDirectory()) walk(path);
|
|
444
|
-
else out.push([path, readFileSync(join(dir, path), 'utf8')]);
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
|
-
walk('');
|
|
448
|
-
return out;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
482
|
/** The template's first-run band on Home: one link that connects this studio to the Claude chat that set it up. */
|
|
452
483
|
export const CONNECT_BAND = `<!-- A new studio from the Homie template. This band goes away once Claude works in the studio
|
|
453
484
|
(homie-studio setup attach removes it); delete it by hand any time. -->
|
|
@@ -469,7 +500,7 @@ and a site with public multiplayer rooms that runs on **your own Cloudflare acco
|
|
|
469
500
|
|
|
470
501
|
The button copies this studio into your GitHub, creates its Worker, database and rooms on your Cloudflare, and
|
|
471
502
|
deploys it with Workers Builds: every push to \`main\` goes live, and every other branch gets its own Preview.
|
|
472
|
-
The site
|
|
503
|
+
The site goes live at once with its own home page ("first game coming soon"); the games come next.
|
|
473
504
|
|
|
474
505
|
Then open the site and tap **Connect to Claude**, or ask Claude in the Claude app to set up your studio with the
|
|
475
506
|
Homie connector: it makes games, songs and videos here, in a pull request you merge with one tap.
|
|
@@ -581,8 +612,9 @@ export function newStudio(folder, { name, homie, slug: askedSlug, install = true
|
|
|
581
612
|
const q = JSON.stringify(realpathSync(dir));
|
|
582
613
|
return { ok: true, command: 'new', dir: realpathSync(dir), name, slug, wrote, git, installed, studio: STUDIO_VERSION, next: [
|
|
583
614
|
`cd ${q}${install ? '' : ' && npm install'}`,
|
|
584
|
-
'npx --no-install homie-studio
|
|
585
|
-
'
|
|
615
|
+
'see a working game first, with nothing copied here: npx --no-install homie-studio demo (a live game on Homie Arcade)',
|
|
616
|
+
'a copy of a starter only when the person asks for one: npx --no-install homie-studio game new <id> --from gem-rush --name "<Game Name>"',
|
|
617
|
+
'npm run dev (the home page, "first game coming soon", at http://127.0.0.1:8787/; with a game: npx --no-install homie-studio check <id> --url http://127.0.0.1:8787)',
|
|
586
618
|
'npm run deploy (then the Homie MCP tool studio_publish, or: npx --no-install homie-studio publish)',
|
|
587
619
|
], online: 'Going online creates one Worker, one D1 database and two Durable Objects on your own Cloudflare account: free plan, no payment method, no R2. `npx --no-install homie-studio deploy --plan` says exactly what, and changes nothing.' };
|
|
588
620
|
}
|
package/lib/studio.mjs
CHANGED
|
@@ -108,7 +108,10 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
108
108
|
const src = join(PACKAGE_ROOT, 'starters', from);
|
|
109
109
|
if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
|
|
110
110
|
const dest = join(root, 'games', id);
|
|
111
|
-
if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id or change that game`);
|
|
111
|
+
if (existsSync(join(dest, 'game.json'))) throw new Error(`games/${id} already exists; pick another id or change that game`);
|
|
112
|
+
// A planned game (its folder holds the Game Codex, maybe art, and no game.json yet): the starter goes in around
|
|
113
|
+
// what is there, and nothing of the plan is overwritten.
|
|
114
|
+
if (existsSync(dest) && readdirSync(dest).some((f) => existsSync(join(src, f)))) throw new Error(`games/${id} holds files of its own that a starter would overwrite; pick another id`);
|
|
112
115
|
cpSync(src, dest, { recursive: true });
|
|
113
116
|
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
114
117
|
meta.id = id;
|
|
@@ -272,6 +272,33 @@
|
|
|
272
272
|
"## Beta": "9d02638207a26e73"
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
+
},
|
|
276
|
+
"0.14.0": {
|
|
277
|
+
"files": {
|
|
278
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
279
|
+
"README.md": "684b3cca8001673c",
|
|
280
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
281
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
282
|
+
"music/README.md": "b8a828de9311bd28",
|
|
283
|
+
"videos/README.md": "70614388cbb203c7",
|
|
284
|
+
"posts/README.md": "37841aae476bd540",
|
|
285
|
+
"site/README.md": "ca131cd0472464fb",
|
|
286
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
287
|
+
},
|
|
288
|
+
"sections": {
|
|
289
|
+
"AGENTS.md": {
|
|
290
|
+
"(intro)": "214aa46eb3a895d5",
|
|
291
|
+
"## Layout": "f14d641159480e6c",
|
|
292
|
+
"## Commands (all through the pinned CLI in node_modules)": "e579cd6ce3ce342a",
|
|
293
|
+
"## The site": "7c1618431bae6324",
|
|
294
|
+
"## Making games": "63069b31c87a2aa2",
|
|
295
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
296
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
297
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
298
|
+
"## Rules": "969318425c99f5a5",
|
|
299
|
+
"## Beta": "9d02638207a26e73"
|
|
300
|
+
}
|
|
301
|
+
}
|
|
275
302
|
}
|
|
276
303
|
}
|
|
277
304
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import { readStudio } from './studio.mjs';
|
|
|
31
31
|
import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
32
32
|
|
|
33
33
|
/** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
|
|
34
|
-
export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
|
|
34
|
+
export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
|
|
35
35
|
/** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
|
|
36
36
|
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
|
|
37
37
|
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
package/mcp/ui/bridge.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Homie's local cards: the MCP Apps bridge (spec 2026-01-26) every card shares. JSON-RPC 2.0 over postMessage
|
|
3
|
+
* with the host: ui/initialize, the tool's input and result, the host's theme, tools/call (the host proxies it to
|
|
4
|
+
* the local Homie server), ui/open-link for every link, ui/update-model-context to tell the model once, and the
|
|
5
|
+
* card's size. No network of its own; every text is set as text, never as HTML.
|
|
6
|
+
*/
|
|
7
|
+
var Card = (function () {
|
|
8
|
+
var seq = 0; var pending = {}; var ctx = {}; var caps = {};
|
|
9
|
+
var S = { result: null, data: null, timer: null, torn: false, visible: true, told: {}, busy: false };
|
|
10
|
+
var spec = null;
|
|
11
|
+
var root = document.getElementById('root');
|
|
12
|
+
|
|
13
|
+
function post(m) { window.parent.postMessage(m, '*'); }
|
|
14
|
+
function rpc(method, params) {
|
|
15
|
+
var id = ++seq;
|
|
16
|
+
post({ jsonrpc: '2.0', id: id, method: method, params: params || {} });
|
|
17
|
+
return new Promise(function (resolve, reject) {
|
|
18
|
+
pending[id] = { resolve: resolve, reject: reject };
|
|
19
|
+
setTimeout(function () { if (pending[id]) { delete pending[id]; reject(new Error('timeout')); } }, 65000);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function note(method, params) { post({ jsonrpc: '2.0', method: method, params: params || {} }); }
|
|
23
|
+
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
|
|
24
|
+
function add(parent) { for (var i = 1; i < arguments.length; i++) { var k = arguments[i]; if (k) parent.appendChild(k); } return parent; }
|
|
25
|
+
function btn(label, cls, onclick) { var b = el('button', cls || '', label); b.type = 'button'; b.onclick = onclick; return b; }
|
|
26
|
+
function pill(text, cls, live) { var p = el('span', 'pill ' + (cls || '')); if (live) p.appendChild(el('span', 'dot')); p.appendChild(el('span', '', text)); return p; }
|
|
27
|
+
function top(label, right) { return add(el('div', 'top'), add(el('div', 'eyebrow'), el('span', 'mark'), el('span', '', 'Homie · ' + label)), right || null); }
|
|
28
|
+
function img(src, alt) {
|
|
29
|
+
if (typeof src !== 'string' || !/^data:image\/(png|jpeg|webp|gif);base64,/.test(src)) return null;
|
|
30
|
+
var i = el('img'); i.alt = alt || ''; i.decoding = 'async'; i.src = src; i.onerror = function () { i.remove(); size(); };
|
|
31
|
+
return i;
|
|
32
|
+
}
|
|
33
|
+
function open(url) { if (typeof url === 'string' && /^https?:\/\//.test(url)) rpc('ui/open-link', { url: url }).catch(function () {}); }
|
|
34
|
+
function call(name, args) { return rpc('tools/call', { name: name, arguments: args || {} }); }
|
|
35
|
+
function tell(key, text) {
|
|
36
|
+
if (S.told[key]) return; S.told[key] = true;
|
|
37
|
+
rpc('ui/update-model-context', { content: [{ type: 'text', text: text }] }).catch(function () {});
|
|
38
|
+
}
|
|
39
|
+
function ago(iso) {
|
|
40
|
+
var t = Date.parse(iso || ''); if (!isFinite(t)) return '';
|
|
41
|
+
var s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
42
|
+
return s < 5 ? 'just now' : s < 60 ? s + ' s ago' : s < 3600 ? Math.round(s / 60) + ' min ago' : Math.round(s / 3600) + ' h ago';
|
|
43
|
+
}
|
|
44
|
+
function dur(ms) { if (!(ms >= 0)) return ''; var s = Math.round(ms / 1000); return s < 60 ? s + ' s' : Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
|
|
45
|
+
// As the ext-apps SDK measures: the content's own height (html at max-content) and the frame's width, once a
|
|
46
|
+
// frame, only when either changed.
|
|
47
|
+
var sized = { w: 0, h: 0, queued: false };
|
|
48
|
+
function size() {
|
|
49
|
+
if (sized.queued) return; sized.queued = true;
|
|
50
|
+
requestAnimationFrame(function () {
|
|
51
|
+
sized.queued = false;
|
|
52
|
+
var html = document.documentElement; var was = html.style.height;
|
|
53
|
+
html.style.height = 'max-content';
|
|
54
|
+
var h = Math.ceil(html.getBoundingClientRect().height);
|
|
55
|
+
html.style.height = was;
|
|
56
|
+
var w = Math.ceil(window.innerWidth);
|
|
57
|
+
if (w === sized.w && h === sized.h) return;
|
|
58
|
+
sized.w = w; sized.h = h;
|
|
59
|
+
note('ui/notifications/size-changed', { width: w, height: h });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function theme(c) {
|
|
63
|
+
if (!c) return;
|
|
64
|
+
if (c.theme === 'light' || c.theme === 'dark') { document.documentElement.setAttribute('data-theme', c.theme); document.documentElement.style.colorScheme = c.theme; }
|
|
65
|
+
var v = c.styles && c.styles.variables;
|
|
66
|
+
if (v) for (var k in v) if (v[k] !== undefined && /^--[a-z0-9-]+$/.test(k)) document.documentElement.style.setProperty(k, v[k]);
|
|
67
|
+
if (c.styles && c.styles.css && c.styles.css.fonts && !document.getElementById('host-fonts')) { var st = el('style'); st.id = 'host-fonts'; st.textContent = c.styles.css.fonts; document.head.appendChild(st); }
|
|
68
|
+
}
|
|
69
|
+
function fullscreenButton() {
|
|
70
|
+
var modes = (ctx && ctx.availableDisplayModes) || [];
|
|
71
|
+
if (modes.indexOf('fullscreen') < 0) return null;
|
|
72
|
+
var full = ctx.displayMode === 'fullscreen';
|
|
73
|
+
return btn(full ? 'Smaller' : 'Full screen', 'ghost small', function () {
|
|
74
|
+
rpc('ui/request-display-mode', { mode: full ? 'inline' : 'fullscreen' }).then(function (r) { ctx.displayMode = r && r.mode; render(); }).catch(function () {});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function textOf(r) { return (r && r.content && r.content[0] && r.content[0].text) || ''; }
|
|
78
|
+
function render() {
|
|
79
|
+
if (!spec) return;
|
|
80
|
+
var card;
|
|
81
|
+
if (!S.data) {
|
|
82
|
+
card = el('main', 'card');
|
|
83
|
+
if (S.result && S.result.isError) add(card, top(spec.label, pill('Not done', 'bad')), el('p', 'note bad', textOf(S.result) || 'That did not work.'));
|
|
84
|
+
else add(card, top(spec.label, pill('Loading', 'live', true)), el('div', 'skel'), el('div', 'skel'));
|
|
85
|
+
} else {
|
|
86
|
+
try { card = spec.render(S.data, S.result); } catch (e) { card = add(el('main', 'card'), top(spec.label), el('p', 'note bad', String(e && e.message || e))); }
|
|
87
|
+
if (S.result && S.result.isError && S.data) card.appendChild(el('p', 'note bad', textOf(S.result)));
|
|
88
|
+
}
|
|
89
|
+
card.id = 'root';
|
|
90
|
+
if (ctx.displayMode === 'fullscreen') card.classList.add('full');
|
|
91
|
+
root.replaceWith(card); root = card; size();
|
|
92
|
+
}
|
|
93
|
+
function take(result) {
|
|
94
|
+
S.result = result;
|
|
95
|
+
if (result && result.structuredContent && result.structuredContent.kind === spec.kind) S.data = result.structuredContent;
|
|
96
|
+
render();
|
|
97
|
+
schedule();
|
|
98
|
+
}
|
|
99
|
+
function schedule() {
|
|
100
|
+
if (S.timer) { clearTimeout(S.timer); S.timer = null; }
|
|
101
|
+
if (S.torn || !spec.poll || !S.data) return;
|
|
102
|
+
var p = spec.poll(S.data);
|
|
103
|
+
if (!p) return;
|
|
104
|
+
S.timer = setTimeout(function () {
|
|
105
|
+
S.timer = null;
|
|
106
|
+
if (!S.visible) { schedule(); return; }
|
|
107
|
+
call(p.tool, p.args).then(function (r) { if (r && r.structuredContent && r.structuredContent.kind === spec.kind) take(r); else schedule(); })
|
|
108
|
+
.catch(function () { schedule(); });
|
|
109
|
+
}, p.every || 2000);
|
|
110
|
+
}
|
|
111
|
+
document.addEventListener('visibilitychange', function () { S.visible = !document.hidden; });
|
|
112
|
+
window.addEventListener('resize', size);
|
|
113
|
+
window.addEventListener('message', function (ev) {
|
|
114
|
+
if (ev.source !== window.parent) return;
|
|
115
|
+
var m = ev.data; if (!m || m.jsonrpc !== '2.0') return;
|
|
116
|
+
if (m.id !== undefined && m.id !== null && !m.method) {
|
|
117
|
+
var p = pending[m.id]; if (!p) return; delete pending[m.id];
|
|
118
|
+
if (m.error) p.reject(m.error); else p.resolve(m.result);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (m.method === 'ui/notifications/tool-input') { if (!S.result) render(); }
|
|
122
|
+
else if (m.method === 'ui/notifications/tool-result') take(m.params);
|
|
123
|
+
else if (m.method === 'ui/notifications/tool-cancelled') { if (!S.result) { S.result = { isError: true, content: [{ type: 'text', text: 'Cancelled.' }] }; render(); } }
|
|
124
|
+
else if (m.method === 'ui/notifications/host-context-changed') { for (var k in m.params || {}) ctx[k] = m.params[k]; theme(m.params); render(); }
|
|
125
|
+
else if (m.method === 'ui/resource-teardown') { S.torn = true; if (S.timer) clearTimeout(S.timer); if (m.id !== undefined) post({ jsonrpc: '2.0', id: m.id, result: {} }); }
|
|
126
|
+
else if (m.method === 'ping' && m.id !== undefined) post({ jsonrpc: '2.0', id: m.id, result: {} });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
el: el, add: add, btn: btn, pill: pill, top: top, img: img, open: open, call: call, tell: tell, ago: ago, dur: dur,
|
|
131
|
+
size: size, render: render, take: take, fullscreenButton: fullscreenButton, textOf: textOf,
|
|
132
|
+
ctx: function () { return ctx; },
|
|
133
|
+
state: S,
|
|
134
|
+
/** spec: { kind, label, render(data, result) -> element, poll(data) -> { tool, args, every } | null } */
|
|
135
|
+
start: function (s) {
|
|
136
|
+
spec = s;
|
|
137
|
+
rpc('ui/initialize', {
|
|
138
|
+
protocolVersion: '2026-01-26',
|
|
139
|
+
appInfo: { name: 'homie-studio-' + s.kind + '-card', version: '1' },
|
|
140
|
+
appCapabilities: { availableDisplayModes: s.fullscreen ? ['inline', 'fullscreen'] : ['inline'] },
|
|
141
|
+
}).then(function (res) {
|
|
142
|
+
caps = (res && res.hostCapabilities) || {}; ctx = (res && res.hostContext) || {};
|
|
143
|
+
theme(ctx); note('ui/notifications/initialized', {}); render();
|
|
144
|
+
}).catch(function () { note('ui/notifications/initialized', {}); render(); });
|
|
145
|
+
void caps;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
})();
|
package/mcp/ui/build.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* The build card: one build's stages, each check going green, a picture and where to play it, money against the
|
|
2
|
+
* budget, and Stop. It follows the build every 2 s while it runs and tells the model once when it ends. */
|
|
3
|
+
(function () {
|
|
4
|
+
var C = Card;
|
|
5
|
+
var ICON = { pass: '✓', done: '✓', fail: '✕', failed: '✕', running: '●', pending: '·', skip: '–', skipped: '–', stopped: '■' };
|
|
6
|
+
var STATE = { running: ['Running', 'live', true], passed: ['Done', 'ok'], failed: ['Failed', 'bad'], stopped: ['Stopped', ''] };
|
|
7
|
+
function money(n, unit) { return unit === 'credits' ? Math.round(n).toLocaleString('en-US') + ' credits' : '$' + Number(n || 0).toFixed(2); }
|
|
8
|
+
function render(sc) {
|
|
9
|
+
var f = sc.feed; var card = C.el('main', 'card');
|
|
10
|
+
var st = STATE[f.state] || ['…', ''];
|
|
11
|
+
C.add(card, C.top('Build', C.add(C.el('div', 'actions'), C.pill(st[0], st[1], st[2]), C.fullscreenButton())), C.el('h1', '', f.title || 'A build'),
|
|
12
|
+
C.el('p', 'sub', [f.studio, f.what === 'game' ? null : f.what, f.state === 'running' ? 'started ' + C.ago(f.startedAt) : f.endedAt ? 'took ' + C.dur(Date.parse(f.endedAt) - Date.parse(f.startedAt)) : null].filter(Boolean).join(' · ')));
|
|
13
|
+
var stages = C.el('div', 'stages');
|
|
14
|
+
(f.stages || []).forEach(function (s) { stages.appendChild(C.add(C.el('div', 'stage ' + s.state), C.el('div', 'bar'), C.el('span', '', (ICON[s.state] && s.state !== 'pending' ? ICON[s.state] + ' ' : '') + s.label))); });
|
|
15
|
+
card.appendChild(stages);
|
|
16
|
+
var cur = (f.stages || []).filter(function (s) { return s.note && s.state !== 'pending'; }).pop();
|
|
17
|
+
if (cur) card.appendChild(C.el('p', 'fine', cur.label + ': ' + cur.note));
|
|
18
|
+
if ((f.checks || []).length) {
|
|
19
|
+
var list = C.el('div');
|
|
20
|
+
f.checks.forEach(function (c) { list.appendChild(C.add(C.el('div', 'check ' + c.state), C.el('span', 'i', ICON[c.state] || '·'), C.el('span', '', c.label + (c.note ? ' — ' + c.note : '')), c.ms ? C.el('span', 'n', C.dur(c.ms)) : null)); });
|
|
21
|
+
card.appendChild(list);
|
|
22
|
+
}
|
|
23
|
+
var p = f.preview;
|
|
24
|
+
if (p && (p.image || p.url)) {
|
|
25
|
+
var box = C.el('div', 'preview');
|
|
26
|
+
var pic = C.img(p.image, p.caption || 'The game');
|
|
27
|
+
if (pic) box.appendChild(pic);
|
|
28
|
+
if (p.caption) box.appendChild(C.el('span', 'fine', p.caption));
|
|
29
|
+
card.appendChild(box);
|
|
30
|
+
}
|
|
31
|
+
if (f.spend && (f.spend.used > 0 || f.spend.budget !== null)) card.appendChild(C.el('p', 'fine', 'Spent ' + money(f.spend.used, f.spend.unit) + (f.spend.budget !== null ? ' of ' + money(f.spend.budget, f.spend.unit) : '')));
|
|
32
|
+
if (f.error) card.appendChild(C.el('p', 'note bad', f.error));
|
|
33
|
+
var failed = (sc.jobs || []).filter(function (j) { return j.state === 'failed'; });
|
|
34
|
+
if (failed.length && !f.error) card.appendChild(C.el('pre', 'log', failed.map(function (j) { return j.label + ': ' + ((j.result && j.result.why) || (j.tail || []).slice(-3).join('\n')); }).join('\n')));
|
|
35
|
+
else if ((f.log || []).length && f.state === 'running') card.appendChild(C.el('pre', 'log', f.log.slice(-3).map(function (l) { return l.text; }).join('\n')));
|
|
36
|
+
var foot = C.el('div', 'foot');
|
|
37
|
+
foot.appendChild(C.el('span', 'when', f.state === 'running' ? 'Updated ' + C.ago(f.updatedAt) : 'Ended ' + C.ago(f.endedAt)));
|
|
38
|
+
var acts = C.el('div', 'actions');
|
|
39
|
+
if (f.state === 'running' && !(f.stop && f.stop.requested)) {
|
|
40
|
+
acts.appendChild(C.btn(C.state.stopping ? 'Stopping…' : 'Stop', 'danger small', function () {
|
|
41
|
+
if (C.state.stopping) return; C.state.stopping = true; C.render();
|
|
42
|
+
C.call('build_stop', { build: sc.build }).then(function (r) { C.state.stopping = false; C.take(r); }).catch(function () { C.state.stopping = false; C.render(); });
|
|
43
|
+
}));
|
|
44
|
+
} else if (f.stop && f.stop.requested && f.state === 'running') acts.appendChild(C.pill('Stopping at the next safe point', 'warn'));
|
|
45
|
+
if (p && p.url && /^https?:/.test(p.url)) acts.appendChild(C.btn('Play it', f.state === 'passed' ? '' : 'ghost small', function () { C.open(p.url); }));
|
|
46
|
+
foot.appendChild(acts);
|
|
47
|
+
card.appendChild(foot);
|
|
48
|
+
var r = sc.run;
|
|
49
|
+
if (r && r.state !== 'running' && f.state === 'running') {
|
|
50
|
+
card.insertBefore(C.el('p', 'note' + (r.state === 'failed' ? ' bad' : ' ok'), (r.state === 'passed' ? '✓ ' : '') + r.title + ': ' + (r.state === 'passed' ? 'passed' : 'failed' + (r.why ? ' — ' + r.why : ''))), foot);
|
|
51
|
+
C.tell('run-' + sc.build + '-' + r.kind, r.title + ' ' + r.state + (r.why ? ': ' + r.why : '') + ' (build ' + sc.build + ' stays open).');
|
|
52
|
+
}
|
|
53
|
+
if (f.state !== 'running') {
|
|
54
|
+
var passed = (f.checks || []).filter(function (c) { return c.state === 'pass'; }).length;
|
|
55
|
+
C.tell('end-' + sc.build, 'Build ' + sc.build + ' (' + f.title + ') ended: ' + f.state + (f.checks && f.checks.length ? ', ' + passed + ' of ' + f.checks.length + ' checks passed' : '') + (f.error ? '. Error: ' + f.error : '') + (p && p.url ? '. Play: ' + p.url : '') + '. Tell the person in a line or two.');
|
|
56
|
+
}
|
|
57
|
+
return card;
|
|
58
|
+
}
|
|
59
|
+
C.start({
|
|
60
|
+
kind: 'build', label: 'Build', fullscreen: true, render: render,
|
|
61
|
+
poll: function (sc) { return sc.feed && sc.feed.state === 'running' ? { tool: 'build_progress', args: { build: sc.build }, every: 2000 } : null; },
|
|
62
|
+
});
|
|
63
|
+
})();
|