makersclaw 0.5.2 → 0.7.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 (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
package/src/pm.mjs ADDED
@@ -0,0 +1,473 @@
1
+ // The package manager behind `makersclaw install`: pnpm, laying npm's flat
2
+ // node_modules with every file hard-linked from one store.
3
+ //
4
+ // Why pnpm, and why this way (plan 0.1, claw docs/BRAIN-AND-LIGHT-APPS-PLAN.md):
5
+ // with npm every app on a machine holds its own copy of next (about 280 MB);
6
+ // with one pnpm store the second app costs its folders and links. The
7
+ // layout stays npm's (`node-linker=hoisted`: no symlinks, no .pnpm tree),
8
+ // so everything that walks node_modules — Next, Turbopack, the check's
9
+ // copy build, claw's `cp -al` staging — sees what it saw under npm.
10
+ //
11
+ // Which pnpm, in order:
12
+ // 1. MAKERSCLAW_PNPM — the one the platform put on the machine (a
13
+ // binary, or a pnpm.cjs this node runs). Taken at any version.
14
+ // 2. `pnpm` on PATH, when its major is PNPM_VERSION's: a lockfile's
15
+ // settings and frozen-install rules are a major's.
16
+ // 3. `npm exec pnpm@PNPM_VERSION`, once: npm fetches that exact pnpm (or
17
+ // finds it in its cache) and says where it laid it, and this node runs
18
+ // that file from then on. No corepack: Node 25 stopped shipping it,
19
+ // and its key rotation in 2025 broke every `corepack pnpm` until
20
+ // upgraded.
21
+ // 4. None of them: npm, said plainly. npm does not read pnpm-lock.yaml,
22
+ // so that install resolves from the registry, not the release lock.
23
+ // Not when npm could not reach the registry to fetch pnpm: npm would
24
+ // need the same registry, and fail after minutes of retries.
25
+ //
26
+ // The store: MAKERSCLAW_PNPM_STORE when set (claw sets one per VM),
27
+ // pnpm's own default otherwise. Every setting that decides the layout is
28
+ // passed on the command line, so an .npmrc cannot change it.
29
+
30
+ import { spawnSync } from "node:child_process";
31
+ import { copyFileSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { LOCKFILE, deriveLock, dropSnapshots, lockDifference, manifestDeps, parseLock, refToKey } from "./lockfile.mjs";
35
+
36
+ /** The pnpm makersclaw installs with: the same major as makersclaw-store's
37
+ * deps.mjs, lockfile 9.0. */
38
+ export const PNPM_VERSION = "10.34.6";
39
+ const PNPM_MAJOR = Number(PNPM_VERSION.split(".")[0]);
40
+
41
+ /** npm's lock, which every app npm installed holds (every app before 0.6.0). */
42
+ export const NPM_LOCKFILE = "package-lock.json";
43
+
44
+ /** How pnpm lays an app. Each one is on the command line of every install. */
45
+ export const PNPM_SETTINGS = [
46
+ "--config.node-linker=hoisted",
47
+ // Hard links on every filesystem. pnpm's default clones on APFS and
48
+ // btrfs; links make a Mac lay the way a Linux VM does, and `du` over a
49
+ // store and its apps counts each file once.
50
+ "--config.package-import-method=hardlink",
51
+ // The release lock's settings: a frozen install refuses a lock made
52
+ // with others.
53
+ "--config.auto-install-peers=true",
54
+ "--config.exclude-links-from-lockfile=false",
55
+ // An app npm installed before holds npm's node_modules; pnpm asks before
56
+ // replacing it, and with no terminal to ask on it fails instead.
57
+ "--config.confirm-modules-purge=false",
58
+ // An app's own "packageManager" field fetches nothing and refuses nothing.
59
+ "--config.manage-package-manager-versions=false",
60
+ "--config.package-manager-strict=false",
61
+ // Every package's install scripts run, as they did under npm. pnpm 10
62
+ // skips them unless told, and says so only in its log: a package that
63
+ // needs its build (better-sqlite3, @prisma/client, canvas) then installs
64
+ // "fine" and fails when the app runs. No package in the release has a
65
+ // script, so this reaches only the packages an app adds itself. A
66
+ // package that will be built is copied into node_modules, never
67
+ // hard-linked (pnpm imports it "clone-or-copy"), so its script cannot
68
+ // write into the store's files.
69
+ "--config.dangerously-allow-all-builds=true",
70
+ // An app laid inside someone's pnpm workspace installs itself, not the
71
+ // workspace around it.
72
+ "--ignore-workspace",
73
+ "--reporter=append-only",
74
+ ];
75
+
76
+ // pnpm 10 turns on Node's compile cache in os.tmpdir() unless told not to
77
+ // (makersclaw-store reports/store-pnpm.md); corepack's shim would ask
78
+ // before a download, and nothing answers it here.
79
+ const CHILD_ENV = { NODE_DISABLE_COMPILE_CACHE: "1", COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" };
80
+ const MAX_BUFFER = 64 * 1024 * 1024;
81
+
82
+ const tail = (text, n = 15) =>
83
+ String(text ?? "")
84
+ .trim()
85
+ .split("\n")
86
+ .slice(-n)
87
+ .join("\n");
88
+
89
+ const list = (names) => names.join(", ");
90
+
91
+ function probe(cmd, args, env, timeout) {
92
+ const r = spawnSync(cmd, [...args, "--version"], { env: { ...env, ...CHILD_ENV }, encoding: "utf8", timeout });
93
+ if (r.error) return { error: r.error.code === "ENOENT" ? "not found" : r.error.message };
94
+ if (r.status !== 0) return { error: `exit ${r.status}: ${tail(r.stderr || r.stdout, 1) || "no output"}` };
95
+ const version = /(\d+\.\d+\.\d+\S*)\s*$/.exec(r.stdout.trim())?.[1];
96
+ return version ? { version } : { error: `no version in "${tail(r.stdout, 1)}"` };
97
+ }
98
+
99
+ // A socket error: the registry did not answer. npm would need it as much.
100
+ const NETWORK = /\b(ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ENETDOWN)\b/;
101
+
102
+ // Run by `npm exec`, whose PATH starts at the bin folder npm laid
103
+ // pnpm@PNPM_VERSION in: print where that pnpm really is (its pnpm.cjs).
104
+ const WHERE_PNPM = `const{delimiter,join}=require("node:path"),fs=require("node:fs");for(const d of(process.env.PATH||"").split(delimiter)){const p=join(d,"pnpm");if(fs.existsSync(p)){process.stdout.write(fs.realpathSync(p));break}}`;
105
+
106
+ /** npm's own words for a failure: its error code and the line after it. */
107
+ function npmFailure(output) {
108
+ const lines = String(output ?? "")
109
+ .split("\n")
110
+ .map((l) => l.replace(/^npm (?:error|ERR!) ?/, "").trim())
111
+ .filter((l) => l && !/^(?:errno|syscall|A complete log)/.test(l));
112
+ const at = lines.findIndex((l) => l.startsWith("code "));
113
+ return at >= 0 ? [lines[at].slice(5), lines[at + 1]].filter(Boolean).join(": ") : tail(lines.join("\n"), 1) || "no output";
114
+ }
115
+
116
+ /**
117
+ * The pnpm to run: { pnpm, tried, unreachable }. `pnpm` is { cmd, args,
118
+ * version, via } or null; `tried` has one line per door that did not
119
+ * answer; `unreachable` is the socket error npm met fetching pnpm, when
120
+ * that is why nothing answered.
121
+ */
122
+ export function locatePnpm({ env = process.env } = {}) {
123
+ const tried = [];
124
+ const named = env.MAKERSCLAW_PNPM?.trim();
125
+ if (named) {
126
+ const byNode = /\.[cm]?js$/.test(named);
127
+ const cmd = byNode ? process.execPath : named;
128
+ const args = byNode ? [named] : [];
129
+ const p = probe(cmd, args, env, 30_000);
130
+ if (p.version) return { pnpm: { cmd, args, version: p.version, via: "MAKERSCLAW_PNPM" }, tried, unreachable: null };
131
+ tried.push(`MAKERSCLAW_PNPM (${named}): ${p.error}`);
132
+ }
133
+ const onPath = probe("pnpm", [], env, 30_000);
134
+ if (onPath.version && Number(onPath.version.split(".")[0]) === PNPM_MAJOR) return { pnpm: { cmd: "pnpm", args: [], version: onPath.version, via: "PATH" }, tried, unreachable: null };
135
+ tried.push(onPath.version ? `pnpm on PATH is ${onPath.version}, not ${PNPM_MAJOR}.x` : `pnpm on PATH: ${onPath.error}`);
136
+
137
+ // npm exec once, to learn where npm laid pnpm; this node runs that file
138
+ // from then on. The version is exact, so a copy in npm's cache is the
139
+ // right one: --prefer-offline asks the registry nothing then. Without it
140
+ // npm asked on every exec, and with the network down each ask took 71 s
141
+ // of retries before npm settled for its cache.
142
+ const door = `npm exec pnpm@${PNPM_VERSION}`;
143
+ const npx = spawnSync("npm", ["exec", "--yes", "--prefer-offline", `--package=pnpm@${PNPM_VERSION}`, "--", process.execPath, "-e", WHERE_PNPM], {
144
+ env: { ...env, ...CHILD_ENV },
145
+ encoding: "utf8",
146
+ timeout: 180_000,
147
+ maxBuffer: MAX_BUFFER,
148
+ });
149
+ const laid = npx.status === 0 ? npx.stdout.trim() : "";
150
+ if (laid) {
151
+ const p = probe(process.execPath, [laid], env, 30_000);
152
+ if (p.version) return { pnpm: { cmd: process.execPath, args: [laid], version: p.version, via: door }, tried, unreachable: null };
153
+ tried.push(`${door}: ${laid}: ${p.error}`);
154
+ return { pnpm: null, tried, unreachable: null };
155
+ }
156
+ if (npx.error) {
157
+ const timedOut = npx.error.code === "ETIMEDOUT";
158
+ tried.push(`${door}: ${npx.error.code === "ENOENT" ? "npm not found" : timedOut ? "no answer in 180 s" : npx.error.message}`);
159
+ return { pnpm: null, tried, unreachable: timedOut ? "npm exec timed out after 180 s" : null };
160
+ }
161
+ const output = `${npx.stdout ?? ""}${npx.stderr ?? ""}`;
162
+ tried.push(`${door}: ${npx.status === 0 ? "npm laid no pnpm" : `exit ${npx.status}: ${npmFailure(output)}`}`);
163
+ return { pnpm: null, tried, unreachable: npx.status === 0 ? null : (NETWORK.exec(output)?.[1] ?? null) };
164
+ }
165
+
166
+ /** The pnpm to run, or null; `tried` gets one line per door that did not
167
+ * answer (scripts/lock.mjs and the tests read it this way). */
168
+ export function findPnpm({ env = process.env, tried = [] } = {}) {
169
+ const r = locatePnpm({ env });
170
+ tried.push(...r.tried);
171
+ return r.pnpm;
172
+ }
173
+
174
+ /** Write a file through a new one and a rename, so a hard link to the old
175
+ * file keeps the old bytes. */
176
+ function replaceFile(path, bytes) {
177
+ const tmp = `${path}.makersclaw-${process.pid}`;
178
+ writeFileSync(tmp, bytes);
179
+ renameSync(tmp, path);
180
+ }
181
+
182
+ /**
183
+ * pnpm writes some of its records in node_modules over the old file, not
184
+ * beside it: `.modules.yaml` and `.pnpm-workspace-state-v1.json` (and
185
+ * trimCurrentLock below wrote `.pnpm/lock.yaml` the same way until it
186
+ * renamed). Where the tree was copied as hard links, as claw stages a
187
+ * working copy (`cp -al`), an install in the copy then rewrote the live
188
+ * app's records too, and they stopped describing its tree. So each record
189
+ * still shared with another folder gets a file of its own first. Package
190
+ * files need nothing: measured, pnpm replaces their folders and never
191
+ * writes into one. Returns how many it copied.
192
+ */
193
+ export function unshareRecords(dir) {
194
+ const nm = join(dir, "node_modules");
195
+ const records = [];
196
+ for (const [folder, every] of [
197
+ [nm, false],
198
+ [join(nm, ".pnpm"), true],
199
+ ]) {
200
+ let names = [];
201
+ try {
202
+ names = readdirSync(folder);
203
+ } catch {
204
+ continue;
205
+ }
206
+ for (const name of names) if (every || name.startsWith(".")) records.push(join(folder, name));
207
+ }
208
+ let n = 0;
209
+ for (const path of records) {
210
+ try {
211
+ const st = lstatSync(path);
212
+ if (!st.isFile() || st.nlink < 2) continue;
213
+ replaceFile(path, readFileSync(path));
214
+ n++;
215
+ } catch {
216
+ // Left as it is: pnpm writes it as before.
217
+ }
218
+ }
219
+ return n;
220
+ }
221
+
222
+ /**
223
+ * pnpm 10's hoisted linker, installing over a tree it laid before, first
224
+ * lists that tree from its current lockfile (node_modules/.pnpm/lock.yaml)
225
+ * with the platform check off (lockfileToHoistedDepGraph builds prevGraph
226
+ * with force: true), and so fetches into the store every package it
227
+ * skipped as another platform's. For the release's versions that is Next's
228
+ * compiler for eight other platforms, and sharp's and lightningcss's:
229
+ * measured, a store of 473 MB became 1.4 GB on the next install that
230
+ * changed the tree (one package added to package.json). The list only
231
+ * decides which folders to remove, and a skipped package never had one;
232
+ * so the snapshots pnpm itself recorded as skipped
233
+ * (node_modules/.modules.yaml) are dropped from that file first. On any
234
+ * doubt the file stays as pnpm wrote it. Returns how many were dropped.
235
+ */
236
+ export function trimCurrentLock(dir) {
237
+ try {
238
+ const path = join(dir, "node_modules", ".pnpm", "lock.yaml");
239
+ const modules = join(dir, "node_modules", ".modules.yaml");
240
+ if (!existsSync(path) || !existsSync(modules)) return 0;
241
+ const skipped = new Set(JSON.parse(readFileSync(modules, "utf8")).skipped ?? []);
242
+ const lock = parseLock(readFileSync(path, "utf8"));
243
+ const drop = new Set([...lock.snapshots.keys()].filter((key) => skipped.has(key)));
244
+ if (!drop.size) return 0;
245
+ const text = dropSnapshots(lock, drop);
246
+ // Every edge left must land on a snapshot: pnpm throws on one that
247
+ // does not (LockfileMissingDependencyError), and would on every install.
248
+ const trimmed = parseLock(text);
249
+ const roots = [...trimmed.importer.values()].flatMap((deps) => [...deps].map(([name, d]) => refToKey(name, d.version)));
250
+ const edges = [...trimmed.snapshots.values()].flatMap((s) => s.deps.map((d) => d.key));
251
+ if ([...roots, ...edges].some((key) => key !== null && !trimmed.snapshots.has(key))) return 0;
252
+ // Renamed into place, never written into: a copy of the tree made with
253
+ // hard links must keep its own.
254
+ replaceFile(path, text);
255
+ return drop.size;
256
+ } catch {
257
+ return 0;
258
+ }
259
+ }
260
+
261
+ /** Packages pnpm installed without running their install scripts, as
262
+ * name@version: `ignoredBuilds` in node_modules/.modules.yaml. */
263
+ export function ignoredBuilds(dir) {
264
+ try {
265
+ const ids = JSON.parse(readFileSync(join(dir, "node_modules", ".modules.yaml"), "utf8")).ignoredBuilds;
266
+ return Array.isArray(ids) ? ids.map(String) : [];
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ /** name@version → name; a scoped name keeps its first "@". */
273
+ const nameOf = (id) => {
274
+ const at = id.indexOf("@", 1);
275
+ return at === -1 ? id : id.slice(0, at);
276
+ };
277
+
278
+ /** The version part of an importer's reference: `1.2.0(react@19.3.0)` → 1.2.0. */
279
+ const bareVersion = (ref) => {
280
+ const paren = ref.indexOf("(");
281
+ return paren === -1 ? ref : ref.slice(0, paren);
282
+ };
283
+
284
+ /**
285
+ * An app npm installed holds package-lock.json, and the packages the app
286
+ * added itself are pinned there, not in the release lock. pnpm reads only
287
+ * pnpm-lock.yaml, so its first install would resolve them again, to the
288
+ * newest in range. So, before it: `pnpm import` turns npm's lock into
289
+ * pnpm's (in a scratch folder, since it writes pnpm-lock.yaml), and the
290
+ * names pnpm-lock.yaml lacks are carried from it by the cut's own rule
291
+ * (deriveLock: not one whose peers resolved to another version than the
292
+ * lock's; that one is resolved again). Says what it did in a note; on any
293
+ * failure the lock stays as it was and the install resolves them.
294
+ */
295
+ function seedFromNpmLock(dir, pnpm, pkg, env, settings, note) {
296
+ const npmPath = join(dir, NPM_LOCKFILE);
297
+ if (!existsSync(npmPath)) return;
298
+ let npm;
299
+ try {
300
+ npm = JSON.parse(readFileSync(npmPath, "utf8"));
301
+ } catch {
302
+ return;
303
+ }
304
+ const lockPath = join(dir, LOCKFILE);
305
+ let current = null;
306
+ if (existsSync(lockPath)) {
307
+ try {
308
+ current = parseLock(readFileSync(lockPath, "utf8"));
309
+ } catch {
310
+ // A lock this CLI cannot read is pnpm's to judge, not ours to replace.
311
+ return;
312
+ }
313
+ }
314
+ const npmVersion = (name) => npm?.packages?.[`node_modules/${name}`]?.version ?? npm?.dependencies?.[name]?.version ?? null;
315
+ const want = [];
316
+ for (const [section, deps] of manifestDeps(pkg)) {
317
+ for (const [name, spec] of deps) {
318
+ const d = current?.importer.get(section).get(name);
319
+ if ((!d || d.specifier !== spec) && npmVersion(name)) want.push({ section, name });
320
+ }
321
+ }
322
+ if (!want.length) return;
323
+ const names = want.map((w) => w.name).sort();
324
+ const scratch = mkdtempSync(join(tmpdir(), "makersclaw-import-"));
325
+ try {
326
+ for (const f of ["package.json", NPM_LOCKFILE, ".npmrc"]) if (existsSync(join(dir, f))) copyFileSync(join(dir, f), join(scratch, f));
327
+ const r = spawnSync(pnpm.cmd, [...pnpm.args, "import", ...settings], {
328
+ cwd: scratch,
329
+ env: { ...env, ...CHILD_ENV },
330
+ encoding: "utf8",
331
+ timeout: 180_000,
332
+ maxBuffer: MAX_BUFFER,
333
+ });
334
+ if (r.status !== 0 || !existsSync(join(scratch, LOCKFILE))) {
335
+ const why = r.error ? r.error.message : tail(`${r.stdout ?? ""}${r.stderr ?? ""}`, 1) || `exit ${r.status}`;
336
+ note(`${NPM_LOCKFILE} pins ${list(names)}; pnpm could not import it (${why}) — resolved again, the newest in range`);
337
+ return;
338
+ }
339
+ const importedText = readFileSync(join(scratch, LOCKFILE), "utf8");
340
+ const imported = parseLock(importedText);
341
+ const text = current ? deriveLock(current, pkg, imported).text : importedText;
342
+ const now = parseLock(text);
343
+ const kept = [];
344
+ const moved = [];
345
+ for (const { section, name } of want) {
346
+ const d = now.importer.get(section).get(name);
347
+ if (d && bareVersion(d.version) === npmVersion(name)) kept.push(`${name} ${npmVersion(name)}`);
348
+ else moved.push(`${name} (npm had ${npmVersion(name)})`);
349
+ }
350
+ if (text !== (current ? readFileSync(lockPath, "utf8") : null)) replaceFile(lockPath, text);
351
+ if (kept.length) note(`${LOCKFILE}: npm's versions kept from ${NPM_LOCKFILE}: ${list(kept.sort())}`);
352
+ if (moved.length) note(`${LOCKFILE}: resolved again, not at ${NPM_LOCKFILE}'s version: ${list(moved.sort())}`);
353
+ } catch (error) {
354
+ note(`${NPM_LOCKFILE} pins ${list(names)}; its versions could not be carried (${error.message}) — resolved again, the newest in range`);
355
+ } finally {
356
+ rmSync(scratch, { recursive: true, force: true });
357
+ }
358
+ }
359
+
360
+ // A frozen install that fails on these is the lock's fault, not the
361
+ // network's: installing again without --frozen-lockfile mends it.
362
+ const LOCK_ERRORS = /ERR_PNPM_(?:OUTDATED_LOCKFILE|LOCKFILE_CONFIG_MISMATCH|LOCKFILE_BREAKING_CHANGE|BROKEN_LOCKFILE|LOCKFILE_MISSING_DEPENDENCY)/;
363
+
364
+ /**
365
+ * Install the app in `dir`: pnpm with its lock, or npm when no pnpm runs.
366
+ * Returns { ok, manager, version, via, frozen, store, why, unreachable,
367
+ * ignoredBuilds, notes, log, code }. `why` says why the install is not
368
+ * frozen (the lock's first difference from package.json); `unreachable`
369
+ * is set when no pnpm could be had because the registry did not answer
370
+ * (nothing was installed); `ignoredBuilds` lists packages whose install
371
+ * scripts did not run; `notes` say what the reader should know (a
372
+ * fallback, a retry, versions carried from package-lock.json, scripts).
373
+ */
374
+ export function installDeps(dir, { env = process.env, onNote = () => {} } = {}) {
375
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
376
+ const notes = [];
377
+ const note = (line) => {
378
+ notes.push(line);
379
+ onNote(line);
380
+ };
381
+ const { pnpm, tried, unreachable } = locatePnpm({ env });
382
+
383
+ if (!pnpm && unreachable) {
384
+ // npm cannot fetch pnpm because the registry did not answer; npm's
385
+ // own install would ask the same registry for every package, retrying
386
+ // for minutes. Nothing is installed, and the reader hears why at once.
387
+ return { ok: false, code: null, manager: null, version: null, via: null, frozen: false, store: null, why: null, unreachable, ignoredBuilds: [], notes, log: tried.join("\n") };
388
+ }
389
+
390
+ if (!pnpm) {
391
+ note(`pnpm was not found (${tried.join(" · ")}) — installing with npm`);
392
+ if (existsSync(join(dir, LOCKFILE))) note(`npm does not read ${LOCKFILE}: versions come from the registry, not this release's lock`);
393
+ const npm = probe("npm", [], env, 30_000);
394
+ const r = spawnSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: dir, env: { ...env, ...CHILD_ENV }, encoding: "utf8", maxBuffer: MAX_BUFFER });
395
+ const log = `${r.stdout ?? ""}${r.stderr ?? ""}`;
396
+ return {
397
+ ok: r.status === 0,
398
+ code: r.status ?? (r.error ? r.error.code ?? -1 : -1),
399
+ manager: "npm",
400
+ version: npm.version ?? null,
401
+ via: "fallback",
402
+ frozen: false,
403
+ store: null,
404
+ why: "npm",
405
+ unreachable: null,
406
+ ignoredBuilds: [],
407
+ notes,
408
+ log: tail(r.error ? r.error.message : log),
409
+ };
410
+ }
411
+
412
+ const store = env.MAKERSCLAW_PNPM_STORE?.trim() || null;
413
+ const settings = [...PNPM_SETTINGS, ...(store ? [`--config.store-dir=${store}`] : [])];
414
+ const spawnPnpm = (args) => spawnSync(pnpm.cmd, [...pnpm.args, ...args, ...settings], { cwd: dir, env: { ...env, ...CHILD_ENV }, encoding: "utf8", maxBuffer: MAX_BUFFER });
415
+
416
+ seedFromNpmLock(dir, pnpm, pkg, env, settings, note);
417
+
418
+ let why = null;
419
+ if (!existsSync(join(dir, LOCKFILE))) why = `no ${LOCKFILE}`;
420
+ else {
421
+ try {
422
+ why = lockDifference(parseLock(readFileSync(join(dir, LOCKFILE), "utf8")), pkg);
423
+ } catch (error) {
424
+ why = `${LOCKFILE} is not one makersclaw reads (${error.message})`;
425
+ }
426
+ }
427
+ let frozen = why === null;
428
+ unshareRecords(dir);
429
+ // Only an install that changes the tree lists the old one; one that finds
430
+ // the tree as the lock says is "Already up to date", and a trim would
431
+ // make it relink everything.
432
+ const current = join(dir, "node_modules", ".pnpm", "lock.yaml");
433
+ if (!frozen || !existsSync(current) || readFileSync(current, "utf8") !== readFileSync(join(dir, LOCKFILE), "utf8")) trimCurrentLock(dir);
434
+ let r = spawnPnpm(["install", frozen ? "--frozen-lockfile" : "--no-frozen-lockfile"]);
435
+ let log = `${r.stdout ?? ""}${r.stderr ?? ""}`;
436
+ if (frozen && r.status !== 0 && LOCK_ERRORS.test(log)) {
437
+ why = `pnpm refused the lock (${LOCK_ERRORS.exec(log)[0]})`;
438
+ note(`${why} — installing again without --frozen-lockfile, which rewrites ${LOCKFILE}`);
439
+ frozen = false;
440
+ r = spawnPnpm(["install", "--no-frozen-lockfile"]);
441
+ log = `${r.stdout ?? ""}${r.stderr ?? ""}`;
442
+ }
443
+
444
+ // An install that finds the tree as the lock says runs no scripts, so a
445
+ // package an earlier install left unbuilt (pnpm 10 before scripts were
446
+ // allowed here) stays unbuilt: rebuild those by name.
447
+ let skipped = r.status === 0 ? ignoredBuilds(dir) : [];
448
+ if (skipped.length) {
449
+ const b = spawnPnpm(["rebuild", ...new Set(skipped.map(nameOf))]);
450
+ log += `${b.stdout ?? ""}${b.stderr ?? ""}`;
451
+ if (b.status !== 0) r = b;
452
+ const still = ignoredBuilds(dir);
453
+ const ran = skipped.filter((id) => !still.includes(id));
454
+ if (b.status === 0 && ran.length) note(`ran the install scripts an earlier install skipped: ${list(ran)}`);
455
+ skipped = still;
456
+ }
457
+ if (skipped.length) note(`pnpm did not run the install scripts of ${list(skipped)} — ${skipped.length === 1 ? "it" : "they"} may fail when the app runs`);
458
+
459
+ return {
460
+ ok: r.status === 0,
461
+ code: r.status ?? -1,
462
+ manager: "pnpm",
463
+ version: pnpm.version,
464
+ via: pnpm.via,
465
+ frozen,
466
+ store,
467
+ why,
468
+ unreachable: null,
469
+ ignoredBuilds: skipped,
470
+ notes,
471
+ log: tail(r.error ? r.error.message : log),
472
+ };
473
+ }
package/src/registry.mjs CHANGED
@@ -51,6 +51,87 @@ export function templateNames() {
51
51
  */
52
52
  export const FILE_CLASSES = ["fixed", "docs", "starter", "merged"];
53
53
 
54
+ // `files.retired` in template.json is not a class: it names fixed files an
55
+ // earlier version laid that this one no longer ships (middleware.js, which
56
+ // Next 16 renamed proxy.js). `update` removes them from an app that has
57
+ // them pinned; nothing lays them.
58
+
59
+ /**
60
+ * The mode files/ is: a Next server in the workspace. Every other mode is
61
+ * files/ with some fixed files replaced and some left out (template.json
62
+ * `modes`), and an app says which in makersclaw.json `mode` (absent: this
63
+ * one). Browser mode (0.7.0, claw's STATIC-SPEC §3): the app's code runs
64
+ * in the member's browser, from files the platform serves, and every read
65
+ * and write goes through the platform's data door. So it lays no
66
+ * proxy.js, no /claw-alive, no pg (lib/db.js) and no server reads of the
67
+ * brain (lib/brain-server.js).
68
+ */
69
+ export const BASE_MODE = "server";
70
+
71
+ /**
72
+ * A mode's overlay and drop list, checked against the base's files. Pure:
73
+ * names in, the first thing wrong thrown, nothing returned. `base` is the
74
+ * base's files ({ path, class }), `overlay` the paths under the mode's
75
+ * overlay folder, `drop` the base paths the mode leaves out.
76
+ * - No file is both dropped and overlaid: a mode lays a file or leaves
77
+ * it out, never both.
78
+ * - Each overlay file is a fixed file of the base. The overlay replaces
79
+ * what the check pins (and update lays again); it adds no file, and
80
+ * never a starter or a doc, which are the app's or read only.
81
+ * - Each dropped file is a fixed file of the base: a drop the base does
82
+ * not ship drops nothing, without a word, and only a pinned file is
83
+ * the template's to remove from an app.
84
+ */
85
+ export function checkMode(template, mode, { base, overlay, drop }) {
86
+ const fixed = new Set(base.filter((f) => f.class === "fixed").map((f) => f.path));
87
+ const dropped = new Set(drop);
88
+ for (const path of overlay) {
89
+ if (dropped.has(path)) throw new Error(`template ${template}: mode ${mode} both drops and overlays ${path} — a mode lays a file or leaves it out, not both`);
90
+ if (!fixed.has(path)) {
91
+ throw new Error(`template ${template}: mode ${mode} overlays ${path}, which is not a fixed file of files/ — an overlay replaces a fixed file and adds none`);
92
+ }
93
+ }
94
+ for (const path of drop) {
95
+ if (!fixed.has(path)) throw new Error(`template ${template}: mode ${mode} drops ${path}, which is not a fixed file of files/ — it would drop nothing`);
96
+ }
97
+ }
98
+
99
+ /** A mode's overlay folder: beside files/, a plain name. */
100
+ const OVERLAY = /^[a-z]+$/;
101
+
102
+ function loadModes(name, dir, meta, base) {
103
+ const modes = {};
104
+ for (const [mode, spec] of Object.entries(meta.modes ?? {})) {
105
+ if (!NAME.test(mode) || mode === BASE_MODE) throw new Error(`template ${name}: mode "${mode}" — ${BASE_MODE} is files/ itself, and a mode is named lowercase and dashed`);
106
+ const folder = spec?.overlay;
107
+ if (typeof folder !== "string" || !OVERLAY.test(folder) || folder === "files") {
108
+ throw new Error(`template ${name}: mode ${mode} names its overlay ${JSON.stringify(folder)} — a plain folder name beside files/`);
109
+ }
110
+ const root = join(dir, folder);
111
+ // A folder that is not there is an empty overlay: the mode only leaves
112
+ // files out. Browser mode's own files come after its drop list does.
113
+ const overlay = existsSync(root) ? walk(root) : [];
114
+ const drop = [...(spec.drop ?? [])];
115
+ checkMode(name, mode, { base, overlay, drop });
116
+ modes[mode] = { root, drop, files: overlay.map((path) => ({ path, class: "fixed", from: join(root, path) })) };
117
+ }
118
+ return modes;
119
+ }
120
+
121
+ /**
122
+ * The files a template lays in a mode: the base's, less the mode's drops,
123
+ * with the mode's copy in place of the base's where its overlay has one.
124
+ * The base mode is files/ as it is. null for a mode the template has not.
125
+ */
126
+ export function modeFiles(template, mode = BASE_MODE) {
127
+ if (mode === BASE_MODE) return template.files;
128
+ const m = template.modes?.[mode];
129
+ if (!m) return null;
130
+ const over = new Map(m.files.map((f) => [f.path, f]));
131
+ const drop = new Set(m.drop);
132
+ return template.files.filter((f) => !drop.has(f.path)).map((f) => over.get(f.path) ?? f);
133
+ }
134
+
54
135
  export function loadTemplate(name) {
55
136
  const dir = join(TEMPLATES, name);
56
137
  const meta = readJson(join(dir, "template.json"));
@@ -82,7 +163,50 @@ export function loadTemplate(name) {
82
163
  }),
83
164
  readme: null,
84
165
  }));
85
- return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components };
166
+ const retired = [...(meta.files?.retired ?? [])];
167
+ for (const path of retired) {
168
+ if (files.some((f) => f.path === path)) throw new Error(`template ${name}: ${path} is retired and shipped — it cannot be both`);
169
+ }
170
+ // The release lock (0.6.0 on): beside files/, never laid as it is. Each
171
+ // app's pnpm-lock.yaml is cut from it (src/lockfile.mjs).
172
+ const lock = existsSync(join(dir, RELEASE_LOCK)) ? join(dir, RELEASE_LOCK) : null;
173
+ const modes = loadModes(name, dir, meta, files);
174
+ return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components, retired, lock, modes };
175
+ }
176
+
177
+ /** The release lock's file, in templates/<name>/: made by `npm run lock`
178
+ * (scripts/lock.mjs), shipped in the package with the template. */
179
+ export const RELEASE_LOCK = "pnpm-lock.yaml";
180
+
181
+ /**
182
+ * What the release lock resolves: the template's package.json, with every
183
+ * free block's packages beside its dependencies — one resolution, so two
184
+ * blocks that share a package share its version, and an app with any set
185
+ * of blocks is a part of it. Only the package's own blocks: premium blocks
186
+ * (MAKERSCLAW_BLOCKS_DIR) are the platform's, and this lock ships on npm.
187
+ * Throws when two blocks name one package at different versions.
188
+ */
189
+ export function releaseManifest(template) {
190
+ const pkg = readJson(template.files.find((f) => f.class === "merged").from);
191
+ const dependencies = { ...(pkg.dependencies ?? {}) };
192
+ const from = Object.fromEntries(Object.keys(dependencies).map((d) => [d, "the template"]));
193
+ for (const block of loadBlocksFrom(BLOCKS, "free")) {
194
+ for (const [dep, spec] of Object.entries(block.dependencies)) {
195
+ if (dependencies[dep] !== undefined && dependencies[dep] !== spec) {
196
+ throw new Error(`${dep}: ${from[dep]} wants ${dependencies[dep]}, block ${block.name} wants ${spec} — one release resolves one version`);
197
+ }
198
+ dependencies[dep] = spec;
199
+ from[dep] ??= `block ${block.name}`;
200
+ }
201
+ }
202
+ const sorted = (o) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
203
+ return {
204
+ name: `makersclaw-${template.name}-release`,
205
+ private: true,
206
+ dependencies: sorted(dependencies),
207
+ ...(pkg.devDependencies ? { devDependencies: sorted(pkg.devDependencies) } : {}),
208
+ ...(pkg.optionalDependencies ? { optionalDependencies: sorted(pkg.optionalDependencies) } : {}),
209
+ };
86
210
  }
