@homie-rocks/studio 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +111 -1
  2. package/README.md +361 -22
  3. package/assets/animate.ts +16 -6
  4. package/assets/assets.ts +14 -3
  5. package/assets/render-page.ts +10 -2
  6. package/assets/safety.mjs +7 -1
  7. package/bin/homie-studio.mjs +142 -123
  8. package/lib/art-cli.mjs +61 -18
  9. package/lib/art-tools.mjs +53 -10
  10. package/lib/asset-check.mjs +249 -46
  11. package/lib/asset-import.mjs +11 -5
  12. package/lib/asset-manifest.mjs +128 -51
  13. package/lib/build.mjs +239 -72
  14. package/lib/characters.mjs +119 -20
  15. package/lib/check.mjs +110 -7
  16. package/lib/chrome.mjs +11 -0
  17. package/lib/clips.mjs +54 -0
  18. package/lib/cloudflare.mjs +127 -8
  19. package/lib/codex.mjs +6 -0
  20. package/lib/collision.mjs +245 -0
  21. package/lib/decisions.mjs +2 -2
  22. package/lib/demo.mjs +16 -4
  23. package/lib/deploy-state.mjs +166 -0
  24. package/lib/dev.mjs +430 -0
  25. package/lib/directory.mjs +116 -5
  26. package/lib/doctor.mjs +143 -16
  27. package/lib/feedback.mjs +3 -2
  28. package/lib/handoff.mjs +2 -6
  29. package/lib/library.mjs +1 -1
  30. package/lib/mcp-tools.mjs +36 -35
  31. package/lib/mcp.mjs +1 -1
  32. package/lib/net.mjs +81 -2
  33. package/lib/node-version.mjs +40 -0
  34. package/lib/office.mjs +6 -6
  35. package/lib/optimise.mjs +39 -4
  36. package/lib/parts-build.mjs +169 -0
  37. package/lib/parts-cli.mjs +104 -0
  38. package/lib/parts-store.mjs +342 -0
  39. package/lib/parts-tools.mjs +92 -0
  40. package/lib/parts.mjs +693 -0
  41. package/lib/perf.mjs +131 -31
  42. package/lib/port-check.mjs +40 -6
  43. package/lib/preview.mjs +77 -0
  44. package/lib/routes.mjs +132 -0
  45. package/lib/scaffold.mjs +88 -22
  46. package/lib/shoot.mjs +307 -0
  47. package/lib/site.mjs +1 -2
  48. package/lib/stage.mjs +118 -0
  49. package/lib/studio.mjs +0 -66
  50. package/lib/style-board.mjs +49 -6
  51. package/lib/template-history.json +101 -0
  52. package/lib/trailer.mjs +47 -0
  53. package/lib/typecheck.mjs +107 -0
  54. package/mcp/ui/lineup.js +3 -3
  55. package/mcp/ui/rights.js +1 -3
  56. package/netplay/NETPLAY.md +424 -16
  57. package/netplay/netplay.ts +756 -40
  58. package/package.json +10 -1
  59. package/parts/PARTS.md +163 -0
  60. package/port/index.ts +8 -2
  61. package/port/personas.ts +275 -0
  62. package/port/powerups.ts +357 -0
  63. package/port/probe.ts +38 -2
  64. package/port/room.ts +103 -19
  65. package/site/SITE.md +102 -20
  66. package/starters/ember-vale/index.html +1 -1
  67. package/starters/ember-vale/src/main.ts +3 -1
  68. package/starters/gem-rush/index.html +1 -1
  69. package/starters/gem-rush/src/main.ts +13 -6
  70. package/starters/gem-rush-3d/assets/RIGHTS.md +0 -24
  71. package/starters/gem-rush-3d/assets/manifest.json +0 -24
  72. package/starters/gem-rush-3d/index.html +1 -1
  73. package/starters/gem-rush-3d/src/main.ts +15 -5
  74. package/starters/hero-rush-3d/assets/RIGHTS.md +0 -19
  75. package/starters/hero-rush-3d/assets/manifest.json +0 -19
  76. package/starters/hero-rush-3d/index.html +1 -1
  77. package/starters/hero-rush-3d/src/main.ts +15 -5
  78. package/worker/arrival.mjs +4 -1
  79. package/worker/discover.mjs +11 -25
  80. package/worker/index.mjs +142 -62
  81. package/worker/license.mjs +45 -41
  82. package/worker/office-page.mjs +0 -4
  83. package/worker/office-schema.mjs +6 -0
  84. package/worker/office.mjs +19 -23
  85. package/worker/pages.mjs +141 -23
  86. package/worker/parts.mjs +234 -0
  87. package/worker/room.mjs +157 -14
  88. package/worker/schema.mjs +6 -8
  89. package/worker/seats.mjs +71 -0
  90. package/worker/site.mjs +51 -53
  91. package/worker/version.mjs +1 -1
  92. package/lib/remix-assets.mjs +0 -84
