makersclaw 0.5.2 → 0.6.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 (73) hide show
  1. package/README.md +29 -3
  2. package/package.json +3 -2
  3. package/r/annotate.json +3 -3
  4. package/r/approval-request.json +2 -2
  5. package/r/audio-player.json +2 -2
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +2 -2
  8. package/r/calendar-view.json +3 -3
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +2 -2
  11. package/r/chat.json +2 -2
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +3 -3
  14. package/r/contact-section.json +2 -2
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +2 -2
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +2 -2
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +3 -3
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +3 -3
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +2 -2
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +4 -4
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +2 -2
  48. package/r/signature-pad.json +2 -2
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +2 -2
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/src/cli.mjs +35 -6
  58. package/src/install.mjs +101 -4
  59. package/src/lockfile.mjs +468 -0
  60. package/src/pm.mjs +473 -0
  61. package/src/registry.mjs +48 -1
  62. package/templates/next/files/README.md +20 -15
  63. package/templates/next/files/app/claw-alive/route.js +2 -2
  64. package/templates/next/files/app/frame.js +6 -5
  65. package/templates/next/files/app/layout.jsx +2 -2
  66. package/templates/next/files/check/run.mjs +55 -14
  67. package/templates/next/files/lib/actions.js +1 -1
  68. package/templates/next/files/lib/data.js +1 -1
  69. package/templates/next/files/next.config.mjs +50 -8
  70. package/templates/next/files/package.json +3 -3
  71. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  72. package/templates/next/pnpm-lock.yaml +5870 -0
  73. package/templates/next/template.json +5 -4
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,11 @@ 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
+
54
59
  export function loadTemplate(name) {
55
60
  const dir = join(TEMPLATES, name);
56
61
  const meta = readJson(join(dir, "template.json"));
@@ -82,7 +87,49 @@ export function loadTemplate(name) {
82
87
  }),
83
88
  readme: null,
84
89
  }));
85
- return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components };
90
+ const retired = [...(meta.files?.retired ?? [])];
91
+ for (const path of retired) {
92
+ if (files.some((f) => f.path === path)) throw new Error(`template ${name}: ${path} is retired and shipped — it cannot be both`);
93
+ }
94
+ // The release lock (0.6.0 on): beside files/, never laid as it is. Each
95
+ // app's pnpm-lock.yaml is cut from it (src/lockfile.mjs).
96
+ const lock = existsSync(join(dir, RELEASE_LOCK)) ? join(dir, RELEASE_LOCK) : null;
97
+ return { name, title: meta.title ?? name, description: meta.description ?? "", root, files, components, retired, lock };
98
+ }
99
+
100
+ /** The release lock's file, in templates/<name>/: made by `npm run lock`
101
+ * (scripts/lock.mjs), shipped in the package with the template. */
102
+ export const RELEASE_LOCK = "pnpm-lock.yaml";
103
+
104
+ /**
105
+ * What the release lock resolves: the template's package.json, with every
106
+ * free block's packages beside its dependencies — one resolution, so two
107
+ * blocks that share a package share its version, and an app with any set
108
+ * of blocks is a part of it. Only the package's own blocks: premium blocks
109
+ * (MAKERSCLAW_BLOCKS_DIR) are the platform's, and this lock ships on npm.
110
+ * Throws when two blocks name one package at different versions.
111
+ */
112
+ export function releaseManifest(template) {
113
+ const pkg = readJson(template.files.find((f) => f.class === "merged").from);
114
+ const dependencies = { ...(pkg.dependencies ?? {}) };
115
+ const from = Object.fromEntries(Object.keys(dependencies).map((d) => [d, "the template"]));
116
+ for (const block of loadBlocksFrom(BLOCKS, "free")) {
117
+ for (const [dep, spec] of Object.entries(block.dependencies)) {
118
+ if (dependencies[dep] !== undefined && dependencies[dep] !== spec) {
119
+ throw new Error(`${dep}: ${from[dep]} wants ${dependencies[dep]}, block ${block.name} wants ${spec} — one release resolves one version`);
120
+ }
121
+ dependencies[dep] = spec;
122
+ from[dep] ??= `block ${block.name}`;
123
+ }
124
+ }
125
+ const sorted = (o) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
126
+ return {
127
+ name: `makersclaw-${template.name}-release`,
128
+ private: true,
129
+ dependencies: sorted(dependencies),
130
+ ...(pkg.devDependencies ? { devDependencies: sorted(pkg.devDependencies) } : {}),
131
+ ...(pkg.optionalDependencies ? { optionalDependencies: sorted(pkg.optionalDependencies) } : {}),
132
+ };
86
133
  }
