zyrk 0.2.3 → 0.3.7

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.
@@ -39,6 +39,11 @@ export type ZyrkLockNode = {
39
39
  origin: string;
40
40
  /** Canonical source identity. */
41
41
  source: string;
42
+ /** Exact archive and execution metadata for network-free reinstall. */
43
+ tarball?: string;
44
+ bin?: Record<string, string>;
45
+ platform?: { os?: string[]; cpu?: string[]; libc?: string[] };
46
+ engines?: Record<string, string>;
42
47
  /** Dependency edge name -> occurrence key (alias-aware). */
43
48
  edges: Record<string, string>;
44
49
  /** Edge name -> prod | optional | peer. */
@@ -34,6 +34,7 @@ export type WriteLockOptions = {
34
34
  version: string;
35
35
  /** Declared direct dependencies for npm's root `dependencies` map. */
36
36
  manifestDeps: Record<string, string>;
37
+ manifestDevDeps?: Record<string, string>;
37
38
  existing?: NpmLockDoc | null;
38
39
  resolution: WriteLockResolution;
39
40
  fingerprints: { manifest: string; scripts: string; config: string };
@@ -85,6 +86,9 @@ export async function writeLockfile(
85
86
  name: options.name,
86
87
  version: options.version,
87
88
  dependencies: { ...options.manifestDeps },
89
+ ...(options.manifestDevDeps === undefined
90
+ ? {}
91
+ : { devDependencies: options.manifestDevDeps }),
88
92
  };
89
93
  for (const root of [...options.resolution.roots].sort((a, b) =>
90
94
  compareStrings(a.name, b.name),
@@ -107,6 +111,59 @@ export async function writeLockfile(
107
111
  integrity: node.integrity,
108
112
  };
109
113
  }
114
+ const populate = (
115
+ location: string,
116
+ key: string,
117
+ ancestors: Map<string, string>,
118
+ ): void => {
119
+ const node = byKey.get(key);
120
+ if (!node)
121
+ throw new ZyrkError(
122
+ "ZYRK_INTERNAL",
123
+ "Cannot serialize a missing dependency node.",
124
+ );
125
+ const dependencies: Record<string, string> = {};
126
+ for (const edge of node.edges) {
127
+ const child = byKey.get(edge.target);
128
+ if (!child)
129
+ throw new ZyrkError(
130
+ "ZYRK_INTERNAL",
131
+ "Cannot serialize a missing dependency edge.",
132
+ );
133
+ dependencies[edge.name] =
134
+ child.name === edge.name
135
+ ? child.version
136
+ : `npm:${child.name}@${child.version}`;
137
+ }
138
+ packages[location] = {
139
+ ...packages[location],
140
+ name: node.name,
141
+ version: node.version,
142
+ resolved: node.tarballUrl,
143
+ integrity: node.integrity,
144
+ dependencies,
145
+ ...(Object.keys(node.bin).length ? { bin: node.bin } : {}),
146
+ };
147
+ const next = new Map(ancestors);
148
+ next.set(key, location);
149
+ for (const edge of node.edges) {
150
+ // An ancestor with this package slot satisfies a real cycle.
151
+ const ancestor = next.get(edge.target);
152
+ if (ancestor && ancestor.endsWith(`node_modules/${edge.name}`)) continue;
153
+ if (next.has(edge.target))
154
+ throw new ZyrkError(
155
+ "ZYRK_RESOLUTION_FAILED",
156
+ "Cannot encode an aliased dependency cycle in npm lock format.",
157
+ {
158
+ remedy:
159
+ "Use non-cyclic registry dependencies until alias-cycle lock support is available.",
160
+ },
161
+ );
162
+ populate(`${location}/node_modules/${edge.name}`, edge.target, next);
163
+ }
164
+ };
165
+ for (const root of options.resolution.roots)
166
+ populate(`node_modules/${root.name}`, root.target, new Map());
110
167
  const sortedPackages: Record<string, Record<string, unknown>> = {};
111
168
  for (const location of Object.keys(packages).sort()) {
112
169
  const entry = packages[location];
@@ -137,6 +194,10 @@ export async function writeLockfile(
137
194
  integrity: node.integrity,
138
195
  origin: node.origin,
139
196
  source: node.sourceId,
197
+ tarball: node.tarballUrl,
198
+ bin: node.bin,
199
+ platform: node.platform,
200
+ ...(node.engines === undefined ? {} : { engines: node.engines }),
140
201
  edges,
141
202
  edgeKinds,
142
203
  peers,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  open,
3
+ lstat,
3
4
  readFile,
4
5
  readdir,
5
6
  rename,
@@ -7,6 +8,7 @@ import {
7
8
  symlink as fsSymlink,
8
9
  } from "node:fs/promises";
9
10
  import * as path from "node:path";
11
+ import { ZyrkError } from "../errors.ts";
10
12
  import { acquireLock } from "../util/locks.ts";
11
13
  import {
12
14
  ensureDirWithMode,
@@ -50,6 +52,12 @@ type ProjectionEvent = {
50
52
  at: string;
51
53
  event: "staged" | "old-moved" | "published" | "done";
52
54
  generationId: string;
55
+ metadataOnly?: boolean;
56
+ files?: Array<{
57
+ name: "package.json" | "package-lock.json";
58
+ before: string | null;
59
+ after: string;
60
+ }>;
53
61
  };
54
62
 
55
63
  function journalPath(projectDir: string): string {
@@ -87,9 +95,27 @@ async function readEvents(projectDir: string): Promise<ProjectionEvent[]> {
87
95
  for (const line of text.split("\n")) {
88
96
  if (line === "") continue;
89
97
  try {
90
- events.push(JSON.parse(line) as ProjectionEvent);
91
- } catch {
92
- // A torn final line after a crash is expected; earlier lines stand.
98
+ const event = JSON.parse(line) as ProjectionEvent;
99
+ if (!/^gen1_[a-zA-Z0-9_-]+$/.test(event.generationId))
100
+ throw new Error("invalid generation");
101
+ if (
102
+ event.files?.some(
103
+ (file) =>
104
+ !["package.json", "package-lock.json"].includes(file.name) ||
105
+ (file.before !== null && typeof file.before !== "string") ||
106
+ typeof file.after !== "string",
107
+ )
108
+ )
109
+ throw new Error("invalid files");
110
+ events.push(event);
111
+ } catch (error) {
112
+ if (!(error instanceof SyntaxError))
113
+ throw new ZyrkError(
114
+ "ZYRK_STORE_CORRUPT",
115
+ "Project recovery journal is malformed.",
116
+ { remedy: "Restore the local journal before retrying installation." },
117
+ );
118
+ // A torn final append may be ignored; durable earlier events remain.
93
119
  }
94
120
  }
95
121
  return events;
@@ -133,6 +159,37 @@ export async function recoverProjection(projectDir: string): Promise<void> {
133
159
  await rmrfNoFollow(previous);
134
160
  }
135
161
  }
162
+ const staged = events.find(
163
+ (event) =>
164
+ event.generationId === last.generationId && event.files !== undefined,
165
+ );
166
+ if (staged?.files) {
167
+ let active: unknown;
168
+ try {
169
+ active = JSON.parse(
170
+ await readFile(
171
+ path.join(nodeModules, ".zyrk", "install-state.json"),
172
+ "utf8",
173
+ ),
174
+ );
175
+ } catch {
176
+ active = null;
177
+ }
178
+ const published =
179
+ staged.metadataOnly === true
180
+ ? last.event === "done"
181
+ : (active as { generationId?: unknown } | null)?.generationId ===
182
+ last.generationId;
183
+ for (const file of staged.files) {
184
+ const contents = published ? file.after : file.before;
185
+ if (contents === null)
186
+ await rm(path.join(projectDir, file.name), { force: true });
187
+ else
188
+ await writeFileAtomic(path.join(projectDir, file.name), contents, {
189
+ mode: 0o644,
190
+ });
191
+ }
192
+ }
136
193
  await rm(journalPath(projectDir), { force: true });
137
194
  }
138
195
 
@@ -143,15 +200,26 @@ export async function recoverProjection(projectDir: string): Promise<void> {
143
200
  export async function installProjection(
144
201
  projectDir: string,
145
202
  entries: ProjectionEntry[],
146
- options: { projectId?: string } = {},
203
+ options: {
204
+ projectId?: string;
205
+ generationId?: string;
206
+ lockHeld?: boolean;
207
+ bins?: Record<string, string>;
208
+ files?: Array<{
209
+ name: "package.json" | "package-lock.json";
210
+ contents: string;
211
+ }>;
212
+ readiness?: "ready" | "intentionally-unbuilt";
213
+ metadata?: Record<string, unknown>;
214
+ } = {},
147
215
  ): Promise<ProjectionResult> {
148
- const lock = await acquireLock(lockPath(projectDir), {
149
- name: "project mutation",
150
- });
216
+ const lock = options.lockHeld
217
+ ? undefined
218
+ : await acquireLock(lockPath(projectDir), { name: "project mutation" });
151
219
  try {
152
220
  await recoverProjection(projectDir);
153
221
 
154
- const generationId = `gen1_${randomId("", 12)}`;
222
+ const generationId = options.generationId ?? `gen1_${randomId("", 12)}`;
155
223
  const staging = path.join(
156
224
  projectDir,
157
225
  `node_modules.zyrk-staging-${generationId}`,
@@ -164,11 +232,51 @@ export async function installProjection(
164
232
 
165
233
  await ensureDirWithMode(staging, 0o755);
166
234
  for (const entry of entries) {
235
+ if (
236
+ !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(
237
+ entry.name,
238
+ ) ||
239
+ entry.name === "node_modules"
240
+ )
241
+ throw new ZyrkError(
242
+ "ZYRK_MANIFEST_INVALID",
243
+ "Unsafe projection dependency name.",
244
+ { remedy: "Use a valid registry package name." },
245
+ );
167
246
  const target = path.join(staging, ...entry.name.split("/"));
168
247
  await mkdirp(path.dirname(target));
169
248
  await fsSymlink(entry.linkTarget, target);
170
249
  }
171
250
 
251
+ if (Object.keys(options.bins ?? {}).length) {
252
+ await mkdirp(path.join(staging, ".bin"));
253
+ for (const [name, target] of Object.entries(options.bins ?? {})) {
254
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name))
255
+ throw new ZyrkError("ZYRK_MANIFEST_INVALID", "Unsafe binary name.", {
256
+ remedy: "Use a package with safe bin entries.",
257
+ });
258
+ const stat = await lstat(target);
259
+ if ((stat.mode & 0o111) !== 0)
260
+ await fsSymlink(target, path.join(staging, ".bin", name));
261
+ else {
262
+ const quote = (value: string): string =>
263
+ "'" + value.replaceAll("'", "'\\''") + "'";
264
+ const firstLine =
265
+ (await readFile(target, "utf8")).split("\n")[0] ?? "";
266
+ if (!firstLine.includes("node") && !/\.(?:[cm]?js|ts)$/.test(target))
267
+ throw new ZyrkError(
268
+ "ZYRK_MANIFEST_INVALID",
269
+ "Non-executable dependency bin has no Node entry point.",
270
+ { remedy: "Select a package shipping executable bin files." },
271
+ );
272
+ await writeFileAtomic(
273
+ path.join(staging, ".bin", name),
274
+ `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(target)} "$@"\n`,
275
+ { mode: 0o755 },
276
+ );
277
+ }
278
+ }
279
+ }
172
280
  await ensureDirWithMode(path.join(staging, ".zyrk"), 0o755);
173
281
  const state = {
174
282
  schemaVersion: 1,
@@ -178,7 +286,8 @@ export async function installProjection(
178
286
  : { projectId: options.projectId }),
179
287
  linker: "shared",
180
288
  createdAt: new Date().toISOString(),
181
- readiness: "ready",
289
+ readiness: options.readiness ?? "ready",
290
+ ...options.metadata,
182
291
  direct: entries.map((entry) => ({
183
292
  name: entry.name,
184
293
  target: entry.linkTarget,
@@ -191,11 +300,26 @@ export async function installProjection(
191
300
  mode: 0o644,
192
301
  },
193
302
  );
303
+ const files = [];
304
+ for (const file of options.files ?? []) {
305
+ let before: string | null = null;
306
+ try {
307
+ before = await readFile(path.join(projectDir, file.name), "utf8");
308
+ } catch (error) {
309
+ if (!isErrno(error, "ENOENT")) throw error;
310
+ }
311
+ files.push({ name: file.name, before, after: file.contents });
312
+ }
194
313
  await appendEvent(projectDir, {
195
314
  at: new Date().toISOString(),
196
315
  event: "staged",
197
316
  generationId,
317
+ files,
198
318
  });
319
+ for (const file of files)
320
+ await writeFileAtomic(path.join(projectDir, file.name), file.after, {
321
+ mode: 0o644,
322
+ });
199
323
 
200
324
  const hadPrevious = await pathExists(nodeModules);
201
325
  if (hadPrevious) {
@@ -223,6 +347,44 @@ export async function installProjection(
223
347
 
224
348
  return { nodeModulesDir: nodeModules, generationId };
225
349
  } finally {
226
- await lock.release();
350
+ await lock?.release();
227
351
  }
228
352
  }
353
+
354
+ /** Journal metadata-only changes while the caller holds the project lock. */
355
+ export async function installMetadata(
356
+ projectDir: string,
357
+ files: Array<{
358
+ name: "package.json" | "package-lock.json";
359
+ contents: string;
360
+ }>,
361
+ ): Promise<void> {
362
+ const generationId = `gen1_${randomId("", 12)}`;
363
+ const snapshots = [];
364
+ for (const file of files) {
365
+ let before: string | null = null;
366
+ try {
367
+ before = await readFile(path.join(projectDir, file.name), "utf8");
368
+ } catch (error) {
369
+ if (!isErrno(error, "ENOENT")) throw error;
370
+ }
371
+ snapshots.push({ name: file.name, before, after: file.contents });
372
+ }
373
+ await appendEvent(projectDir, {
374
+ at: new Date().toISOString(),
375
+ event: "staged",
376
+ generationId,
377
+ metadataOnly: true,
378
+ files: snapshots,
379
+ });
380
+ for (const file of snapshots)
381
+ await writeFileAtomic(path.join(projectDir, file.name), file.after, {
382
+ mode: 0o644,
383
+ });
384
+ await appendEvent(projectDir, {
385
+ at: new Date().toISOString(),
386
+ event: "done",
387
+ generationId,
388
+ });
389
+ await rm(journalPath(projectDir), { force: true });
390
+ }