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
4 changes: 4 additions & 0 deletions docs/pm/cli/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ bun install --frozen-lockfile

Bun does not enable `--frozen-lockfile` automatically in CI; pass the flag or use `bun ci`. If there is no lockfile at all, `--frozen-lockfile` installs from `package.json` without writing one.

If the project has a `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml` but no `bun.lock`, `--frozen-lockfile` migrates that lockfile in memory and installs from it. The install fails if the migrated lockfile does not match `package.json`. Bun writes nothing in this case: no `bun.lock`, and none of the `package.json` edits a [pnpm migration](#pnpm-migration) makes. It prints a `note:` instead. To finish the migration, run `bun install` without the flag and commit the files it writes. The `bun.lockb` to `bun.lock` conversion described in [lockfile](/pm/lockfile) is the one write `--frozen-lockfile` still performs.

`--frozen-lockfile` works on a pruned monorepo checkout (e.g. `turbo prune` output, or a Docker context with only some workspace folders copied in). If a workspace listed in `bun.lock` is missing its `package.json` on disk, Bun skips it with a `note:` and does not install its exclusive dependencies. If a remaining workspace depends on a skipped one, the install fails.

To validate the lockfile without installing, use `bun install --frozen-lockfile --dry-run`.
Expand Down Expand Up @@ -516,6 +518,8 @@ bun install

Migration only runs when `bun.lock` is absent. There is currently no opt-out flag for pnpm migration.

Bun writes the migrated `bun.lock` and the `package.json` edits described below together, and only when the command saves a lockfile. `bun install`, `bun add`, `bun remove`, `bun update`, `bun pm migrate`, and `bun pm trust` write both files. `bun install --frozen-lockfile` (and `bun ci`), `bun install --dry-run`, `bun install --no-save`, and commands that only read the lockfile, such as `bun outdated` and `bun pm why`, migrate in memory and leave both files untouched.

The migration process handles:

### Lockfile Migration
Expand Down
4 changes: 3 additions & 1 deletion docs/pm/lockfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Yes

#### Generate a lockfile without installing?

To generate a lockfile without installing to `node_modules`, use the `--lockfile-only` flag. Bun always saves the lockfile to disk, even if it is already up to date with your project's `package.json`(s). The exception is when `--frozen-lockfile` (or `--production`) is set.
To generate a lockfile without installing to `node_modules`, use the `--lockfile-only` flag. Bun always saves the lockfile to disk, even if it is already up to date with your project's `package.json`(s). The exceptions are `--frozen-lockfile` (or `--production`), `--dry-run`, and `--no-save`, which save nothing.

```bash terminal icon="terminal"
bun install --lockfile-only
Expand Down Expand Up @@ -65,3 +65,5 @@ When you run `bun install` in a project without a `bun.lock`, Bun automatically
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.

Bun preserves the original lockfile. You can remove it manually after verification.

Bun writes the migrated `bun.lock` only when the command saves a lockfile. `bun install --frozen-lockfile` (and `bun ci`), `--dry-run`, `--no-save`, and read-only commands such as `bun outdated` use the migrated lockfile in memory and write nothing. Under `--frozen-lockfile`, Bun prints a `note:` asking you to run `bun install` and commit the result. To migrate without installing, run `bun pm migrate` or `bun install --lockfile-only`.
6 changes: 6 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,10 @@ pub struct PackageManager {
// package.json cache entries that differ from disk; written by package_json_write_back::flush.
pub(crate) edited_package_jsons: Vec<package_json_write_back::EditedPackageJson>,

// pnpm migration: what it moved into the cached root package.json. The file is only written along with the
// migrated lockfile (package_json_write_back::record_migrated_root); loads that are never saved leave it alone.
pub(crate) migrated_package_json_moves: Vec<&'static str>,

// bun add: catalog references decided per target and the root entries they need; see add_catalog.rs
pub(crate) catalog_add: add_catalog::State,

Expand Down Expand Up @@ -2120,6 +2124,7 @@ pub fn init(
wr!(filtered_link_targets, None);
wr!(pending_filtered_write, None);
wr!(edited_package_jsons, Vec::new());
wr!(migrated_package_json_moves, Vec::new());
wr!(catalog_add, add_catalog::State::default());
wr!(patched_dependencies_to_remove, ArrayHashMap::default());
wr!(last_reported_slow_lifecycle_script_at, 0);
Expand Down Expand Up @@ -2569,6 +2574,7 @@ fn init_with_runtime_once(
wr!(filtered_link_targets, None);
wr!(pending_filtered_write, None);
wr!(edited_package_jsons, Vec::new());
wr!(migrated_package_json_moves, Vec::new());
wr!(catalog_add, add_catalog::State::default());
wr!(patched_dependencies_to_remove, ArrayHashMap::default());
wr!(last_reported_slow_lifecycle_script_at, 0);
Expand Down
48 changes: 41 additions & 7 deletions src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,12 @@ pub fn install_with_manager(
}
}

if manager.options.enable.frozen_lockfile() && !manager.options.do_.save_lockfile() {
if let Some(source) = load_result.migrated().source_lockfile_name() {
note_migrated_lockfile_not_saved(manager, source);
}
}

// BACKREF: `manager.lockfile` is a `Box<Lockfile>` whose allocation is
// never replaced for the remainder of this function (only its fields
// mutate). Wrap once as `ParentRef` so the two `save_lockfile` read sites
Expand Down Expand Up @@ -966,7 +972,7 @@ pub fn install_with_manager(
// It's unnecessary work to re-save the lockfile if there are no changes.
// A loaded text lockfile is never re-saved just to bump its version: an
// existing `bun.lock` keeps the version it was written with.
let should_save_lockfile = saves_migrated_lockfile(&load_result, save_format)
let should_save_lockfile = converts_binary_lockfile_to_text(&load_result, save_format)
// check `save_lockfile` after checking if loaded from binary and save format is text
// because `save_lockfile` is set to false for `--frozen-lockfile`
|| (manager.options.do_.save_lockfile()
Expand All @@ -980,6 +986,7 @@ pub fn install_with_manager(
|| manager.options.enable.force_save_lockfile()));

if should_save_lockfile {
super::package_json_write_back::record_migrated_root(manager);
save_lockfile(
manager,
&load_result,
Expand Down Expand Up @@ -1469,7 +1476,9 @@ fn overrides_field_name(
}

pub(crate) fn loaded_lockfile_name(load_result: &lockfile::LoadResult) -> &'static str {
if load_result.loaded_from_binary_lockfile() {
if let Some(source) = load_result.migrated().source_lockfile_name() {
source
} else if load_result.loaded_from_binary_lockfile() {
"bun.lockb"
} else {
"bun.lock"
Expand Down Expand Up @@ -2206,19 +2215,42 @@ fn run_security_scanner(
}
}

// bun.lockb / package-lock.json / yarn.lock / pnpm-lock.yaml -> bun.lock is written even under --frozen-lockfile.
fn saves_migrated_lockfile(
// The documented bun.lockb -> bun.lock recipe (`--save-text-lockfile --frozen-lockfile --lockfile-only`) is the one
// write that happens while `Do::SAVE_LOCKFILE` is off (--frozen-lockfile, --dry-run, --no-save). A migrated
// package-lock.json / yarn.lock / pnpm-lock.yaml is saved through `FORCE_SAVE_LOCKFILE` like any other change, so
// those flags keep it in memory. (A migrated load can report `Format::Binary` too, hence the `migrated` check.)
fn converts_binary_lockfile_to_text(
load_result: &lockfile::LoadResult,
save_format: lockfile::Format,
) -> bool {
save_format == lockfile::Format::Text
&& matches!(
load_result,
lockfile::LoadResult::Ok(ok)
if ok.format == lockfile::Format::Binary || ok.migrated != lockfile::Migrated::None
if ok.format == lockfile::Format::Binary && ok.migrated == lockfile::Migrated::None
)
}

#[cold]
#[inline(never)]
fn note_migrated_lockfile_not_saved(manager: &PackageManager, source: &str) {
if manager.options.log_level.is_silent() {
return;
}
Output::flush();
let files = if manager.migrated_package_json_moves.is_empty() {
"bun.lock"
} else {
"bun.lock and package.json"
};
bun_core::note!(
"the lockfile is frozen, so the migration from {} was not written to {}; run 'bun install' and commit the result",
source,
files,
);
Output::flush();
}

#[cold]
#[inline(never)]
#[allow(clippy::too_many_arguments)]
Expand All @@ -2232,8 +2264,8 @@ fn save_lockfile_only(
packages_len_before_install: usize,
log_level: Options::LogLevel,
) -> crate::Result<()> {
if (manager.options.enable.frozen_lockfile()
&& !saves_migrated_lockfile(load_result, save_format))
if (!manager.options.do_.save_lockfile()
&& !converts_binary_lockfile_to_text(load_result, save_format))
|| (manager.subcommand == Subcommand::Dedupe && manager.dedupe_report.is_none())
{
Output::flush();
Expand All @@ -2246,6 +2278,7 @@ fn save_lockfile_only(
packages_len_before_install,
)?;

super::package_json_write_back::record_migrated_root(manager);
let saved = save_lockfile(
manager,
load_result,
Expand All @@ -2255,6 +2288,7 @@ fn save_lockfile_only(
packages_len_before_install,
log_level,
)?;
super::package_json_write_back::flush(manager)?;

if manager.subcommand == Subcommand::Dedupe {
if manager.options.do_.summary() {
Expand Down
31 changes: 31 additions & 0 deletions src/install/PackageManager/package_json_write_back.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@ fn root_target() -> WorkspaceTarget {
}
}

/// The root package.json a pnpm migration edited in memory (`pnpm::update_package_json_after_migration`), announced
/// once; `None` when the load did not touch it. Only the places that save the migrated lockfile ask for it, so a load
/// that is never saved (`--frozen-lockfile`, `--dry-run`, `bun outdated`, ...) leaves the file alone.
fn take_migrated_root(manager: &mut PackageManager) -> Option<WorkspaceTarget> {
if manager.migrated_package_json_moves.is_empty() {
return None;
}
let moved = core::mem::take(&mut manager.migrated_package_json_moves);
if !manager.options.log_level.is_silent() {
bun_core::pretty_errorln!("<d>moved {} in <r><green>package.json<r>", moved.join(", "));
}
Some(root_target())
}

/// `install_with_manager`: written by `flush` along with the command's other package.json edits.
pub(crate) fn record_migrated_root(manager: &mut PackageManager) {
if let Some(root) = take_migrated_root(manager) {
record(manager, root, false);
}
}

/// `bun pm migrate` and `bun pm trust` save the lockfile without consulting `--dry-run` / `--no-save`, so the
/// package.json that belongs with it is written the same way instead of through `flush`, which those flags disable.
pub fn write_migrated_root(manager: &mut PackageManager) {
if let Some(root) = take_migrated_root(manager) {
if !write_target(manager, &root) {
Global::exit(1);
}
}
}

/// Phase 1 (before bun.lock is saved): write the resolved versions into the edited package.json entries and re-derive bun.lock's declared columns from them.
#[inline]
pub(crate) fn edit_after_resolve(manager: &mut PackageManager) -> crate::Result<()> {
Expand Down
74 changes: 34 additions & 40 deletions src/install/PackageManager/updatePackageJSONAndInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,12 +486,12 @@ fn update_package_json_and_install_with_manager_with_updates(
// The Smarter™ approach is you resolve ahead of time and write to disk once!
// But, turns out that's slower in any case where more than one package has to be resolved (most of the time!)
// Concurrent network requests are faster than doing one and then waiting until the next batch
let new_package_json_source: Vec<u8> = package_json_writer
.ctx
.written_without_trailing_zero()
.to_vec();
// The cache entry (`Cow<'static, [u8]>`) outlives this stack frame, so it needs its own copy.
current_package_json.source.contents = Cow::Owned(new_package_json_source.clone());
current_package_json.source.contents = Cow::Owned(
package_json_writer
.ctx
.written_without_trailing_zero()
.to_vec(),
);
// The edits above went into a promoted copy
// (`current_package_json_root`), so re-parse the
// printed source so the cached AST (consumed by `FolderResolver` for workspace
Expand Down Expand Up @@ -664,40 +664,34 @@ fn update_package_json_and_install_with_manager_with_updates(
}

if manager.options.do_.contains(Do::WRITE_PACKAGE_JSON) {
let (source, path): (&[u8], &ZStr) =
if matches!(manager.options.patch_features, PatchFeatures::Commit { .. }) {
'source_and_path: {
let root_package_json_entry = match manager
.workspace_package_json_cache
.get_with_path(
manager.log_mut(),
root_package_json_path.as_bytes(),
GetJSONOptions::default(),
)
.unwrap()
{
Ok(e) => e,
Err(err) => {
Output::err(
err,
"failed to read/parse package.json at '{s}'",
(BStr::new(root_package_json_path.as_bytes()),),
);
Global::exit(1);
}
};

break 'source_and_path (
&root_package_json_entry.source.contents,
root_package_json_path,
);
}
} else {
(
&new_package_json_source,
manager.original_package_json_path.as_zstr(),
)
};
let path: &ZStr = if matches!(manager.options.patch_features, PatchFeatures::Commit { .. })
{
root_package_json_path
} else {
manager.original_package_json_path.as_zstr()
};
// The cache entry, not the source printed above: a lockfile migration during the install edits the
// root entry too (`pnpm::update_package_json_after_migration`).
let entry = match manager
.workspace_package_json_cache
.get_with_path(
manager.log_mut(),
path.as_bytes(),
GetJSONOptions::default(),
)
.unwrap()
{
Ok(e) => e,
Err(err) => {
Output::err(
err,
"failed to read/parse package.json at '{s}'",
(BStr::new(path.as_bytes()),),
);
Global::exit(1);
}
};
let source: &[u8] = &entry.source.contents;

