makersclaw 0.1.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 (57) hide show
  1. package/README.md +68 -0
  2. package/bin/makersclaw.mjs +10 -0
  3. package/package.json +34 -0
  4. package/registry/blocks/board/README.md +130 -0
  5. package/registry/blocks/board/block.json +10 -0
  6. package/registry/blocks/board/board.jsx +435 -0
  7. package/registry/blocks/board/demo.jsx +50 -0
  8. package/registry/blocks/board/index.js +4 -0
  9. package/registry/blocks/job-panel/README.md +68 -0
  10. package/registry/blocks/job-panel/block.json +10 -0
  11. package/registry/blocks/job-panel/demo.jsx +75 -0
  12. package/registry/blocks/job-panel/index.js +3 -0
  13. package/registry/blocks/job-panel/job-panel.jsx +110 -0
  14. package/src/cli.mjs +301 -0
  15. package/src/fsutil.mjs +76 -0
  16. package/src/install.mjs +457 -0
  17. package/src/registry.mjs +191 -0
  18. package/templates/next/files/README.md +626 -0
  19. package/templates/next/files/app/auto-refresh.jsx +26 -0
  20. package/templates/next/files/app/error-relay.jsx +53 -0
  21. package/templates/next/files/app/error.jsx +44 -0
  22. package/templates/next/files/app/global-error.jsx +36 -0
  23. package/templates/next/files/app/globals.css +178 -0
  24. package/templates/next/files/app/layout.jsx +51 -0
  25. package/templates/next/files/app/not-found.jsx +23 -0
  26. package/templates/next/files/app/page.jsx +22 -0
  27. package/templates/next/files/app/s/[key]/page.jsx +47 -0
  28. package/templates/next/files/app/shell.jsx +64 -0
  29. package/templates/next/files/check/run.mjs +683 -0
  30. package/templates/next/files/components/parts/fixture.jsx +60 -0
  31. package/templates/next/files/components/parts/index.js +5 -0
  32. package/templates/next/files/components/parts/record-switcher.jsx +49 -0
  33. package/templates/next/files/components/ui/button.jsx +81 -0
  34. package/templates/next/files/components/ui/cx.js +4 -0
  35. package/templates/next/files/components/ui/depiction.jsx +66 -0
  36. package/templates/next/files/components/ui/field.jsx +126 -0
  37. package/templates/next/files/components/ui/icon.jsx +87 -0
  38. package/templates/next/files/components/ui/index.js +12 -0
  39. package/templates/next/files/components/ui/menu.jsx +123 -0
  40. package/templates/next/files/components/ui/notice.jsx +86 -0
  41. package/templates/next/files/components/ui/sheet.jsx +89 -0
  42. package/templates/next/files/components/ui/tabs.jsx +81 -0
  43. package/templates/next/files/lib/actions.js +187 -0
  44. package/templates/next/files/lib/claw.js +219 -0
  45. package/templates/next/files/lib/data.js +171 -0
  46. package/templates/next/files/lib/db.js +72 -0
  47. package/templates/next/files/lib/hooks.js +218 -0
  48. package/templates/next/files/lib/platform.js +59 -0
  49. package/templates/next/files/lib/sample.js +12 -0
  50. package/templates/next/files/middleware.js +42 -0
  51. package/templates/next/files/next.config.mjs +41 -0
  52. package/templates/next/files/package.json +19 -0
  53. package/templates/next/files/postcss.config.mjs +3 -0
  54. package/templates/next/files/schema.sql +22 -0
  55. package/templates/next/files/surfaces/README.md +32 -0
  56. package/templates/next/files/surfaces.js +53 -0
  57. package/templates/next/template.json +118 -0
