@homie-rocks/studio 0.19.0 → 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
@@ -440,6 +446,12 @@ playable, heap, the host's upload) must not get worse. Results are files under `
440
446
  command prints paths and medians. The Homie plugin's `perf` skill runs the whole loop: baseline, one change at a time,
441
447
  keep or revert, and a report in the studio's `perf/` folder.
442
448
 
449
+ `perf sizes` reads each big script for whether it is minified from its code, not from how well it gzips: whitespace,
450
+ comments and names outside its strings (a minifier leaves about 1 to 2% whitespace; indented source has 15 to 30%).
451
+ Minified JavaScript gzips about as well as source text, and a bundle with three.js in it carries the shaders as GLSL
452
+ source in strings, which no minifier touches; the line for such a file says `minified, 19% GLSL shader source in
453
+ strings`, and the JSON's `code` has every measure.
454
+
443
455
  ## Checks on a computer without a GPU
444
456
 
445
457
  `check`, `port check` and `look` run Chrome headless. On a Mac they use its GPU. On Linux (a Claude Code cloud
@@ -492,6 +504,7 @@ npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner te
492
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). |
493
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). |
494
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. |
495
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`. |
496
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. |
497
510
  | `template/` (repository root), `scripts/template.mjs` | The public "Deploy to Cloudflare" template, generated from `new --template`. |
@@ -31,7 +31,8 @@
31
31
  * emulated (a --cpu times slower CPU, 4G) on this computer's GPU. The plugin's perf
32
32
  * skill runs the whole measure, change, compare, keep-or-revert loop)
33
33
  * homie-studio perf sizes <id> (what a player downloads: every built file, raw and gzipped, the biggest first; with
34
- * build --maps, which modules make up the bundle)
34
+ * build --maps, which modules make up the bundle; each big script read for whether it
35
+ * is minified, from its code, and how much of it is GLSL shader source in strings)
35
36
  * homie-studio perf compare <before dir> <after dir> [--goal phone.host.frame.p95] [--min 3] [--guards a,b] [--also c,d]
36
37
  * (better, worse or the same within the noise: medians, a 95% interval, a rank test,
37
38
  * and the guards that must not get worse; runs taken side by side with the same
@@ -60,7 +61,9 @@
60
61
  * homie-studio upgrade [--apply] [--diff]
61
62
  * (what this version's template adds to an existing studio: AGENTS.md
62
63
  * sections, READMEs, .gitignore lines, the pin; changes nothing until
63
- * --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)
64
67
  * homie-studio stats [--range 1d|7d|30d|90d] [--game <id> | --song <slug> | --video <slug>] [--url <site>]
65
68
  * homie-studio stats key [--hours 1] (a read key for the Homie MCP tool studio_stats)
66
69
  * homie-studio stats link (a one-time link to the private stats page, for the owner's browser)
@@ -169,6 +172,7 @@ import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
169
172
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
170
173
  import { ensureMigrations, migrationWord, newStudio } from '../lib/scaffold.mjs';
171
174
  import { lineDiff, upgradeApply, upgradePlan } from '../lib/upgrade.mjs';
175
+ import { whatsNewLines } from '../lib/changelog.mjs';
172
176
  import { listGames, newGame, readStudio, remixGame, requireStudio, siteUrl, starters, workerDir } from '../lib/studio.mjs';
173
177
  import { STUDIO_VERSION } from '../lib/version.mjs';
174
178
  import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/stats.mjs';
@@ -438,7 +442,9 @@ function print(result) {
438
442
  lines.push(result.applied
439
443
  ? `${result.studio} is on the @homie-rocks/studio ${result.to} template now (${list.length} change${list.length === 1 ? '' : 's'}).`
440
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.`);
441
- 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:');
442
448
  for (const c of list) {
443
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}"`})` : ''}`);
444
450
  if (result.applied) continue;
@@ -477,7 +483,7 @@ function print(result) {
477
483
  case 'perf sizes': {
478
484
  const kb = (b) => `${(b / 1024).toFixed(1)} KB`;
479
485
  lines.push(`${result.game}: ${result.total.files} files, ${kb(result.total.bytes)} (${kb(result.total.gzip)} gzipped); JavaScript ${kb(result.js.bytes)} (${kb(result.js.gzip)} gzipped)`,
480
- 'Biggest:', ...result.biggest.slice(0, 8).map((f) => ` ${kb(f.bytes).padStart(10)} ${f.path}${f.gzip !== f.bytes ? ` (${kb(f.gzip)} gzipped)` : ''}`),
486
+ 'Biggest:', ...result.biggest.slice(0, 8).map((f) => ` ${kb(f.bytes).padStart(10)} ${f.path}${f.gzip !== f.bytes ? ` (${kb(f.gzip)} gzipped)` : ''}${f.code ? ` ${f.code.minified ? `minified${f.code.shaderPct >= 5 ? `, ${f.code.shaderPct}% GLSL shader source in strings` : ''}` : 'NOT minified'}` : ''}`),
481
487
  ...(result.modules ? ['Bundle modules:', ...result.modules.top.slice(0, 8).map((m) => ` ${kb(m.bytes).padStart(10)} ${m.module}`)] : []),
482
488
  ...(result.apart ? [`Not loaded by the game: ${result.apart.files} file(s), ${kb(result.apart.bytes)} (${result.apart.note})`] : []));
483
489
  break;
@@ -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 });