87
134
 
88
135
  let extraRoots = [];
@@ -1,11 +1,11 @@
1
1
  # artifact template
2
2
 
3
- The app every artifact starts from: Next.js 15 (app router, JSX, server
4
- actions), Postgres via `DATABASE_URL`, Tailwind v4, and a kit of parts
5
- (`components/ui`, `components/parts`). It carries the behaviour every
6
- app shares, the parts every app is assembled from, and the workspace's
7
- colours. You design the surfaces: what is on them, how they are laid
8
- out, how dense, how they read — never what colour anything is.
3
+ The app every artifact starts from: Next.js 16 (app router, JSX, server
4
+ actions, Turbopack), Postgres via `DATABASE_URL`, Tailwind v4, and a kit
5
+ of parts (`components/ui`, `components/parts`). It carries the behaviour
6
+ every app shares, the parts every app is assembled from, and the
7
+ workspace's colours. You design the surfaces: what is on them, how they
8
+ are laid out, how dense, how they read — never what colour anything is.
9
9
 
10
10
  ## 1. What you are inside
11
11
 
@@ -74,8 +74,8 @@ components/parts/ the parts: RecordSwitcher, LiveEmpty + LiveWork, useFixtu
74
74
  check/pins.json the fixed files by hash; `npm run check` refuses one that differs