package/src/fsutil.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // File helpers. Node's own fs and crypto only — the CLI has no
2
+ // dependencies, so `npx makersclaw` costs one small download.
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, relative, sep } from "node:path";
7
+
8
+ /** sha256 of the bytes, hex — the same hash `npm run check` pins. */
9
+ export function sha256(bytes) {
10
+ return createHash("sha256").update(bytes).digest("hex");
11
+ }
12
+
13
+ /** The file's sha256, or null when there is no file. */
14
+ export function hashFile(path) {
15
+ return existsSync(path) ? sha256(readFileSync(path)) : null;
16
+ }
17
+
18
+ const WALK_SKIP = new Set(["node_modules", ".next", ".next-check", ".git", ".DS_Store"]);
19
+
20
+ /** Every file under `root`, relative to it, "/"-separated, sorted. */
21
+ export function walk(root, sub = "") {
22
+ const out = [];
23
+ for (const name of readdirSync(join(root, sub))) {
24
+ if (WALK_SKIP.has(name)) continue;
25
+ const rel = sub ? `${sub}/${name}` : name;
26
+ if (statSync(join(root, rel)).isDirectory()) out.push(...walk(root, rel));
27
+ else out.push(rel);
28
+ }
29
+ return out.sort();
30
+ }
31
+
32
+ export function readJson(path, fallback = null) {
33
+ if (!existsSync(path)) return fallback;
34
+ return JSON.parse(readFileSync(path, "utf8"));
35
+ }
36
+
37
+ export function writeJson(path, value) {
38
+ writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
39
+ }
40
+
41
+ /** Write bytes, making the parent folders. */
42
+ export function writeFile(path, bytes) {
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, bytes);
45
+ }
46
+
47
+ /** Remove a file, then every parent folder it leaves empty, stopping at
48
+ * `stop` (never removed). */
49
+ export function removeFile(path, stop) {
50
+ rmSync(path, { force: true });
51
+ let dir = dirname(path);
52
+ while (dir.startsWith(stop + sep) && dir !== stop) {
53
+ try {
54
+ if (readdirSync(dir).length > 0) break;
55
+ rmdirSync(dir);
56
+ } catch {
57
+ break;
58
+ }
59
+ dir = dirname(dir);
60
+ }
61
+ }
62
+
63
+ /** A glob with `*` (any run of characters but "/") as an anchored RegExp.
64
+ * Everything else is literal — `app/s/[key]/page.jsx` means those brackets. */
65
+ export function globToRegExp(glob) {
66
+ const body = glob
67
+ .split("*")
68
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
69
+ .join("[^/]*");
70
+ return new RegExp(`^${body}$`);
71
+ }
72
+
73
+ /** "/"-separated path from `root` to `path`. */
74
+ export function rel(root, path) {
75
+ return relative(root, path).split(sep).join("/");
76
+ }
@@ -0,0 +1,457 @@
1
+ // What the commands do to an app folder. Every function returns a plain
2
+ // result and prints nothing: src/cli.mjs says it, as text or as --json.
3
+ //
4
+ // Two files record what an app holds:
5
+ // makersclaw.json the template and version it was laid from, and each
6
+ // block with the version it came from
7
+ // check/pins.json sha256 of every fixed file, the template's and the
8
+ // blocks' — `npm run check` refuses one that differs
9
+ //
10
+ // Everything this CLI writes is FIXED (ours: pinned, replaced by update),
11
+ // DOCS (ours, not pinned), a STARTER (laid when missing, the app's from
12
+ // then on) or MERGED (package.json). The app's own files — the shell, the
13
+ // surfaces, components/*.jsx — are never written.
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { hashFile, readJson, removeFile, sha256, walk, writeFile, writeJson } from "./fsutil.mjs";
18
+ import { BLOCK_DIR, loadBlocks, loadItems, loadTemplate, packageInfo, readmeOf, suggest, templateNames } from "./registry.mjs";
19
+
20
+ export const MANIFEST = "makersclaw.json";
21
+ export const PINS = "check/pins.json";
22
+
23
+ /** A refusal the user can act on: what is wrong, and what to run instead. */
24
+ export class CliError extends Error {
25
+ constructor(message, next = null) {
26
+ super(message);
27
+ this.next = next;
28
+ }
29
+ }
30
+
31
+ const version = () => packageInfo().version;
32
+
33
+ function parseVersion(v) {
34
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? ""));
35
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
36
+ }
37
+
38
+ /** Same contract: the same major and — while the major is 0 — the same
39
+ * minor. A patch never changes what a screen can rely on. */
40
+ export function compatible(from, to) {
41
+ const a = parseVersion(from);
42
+ const b = parseVersion(to);
43
+ if (!a || !b) return false;
44
+ return a[0] === b[0] && (a[0] > 0 || a[1] === b[1]);
45
+ }
46
+
47
+ function newer(a, b) {
48
+ const x = parseVersion(a);
49
+ const y = parseVersion(b);
50
+ if (!x || !y) return false;
51
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
52
+ return false;
53
+ }
54
+
55
+ function sortKeys(obj) {
56
+ return Object.fromEntries(Object.entries(obj ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
57
+ }
58
+
59
+ function readState(dir) {
60
+ return { manifest: readJson(join(dir, MANIFEST)), pins: readJson(join(dir, PINS)) ?? {} };
61
+ }
62
+
63
+ function requireManifest(dir) {
64
+ const state = readState(dir);
65
+ if (!state.manifest) {
66
+ const hasApp = existsSync(join(dir, "package.json"));
67
+ throw new CliError(
68
+ `${dir} has no ${MANIFEST}` + (hasApp ? " — this app was laid before makersclaw, or by hand" : " — there is no app here"),
69
+ hasApp
70
+ ? "makersclaw update --adopt takes it on: this version's fixed files laid again and recorded, every screen of yours kept"
71
+ : "makersclaw init lays the template here",
72
+ );
73
+ }
74
+ return state;
75
+ }
76
+
77
+ function loadTemplateOrFail(name) {
78
+ const tpl = loadTemplate(name);
79
+ if (!tpl) throw new CliError(`this makersclaw has no template "${name}"`, `templates: ${templateNames().join(", ")}`);
80
+ return tpl;
81
+ }
82
+
83
+ /** package.json: the template's scripts and dependencies win for the keys
84
+ * it names; every other key is the app's. */
85
+ function mergePackage(app, tpl) {
86
+ const out = { ...app };
87
+ const changes = [];
88
+ for (const key of ["scripts", "dependencies", "devDependencies"]) {
89
+ if (!tpl[key]) continue;
90
+ out[key] = { ...(app[key] ?? {}) };
91
+ for (const [name, value] of Object.entries(tpl[key])) {
92
+ if (out[key][name] === value) continue;
93
+ changes.push({ key, name, from: out[key][name] ?? null, to: value });
94
+ out[key][name] = value;
95
+ }
96
+ }
97
+ return { out, changes };
98
+ }
99
+
100
+ /** Paths a template may remove when a newer version stops shipping them:
101
+ * its fixed files and the kit, parts and block folders. Anything else a
102
+ * pins.json names is left alone — it is not ours to delete. */
103
+ function ownedPath(path, tpl) {
104
+ if (/^components\/(?:ui|parts|blocks)\//.test(path) || /^lib\/[^/]+\.js$/.test(path)) return true;
105
+ return tpl.files.some((f) => f.class === "fixed" && f.path === path);
106
+ }
107
+
108
+ /**
109
+ * Lay a template — and the blocks the app already has — over `dir`: the
110
+ * one routine behind init, init --force and update. Fixed files and docs
111
+ * are replaced; starters are laid only when missing; package.json is
112
+ * merged; blocks come again from this version; what an older version
113
+ * pinned and this one no longer ships is removed.
114
+ */
115
+ function lay(dir, tpl, before) {
116
+ const v = version();
117
+ const written = [];
118
+ const kept = [];
119
+ const removed = [];
120
+ const notes = [];
121
+ let pkg = [];
122
+ const pins = {};
123
+ const fresh = !existsSync(join(dir, "package.json"));
124
+
125
+ for (const f of tpl.files) {
126
+ const to = join(dir, f.path);
127
+ if (f.class === "starter" && existsSync(to)) {
128
+ kept.push(f.path);
129
+ continue;
130
+ }
131
+ if (f.class === "merged" && existsSync(to)) {
132
+ const { out, changes } = mergePackage(readJson(to), readJson(f.from));
133
+ if (changes.length) {
134
+ writeJson(to, out);
135
+ written.push(f.path);
136
+ }
137
+ pkg = changes;
138
+ continue;
139
+ }
140
+ const bytes = readFileSync(f.from);
141
+ const hash = sha256(bytes);
142
+ if (hashFile(to) !== hash) {
143
+ writeFile(to, bytes);
144
+ written.push(f.path);
145
+ }
146
+ if (f.class === "fixed") pins[f.path] = hash;
147
+ }
148
+
149
+ const blocks = {};
150
+ const known = loadBlocks();
151
+ for (const [name, from] of Object.entries(before.manifest?.blocks ?? {})) {
152
+ if (!existsSync(join(dir, BLOCK_DIR, name))) {
153
+ notes.push(`${name}: its folder is gone — dropped from ${MANIFEST}`);
154
+ continue;
155
+ }
156
+ const block = known.find((b) => b.name === name);
157
+ if (!block) {
158
+ for (const [p, h] of Object.entries(before.pins)) if (p.startsWith(`${BLOCK_DIR}/${name}/`)) pins[p] = h;
159
+ blocks[name] = from;
160
+ notes.push(`${name}: not in makersclaw ${v} — left as installed (${from})`);
161
+ continue;
162
+ }
163
+ for (const f of block.files) {
164
+ const bytes = readFileSync(f.from);
165
+ const hash = sha256(bytes);
166
+ if (hashFile(join(dir, f.to)) !== hash) {
167
+ writeFile(join(dir, f.to), bytes);
168
+ written.push(f.to);
169
+ }
170
+ pins[f.to] = hash;
171
+ }
172
+ blocks[name] = v;
173
+ }
174
+
175
+ for (const p of Object.keys(before.pins)) {
176
+ if (p in pins) continue;
177
+ if (p.startsWith(`${BLOCK_DIR}/`) && !(p.split("/")[2] in blocks)) continue;
178
+ if (!ownedPath(p, tpl)) {
179
+ notes.push(`${p}: pinned before, not a template file — left alone`);
180
+ continue;
181
+ }
182
+ if (existsSync(join(dir, p))) {
183
+ removeFile(join(dir, p), dir);
184
+ removed.push(p);
185
+ }
186
+ }
187
+
188
+ writeJson(join(dir, PINS), sortKeys(pins));
189
+ writeJson(join(dir, MANIFEST), { template: tpl.name, version: v, blocks: sortKeys(blocks) });
190
+ return {
191
+ template: tpl.name,
192
+ version: v,
193
+ from: before.manifest?.version ?? null,
194
+ written,
195
+ kept,
196
+ removed,
197
+ package: pkg,
198
+ install: fresh || pkg.some((c) => c.key !== "scripts"),
199
+ pinned: Object.keys(pins).length,
200
+ blocks: Object.keys(blocks),
201
+ notes,
202
+ };
203
+ }
204
+
205
+ /** `init [dir] [-t next] [--force]` */
206
+ export function init(dir, { template = "next", force = false } = {}) {
207
+ const tpl = loadTemplateOrFail(template);
208
+ const before = readState(dir);
209
+ if (existsSync(join(dir, "package.json")) && !force) {
210
+ throw new CliError(
211
+ `${dir} already holds an app`,
212
+ before.manifest
213
+ ? "makersclaw update lays this version over it"
214
+ : "makersclaw init --force lays the template over it: fixed files and docs are replaced, your own files stay",
215
+ );
216
+ }
217
+ if (before.manifest?.template && before.manifest.template !== template) {
218
+ throw new CliError(`this app was laid from template "${before.manifest.template}", not "${template}"`, `makersclaw init --force -t ${before.manifest.template}`);
219
+ }
220
+ return lay(dir, tpl, before);
221
+ }
222
+
223
+ /** `update [--major] [--adopt]` — this version's fixed files, docs and
224
+ * blocks. `--adopt` takes on an app laid before makersclaw kept a
225
+ * manifest — but only one from the kit's era (it has check/pins.json):
226
+ * its screens were written against this kit, so laying the fixed files
227
+ * again keeps them working. An older app drew its own controls; it needs
228
+ * its screens rebuilt, not its files replaced. */
229
+ export function update(dir, { major = false, adopt = false } = {}) {
230
+ if (adopt && !existsSync(join(dir, MANIFEST)) && existsSync(join(dir, "package.json"))) {
231
+ if (!existsSync(join(dir, PINS))) {
232
+ throw new CliError(
233
+ `${dir} was laid before the kit (no check/pins.json): its screens draw their own controls and colours`,
234
+ "rebuild it onto the template — its screens need writing again, not just its files replacing",
235
+ );
236
+ }
237
+ return { ...lay(dir, loadTemplateOrFail("next"), readState(dir)), adopted: true };
238
+ }
239
+ const before = requireManifest(dir);
240
+ const tpl = loadTemplateOrFail(before.manifest.template);
241
+ const v = version();
242
+ if (newer(before.manifest.version, v) && !major) {
243
+ throw new CliError(
244
+ `this app is on makersclaw ${before.manifest.version}, newer than this one (${v})`,
245
+ `run npx makersclaw@${before.manifest.version}, or update --major to go back`,
246
+ );
247
+ }
248
+ if (!compatible(before.manifest.version, v) && !major) {
249
+ throw new CliError(
250
+ `${before.manifest.version} → ${v} changes the template's contract; screens written against ${before.manifest.version} may not build`,
251
+ "makersclaw diff shows what changes; makersclaw update --major lays it anyway",
252
+ );
253
+ }
254
+ return lay(dir, tpl, before);
255
+ }
256
+
257
+ function importFor(item) {
258
+ if (item.type === "block") {
259
+ const names = item.exports?.length ? item.exports.join(", ") : "…";
260
+ return `import { ${names} } from "../components/blocks/${item.name}";`;
261
+ }
262
+ const folder = item.type === "part" ? "parts" : "ui";
263
+ return `import { ${item.title.split(",")[0].trim()} } from "../components/${folder}";`;
264
+ }
265
+
266
+ /** `add <name...>` — install blocks (and what they require), or restore a
267
+ * kit component. Refuses an app on another version: a block assumes the
268
+ * kit of its own version. */
269
+ export function add(dir, names) {
270
+ if (names.length === 0) throw new CliError("name a block or a component to add", "makersclaw search <words> finds one by what it does");
271
+ const { manifest, pins } = requireManifest(dir);
272
+ const v = version();
273
+ if (manifest.version !== v) {
274
+ throw new CliError(
275
+ `this app is on makersclaw ${manifest.version}; this is ${v}, and a block assumes the kit of its own version`,
276
+ compatible(manifest.version, v) && !newer(manifest.version, v)
277
+ ? `makersclaw update first (${manifest.version} → ${v} is compatible), then add`
278
+ : `npx makersclaw@${manifest.version} add ${names.join(" ")}`,
279
+ );
280
+ }
281
+ const tpl = loadTemplateOrFail(manifest.template);
282
+ const items = loadItems(tpl);
283
+ const order = [];
284
+ const seen = new Set();
285
+ const visit = (name, by) => {
286
+ if (seen.has(name)) return;
287
+ const item = items.find((i) => i.name === name);
288
+ if (!item) {
289
+ const close = suggest(items, name);
290
+ throw new CliError(
291
+ `no block or component named "${name}"${by ? ` (${by} requires it)` : ""}`,
292
+ close.length ? `did you mean ${close.join(", ")}? makersclaw search <words> finds by what it does` : "makersclaw search <words> finds by what it does",
293
+ );
294
+ }
295
+ seen.add(name);
296
+ for (const r of item.requires) visit(r, name);
297
+ order.push(item);
298
+ };
299
+ for (const n of names) visit(n, null);
300
+
301
+ const added = [];
302
+ const deps = {};
303
+ for (const item of order) {
304
+ const files = [];
305
+ let changed = 0;
306
+ for (const f of item.files) {
307
+ const bytes = readFileSync(f.from);
308
+ const hash = sha256(bytes);
309
+ if (hashFile(join(dir, f.to)) !== hash) {
310
+ writeFile(join(dir, f.to), bytes);
311
+ changed++;
312
+ }
313
+ pins[f.to] = hash;
314
+ files.push(f.to);
315
+ }
316
+ if (item.type === "block") {
317
+ for (const p of Object.keys(pins)) {
318
+ if (p.startsWith(`${BLOCK_DIR}/${item.name}/`) && !files.includes(p)) {
319
+ removeFile(join(dir, p), dir);
320
+ delete pins[p];
321
+ }
322
+ }
323
+ manifest.blocks = { ...(manifest.blocks ?? {}), [item.name]: v };
324
+ Object.assign(deps, item.dependencies);
325
+ }
326
+ added.push({
327
+ name: item.name,
328
+ type: item.type,
329
+ files,
330
+ changed,
331
+ requested: names.includes(item.name),
332
+ import: importFor(item),
333
+ });
334
+ }
335
+
336
+ let pkg = [];
337
+ if (Object.keys(deps).length) {
338
+ const path = join(dir, "package.json");
339
+ const { out, changes } = mergePackage(readJson(path, {}), { dependencies: deps });
340
+ if (changes.length) writeJson(path, out);
341
+ pkg = changes;
342
+ }
343
+ writeJson(join(dir, PINS), sortKeys(pins));
344
+ writeJson(join(dir, MANIFEST), { ...manifest, blocks: sortKeys(manifest.blocks) });
345
+ return {
346
+ version: v,
347
+ added,
348
+ package: pkg,
349
+ install: pkg.length > 0,
350
+ usage: order.filter((i) => i.type === "block" && names.includes(i.name)).map((i) => ({ name: i.name, readme: readmeOf(i) })),
351
+ };
352
+ }
353
+
354
+ /** Files outside the block that import it: a surface still using a block
355
+ * must not lose it. */
356
+ function importersOf(dir, name) {
357
+ const needle = new RegExp(`blocks/${name}(?:["'/]|$)`, "m");
358
+ return walk(dir).filter(
359
+ (p) => /\.(?:jsx?|mjs)$/.test(p) && !p.startsWith(`${BLOCK_DIR}/${name}/`) && needle.test(readFileSync(join(dir, p), "utf8")),
360
+ );
361
+ }
362
+
363
+ /** `remove <block...>` — uninstall blocks nothing imports. */
364
+ export function remove(dir, names) {
365
+ if (names.length === 0) throw new CliError("name a block to remove");
366
+ const { manifest, pins } = requireManifest(dir);
367
+ const tpl = loadTemplateOrFail(manifest.template);
368
+ const installed = manifest.blocks ?? {};
369
+ const known = loadBlocks();
370
+ const removed = [];
371
+ for (const name of names) {
372
+ if (tpl.components.some((c) => c.name === name)) throw new CliError(`${name} is part of the kit — every app has it`);
373
+ if (!installed[name]) {
374
+ throw new CliError(`${name} is not installed here`, `installed: ${Object.keys(installed).join(", ") || "no blocks"}`);
375
+ }
376
+ const dependents = Object.keys(installed).filter(
377
+ (other) => other !== name && !names.includes(other) && known.find((b) => b.name === other)?.requires.includes(name),
378
+ );
379
+ if (dependents.length) throw new CliError(`${dependents.join(", ")} requires ${name}`, `remove ${dependents.join(" ")} with it`);
380
+ const users = importersOf(dir, name);
381
+ if (users.length) {
382
+ throw new CliError(`${users.join(", ")} still import${users.length === 1 ? "s" : ""} ${name}`, "remove the import first — a surface that imports a missing block breaks the build");
383
+ }
384
+ }
385
+ for (const name of names) {
386
+ for (const p of Object.keys(pins)) {
387
+ if (!p.startsWith(`${BLOCK_DIR}/${name}/`)) continue;
388
+ removeFile(join(dir, p), dir);
389
+ delete pins[p];
390
+ removed.push(p);
391
+ }
392
+ delete installed[name];
393
+ }
394
+ writeJson(join(dir, PINS), sortKeys(pins));
395
+ writeJson(join(dir, MANIFEST), { ...manifest, blocks: sortKeys(installed) });
396
+ return { removed, blocks: Object.keys(installed) };
397
+ }
398
+
399
+ /**
400
+ * `diff` — what update would do, file by file, without doing it:
401
+ * changed the app has the old version's copy; this version's differs
402
+ * edited a fixed file no longer matches its pin (someone changed it)
403
+ * missing pinned, and gone from the app
404
+ * new this version ships a file the app never had
405
+ * dropped pinned by the old version, not shipped by this one
406
+ */
407
+ export function diff(dir) {
408
+ const { manifest, pins } = requireManifest(dir);
409
+ const tpl = loadTemplateOrFail(manifest.template);
410
+ const v = version();
411
+ const entries = [];
412
+ const statusOf = (path, have, want, pinned) => {
413
+ if (have === want) return null;
414
+ if (have === null) return pinned ? "missing" : "new";
415
+ if (pinned && have !== pinned) return "edited";
416
+ return "changed";
417
+ };
418
+
419
+ for (const f of tpl.files) {
420
+ if (f.class !== "fixed" && f.class !== "docs") continue;
421
+ const status = statusOf(f.path, hashFile(join(dir, f.path)), sha256(readFileSync(f.from)), f.class === "fixed" ? pins[f.path] : null);
422
+ if (status) entries.push({ item: "template", path: f.path, status });
423
+ }
424
+ const shipped = new Set(tpl.files.map((f) => f.path));
425
+ for (const p of Object.keys(pins)) {
426
+ if (!p.startsWith(`${BLOCK_DIR}/`) && !shipped.has(p) && ownedPath(p, tpl)) entries.push({ item: "template", path: p, status: "dropped" });
427
+ }
428
+
429
+ const known = loadBlocks();
430
+ for (const name of Object.keys(manifest.blocks ?? {})) {
431
+ const block = known.find((b) => b.name === name);
432
+ if (!block) {
433
+ entries.push({ item: name, path: `${BLOCK_DIR}/${name}/`, status: "kept", detail: `not in makersclaw ${v}; update leaves it as installed` });
434
+ continue;
435
+ }
436
+ const files = new Set(block.files.map((f) => f.to));
437
+ for (const f of block.files) {
438
+ const status = statusOf(f.to, hashFile(join(dir, f.to)), sha256(readFileSync(f.from)), pins[f.to]);
439
+ if (status) entries.push({ item: name, path: f.to, status });
440
+ }
441
+ for (const p of Object.keys(pins)) {
442
+ if (p.startsWith(`${BLOCK_DIR}/${name}/`) && !files.has(p)) entries.push({ item: name, path: p, status: "dropped" });
443
+ }
444
+ }
445
+
446
+ const pkgPath = join(dir, "package.json");
447
+ const tplPkg = tpl.files.find((f) => f.class === "merged");
448
+ const pkg = existsSync(pkgPath) && tplPkg ? mergePackage(readJson(pkgPath), readJson(tplPkg.from)).changes : [];
449
+ return {
450
+ from: manifest.version,
451
+ to: v,
452
+ compatible: compatible(manifest.version, v) && !newer(manifest.version, v),
453
+ entries,
454
+ package: pkg,
455
+ blocks: manifest.blocks ?? {},
456
+ };
457
+ }