@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/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@0.18.2 upgrade # the plan; changes nothing
115
- npx -y @homie-rocks/studio@0.18.2 upgrade --apply # after the person agrees
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`. |
@@ -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
- if (list.length) lines.push('');
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
- return startJob({ root, label, cmd: node.bin, args: [cliOf(root), ...args, '--json'], json: true, ...opts });
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
- const r = await cli(ctx, root, `homie-studio ${args.slice(0, 3).join(' ')}`, args);
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
- next: changes.length ? ['npx --no-install homie-studio upgrade --apply (after the person agrees)'] : [],
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, and a live game to try while it has none. */
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.1",
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
  ],
@@ -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.1';
2
+ export const STUDIO_VERSION_TAG = '0.19.2';