Skip to content

Commit 1e0dd05

Browse files
authored
Pnpm lockfile migration (#283)
This PR adds a way to migrate a repository from pnpm to Yarn. I have an older repository I need to migrate so this will be handy. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Modifies lockfile generation by shelling out to `pnpm` and interpreting its tree, including optional-dependency and path handling; incorrect parsing could produce wrong resolutions during migrations. CI/e2e coverage is added, but behavior depends on pnpm version differences. > > **Overview** > Adds a pnpm-to-Yarn migration path that builds a Yarn `Lockfile` from `node_modules` by invoking `pnpm list -r --json`, selecting `--depth=Infinity` on newer pnpm versions (fallback `--depth=3`), normalizing relative paths, and skipping missing optional dependencies. > > Updates the e2e sandbox image to install `pnpm`, and introduces a new `tests/e2e/pnpm-migration.sh` that asserts the migration largely preserves pnpm-resolved package versions after switching to `yarn install`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1d9b669. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent defef18 commit 1e0dd05

File tree

5 files changed

+113
-7
lines changed

5 files changed

+113
-7
lines changed

.github/actions/run-e2e-sandbox/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ RUN apt-get update \
1313
npm \
1414
python3 \
1515
python-is-python3 \
16-
&& rm -rf /var/lib/apt/lists/*
16+
&& rm -rf /var/lib/apt/lists/* \
17+
&& npm install -g pnpm
1718

1819
COPY entrypoint.sh /entrypoint.sh
1920
RUN chmod +x /entrypoint.sh

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ profile.json.gz
2121
/.yarn/cache
2222
/.yarn/unplugged
2323

24+
/.claude
25+
/.superset
26+
2427
/bench-*.json

packages/zpm-primitives/src/reference.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ fn format_pypi_registry(ident: &Ident, version: &crate::PypiVersion, url: Option
3333
None => format!("pypi:{}@{}", ident.to_file_string(), version.to_file_string()),
3434
}
3535
}
36-
3736
fn format_workspace_path(path: &Path) -> String {
3837
if path.is_empty() {
3938
"workspace:.".to_string()

packages/zpm/src/lockfile.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ struct PnpmListDependency {
408408
/// Builds a lockfile from pnpm's installed packages using `pnpm list --json`.
409409
///
410410
/// The approach:
411-
/// 1. Run `pnpm list --json --depth=3` to get most of the full dependency tree
411+
/// 1. Run `pnpm list --json` to get the full dependency tree
412+
/// (uses `--depth=Infinity` on pnpm >= 10.29.3, `--depth=3` on older versions)
412413
/// 2. Recursively walk the tree to collect all packages with their resolved URLs
413414
/// 3. For each package, read its package.json to get the original dependency ranges
414415
/// 4. Build descriptor -> locator mappings
@@ -438,9 +439,17 @@ pub fn from_pnpm_node_modules(project_cwd: &Path) -> Result<Lockfile, Error> {
438439
return Ok(Lockfile::new());
439440
}
440441

441-
// Run pnpm list --json --depth=Infinity
442+
let depth_flag = std::process::Command::new("pnpm")
443+
.arg("--version")
444+
.output()
445+
.ok()
446+
.and_then(|o| String::from_utf8(o.stdout).ok())
447+
.and_then(|v| zpm_semver::Version::from_file_string(v.trim()).ok())
448+
.filter(|v| *v >= zpm_semver::Version::from_file_string("10.29.3").unwrap())
449+
.map_or("--depth=3", |_| "--depth=Infinity");
450+
442451
let output = std::process::Command::new("pnpm")
443-
.args(["list", "-r", "--json", "--depth=3"])
452+
.args(["list", "-r", "--json", depth_flag])
444453
.current_dir(project_cwd.as_str())
445454
.output()
446455
.map_err(|_| Error::PnpmNodeModulesReadError)?;
@@ -466,14 +475,22 @@ pub fn from_pnpm_node_modules(project_cwd: &Path) -> Result<Lockfile, Error> {
466475
continue;
467476
};
468477

469-
let Ok(package_path) = Path::try_from(package_path_str.as_str()) else {
478+
let Ok(raw_path) = Path::try_from(package_path_str.as_str()) else {
470479
continue;
471480
};
472481

482+
let package_path = if raw_path.is_relative() {
483+
project_cwd.with_join_str("node_modules").with_join(&raw_path)
484+
} else {
485+
raw_path
486+
};
487+
473488
#[derive(Debug, Deserialize)]
474489
struct Manifest {
475490
#[serde(default)]
476491
dependencies: BTreeMap<String, String>,
492+
#[serde(default, rename = "optionalDependencies")]
493+
optional_dependencies: BTreeMap<String, String>,
477494
}
478495

479496
let manifest: Option<Manifest>
@@ -487,7 +504,11 @@ pub fn from_pnpm_node_modules(project_cwd: &Path) -> Result<Lockfile, Error> {
487504
continue;
488505
};
489506

490-
for (name, range) in manifest.dependencies {
507+
let all_dependencies
508+
= manifest.dependencies.into_iter().map(|(n, r)| (n, r, false))
509+
.chain(manifest.optional_dependencies.into_iter().map(|(n, r)| (n, r, true)));
510+
511+
for (name, range, is_optional) in all_dependencies {
491512
let Ok(ident) = Ident::from_file_string(&name) else {
492513
continue;
493514
};
@@ -501,6 +522,25 @@ pub fn from_pnpm_node_modules(project_cwd: &Path) -> Result<Lockfile, Error> {
501522
continue;
502523
};
503524

525+
if is_optional {
526+
let installed = resolved_entry.path.as_ref().and_then(|p| {
527+
let p
528+
= Path::try_from(p.as_str()).ok()?;
529+
530+
let p = if p.is_relative() {
531+
project_cwd.with_join_str("node_modules").with_join(&p)
532+
} else {
533+
p
534+
};
535+
536+
Some(p.with_join_str("package.json").fs_exists())
537+
});
538+
539+
if !installed.unwrap_or(false) {
540+
continue;
541+
}
542+
}
543+
504544
let Some(version) = &resolved_entry.version else {
505545
continue;
506546
};

tests/e2e/pnpm-migration.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env bash
2+
3+
mkdir -p packages/pkg-a
4+
5+
cat > package.json <<'JSON'
6+
{
7+
"name": "e2e-pnpm-migration",
8+
"private": true,
9+
"workspaces": [
10+
"packages/*"
11+
]
12+
}
13+
JSON
14+
15+
cat > pnpm-workspace.yaml <<'YAML'
16+
packages:
17+
- "packages/*"
18+
YAML
19+
20+
cat > packages/pkg-a/package.json <<'JSON'
21+
{
22+
"name": "pkg-a",
23+
"version": "1.0.0",
24+
"private": true,
25+
"dependencies": {
26+
"jest": "*"
27+
}
28+
}
29+
JSON
30+
31+
pnpm install
32+
33+
# Capture the versions pnpm resolved (normalized to name@version)
34+
pnpm ls -r --json --depth=Infinity \
35+
| jq -r '.. | objects | select(.version? and .from?) | "\(.from)@\(.version)"' \
36+
| sort -u > "${TEMP_DIR}/pnpm-versions.txt"
37+
38+
rm -f pnpm-lock.yaml
39+
40+
yarn install
41+
42+
# Capture the versions yarn resolved (normalized to name@version)
43+
yarn info -AR --json \
44+
| jq -r '.value' \
45+
| grep '@npm:' \
46+
| sed -E 's/@npm:(.+@)?/\t/' \
47+
| awk -F'\t' '{print $1"@"$2}' \
48+
| sort -u > "${TEMP_DIR}/yarn-versions.txt"
49+
50+
# Every version from pnpm must appear in yarn's resolution.
51+
# Platform-specific packages (like fsevents) may be absent; that's expected.
52+
MISMATCHES=0
53+
while IFS= read -r line; do
54+
if ! grep -qxF "${line}" "${TEMP_DIR}/yarn-versions.txt"; then
55+
echo "MISMATCH: pnpm had ${line} but yarn does not" >&2
56+
MISMATCHES=$((MISMATCHES + 1))
57+
fi
58+
done < "${TEMP_DIR}/pnpm-versions.txt"
59+
60+
if [[ "${MISMATCHES}" -gt 5 ]]; then
61+
echo "Too many version mismatches (${MISMATCHES}); migration did not preserve versions" >&2
62+
exit 1
63+
fi

0 commit comments

Comments
 (0)