// Now that we've run the install step
// We can save our in-memory package.json to disk
Expand Down
18 changes: 18 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,17 @@ pub enum Migrated {
Pnpm,
}

impl Migrated {
pub(crate) fn source_lockfile_name(self) -> Option<&'static str> {
match self {
Migrated::None => None,
Migrated::Npm => Some("package-lock.json"),
Migrated::Yarn => Some("yarn.lock"),
Migrated::Pnpm => Some("pnpm-lock.yaml"),
}
}
}

pub struct LoadResultErr {
pub step: LoadStep,
pub value: BunError,
Expand Down Expand Up @@ -383,6 +394,13 @@ impl<'a> LoadResult<'a> {
}
}

pub(crate) fn migrated(&self) -> Migrated {
match self {
LoadResult::Ok(ok) => ok.migrated,
_ => Migrated::None,
}
}

pub(crate) fn save_format(&self, options: &PackageManagerOptions) -> LockfileFormat {
match self {
LoadResult::NotFound => {
Expand Down
2 changes: 1 addition & 1 deletion src/install/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub fn detect_and_load_other_lockfile<'a>(
let Ok(data) = File::read_from(dir, b"pnpm-lock.yaml") else {
break 'pnpm;
};
let migrate_result = match pnpm::migrate_pnpm_lockfile(this, manager, log, &data, dir) {
let migrate_result = match pnpm::migrate_pnpm_lockfile(this, manager, log, &data) {
Ok(r) => r,
Err(MigratePnpmLockfileError::PnpmLockfileTooOld) => {
report_unsupported_lockfile_version(
Expand Down
Loading