75
75
  lib/* db, data, actions, claw, platform, hooks, sample (§5)
76
76
  check/ `npm run check` (§6)
77
- middleware.js product-token check, x-claw-surface and x-claw-theme headers
78
- next.config.mjs basePath + Server Actions allowedOrigins, both from env (§4b)
77
+ proxy.js product-token check, x-claw-surface and x-claw-theme headers (Next 16's name for middleware.js)
78
+ next.config.mjs basePath + Server Actions allowedOrigins (and the dev server's allowedDevOrigins), from env (§4b)
79
79
  postcss.config.mjs Tailwind v4
80
80
  schema.sql public.artifact_activity, for reference
81
81
  ```
@@ -610,7 +610,7 @@ action="/…">`. A real external URL (`https://`, `mailto:`) is fine.
610
610
 
611
611
  Already handled. Do not fix it in `next.config.mjs`.
612
612
 
613
- Next 15 refuses a Server Action whose `Origin` header disagrees with
613
+ Next refuses a Server Action whose `Origin` header disagrees with
614
614
  `x-forwarded-host`, and in this stack they always disagree: the sandbox
615
615
  ingress rewrites `x-forwarded-host` to its own vercel.run name while the
616
616
  browser is on the product host. Untreated, the app builds, loads and
@@ -620,7 +620,9 @@ button rather than a proxy policy.
620
620
  `next.config.mjs` already reads `CLAW_ALLOWED_ORIGINS`, which `product_run`
621
621
  derives per environment (`src/lib/products.ts`, `productAllowedOrigins`):
622
622
  the product's own FQDN in production, the dashboard's dev origin under a
623
- path prefix.
623
+ path prefix. The same hosts are the dev server's `allowedDevOrigins`:
624
+ Next 16's `next dev` refuses its own resources (the HMR socket, the error
625
+ overlay) to any other host, and says "Blocked cross-origin request".
624
626
 
625
627
  **Never hardcode an origin here.** `allowedOrigins: ["localhost:5173"]`
626
628
  passes every test you can run in development and refuses every write in
@@ -665,7 +667,7 @@ key of the request, from `x-claw-surface`, unless one is passed).
665
667
  Each quotes names with `ident()`, writes one activity row through
666
668
  `logActivity`, calls `revalidatePath("/", "layout")`, and resolves
667
669
  `{ ok }` or `{ ok: false, error }`. Never throws. `surface` defaults to
668
- the request's surface (middleware sets it from `/s/<key>`).
670
+ the request's surface (proxy.js sets it from `/s/<key>`).
669
671
 
670
672
  ```js
671
673
  updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data })
@@ -996,9 +998,9 @@ files has to). The platform agent reads them as console events. Call
996
998
  line per rule, under twenty lines, exit 1 on a failure that names the
997
999
  file and line and what to use instead. It builds for itself, into
998
1000
  `.next-check`, and only when a source changed since the last check —
999
- never run `npm run build` first, and never build into `.next`: the dev
1000
- server product_run started owns that folder as another user, and a
1001
- second build there dies with `EACCES: .next/trace`.
1001
+ never run `npm run build` first, and never build into `.next`: it is not
1002
+ the check's folder. In a working copy the preview's dev server owns it;
1003
+ in a live app it holds the build the platform made and serves.
1002
1004
 
1003
1005
  ```
1004
1006
  check: 5 files · 2 surfaces
@@ -1040,7 +1042,10 @@ surface; `--static` skips the build and the smoke. Then, by eye:
1040
1042
  `artifact_scaffold {dir}` runs it and makes `public.artifact_activity`
1041
1043
  in the workspace database). `makersclaw.json` records the version and
1042
1044
  the blocks; `makersclaw update` moves the fixed files and blocks to a
1043
- newer version and never touches your own files.
1045
+ newer version and never touches your own files. `pnpm-lock.yaml` is
1046
+ cut from the lock this version ships (by init, add and update), so
1047
+ every app on the version installs the same packages;
1048
+ `makersclaw install` installs from it. Never edit it by hand.
1044
1049
  2. `app_database {name, dir}` → `DATABASE_URL` in `.env`. The app's role
1045
1050
  reads and writes every table in `public`.
1046
1051
  3. Make the domain tables with the `sql` verb (they land in `public`).
@@ -4,8 +4,8 @@
4
4
  // before every refresh: only a 2xx from here means the refresh will get
5
5
  // its payload and not reload the frame (app/frame.js, answering, has the
6
6
  // story). It renders nothing and reads nothing, so the question costs the
7
- // app almost nothing; the middleware's token check still runs first, as
8
- // on every request.
7
+ // app almost nothing; proxy.js's token check still runs first, as on
8
+ // every request.
9
9
  //
10
10
  // A 204 and not the 404 of a path nobody serves: the browser logs every
11
11
  // 404 fetch as a console error, and an app that logs one every 5 s reads
@@ -142,9 +142,9 @@ export function nextRefreshIn(lastCostMs = 0) {
142
142
  }
143
143
 
144
144
  /** What to ask before a refresh: app/claw-alive/route.js, a fixed route
145
- * that answers 204 with no body — no render, no database. The middleware
146
- * checks the proxy's token first, as on every request, so a 204 says the
147
- * page's own request would be answered too.
145
+ * that answers 204 with no body — no render, no database. proxy.js
146
+ * checks the product proxy's token first, as on every request, so a 204
147
+ * says the page's own request would be answered too.
148
148
  *
149
149
  * Not a path the app merely 404s. The first draft of 0.3.3 asked for a
150
150
  * file under _next/static/ that no build writes and read the 404 as
@@ -172,8 +172,9 @@ export function probeUrl(doc) {
172
172
  *
173
173
  * Why ask first: router.refresh() treats any answer that is not its own
174
174
  * payload as "this is not my app any more" and loads the whole page
175
- * again (Next 15, fetch-server-response.js: a non-OK or non-RSC response
176
- * becomes a browser navigation). In the frame that reload wipes a
175
+ * again (fetch-server-response.js: a non-OK or non-RSC response becomes
176
+ * a browser navigation; Next 16's refresh reaches it through
177
+ * ppr-navigations.js and still does). In the frame that reload wipes a
177
178
  * half-typed field, and one 502 was enough (landing-page-reviewer,
178
179
  * okr-tracker, 2026-09-24). So the probe says yes exactly where the
179
180
  * refresh would not turn into a reload. Asking first leaves only the
@@ -20,8 +20,8 @@ export const metadata = { title: process.env.CLAW_PRODUCT || "Artifact" };
20
20
  // lib/claw.js, the product name, and data-theme. The theme follows the
21
21
  // desk: the OS window opens the app with ?theme=light|dark and the
22
22
  // product proxy repeats it as the x-claw-theme header on every request
23
- // (middleware.js explains); the claw_theme cookie covers a run without
24
- // the proxy. Dark when neither says — the desk is dark.
23
+ // (proxy.js explains); the claw_theme cookie covers a run without
24
+ // the product proxy. Dark when neither says — the desk is dark.
25
25
  //
26
26
  // The palette follows the WORKSPACE: <head> links `_claw/theme.css`, the
27
27
  // preset picked in System Settings → Appearance, served by the product