-
Notifications
You must be signed in to change notification settings - Fork 78
Expand file tree
/
Copy pathconvertPackageLockToShrinkwrap.js
More file actions
281 lines (241 loc) · 10.1 KB
/
convertPackageLockToShrinkwrap.js
File metadata and controls
281 lines (241 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import {readFile} from "node:fs/promises";
import path from "path";
import {Arborist} from "@npmcli/arborist";
import pacote from "pacote";
import Config from "@npmcli/config";
import pkg from "@npmcli/config/lib/definitions/index.js";
const {shorthands, definitions, flatten} = pkg;
async function readJson(filePath) {
const jsonString = await readFile(filePath, {encoding: "utf-8"});
return JSON.parse(jsonString);
}
export default async function convertPackageLockToShrinkwrap(workspaceRootDir, targetPackageName) {
const packageLockJson = await readJson(path.join(workspaceRootDir, "package-lock.json"));
// Input validation
if (!packageLockJson || typeof packageLockJson !== "object") {
throw new Error("Invalid package-lock.json: must be a valid JSON object");
}
if (!targetPackageName || typeof targetPackageName !== "string" || targetPackageName.trim() === "") {
throw new Error("Invalid target package name: must be a non-empty string");
}
if (!packageLockJson.packages) {
throw new Error("Invalid package-lock.json: missing packages field");
}
if (typeof packageLockJson.packages !== "object") {
throw new Error("Invalid package-lock.json: packages field must be an object");
}
// Validate lockfile version - only support version 3
if (packageLockJson.lockfileVersion && packageLockJson.lockfileVersion !== 3) {
throw new Error(`Unsupported lockfile version: ${packageLockJson.lockfileVersion}. ` +
`Only lockfile version 3 is supported`);
}
// Default to version 3 if not specified
if (!packageLockJson.lockfileVersion) {
packageLockJson.lockfileVersion = 3;
}
// We use arborist to traverse the dependency graph correctly. It handles various edge cases such as
// dependencies installed via "npm:xyz", which required special parsing (see package "@isaacs/cliui").
const arb = new Arborist({
path: workspaceRootDir,
});
const tree = await arb.loadVirtual();
let targetNode = tree.inventory.get(`node_modules/${targetPackageName}`);
if (!targetNode) {
throw new Error(`Target package "${targetPackageName}" not found in workspace`);
}
targetNode = targetNode.isLink ? targetNode.target : targetNode;
const virtualFlatTree = [];
// Collect all package keys using arborist
resolveVirtualTree(targetNode, virtualFlatTree);
const physicalTree = new Map();
await buildPhysicalTree(
virtualFlatTree, physicalTree, packageLockJson, workspaceRootDir);
// Build a map of package paths to their versions for collision detection
// Sort packages by key to ensure consistent order (just like the npm cli does it)
const sortedExtractedPackages = Object.create(null);
const sortedKeys = Array.from(physicalTree.keys()).sort((a, b) => a.localeCompare(b));
for (const key of sortedKeys) {
sortedExtractedPackages[key] = physicalTree.get(key)[0];
}
// Generate npm-shrinkwrap.json
const shrinkwrap = {
name: targetPackageName,
version: targetNode.version,
lockfileVersion: 3,
requires: true,
packages: sortedExtractedPackages
};
return shrinkwrap;
}
function resolveVirtualTree(node, virtualFlatTree, curPath, parentNode) {
if (node.isLink) {
node = node.target;
}
const fullPath = [curPath, node.name].join(" | ");
if (virtualFlatTree.some(([path]) => path === fullPath)) {
return;
}
if (node.isLink) {
node = node.target;
}
virtualFlatTree.push([fullPath, [node, parentNode]]);
for (const edge of node.edgesOut.values()) {
if (edge.dev) {
continue;
}
resolveVirtualTree(edge.to, virtualFlatTree, fullPath, node);
}
}
async function buildPhysicalTree(
virtualFlatTree, physicalTree, packageLockJson, workspaceRootDir) {
// Sort by path depth and then alphabetically to ensure parent
// packages are processed before children. It's important to
// process parents first to correctly handle version collisions and hoisting
virtualFlatTree.sort(([pathA], [pathB]) => {
if (pathA.split(" | ").length < pathB.split(" | ").length) {
return -1;
} else if (pathA.split(" | ").length > pathB.split(" | ").length) {
return 1;
} else {
return pathA.localeCompare(pathB);
}
});
const targetNode = virtualFlatTree[0][1][0];
const targetPackageName = targetNode.packageName;
// Collect information to resolve potential version conflicts later
const statsToResolveConflicts = new Map();
for (const [, nodes] of virtualFlatTree) {
const packageLoc = resolveLocation(nodes, physicalTree, targetPackageName);
const [node, parentNode] = nodes;
const {version} = node;
const isTargetPackageHardDep = (parentNode?.packageName === targetPackageName);
// index 0: Set of versions found for this location
// index 1: Map of version -> count
// (this will be used eventually to elect the most common version in root node_modules)
// index 2: If target package has direct dependency here, the version
const packageStats = statsToResolveConflicts.get(packageLoc) || [new Set(), Object.create(null)];
packageStats[0].add(version);
packageStats[1][version] ??= 0;
packageStats[1][version]++;
if (isTargetPackageHardDep) {
if (packageStats[2]) {
throw new Error(`Impossible to resolve hoisting conflicts. ` +
`Target package direct dependency "${node.packageName}" ` +
`has multiple versions: ${packageStats[2]} and ${version}.`);
}
packageStats[2] = version;
}
statsToResolveConflicts.set(packageLoc, packageStats);
}
const resolvedPackageLocations = new Map();
for (const [, nodes] of virtualFlatTree) {
let packageLoc = resolveLocation(nodes, physicalTree, targetPackageName);
const [node, parentNode] = nodes;
const {location, version} = node;
const pkg = packageLockJson.packages[location];
const isRootNodeModulesLocation = `node_modules/${node.packageName}` === packageLoc;
const isTargetModuleDependency = (parentNode?.packageName === targetPackageName);
// Handle version conflicts in root node_modules
if (isRootNodeModulesLocation && !isTargetModuleDependency) {
const packageStats = statsToResolveConflicts.get(packageLoc);
const hasConflictingLocationAndVersion = packageStats[0].size > 1;
// Which is the version of the package that's (eventually) used as
// dependency of the target package.
let selectedVersionForRootNodeModules = version;
if (hasConflictingLocationAndVersion) {
const targetPackageVersion = packageStats[2];
const versionsCount = packageStats[1];
// Use target package direct dependency version if available,
// otherwise elect the most common version among dependents
selectedVersionForRootNodeModules = targetPackageVersion ??
Object.keys(packageStats[1]).reduce((acc, versionKey) => {
return versionsCount[acc] > versionsCount[versionKey] ? acc : versionKey;
});
}
if (selectedVersionForRootNodeModules !== version) {
const parentPath = resolvedPackageLocations.get(parentNode) ??
// Fallback in case parentNode is not yet resolved (should never happen)
// check virtualFlatTree.sort(...) above
normalizePackageLocation(parentNode.location, parentNode, targetPackageName);
packageLoc = parentPath ? `${parentPath}/${packageLoc}` : packageLoc;
}
}
if (packageLoc !== "" && !pkg.resolved) {
// For all but the root package, ensure that "resolved" and "integrity" fields are present
// These are always missing for locally linked packages, but sometimes also for others (e.g. if installed
// from local cache)
const {resolved, integrity} =
await fetchPackageMetadata(node.packageName, node.version, workspaceRootDir);
pkg.resolved = resolved;
pkg.integrity = integrity;
}
resolvedPackageLocations.set(node, packageLoc);
physicalTree.set(packageLoc, [pkg, node]);
}
}
function resolveLocation(nodes, physicalTree, targetPackageName) {
let packageLoc;
const [node, parentNode] = nodes;
const {location} = node;
if (node.packageName === targetPackageName) {
// Make the target package the root package
packageLoc = "";
if (physicalTree[location]) {
throw new Error(`Duplicate root package entry for "${targetPackageName}"`);
}
} else if (parentNode?.packageName === targetPackageName) {
// Direct dependencies of the target package go into node_modules.
packageLoc = `node_modules/${node.packageName}`;
} else {
packageLoc = normalizePackageLocation(location, node, targetPackageName);
}
return packageLoc;
}
function normalizePackageLocation(location, node, targetPackageName) {
const topPackageName = node.top.packageName;
const rootPackageName = node.root.packageName;
let curLocation = location;
if (topPackageName === targetPackageName) {
// Remove location for packages within target package (e.g. @ui5/cli)
curLocation = location.substring(node.top.location.length + 1);
} else if (topPackageName !== rootPackageName) {
// Add package within node_modules of actual package name (e.g. @ui5/fs)
curLocation = `node_modules/${topPackageName}/${location.substring(node.top.location.length + 1)}`;
}
// If it's already within the root workspace package, keep as-is
return curLocation.endsWith("/") ? curLocation.slice(0, -1) : curLocation;
}
/**
* Fetch package metadata from npm registry using pacote
*
* @param {string} packageName - Name of the package
* @param {string} version - Version of the package
* @param {string} workspaceRoot - Root directory of the workspace to read npm config from
* @returns {Promise<{resolved: string, integrity: string}>} - Resolved URL and integrity hash
*/
async function fetchPackageMetadata(packageName, version, workspaceRoot) {
try {
const spec = `${packageName}@${version}`;
const conf = new Config({
npmPath: workspaceRoot,
definitions,
shorthands,
flatten,
argv: process.argv,
env: process.env,
execPath: process.execPath,
platform: process.platform,
cwd: process.cwd(),
});
await conf.load();
const registry = conf.get("registry") || "https://registry.npmjs.org/";
const manifest = await pacote.manifest(spec, {registry});
return {
resolved: manifest.dist.tarball,
integrity: manifest.dist.integrity || ""
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Could not fetch registry metadata for ${packageName}@${version}: ${errorMessage}`);
}
}