@@ -0,0 +1,169 @@
1
+ /**
2
+ * GAME PARTS IN THE BUILD (parts/PARTS.md). Three small things lib/build.mjs and the deploy plan call, so the build
3
+ * itself stays one line longer in each place:
4
+ *
5
+ * partsPlugin(root, game) esbuild resolves `@parts/<host>/<id>` (a part brought in) and `@parts/<id>` (one of
6
+ * the studio's own) to the part's entry, so adding a part and one import line is a
7
+ * running piece. A game that imports a brought-in part is recorded against it and
8
+ * credited (the existing credits.json), whether or not the add named the game.
9
+ * buildParts(root, dist) site/dist/parts/: every packed version of every SHARED part, byte for byte as
10
+ * hashed, and index.json, what the Worker answers /.well-known/homie-parts.json from.
11
+ * What is shared is sharedPartsOf() and nothing else. A private part is never copied:
12
+ * what is not in dist cannot be served.
13
+ * partsPublishReport(root) before a game that uses parts goes online: each part's licence and attribution, and
14
+ * plainly where licences cannot be combined.
15
+ */
16
+ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import {
19
+ assetRecords, licenceIssues, licenseOfPart, listOwnParts, packPart, packedDir, packedVersions, publicPart, readOrigins, readPart, safePath, shareProblems,
20
+ sharedPartsOf, syncPartCredits, vendorDir, verifyFiles, writeOrigins,
21
+ } from './parts.mjs';
22
+ import { gameFacts, partItems } from './parts-store.mjs';
23
+
24
+ /* ------------------------------------------------------------------ importing a part */
25
+
26
+ /**
27
+ * Where `@parts/…` points: { path } or { error }. `@parts/<host>/<id>[/<file>]` is a part the lock names;
28
+ * `@parts/<id>[/<file>]` is one of the studio's own. With no file it is the part's `entry`.
29
+ */
30
+ export function resolvePartImport(root, spec, lock = readOrigins(root)) {
31
+ const segs = String(spec).replace(/^@parts\//, '').split('/').filter(Boolean);
32
+ let dir = null; let rest = []; let ref = null; let vendored = false;
33
+ if (segs.length >= 2 && lock.parts[`${segs[0]}/${segs[1]}`]) { ref = `${segs[0]}/${segs[1]}`; dir = vendorDir(root, ref); rest = segs.slice(2); vendored = true; }
34
+ else if (segs.length >= 1 && existsSync(join(root, 'parts', segs[0], 'part.json'))) { ref = segs[0]; dir = join(root, 'parts', segs[0]); rest = segs.slice(1); }
35
+ if (!dir) return { error: `'${spec}' names a part this studio does not have. Parts here: ${[...Object.keys(lock.parts), ...listOwnParts(root).map((o) => o.id)].join(', ') || 'none'}. Bring one in with part_add ("<studio site>/<part id>").` };
36
+ if (!existsSync(dir)) return { error: `'${spec}': parts/origins.json names ${ref} but its files are gone. Add it again (${ref}@${lock.parts[ref].version}).` };
37
+ if (rest.length) {
38
+ const file = rest.join('/');
39
+ if (!safePath(file) || !existsSync(join(dir, file))) return { error: `'${spec}': the ${ref} part has no file ${file}` };
40
+ return { path: join(dir, file), ref, vendored };
41
+ }
42
+ let part;
43
+ try { part = readPart(dir); } catch (error) { return { error: `'${spec}': ${error.message}` }; }
44
+ if (!part.entry) return { error: `'${spec}': the ${ref} part has no code to import (it is ${part.kind}: files only). Name a file: '@parts/${ref}/<path>'.` };
45
+ if (!existsSync(join(dir, part.entry))) return { error: `'${spec}': the ${ref} part's entry ${part.entry} is missing` };
46
+ return { path: join(dir, part.entry), ref, vendored };
47
+ }
48
+
49
+ /** The esbuild plugin (lib/build.mjs passes it to every game's bundle). `game`: the game being bundled, to credit. */
50
+ export function partsPlugin(root, game = null) {
51
+ return {
52
+ name: 'homie-parts',
53
+ setup(build) {
54
+ const used = new Set();
55
+ let lock = null;
56
+ build.onStart(() => { used.clear(); lock = null; });
57
+ build.onResolve({ filter: /^@parts\// }, (args) => {
58
+ try { lock ??= readOrigins(root); } catch (error) { return { errors: [{ text: error.message }] }; }
59
+ const r = resolvePartImport(root, args.path, lock);
60
+ if (r.error) return { errors: [{ text: r.error }] };
61
+ if (r.vendored) used.add(r.ref);
62
+ return { path: r.path };
63
+ });
64
+ // A brought-in part a game imports is that game's to credit, even when it was added without naming the game.
65
+ build.onEnd(() => {
66
+ if (!game || !used.size || !existsSync(join(root, 'games', game))) return;
67
+ try {
68
+ const now = readOrigins(root);
69
+ let changed = false;
70
+ for (const ref of used) { const e = now.parts[ref]; if (e && !(e.games ?? []).includes(game)) { e.games = [...(e.games ?? []), game].sort(); changed = true; } }
71
+ if (changed) writeOrigins(root, now);
72
+ syncPartCredits(root, game, now);
73
+ } catch { /* a credit line is never worth a failed bundle; the publish report says what is missing */ }
74
+ });
75
+ },
76
+ };
77
+ }
78
+
79
+ /* ------------------------------------------------------------------ the site's parts */
80
+
81
+ /**
82
+ * site/dist/parts/: the shared parts and their index. Returns { shared: [{ id, versions }], kept: <private count> }.
83
+ * A part marked shared that cannot be (someone set `share` by hand past the checks) STOPS the build with the
84
+ * reasons: publishing a file of unknown rights quietly is the one thing this must never do.
85
+ */
86
+ export function buildParts(root, dist, { studio = {}, log = () => {} } = {}) {
87
+ const out = join(dist, 'parts');
88
+ rmSync(out, { recursive: true, force: true });
89
+ const own = listOwnParts(root);
90
+ const records = assetRecords(root);
91
+ for (const o of own) {
92
+ if (o.part?.share !== true) continue;
93
+ const problems = shareProblems(o.part, { dir: o.dir, records }).filter((p) => !(p.field === 'files' && /^the files differ/.test(p.problem)));
94
+ if (problems.length) throw new Error(`parts/${o.id} is marked shared but cannot be: ${problems.map((p) => `${p.field}: ${p.problem}`).slice(0, 4).join('; ')}. Fix it, or stop sharing it (part_share with share: false); the site was not built with it.`);
95
+ // Edited since it was packed: the packed version is what is public; the edit needs a version of its own.
96
+ if (!verifyFiles(o.dir, o.part).ok || !packedVersions(root, o.id).includes(o.part.version)) {
97
+ const packed = verifyFiles(o.dir, o.part).ok ? packPart(root, o.id) : { ok: false };
98
+ if (!packed.ok) log(`warning: parts/${o.id} changed since version ${packedVersions(root, o.id).at(-1) ?? '(none)'} was shared; the site shares that version. To share the change, raise "version" in its part.json and share it again.`);
99
+ }
100
+ }
101
+ // The one answer to "what does this studio share".
102
+ const sharing = sharedPartsOf(root);
103
+ const names = new Map();
104
+ const index = [];
105
+ for (const { id, versions, part } of sharing) {
106
+ for (const v of versions) {
107
+ const src = packedDir(root, id, v);
108
+ const packed = readPart(src);
109
+ if (!verifyFiles(src, packed).ok) throw new Error(`parts/_packed/${id}/${v} no longer matches its hashes: a shared version never changes. Restore it from git.`);
110
+ const dest = join(out, id, v);
111
+ for (const f of packed.files) { mkdirSync(dirname(join(dest, f.path)), { recursive: true }); cpSync(join(src, f.path), join(dest, f.path)); }
112
+ writeFileSync(join(dest, 'part.json'), `${JSON.stringify(publicPart({ ...packed, share: true }))}\n`);
113
+ }
114
+ // The game it came out of, by the name the studio gives it.
115
+ const g = part.from?.game;
116
+ if (g && !names.has(g)) names.set(g, gameFacts(root, g).name);
117
+ index.push({ ...part, ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions });
118
+ log(`shared part ${id} (${versions.join(', ')})`);
119
+ }
120
+ mkdirSync(out, { recursive: true });
121
+ writeFileSync(join(out, 'index.json'), `${JSON.stringify({ v: 1, studio: { name: studio.name ?? null }, parts: index.sort((a, b) => a.id.localeCompare(b.id)) })}\n`);
122
+ return { shared: sharing.map(({ id, versions }) => ({ id, versions })), kept: own.length - sharing.length };
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ before going online */
126
+
127
+ /**
128
+ * What a deploy plan shows about parts, and changes nothing:
129
+ *
130
+ * games each game that uses brought-in parts: every part's licence, what it asks, its attribution and the game
131
+ * it came from, and plainly where licences cannot be combined (licenceIssues)
132
+ * sharing the studio's own parts this deploy makes live (sharedPartsOf)
133
+ * ok false when a game's licences cannot be combined: the caller decides whether that stops it
134
+ *
135
+ * `partsPlanLines(report)` is the same in plain lines: said before a deploy (its plan) and before a studio is listed
136
+ * (`publish`, lib/directory.mjs), in the same words.
137
+ */
138
+ export function partsPublishReport(root) {
139
+ let lock;
140
+ try { lock = readOrigins(root); } catch (error) { return { ok: false, games: [], sharing: [], why: error.message }; }
141
+ const byGame = new Map();
142
+ for (const [ref, e] of Object.entries(lock.parts)) for (const g of e.games ?? []) { if (!byGame.has(g)) byGame.set(g, []); byGame.get(g).push([ref, e]); }
143
+ const games = [];
144
+ for (const [game, list] of [...byGame].sort(([a], [b]) => a.localeCompare(b))) {
145
+ if (!existsSync(join(root, 'games', game, 'game.json'))) continue;
146
+ const issues = licenceIssues(partItems(root, { game }), { game: gameFacts(root, game) });
147
+ games.push({
148
+ game,
149
+ parts: list.sort(([a], [b]) => a.localeCompare(b)).map(([ref, e]) => { const lic = licenseOfPart(e.license); return { ref, name: e.name, version: e.version, license: lic?.id ?? null, asks: lic?.asks ?? 'nothing is known: it names no licence', attribution: e.attribution || null, from: e.from ?? null }; }),
150
+ issues, ok: !issues.some((i) => i.level === 'conflict'),
151
+ });
152
+ }
153
+ const sharing = sharedPartsOf(root).map(({ id, versions, part }) => ({ id, version: versions.at(-1), license: part.license ?? null, from: part.from ?? null }));
154
+ return { ok: games.every((g) => g.ok), games, sharing };
155
+ }
156
+
157
+ export function partsPlanLines(report, { at = 'deploy' } = {}) {
158
+ const L = [];
159
+ if (report.why) L.push(`Parts: ${report.why}`);
160
+ for (const g of report.games) {
161
+ L.push(`Parts in ${g.game} from other studios (their licences travel with the game):`);
162
+ for (const p of g.parts) L.push(` ${p.name} ${p.version} (${p.ref})${p.from?.name || p.from?.game ? `, from ${p.from.name ?? p.from.game}` : ''}: ${p.license ?? 'NO LICENCE'}; asks ${p.asks}${p.attribution ? `; credit: ${p.attribution}` : ''}`);
163
+ for (const i of g.issues) L.push(` ${i.level === 'conflict' ? 'CANNOT BE COMBINED' : i.level === 'warn' ? 'look' : 'note'} ${i.parts.join(' + ')}: ${i.problem}${i.fix ? ` (${i.fix})` : ''}`);
164
+ if (!g.issues.length) L.push(' Their licences can be combined, and each is credited in the game\'s credits.');
165
+ }
166
+ // The same lines before a deploy and before a listing; only where a shared part becomes visible differs.
167
+ if (report.sharing.length) L.push(`Parts this studio shares (${at === 'publish' ? 'the parts catalog shows them once the studio is listed' : 'live with this deploy'}): ${report.sharing.map((s) => `${s.id} ${s.version} (${s.license})`).join(', ')}`);
168
+ return L;
169
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `homie-studio parts …`: the plumbing behind the four chat tools (lib/parts-tools.mjs; parts/PARTS.md). A creator
3
+ * works in chat and never types these; an agent with a shell and no MCP connection may.
4
+ *
5
+ * parts find <words> [--kind <kind>] [--tag <tag>] [--license <SPDX>] [--builds <package or rig>]
6
+ * parts new <id> [--kind <kind>] [--name "<Name>"] [--from <game> <paths in the game…>]
7
+ * parts add <host>/<id>[@<version>] [--game <id>] [--overwrite] [--no-install] (again: the newer version)
8
+ * --no-install: bring the part in and install nothing. package.json and package-lock.json are not touched and
9
+ * npm is only asked what the studio has (`npm ls`). Each package the part builds on that is missing is named
10
+ * "NOT INSTALLED" with the one npm command that installs it. The part will not build until that is run.
11
+ * parts share <id> parts unshare <id> (live after the studio's next deploy)
12
+ * parts check [<id>] (what stands in the way of sharing; rewrites the hashes)
13
+ */
14
+ import { newPart } from './parts.mjs';
15
+ import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
16
+
17
+ export const PARTS_VERBS = ['find', 'new', 'add', 'share', 'unshare', 'check'];
18
+
19
+ /**
20
+ * What `parts add` takes, in the words the command prints when it is not given a part. `--no-install` is said as
21
+ * what it does: nothing is installed and package.json is not written (npm writes it, when npm installs). A test
22
+ * runs the flag through the real command and holds these words to what happened.
23
+ */
24
+ export const PARTS_ADD_USAGE = 'homie-studio parts add <studio site>/<part id>[@<version>] [--game <id>] [--overwrite] [--no-install]\n --no-install: install nothing and leave package.json as it is; a package the part builds on that this studio does not have is named NOT INSTALLED with the npm command that installs it (the part will not build until then).';
25
+
26
+ export async function partsCommand(root, sub, positional, flags, { log = () => {}, fetch, npm } = {}) {
27
+ const arg = positional[2];
28
+ const str = (k) => (typeof flags.get(k) === 'string' ? flags.get(k) : null);
29
+ switch (sub ?? 'find') {
30
+ case 'find': return findParts(root, positional.slice(2).join(' '), { kind: str('kind'), tag: str('tag'), license: str('license'), builds: str('builds'), fetch, hub: str('hub') });
31
+ case 'new': return newPart(root, arg, { kind: str('kind') ?? 'mechanic', name: str('name'), from: str('from'), paths: positional.slice(3) });
32
+ case 'check': return checkParts(root, arg ?? null, { write: true });
33
+ case 'share': return arg ? sharePart(root, arg, true) : { ok: false, command: 'parts share', why: 'name the part to share' };
34
+ case 'unshare': return arg ? sharePart(root, arg, false) : { ok: false, command: 'parts unshare', why: 'name the part to stop sharing' };
35
+ case 'add': return arg ? addPart(root, arg, { fetch, ...(npm ? { npm } : {}), install: !flags.has('no-install'), say: log, game: str('game'), overwrite: flags.has('overwrite') }) : { ok: false, command: 'parts add', why: `name the part: "<studio site>/<part id>"\n${PARTS_ADD_USAGE}` };
36
+ default: return { ok: false, command: 'parts', why: `"${sub}" is not a parts command: ${PARTS_VERBS.join(', ')}` };
37
+ }
38
+ }
39
+
40
+ /* ------------------------------------------------------------------ plain lines */
41
+
42
+ const costWords = (c) => { const b = []; if (Number.isFinite(c?.bytes)) b.push(`${Math.max(1, Math.round(c.bytes / 1024))} KB`); if (Number.isFinite(c?.triangles)) b.push(`${c.triangles} triangles`); if (Number.isFinite(c?.drawCalls)) b.push(`${c.drawCalls} draws`); if (Number.isFinite(c?.textureMB)) b.push(`${c.textureMB} MB textures`); return b.join(', '); };
43
+ const buildsWords = (r) => [...Object.keys(r?.packages && typeof r.packages === 'object' ? r.packages : {}), ...(Array.isArray(r?.parts) ? r.parts.map((x) => `the part ${x}`) : [])].join(', ');
44
+ const issueLines = (issues) => issues.map((i) => ` ${i.level === 'conflict' ? 'CANNOT BE COMBINED' : i.level === 'warn' ? 'look' : 'note'} ${i.parts.join(' + ')}: ${i.problem}${i.fix ? `\n ${i.fix}` : ''}`);
45
+ const packageLines = (rows) => rows.filter((p) => p.state !== 'ok').map((p) => (p.state === 'installed' ? ` npm installed ${p.name}@${p.range}, for ${p.by}` : p.state === 'missing' ? ` NOT INSTALLED ${p.name}@${p.range}: ${p.why} npm installs it with: ${p.command}` : ` ${p.state === 'pinned' ? 'PACKAGE DOES NOT FIT' : 'NPM FAILED'}: ${p.why}`));
46
+
47
+ function addLines(r) {
48
+ const L = [];
49
+ if (r.was && r.same) L.push(`${r.ref} ${r.version} is already here and unchanged.`);
50
+ else if (r.was) L.push(`${r.ref}: ${r.was} → ${r.version}. ${[r.changes.changed.length ? `changed: ${r.changes.changed.join(', ')}` : '', r.changes.added.length ? `new: ${r.changes.added.join(', ')}` : '', r.changes.removed.length ? `gone: ${r.changes.removed.join(', ')}` : ''].filter(Boolean).join('; ') || 'the same files'}. Your tuning.json was kept${r.replaced?.length ? `; REPLACED your edited ${r.replaced.join(', ')} as asked` : ''}.`);
51
+ else L.push(`${r.name} ${r.version} (${r.kind}) from ${r.host} is in ${r.dir}: ${r.files} files, every one checked against its SHA-256. It is this studio's to tune now.`);
52
+ if (r.from?.name || r.from?.game) L.push(` It came out of ${r.from.name ?? r.from.game}${r.from.studio ? ` by ${r.from.studio}` : ''}.`);
53
+ L.push(` Licence: ${r.license ?? 'NONE NAMED'} (asks ${r.asks ?? 'unknown'})${r.attribution ? `; credit: ${r.attribution}` : ''}`);
54
+ if (r.credits?.length) L.push(` Credited in ${r.credits.join(', ')}.`);
55
+ else if (!r.game) L.push(' It is credited in a game\'s credits when that game imports it (the build does it).');
56
+ if (r.import) L.push(` Use it: import … from '${r.import}'`);
57
+ L.push(...packageLines(r.packages ?? []));
58
+ for (const n of r.needs ?? []) L.push(` It needs another part, ${n.ref}: ${n.add}`);
59
+ L.push(...issueLines(r.licences ?? []));
60
+ return L;
61
+ }
62
+
63
+ export function partsLines(r) {
64
+ const L = [];
65
+ switch (r.command) {
66
+ case 'parts find': {
67
+ if (!r.hub.ok) L.push(r.hub.why, '');
68
+ if (!r.results.length) L.push(r.hub.ok ? `No shared part matches${r.query ? ` "${r.query}"` : ''} (${r.searched.hub} in the catalogue, ${r.searched.here} in this studio). Write it; if it turns out reusable, it can become a part.` : 'Nothing in this studio matches.');
69
+ for (const p of r.results) {
70
+ L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
71
+ if (p.summary) L.push(` ${p.summary}`);
72
+ if (p.from?.name || p.from?.game) L.push(` from ${p.from.name ?? p.from.game}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
73
+ const bits = [costWords(p.cost) && `costs ${costWords(p.cost)}`, buildsWords(p.requires) && `builds on ${buildsWords(p.requires)}`, p.rig && `rig ${p.rig}`, p.netplay && `in a room: ${p.netplay}`].filter(Boolean);
74
+ if (bits.length) L.push(` ${bits.join(' · ')}`);
75
+ L.push(` ${p.say}`);
76
+ }
77
+ break;
78
+ }
79
+ case 'parts new':
80
+ L.push(`parts/${r.id} is a new ${r.kind} part (private).${r.lifted ? ` Lifted out of games/${r.from}: ${r.lifted.moved.length} module(s) moved${r.lifted.copied.length ? `, ${r.lifted.copied.length} other file(s) copied` : ''}.` : ''}`, ...r.next.map((n) => ` ${n}`));
81
+ break;
82
+ case 'parts check':
83
+ for (const row of r.rows) {
84
+ L.push(`${row.id}${row.version ? ` ${row.version}` : ''}: ${row.ok ? 'reads fine' : 'NOT A PART YET'}${row.share ? ', shared' : row.shareable ? ', could be shared' : ', private'}`);
85
+ for (const p of row.problems) L.push(` ${p.level === 'refuse' ? 'FIX ' : 'look'} ${p.field}: ${p.problem}${p.fix ? `\n ${p.fix}` : ''}`);
86
+ if (!row.shareable) { L.push(' Before it can be shared:'); for (const p of row.sharing) L.push(` ${p.field}: ${p.problem}${p.fix ? `\n ${p.fix}` : ''}`); }
87
+ }
88
+ for (const b of r.brought) L.push(`${b.ref}${b.version ? ` ${b.version}` : ''}: ${b.ok ? (b.edited.length ? `edited here (${b.edited.join(', ')}); a newer version will not replace them without being told to` : 'as fetched') : b.problem ?? `missing ${b.missing.join(', ')}`}${b.fix ? `\n ${b.fix}` : ''}`);
89
+ if (!r.rows.length && !r.brought.length) L.push('No parts here yet.');
90
+ break;
91
+ case 'parts share':
92
+ L.push(`parts/${r.id} ${r.version} is shared under ${r.license} (asks ${r.asks}).`, r.effect);
93
+ break;
94
+ case 'parts unshare':
95
+ L.push(`parts/${r.id} is private.`, r.effect);
96
+ break;
97
+ case 'parts add':
98
+ L.push(...addLines(r));
99
+ break;
100
+ default:
101
+ L.push(JSON.stringify(r));
102
+ }
103
+ return L;
104
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * FINDING, ADDING AND SHARING PARTS (parts/PARTS.md): what the four chat tools do (lib/parts-tools.mjs).
3
+ *
4
+ * A part is copied into the studio and is then the studio's own to tune. This file is deliberately NOT a package
5
+ * manager, and rebuilds nothing that already works:
6
+ *
7
+ * packages are npm's. What a part builds on (`requires.packages`) is handed to npm: `npm ls` says whether the
8
+ * studio has it, `npm install` installs what is missing, and npm's own words are relayed as they are.
9
+ * package.json and package-lock.json are the only record. No resolver, no ranges, no lockfile here.
10
+ * parts one exact version is fetched (the latest shared one unless a version is named), its integrity
11
+ * checked (every file's SHA-256 and size, node:crypto) BEFORE a byte reaches the studio, copied to
12
+ * parts/_vendor/<host>/<id>/, and where it came from recorded in parts/origins.json.
13
+ * credits one line in the game's credits.json, through the same `parts[]` the asset credits use.
14
+ *
15
+ * Two things are injectable so tests touch neither the network nor the registry: `fetch` (every request goes
16
+ * through the one get() below) and `npm` (every npm call goes through one runner).
17
+ */
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import {
22
+ LIMITS, TUNING_FILE, TUNING_UPSTREAM, assetRecords, checkPart, compareVersions, isVersion, licenceIssues, licenseOfPart, listOwnParts, packPart, packedDir,
23
+ packedVersions, parseRef, partDir, readOrigins, readPart, requiredParts, sha256, shareProblems, syncPartCredits, vendorDir, verifyFiles, writeHashes,
24
+ writeOrigins, writePart,
25
+ } from './parts.mjs';
26
+ import { listGames, readStudio } from './studio.mjs';
27
+
28
+ export const HUB = 'https://homie.rocks';
29
+ const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
30
+ const line = (v, n = 200) => (typeof v === 'string' ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : '');
31
+
32
+ /* ------------------------------------------------------------------ the one fetch */
33
+
34
+ /** A studio's site from its host: https, except a site on this computer (the local preview), which has no certificate. */
35
+ export function baseOf(host) {
36
+ return `${/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) ? 'http' : 'https'}://${host}`;
37
+ }
38
+
39
+ /** Every request this file makes. Bytes back, capped; a plain sentence when it fails. */
40
+ async function get(url, { fetch: f = globalThis.fetch, max = LIMITS.fileBytes, what = 'the file' } = {}) {
41
+ let res;
42
+ try { res = await f(url, { redirect: 'follow', headers: { accept: '*/*' } }); } catch (error) { throw new Error(`${new URL(url).host} could not be reached (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)})`); }
43
+ if (!res.ok) throw new Error(`${url} answered ${res.status}: ${what} is not there`);
44
+ const bytes = Buffer.from(await res.arrayBuffer());
45
+ if (bytes.length > max) throw new Error(`${url} is ${bytes.length} bytes, more than ${what} may be (${max})`);
46
+ return bytes;
47
+ }
48
+ async function getJson(url, opts) {
49
+ const bytes = await get(url, { max: 4 * 1024 * 1024, ...opts });
50
+ try { return JSON.parse(bytes.toString('utf8')); } catch { throw new Error(`${url} is not JSON`); }
51
+ }
52
+
53
+ /**
54
+ * One exact version of a shared part, fetched and CHECKED: { part, files: Map(path → bytes), entry, base, version }.
55
+ * Nothing is returned unless every file's SHA-256 and size are what the part.json says, so nothing unverified can
56
+ * be written by a caller.
57
+ */
58
+ export async function fetchPart(refText, { fetch } = {}) {
59
+ const ref = parseRef(refText);
60
+ if (!ref) throw new Error(`"${String(refText).slice(0, 80)}" does not name a part: "<studio site>/<part id>", such as "owls.example/pickup-field" (and "@1.2.0" for one version)`);
61
+ const base = baseOf(ref.host);
62
+ const index = await getJson(`${base}/.well-known/homie-parts.json`, { fetch, what: 'the studio\'s list of shared parts' }).catch((error) => { throw new Error(`${ref.host} shares no parts that can be read: ${error.message}`); });
63
+ const entry = (Array.isArray(index?.parts) ? index.parts : []).find((p) => p?.id === ref.id);
64
+ if (!entry) {
65
+ const names = (Array.isArray(index?.parts) ? index.parts : []).map((p) => p?.id).filter(Boolean);
66
+ throw new Error(`${ref.host} does not share a part "${ref.id}"${names.length ? ` (it shares: ${names.slice(0, 12).join(', ')})` : ' (it shares none)'}. A part its studio keeps private is not there to add.`);
67
+ }
68
+ const versions = (Array.isArray(entry.versions) ? entry.versions : [entry.version]).filter(isVersion).sort(compareVersions);
69
+ const version = ref.version ?? (isVersion(entry.version) ? entry.version : versions.at(-1));
70
+ if (!version || !versions.includes(version)) throw new Error(`${ref.ref} has no version ${ref.version ?? '(none listed)'}; it has ${versions.join(', ') || 'none'}`);
71
+ const at = `${base}/parts/${ref.id}/${version}/`;
72
+ const part = await getJson(`${at}part.json`, { fetch, max: LIMITS.json, what: 'the part\'s part.json' });
73
+ const shape = checkPart(part);
74
+ if (!shape.ok) throw new Error(`${ref.ref} ${version} is not a part this toolkit can read: ${shape.problems.filter((p) => p.level === 'refuse').map((p) => `${p.field}: ${p.problem}`).slice(0, 3).join('; ')}`);
75
+ if (part.id !== ref.id || part.version !== version) throw new Error(`${at}part.json says it is ${part.id} ${part.version}, not ${ref.id} ${version}`);
76
+ if (part.share !== true) throw new Error(`${ref.ref} is not shared`);
77
+ if (!Array.isArray(part.files) || !part.files.length) throw new Error(`${ref.ref} ${version} lists no files`);
78
+ const files = new Map();
79
+ let total = 0;
80
+ for (const f of part.files) {
81
+ const bytes = await get(`${at}${f.path.split('/').map(encodeURIComponent).join('/')}`, { fetch, max: Math.min(LIMITS.fileBytes, f.bytes), what: f.path });
82
+ if (bytes.length !== f.bytes || sha256(bytes) !== f.sha256) throw new Error(`${ref.ref} ${version}: ${f.path} is not the file its part.json describes (its SHA-256 or size differs). Nothing was written; tell its studio.`);
83
+ total += bytes.length;
84
+ if (total > LIMITS.totalBytes) throw new Error(`${ref.ref} is over ${Math.round(LIMITS.totalBytes / 1048576)} MB`);
85
+ files.set(f.path, bytes);
86
+ }
87
+ return { ref, part, files, entry, base, version, versions };
88
+ }
89
+
90
+ /* ------------------------------------------------------------------ packages: npm's */
91
+
92
+ /** npm, in the studio folder: { status, stdout, stderr }. The one place npm is run (tests pass their own). */
93
+ export function runNpm(args, { cwd }) {
94
+ const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { cwd, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 16 * 1024 * 1024, shell: process.platform === 'win32' });
95
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? (r.error ? String(r.error.message) : '') };
96
+ }
97
+ const pinnedIn = (root, name) => {
98
+ try { const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return p.dependencies?.[name] ?? p.devDependencies?.[name] ?? null; } catch { return null; }
99
+ };
100
+ /** The packages a set of parts builds on: [{ name, range, by }] (one row per part that asks; npm judges each). */
101
+ export function wantedPackages(items) {
102
+ const out = [];
103
+ for (const x of items) for (const [name, range] of Object.entries(isObj(x.part?.requires?.packages) ? x.part.requires.packages : {})) out.push({ name, range, by: x.ref });
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * What npm says about each wanted package, and (with `install`) npm installing what is missing.
109
+ *
110
+ * ok `npm ls <name>@<range>` found it: nothing to do
111
+ * pinned the studio's package.json already names the package and npm says that version does not fit: explained,
112
+ * and NOTHING is changed (a studio's pin is its own; moving it is the person's call)
113
+ * installed npm installed it (pinned exactly, as studios pin); `said` is npm's own output
114
+ * failed npm could not; `said` is npm's own error, as it printed it
115
+ * missing not installed and not asked to (`install: false`, or --no-install): `command` installs it
116
+ *
117
+ * `say` gets one plain line BEFORE anything is installed: what, and which part it is for.
118
+ */
119
+ export function ensurePackages(root, wants, { npm = runNpm, install = false, say = () => {} } = {}) {
120
+ const rows = [];
121
+ for (const w of wants) {
122
+ const spec = `${w.name}@${w.range}`;
123
+ const ls = npm(['ls', spec, '--depth=0'], { cwd: root });
124
+ if (ls.status === 0) { rows.push({ ...w, state: 'ok' }); continue; }
125
+ const pinned = pinnedIn(root, w.name);
126
+ if (pinned) { rows.push({ ...w, state: 'pinned', has: pinned, why: `${w.by} is built on ${w.name} ${w.range}; this studio's package.json has ${w.name} at ${pinned}, which npm says does not fit. Nothing was changed: move the studio to a version in that range if the part is worth it, or use a part written against ${pinned}.` }); continue; }
127
+ const command = `npm install --save-exact ${spec}`;
128
+ if (!install) { rows.push({ ...w, state: 'missing', command, why: `${w.by} is built on ${w.name} ${w.range}, which this studio does not have.` }); continue; }
129
+ say(`Installing ${spec} from npm: ${w.by} is built on it.`);
130
+ const r = npm(['install', '--save-exact', spec], { cwd: root });
131
+ const said = `${r.stdout}${r.stderr}`.trim();
132
+ rows.push(r.status === 0 ? { ...w, state: 'installed', said } : { ...w, state: 'failed', said, command, why: `npm could not install ${spec} for ${w.by}. npm said:\n${said}` });
133
+ }
134
+ return rows;
135
+ }
136
+
137
+ /* ------------------------------------------------------------------ what a game is, to a licence */
138
+
139
+ /** What a part's licence can care about in the game using it: whether the studio sells, and who the game credits. */
140
+ export function gameFacts(root, id) {
141
+ let g = {}; let credits = {};
142
+ try { g = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { g = {}; }
143
+ try { credits = JSON.parse(readFileSync(join(root, 'games', id, 'credits.json'), 'utf8')); } catch { credits = {}; }
144
+ return { id, name: g.name ?? id, sells: existsSync(join(root, 'shop.json')), credits: Array.isArray(credits.parts) ? credits.parts : [] };
145
+ }
146
+
147
+ /** The studio's parts as licenceIssues() takes them: its own, and the ones it brought in (with what was changed locally). */
148
+ export function partItems(root, { game = null, refs = null } = {}) {
149
+ const lock = readOrigins(root);
150
+ const items = [];
151
+ for (const [ref, e] of Object.entries(lock.parts)) {
152
+ if (refs ? !refs.includes(ref) : game && !(e.games ?? []).includes(game)) continue;
153
+ const dir = vendorDir(root, ref);
154
+ let part = null;
155
+ try { part = readPart(dir); } catch { part = null; }
156
+ items.push({ ref, part, vendored: true, changed: part ? verifyFiles(dir, { files: e.files }, { skipTuning: true }).changed : [] });
157
+ }
158
+ for (const o of listOwnParts(root)) if (refs ? refs.includes(o.id) : !game) items.push({ ref: o.id, part: o.part, vendored: false });
159
+ return items;
160
+ }
161
+
162
+ /* ------------------------------------------------------------------ add (and the newer version) */
163
+
164
+ const diffFiles = (was, now) => {
165
+ const a = new Map((was ?? []).map((f) => [f.path, f.sha256])); const b = new Map((now ?? []).map((f) => [f.path, f.sha256]));
166
+ return { added: [...b.keys()].filter((p) => !a.has(p)), removed: [...a.keys()].filter((p) => !b.has(p)), changed: [...b.keys()].filter((p) => a.has(p) && a.get(p) !== b.get(p)) };
167
+ };
168
+
169
+ /**
170
+ * Adding a part (`<host>/<id>[@version]`, for a game), and the same call for a part already here: the newer
171
+ * version, with what changed shown.
172
+ *
173
+ * Order matters and is the safety: fetch and verify everything; refuse to replace a file the studio edited unless
174
+ * told to (`overwrite`); only then write. tuning.json is the studio's from the first add and is never replaced: a
175
+ * new version's defaults land beside it as tuning.upstream.json.
176
+ */
177
+ export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
178
+ const command = 'parts add';
179
+ const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
180
+ if (game && !existsSync(join(root, 'games', game, 'game.json'))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
181
+ let got;
182
+ try { got = await fetchPart(refText, { fetch }); } catch (error) { return fail(error.message); }
183
+ const { ref, part, files, version, base, entry } = got;
184
+ const lock = readOrigins(root);
185
+ const was = lock.parts[ref.ref] ?? null;
186
+ const dir = vendorDir(root, ref.ref);
187
+ const here = was && existsSync(dir);
188
+ // What the studio changed in its copy (tuning.json is meant to be changed and is never in this list).
189
+ const edited = here ? verifyFiles(dir, { files: was.files }, { skipTuning: true }) : { changed: [], missing: [] };
190
+ const changes = here ? diffFiles(was.files, part.files) : null;
191
+ const same = here && was.version === version && !changes.added.length && !changes.removed.length && !changes.changed.length;
192
+ if (here && !same && edited.changed.length && !overwrite) {
193
+ return fail(`${ref.ref} ${was.version} has files this studio edited: ${edited.changed.join(', ')}. Version ${version} would replace them, so nothing was changed. Keep your edits by moving them into tuning.json (which an update never touches) or into the game; or ask to replace them (overwrite).`, { ref: ref.ref, from: was.version, to: version, edited: edited.changed, changes });
194
+ }
195
+ if (!same) {
196
+ for (const p of changes?.removed ?? []) if (p !== TUNING_FILE) rmSync(join(dir, p), { force: true });
197
+ for (const [path, bytes] of files) {
198
+ const dest = join(dir, path);
199
+ mkdirSync(dirname(dest), { recursive: true });
200
+ if (path === TUNING_FILE && existsSync(dest)) { if (!readFileSync(dest).equals(bytes)) writeFileSync(join(dir, TUNING_UPSTREAM), bytes); else rmSync(join(dir, TUNING_UPSTREAM), { force: true }); continue; }
201
+ writeFileSync(dest, bytes);
202
+ }
203
+ writePart(dir, part);
204
+ }
205
+ const games = [...new Set([...(was?.games ?? []), ...(game ? [game] : [])])].sort();
206
+ const from = isObj(entry.from) ? { game: line(entry.from.game, 40) || null, name: line(entry.from.name, 80) || null, studio: line(entry.from.studio, 80) || null, page: /^https:\/\//.test(String(entry.from.page ?? '')) ? String(entry.from.page).slice(0, 300) : null } : isObj(part.from) ? { game: part.from.game ?? null, studio: line(part.from.studio, 80) || null } : null;
207
+ lock.parts[ref.ref] = {
208
+ version, url: `${base}/parts/${ref.id}/${version}/`, license: part.license ?? null, attribution: part.attribution ?? '', name: part.name, kind: part.kind,
209
+ ...(from ? { from } : {}), page: `${base}/parts/${ref.id}/`, studio: line(got.entry?.studio?.name, 80) || null,
210
+ files: part.files.map(({ path, sha256: h, bytes }) => ({ path, sha256: h, bytes })), games, addedAt: was?.addedAt ?? new Date().toISOString(), ...(was && !same ? { updatedAt: new Date().toISOString() } : {}),
211
+ };
212
+ writeOrigins(root, lock);
213
+ const credits = games.map((g) => syncPartCredits(root, g, lock)).filter(Boolean);
214
+ // Packages: npm judges, npm installs, npm's words are relayed.
215
+ const packages = ensurePackages(root, wantedPackages([{ ref: ref.ref, part }]), { npm, install, say });
216
+ // Other parts it names: named, with what adds each. Nothing is solved or fetched for them.
217
+ const needs = requiredParts(part).parts.filter((d) => !lock.parts[d.ref]).map((d) => ({ ...d, add: `homie-studio parts add ${d.ref}${game ? ` --game ${game}` : ''}` }));
218
+ const lic = licenseOfPart(part.license);
219
+ const licences = game ? licenceIssues(partItems(root, { game }), { game: gameFacts(root, game) }) : licenceIssues(partItems(root, { refs: [ref.ref] }));
220
+ const blocked = packages.filter((p) => p.state === 'pinned' || p.state === 'failed');
221
+ return {
222
+ ok: true, command, ref: ref.ref, id: ref.id, host: ref.host, version, name: part.name, kind: part.kind, dir: `parts/_vendor/${ref.host.replace(':', '_')}/${ref.id}`,
223
+ ...(here ? { was: was.version, same, changes, replaced: same ? [] : edited.changed } : {}),
224
+ license: lic?.id ?? null, asks: lic?.asks ?? null, attribution: part.attribution ?? '', ...(from ? { from } : {}),
225
+ files: part.files.length, bytes: part.files.reduce((n, f) => n + f.bytes, 0), verified: true,
226
+ entry: part.entry ?? null, import: part.entry ? `@parts/${ref.ref}` : null, game, credits,
227
+ packages, needs, licences,
228
+ ready: !blocked.length && !packages.some((p) => p.state === 'missing') && !needs.length && !licences.some((c) => c.level === 'conflict'),
229
+ };
230
+ }
231
+
232
+ /* ------------------------------------------------------------------ list, check, share */
233
+
234
+ /** `parts check [<id>] [--write]`: each of the studio's parts against the format, its hashes and the rights of every file; and each brought-in part against what was fetched. */
235
+ export function checkParts(root, id = null, { write = false } = {}) {
236
+ const records = assetRecords(root);
237
+ const own = listOwnParts(root).filter((o) => !id || o.id === id);
238
+ if (id && !own.length && !existsSync(partDir(root, id))) return { ok: false, command: 'parts check', why: `there is no part "${id}" in parts/ (${listOwnParts(root).map((o) => o.id).join(', ') || 'none yet'})` };
239
+ const rows = [];
240
+ for (const o of own) {
241
+ if (!o.part) { rows.push({ id: o.id, ok: false, problems: [{ level: 'refuse', field: 'part.json', problem: o.error, fix: null }], shareable: false, sharing: [] }); continue; }
242
+ const part = write ? writeHashes(o.dir) : o.part;
243
+ const shape = checkPart(part, { id: o.id });
244
+ const v = verifyFiles(o.dir, part);
245
+ const problems = [...shape.problems];
246
+ if (!v.ok) problems.push({ level: 'refuse', field: 'files', problem: `the files differ from the hashes (${[...v.changed.map((p) => `${p} changed`), ...v.missing.map((p) => `${p} is missing`), ...v.extra.map((p) => `${p} is not listed`)].slice(0, 6).join('; ')})`, fix: 'checking the part rewrites them (part_share with nothing to change)' });
247
+ const sharing = shareProblems(part, { dir: o.dir, records });
248
+ rows.push({ id: o.id, version: part.version, share: part.share === true, ok: !problems.some((p) => p.level === 'refuse'), problems, shareable: !sharing.length, sharing, files: (part.files ?? []).length });
249
+ }
250
+ const lock = readOrigins(root);
251
+ const brought = id ? [] : Object.entries(lock.parts).map(([ref, e]) => {
252
+ const dir = vendorDir(root, ref);
253
+ if (!existsSync(dir)) return { ref, ok: false, problem: 'its files are gone from parts/_vendor', fix: `add it again: homie-studio parts add ${ref}@${e.version}` };
254
+ const v = verifyFiles(dir, { files: e.files }, { skipTuning: true });
255
+ return { ref, version: e.version, ok: !v.missing.length, edited: v.changed, missing: v.missing, ...(v.changed.length ? { note: 'edited here: a newer version will not replace them without being told to' } : {}) };
256
+ });
257
+ return { ok: rows.every((r) => r.ok) && brought.every((b) => b.ok), command: 'parts check', rows, brought, wrote: write };
258
+ }
259
+
260
+ /**
261
+ * Sharing a part, or stopping. Sharing checks everything, packs this version and sets `share`; nothing is uploaded:
262
+ * it is LIVE AFTER THE STUDIO'S NEXT DEPLOY, and every answer says so.
263
+ */
264
+ export function sharePart(root, id, on = true) {
265
+ const dir = partDir(root, id);
266
+ const command = on ? 'parts share' : 'parts unshare';
267
+ if (!existsSync(join(dir, 'part.json'))) return { ok: false, command, why: `there is no part "${id}" in parts/ (${listOwnParts(root).map((o) => o.id).join(', ') || 'none yet'}). A part brought in from another studio is theirs to share.` };
268
+ if (!on) {
269
+ const part = readPart(dir);
270
+ const was = part.share === true;
271
+ part.share = false;
272
+ writePart(dir, part);
273
+ for (const v of packedVersions(root, id)) { const d = packedDir(root, id, v); const p = readPart(d); p.share = false; writePart(d, p); }
274
+ return { ok: true, command, id, share: false, was, effect: was ? 'It stays public until the studio\'s next deploy; from that deploy on it is gone from the site and the hub. Copies other studios already made stay theirs under the licence it was shared with.' : 'It was already private.' };
275
+ }
276
+ const part = writeHashes(dir);
277
+ const problems = shareProblems(part, { dir, records: assetRecords(root) });
278
+ if (problems.length) return { ok: false, command, id, why: `parts/${id} cannot be shared yet:\n${problems.map((p) => ` ${p.field}: ${p.problem}${p.fix ? `\n ${p.fix}` : ''}`).join('\n')}`, problems };
279
+ part.share = true;
280
+ writePart(dir, part);
281
+ const packed = packPart(root, id);
282
+ if (!packed.ok) { part.share = false; writePart(dir, part); return { ...packed, command }; }
283
+ const lic = licenseOfPart(part.license);
284
+ return { ok: true, command, id, share: true, version: part.version, license: lic.id, asks: lic.asks, files: part.files.length, bytes: part.cost?.bytes ?? 0, ...(part.from?.game ? { from: part.from } : {}), effect: 'It is live after the studio\'s next deploy, not before: nothing was uploaded now. Sharing a part does not list the studio in the directory.' };
285
+ }
286
+
287
+ /* ------------------------------------------------------------------ find */
288
+
289
+ const row = (p, extra) => ({
290
+ id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
291
+ from: isObj(p.from) ? { game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
292
+ cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
293
+ });
294
+ function matches(p, { words, kind, tag, license, builds }) {
295
+ if (kind && p.kind !== kind) return false;
296
+ if (tag && !(p.tags ?? []).includes(tag)) return false;
297
+ if (license && String(p.license ?? '').toLowerCase() !== String(license).toLowerCase()) return false;
298
+ // What a part builds on: a package it names, or the skeleton it is made for.
299
+ if (builds && !(Object.keys(p.requires?.packages && typeof p.requires.packages === 'object' ? p.requires.packages : {}).includes(builds) || p.rig === builds)) return false;
300
+ const hay = [p.id, p.name, p.kind, p.summary, ...(p.tags ?? []), p.from?.name, p.from?.game, p.from?.studio].filter(Boolean).join(' ').toLowerCase();
301
+ return words.every((w) => hay.includes(w));
302
+ }
303
+
304
+ /**
305
+ * Finding parts (words, and kind, tag, licence, what a part builds on): the hub's catalogue of parts studios shared, and this
306
+ * studio's own and brought-in parts. Packages are not parts and are not listed.
307
+ *
308
+ * A HUB THAT CANNOT BE REACHED IS SAID, NEVER HIDDEN: `hub.ok` is false with why, and the studio's own results still
309
+ * come back, so an empty list is never mistaken for "nothing exists".
310
+ */
311
+ export async function findParts(root, query = '', { kind = null, tag = null, license = null, builds = null, fetch, hub = null } = {}) {
312
+ const words = String(query ?? '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 8);
313
+ const filter = { words, kind, tag, license, builds };
314
+ const here = [];
315
+ if (root) {
316
+ for (const o of listOwnParts(root)) if (o.part) here.push(row(o.part, { where: 'own', share: o.part.share === true, import: o.part.entry ? `@parts/${o.id}` : null, say: `It is this studio's own part: import it from '@parts/${o.id}'.` }));
317
+ for (const [ref, e] of Object.entries(readOrigins(root).parts)) {
318
+ let part = null;
319
+ try { part = readPart(vendorDir(root, ref)); } catch { part = null; }
320
+ if (part) here.push(row({ ...part, from: e.from ?? part.from }, { where: 'here', ref, add: ref, import: part.entry ? `@parts/${ref}` : null, games: e.games ?? [], say: `Already in this studio (${e.version}): import it from '@parts/${ref}'.` }));
321
+ }
322
+ }
323
+ let base = hub;
324
+ if (!base && root) { try { const d = readStudio(root).homie?.directory; base = d === false || d === null ? null : d; } catch { base = null; } }
325
+ base = /^https?:\/\/[a-z0-9.:-]+$/i.test(String(base ?? '')) ? base : HUB;
326
+ const url = `${base}/parts/index.json`;
327
+ let hubState; let found = [];
328
+ try {
329
+ const index = await getJson(url, { fetch, what: 'the catalogue of shared parts' });
330
+ if (!Array.isArray(index?.parts)) throw new Error(`${url} is not a parts catalogue`);
331
+ const have = new Set(here.filter((h) => h.where === 'here').map((h) => h.ref));
332
+ found = index.parts.filter((p) => isObj(p) && typeof p.id === 'string' && (typeof p.host === 'string' || typeof p.add === 'string')).map((p) => {
333
+ const ref = parseRef(typeof p.add === 'string' ? p.add : `${p.host}/${p.id}`)?.ref;
334
+ return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `Add it: part_add { "part": "${ref}" }.` }) : null;
335
+ }).filter(Boolean);
336
+ hubState = { ok: true, url, parts: found.length };
337
+ } catch (error) {
338
+ hubState = { ok: false, url, why: `The catalogue of shared parts could not be read just now (${error.message}). This is NOT "no parts exist": only this studio's own parts were searched. Try again, or add a part straight from a studio you know (part_add with "<studio site>/<part id>").` };
339
+ }
340
+ const results = [...here.filter((p) => matches(p, filter)), ...found.filter((p) => !p.here && matches(p, filter))];
341
+ return { ok: true, command: 'parts find', query: words.join(' '), filter: { kind, tag, license, builds }, hub: hubState, results, searched: { hub: found.length, here: here.length } };
342
+ }