zyrk 0.4.8 → 0.4.9

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 CHANGED
@@ -141,8 +141,11 @@ zyrk add package-name # edit this importer, preserve other select
141
141
 
142
142
  Frozen/offline installs, live peers, patches, mutable registry packages, inspection,
143
143
  and repair have workspace fixtures. Builds that reach live workspace sources are
144
- blocked before execution. Workspace npm-lock adoption and portable workspace
145
- export still need adapters and fail explicitly. Recursive execution supports
144
+ blocked before execution. Existing npm v2/v3 workspace `package-lock.json` selections can be adopted with
145
+ `zyrk import --from npm`, preserving exact versions, aliases and live members.
146
+ Adoption validates the complete graph before project changes; npm shrinkwrap
147
+ precedence and unsupported bundled/external links fail explicitly. Portable
148
+ workspace export still needs its adapter. Recursive execution supports
146
149
  member scripts; filters are exact names or paths, not arbitrary glob expressions.
147
150
 
148
151
  ### Run a one-off tool
package/VERSION CHANGED
@@ -1 +1 @@
1
- v0.4.8
1
+ v0.4.9
@@ -0,0 +1,5 @@
1
+ import type { NpmLockDoc } from "../lockfile/types.ts";
2
+ import type { WorkspaceProject } from "./workspaces.ts";
3
+ import type { WorkspaceGraph } from "./workspace-graph.ts";
4
+ export declare function importNpmWorkspaceLock(doc: NpmLockDoc, project: WorkspaceProject, origin: string): WorkspaceGraph;
5
+ //# sourceMappingURL=import-npm-workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import-npm-workspace.d.ts","sourceRoot":"","sources":["../../src/install/import-npm-workspace.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAQvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EACV,cAAc,EAGf,MAAM,sBAAsB,CAAC;AAE9B,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,UAAU,EACf,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,MAAM,GACb,cAAc,CAyThB"}
@@ -0,0 +1,283 @@
1
+ /** Translate npm's physical virtual tree without selecting new registry versions. */
2
+ import * as path from "node:path";
3
+ import { satisfies } from "semver";
4
+ import { validateLockDoc } from "../lockfile/read.js";
5
+ import { sha512Hex } from "../util/hash.js";
6
+ import { canonicalJson } from "../util/json.js";
7
+ import { importNpmLock } from "./import-npm.js";
8
+ import { lockMismatch, cleanUrl } from "./lock.js";
9
+ import { ZyrkError } from "../errors.js";
10
+ import { assertWorkspacePlatform } from "./workspace-graph.js";
11
+ export function importNpmWorkspaceLock(doc, project, origin) {
12
+ if (!doc ||
13
+ typeof doc !== "object" ||
14
+ Array.isArray(doc) ||
15
+ !doc.packages ||
16
+ typeof doc.packages !== "object" ||
17
+ Array.isArray(doc.packages) ||
18
+ Object.values(doc.packages).some((e) => !e || typeof e !== "object" || Array.isArray(e)))
19
+ throw lockMismatch("npm workspace lock has malformed package entries");
20
+ const portable = structuredClone(doc);
21
+ // Relative file declarations are representable only when they name a
22
+ // discovered live importer. Validate their targets before URL validation.
23
+ const importerIds = new Set(project.importers.map((i) => (i.id === "." ? "" : i.id)));
24
+ for (const [location, entry] of Object.entries(portable.packages ?? {}))
25
+ for (const group of [
26
+ "dependencies",
27
+ "devDependencies",
28
+ "optionalDependencies",
29
+ "peerDependencies",
30
+ ])
31
+ if (entry[group] && typeof entry[group] === "object")
32
+ for (const [name, spec] of Object.entries(entry[group]))
33
+ if (typeof spec === "string" && spec.startsWith("file:")) {
34
+ const relative = spec.slice(5);
35
+ if (!importerIds.has(location) ||
36
+ relative.startsWith("/") ||
37
+ relative.includes("\\") ||
38
+ /^[A-Za-z]:/.test(relative) ||
39
+ !importerIds.has(path.posix.normalize(path.posix.join(location, relative))))
40
+ throw lockMismatch("npm file declaration is not a declared workspace");
41
+ entry[group][name] = "workspace:*";
42
+ }
43
+ // npm v2 also has a redundant legacy dependency view. Do not adopt a
44
+ // second authority; it is still scanned for unsafe metadata.
45
+ const sanitizeLegacyLinks = (value) => {
46
+ if (typeof value === "string" &&
47
+ value.startsWith("file:") &&
48
+ importerIds.has(value.slice(5)))
49
+ return "workspace:*";
50
+ if (Array.isArray(value))
51
+ return value.map(sanitizeLegacyLinks);
52
+ if (value && typeof value === "object")
53
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [
54
+ key,
55
+ sanitizeLegacyLinks(child),
56
+ ]));
57
+ return value;
58
+ };
59
+ if (portable.dependencies)
60
+ portable.dependencies = sanitizeLegacyLinks(portable.dependencies);
61
+ validateLockDoc(portable, "package-lock.json");
62
+ if (doc.zyrk ||
63
+ ![2, 3].includes(doc.lockfileVersion ?? 0) ||
64
+ !doc.packages ||
65
+ !cleanUrl(origin))
66
+ throw lockMismatch("workspace adoption needs an unannotated npm v2/v3 lock");
67
+ const packages = doc.packages;
68
+ const graph = { nodes: [], importers: {}, omissions: [] };
69
+ const locations = new Map();
70
+ const local = new Map(project.importers.map((i) => [i.id === "." ? "" : i.id, i]));
71
+ for (const [location, entry] of Object.entries(packages)) {
72
+ if (location.includes("\\") ||
73
+ location.startsWith("/") ||
74
+ location.split("/").some((s) => s === ".." || s === ".") ||
75
+ location.includes("\0"))
76
+ throw lockMismatch("unsafe npm package location");
77
+ if (entry.link === true &&
78
+ (typeof entry.resolved !== "string" ||
79
+ !local.has(entry.resolved) ||
80
+ entry.resolved === ""))
81
+ throw lockMismatch("npm link does not select a declared workspace");
82
+ if (entry.inBundle === true)
83
+ throw lockMismatch("bundled npm selections are unsupported");
84
+ }
85
+ for (const [location, importer] of local) {
86
+ const entry = packages[location];
87
+ if (!entry || entry.link)
88
+ throw lockMismatch("npm lock lacks a workspace importer");
89
+ for (const field of [
90
+ "dependencies",
91
+ "devDependencies",
92
+ "optionalDependencies",
93
+ "peerDependencies",
94
+ "peerDependenciesMeta",
95
+ "workspaces",
96
+ ])
97
+ if (canonicalJson(entry[field] ?? {}) !==
98
+ canonicalJson(importer.manifest[field] ?? {}))
99
+ throw lockMismatch("npm workspace manifest declarations differ");
100
+ if (location &&
101
+ ((entry.name !== undefined && entry.name !== importer.manifest.name) ||
102
+ entry.version !== importer.manifest.version))
103
+ throw lockMismatch("npm workspace identity differs");
104
+ const node = {
105
+ kind: "local",
106
+ key: `workspace:${importer.id}`,
107
+ name: importer.manifest.name ?? "workspace-root",
108
+ version: importer.manifest.version ?? "0.0.0",
109
+ importer: importer.id,
110
+ edges: [],
111
+ peers: {},
112
+ optionalPeers: [],
113
+ };
114
+ graph.importers[importer.id] = node.key;
115
+ graph.nodes.push(node);
116
+ locations.set(location, node);
117
+ }
118
+ const find = (from, name) => {
119
+ let current = from;
120
+ for (;;) {
121
+ if (!current.endsWith("node_modules")) {
122
+ const slot = `${current ? current + "/" : ""}node_modules/${name}`;
123
+ if (Object.hasOwn(packages, slot))
124
+ return slot;
125
+ }
126
+ if (!current)
127
+ return undefined;
128
+ current = current.includes("/")
129
+ ? current.slice(0, current.lastIndexOf("/"))
130
+ : "";
131
+ }
132
+ };
133
+ const populated = new Set();
134
+ const load = (location, name, spec, from) => {
135
+ const entry = packages[location];
136
+ if (entry.link === true) {
137
+ const target = locations.get(entry.resolved);
138
+ const range = spec.startsWith("workspace:") ? spec.slice(10) : spec;
139
+ if (spec.startsWith("file:")
140
+ ? path.posix.normalize(path.posix.join(from, spec.slice(5))) !==
141
+ (target.kind === "local" ? target.importer : "")
142
+ : name !== target.name ||
143
+ (!["*", "^", "~"].includes(range) &&
144
+ !satisfies(target.version, range)))
145
+ throw lockMismatch("npm workspace link disagrees with requirement");
146
+ return target;
147
+ }
148
+ // Reuse the strict single-selection adapter's archive, alias, source and
149
+ // platform validation. Dependencies are wired below using npm locations.
150
+ const shallow = { ...entry };
151
+ delete shallow.dependencies;
152
+ delete shallow.optionalDependencies;
153
+ delete shallow.peerDependencies;
154
+ const selected = importNpmLock({
155
+ lockfileVersion: 3,
156
+ packages: { "": {}, [`node_modules/${name}`]: shallow },
157
+ }, { [name]: spec }, origin).nodes[0];
158
+ let node = locations.get(location);
159
+ if (!node) {
160
+ const { peerBindings: _bindings, ...source } = selected;
161
+ node = {
162
+ ...source,
163
+ key: `${selected.name}@${selected.version}#${sha512Hex(location).slice(0, 16)}`,
164
+ kind: "registry",
165
+ edges: [],
166
+ peers: {},
167
+ optionalPeers: [],
168
+ };
169
+ assertWorkspacePlatform({ nodes: [node], importers: {}, omissions: [] });
170
+ graph.nodes.push(node);
171
+ locations.set(location, node);
172
+ }
173
+ try {
174
+ populate(location, node);
175
+ }
176
+ catch (cause) {
177
+ populated.delete(location);
178
+ locations.delete(location);
179
+ graph.nodes = graph.nodes.filter((n) => n !== node);
180
+ throw cause;
181
+ }
182
+ return node;
183
+ };
184
+ const populate = (location, node) => {
185
+ if (populated.has(location))
186
+ return;
187
+ if (populated.size >= 10000)
188
+ throw lockMismatch("npm import resource limit exceeded");
189
+ populated.add(location);
190
+ const entry = packages[location];
191
+ const requirements = new Map();
192
+ for (const [field, kind] of [
193
+ ["dependencies", "prod"],
194
+ ["devDependencies", "dev"],
195
+ ["optionalDependencies", "optional"],
196
+ ["peerDependencies", "peer"],
197
+ ]) {
198
+ if (kind === "dev" && node.kind !== "local")
199
+ continue;
200
+ const values = entry[field];
201
+ if (values === undefined)
202
+ continue;
203
+ if (!values || typeof values !== "object" || Array.isArray(values))
204
+ throw lockMismatch("invalid npm dependency declarations");
205
+ for (const [name, spec] of Object.entries(values)) {
206
+ if (typeof spec !== "string")
207
+ throw lockMismatch("invalid npm dependency requirement");
208
+ // A peer declaration does not replace an existing regular dependency.
209
+ if (kind !== "peer" || !requirements.has(name))
210
+ requirements.set(name, { spec, kind });
211
+ }
212
+ }
213
+ for (const [name, requirement] of requirements) {
214
+ const meta = entry.peerDependenciesMeta;
215
+ const optionalPeer = requirement.kind === "peer" && meta?.[name]?.optional === true;
216
+ if (optionalPeer)
217
+ node.optionalPeers.push(name);
218
+ const targetLocation = find(location, name);
219
+ if (targetLocation === undefined) {
220
+ if (optionalPeer) {
221
+ node.peers[name] = null;
222
+ continue;
223
+ }
224
+ if (requirement.kind === "optional")
225
+ continue;
226
+ throw lockMismatch("npm workspace lock is missing a required selection");
227
+ }
228
+ let target;
229
+ try {
230
+ target = load(targetLocation, name, requirement.spec, location);
231
+ }
232
+ catch (cause) {
233
+ if (requirement.kind === "optional" &&
234
+ cause instanceof ZyrkError &&
235
+ cause.code === "ZYRK_PLATFORM_UNSUPPORTED") {
236
+ graph.omissions.push({ name, parent: node.key, reason: "platform" });
237
+ continue;
238
+ }
239
+ throw cause;
240
+ }
241
+ node.edges.push({ name, target: target.key, ...requirement });
242
+ if (requirement.kind === "peer")
243
+ node.peers[name] = target.key;
244
+ }
245
+ // npm allows the same name in dependencies and peerDependencies. Its
246
+ // ordinary edge keeps its kind, but the peer range and binding still apply.
247
+ const peers = entry.peerDependencies;
248
+ const meta = entry.peerDependenciesMeta;
249
+ for (const [name, spec] of Object.entries(peers ?? {})) {
250
+ if (Object.hasOwn(node.peers, name))
251
+ continue;
252
+ const optional = meta?.[name]?.optional === true;
253
+ if (optional)
254
+ node.optionalPeers.push(name);
255
+ const targetLocation = find(location, name);
256
+ if (targetLocation === undefined) {
257
+ if (!optional)
258
+ throw lockMismatch("npm workspace lock is missing a required peer");
259
+ node.peers[name] = null;
260
+ continue;
261
+ }
262
+ const target = load(targetLocation, name, spec, location);
263
+ node.peers[name] = target.key;
264
+ if (!node.edges.some((edge) => edge.name === name))
265
+ node.edges.push({ name, target: target.key, kind: "peer", spec });
266
+ }
267
+ };
268
+ for (const [location] of local)
269
+ populate(location, locations.get(location));
270
+ const reachable = new Set(), pending = Object.values(graph.importers), byKey = new Map(graph.nodes.map((n) => [n.key, n]));
271
+ while (pending.length) {
272
+ const key = pending.pop();
273
+ if (reachable.has(key))
274
+ continue;
275
+ reachable.add(key);
276
+ for (const edge of byKey.get(key).edges)
277
+ pending.push(edge.target);
278
+ }
279
+ graph.nodes = graph.nodes.filter((n) => reachable.has(n.key));
280
+ graph.omissions = graph.omissions.filter((o) => reachable.has(o.parent));
281
+ graph.nodes.sort((a, b) => a.key.localeCompare(b.key));
282
+ return graph;
283
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"workspace-install.d.ts","sourceRoot":"","sources":["../../src/install/workspace-install.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AA8E9D,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAmpBxB"}
1
+ {"version":3,"file":"workspace-install.d.ts","sourceRoot":"","sources":["../../src/install/workspace-install.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAyH9D,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAopBxB"}
@@ -4,6 +4,7 @@ import { constants } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { discoverWorkspaces, findWorkspaceRoot, assertNoLiveSourceBuilds, } from "./workspaces.js";
6
6
  import { resolveWorkspaceGraph, workspaceRegistryOccurrences, workspaceContextId, assertWorkspacePlatform, } from "./workspace-graph.js";
7
+ import { importNpmWorkspaceLock } from "./import-npm-workspace.js";
7
8
  import { readWorkspaceLock, workspaceLockDoc } from "./workspace-lock.js";
8
9
  import { publishWorkspaceProjection, recoverWorkspaceProjection, workspaceProjectionPaths, } from "./workspace-transaction.js";
9
10
  import { ZyrkError } from "../errors.js";
@@ -48,10 +49,40 @@ async function realDirectory(directory) {
48
49
  if (!st.isDirectory() || st.isSymbolicLink())
49
50
  throw lockMismatch("workspace control parent must be a real directory");
50
51
  }
52
+ async function readNpmWorkspaceImport(root, project, options) {
53
+ if (options.frozen || options.offline)
54
+ throw lockMismatch("npm workspace adoption needs a non-frozen online install");
55
+ if (await pathExists(path.join(root, "npm-shrinkwrap.json")))
56
+ throw lockMismatch("npm-shrinkwrap.json takes npm precedence; workspace adoption currently supports package-lock.json only");
57
+ let npmDoc;
58
+ try {
59
+ npmDoc = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
60
+ }
61
+ catch {
62
+ throw lockMismatch("npm workspace adoption needs an existing readable JSON lock");
63
+ }
64
+ const graph = importNpmWorkspaceLock(npmDoc, project, options.registry.origin);
65
+ // Prove the complete graph and ordinary npm projection are representable
66
+ // before any project state is created. Actual policy fingerprints are added
67
+ // under the install lock after policy validation.
68
+ workspaceLockDoc(project, graph, {
69
+ manifest: project.manifestFingerprint,
70
+ scripts: scriptPolicyFingerprint(undefined),
71
+ config: configFingerprint({
72
+ registry: options.registry.origin,
73
+ linker: "shared",
74
+ }),
75
+ });
76
+ return graph;
77
+ }
51
78
  export async function installWorkspaceProject(options) {
52
79
  const root = await findWorkspaceRoot(options.projectDir);
53
80
  if (!root)
54
81
  throw lockMismatch("no authoritative workspace root");
82
+ // Refuse untranslatable locks before creating control directories or
83
+ // recovering a previous generation. Revalidate under the project lock below.
84
+ if (options.importNpm)
85
+ await readNpmWorkspaceImport(root, await discoverWorkspaces(root), options);
55
86
  await realDirectory(path.join(root, ".zyrk-local"));
56
87
  const held = await acquireLock(path.join(root, ".zyrk-local/project.lock"), {
57
88
  name: "workspace install",
@@ -66,8 +97,6 @@ export async function installWorkspaceProject(options) {
66
97
  i.id,
67
98
  await readFile(path.join(i.directory, "package.json"), "utf8"),
68
99
  ])));
69
- if (options.importNpm)
70
- unsupported("Workspace npm-lock adoption needs its multi-importer adapter.");
71
100
  const mutation = await prepareWorkspaceMutation(project, options);
72
101
  if (manifest.overrides !== undefined ||
73
102
  manifest.bundledDependencies !== undefined ||
@@ -110,7 +139,12 @@ export async function installWorkspaceProject(options) {
110
139
  ...(patchDigest ? { patches: patchDigest } : {}),
111
140
  }),
112
141
  };
113
- let existing = await readWorkspaceLock(root, project, options.registry.origin), graph;
142
+ const imported = options.importNpm
143
+ ? await readNpmWorkspaceImport(root, project, options)
144
+ : undefined;
145
+ let existing = options.importNpm
146
+ ? null
147
+ : await readWorkspaceLock(root, project, options.registry.origin), graph = imported;
114
148
  if (existing) {
115
149
  const ext = existing.doc.zyrk;
116
150
  if (!mutation.changed &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyrk",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Storage-efficient Node package manager: one verified store per user, shared across projects through links.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@npmcli/arborist": "10.0.3",
35
- "@types/node": "^24.19.2",
35
+ "@types/node": "^26.6.5",
36
36
  "@types/semver": "^7.8.0",
37
37
  "prettier": "3.9.10",
38
38
  "typescript": "^7.0.2",