makersclaw 0.5.1 → 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.
- package/README.md +29 -3
- package/package.json +3 -2
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/contact-section.json +2 -2
- package/r/csv-import.json +3 -3
- package/r/data-table.json +2 -2
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +2 -2
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +2 -2
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/src/cli.mjs +35 -6
- package/src/install.mjs +101 -4
- package/src/lockfile.mjs +468 -0
- package/src/pm.mjs +473 -0
- package/src/registry.mjs +48 -1
- package/templates/next/files/README.md +20 -15
- package/templates/next/files/app/claw-alive/route.js +2 -2
- package/templates/next/files/app/frame.js +6 -5
- package/templates/next/files/app/layout.jsx +2 -2
- package/templates/next/files/check/run.mjs +125 -15
- package/templates/next/files/lib/actions.js +1 -1
- package/templates/next/files/lib/data.js +1 -1
- package/templates/next/files/next.config.mjs +50 -8
- package/templates/next/files/package.json +3 -3
- package/templates/next/files/{middleware.js → proxy.js} +13 -6
- package/templates/next/pnpm-lock.yaml +5870 -0
- 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
|
-
|
|
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
|
|
4
|
-
actions), Postgres via `DATABASE_URL`, Tailwind v4, and a kit
|
|
5
|
-
(`components/ui`, `components/parts`). It carries the behaviour
|
|
6
|
-
app shares, the parts every app is assembled from, and the
|
|
7
|
-
colours. You design the surfaces: what is on them, how they
|
|
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
|
-
|
|
78
|
-
next.config.mjs basePath + Server Actions allowedOrigins,
|
|
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
|
|
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 (
|
|
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`:
|
|
1000
|
-
|
|
1001
|
-
|
|
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;
|
|
8
|
-
//
|
|
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.
|
|
146
|
-
* checks the proxy's token first, as on every request, so a 204
|
|
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 (
|
|
176
|
-
*
|
|
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
|
-
// (
|
|
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
|