Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/pm/lockfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ For more on the format, see [the blog post](https://bun.com/blog/bun-lock-text-l
When you run `bun install` in a project without a `bun.lock`, Bun automatically migrates existing lockfiles:

- `yarn.lock` (v1)
- `package-lock.json` (npm, `lockfileVersion` 2, 3 or 4)
- `npm-shrinkwrap.json` and `package-lock.json` (npm, `lockfileVersion` 2, 3 or 4)
- `pnpm-lock.yaml` (pnpm)

Bun does not migrate a `package-lock.json` from npm 6 or older (`lockfileVersion` 1); it prints a warning and resolves from `package.json` instead.
When a project has both npm lockfiles, Bun reads `npm-shrinkwrap.json` and ignores `package-lock.json`, as npm does.

Bun does not migrate an npm lockfile from npm 6 or older (`lockfileVersion` 1); it prints a warning and resolves from `package.json` instead.

Bun preserves the original lockfile. You can remove it manually after verification.
53 changes: 33 additions & 20 deletions src/install/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,39 +33,50 @@ pub fn detect_and_load_other_lockfile<'a>(
manager: &mut PackageManager,
log: &mut bun_ast::Log,
) -> LoadResult<'a> {
// check for package-lock.json, yarn.lock, etc...
// check for npm-shrinkwrap.json, package-lock.json, yarn.lock, etc...
// if it exists, do an in-memory migration

