@homie-rocks/studio 0.19.1 → 0.19.2
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/CHANGELOG.md +875 -0
- package/README.md +10 -3
- package/bin/homie-studio.mjs +7 -2
- package/lib/changelog.mjs +204 -0
- package/lib/jobs.mjs +3 -2
- package/lib/mcp-tools.mjs +27 -5
- package/lib/template-history.json +37 -0
- package/lib/upgrade.mjs +9 -1
- package/mcp/ui/card.css +2 -0
- package/mcp/ui/studio.js +9 -1
- package/package.json +2 -1
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -108,14 +108,20 @@ are watched only by those they let in (NETPLAY.md section 16, site/SITE.md).
|
|
|
108
108
|
|
|
109
109
|
## Upgrading a studio
|
|
110
110
|
|
|
111
|
-
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
111
|
+
A studio pins one version, so nothing changes until it asks. To take a newer one (`latest`, or a version):
|
|
112
112
|
|
|
113
113
|
```sh
|
|
114
|
-
npx -y @homie-rocks/studio@
|
|
115
|
-
npx -y @homie-rocks/studio@0.
|
|
114
|
+
npx -y @homie-rocks/studio@latest upgrade # what's new, and the plan; changes nothing
|
|
115
|
+
npx -y @homie-rocks/studio@0.19.2 upgrade --apply # after the person agrees (the version the plan named)
|
|
116
116
|
npm install && npm run build
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
The plan starts with **what's new** since the version the studio pins: one line per version and every upgrade note
|
|
120
|
+
(anything the person has to do), read from the new version's own `CHANGELOG.md`, which ships in the package (the
|
|
121
|
+
repository's [CHANGELOG.md](https://github.com/homie-rocks/homie/blob/main/CHANGELOG.md) is the same file). `--json`
|
|
122
|
+
carries it as `whatsNew`. In Claude Desktop, `studio_run ["upgrade"]` runs the extension's own newer toolkit for
|
|
123
|
+
this, and the studio card says what's new when a studio is behind.
|
|
124
|
+
|
|
119
125
|
`upgrade` compares the studio with what this version's `new` would write for it: an AGENTS.md section, a README,
|
|
120
126
|
a `.gitignore` line, a D1 migration or a package.json script it lacks is added, and one it never changed since an
|
|
121
127
|
older template wrote it (`lib/template-history.json` knows every earlier version's text by a fingerprint) takes
|
|
@@ -498,6 +504,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
|
|
|
498
504
|
| `lib/cloudflare.mjs` | `deploy` (and `deploy --plan`): Wrangler, D1, migrations; refuses resources it did not create. In Workers Builds, migrations and deploy only. `storage add`: the optional R2 bucket; `media move`: big media into it, checked by SHA-256 (every deploy runs it). |
|
|
499
505
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round with both in it, with each one's frame rate; on a busy computer it waits out a round a browser was dropped from, and says why when none counts. `lib/chrome.mjs`: which Chrome, and how (the GPU on a Mac, SwiftShader on Linux). |
|
|
500
506
|
| `lib/upgrade.mjs`, `lib/template-history.json` | `upgrade`: an existing studio takes what a newer template adds, never over its own edits. |
|
|
507
|
+
| `lib/changelog.mjs`, `CHANGELOG.md` | The release history this package carries (a copy of the repository's): `upgrade`'s what's new, the studio card's, and the release notes. |
|
|
501
508
|
| `lib/progress.mjs`, `lib/setup.mjs` | The progress feed (`progress …`), a build the chat opened (`progress attach`), a change as a pull request (`progress change`, `progress pr`), and `setup attach`. |
|
|
502
509
|
| `lib/net.mjs`, `lib/repo.mjs` | The toolkit's own web requests: through the environment's proxy, and an honest reason when one fails. The studio's repository (`owner/name`), from its remote, never the engine's. |
|
|
503
510
|
| `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -61,7 +61,9 @@
|
|
|
61
61
|
* homie-studio upgrade [--apply] [--diff]
|
|
62
62
|
* (what this version's template adds to an existing studio: AGENTS.md
|
|
63
63
|
* sections, READMEs, .gitignore lines, the pin; changes nothing until
|
|
64
|
-
* --apply, and never a file or section the studio changed
|
|
64
|
+
* --apply, and never a file or section the studio changed. It starts
|
|
65
|
+
* with what's new since the studio's version, from this package's
|
|
66
|
+
* CHANGELOG.md)
|
|
65
67
|
* homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
|
|
66
68
|
* homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
|
|
67
69
|
* homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
|
|
@@ -170,6 +172,7 @@ import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
|
|
|
170
172
|
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
171
173
|
import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
|
|
172
174
|
import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
|
|
175
|
+
import { whatsNewLines } from '../lib/changelog.mjs';
|
|
173
176
|
import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
|
|
174
177
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
175
178
|
import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
|
|
@@ -439,7 +442,9 @@ function print(result) {
|
|
|
439
442
|
lines.push(result.applied
|
|
440
443
|
? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
|
|
441
444
|
: list.length ? `${result.studio}: what the @homie-rocks/studio ${result.to} template adds (from ${result.from ?? 'an older version'}). Nothing is changed until --apply.` : `${result.studio} has everything the @homie-rocks/studio ${result.to} template writes.`);
|
|
442
|
-
|
|
445
|
+
// What the versions since the studio's own brought, from this package's CHANGELOG.md (also after --apply).
|
|
446
|
+
if (result.whatsNew) lines.push('', ...whatsNewLines(result.whatsNew, { source: `@homie-rocks/studio ${result.to}'s CHANGELOG.md` }));
|
|
447
|
+
if (list.length) lines.push('', result.applied ? 'Done:' : 'The changes:');
|
|
443
448
|
for (const c of list) {
|
|
444
449
|
lines.push(` ${mark[c.kind] ?? '·'} ${c.file.padEnd(18)} ${c.what}${c.kind === 'add-section' && (c.after || c.before) ? ` (${c.after ? `after "${c.after}"` : `before "${c.before}"`})` : ''}`);
|
|
445
450
|
if (result.applied) continue;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHANGELOG.md: what changed in each @homie-rocks/studio version, and the Homie plugin's version beside it.
|
|
3
|
+
*
|
|
4
|
+
* The repository keeps it at its root; the package carries an identical copy (packages/studio/CHANGELOG.md, in
|
|
5
|
+
* `files`, kept the same by `node scripts/changelog.mjs --sync`), so an installed toolkit knows its own history with
|
|
6
|
+
* no network. Read by:
|
|
7
|
+
* - `homie-studio upgrade`: "What's new since <the pinned version>", from the NEW version's own copy (the one
|
|
8
|
+
* `npx -y @homie-rocks/studio@<new> upgrade` runs);
|
|
9
|
+
* - the studio card (lib/mcp-tools.mjs), when a studio pins an older toolkit than the one answering;
|
|
10
|
+
* - scripts/changelog.mjs: CI's check, and the GitHub release notes the publish workflow writes.
|
|
11
|
+
*
|
|
12
|
+
* The shape (Keep a Changelog, https://keepachangelog.com/en/1.1.0/), newest first:
|
|
13
|
+
*
|
|
14
|
+
* ## [0.19.2] - 2026-10-02 one section per studio version
|
|
15
|
+
*
|
|
16
|
+
* **Plugin 0.20.2** · [#29](…) · [release-…](…) the meta line: the plugin's version (", then 0.20.3" when
|
|
17
|
+
* the plugin had a release of its own after it), the pull
|
|
18
|
+
* requests and the tag
|
|
19
|
+
* One sentence: what this version is. the summary "What's new" prints
|
|
20
|
+
*
|
|
21
|
+
* ### Added | ### Changed | ### Fixed | ### Upgrade notes bullets ("- " and lines indented under them)
|
|
22
|
+
*
|
|
23
|
+
* [0.19.2]: https://… link definitions, at the end of the file
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
export const CHANGELOG_FILE = fileURLToPath(new URL('../CHANGELOG.md', import.meta.url));
|
|
29
|
+
export const GROUPS = Object.freeze(['Added', 'Changed', 'Fixed', 'Upgrade notes']);
|
|
30
|
+
export const REPO_URL = 'https://github.com/homie-rocks/homie';
|
|
31
|
+
|
|
32
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
const HEADING = /^## \[?(\d+\.\d+\.\d+)\]?(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$/;
|
|
34
|
+
const LINK_DEF = /^\[[^\]]+\]:\s+\S+/;
|
|
35
|
+
|
|
36
|
+
/** -1, 0 or 1, as a sorts before, with or after b (x.y.z only; anything else sorts first). */
|
|
37
|
+
export function compareVersions(a, b) {
|
|
38
|
+
const x = VERSION.test(String(a)) ? String(a).split('.').map(Number) : [-1, -1, -1];
|
|
39
|
+
const y = VERSION.test(String(b)) ? String(b).split('.').map(Number) : [-1, -1, -1];
|
|
40
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Markdown as one line of plain words: links keep their text, code and bold lose their marks. */
|
|
45
|
+
export function plain(markdown) {
|
|
46
|
+
return String(markdown ?? '')
|
|
47
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
48
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
49
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The paragraphs and bullets of a run of lines: paragraphs split on blank lines, a bullet takes the lines indented under it. */
|
|
55
|
+
function blocks(lines) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let cur = null;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.trim()) { cur = null; continue; }
|
|
60
|
+
if (/^- /.test(line)) { cur = { kind: 'bullet', text: line.slice(2).trim() }; out.push(cur); continue; }
|
|
61
|
+
if (cur && (cur.kind === 'paragraph' || /^\s+\S/.test(line))) { cur.text += ` ${line.trim()}`; continue; }
|
|
62
|
+
cur = { kind: 'paragraph', text: line.trim() }; out.push(cur);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The file as its sections. Each: { version, date, plugin, plugins, meta, summary, notes, groups, body, line }.
|
|
69
|
+
* `body` is the section's own Markdown (no heading, no link definitions), what a release's notes are made of.
|
|
70
|
+
* `problems` lists what does not follow the shape (CI fails on them); a heading that is not a version (an
|
|
71
|
+
* "Unreleased" one, say) ends the section before it and is otherwise left alone.
|
|
72
|
+
*/
|
|
73
|
+
export function parseChangelog(text) {
|
|
74
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
75
|
+
const versions = [];
|
|
76
|
+
const problems = [];
|
|
77
|
+
let cur = null;
|
|
78
|
+
const close = () => {
|
|
79
|
+
if (!cur) return;
|
|
80
|
+
while (cur.lines.length && !cur.lines[cur.lines.length - 1].trim()) cur.lines.pop();
|
|
81
|
+
const { lines: own, ...section } = cur;
|
|
82
|
+
section.body = own.join('\n').replace(/^\n+/, '');
|
|
83
|
+
// Before the first ### heading: the meta line, the summary and any notes; then the groups.
|
|
84
|
+
const groups = {};
|
|
85
|
+
let head = [];
|
|
86
|
+
let group = null;
|
|
87
|
+
for (const line of own) {
|
|
88
|
+
const g = /^### (.+?)\s*$/.exec(line);
|
|
89
|
+
if (g) { group = g[1]; groups[group] = []; continue; }
|
|
90
|
+
if (group === null) head.push(line); else groups[group].push(line);
|
|
91
|
+
}
|
|
92
|
+
const paras = blocks(head).map((b) => b.text);
|
|
93
|
+
const metaAt = paras.findIndex((p) => /^\*\*Plugin /.test(p));
|
|
94
|
+
section.meta = metaAt >= 0 ? paras[metaAt] : null;
|
|
95
|
+
const rest = paras.filter((_, i) => i !== metaAt);
|
|
96
|
+
section.summary = rest[0] ?? null;
|
|
97
|
+
section.notes = rest.slice(1);
|
|
98
|
+
section.plugins = section.meta ? [...(/^\*\*Plugin ([^*]+)\*\*/.exec(section.meta)?.[1] ?? '').matchAll(/\d+\.\d+\.\d+/g)].map((m) => m[0]) : [];
|
|
99
|
+
section.plugin = section.plugins.at(-1) ?? null;
|
|
100
|
+
section.groups = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, blocks(v).filter((b) => b.kind === 'bullet').map((b) => b.text)]));
|
|
101
|
+
const where = `CHANGELOG.md line ${section.line} (## [${section.version}])`;
|
|
102
|
+
if (!section.date) problems.push(`${where}: the heading has no date; write it as "## [${section.version}] - YYYY-MM-DD"`);
|
|
103
|
+
if (!section.meta) problems.push(`${where}: no "**Plugin x.y.z** · …" line under the heading`);
|
|
104
|
+
else if (!section.plugin) problems.push(`${where}: the Plugin line names no version`);
|
|
105
|
+
if (!section.summary) problems.push(`${where}: no one-sentence summary under the Plugin line`);
|
|
106
|
+
for (const k of Object.keys(section.groups)) if (!GROUPS.includes(k)) problems.push(`${where}: "### ${k}" is not one of ${GROUPS.map((g) => `"### ${g}"`).join(', ')}`);
|
|
107
|
+
if (!Object.values(section.groups).some((b) => b.length)) problems.push(`${where}: no "- " entries under ${GROUPS.map((g) => `"### ${g}"`).join(', ')}`);
|
|
108
|
+
versions.push(section);
|
|
109
|
+
cur = null;
|
|
110
|
+
};
|
|
111
|
+
lines.forEach((line, i) => {
|
|
112
|
+
if (/^## /.test(line)) {
|
|
113
|
+
close();
|
|
114
|
+
const m = HEADING.exec(line);
|
|
115
|
+
if (m) cur = { version: m[1], date: m[2] ?? null, heading: line, line: i + 1, lines: [] };
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (cur && LINK_DEF.test(line)) { close(); return; }
|
|
119
|
+
if (cur) cur.lines.push(line);
|
|
120
|
+
});
|
|
121
|
+
close();
|
|
122
|
+
for (let i = 1; i < versions.length; i++) {
|
|
123
|
+
if (compareVersions(versions[i - 1].version, versions[i].version) <= 0) problems.push(`CHANGELOG.md line ${versions[i].line}: ${versions[i].version} comes after ${versions[i - 1].version}; newest first, each version once`);
|
|
124
|
+
}
|
|
125
|
+
return { versions, problems };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The package's own CHANGELOG.md, parsed; null when it is missing (a checkout from before 0.19.2) or unreadable. */
|
|
129
|
+
export function readChangelog(file = CHANGELOG_FILE) {
|
|
130
|
+
try { return { file, ...parseChangelog(readFileSync(file, 'utf8')) }; } catch { return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One version's section, or null. */
|
|
134
|
+
export function sectionOf(changelog, version) {
|
|
135
|
+
return changelog?.versions?.find((s) => s.version === version) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What changed after `from` up to and including `to`, newest first: each version's summary and its upgrade notes
|
|
140
|
+
* (what the person may have to do). null when there is nothing to say (no pin known, no changelog, or not older).
|
|
141
|
+
*/
|
|
142
|
+
export function whatsNew(from, to, { changelog = readChangelog() } = {}) {
|
|
143
|
+
if (!VERSION.test(String(from ?? '')) || !VERSION.test(String(to ?? '')) || compareVersions(from, to) >= 0 || !changelog) return null;
|
|
144
|
+
const versions = changelog.versions
|
|
145
|
+
.filter((s) => compareVersions(s.version, from) > 0 && compareVersions(s.version, to) <= 0)
|
|
146
|
+
.map((s) => ({ version: s.version, date: s.date, plugin: s.plugin, summary: plain(s.summary), upgrade: (s.groups['Upgrade notes'] ?? []).map(plain) }));
|
|
147
|
+
return versions.length ? { from, to, versions } : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* whatsNew as a few lines for a terminal or a card's text: one line per version (the newest `maxVersions`), then the
|
|
152
|
+
* upgrade notes of every version in the range (the first `maxNotes`), each cut to `width`.
|
|
153
|
+
*/
|
|
154
|
+
export function whatsNewLines(news, { width = 110, maxVersions = 8, maxNotes = 8, source = null } = {}) {
|
|
155
|
+
if (!news?.versions?.length) return [];
|
|
156
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1).replace(/\s+\S*$/, '')}…` : s);
|
|
157
|
+
const shown = news.versions.slice(0, maxVersions);
|
|
158
|
+
const pad = Math.max(...news.versions.map((v) => v.version.length));
|
|
159
|
+
const lines = [`What's new since ${news.from}${source ? ` (${source})` : ''}:`];
|
|
160
|
+
for (const v of shown) lines.push(` ${v.version.padEnd(pad)} ${cut(v.summary ?? '', width - pad - 4)}`);
|
|
161
|
+
const more = news.versions.length - shown.length;
|
|
162
|
+
if (more) lines.push(` … and ${more} earlier version${more === 1 ? '' : 's'} (CHANGELOG.md has every one)`);
|
|
163
|
+
const notes = news.versions.flatMap((v) => v.upgrade.map((u) => [v.version, u]));
|
|
164
|
+
if (notes.length) {
|
|
165
|
+
lines.push('Upgrade notes (anything to do yourself):');
|
|
166
|
+
for (const [v, u] of notes.slice(0, maxNotes)) lines.push(` ${v.padEnd(pad)} ${cut(u, width - pad - 4)}`);
|
|
167
|
+
if (notes.length > maxNotes) lines.push(` … and ${notes.length - maxNotes} more (--json has every one)`);
|
|
168
|
+
}
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Markdown with each paragraph and each "- " entry on one line. CHANGELOG.md wraps them at 120 columns, which a
|
|
174
|
+
* file ignores; GitHub's release notes render every line end as a break, so a wrapped entry would break mid-sentence.
|
|
175
|
+
*/
|
|
176
|
+
export function unwrap(markdown) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const line of String(markdown ?? '').split('\n')) {
|
|
179
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
180
|
+
const starts = !line.trim() || /^(#{1,6} |- |\* |\d+\. |---\s*$|\|)/.test(line.trim()) && !/^\s+/.test(line);
|
|
181
|
+
const continues = prev.trim() && !/^(#{1,6} |---\s*$|\|)/.test(prev.trim());
|
|
182
|
+
if (!starts && continues) out[out.length - 1] = `${prev.replace(/\s+$/, '')} ${line.trim()}`;
|
|
183
|
+
else out.push(line);
|
|
184
|
+
}
|
|
185
|
+
return out.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A GitHub release's notes: the version's section as it is in CHANGELOG.md (each paragraph and entry on one line),
|
|
190
|
+
* then the Desktop extension's line (its .mcpb is attached to the release) and where every version is.
|
|
191
|
+
*/
|
|
192
|
+
export function releaseNotes(changelog, version, { tag = null, repo = REPO_URL } = {}) {
|
|
193
|
+
const s = sectionOf(changelog, version);
|
|
194
|
+
if (!s) return null;
|
|
195
|
+
return [
|
|
196
|
+
unwrap(s.body),
|
|
197
|
+
'',
|
|
198
|
+
'---',
|
|
199
|
+
'',
|
|
200
|
+
`Homie for Claude Desktop ${version}: [desktop/README.md](${repo}/blob/${tag ?? 'main'}/desktop/README.md) says how to install it (\`homie-studio-${version}.mcpb\` below).`,
|
|
201
|
+
`Every version: [CHANGELOG.md](${repo}/blob/main/CHANGELOG.md).`,
|
|
202
|
+
'',
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
package/lib/jobs.mjs
CHANGED
|
@@ -159,10 +159,11 @@ export function jobView(job, { lines = 12 } = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** Run the studio's pinned CLI as a job: `homie-studio <args> --json`. */
|
|
162
|
-
export function cliJob(root, label, args, opts = {}) {
|
|
162
|
+
export function cliJob(root, label, args, { cli = null, ...opts } = {}) {
|
|
163
163
|
const node = findNode();
|
|
164
164
|
if (!node) throw new Error('no Node.js 22 or newer on this computer: install it from https://nodejs.org/en/download (the LTS), then ask again');
|
|
165
|
-
|
|
165
|
+
// `cli`: another homie-studio than the studio's pinned one (this package's, for an upgrade to it).
|
|
166
|
+
return startJob({ root, label, cmd: node.bin, args: [cli ?? cliOf(root), ...args, '--json'], json: true, ...opts });
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
/** `npm install` in a studio (its pinned toolkit and Wrangler). */
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -28,8 +28,10 @@ import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs,
|
|
|
28
28
|
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
29
29
|
import { newStudio, slugify } from './scaffold.mjs';
|
|
30
30
|
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
31
|
-
import { GAME_ID, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
31
|
+
import { GAME_ID, PACKAGE_ROOT, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
32
32
|
import { STUDIO_VERSION } from './version.mjs';
|
|
33
|
+
import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
|
|
34
|
+
import { pinnedVersion } from './upgrade.mjs';
|
|
33
35
|
import { AgentSeat } from './agent-seat.mjs';
|
|
34
36
|
|
|
35
37
|
export const UI = Object.freeze({
|
|
@@ -120,8 +122,8 @@ const ok = (text, structured, extra = {}) => ({ content: [{ type: 'text', text }
|
|
|
120
122
|
const fail = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}), isError: true });
|
|
121
123
|
|
|
122
124
|
/** Run the pinned CLI and wait a while; { ended, job, result }. */
|
|
123
|
-
async function cli(ctx, root, label, args, { wait = ctx.waitMs, onEnd = null } = {}) {
|
|
124
|
-
const job = cliJob(root, label, args, { onEnd });
|
|
125
|
+
async function cli(ctx, root, label, args, { wait = ctx.waitMs, onEnd = null, cli: bin = null } = {}) {
|
|
126
|
+
const job = cliJob(root, label, args, { onEnd, ...(bin ? { cli: bin } : {}) });
|
|
125
127
|
const ended = await waitJob(job, wait);
|
|
126
128
|
return { ended, job, result: job.result };
|
|
127
129
|
}
|
|
@@ -245,6 +247,18 @@ function published(root, kind) {
|
|
|
245
247
|
} catch { return []; }
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
/**
|
|
251
|
+
* When the studio pins an older @homie-rocks/studio than this one: the pin, this version, and what's new between them
|
|
252
|
+
* (this package's own CHANGELOG.md). null when it is on this version, a newer one, or a link to a checkout.
|
|
253
|
+
*/
|
|
254
|
+
export function behindOf(root) {
|
|
255
|
+
let pkg = null;
|
|
256
|
+
try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
|
|
257
|
+
const pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']);
|
|
258
|
+
if (!pinned || compareVersions(pinned, STUDIO_VERSION) >= 0) return null;
|
|
259
|
+
return { pinned, here: STUDIO_VERSION, whatsNew: whatsNew(pinned, STUDIO_VERSION) };
|
|
260
|
+
}
|
|
261
|
+
|
|
248
262
|
async function studioCard(ctx, root) {
|
|
249
263
|
const studio = readStudio(root);
|
|
250
264
|
const dev = devOf(root);
|
|
@@ -282,6 +296,8 @@ async function studioCard(ctx, root) {
|
|
|
282
296
|
games, songs: published(root, 'music'), videos: published(root, 'videos'), posts,
|
|
283
297
|
dev: dev ? { url: dev.url } : null, site: live, rooms, demo, installed: installed(root),
|
|
284
298
|
};
|
|
299
|
+
const behind = behindOf(root);
|
|
300
|
+
if (behind) data.behind = { ...behind, whatsNew: behind.whatsNew ? { ...behind.whatsNew, versions: behind.whatsNew.versions.slice(0, 6).map(({ version, summary, upgrade }) => ({ version, summary, upgrade: upgrade.slice(0, 2) })) } : null };
|
|
285
301
|
const text = [
|
|
286
302
|
`${studio.name} (${root})`,
|
|
287
303
|
games.length ? `Games: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : 'No game yet: its home page says "First game coming soon".',
|
|
@@ -289,6 +305,10 @@ async function studioCard(ctx, root) {
|
|
|
289
305
|
live ? `Live: ${live}/` : 'Not online yet (studio_deploy).',
|
|
290
306
|
...(rooms.length ? [`Live rooms: ${rooms.map((r) => `${r.game} ${r.room} (${r.players})`).join(', ')}`] : []),
|
|
291
307
|
...(demo ? [`See a working game meanwhile: ${demo.name}, ${demo.play}`] : []),
|
|
308
|
+
...(behind ? [
|
|
309
|
+
`Toolkit: this studio pins @homie-rocks/studio ${behind.pinned}; this Homie is ${behind.here}. Tell the person what is new (below) and offer the upgrade: studio_run { "args": ["upgrade"] } shows the plan and changes nothing; with their yes, ["upgrade","--apply"], then studio_install.`,
|
|
310
|
+
...whatsNewLines(behind.whatsNew, { maxVersions: 6, maxNotes: 6 }),
|
|
311
|
+
] : []),
|
|
292
312
|
].join('\n');
|
|
293
313
|
return ok(text, data);
|
|
294
314
|
}
|
|
@@ -948,7 +968,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
948
968
|
},
|
|
949
969
|
{
|
|
950
970
|
name: 'studio_run', title: 'Run a studio command',
|
|
951
|
-
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job.',
|
|
971
|
+
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job. ["upgrade"] runs with THIS Homie\'s toolkit when the studio pins an older one (that is how a studio moves up to it): it says what is new since the studio\'s version and what would change, and changes nothing; ["upgrade","--apply"] only after the person agrees, then studio_install.',
|
|
952
972
|
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
|
|
953
973
|
annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
954
974
|
run: async (a) => {
|
|
@@ -959,7 +979,9 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
959
979
|
if (refused) return fail(`not here: ${refused}`);
|
|
960
980
|
if (!['help', 'version', 'setup', 'doctor', 'demo', 'starters', 'progress', 'codex', 'games', 'status'].includes(args[0])) { const need = needsInstall(ctx, root); if (need) return need; }
|
|
961
981
|
if (args[0] === 'game' && args[1] === 'new') ctx.note(root).demo = true;
|
|
962
|
-
|
|
982
|
+
// The upgrade is the newer toolkit's to make: the studio's own pinned copy knows nothing newer than itself.
|
|
983
|
+
const own = args[0] === 'upgrade' && behindOf(root) ? { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs') } : {};
|
|
984
|
+
const r = await cli(ctx, root, `homie-studio ${args.slice(0, 3).join(' ')}`, args, own);
|
|
963
985
|
if (!r.ended) return stillRunning(r.job, `homie-studio ${args.slice(0, 2).join(' ')}`);
|
|
964
986
|
const text = r.result ? JSON.stringify(r.result, null, 1).slice(0, 12_000) : jobView(r.job, { lines: 40 }).tail.join('\n');
|
|
965
987
|
return r.job.code === 0 ? ok(text, { kind: 'run', args, result: r.result }) : fail(`homie-studio ${args.join(' ')}: ${whyOf(r.job)}`, { kind: 'run', args, result: r.result });
|
|
@@ -813,6 +813,43 @@
|
|
|
813
813
|
}
|
|
814
814
|
}
|
|
815
815
|
}
|
|
816
|
+
},
|
|
817
|
+
"0.19.2": {
|
|
818
|
+
"files": {
|
|
819
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
820
|
+
"README.md": "684b3cca8001673c",
|
|
821
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
822
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
823
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
824
|
+
"videos/README.md": "16babc3437d5545b",
|
|
825
|
+
"posts/README.md": "37841aae476bd540",
|
|
826
|
+
"site/README.md": "ca131cd0472464fb",
|
|
827
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
828
|
+
},
|
|
829
|
+
"sections": {
|
|
830
|
+
"AGENTS.md": {
|
|
831
|
+
"(intro)": "214aa46eb3a895d5",
|
|
832
|
+
"## Layout": "8dc97df0d77e655e",
|
|
833
|
+
"## Commands (all through the pinned CLI in node_modules)": "a06ab13fc385ab25",
|
|
834
|
+
"## The site": "ab922bb6bde23293",
|
|
835
|
+
"## Making games": "a8a893eeada33bf5",
|
|
836
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
837
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
838
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
839
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
840
|
+
"## Rules": "969318425c99f5a5",
|
|
841
|
+
"## Beta": "9d02638207a26e73"
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
"named": {
|
|
845
|
+
"Homie Arcade": {
|
|
846
|
+
"sections": {
|
|
847
|
+
"AGENTS.md": {
|
|
848
|
+
"## Commands (all through the pinned CLI in node_modules)": "677ee39dc13485e0"
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
816
853
|
}
|
|
817
854
|
}
|
|
818
855
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -25,11 +25,16 @@
|
|
|
25
25
|
* Media (0.18.0): the plan also says which songs and videos are big media (over studio.json media.r2Over, 1 MiB
|
|
26
26
|
* unless set, or left out of git), what is in the studio's R2 already, and the step that moves the rest
|
|
27
27
|
* (`media move`, which every deploy of a studio with storage also runs). Upgrade itself never moves a file.
|
|
28
|
+
*
|
|
29
|
+
* What's new (0.19.2): the plan and the applied result carry `whatsNew`, every version after the one the studio
|
|
30
|
+
* was on up to this one, each with its one-line summary and its upgrade notes, read from THIS package's own
|
|
31
|
+
* CHANGELOG.md (lib/changelog.mjs): the version being moved to says what it brings, with no network.
|
|
28
32
|
*/
|
|
29
33
|
import { createHash } from 'node:crypto';
|
|
30
34
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
31
35
|
import { spawnSync } from 'node:child_process';
|
|
32
36
|
import { dirname, join } from 'node:path';
|
|
37
|
+
import { whatsNew } from './changelog.mjs';
|
|
33
38
|
import { R2_COST, mediaPlan, r2OverOf, sizeOf } from './media.mjs';
|
|
34
39
|
import { studioFiles } from './scaffold.mjs';
|
|
35
40
|
import { readStudio } from './studio.mjs';
|
|
@@ -243,12 +248,15 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
243
248
|
const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
244
249
|
const dirty = git.status === 0 ? git.stdout.split('\n').filter(Boolean).length : null;
|
|
245
250
|
const media = mediaNote(root, studio);
|
|
251
|
+
const news = whatsNew(from, STUDIO_VERSION);
|
|
246
252
|
return {
|
|
247
253
|
ok: true, command: 'upgrade', applied: false, root, studio: name, from, to: STUDIO_VERSION,
|
|
254
|
+
...(news ? { whatsNew: news } : {}),
|
|
248
255
|
changes, kept,
|
|
249
256
|
...(media ? { media } : {}),
|
|
250
257
|
...(dirty ? { uncommitted: dirty } : {}),
|
|
251
|
-
|
|
258
|
+
// A plan that moves the pin is this (newer) toolkit's to apply: the studio's own older copy knows nothing newer.
|
|
259
|
+
next: changes.length ? [`${changes.some((c) => c.kind === 'pin') ? `npx -y @homie-rocks/studio@${STUDIO_VERSION} upgrade --apply` : 'npx --no-install homie-studio upgrade --apply'} (after the person agrees)`] : [],
|
|
252
260
|
};
|
|
253
261
|
}
|
|
254
262
|
|
package/mcp/ui/card.css
CHANGED
|
@@ -63,6 +63,8 @@ ol.list,ul.list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
|
|
|
63
63
|
.game b{font-size:14px}.game span{font-size:12.5px;color:var(--c-fg2)}
|
|
64
64
|
.soon{padding:14px;border-radius:var(--r-md);background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 18%,var(--c-bg2)),var(--c-bg2));display:grid;gap:6px}
|
|
65
65
|
.soon b{font-size:16px}
|
|
66
|
+
.news{margin:12px 0 0;padding:12px 14px;border-radius:var(--r-md);background:var(--c-bg2);display:grid;gap:6px;font-size:13px;color:var(--c-fg2)}
|
|
67
|
+
.news b{color:var(--c-fg);font-size:14px}.news code{font-weight:600;color:var(--c-fg);margin-right:2px}.news .fine{margin:2px 0 0}
|
|
66
68
|
.stats{display:flex;flex-wrap:wrap;gap:6px 16px;margin:4px 0 6px;color:var(--c-fg2);font-size:13px}.stats b{color:var(--c-fg)}
|
|
67
69
|
.log{margin:10px 0 0;padding:8px 10px;border-radius:var(--r-md);background:var(--c-bg2);font:12px/1.5 var(--f-mono);color:var(--c-fg2);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
68
70
|
.frame{width:100%;height:620px;border:0;border-radius:var(--r-md);background:var(--c-bg2);display:block}
|
package/mcp/ui/studio.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* The studio card: its games with Play (on this computer and live), songs, videos and posts, where it runs, its
|
|
2
|
-
* live rooms,
|
|
2
|
+
* live rooms, a live game to try while it has none, and what's new when its toolkit is older than this Homie. */
|
|
3
3
|
(function () {
|
|
4
4
|
var C = Card;
|
|
5
5
|
function initials(name) { return String(name || '?').split(/\s+/).filter(Boolean).slice(0, 2).map(function (w) { return w[0]; }).join('').toUpperCase(); }
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
card.appendChild(soon);
|
|
29
29
|
}
|
|
30
30
|
if ((sc.rooms || []).length) card.appendChild(C.el('p', 'fine', 'Playing now: ' + sc.rooms.map(function (r) { return r.game + ' (' + r.players + ')'; }).join(', ')));
|
|
31
|
+
// A studio on an older toolkit than this Homie: what's new since (this package's CHANGELOG.md), and the words to ask.
|
|
32
|
+
if (sc.behind) {
|
|
33
|
+
var news = C.add(C.el('div', 'news'), C.el('b', '', 'New in Homie since ' + sc.behind.pinned));
|
|
34
|
+
((sc.behind.whatsNew && sc.behind.whatsNew.versions) || []).slice(0, 3).forEach(function (v) { news.appendChild(C.add(C.el('span'), C.el('code', '', v.version), C.el('span', '', ' ' + v.summary))); });
|
|
35
|
+
news.appendChild(C.el('span', 'fine', 'This studio is on ' + sc.behind.pinned + ', and ' + sc.behind.here + ' is here. Say \u201cUpgrade my studio\u201d to see what changes; nothing does until you agree.'));
|
|
36
|
+
card.appendChild(news);
|
|
37
|
+
C.tell('behind', sc.name + ' pins @homie-rocks/studio ' + sc.behind.pinned + ' and this Homie is ' + sc.behind.here + ': offer the upgrade (studio_run ["upgrade"] shows what is new and the plan, and changes nothing).');
|
|
38
|
+
}
|
|
31
39
|
var foot = C.el('div', 'foot');
|
|
32
40
|
foot.appendChild(C.el('span', 'when', sc.site ? sc.site.replace(/^https?:\/\//, '') : sc.dev ? sc.dev.url.replace(/^https?:\/\//, '') : 'Not online yet'));
|
|
33
41
|
var acts = C.el('div', 'actions');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"media/",
|
|
51
51
|
"site/",
|
|
52
52
|
"README.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
53
54
|
"LICENSE",
|
|
54
55
|
"NOTICE"
|
|
55
56
|
],
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.19.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.19.2';
|