@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/CHANGELOG.md +875 -0
- package/README.md +16 -3
- package/bin/homie-studio.mjs +10 -4
- package/lib/changelog.mjs +204 -0
- package/lib/jobs.mjs +3 -2
- package/lib/mcp-tools.mjs +27 -5
- package/lib/perf-code.mjs +255 -0
- package/lib/perf.mjs +15 -1
- package/lib/template-history.json +74 -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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a JavaScript file minified, and how much of it is strings and GLSL shader source? Read from the code itself,
|
|
3
|
+
* never from how well the whole file gzips.
|
|
4
|
+
*
|
|
5
|
+
* How well a file compresses says nothing about it: minified JavaScript gzips to a quarter or a third of its bytes
|
|
6
|
+
* (three.core.min.js 26%, three.module.min.js 24%), more than its indented source does (20%: whitespace is the easiest
|
|
7
|
+
* thing to compress). And a bundle with three.js in it carries the shaders as GLSL source in strings, line by line, with
|
|
8
|
+
* their indentation and their long names, which no minifier touches: a game's minified three.js bundle can be thousands
|
|
9
|
+
* of lines, nearly all of them inside shader strings.
|
|
10
|
+
*
|
|
11
|
+
* So the file is read the way a JavaScript parser reads it, just far enough to tell code from strings, template
|
|
12
|
+
* literals, regular expressions and comments, and only the code is judged:
|
|
13
|
+
* - whitespace: the share of the code's characters that are spaces, tabs and line breaks. A minifier leaves the
|
|
14
|
+
* few a keyword needs (about 1 to 2%); indented source is 15 to 30%;
|
|
15
|
+
* - comments: the share of the file in comments, licence comments (`/*!`, `@license`, `@preserve`) apart, since a
|
|
16
|
+
* minifier keeps those;
|
|
17
|
+
* - lines: the code's characters per line (string contents not counted);
|
|
18
|
+
* - names: the identifiers that are not a property (after `.`) or an object's key, which a minifier renames: their
|
|
19
|
+
* mean length and the share one or two characters long (`mangled`). A minifier that keeps names (whitespace and
|
|
20
|
+
* syntax only) is still a minifier.
|
|
21
|
+
* And the strings: the share of the file inside string and template literals, and the share of those that are GLSL
|
|
22
|
+
* shader source (#ifdef, uniform vec3, gl_FragColor, texture2D( ...).
|
|
23
|
+
*
|
|
24
|
+
* It is a reader, not a parser: a `/` after `)` or `}` is division unless the `(` followed if/while/for/with or the
|
|
25
|
+
* `{` opened a block, and a regular expression never spans a line (when it seems to, it was division). That is what a
|
|
26
|
+
* minifier's output and ordinary source need; a file it cannot read cleanly is judged on what it read.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const KEYWORDS = new Set(['break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'await', 'of', 'null', 'true', 'false', 'static', 'async', 'get', 'set', 'enum', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'undefined', 'arguments', 'eval']);
|
|
30
|
+
/** After these a `/` starts a regular expression; after any other keyword (this, null, true ...) it is division. */
|
|
31
|
+
const REGEX_AFTER_KEYWORD = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw', 'case', 'do', 'else', 'yield', 'await', 'extends']);
|
|
32
|
+
/** A `{` after one of these opens a block (a `/` after its `}` starts a statement); anywhere else, an object. */
|
|
33
|
+
const BLOCK_AFTER_PUNCT = new Set([')', ';', '{', '}', '=>']);
|
|
34
|
+
const BLOCK_AFTER_KEYWORD = new Set(['else', 'do', 'try', 'finally']);
|
|
35
|
+
|
|
36
|
+
/** GLSL in a string: one of these marks a shader line; a literal with two different ones (or a long one with one) is shader source. */
|
|
37
|
+
const GLSL = [
|
|
38
|
+
/#\s*(?:ifdef|ifndef|endif|elif|define|undef|include\s*<|pragma|extension|version)\b/,
|
|
39
|
+
/\b(?:uniform|varying|attribute)\s+(?:(?:lowp|mediump|highp)\s+)?(?:float|int|uint|bool|[biu]?vec[234]|mat[234](?:x[234])?|sampler\w*)\b/,
|
|
40
|
+
/\bprecision\s+(?:lowp|mediump|highp)\s+(?:float|int|sampler\w*)\b/,
|
|
41
|
+
/\bgl_(?:Position|FragColor|FragCoord|FragData|PointSize|PointCoord|InstanceID|VertexID|FrontFacing)\b/,
|
|
42
|
+
/\bvoid\s+main\s*\(/,
|
|
43
|
+
/\b(?:texture2D|textureCube|texture2DLodEXT|textureLod|texelFetch)\s*\(/,
|
|
44
|
+
/\b(?:[biu]?vec[234]|mat[234])\s+\w+\s*[=;,)]/,
|
|
45
|
+
/\b(?:[biu]?vec[234]|mat[234])\s*\(/,
|
|
46
|
+
/\b(?:smoothstep|inversesqrt|fract|clamp|saturate|dot|normalize|mix)\s*\([^)]*\)\s*;/,
|
|
47
|
+
];
|
|
48
|
+
function glslScore(text) {
|
|
49
|
+
// A shader says what it is in its first lines; a huge literal (a base64 blob) is read only that far.
|
|
50
|
+
const head = text.length > 65_536 ? text.slice(0, 65_536) : text;
|
|
51
|
+
let n = 0;
|
|
52
|
+
for (const re of GLSL) if (re.test(head)) n++;
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
const isShader = (text) => { const s = glslScore(text); return s >= 2 || (s >= 1 && text.length >= 400); };
|
|
56
|
+
|
|
57
|
+
const isIdStart = (c) => (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 36 || c === 95 || c >= 0x80 || c === 92;
|
|
58
|
+
const isIdPart = (c) => isIdStart(c) || (c >= 48 && c <= 57);
|
|
59
|
+
const isDigit = (c) => c >= 48 && c <= 57;
|
|
60
|
+
const isWs = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 11 || c === 12 || c === 0xa0 || c === 0xfeff || c === 0x2028 || c === 0x2029;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read `text` (a JavaScript file). Returns the measures above, rounded, and the verdict:
|
|
64
|
+
* { minified, mangled, whitespacePct, commentPct, licencePct, codeCharsPerLine, nameLength, shortNamesPct, names,
|
|
65
|
+
* stringPct, shaderPct, bytes, why }
|
|
66
|
+
*/
|
|
67
|
+
export function readCode(text) {
|
|
68
|
+
const src = String(text);
|
|
69
|
+
const n = src.length;
|
|
70
|
+
let i = 0;
|
|
71
|
+
let codeChars = 0; // characters read as code (not in a string, template, comment or regular expression's body)
|
|
72
|
+
let codeWs = 0;
|
|
73
|
+
let codeBreaks = 0;
|
|
74
|
+
let commentChars = 0;
|
|
75
|
+
let licenceChars = 0;
|
|
76
|
+
let stringChars = 0;
|
|
77
|
+
let shaderChars = 0;
|
|
78
|
+
const names = { count: 0, short: 0, chars: 0 };
|
|
79
|
+
/** The last significant token: { t: 'name'|'kw'|'num'|'str'|'regex'|'punct', v } */
|
|
80
|
+
let prev = null;
|
|
81
|
+
const braces = []; // '{b' a block, '{o' an object or other expression, '${' a template's substitution
|
|
82
|
+
const parens = []; // true when the ( followed if/while/for/with
|
|
83
|
+
const templates = []; // the text of each open template literal (one may hold ${ } with another inside)
|
|
84
|
+
|
|
85
|
+
const literal = (body) => {
|
|
86
|
+
stringChars += body.length;
|
|
87
|
+
if (body.length >= 12 && isShader(body)) shaderChars += body.length;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const regexAllowed = () => {
|
|
91
|
+
if (!prev) return true;
|
|
92
|
+
if (prev.t === 'name' || prev.t === 'num' || prev.t === 'str' || prev.t === 'regex') return false;
|
|
93
|
+
if (prev.t === 'kw') return REGEX_AFTER_KEYWORD.has(prev.v);
|
|
94
|
+
if (prev.v === ')' || prev.v === ']' || prev.v === '}') return false;
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Scan a template literal's text from i up to its closing backtick (done) or a `${` (back to code). */
|
|
99
|
+
const templateText = () => {
|
|
100
|
+
const start = i;
|
|
101
|
+
while (i < n) {
|
|
102
|
+
const c = src.charCodeAt(i);
|
|
103
|
+
if (c === 92) { i += 2; continue; }
|
|
104
|
+
if (c === 96) { templates[templates.length - 1] += src.slice(start, i); i++; literal(templates.pop()); prev = { t: 'str' }; return; }
|
|
105
|
+
if (c === 36 && src.charCodeAt(i + 1) === 123) { templates[templates.length - 1] += src.slice(start, i); i += 2; braces.push('${'); prev = { t: 'punct', v: '${' }; return; }
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
templates[templates.length - 1] += src.slice(start, n);
|
|
109
|
+
literal(templates.pop());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
while (i < n) {
|
|
113
|
+
const c = src.charCodeAt(i);
|
|
114
|
+
if (isWs(c)) {
|
|
115
|
+
codeChars++; codeWs++;
|
|
116
|
+
if (c === 10) codeBreaks++;
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Comments.
|
|
121
|
+
if (c === 47 && src.charCodeAt(i + 1) === 47) {
|
|
122
|
+
const end = src.indexOf('\n', i);
|
|
123
|
+
const stop = end < 0 ? n : end;
|
|
124
|
+
commentChars += stop - i;
|
|
125
|
+
i = stop;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (c === 47 && src.charCodeAt(i + 1) === 42) {
|
|
129
|
+
const end = src.indexOf('*/', i + 2);
|
|
130
|
+
const stop = end < 0 ? n : end + 2;
|
|
131
|
+
const body = src.slice(i, stop);
|
|
132
|
+
if (body.startsWith('/*!') || /@license|@preserve|@copyright/i.test(body)) licenceChars += stop - i; else commentChars += stop - i;
|
|
133
|
+
i = stop;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Strings.
|
|
137
|
+
if (c === 39 || c === 34) {
|
|
138
|
+
const start = i + 1;
|
|
139
|
+
let j = start;
|
|
140
|
+
while (j < n) {
|
|
141
|
+
const d = src.charCodeAt(j);
|
|
142
|
+
if (d === 92) { j += 2; continue; }
|
|
143
|
+
if (d === c || d === 10) break;
|
|
144
|
+
j++;
|
|
145
|
+
}
|
|
146
|
+
literal(src.slice(start, j));
|
|
147
|
+
i = Math.min(n, j + 1);
|
|
148
|
+
prev = { t: 'str' };
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (c === 96) { i++; templates.push(''); templateText(); continue; }
|
|
152
|
+
// Regular expressions (only where an expression can start, and never across a line).
|
|
153
|
+
if (c === 47 && regexAllowed()) {
|
|
154
|
+
let j = i + 1;
|
|
155
|
+
let inClass = false;
|
|
156
|
+
let ok = false;
|
|
157
|
+
while (j < n) {
|
|
158
|
+
const d = src.charCodeAt(j);
|
|
159
|
+
if (d === 10 || d === 13) break;
|
|
160
|
+
if (d === 92) { j += 2; continue; }
|
|
161
|
+
if (d === 91) inClass = true;
|
|
162
|
+
else if (d === 93) inClass = false;
|
|
163
|
+
else if (d === 47 && !inClass) { ok = true; break; }
|
|
164
|
+
j++;
|
|
165
|
+
}
|
|
166
|
+
if (ok) {
|
|
167
|
+
j++;
|
|
168
|
+
while (j < n && isIdPart(src.charCodeAt(j))) j++;
|
|
169
|
+
codeChars += j - i;
|
|
170
|
+
i = j;
|
|
171
|
+
prev = { t: 'regex' };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Names and keywords.
|
|
176
|
+
if (isIdStart(c)) {
|
|
177
|
+
let j = i + 1;
|
|
178
|
+
while (j < n && isIdPart(src.charCodeAt(j))) j++;
|
|
179
|
+
const word = src.slice(i, j);
|
|
180
|
+
codeChars += j - i;
|
|
181
|
+
const afterDot = prev && prev.t === 'punct' && prev.v === '.';
|
|
182
|
+
if (!afterDot && KEYWORDS.has(word)) prev = { t: 'kw', v: word };
|
|
183
|
+
else {
|
|
184
|
+
// A property (after `.`) and an object's key (`{a:` or `,a:`) keep their names in any minifier: not counted.
|
|
185
|
+
let k = j;
|
|
186
|
+
while (k < n && isWs(src.charCodeAt(k))) k++;
|
|
187
|
+
const key = prev && prev.t === 'punct' && (prev.v === '{' || prev.v === ',') && src.charCodeAt(k) === 58 && braces[braces.length - 1] === '{o';
|
|
188
|
+
if (!afterDot && !key) {
|
|
189
|
+
names.count++;
|
|
190
|
+
names.chars += word.length;
|
|
191
|
+
if (word.length <= 2) names.short++;
|
|
192
|
+
}
|
|
193
|
+
prev = { t: 'name', v: word };
|
|
194
|
+
}
|
|
195
|
+
i = j;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// Numbers.
|
|
199
|
+
if (isDigit(c) || (c === 46 && isDigit(src.charCodeAt(i + 1)))) {
|
|
200
|
+
let j = i + 1;
|
|
201
|
+
while (j < n) {
|
|
202
|
+
const d = src.charCodeAt(j);
|
|
203
|
+
if (isIdPart(d) || d === 46) { j++; continue; }
|
|
204
|
+
if ((d === 43 || d === 45) && /[eE]/.test(src[j - 1]) && !/^0[xX]/.test(src.slice(i, j))) { j++; continue; }
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
codeChars += j - i;
|
|
208
|
+
i = j;
|
|
209
|
+
prev = { t: 'num' };
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// Punctuation.
|
|
213
|
+
codeChars++;
|
|
214
|
+
if (c === 123) { // {
|
|
215
|
+
const block = !prev || (prev.t === 'punct' && BLOCK_AFTER_PUNCT.has(prev.v)) || (prev.t === 'kw' && BLOCK_AFTER_KEYWORD.has(prev.v));
|
|
216
|
+
braces.push(block ? '{b' : '{o');
|
|
217
|
+
prev = { t: 'punct', v: '{' };
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (c === 125) { // }
|
|
222
|
+
const open = braces.pop();
|
|
223
|
+
i++;
|
|
224
|
+
if (open === '${') { templateText(); continue; }
|
|
225
|
+
// After a block a statement starts (a `/` there is a regular expression); after an object it is division.
|
|
226
|
+
prev = { t: 'punct', v: open === '{b' ? ';' : '}' };
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (c === 40) { parens.push(Boolean(prev && prev.t === 'kw' && ['if', 'while', 'for', 'with'].includes(prev.v))); prev = { t: 'punct', v: '(' }; i++; continue; }
|
|
230
|
+
if (c === 41) { const cond = parens.pop(); prev = { t: 'punct', v: cond ? ';' : ')' }; i++; continue; }
|
|
231
|
+
if (c === 61 && src.charCodeAt(i + 1) === 62) { codeChars++; prev = { t: 'punct', v: '=>' }; i += 2; continue; }
|
|
232
|
+
if (c === 46 && src.charCodeAt(i + 1) === 46 && src.charCodeAt(i + 2) === 46) { codeChars += 2; prev = { t: 'punct', v: '...' }; i += 3; continue; }
|
|
233
|
+
prev = { t: 'punct', v: src[i] };
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0);
|
|
238
|
+
const whitespacePct = pct(codeWs, codeChars);
|
|
239
|
+
const commentPct = pct(commentChars, n);
|
|
240
|
+
const licencePct = pct(licenceChars, n);
|
|
241
|
+
const codeCharsPerLine = Math.round((codeChars - codeWs) / (codeBreaks + 1));
|
|
242
|
+
const nameLength = names.count ? Math.round((names.chars / names.count) * 10) / 10 : null;
|
|
243
|
+
const shortNamesPct = pct(names.short, names.count);
|
|
244
|
+
const stringPct = pct(stringChars, n);
|
|
245
|
+
const shaderPct = pct(shaderChars, n);
|
|
246
|
+
// Minified: the code carries almost no whitespace, and almost no comments but its licence. Mangled: most names
|
|
247
|
+
// one or two characters. (Indented source: 15-30% whitespace; esbuild's or terser's output: 1-2%.)
|
|
248
|
+
const minified = codeChars > 0 && whitespacePct < 6 && commentPct < 2;
|
|
249
|
+
const mangled = names.count >= 20 && shortNamesPct >= 70;
|
|
250
|
+
const said = `whitespace ${whitespacePct}% of the code, comments ${commentPct}%, ${codeCharsPerLine} characters a line of code, names ${nameLength ?? '-'} characters on average (${shortNamesPct}% one or two)`;
|
|
251
|
+
const why = minified
|
|
252
|
+
? `minified${mangled ? ', names shortened' : ', names kept'} (${said})${stringPct >= 15 ? `; ${stringPct}% of its bytes are strings${shaderPct >= 5 ? `, ${shaderPct}% GLSL shader source` : ''}` : ''}`
|
|
253
|
+
: `not minified (${said})`;
|
|
254
|
+
return { minified, mangled, whitespacePct, commentPct, licencePct, codeCharsPerLine, nameLength, shortNamesPct, names: names.count, stringPct, shaderPct, bytes: n, why };
|
|
255
|
+
}
|
package/lib/perf.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { gzipSync } from 'node:zlib';
|
|
|
47
47
|
import { SOFTWARE_GL, chromeArgs, findChrome, noChrome } from './chrome.mjs';
|
|
48
48
|
import { LAUNCH_TIMEOUT_MS } from './check.mjs';
|
|
49
49
|
import { judge, judgePaired, round, summarize } from './perf-stats.mjs';
|
|
50
|
+
import { readCode } from './perf-code.mjs';
|
|
50
51
|
import { sourceMapLookup, summarizeProfile } from './perf-profile.mjs';
|
|
51
52
|
|
|
52
53
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -558,6 +559,11 @@ const COMPRESSIBLE = new Set(['js', 'css', 'html', 'data', 'model']);
|
|
|
558
559
|
* map (`build --maps`) which source modules make up the bundle. `source.json` (for remixers), `_landing/`, `hero/` and
|
|
559
560
|
* the cover (the landing page's) are listed apart: the game itself never loads them. What a browser really fetched is
|
|
560
561
|
* in each run (`load`).
|
|
562
|
+
*
|
|
563
|
+
* Each of the biggest JavaScript files (20 KB or more) carries `code`: whether it is minified, read from its code
|
|
564
|
+
* (whitespace, comments and names outside its strings: lib/perf-code.mjs), and how much of it is strings and GLSL
|
|
565
|
+
* shader source. A bundle with three.js in it gzips like text even minified (its shaders are source in strings), so
|
|
566
|
+
* how well a file compresses never says it is unminified.
|
|
561
567
|
*/
|
|
562
568
|
export function perfSizes(root, game) {
|
|
563
569
|
const dir = join(root, 'site', 'dist', 'games', game);
|
|
@@ -595,12 +601,20 @@ export function perfSizes(root, game) {
|
|
|
595
601
|
total: { files: game_.length, bytes: sum(game_, 'bytes'), gzip: sum(game_, 'gzip') },
|
|
596
602
|
js: { bytes: byKind.js?.bytes ?? 0, gzip: byKind.js?.gzip ?? 0 },
|
|
597
603
|
byKind,
|
|
598
|
-
biggest: [...game_].sort((a, b) => b.bytes - a.bytes).slice(0, 12).map(({ apart, ...f }) => f),
|
|
604
|
+
biggest: [...game_].sort((a, b) => b.bytes - a.bytes).slice(0, 12).map(({ apart, ...f }) => (f.kind === 'js' && f.bytes >= 20 * 1024 ? { ...f, code: codeOf(join(dir, f.path)) } : f)),
|
|
599
605
|
apart: files.filter((f) => f.apart).length ? { files: files.filter((f) => f.apart).length, bytes: sum(files.filter((f) => f.apart), 'bytes'), note: 'the remix source and the landing page\'s art (hero/, the cover): never loaded by the game' } : null,
|
|
600
606
|
modules,
|
|
601
607
|
};
|
|
602
608
|
}
|
|
603
609
|
|
|
610
|
+
/** What `perf sizes` says about one JavaScript file's code (lib/perf-code.mjs), or null when it cannot be read. */
|
|
611
|
+
function codeOf(file) {
|
|
612
|
+
try {
|
|
613
|
+
const { minified, mangled, whitespacePct, commentPct, codeCharsPerLine, nameLength, shortNamesPct, stringPct, shaderPct, why } = readCode(readFileSync(file, 'utf8'));
|
|
614
|
+
return { minified, mangled, whitespacePct, commentPct, codeCharsPerLine, nameLength, shortNamesPct, stringPct, shaderPct, why };
|
|
615
|
+
} catch { return null; }
|
|
616
|
+
}
|
|
617
|
+
|
|
604
618
|
/** Size metrics, flat like a run's: `bytes.total`, `bytes.gzip`, `bytes.js`, `bytes.jsGzip` (the same every run). */
|
|
605
619
|
export function metricsOfSizes(s) {
|
|
606
620
|
if (!s?.ok) return {};
|
|
@@ -776,6 +776,80 @@
|
|
|
776
776
|
}
|
|
777
777
|
}
|
|
778
778
|
}
|
|
779
|
+
},
|
|
780
|
+
"0.19.1": {
|
|
781
|
+
"files": {
|
|
782
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
783
|
+
"README.md": "684b3cca8001673c",
|
|
784
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
785
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
786
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
787
|
+
"videos/README.md": "16babc3437d5545b",
|
|
788
|
+
"posts/README.md": "37841aae476bd540",
|
|
789
|
+
"site/README.md": "ca131cd0472464fb",
|
|
790
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
791
|
+
},
|
|
792
|
+
"sections": {
|
|
793
|
+
"AGENTS.md": {
|
|
794
|
+
"(intro)": "214aa46eb3a895d5",
|
|
795
|
+
"## Layout": "8dc97df0d77e655e",
|
|
796
|
+
"## Commands (all through the pinned CLI in node_modules)": "a06ab13fc385ab25",
|
|
797
|
+
"## The site": "ab922bb6bde23293",
|
|
798
|
+
"## Making games": "a8a893eeada33bf5",
|
|
799
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
800
|
+
"## Running live games (the back office)": "f0566f07cff04113",
|
|
801
|
+
"## Servers and AI seats": "0a332b07e22d3fce",
|
|
802
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
803
|
+
"## Rules": "969318425c99f5a5",
|
|
804
|
+
"## Beta": "9d02638207a26e73"
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
"named": {
|
|
808
|
+
"Homie Arcade": {
|
|
809
|
+
"sections": {
|
|
810
|
+
"AGENTS.md": {
|
|
811
|
+
"## Commands (all through the pinned CLI in node_modules)": "677ee39dc13485e0"
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
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
|
+
}
|
|
779
853
|
}
|
|
780
854
|
}
|
|
781
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';
|