'npm: {
// npm itself reads npm-shrinkwrap.json over package-lock.json; the two share a format.
for (lockfile_name, lockfile_name_z) in [
("npm-shrinkwrap.json", zstr!("npm-shrinkwrap.json")),
("package-lock.json", zstr!("package-lock.json")),
] {
let timer = std::time::Instant::now();
let Ok(lockfile) = File::openat(dir, b"package-lock.json", O::RDONLY, 0) else {
break 'npm;
let Ok(lockfile) = File::openat(dir, lockfile_name_z, O::RDONLY, 0) else {
continue;
};
// file closes on Drop
let mut lockfile_path_buf = PathBuffer::uninit();
let Ok(lockfile_path) = bun_sys::get_fd_path(lockfile.handle(), &mut lockfile_path_buf)
else {
break 'npm;
continue;
};
let lockfile_path: &[u8] = &*lockfile_path;
let Ok(data) = lockfile.read_to_end() else {
break 'npm;
continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
let migrate_result = match migrate_npm_lockfile(
this,
manager,
log,
&data,
lockfile_path,
dir,
lockfile_name,
) {
Ok(r) => r,
Err(e) => {
return LoadResult::Err(LoadResultErr {
step: LoadStep::Migrating,
value: e,
lockfile_path: lockfile_name_z,
format: LockfileFormat::Text,
});
}
};
let migrate_result =
match migrate_npm_lockfile(this, manager, log, &data, lockfile_path, dir) {
Ok(r) => r,
Err(e) => {
return LoadResult::Err(LoadResultErr {
step: LoadStep::Migrating,
value: e,
lockfile_path: zstr!("package-lock.json"),
format: LockfileFormat::Text,
});
}
};

if matches!(migrate_result, LoadResult::Ok { .. }) {
report_migrated(manager, log, &timer, "package-lock.json");
report_migrated(manager, log, &timer, lockfile_name);
}

return migrate_result;
Expand Down Expand Up @@ -251,6 +262,7 @@ fn migrate_npm_lockfile<'a>(
data: &[u8],
abs_path: &[u8],
dir: Fd,
lockfile_name: &'static str,
) -> Result<LoadResult<'a>, Error> {
debug!("begin lockfile migration");

Expand All @@ -271,7 +283,7 @@ fn migrate_npm_lockfile<'a>(
Some(E::JsonValue::Number(n)) => {
report_unsupported_lockfile_version(
manager,
"package-lock.json",
lockfile_name,
&(n.value() as i64),
"npm install --package-lock-only --lockfile-version=3",
);
Expand Down Expand Up @@ -398,6 +410,7 @@ fn migrate_npm_lockfile<'a>(
log,
packages_properties,
workspace_map.as_ref(),
lockfile_name,
)?;
clear_non_registry_platform_constraints(this);
npm_lock::apply_root_overrides(this, manager, log, dir, workspace_map.as_ref(), abs_path)?;
Expand Down
19 changes: 14 additions & 5 deletions src/install/migration/npm_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ struct Migrator<'a> {
url: Vec<u8>,
patched: String,
silent: bool,
/// `npm-shrinkwrap.json` or `package-lock.json`, for warnings.
lockfile_name: &'static str,
}

pub(super) fn migrate_packages(
Expand All @@ -122,6 +124,7 @@ pub(super) fn migrate_packages(
_log: &mut bun_ast::Log,
packages_properties: &[E::PropertyJSON],
workspace_map: Option<&WorkspaceMap>,
lockfile_name: &'static str,
) -> Result<(), Error> {
debug_assert!(!packages_properties.is_empty() && packages_properties[0].key.slice().is_empty());

Expand All @@ -148,6 +151,7 @@ pub(super) fn migrate_packages(
url: Vec::new(),
patched: String::new(),
silent,
lockfile_name,
};

migrator.build_index()?;
Expand All @@ -170,8 +174,9 @@ pub(super) fn migrate_packages(

if !migrator.patched.is_empty() {
bun_core::warn!(
"skipped npm patches for {} from package-lock.json",
"skipped npm patches for {} from {}",
migrator.patched,
lockfile_name,
);
bun_core::note!("bun patch \\<pkg\\>");
}
Expand Down Expand Up @@ -729,9 +734,10 @@ impl<'a> Migrator<'a> {
else {
if !self.silent {
bun_core::warn!(
"skipped \"{}@{}\" from package-lock.json: unsupported version specifier",
"skipped \"{}@{}\" from {}: unsupported version specifier",
bstr::BStr::new(name),
bstr::BStr::new(spec),
self.lockfile_name,
);
}
continue;
Expand Down Expand Up @@ -837,9 +843,10 @@ impl<'a> Migrator<'a> {
let Some(t) = target else {
if !self.silent {
bun_core::warn!(
"workspace \"{}\" ({}) is not in package-lock.json; resolving it from package.json",
"workspace \"{}\" ({}) is not in {}; resolving it from package.json",
bstr::BStr::new(&ws.name),
bstr::BStr::new(path),
self.lockfile_name,
);
}
continue;
Expand Down Expand Up @@ -937,8 +944,9 @@ impl<'a> Migrator<'a> {
self.skipped_external.set(t as usize);
if !self.silent {
bun_core::warn!(
"skipped \"{}\" from package-lock.json: transitive folder dependency \"{}\" is outside the project",
"skipped \"{}\" from {}: transitive folder dependency \"{}\" is outside the project",
bstr::BStr::new(name),
self.lockfile_name,
bstr::BStr::new(self.entries[t as usize].key.slice()),
);
}
Expand Down Expand Up @@ -1001,8 +1009,9 @@ impl<'a> Migrator<'a> {
let _ = write!(list, " and {} more", count - 5);
}
bun_core::warn!(
"skipped {} package-lock.json {} not depended on by any package: {}",
"skipped {} {} {} not depended on by any package: {}",
count,
self.lockfile_name,
if count == 1 { "entry" } else { "entries" },
list,
);
Expand Down
121 changes: 120 additions & 1 deletion test/cli/install/migration/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ test("npm lockfile with relative workspaces", async () => {
expect(exitCode).toBe(0);
});

const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
const lockfiles = ["npm-shrinkwrap.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"];

for (const lockfile of lockfiles) {
test(`should create bun.lock if ${lockfile} migration fails`, async () => {
Expand Down Expand Up @@ -1669,6 +1669,125 @@ describe("package-lock.json migration fixes", () => {
expect(lock.packages).toStrictEqual({});
});

// npm-shrinkwrap.json is package-lock.json under another name, and npm reads it in preference to package-lock.json.
describe("npm-shrinkwrap.json", () => {
test.concurrent("bun install keeps the versions it pins instead of resolving afresh", async () => {
using registry = localRegistry();
// The registry also has no-deps 1.0.1 and 1.1.0, which a fresh resolve of this range would pick.
const dependencies = { "no-deps": "^1.0.0" };
using dir = synthetic(
"npm-migrate-shrinkwrap-install",
{
"package.json": JSON.stringify({ name: "shrinkwrapped", dependencies }),
"npm-shrinkwrap.json": npmLock("shrinkwrapped", {
"": { name: "shrinkwrapped", dependencies },
"node_modules/no-deps": {
version: "1.0.0",
resolved: registry.tarball("no-deps", "1.0.0"),
integrity: registry.integrity("no-deps", "1.0.0"),
},
}),
},
registry.url,
);

const { stderr, exitCode } = await run(dir, "install");
expect(stderr).toContain("migrated lockfile from npm-shrinkwrap.json");
expect(exitCode).toBe(0);
expect(await Bun.file(join(String(dir), "node_modules", "no-deps", "package.json")).json()).toStrictEqual({
name: "no-deps",
version: "1.0.0",
});
const { lock } = await readLock(dir);
expect(lock.packages["no-deps"]).toStrictEqual([
"no-deps@1.0.0",
registry.tarball("no-deps", "1.0.0"),
{},
registry.integrity("no-deps", "1.0.0"),
]);
expect(registry.requests).toStrictEqual(["/no-deps/-/no-deps-1.0.0.tgz"]);
await frozen(dir);
});

test.concurrent("bun pm migrate reads it", async () => {
const dependencies = { x: "^1.0.0" };
using dir = synthetic("npm-migrate-shrinkwrap-pm-migrate", {
"package.json": JSON.stringify({ name: "shrinkwrapped", dependencies }),
"npm-shrinkwrap.json": npmLock("shrinkwrapped", {
"": { name: "shrinkwrapped", dependencies },
"node_modules/x": { version: "1.0.0" },
}),
});
const { stderr, lock } = await migrate(dir);
expect(stderr).toContain("migrated lockfile from npm-shrinkwrap.json");
expect(lock.packages.x).toStrictEqual(["x@1.0.0", `${OFFLINE_REGISTRY}x/-/x-1.0.0.tgz`, {}, ""]);
await frozen(dir);
});

test.concurrent("takes precedence over a package-lock.json next to it, as in npm", async () => {
const dependencies = { x: "^1.0.0" };
using dir = synthetic("npm-migrate-shrinkwrap-precedence", {
"package.json": JSON.stringify({ name: "both", dependencies }),
"npm-shrinkwrap.json": npmLock("both", {
"": { name: "both", dependencies },
"node_modules/x": { version: "1.0.0" },
}),
"package-lock.json": npmLock("both", {
"": { name: "both", dependencies },
"node_modules/x": { version: "1.0.1" },
}),
});
const { stderr, lock } = await migrate(dir);
expect(stderr).toContain("migrated lockfile from npm-shrinkwrap.json");
expect(stderr).not.toContain("package-lock.json");
expect(firstsOf(lock.packages)).toStrictEqual(["x@1.0.0"]);
await frozen(dir);
});

test.concurrent("migration warnings name it", async () => {
const dependencies = { x: "1.0.0" };
using dir = synthetic("npm-migrate-shrinkwrap-warnings", {
"package.json": JSON.stringify({ name: "warned", dependencies }),
"npm-shrinkwrap.json": npmLock("warned", {
"": { name: "warned", dependencies },
"node_modules/x": { version: "1.0.0", patched: { "patches/x.patch": "sha512-x" } },
"node_modules/y": { version: "1.0.0" },
}),
});
const { stderr, lock } = await migrate(dir);
expect(stderr).toContain(
'warn: skipped 1 npm-shrinkwrap.json entry not depended on by any package: "node_modules/y"\n',
);
expect(stderr).toContain('warn: skipped npm patches for "x" from npm-shrinkwrap.json\nnote: bun patch <pkg>\n');
expect(stderr).not.toContain("package-lock.json");
expect(firstsOf(lock.packages)).toStrictEqual(["x@1.0.0"]);
});

test.concurrent("an unsupported lockfileVersion is reported under its own name", async () => {
using dir = synthetic("npm-migrate-shrinkwrap-v1", {
"package.json": JSON.stringify({ name: "v1", dependencies: { "dep-1": "file:dep-1" } }),
"dep-1/package.json": JSON.stringify({ name: "dep-1" }),
"npm-shrinkwrap.json": JSON.stringify({ name: "v1", lockfileVersion: 1, requires: true, dependencies: {} }),
});
const { stderr, exitCode } = await run(dir, "pm", "migrate");
expect(stderr).toBe(
"error: npm-shrinkwrap.json is lockfileVersion 1, which bun cannot migrate\nnote: npm install --package-lock-only --lockfile-version=3\n",
);
expect(exitCode).toBe(1);
expect(fs.existsSync(join(String(dir), "bun.lock"))).toBeFalse();

const install = await run(dir, "install");
expect(install.stderr).toContain(
"warn: npm-shrinkwrap.json is lockfileVersion 1, which bun cannot migrate; resolving from package.json instead\nnote: npm install --package-lock-only --lockfile-version=3\n",
);
expect(install.stderr).not.toContain("migrated lockfile");
expect(install.stderr).not.toContain("failed to migrate");
expect(install.exitCode).toBe(0);
expect((await readLock(dir)).lock.packages["dep-1"]).toStrictEqual(["dep-1@file:dep-1", {}]);
expect(fs.existsSync(join(String(dir), "node_modules", "dep-1", "package.json"))).toBeTrue();
});
});

describe("arborist fixtures", () => {
// Snapshot matchers are unsupported inside a concurrent group, so these stay sequential.
test.each(arboristFixtures.map(f => f.name))("%s", async name => {
Expand Down