diff --git a/src/install/PackageManager/package_json_write_back.rs b/src/install/PackageManager/package_json_write_back.rs index becf4d246f84..1096eaac2f8f 100644 --- a/src/install/PackageManager/package_json_write_back.rs +++ b/src/install/PackageManager/package_json_write_back.rs @@ -10,7 +10,7 @@ use crate::dependency::DependencyExt as _; use crate::lockfile::package::PackageColumns as _; use crate::lockfile::{Lockfile, Package}; use crate::resolution::Tag as ResolutionTag; -use crate::{Dependency, PackageID, PackageNameHash, invalid_package_id}; +use crate::{Dependency, Features, PackageID, PackageNameHash, invalid_package_id}; use super::add_catalog; use super::add_remove_with_filter::{ @@ -267,7 +267,7 @@ fn target_package_ids(lockfile: &Lockfile, edited: &[EditedPackageJson]) -> Vec< /// Re-parses the edited files the way `bun install` would and copies every declared literal that differs (and, for the root, `overrides` + `catalogs`) into `manager.lockfile`, so the next install's differ sees no change. fn sync_lockfile(manager: &mut PackageManager, edited: &[EditedPackageJson]) -> crate::Result<()> { let mut scratch = super::workspace_manifests::ScratchManifests::new(); - scratch.parse_root(manager)?; + scratch.parse_root(manager, Features::main())?; let mut root_pkg = Some(core::mem::take(&mut scratch.root)); let mut parsed: Vec<(usize, Package)> = Vec::with_capacity(edited.len()); for (i, e) in edited.iter().enumerate() { diff --git a/src/install/PackageManager/workspace_manifests.rs b/src/install/PackageManager/workspace_manifests.rs index aeade57954aa..a459b638a2aa 100644 --- a/src/install/PackageManager/workspace_manifests.rs +++ b/src/install/PackageManager/workspace_manifests.rs @@ -1,3 +1,5 @@ +use core::fmt; + use bstr::BStr; use bun_collections::HashMap; use bun_core::{Global, Output}; @@ -11,6 +13,7 @@ use super::add_remove_with_filter::{WorkspaceTarget, fetch_entry, root_package_j use super::workspace_selection::WorkspaceGraph; /// Root + member package.json files parsed the way `bun install` parses them, into a throw-away lockfile. +/// Errors the parse only logs (an invalid catalog range, say) fail it here, as they fail `bun install`. pub(crate) struct ScratchManifests { pub(crate) lockfile: Lockfile, pub(crate) log: bun_ast::Log, @@ -27,7 +30,12 @@ impl ScratchManifests { } /// Must run first: it fills `lockfile.workspace_paths`, which `workspace:` rows in every file resolve through. - pub(crate) fn parse_root(&mut self, manager: &mut PackageManager) -> crate::Result<()> { + /// `features` must include `workspaces` for that, and `is_main` for the catalogs. + pub(crate) fn parse_root( + &mut self, + manager: &mut PackageManager, + features: Features, + ) -> crate::Result<()> { let root_target = WorkspaceTarget { name: Box::default(), name_hash: None, @@ -46,8 +54,9 @@ impl ScratchManifests { &root_source, root_json, &mut resolver, - Features::main(), - ) + features, + )?; + self.fail_on_logged_errors() } pub(crate) fn parse_member( @@ -71,8 +80,77 @@ impl ScratchManifests { &mut resolver, Features::WORKSPACE, )?; + self.fail_on_logged_errors()?; Ok(pkg) } + + fn fail_on_logged_errors(&self) -> crate::Result<()> { + if self.log.has_errors() { + return Err(crate::Error::InstallFailed); + } + Ok(()) + } +} + +/// The workspace versions and catalogs `bun pm pack` / `bun publish` substitute, read from the +/// package.json files as they are now. Not from bun.lock: it has the versions of the last install, +/// and releases bump versions between that install and the publish. +pub struct WorkspaceManifests { + lockfile: Lockfile, + root_package_json_path: Box<[u8]>, +} + +impl WorkspaceManifests { + /// Exits with `bun install`'s errors when the root package.json or a workspace does not parse. + pub fn load(manager: &mut PackageManager) -> WorkspaceManifests { + // Only the two things pack reads: the `workspaces` walk and the catalogs. The root's own + // dependency sections are not parsed, so `bun install`'s checks on them (a `workspace:1.2.3` + // range no workspace satisfies, say) do not decide whether a package packs. + let features = Features { + is_main: true, + workspaces: true, + dependencies: false, + peer_dependencies: false, + ..Features::default() + }; + let mut scratch = ScratchManifests::new(); + if let Err(err) = scratch.parse_root(manager, features) { + crash( + &mut scratch.log, + err, + format_args!("failed to read the workspace's package.json files"), + ); + } + WorkspaceManifests { + lockfile: scratch.lockfile, + root_package_json_path: root_package_json_path(), + } + } + + /// The package.json whose `workspaces` and catalogs these are: the workspace root's when the + /// package being packed is one of its workspaces, otherwise the package's own. + pub fn root_package_json_path(&self) -> &[u8] { + &self.root_package_json_path + } + + /// The `version` in the package.json of the workspace named `name`. `None` when no workspace + /// has that name or its package.json has no (semver) version. + pub fn workspace_version(&self, name: &[u8]) -> Option { + let name_hash: PackageNameHash = bun_semver::string::Builder::string_hash(name); + let version = self.lockfile.workspace_versions.get(&name_hash)?; + Some(version.fmt(self.lockfile.buffers.string_bytes.as_slice())) + } + + /// The range catalog `catalog_name` (`""` and `"default"` both name the default catalog) + /// declares for `dependency_name`, as written in the root package.json. + pub fn catalog_version(&self, catalog_name: &[u8], dependency_name: &[u8]) -> Option<&[u8]> { + let string_buf = self.lockfile.buffers.string_bytes.as_slice(); + let dependency = self + .lockfile + .catalogs + .find(string_buf, catalog_name, dependency_name)?; + Some(dependency.version.literal.slice(string_buf)) + } } /// Graph index i == `targets[i]`; the target whose `name_hash` is `None` is the root. @@ -82,8 +160,8 @@ pub(crate) fn relation_graph( pattern: &[u8], ) -> WorkspaceGraph { let mut scratch = ScratchManifests::new(); - if let Err(err) = scratch.parse_root(manager) { - crash(&mut scratch.log, pattern, err); + if let Err(err) = scratch.parse_root(manager, Features::main()) { + crash_for_filter(&mut scratch.log, pattern, err); } let mut parsed: Vec<(u32, Package)> = Vec::with_capacity(targets.len()); @@ -94,7 +172,7 @@ pub(crate) fn relation_graph( } match scratch.parse_member(manager, target) { Ok(pkg) => parsed.push((i as u32, pkg)), - Err(err) => crash(&mut scratch.log, pattern, err), + Err(err) => crash_for_filter(&mut scratch.log, pattern, err), } } @@ -150,14 +228,23 @@ pub(crate) fn relation_graph( WorkspaceGraph::from_edges(targets.len(), edges) } -fn crash(log: &mut bun_ast::Log, pattern: &[u8], err: crate::Error) -> ! { +fn crash_for_filter(log: &mut bun_ast::Log, pattern: &[u8], err: crate::Error) -> ! { + crash( + log, + err, + format_args!( + "failed to read the workspace dependencies for --filter \"{}\"", + BStr::new(pattern) + ), + ) +} + +/// The parse errors explain the failure when there are any; `what` and `err` are the fallback. +fn crash(log: &mut bun_ast::Log, err: crate::Error, what: fmt::Arguments<'_>) -> ! { if log.has_errors() { let _ = log.print(std::ptr::from_mut(Output::error_writer())); } else { - Output::err_generic( - "failed to read the workspace dependencies for --filter \"{}\": {}", - (BStr::new(pattern), err.name()), - ); + Output::err_generic("{}: {}", (what, err.name())); } Global::crash(); } diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index aafee8e9db8c..7a9e33cc7c23 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -8,9 +8,10 @@ use bun_alloc::AllocError; use bun_collections::StringHashMap; use bun_core::{Global, Output, Progress, fmt as bun_fmt}; use bun_glob as glob; +use bun_install::PackageManager; use bun_install::package_manager::LogLevel; +use bun_install::package_manager::workspace_manifests::WorkspaceManifests; use bun_install::package_manager::workspace_package_json_cache as WorkspacePackageJSONCache; -use bun_install::{Lockfile, PackageManager}; use bun_parsers::json as JSON; // Note: `WorkspacePackageJSONCache` returns the T2 value-subset // `bun_ast::Expr` (see `bun_install::bun_json`), not the full T4 @@ -29,7 +30,6 @@ use crate::cli::run_command::{ConfigureEnvOptions, RunCommand}; use bun_core::ZBox; use bun_core::{ZStr, strings}; use bun_paths::resolve_path; -use bun_semver as Semver; use bun_sha_hmac::sha; use bun_sys::{ self, CloseOnDrop, Dir, Fd, FdDirExt as _, FdExt as _, File, dir_iterator as DirIterator, @@ -111,12 +111,6 @@ pub(crate) struct Context<'a> { // allocator param dropped — global mimalloc (see PORTING.md §Allocators) pub(crate) command_ctx: Command::Context<'a>, - /// `bun pack` does not require a lockfile, but - /// it's possible we will need it for finding - /// workspace versions. This is the only valid lockfile - /// pointer in this file. `manager.lockfile` is incorrect - pub(crate) lockfile: Option<&'a Lockfile>, - pub(crate) bundled_deps: Vec, pub(crate) stats: Stats, @@ -202,8 +196,6 @@ impl PackCommand { ctx: Command::Context<'_>, manager: &mut PackageManager, ) -> crate::Result<()> { - use bun_install::lockfile::{LoadResult, LoadStep}; - if manager.options.log_level != LogLevel::Silent && manager.options.log_level != LogLevel::Quiet { @@ -214,56 +206,6 @@ impl PackCommand { Output::flush(); } - let mut lockfile = Lockfile::default(); - // `log` is non-null after `PackageManager::init()`. - let log_ptr: *mut bun_ast::Log = manager.log; - let manager_ptr: *mut PackageManager = manager; - // SAFETY: `manager_ptr`/`log_ptr` came from live `&mut`; reborrowed - // disjointly (`log` is a separate allocation from the manager fields - // `load_from_cwd` touches). - let load_from_disk_result = lockfile - .load_from_cwd::(Some(unsafe { &mut *manager_ptr }), unsafe { &mut *log_ptr }); - - let lockfile_ref: Option<&Lockfile> = match load_from_disk_result { - LoadResult::Ok(ok) => Some(&*ok.lockfile), - LoadResult::Err(cause) => 'err: { - match cause.step { - LoadStep::OpenFile => { - if cause.value == bun_install::Error::Sys(bun_errno::SystemErrno::ENOENT) { - break 'err None; - } - Output::err_generic( - "failed to open lockfile: {}", - format_args!("{}", cause.value.name()), - ); - } - LoadStep::ParseFile => { - Output::err_generic( - "failed to parse lockfile: {}", - format_args!("{}", cause.value.name()), - ); - } - LoadStep::ReadFile => { - Output::err_generic( - "failed to read lockfile: {}", - format_args!("{}", cause.value.name()), - ); - } - LoadStep::Migrating => { - Output::err_generic( - "failed to migrate lockfile: {}", - format_args!("{}", cause.value.name()), - ); - } - } - if pm_log(manager_ptr).has_errors() { - let _ = pm_log(manager_ptr).print(std::ptr::from_mut(Output::error_writer())); - } - Global::crash(); - } - LoadResult::NotFound => None, - }; - // Note: split-borrowing through // `Context` would conflict with `&mut PackageManager`, so capture the // package.json path before constructing `Context`. @@ -272,7 +214,6 @@ impl PackCommand { let mut pack_ctx = Context { manager, command_ctx: ctx, - lockfile: lockfile_ref, bundled_deps: Vec::new(), stats: Stats::default(), }; @@ -1890,20 +1831,15 @@ fn opt_pack_gzip_level(m: &PackageManager) -> Option<&[u8]> { // `Some` only when FOR_PUBLISH == true. pub(crate) type PackReturn<'a, const FOR_PUBLISH: bool> = Option>; -pub(crate) fn pack( - ctx: &mut Context<'_>, +/// The entry lives in the cache's hash map: fetch it again after anything that inserts or clears. +fn read_package_json<'a>( + manager_ptr: *mut PackageManager, abs_package_json_path: &ZStr, -) -> Result, PackError> { - // Raw pointer for the `pm_workspace_cache`/`pm_log` disjoint-field - // projections and the `'static` lifetime extension when returning - // `Publish::Context`. - let manager_ptr: *mut PackageManager = &raw mut *ctx.manager; - let log_level = ctx.manager.options.log_level; - let bump = pack_bump(); +) -> &'a mut WorkspacePackageJSONCache::MapEntry { // Note: `workspace_package_json_cache` and `log` are disjoint fields on // `PackageManager`; route through raw-pointer field projections so the // two `&mut` borrows don't conflict. - let mut json = match pm_workspace_cache(manager_ptr).get_with_path( + match pm_workspace_cache(manager_ptr).get_with_path( pm_log(manager_ptr), abs_package_json_path.as_bytes(), WorkspacePackageJSONCache::GetJSONOptions { @@ -1929,7 +1865,20 @@ pub(crate) fn pack( Global::crash(); } WorkspacePackageJSONCache::GetResult::Entry(entry) => entry, - }; + } +} + +pub(crate) fn pack( + ctx: &mut Context<'_>, + abs_package_json_path: &ZStr, +) -> Result, PackError> { + // Raw pointer for the `pm_workspace_cache`/`pm_log` disjoint-field + // projections and the `'static` lifetime extension when returning + // `Publish::Context`. + let manager_ptr: *mut PackageManager = &raw mut *ctx.manager; + let log_level = ctx.manager.options.log_level; + let bump = pack_bump(); + let mut json = read_package_json(manager_ptr, abs_package_json_path); if FOR_PUBLISH { if let Some(config) = json.root.get(b"publishConfig") { @@ -2137,53 +2086,10 @@ pub(crate) fn pack( break 'post_scripts (postpack_script, None, None, did_run_scripts); }; - // If any lifecycle scripts ran, they may have modified package.json, - // so we need to re-read it from disk to pick up any changes. + // The scripts may have edited any package.json in the workspace, and the cache predates them. if ran_scripts { - // Invalidate the cached entry by removing it. - // On Windows, the cache key is stored with POSIX path separators, - // so we need to convert the path before removing. - #[cfg(windows)] - let mut cache_key_buf = PathBuffer::uninit(); - #[cfg(windows)] - let cache_key: &[u8] = { - let len = abs_package_json_path.as_bytes().len(); - cache_key_buf[..len].copy_from_slice(abs_package_json_path.as_bytes()); - path::dangerously_convert_path_to_posix_in_place::(&mut cache_key_buf[..len]); - &cache_key_buf[..len] - }; - #[cfg(not(windows))] - let cache_key: &[u8] = abs_package_json_path.as_bytes(); - let _ = pm_workspace_cache(manager_ptr).map.remove(cache_key); - - // Re-read package.json from disk - json = match pm_workspace_cache(manager_ptr).get_with_path( - pm_log(manager_ptr), - abs_package_json_path.as_bytes(), - WorkspacePackageJSONCache::GetJSONOptions { - guess_indentation: true, - ..Default::default() - }, - ) { - WorkspacePackageJSONCache::GetResult::ReadErr(err) => { - Output::err( - err, - "failed to read package.json: {}", - format_args!("{}", bstr::BStr::new(abs_package_json_path.as_bytes())), - ); - Global::crash(); - } - WorkspacePackageJSONCache::GetResult::ParseErr(err) => { - Output::err( - err, - "failed to parse package.json: {}", - format_args!("{}", bstr::BStr::new(abs_package_json_path.as_bytes())), - ); - let _ = pm_log(manager_ptr).print(std::ptr::from_mut(Output::error_writer())); - Global::crash(); - } - WorkspacePackageJSONCache::GetResult::Entry(entry) => entry, - }; + pm_workspace_cache(manager_ptr).map.clear(); + json = read_package_json(manager_ptr, abs_package_json_path); // Re-validate private flag after scripts may have modified it. if FOR_PUBLISH { @@ -2222,7 +2128,13 @@ pub(crate) fn pack( } // Create the edited package.json content after lifecycle scripts have run - let edited_package_json = edit_root_package_json(ctx.lockfile, json)?; + let workspace_manifests = + needs_workspace_manifests(json.root).then(|| WorkspaceManifests::load(ctx.manager)); + if workspace_manifests.is_some() { + // Loading added the other workspaces' package.json files to the cache `json` points into. + json = read_package_json(manager_ptr, abs_package_json_path); + } + let edited_package_json = edit_root_package_json(workspace_manifests.as_ref(), json)?; let root_dir: Dir = 'root_dir: { let mut path_buf = PathBuffer::uninit(); @@ -3282,164 +3194,142 @@ fn add_archive_entry( Ok(entry.clear()) } -/// Strips workspace and catalog protocols from dependency versions then -/// returns the printed json -fn edit_root_package_json( - maybe_lockfile: Option<&Lockfile>, - json: &mut WorkspacePackageJSONCache::MapEntry, -) -> Result, AllocError> { +/// What the tarball's package.json gets in place of a `workspace:` or `catalog:` spec. +enum Substitution<'a> { + /// `workspace:^`, `workspace:~`, `workspace:*`: the workspace's current version behind that prefix. + WorkspaceVersion { prefix: &'static str }, + /// `workspace:1.2.3`, `workspace:1.x`, ...: the range as written. + WorkspaceRange(&'a [u8]), + /// `catalog:` / `catalog:`: that catalog's entry for the dependency. + Catalog { catalog_name: &'a [u8] }, +} + +impl<'a> Substitution<'a> { + fn for_spec(spec: &'a [u8]) -> Option> { + if let Some(range) = strings::without_prefix_if_possible_comptime(spec, b"workspace:") { + return Some(match range { + b"^" => Substitution::WorkspaceVersion { prefix: "^" }, + b"~" => Substitution::WorkspaceVersion { prefix: "~" }, + b"*" => Substitution::WorkspaceVersion { prefix: "" }, + _ => Substitution::WorkspaceRange(range), + }); + } + let catalog_name = strings::without_prefix_if_possible_comptime(spec, b"catalog:")?; + Some(Substitution::Catalog { + catalog_name: strings::trim(catalog_name, &strings::WHITESPACE_CHARS), + }) + } + + fn needs_workspace_manifests(&self) -> bool { + !matches!(self, Substitution::WorkspaceRange(_)) + } +} + +/// Section order is the order errors get reported in. +fn for_each_dependency( + package_json: Expr, + mut f: impl FnMut(&'static [u8], &mut bun_ast::G::Property), +) { use bun_install_types::DependencyGroup; - // preserve deps→dev→peer→optional order (error-message ordering) - for dependency_group in [ + for group in [ DependencyGroup::DEPENDENCIES, DependencyGroup::DEV, DependencyGroup::PEER, DependencyGroup::OPTIONAL, - ] - .map(|g| g.prop) - { - if let Some(dependencies_expr) = json.root.get(dependency_group) { - if let ExprData::EObject(mut dependencies) = dependencies_expr.data { - for dependency in dependencies.properties.slice_mut() { - if dependency.key.is_none() { - continue; - } - if dependency.value.is_none() { - continue; - } - - let Some(package_spec) = dependency - .value - .as_ref() - .expect("infallible: prop has value") - .as_utf8_string_literal() - else { - continue; - }; - if let Some(without_workspace_protocol) = - strings::without_prefix_if_possible_comptime(package_spec, b"workspace:") - { - // TODO: make semver parsing more strict. `^`, `~` are not valid - - if without_workspace_protocol.len() == 1 { - // TODO: this might be too strict - let c = without_workspace_protocol[0]; - if c == b'^' || c == b'~' || c == b'*' { - let dependency_name = match dependency - .key - .as_ref() - .expect("infallible: prop has key") - .as_utf8_string_literal() - { - Some(n) => n, - None => { - Output::err_generic( - "expected string value for dependency name in \"{}\"", - format_args!("{}", bstr::BStr::new(dependency_group)), - ); - Global::crash(); - } - }; - - let resolved = 'failed_to_resolve: { - // find the current workspace version and append to package spec without `workspace:` - let Some(lockfile) = maybe_lockfile else { - break 'failed_to_resolve false; - }; - let Some(workspace_version) = lockfile.workspace_versions.get( - &Semver::string::Builder::string_hash(dependency_name), - ) else { - break 'failed_to_resolve false; - }; - let prefix: &[u8] = match c { - b'^' => b"^", - b'~' => b"~", - b'*' => b"", - _ => unreachable!(), - }; - // Format on the heap then copy into the - // pack arena; `EString::init` erases the - // lifetime. - let tmp = format!( - "{}{}", - bstr::BStr::new(prefix), - workspace_version - .fmt(lockfile.buffers.string_bytes.as_slice()), - ); - let data = pack_bump().alloc_slice_copy(tmp.as_bytes()); - dependency.value = Some(Expr::init( - E::EString::init(data), - Default::default(), - )); - true - }; - if resolved { - continue; - } - - // only produce this error only when we need to get the workspace version - Output::err_generic( - "Failed to resolve workspace version for \"{}\" in `{}`. Run `bun install` and try again.", - ( - bstr::BStr::new(dependency_name), - bstr::BStr::new(dependency_group), - ), - ); - Global::crash(); - } - } + ] { + let Some(section) = package_json.get(group.prop) else { + continue; + }; + let ExprData::EObject(mut dependencies) = section.data else { + continue; + }; + for dependency in dependencies.properties.slice_mut() { + f(group.prop, dependency); + } + } +} - let dup = pack_bump().alloc_slice_copy(without_workspace_protocol); - dependency.value = - Some(Expr::init(E::EString::init(dup), Default::default())); - } else if let Some(catalog_name_str) = - strings::without_prefix_if_possible_comptime(package_spec, b"catalog:") - { - let dep_name_str = dependency - .key - .as_ref() - .expect("infallible: prop has key") - .as_utf8_string_literal() - .expect("infallible: is_string checked"); - - let lockfile = match maybe_lockfile { - Some(l) => l, - None => { - Output::err_generic( - "Failed to resolve catalog version for \"{}\" in `{}` (catalogs require a lockfile).", - ( - bstr::BStr::new(dep_name_str), - bstr::BStr::new(dependency_group), - ), - ); - Global::crash(); - } - }; +/// Packages without such specs must pack whatever state the rest of the workspace is in. +fn needs_workspace_manifests(package_json: Expr) -> bool { + let mut needed = false; + for_each_dependency(package_json, |_, dependency| { + needed |= dependency + .value + .as_ref() + .and_then(Expr::as_utf8_string_literal) + .and_then(Substitution::for_spec) + .is_some_and(|substitution| substitution.needs_workspace_manifests()); + }); + needed +} - let map_buf: &[u8] = lockfile.buffers.string_bytes.as_slice(); - let catalog_name = - strings::trim(catalog_name_str, &strings::WHITESPACE_CHARS); - let Some(dep) = lockfile.catalogs.find(map_buf, catalog_name, dep_name_str) - else { - Output::err_generic( - "Failed to resolve catalog version for \"{}\" in `{}` (no matching catalog dependency).", - ( - bstr::BStr::new(dep_name_str), - bstr::BStr::new(dependency_group), - ), - ); - Global::crash(); - }; +/// Edits `json.root` in place (`bun publish` sends that tree to the registry) and returns it printed. +/// `workspace_manifests` is `Some` whenever `needs_workspace_manifests(json.root)` is. +fn edit_root_package_json( + workspace_manifests: Option<&WorkspaceManifests>, + json: &mut WorkspacePackageJSONCache::MapEntry, +) -> Result, AllocError> { + let bump = pack_bump(); + for_each_dependency(json.root, |dependency_group, dependency| { + let (Some(name), Some(spec)) = (dependency.key.as_ref(), dependency.value.as_ref()) else { + return; + }; + let Some(substitution) = spec + .as_utf8_string_literal() + .and_then(Substitution::for_spec) + else { + return; + }; + let Some(dependency_name) = name.as_utf8_string_literal() else { + Output::err_generic( + "expected string value for dependency name in \"{}\"", + format_args!("{}", bstr::BStr::new(dependency_group)), + ); + Global::crash(); + }; + let manifests = + || workspace_manifests.expect("pack() loads the manifests when a spec needs them"); + let fail = |what: &str, why: fmt::Arguments<'_>| -> ! { + Output::err_generic( + "Failed to resolve {} version for \"{}\" in `{}` ({}).", + ( + what, + bstr::BStr::new(dependency_name), + bstr::BStr::new(dependency_group), + why, + ), + ); + Global::crash(); + }; - let literal = - pack_bump().alloc_slice_copy(dep.version.literal.slice(map_buf)); - dependency.value = - Some(Expr::init(E::EString::init(literal), Default::default())); - } + // `E::EString::init` keeps a pointer to the bytes, so they go into the pack arena. + let replacement: &[u8] = match substitution { + Substitution::WorkspaceRange(range) => bump.alloc_slice_copy(range), + Substitution::WorkspaceVersion { prefix } => { + let Some(version) = manifests().workspace_version(dependency_name) else { + fail( + "workspace", + format_args!( + "\"{}\" has no workspace named \"{}\", or its package.json has no version", + bstr::BStr::new(manifests().root_package_json_path()), + bstr::BStr::new(dependency_name), + ), + ) + }; + bump.alloc_slice_copy(format!("{prefix}{version}").as_bytes()) + } + Substitution::Catalog { catalog_name } => { + match manifests().catalog_version(catalog_name, dependency_name) { + Some(version) => bump.alloc_slice_copy(version), + None => fail("catalog", format_args!("no matching catalog dependency")), } } - } - } + }; + dependency.value = Some(Expr::init( + E::EString::init(replacement), + Default::default(), + )); + }); let has_trailing_newline = !json.source.contents.is_empty() && json.source.contents[json.source.contents.len() - 1] == b'\n'; diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index 60a861015688..8467488ca25a 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -11,8 +11,7 @@ use bun_core::{Environment, Global, Output}; use bun_core::{ZStr, strings}; use bun_dotenv as dotenv; use bun_http as http; -use bun_install::lockfile::{LoadResult, LoadStep}; -use bun_install::{self as install, Lockfile, Npm, PackageManager, Subcommand}; +use bun_install::{self as install, Npm, PackageManager, Subcommand}; use bun_libarchive::lib::{Archive, ArchiveIterator, IteratorResult as ArchiveIterResult}; use bun_parsers::json as json_mod; use bun_paths::resolve_path::{join_abs_string_buf_z, normalize_buf, normalize_buf_z}; @@ -458,64 +457,14 @@ impl<'a, const DIRECTORY_PUBLISH: bool> Context<'a, DIRECTORY_PUBLISH> { ctx: Command::Context<'a>, manager: &'a mut PackageManager, ) -> Result, FromWorkspaceError> { - let mut lockfile = Lockfile::default(); - let manager_ptr: *mut PackageManager = manager; - let log: &mut bun_ast::Log = manager.log_mut(); - // SAFETY: `manager_ptr` was just derived from `manager: &'a mut PackageManager`; - // `log` borrows the disjoint `.log` field, so the re-derived `&mut` - // never touches memory the live `log` borrow covers. - let load_from_disk_result = - lockfile.load_from_cwd::(Some(unsafe { &mut *manager_ptr }), log); - - let lockfile_ref: Option<&Lockfile> = match load_from_disk_result { - LoadResult::Ok(ok) => Some(&*ok.lockfile), - LoadResult::NotFound => None, - LoadResult::Err(cause) => 'err: { - match cause.step { - LoadStep::OpenFile => { - if cause.value == bun_install::Error::Sys(bun_errno::SystemErrno::ENOENT) { - break 'err None; - } - Output::err_generic("failed to open lockfile: {}", (cause.value.name(),)); - } - LoadStep::ParseFile => { - Output::err_generic("failed to parse lockfile: {}", (cause.value.name(),)); - } - LoadStep::ReadFile => { - Output::err_generic("failed to read lockfile: {}", (cause.value.name(),)); - } - LoadStep::Migrating => { - Output::err_generic( - "failed to migrate lockfile: {}", - (cause.value.name(),), - ); - } - } - - if log.has_errors() { - let _ = log.print(std::ptr::from_mut(Output::error_writer())); - } - - Global::crash(); - } - }; - // Note: capture the package.json path before constructing // `pack::Context` so the `&mut PackageManager` borrow doesn't conflict. - // SAFETY: `manager_ptr` came from `&'a mut PackageManager`. - let abs_pkg_json = bun_core::ZBox::from_bytes( - unsafe { &*manager_ptr } - .original_package_json_path - .as_bytes(), - ); + let abs_pkg_json = + bun_core::ZBox::from_bytes(manager.original_package_json_path.as_bytes()); let mut pack_ctx = pack::Context { - // SAFETY: `manager_ptr` came from `&'a mut PackageManager`; - // `lockfile_ref` borrows the local `lockfile`, not the manager, - // so the re-derived `&mut` is the only live manager borrow. - manager: unsafe { &mut *manager_ptr }, + manager, command_ctx: ctx, - lockfile: lockfile_ref, bundled_deps: Vec::new(), stats: pack::Stats::default(), }; diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 34f42ac8a360..94fd591ef1de 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -721,12 +721,12 @@ describe("workspaces", () => { }); } - test("fails gracefully when workspace version fails to resolve", async () => { + test("resolves workspace:* from the workspace's package.json without a lockfile", async () => { await Promise.all([ write( join(packageDir, "package.json"), JSON.stringify({ - name: "pack-workspace-protocol-fail", + name: "pack-workspace-protocol-no-lockfile", version: "2.2.3", workspaces: ["pkgs/*"], dependencies: { @@ -738,19 +738,278 @@ describe("workspaces", () => { write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.1.1" })), ]); - const { err } = await packExpectError(packageDir, bunEnv); - expect(err).toContain( - 'error: Failed to resolve workspace version for "pkg1" in `dependencies`. Run `bun install` and try again.', - ); - - await runBunInstall(bunEnv, packageDir); await pack(packageDir, bunEnv); - const tarball = readTarball(join(packageDir, "pack-workspace-protocol-fail-2.2.3.tgz")); + + const tarball = readTarball(join(packageDir, "pack-workspace-protocol-no-lockfile-2.2.3.tgz")); expect(tarball.entries).toMatchObject([ { "pathname": "package/package.json" }, { "pathname": "package/pkgs/pkg1/package.json" }, { "pathname": "package/root.js" }, ]); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "1.1.1" }); + }); + + test("fails when no workspace with a version matches a workspace:* dependency", async () => { + await Promise.all([ + write(join(packageDir, "package.json"), JSON.stringify({ name: "root", workspaces: ["pkgs/*"] })), + write(join(packageDir, "pkgs", "unversioned", "package.json"), JSON.stringify({ name: "unversioned" })), + write( + join(packageDir, "pkgs", "app1", "package.json"), + JSON.stringify({ name: "app1", version: "1.0.0", dependencies: { "not-a-workspace": "workspace:*" } }), + ), + write( + join(packageDir, "pkgs", "app2", "package.json"), + JSON.stringify({ name: "app2", version: "1.0.0", devDependencies: { "unversioned": "workspace:^" } }), + ), + ]); + + const app1 = await packExpectError(join(packageDir, "pkgs", "app1"), bunEnv); + expect(app1.err).toContain('error: Failed to resolve workspace version for "not-a-workspace" in `dependencies` ('); + expect(app1.err).toContain( + 'package.json" has no workspace named "not-a-workspace", or its package.json has no version).', + ); + expect(await exists(join(packageDir, "pkgs", "app1", "app1-1.0.0.tgz"))).toBeFalse(); + + const app2 = await packExpectError(join(packageDir, "pkgs", "app2"), bunEnv); + expect(app2.err).toContain('error: Failed to resolve workspace version for "unversioned" in `devDependencies` ('); + expect(app2.err).toContain( + 'package.json" has no workspace named "unversioned", or its package.json has no version).', + ); + expect(await exists(join(packageDir, "pkgs", "app2", "app2-1.0.0.tgz"))).toBeFalse(); + }); + + // https://github.com/oven-sh/bun/issues/20477: a release bumps versions (`bun pm version`, + // changesets) after the last `bun install`, so bun.lock still has the versions from before + // the bump when the packages get packed and published. + test("uses the versions and catalogs in the package.json files, not the ones in bun.lock", async () => { + const rootPackageJson = (react: string) => + JSON.stringify({ + name: "mono", + private: true, + workspaces: { packages: ["packages/*"], catalog: { react } }, + }); + const corePackageJson = (version: string) => JSON.stringify({ name: "@acme/core", version }); + const utilsPackageJson = (version: string) => + JSON.stringify({ + name: "@acme/utils", + version, + dependencies: { "@acme/core": "workspace:*" }, + devDependencies: { "@acme/core": "workspace:~" }, + peerDependencies: { "@acme/core": "workspace:^", "react": "catalog:" }, + // optional so that `bun install` does not need a registry to install react + peerDependenciesMeta: { react: { optional: true } }, + }); + const coreDir = join(packageDir, "packages", "core"); + const utilsDir = join(packageDir, "packages", "utils"); + + await Promise.all([ + write(join(packageDir, "package.json"), rootPackageJson("^18.3.1")), + write(join(coreDir, "package.json"), corePackageJson("1.2.3")), + write(join(utilsDir, "package.json"), utilsPackageJson("0.4.0")), + ]); + await runBunInstall(bunEnv, packageDir); + + await Promise.all([ + write(join(packageDir, "package.json"), rootPackageJson("^19.1.0")), + write(join(coreDir, "package.json"), corePackageJson("1.3.0")), + write(join(utilsDir, "package.json"), utilsPackageJson("0.4.1")), + ]); + const lockfile = await file(join(packageDir, "bun.lock")).text(); + expect(lockfile).toContain('"version": "1.2.3"'); + expect(lockfile).toContain('"react": "^18.3.1"'); + + await pack(utilsDir, bunEnv); + + const tarball = readTarball(join(utilsDir, "acme-utils-0.4.1.tgz")); + expect(JSON.parse(tarball.entries[0].contents)).toEqual({ + name: "@acme/utils", + version: "0.4.1", + dependencies: { "@acme/core": "1.3.0" }, + devDependencies: { "@acme/core": "~1.3.0" }, + peerDependencies: { "@acme/core": "^1.3.0", "react": "^19.1.0" }, + peerDependenciesMeta: { react: { optional: true } }, + }); + }); + + test("packing the workspace root uses the versions in the workspaces' package.json files", async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "pack-workspace-root", + version: "2.0.0", + workspaces: ["pkgs/*"], + dependencies: { "pkg1": "workspace:*" }, + }), + ), + write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.1.1" })), + ]); + await runBunInstall(bunEnv, packageDir); + await write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.2.0" })); + + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "pack-workspace-root-2.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "1.2.0" }); + }); + + test("sees the versions a prepack script writes to other workspaces", async () => { + const pkg1PackageJson = join(packageDir, "pkgs", "pkg1", "package.json"); + await Promise.all([ + write(join(packageDir, "package.json"), JSON.stringify({ name: "root", workspaces: ["pkgs/*"] })), + write(pkg1PackageJson, JSON.stringify({ name: "pkg1", version: "1.0.0" })), + write( + join(packageDir, "pkgs", "app", "package.json"), + JSON.stringify({ + name: "app", + version: "1.0.0", + scripts: { prepack: `${bunExe()} bump-pkg1.js` }, + dependencies: { "pkg1": "workspace:*" }, + }), + ), + write( + join(packageDir, "pkgs", "app", "bump-pkg1.js"), + `require("fs").writeFileSync(${JSON.stringify(pkg1PackageJson)}, JSON.stringify({ name: "pkg1", version: "2.0.0" }));`, + ), + ]); + await runBunInstall(bunEnv, packageDir); + + await pack(join(packageDir, "pkgs", "app"), bunEnv); + + const tarball = readTarball(join(packageDir, "pkgs", "app", "app-1.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "2.0.0" }); + }); + + // pack does not read the lockfile, so a lockfile that would not parse (mid-rebase, truncated) does + // not get in the way, whether or not the package has specs to resolve. + const unreadableLockfiles = [ + { label: "an empty bun.lock", file: "bun.lock", contents: "" }, + { + label: "a bun.lock with git conflict markers", + file: "bun.lock", + contents: `{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "root", +<<<<<<< HEAD + "dependencies": {}, +======= + "devDependencies": {}, +>>>>>>> feature + }, + }, + "packages": {}, +} +`, + }, + { label: "a corrupt bun.lockb", file: "bun.lockb", contents: "not a lockfile" }, + ]; + + for (const { label, file: lockfile, contents } of unreadableLockfiles) { + test(`packs a package without workspace specs next to ${label}`, async () => { + await Promise.all([ + write(join(packageDir, "package.json"), JSON.stringify({ name: "pack-bad-lockfile", version: "1.0.0" })), + write(join(packageDir, "index.js"), "module.exports = 1"), + write(join(packageDir, lockfile), contents), + ]); + + const { err } = await pack(packageDir, bunEnv); + expect(err).toBe(""); + + const tarball = readTarball(join(packageDir, "pack-bad-lockfile-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([ + { "pathname": "package/package.json" }, + { "pathname": "package/index.js" }, + ]); + }); + + test(`resolves workspace:* next to ${label}`, async () => { + await Promise.all([ + write(join(packageDir, "package.json"), JSON.stringify({ name: "root", workspaces: ["pkgs/*"] })), + write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.1.1" })), + write( + join(packageDir, "pkgs", "app", "package.json"), + JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "pkg1": "workspace:*" } }), + ), + write(join(packageDir, lockfile), contents), + ]); + + const { err } = await pack(join(packageDir, "pkgs", "app"), bunEnv); + expect(err).toBe(""); + + const tarball = readTarball(join(packageDir, "pkgs", "app", "app-1.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "1.1.1" }); + }); + } + + // Only the root's `workspaces` and catalogs are read to resolve a spec. Its own dependency sections + // are `bun install`'s business: a `workspace:` there is packed as written (see the table + // above) and does not have to match the workspace, whether the root or a member is being packed. + describe("a workspace: range in the root that no workspace satisfies", () => { + beforeEach(async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "root", + version: "1.0.0", + workspaces: ["pkgs/*"], + dependencies: { "pkg1": "workspace:9.9.9", "pkg2": "workspace:*" }, + }), + ), + write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.0.1" })), + write(join(packageDir, "pkgs", "pkg2", "package.json"), JSON.stringify({ name: "pkg2", version: "2.0.0" })), + write( + join(packageDir, "pkgs", "app", "package.json"), + JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "pkg2": "workspace:^" } }), + ), + ]); + }); + + test("packing the root", async () => { + await pack(packageDir, bunEnv); + + const tarball = readTarball(join(packageDir, "root-1.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg1": "9.9.9", "pkg2": "2.0.0" }); + }); + + test("packing a member", async () => { + await pack(join(packageDir, "pkgs", "app"), bunEnv); + + const tarball = readTarball(join(packageDir, "pkgs", "app", "app-1.0.0.tgz")); + expect(JSON.parse(tarball.entries[0].contents).dependencies).toEqual({ "pkg2": "^2.0.0" }); + }); + }); + + describe("a workspaces entry that does not exist", () => { + beforeEach(async () => { + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ name: "root", workspaces: ["pkgs/pkg1", "pkgs/app", "pkgs/plain", "pkgs/missing"] }), + ), + write(join(packageDir, "pkgs", "pkg1", "package.json"), JSON.stringify({ name: "pkg1", version: "1.0.0" })), + write( + join(packageDir, "pkgs", "app", "package.json"), + JSON.stringify({ name: "app", version: "1.0.0", dependencies: { "pkg1": "workspace:*" } }), + ), + write(join(packageDir, "pkgs", "plain", "package.json"), JSON.stringify({ name: "plain", version: "1.0.0" })), + ]); + }); + + test("fails a pack that has to resolve a workspace: spec, with bun install's error", async () => { + const { err } = await packExpectError(join(packageDir, "pkgs", "app"), bunEnv); + expect(err).toContain('error: Workspace not found "pkgs/missing"'); + expect(err).toContain("package.json:1:"); + expect(await exists(join(packageDir, "pkgs", "app", "app-1.0.0.tgz"))).toBeFalse(); + }); + + test("does not affect a pack that has nothing to resolve", async () => { + const { err } = await pack(join(packageDir, "pkgs", "plain"), bunEnv); + expect(err).toBe(""); + expect(await exists(join(packageDir, "pkgs", "plain", "plain-1.0.0.tgz"))).toBeTrue(); + }); }); }); diff --git a/test/cli/install/bun-publish.test.ts b/test/cli/install/bun-publish.test.ts index c701b322806c..c404845f8f2a 100644 --- a/test/cli/install/bun-publish.test.ts +++ b/test/cli/install/bun-publish.test.ts @@ -741,6 +741,114 @@ test("can publish workspace package", async () => { expect(await file(join(packageDir, "node_modules", "publish-pkg-3", "package.json")).json()).toEqual(pkgJson); }); +// https://github.com/oven-sh/bun/issues/20477: releases bump the versions after the last `bun install`, +// so the manifest sent to the registry has to use the package.json files, not bun.lock. +test("publishes the workspace versions and catalog ranges currently in package.json", async () => { + let captured: any = null; + using mock = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") captured = await req.json(); + return new Response("OK", { status: 200 }); + }, + }); + + const packageDir = tmpdirSync(); + const coreDir = join(packageDir, "packages", "core"); + const utilsDir = join(packageDir, "packages", "utils"); + const rootPackageJson = (react: string) => + JSON.stringify({ name: "mono", private: true, workspaces: { packages: ["packages/*"], catalog: { react } } }); + const corePackageJson = (version: string) => JSON.stringify({ name: "@acme/core", version }); + const utilsPackageJson = (version: string) => + JSON.stringify({ + name: "@acme/utils", + version, + dependencies: { "@acme/core": "workspace:*" }, + peerDependencies: { "@acme/core": "workspace:^", "react": "catalog:" }, + // optional so that `bun install` does not ask the mock registry for react + peerDependenciesMeta: { react: { optional: true } }, + }); + + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: { url: `http://localhost:${mock.port}`, token: "unused" }, + }, + }), + ), + write(join(packageDir, "package.json"), rootPackageJson("^18.3.1")), + write(join(coreDir, "package.json"), corePackageJson("1.2.3")), + write(join(utilsDir, "package.json"), utilsPackageJson("0.4.0")), + ]); + await runBunInstall(env, packageDir); + + // the release bump, without another install + await Promise.all([ + write(join(packageDir, "package.json"), rootPackageJson("^19.1.0")), + write(join(coreDir, "package.json"), corePackageJson("1.3.0")), + write(join(utilsDir, "package.json"), utilsPackageJson("0.4.1")), + ]); + + const { err, exitCode } = await publish(env, utilsDir); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + + expect(captured.versions["0.4.1"]).toMatchObject({ + name: "@acme/utils", + version: "0.4.1", + dependencies: { "@acme/core": "1.3.0" }, + peerDependencies: { "@acme/core": "^1.3.0", "react": "^19.1.0" }, + }); +}); + +test("publishes a workspace package next to a bun.lock that does not parse", async () => { + let captured: any = null; + using mock = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") captured = await req.json(); + return new Response("OK", { status: 200 }); + }, + }); + + const packageDir = tmpdirSync(); + const utilsDir = join(packageDir, "packages", "utils"); + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { registry: { url: `http://localhost:${mock.port}`, token: "unused" } }, + }), + ), + write( + join(packageDir, "package.json"), + JSON.stringify({ name: "mono", private: true, workspaces: ["packages/*"] }), + ), + write(join(packageDir, "bun.lock"), "<<<<<<< HEAD\n"), + write( + join(packageDir, "packages", "core", "package.json"), + JSON.stringify({ name: "@acme/core", version: "1.3.0" }), + ), + write( + join(utilsDir, "package.json"), + JSON.stringify({ name: "@acme/utils", version: "0.4.1", dependencies: { "@acme/core": "workspace:^" } }), + ), + ]); + + const { err, exitCode } = await publish(env, utilsDir); + expect(err).toBe(""); + expect(exitCode).toBe(0); + + expect(captured.versions["0.4.1"]).toMatchObject({ + name: "@acme/utils", + version: "0.4.1", + dependencies: { "@acme/core": "^1.3.0" }, + }); +}); + describe("--dry-run", async () => { test("does not publish", async () => { const { packageDir, packageJson } = await registry.createTestDir(); diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index 159c6aabe4aa..bb2428f01269 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -1598,11 +1598,64 @@ describe("peer dependencies", () => { }); }); - // package.json is edited after the install so bun.lock's catalogs are the ones that lack the entry. + // The root catalog is the one in package.json right now, not the one bun.lock recorded at the last install. + test.concurrent("bun pm pack substitutes the catalog range edited into package.json after the install", async () => { + const dir = await makeRepo({ + catalog: { "no-deps": "^1.0.0" }, + peerSpec: "catalog:", + libVersion: "1.2.3", + linker: "hoisted", + }); + await install(dir, "hoisted"); + await rewriteRootPackageJson(dir, { catalog: { "no-deps": "^2.0.0" } }); + expect(await Bun.file(join(dir, "bun.lock")).text()).toContain('"no-deps": "^1.0.0"'); + + const libDir = join(dir, "packages", "lib"); + await pack(libDir, bunEnv); + const tarball = readTarball(join(libDir, "lib-1.2.3.tgz")); + const packageJson = tarball.entries.find( + (entry: { pathname: string }) => entry.pathname === "package/package.json", + ); + expect(JSON.parse(packageJson.contents)).toStrictEqual({ + name: "lib", + version: "1.2.3", + peerDependencies: { "no-deps": "^2.0.0" }, + }); + }); + + // The catalog parse logs this error and skips the entry; pack reports it like `bun install` does + // instead of complaining that the entry is missing. + test.concurrent("bun pm pack reports an invalid catalog range at its definition", async () => { + const dir = await makeRepo({ + catalog: { "no-deps": ".:" }, + peerSpec: "catalog:", + libVersion: "1.2.3", + linker: "hoisted", + }); + const libDir = join(dir, "packages", "lib"); + + await using proc = spawn({ + cmd: [bunExe(), "pm", "pack"], + cwd: libDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const normalizedErr = normalizeBunSnapshot(err, dir); + expect(normalizedErr).toContain("error: Invalid dependency version\n"); + expect(normalizedErr).toContain("at /package.json:"); + expect(normalizedErr).not.toContain("no matching catalog dependency"); + expect(normalizeBunSnapshot(out, dir)).toBe("bun pack ()"); + expect(exitCode).toBe(1); + expect(existsSync(join(libDir, "lib-1.2.3.tgz"))).toBeFalse(); + }); + + // lib's package.json is edited after the install; the root catalog never had these entries. describe.each([ ["a-dep", "catalog:"], ["no-deps", "catalog:missing"], - ] as const)("bun pm pack with a %s peer of %s missing from the lockfile's catalogs", (peerName, peerSpec) => { + ] as const)("bun pm pack with a %s peer of %s missing from the catalogs", (peerName, peerSpec) => { test.concurrent("fails without writing a tarball", async () => { const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" },