zyrk 0.2.3 → 0.3.5

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.
@@ -0,0 +1,260 @@
1
+ /** Read-only advisory reporting and verified source prefetch. */
2
+ import { readFile } from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { json as fetchJson } from "npm-registry-fetch";
5
+ import { satisfies, validRange } from "semver";
6
+ import { ZyrkError } from "../errors.ts";
7
+ import type { RegistryClient } from "../fetch/registry.ts";
8
+ import type { Store } from "../store/layout.ts";
9
+ import { installProject, type InstallResult } from "../install/run.ts";
10
+ import { readLockfile, assertFrozenCompatible } from "../lockfile/read.ts";
11
+ import {
12
+ manifestFingerprint,
13
+ configFingerprint,
14
+ scriptPolicyFingerprint,
15
+ } from "../lockfile/fingerprints.ts";
16
+ import { lockedGraph, lockMismatch } from "../install/lock.ts";
17
+
18
+ const severities = ["info", "low", "moderate", "high", "critical"] as const;
19
+ type Severity = (typeof severities)[number];
20
+ export type AuditResult = {
21
+ kind: "audit";
22
+ status: "complete" | "disabled" | "unavailable";
23
+ advisories: Array<{
24
+ name: string;
25
+ version: string;
26
+ severity: Severity;
27
+ title: string;
28
+ url: string;
29
+ }>;
30
+ thresholdExceeded: boolean;
31
+ warnings: string[];
32
+ };
33
+ function object(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+ function usage(reason: string): ZyrkError {
37
+ return new ZyrkError("ZYRK_USAGE", `Cannot run maintenance: ${reason}.`, {
38
+ remedy:
39
+ "Use audit [--audit-level <severity>] [--offline], or fetch --frozen-lockfile [--offline].",
40
+ });
41
+ }
42
+ export async function runMaintenanceCommand(options: {
43
+ command: "audit" | "fetch" | "repair";
44
+ args: readonly string[];
45
+ projectDir: string;
46
+ store: Store;
47
+ registry: RegistryClient;
48
+ }): Promise<InstallResult | AuditResult> {
49
+ let frozen = false,
50
+ offline = false;
51
+ let threshold: string | undefined;
52
+ for (let i = 0; i < options.args.length; i++) {
53
+ const arg = options.args[i]!;
54
+ if (arg === "--offline") offline = true;
55
+ else if (arg === "--frozen-lockfile" && options.command === "fetch")
56
+ frozen = true;
57
+ else if (arg === "--audit-level" && options.command === "audit") {
58
+ threshold = options.args[++i];
59
+ if (threshold === undefined)
60
+ throw usage("--audit-level requires a severity");
61
+ } else if (arg.startsWith("--audit-level=") && options.command === "audit")
62
+ threshold = arg.slice(14);
63
+ else throw usage("an unsupported option or argument was supplied");
64
+ }
65
+ if (options.command === "repair")
66
+ throw new ZyrkError(
67
+ "ZYRK_STORE_CORRUPT",
68
+ "Cannot repair shared content in place while other projects may reference it.",
69
+ {
70
+ remedy:
71
+ "Use store verify to identify affected sources; immutable replacement and reverse-closure repair are not available yet.",
72
+ },
73
+ );
74
+ if (options.command === "fetch") {
75
+ if (!frozen) throw usage("fetch requires --frozen-lockfile");
76
+ return installProject({
77
+ projectDir: options.projectDir,
78
+ store: options.store,
79
+ registry: options.registry,
80
+ frozen: true,
81
+ offline,
82
+ fetchOnly: true,
83
+ });
84
+ }
85
+ let manifest: unknown;
86
+ try {
87
+ manifest = JSON.parse(
88
+ await readFile(path.join(options.projectDir, "package.json"), "utf8"),
89
+ );
90
+ } catch {
91
+ throw new ZyrkError(
92
+ "ZYRK_MANIFEST_INVALID",
93
+ "Cannot audit dependencies without a readable package.json.",
94
+ { remedy: "Restore the project manifest and retry." },
95
+ );
96
+ }
97
+ if (!object(manifest)) throw usage("package.json must be an object");
98
+ const config = object(manifest.zyrk) ? manifest.zyrk.audit : undefined;
99
+ if (
100
+ config !== undefined &&
101
+ (!object(config) ||
102
+ Object.keys(config).some((key) => !["enabled", "failOn"].includes(key)) ||
103
+ (config.enabled !== undefined && typeof config.enabled !== "boolean") ||
104
+ (config.failOn !== undefined && typeof config.failOn !== "string"))
105
+ )
106
+ throw new ZyrkError(
107
+ "ZYRK_POLICY_DENIED",
108
+ "Cannot audit dependencies with unsupported audit policy settings.",
109
+ {
110
+ remedy:
111
+ "Set package.json zyrk.audit to enabled (boolean) and failOn (none, info, low, moderate, high, or critical).",
112
+ },
113
+ );
114
+ const policy = object(config) ? config : {};
115
+ threshold ??= typeof policy.failOn === "string" ? policy.failOn : "none";
116
+ if (threshold !== "none" && !severities.includes(threshold as Severity))
117
+ throw usage("audit severity threshold is invalid");
118
+ const base: AuditResult = {
119
+ kind: "audit",
120
+ status: "complete",
121
+ advisories: [],
122
+ thresholdExceeded: false,
123
+ warnings: [],
124
+ };
125
+ if (policy.enabled === false)
126
+ return {
127
+ ...base,
128
+ status: "disabled",
129
+ warnings: [
130
+ "Audit is disabled by project policy; no dependency information was sent.",
131
+ ],
132
+ };
133
+ if (offline)
134
+ return {
135
+ ...base,
136
+ status: "unavailable",
137
+ warnings: [
138
+ "Offline audit has no cached advisory data; vulnerability status is unknown.",
139
+ ],
140
+ };
141
+ const lock = await readLockfile(
142
+ path.join(options.projectDir, "package-lock.json"),
143
+ );
144
+ if (!lock?.doc.zyrk)
145
+ throw lockMismatch("audit requires an exact zyrk lockfile");
146
+ assertFrozenCompatible(lock.doc, {
147
+ manifest: manifestFingerprint(manifest),
148
+ scripts: scriptPolicyFingerprint(
149
+ object(manifest.zyrk) ? manifest.zyrk.scripts : undefined,
150
+ ),
151
+ config: configFingerprint({
152
+ registry: options.registry.origin,
153
+ linker: "shared",
154
+ }),
155
+ });
156
+ const graph = lockedGraph(lock.doc);
157
+ const packages: Record<string, string[]> = Object.create(null) as Record<
158
+ string,
159
+ string[]
160
+ >;
161
+ for (const node of graph.nodes) {
162
+ if (node.origin !== options.registry.origin)
163
+ throw new ZyrkError(
164
+ "ZYRK_POLICY_DENIED",
165
+ "Cannot audit a graph from a different registry origin.",
166
+ {
167
+ remedy:
168
+ "Select the registry recorded by the lockfile before sending dependency names.",
169
+ },
170
+ );
171
+ packages[node.name] ??= [];
172
+ if (!packages[node.name]!.includes(node.version))
173
+ packages[node.name]!.push(node.version);
174
+ }
175
+ if (!graph.nodes.length) return base;
176
+ let response: unknown;
177
+ try {
178
+ response = await fetchJson(
179
+ new URL("-/npm/v1/security/advisories/bulk", options.registry.registry)
180
+ .href,
181
+ {
182
+ method: "POST",
183
+ body: packages,
184
+ registry: options.registry.registry,
185
+ retry: false,
186
+ timeout: 15_000,
187
+ headers: { "content-type": "application/json" },
188
+ },
189
+ );
190
+ } catch {
191
+ return {
192
+ ...base,
193
+ status: "unavailable",
194
+ warnings: [
195
+ "The configured registry advisory service is unavailable; vulnerability status is unknown.",
196
+ ],
197
+ };
198
+ }
199
+ const unavailable = (): AuditResult => ({
200
+ ...base,
201
+ status: "unavailable",
202
+ warnings: [
203
+ "The configured advisory service returned unsupported data; vulnerability status is unknown.",
204
+ ],
205
+ });
206
+ if (!object(response)) return unavailable();
207
+ for (const [name, entries] of Object.entries(response)) {
208
+ if (!Object.hasOwn(packages, name) || !Array.isArray(entries))
209
+ return unavailable();
210
+ for (const entry of entries) {
211
+ if (
212
+ !object(entry) ||
213
+ typeof entry.severity !== "string" ||
214
+ !severities.includes(entry.severity as Severity) ||
215
+ typeof entry.vulnerable_versions !== "string" ||
216
+ validRange(entry.vulnerable_versions) === null ||
217
+ typeof entry.title !== "string" ||
218
+ typeof entry.url !== "string"
219
+ )
220
+ return unavailable();
221
+ let url: URL;
222
+ try {
223
+ url = new URL(entry.url);
224
+ } catch {
225
+ return unavailable();
226
+ }
227
+ if (
228
+ !["https:", "http:"].includes(url.protocol) ||
229
+ url.username ||
230
+ url.password
231
+ )
232
+ return unavailable();
233
+ for (const version of packages[name]!)
234
+ if (satisfies(version, entry.vulnerable_versions))
235
+ base.advisories.push({
236
+ name,
237
+ version,
238
+ severity: entry.severity as Severity,
239
+ title: entry.title
240
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
241
+ .slice(0, 500),
242
+ url: url.href,
243
+ });
244
+ }
245
+ }
246
+ base.advisories.sort(
247
+ (a, b) =>
248
+ a.name.localeCompare(b.name) ||
249
+ a.version.localeCompare(b.version) ||
250
+ a.title.localeCompare(b.title),
251
+ );
252
+ base.thresholdExceeded =
253
+ threshold !== "none" &&
254
+ base.advisories.some(
255
+ (advisory) =>
256
+ severities.indexOf(advisory.severity) >=
257
+ severities.indexOf(threshold as Severity),
258
+ );
259
+ return base;
260
+ }
@@ -0,0 +1,408 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { ZyrkError } from "../errors.ts";
4
+ import { lockedGraph } from "../install/lock.ts";
5
+ import type { NpmLockDoc } from "../lockfile/types.ts";
6
+ import { Store } from "../store/layout.ts";
7
+ import { readSourceManifest } from "../store/manifests.ts";
8
+ import { objectPathFor, verifyObjectPayload } from "../store/objects.ts";
9
+ import { isErrno, writeFileAtomic } from "../util/fsx.ts";
10
+ import { modeClassOf, sha512Hex } from "../util/hash.ts";
11
+ import { canonicalJson } from "../util/json.ts";
12
+ import { randomId } from "../util/ids.ts";
13
+ import { acquireLock } from "../util/locks.ts";
14
+
15
+ type Change = {
16
+ path: string;
17
+ before: string | null;
18
+ content: string | null;
19
+ executable: boolean;
20
+ };
21
+ export type ProjectPatch = {
22
+ schemaVersion: 1;
23
+ sourceId: string;
24
+ manifestHash: string;
25
+ changes: Change[];
26
+ };
27
+ export type LoadedProjectPatch = ProjectPatch & {
28
+ digest: string;
29
+ artifact: string;
30
+ };
31
+ type Manifest = {
32
+ zyrk?: { patches?: Record<string, string>; [key: string]: unknown };
33
+ [key: string]: unknown;
34
+ };
35
+ function fail(why: string): never {
36
+ throw new ZyrkError(
37
+ "ZYRK_MANIFEST_INVALID",
38
+ `Cannot process dependency patch: ${why}.`,
39
+ {
40
+ remedy:
41
+ "Use a patch editing area created by zyrk, keep changes inside the intended package, and reinstall after committing the patch.",
42
+ },
43
+ );
44
+ }
45
+ function relative(value: string): string {
46
+ if (
47
+ !value ||
48
+ value.includes("\\") ||
49
+ path.posix.isAbsolute(value) ||
50
+ value.split("/").some((part) => !part || part === "." || part === "..")
51
+ )
52
+ fail("unsafe package-relative path");
53
+ return value;
54
+ }
55
+ async function readJson(file: string): Promise<unknown> {
56
+ if (!(await fs.lstat(file)).isFile()) fail("metadata is not a regular file");
57
+ try {
58
+ return JSON.parse(await fs.readFile(file, "utf8"));
59
+ } catch {
60
+ return fail("invalid JSON metadata");
61
+ }
62
+ }
63
+ async function manifest(project: string): Promise<Manifest> {
64
+ return (await readJson(path.join(project, "package.json"))) as Manifest;
65
+ }
66
+ async function safeParent(root: string, relativePath: string, create: boolean) {
67
+ relative(relativePath);
68
+ let current = root;
69
+ if (!(await fs.lstat(root)).isDirectory())
70
+ fail("package root is not a real directory");
71
+ for (const segment of relativePath.split("/").slice(0, -1)) {
72
+ current = path.join(current, segment);
73
+ try {
74
+ if (!(await fs.lstat(current)).isDirectory())
75
+ fail("path crosses a link or file");
76
+ } catch (error) {
77
+ if (!create || !isErrno(error, "ENOENT")) throw error;
78
+ await fs.mkdir(current, { mode: 0o700 });
79
+ }
80
+ }
81
+ return path.join(root, ...relativePath.split("/"));
82
+ }
83
+ function scanSecrets(bytes: Buffer) {
84
+ const text = bytes.toString("utf8");
85
+ if (
86
+ /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\bnpm_[A-Za-z0-9]{20,}|\bgh[pousr]_[A-Za-z0-9]{20,}|https?:\/\/[^\s/@]+:[^\s/@]+@/.test(
87
+ text,
88
+ )
89
+ )
90
+ fail("patch contains credential-like content");
91
+ }
92
+
93
+ export async function loadProjectPatches(
94
+ project: string,
95
+ ): Promise<LoadedProjectPatch[]> {
96
+ const config = (await manifest(project)).zyrk?.patches;
97
+ if (config === undefined) return [];
98
+ if (!config || typeof config !== "object" || Array.isArray(config))
99
+ fail("zyrk.patches must map source identities to artifact paths");
100
+ const result: LoadedProjectPatch[] = [];
101
+ for (const sourceId of Object.keys(config).sort()) {
102
+ const artifact = config[sourceId];
103
+ if (typeof artifact !== "string" || !artifact.startsWith(".zyrk/patches/"))
104
+ fail("patch artifact must live in .zyrk/patches");
105
+ const file = await safeParent(project, artifact, false);
106
+ const raw = (await readJson(file)) as ProjectPatch;
107
+ if (
108
+ !raw ||
109
+ raw.schemaVersion !== 1 ||
110
+ raw.sourceId !== sourceId ||
111
+ typeof raw.manifestHash !== "string" ||
112
+ !/^[0-9a-f]{128}$/.test(raw.manifestHash) ||
113
+ !Array.isArray(raw.changes)
114
+ )
115
+ fail("patch source binding is invalid");
116
+ const seen = new Set<string>();
117
+ for (const change of raw.changes) {
118
+ if (!change || typeof change.path !== "string") fail("invalid change");
119
+ relative(change.path);
120
+ if (seen.has(change.path)) fail("duplicate change");
121
+ seen.add(change.path);
122
+ if (
123
+ change.before !== null &&
124
+ (typeof change.before !== "string" ||
125
+ !/^[0-9a-f]{128}$/.test(change.before))
126
+ )
127
+ fail("invalid original file digest");
128
+ if (
129
+ typeof change.executable !== "boolean" ||
130
+ (change.content !== null &&
131
+ (typeof change.content !== "string" ||
132
+ Buffer.from(change.content, "base64").toString("base64") !==
133
+ change.content))
134
+ )
135
+ fail("invalid file delta");
136
+ if (change.content !== null)
137
+ scanSecrets(Buffer.from(change.content, "base64"));
138
+ }
139
+ result.push({ ...raw, artifact, digest: sha512Hex(canonicalJson(raw)) });
140
+ }
141
+ return result;
142
+ }
143
+ export async function hasProjectPatches(project: string): Promise<boolean> {
144
+ return (await loadProjectPatches(project)).length > 0;
145
+ }
146
+
147
+ /** Caller supplies the complete private generation, copied/reflinked without store hardlinks. */
148
+ export async function applyProjectPatches(options: {
149
+ project: string;
150
+ packages: ReadonlyMap<string, { sourceId: string; directory: string }>;
151
+ }): Promise<{ digest: string; count: number }> {
152
+ const project = await fs.realpath(options.project);
153
+ const patches = await loadProjectPatches(project);
154
+ let count = 0;
155
+ for (const patch of patches) {
156
+ const targets = [...options.packages.values()].filter(
157
+ (pkg) => pkg.sourceId === patch.sourceId,
158
+ );
159
+ if (!targets.length)
160
+ fail("patched source is absent from the selected graph");
161
+ for (const pkg of targets) {
162
+ const root = await fs.realpath(pkg.directory);
163
+ if (
164
+ !root.startsWith(`${project}${path.sep}`) ||
165
+ !root.startsWith(path.join(project, ".zyrk") + path.sep)
166
+ )
167
+ fail("patch destination is outside the private generation");
168
+ for (const change of patch.changes) {
169
+ const target = await safeParent(root, change.path, true);
170
+ let before: Buffer | null = null;
171
+ try {
172
+ const stat = await fs.lstat(target);
173
+ if (!stat.isFile() || stat.nlink > 1)
174
+ fail("patch target is a link or shares an inode");
175
+ before = await fs.readFile(target);
176
+ } catch (error) {
177
+ if (!isErrno(error, "ENOENT")) throw error;
178
+ }
179
+ if ((before === null ? null : sha512Hex(before)) !== change.before)
180
+ fail("original file digest differs from patch");
181
+ if (change.content === null) {
182
+ if (before !== null) await fs.unlink(target);
183
+ } else
184
+ await writeFileAtomic(target, Buffer.from(change.content, "base64"), {
185
+ mode: change.executable ? 0o755 : 0o644,
186
+ });
187
+ }
188
+ count++;
189
+ }
190
+ }
191
+ return {
192
+ digest: sha512Hex(
193
+ canonicalJson(
194
+ patches.map(({ sourceId, digest }) => ({ sourceId, digest })),
195
+ ),
196
+ ),
197
+ count,
198
+ };
199
+ }
200
+
201
+ export async function runPatchCommand(options: {
202
+ command: "patch" | "patch-commit";
203
+ args: readonly string[];
204
+ store: Store;
205
+ cwd: string;
206
+ }): Promise<unknown> {
207
+ if (options.args.length !== 1 || !options.args[0])
208
+ fail("expected one package selector or editing-area path");
209
+ const project = await fs.realpath(options.cwd);
210
+ const control = path.join(project, ".zyrk");
211
+ try {
212
+ if (!(await fs.lstat(control)).isDirectory())
213
+ fail("project control directory is not a real directory");
214
+ } catch (error) {
215
+ if (!isErrno(error, "ENOENT")) throw error;
216
+ await fs.mkdir(control, { mode: 0o700 });
217
+ }
218
+ const held = await acquireLock(path.join(control, "project.lock"), {
219
+ name: "project patch",
220
+ });
221
+ try {
222
+ return options.command === "patch"
223
+ ? await createEditingArea(project, options.args[0], options.store)
224
+ : await commitPatch(project, options.args[0], options.store);
225
+ } finally {
226
+ await held.release();
227
+ }
228
+ }
229
+ async function createEditingArea(
230
+ project: string,
231
+ selector: string,
232
+ store: Store,
233
+ ) {
234
+ const graph = lockedGraph(
235
+ (await readJson(path.join(project, "package-lock.json"))) as NpmLockDoc,
236
+ );
237
+ const matches = graph.nodes.filter(
238
+ (node) =>
239
+ node.name === selector ||
240
+ `${node.name}@${node.version}` === selector ||
241
+ node.key === selector,
242
+ );
243
+ const ids = [...new Set(matches.map((node) => node.sourceId))];
244
+ if (ids.length !== 1 || !ids[0])
245
+ fail("selector must identify exactly one installed source");
246
+ const sourceId = ids[0];
247
+ const doc = await readSourceManifest(store, sourceId);
248
+ const base = path.join(project, ".zyrk", "patch-work");
249
+ await safeParent(project, ".zyrk/patch-work/placeholder", true);
250
+ const editingArea = path.join(base, randomId("edit1_"));
251
+ await fs.mkdir(editingArea, { mode: 0o700 });
252
+ const packageRoot = path.join(editingArea, "package");
253
+ await fs.mkdir(packageRoot, { mode: 0o700 });
254
+ for (const entry of doc.manifest.entries) {
255
+ const target = await safeParent(packageRoot, entry.path, true);
256
+ if (entry.type === "dir")
257
+ await fs.mkdir(target, { recursive: true, mode: 0o755 });
258
+ else if (entry.type === "symlink") await fs.symlink(entry.target, target);
259
+ else {
260
+ const object = objectPathFor(store, entry.objectId);
261
+ if (!(await fs.lstat(object)).isFile())
262
+ fail("source object is not a regular file");
263
+ const verified = await verifyObjectPayload(store, {
264
+ objectId: entry.objectId,
265
+ innerDigestHex: entry.innerDigest,
266
+ modeClass: entry.mode,
267
+ bytes: entry.size,
268
+ });
269
+ if (!verified.ok) fail("source payload verification failed");
270
+ await fs.copyFile(object, target);
271
+ await fs.chmod(target, entry.mode === "exec" ? 0o755 : 0o644);
272
+ }
273
+ }
274
+ await writeFileAtomic(
275
+ path.join(editingArea, "edit.json"),
276
+ JSON.stringify({
277
+ schemaVersion: 1,
278
+ sourceId,
279
+ manifestHash: doc.manifestHash,
280
+ }),
281
+ { mode: 0o600 },
282
+ );
283
+ return {
284
+ editingArea: packageRoot,
285
+ sourceId,
286
+ next: "Edit these private files, then run zyrk patch-commit <editing-area>.",
287
+ };
288
+ }
289
+ async function commitPatch(project: string, argument: string, store: Store) {
290
+ let area = await fs.realpath(path.resolve(project, argument));
291
+ if (path.basename(area) === "package") area = path.dirname(area);
292
+ const base = path.join(project, ".zyrk", "patch-work");
293
+ if (path.dirname(area) !== base) fail("editing area is outside this project");
294
+ const info = (await readJson(path.join(area, "edit.json"))) as {
295
+ sourceId: string;
296
+ manifestHash: string;
297
+ };
298
+ if (
299
+ !info ||
300
+ typeof info.sourceId !== "string" ||
301
+ !/^[a-z0-9_]+$/.test(info.sourceId)
302
+ )
303
+ fail("invalid editing source identity");
304
+ const original = await readSourceManifest(store, info.sourceId);
305
+ if (original.manifestHash !== info.manifestHash)
306
+ fail("source manifest changed");
307
+ const edited = new Map<string, { bytes: Buffer; executable: boolean }>();
308
+ const originalEntries = new Map(
309
+ original.manifest.entries.map((entry) => [entry.path, entry]),
310
+ );
311
+ const walk = async (directory: string, prefix: string): Promise<void> => {
312
+ if (!(await fs.lstat(directory)).isDirectory())
313
+ fail("editing root is not a real directory");
314
+ for (const name of (await fs.readdir(directory)).sort()) {
315
+ const rel = prefix ? `${prefix}/${name}` : name;
316
+ const full = path.join(directory, name);
317
+ const stat = await fs.lstat(full);
318
+ if (stat.isDirectory()) {
319
+ await walk(full, rel);
320
+ continue;
321
+ }
322
+ if (stat.isSymbolicLink()) {
323
+ const old = originalEntries.get(rel);
324
+ if (
325
+ !old ||
326
+ old.type !== "symlink" ||
327
+ (await fs.readlink(full)) !== old.target
328
+ )
329
+ fail("symlink edits are unsupported");
330
+ continue;
331
+ }
332
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024)
333
+ fail("unsupported or oversized edited file");
334
+ const bytes = await fs.readFile(full);
335
+ scanSecrets(bytes);
336
+ edited.set(relative(rel), {
337
+ bytes,
338
+ executable: modeClassOf(stat.mode) === "exec",
339
+ });
340
+ }
341
+ };
342
+ await walk(path.join(area, "package"), "");
343
+ const changes: Change[] = [];
344
+ for (const [name, old] of originalEntries) {
345
+ if (old.type === "symlink") {
346
+ try {
347
+ if (
348
+ !(
349
+ await fs.lstat(path.join(area, "package", ...name.split("/")))
350
+ ).isSymbolicLink()
351
+ )
352
+ fail("symlink edits are unsupported");
353
+ } catch {
354
+ fail("symlink edits are unsupported");
355
+ }
356
+ }
357
+ }
358
+ const files = new Set([
359
+ ...original.manifest.entries
360
+ .filter((entry) => entry.type === "file")
361
+ .map((entry) => entry.path),
362
+ ...edited.keys(),
363
+ ]);
364
+ for (const name of [...files].sort()) {
365
+ const old = originalEntries.get(name);
366
+ if (old && old.type !== "file") fail("entry type changes are unsupported");
367
+ const next = edited.get(name);
368
+ const digest = next ? sha512Hex(next.bytes) : null;
369
+ if (
370
+ old?.innerDigest === digest &&
371
+ (old.mode === "exec") === next?.executable
372
+ )
373
+ continue;
374
+ changes.push({
375
+ path: name,
376
+ before: old?.innerDigest ?? null,
377
+ content: next?.bytes.toString("base64") ?? null,
378
+ executable: next?.executable ?? false,
379
+ });
380
+ }
381
+ const patch: ProjectPatch = {
382
+ schemaVersion: 1,
383
+ sourceId: info.sourceId,
384
+ manifestHash: info.manifestHash,
385
+ changes,
386
+ };
387
+ const digest = sha512Hex(canonicalJson(patch));
388
+ const artifact = `.zyrk/patches/${digest}.json`;
389
+ const file = await safeParent(project, artifact, true);
390
+ await writeFileAtomic(file, `${canonicalJson(patch)}\n`, { mode: 0o644 });
391
+ const config = await manifest(project);
392
+ config.zyrk = {
393
+ ...config.zyrk,
394
+ patches: { ...config.zyrk?.patches, [info.sourceId]: artifact },
395
+ };
396
+ await writeFileAtomic(
397
+ path.join(project, "package.json"),
398
+ `${JSON.stringify(config, null, 2)}\n`,
399
+ { mode: 0o644 },
400
+ );
401
+ return {
402
+ artifact,
403
+ digest,
404
+ sourceId: info.sourceId,
405
+ changedFiles: changes.length,
406
+ next: "Commit the artifact and package.json, then reinstall to apply it to a private project generation.",
407
+ };
408
+ }