Skip to content

Commit 999d9f0

Browse files
committed
feat: Add shrinkwrap-extractor to the root project's lib
1 parent cf9b982 commit 999d9f0

13 files changed

Lines changed: 52179 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# see http://editorconfig.org
2+
3+
root = true
4+
5+
[*]
6+
charset = utf-8
7+
indent_style = tab
8+
9+
[*.{css,html,js,cjs,mjs,jsx,ts,tsx,less,txt,json,yml,md}]
10+
trim_trailing_whitespace = true
11+
end_of_line = lf
12+
indent_size = 4
13+
insert_final_newline = true
14+
15+
[*.{yml,yaml}]
16+
indent_style = space
17+
indent_size = 2
18+
19+
[*.md]
20+
trim_trailing_whitespace = false
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* text=auto eol=lf

lib/shrinkwrap-extractor/.npmrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Enforce public npm registry
2+
registry=https://registry.npmjs.org/
3+
lockfile-version=3
4+
ignore-scripts=true

lib/shrinkwrap-extractor/cli.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env node
2+
3+
import {readFile, writeFile} from "node:fs/promises";
4+
import {join} from "node:path";
5+
import convertPackageLockToShrinkwrap from "./lib/convertPackageLockToShrinkwrap.js";
6+
7+
async function main() {
8+
const args = process.argv.slice(2);
9+
10+
// Validate arguments
11+
if (args.length !== 1) {
12+
console.error("Error: Expected exactly 1 argument");
13+
console.error("Usage: shrinkwrap-extractor <path-to-workspace-root>");
14+
process.exit(1);
15+
}
16+
17+
const [workspaceRootPath] = args;
18+
19+
try {
20+
console.log(`Generating shrinkwrap in: ${process.cwd()}`);
21+
console.log(`Using workspace root: ${workspaceRootPath}`);
22+
23+
// Read and parse package.json
24+
const packageJsonContent = await readFile(join(process.cwd(), "package.json"), "utf-8");
25+
const packageJson = JSON.parse(packageJsonContent);
26+
const packageName = packageJson.name;
27+
28+
// Validate package name
29+
if (!packageName || packageName.trim() === "") {
30+
console.error("Error: Package name cannot be empty");
31+
process.exit(1);
32+
}
33+
34+
console.log(`Converting dependencies for package: ${packageName}`);
35+
36+
// Extract into shrinkwrap
37+
const shrinkwrap = await convertPackageLockToShrinkwrap(workspaceRootPath, packageName);
38+
39+
// Write npm-shrinkwrap.json to current working directory
40+
const outputPath = join(process.cwd(), "npm-shrinkwrap.json");
41+
const shrinkwrapContent = JSON.stringify(shrinkwrap, null, "\t");
42+
43+
await writeFile(outputPath, shrinkwrapContent, "utf-8");
44+
45+
console.log(`Successfully generated npm-shrinkwrap.json with ${Object.keys(shrinkwrap.packages).length - 1} dependencies (excluding root)`);
46+
console.log(`Output written to: ${outputPath}`);
47+
} catch (error) {
48+
console.error(`Unexpected error: ${error.message}`);
49+
console.error("Stack trace:", error.stack);
50+
51+
process.exit(1);
52+
}
53+
}
54+
55+
// Handle uncaught exceptions
56+
process.on("uncaughtException", (error) => {
57+
console.error("Uncaught exception:", error.message);
58+
process.exit(1);
59+
});
60+
61+
process.on("unhandledRejection", (reason, promise) => {
62+
console.error("Unhandled rejection at:", promise, "reason:", reason);
63+
process.exit(1);
64+
});
65+
66+
main();
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import js from "@eslint/js";
2+
import globals from "globals";
3+
import google from "eslint-config-google";
4+
5+
export default [{
6+
ignores: [
7+
"**/coverage/",
8+
"test/tmp/",
9+
"test/expected/",
10+
"test/fixtures/",
11+
],
12+
}, js.configs.recommended, google, {
13+
languageOptions: {
14+
globals: {
15+
...globals.node,
16+
},
17+
18+
ecmaVersion: 2023,
19+
sourceType: "module",
20+
},
21+
22+
rules: {
23+
"indent": ["error", "tab"],
24+
"linebreak-style": ["error", "unix"],
25+
26+
"quotes": ["error", "double", {
27+
allowTemplateLiterals: true,
28+
}],
29+
30+
"semi": ["error", "always"],
31+
"no-negated-condition": "off",
32+
"require-jsdoc": "off",
33+
"no-mixed-requires": "off",
34+
35+
"max-len": ["error", {
36+
code: 160,
37+
ignoreUrls: true,
38+
ignoreRegExpLiterals: true,
39+
}],
40+
41+
"no-implicit-coercion": [2, {
42+
allow: ["!!"],
43+
}],
44+
45+
"comma-dangle": "off",
46+
"no-tabs": "off",
47+
"no-eval": 2,
48+
// The following rule must be disabled as of ESLint 9.
49+
// It's removed and causes issues when present
50+
// https://eslint.org/docs/latest/rules/valid-jsdoc
51+
"valid-jsdoc": 0,
52+
},
53+
}
54+
];
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {readFile} from "node:fs/promises";
2+
import path from "path";
3+
import {Arborist} from "@npmcli/arborist";
4+
import pacote from "pacote";
5+
6+
async function readJson(filePath) {
7+
const jsonString = await readFile(filePath, {encoding: "utf-8"});
8+
return JSON.parse(jsonString);
9+
}
10+
11+
export default async function convertPackageLockToShrinkwrap(workspaceRootDir, targetPackageName) {
12+
const packageLockJson = await readJson(path.join(workspaceRootDir, "package-lock.json"));
13+
14+
// Input validation
15+
if (!packageLockJson || typeof packageLockJson !== "object") {
16+
throw new Error("Invalid package-lock.json: must be a valid JSON object");
17+
}
18+
19+
if (!targetPackageName || typeof targetPackageName !== "string" || targetPackageName.trim() === "") {
20+
throw new Error("Invalid target package name: must be a non-empty string");
21+
}
22+
23+
if (!packageLockJson.packages) {
24+
throw new Error("Invalid package-lock.json: missing packages field");
25+
}
26+
27+
if (typeof packageLockJson.packages !== "object") {
28+
throw new Error("Invalid package-lock.json: packages field must be an object");
29+
}
30+
31+
// Validate lockfile version - only support version 3
32+
if (packageLockJson.lockfileVersion && packageLockJson.lockfileVersion !== 3) {
33+
throw new Error(`Unsupported lockfile version: ${packageLockJson.lockfileVersion}. Only lockfile version 3 is supported`);
34+
}
35+
36+
// Default to version 3 if not specified
37+
if (!packageLockJson.lockfileVersion) {
38+
packageLockJson.lockfileVersion = 3;
39+
}
40+
41+
// We use arborist to traverse the dependency graph correctly. It handles various edge cases such as
42+
// dependencies installed via "npm:xyz", which required special parsing (see package "@isaacs/cliui").
43+
const arb = new Arborist({
44+
path: workspaceRootDir,
45+
});
46+
const tree = await arb.loadVirtual();
47+
const cliNode = Array.from(tree.tops).find((node) => node.packageName === targetPackageName);
48+
if (!cliNode) {
49+
throw new Error(`Target package "${targetPackageName}" not found in workspace`);
50+
}
51+
52+
const relevantPackageLocations = new Map();
53+
// Collect all package keys using arborist
54+
collectDependencies(cliNode, relevantPackageLocations);
55+
56+
// Using the keys, extract relevant package-entries from package-lock.json
57+
const extractedPackages = Object.create(null);
58+
for (let [packageLoc, node] of relevantPackageLocations) {
59+
let pkg = packageLockJson.packages[packageLoc];
60+
if (pkg.link) {
61+
pkg = packageLockJson.packages[pkg.resolved];
62+
}
63+
if (pkg.name === targetPackageName) {
64+
// Make the target package the root package
65+
packageLoc = "";
66+
if (extractedPackages[packageLoc]) {
67+
throw new Error(`Duplicate root package entry for "${targetPackageName}"`);
68+
}
69+
} else if (!pkg.resolved) {
70+
// For all but the root package, ensure that "resolved" and "integrity" fields are present
71+
// These are always missing for locally linked packages, but sometimes also for others (e.g. if installed
72+
// from local cache)
73+
const {resolved, integrity} = await fetchPackageMetadata(node.packageName, node.version);
74+
pkg.resolved = resolved;
75+
pkg.integrity = integrity;
76+
}
77+
extractedPackages[packageLoc] = pkg;
78+
}
79+
80+
// Sort packages by key to ensure consistent order (just like the npm cli does it)
81+
const sortedExtractedPackages = Object.create(null);
82+
const sortedKeys = Object.keys(extractedPackages).sort((a, b) => a.localeCompare(b));
83+
for (const key of sortedKeys) {
84+
sortedExtractedPackages[key] = extractedPackages[key];
85+
}
86+
87+
// Generate npm-shrinkwrap.json
88+
const shrinkwrap = {
89+
name: targetPackageName,
90+
version: cliNode.version,
91+
lockfileVersion: 3,
92+
requires: true,
93+
packages: sortedExtractedPackages
94+
};
95+
96+
return shrinkwrap;
97+
}
98+
99+
function collectDependencies(node, relevantPackageLocations) {
100+
if (relevantPackageLocations.has(node.location)) {
101+
// Already processed
102+
return;
103+
}
104+
relevantPackageLocations.set(node.location, node);
105+
if (node.isLink) {
106+
node = node.target;
107+
}
108+
for (const edge of node.edgesOut.values()) {
109+
if (edge.dev) {
110+
continue;
111+
}
112+
collectDependencies(edge.to, relevantPackageLocations);
113+
}
114+
}
115+
116+
/**
117+
* Fetch package metadata from npm registry using pacote
118+
*/
119+
async function fetchPackageMetadata(packageName, version) {
120+
try {
121+
const spec = `${packageName}@${version}`;
122+
const manifest = await pacote.manifest(spec);
123+
124+
return {
125+
resolved: manifest.dist.tarball,
126+
integrity: manifest.dist.integrity
127+
};
128+
} catch (error) {
129+
throw new Error(`Could not fetch registry metadata for ${packageName}@${version}: ${error.message}`);
130+
}
131+
}

0 commit comments

Comments
 (0)