87
211
 
88
212
  let extraRoots = [];
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { claw } from "../lib/claw";
5
+ import { dataChanged, whenLanded } from "../lib/door";
6
+ import { changeFeed, refreshLoop } from "./frame";
7
+
8
+ // A browser app's auto-refresh (makersclaw.json "mode": "browser"). Rows
9
+ // change under this page from outside it: the agent writes them while
10
+ // the founder watches, and so do jobs and other members. The page is
11
+ // files, so there is nothing to re-render from a server: the hooks that
12
+ // read the rows (lib/data's useRows and the rest, lib/brain.js's) read
13
+ // again, and only when the data door says something changed.
14
+ //
15
+ // The rules are app/frame.js's (refreshLoop, and the reasons with each),
16
+ // the server app's every one:
17
+ //
18
+ // - only while the tab is visible AND the App tab is on screen: no timer
19
+ // at all otherwise, and coming back asks at once;
20
+ // - only while someone is there: after 30 minutes with no key, pointer,
21
+ // touch, wheel or focus in the app it stops, and the next sign asks
22
+ // at once;
23
+ // - only while the founder is not typing and no dialog is open
24
+ // (isEditing), and never while a surface has opted out with
25
+ // data-claw-refresh="off" on its root;
26
+ // - one tick at a time, the next timed once the last has landed: the
27
+ // interval plus what the last one took.
28
+ //
29
+ // What a tick does is this mode's own (changeFeed):
30
+ //
31
+ // - it asks GET _claw/data/changes?since=<cursor>. The first answer
32
+ // only sets the cursor and the stamp of the app's own tables; after
33
+ // it, a brain change or a different stamp is a change;
34
+ // - a change is dataChanged(), every hook on the page reading again,
35
+ // and the tick lands when whenLanded() says they all have their new
36
+ // rows. A read that fails keeps the rows it had;
37
+ // - never router.refresh(): under an export it fetches the same
38
+ // prebuilt payload. Never /claw-alive: nothing in the machine answers
39
+ // for a browser app, and the door is the platform's.
40
+ //
41
+ // The feed's first answer is asked as the page mounts, not at the first
42
+ // tick. That answer only sets where the feed is, so a change between it
43
+ // and the tick would never count: asked five seconds late, it would hide
44
+ // every change made while the page opened. This sits ahead of the Shell
45
+ // in the layout and React runs effects in that order, so the ask goes
46
+ // out before the surfaces' first reads: a change made in between is read
47
+ // twice, not missed.
48
+ export default function AutoRefresh() {
49
+ useEffect(() => {
50
+ const feed = changeFeed((path, signal) => claw(path, { signal }));
51
+ feed.probe();
52
+ const loop = refreshLoop({
53
+ doc: document,
54
+ probe: feed.probe,
55
+ refresh: () => {
56
+ feed.covered();
57
+ dataChanged();
58
+ return whenLanded();
59
+ },
60
+ });
61
+ return () => {
62
+ loop.stop();
63
+ feed.stop();
64
+ };
65
+ }, []);
66
+ return null;
67
+ }