Skip to content
Merged
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
56 changes: 40 additions & 16 deletions crates/turborepo-repository/src/package_manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -994,16 +994,15 @@ impl PackageManager {
.then(|| PackageManager::Aube {
lockfile: Box::new(aube::underlying_lockfile_manager(repo_root)),
});
let native_nub =
repo_root
.join_component(nub::LOCKFILE)
.exists()
.then(|| PackageManager::Nub {
lockfile: Box::new(nub::underlying_lockfile_manager(repo_root)),
});
// nub is recognized ONLY through the `packageManager` field /
// `devEngines.packageManager` (handled in `get_package_manager`), never
// from the presence of its `lock.yaml`: nub's lockfile name is
// deliberately neutral and nub is lockfile-compatible with whatever the
// project already uses, so the file's presence is not a reliable nub
// signal. Lockfile parsing still happens once nub is detected via the
// field — only the name-based *detection* is dropped here.
let detected_package_managers = native_aube
.into_iter()
.chain(native_nub)
.map(Ok)
.chain(PnpmDetector::new(repo_root))
.chain(NpmDetector::new(repo_root))
Expand Down Expand Up @@ -1325,9 +1324,13 @@ impl PackageManager {
pnpm::link_workspace_packages(pnpm_version, repo_root)
}
PackageManager::Yarn | PackageManager::Bun | PackageManager::Npm => true,
// nub links workspace packages by default, delegating to the
// underlying manager's behavior where it has one.
PackageManager::Nub { lockfile } => lockfile.link_workspace_packages(repo_root),
// nub links a local workspace package for a bare version specifier
// (e.g. `"@repo/ui": "*"`), like npm/yarn/bun and unlike pnpm. It
// does not require the `workspace:` protocol. Delegating to the
// underlying lockfile format (pnpm) would default this to false and
// drop every internal workspace edge declared without `workspace:`,
// so resolve it from nub's own behavior instead.
PackageManager::Nub { .. } => true,
PackageManager::Aube { lockfile } => match lockfile.as_ref() {
PackageManager::Pnpm9 | PackageManager::Pnpm | PackageManager::Pnpm6 => {
let pnpm_version = pnpm::PnpmVersion::try_from(lockfile.as_ref())
Expand Down Expand Up @@ -1878,8 +1881,11 @@ mod tests {
}

#[test]
fn test_native_nub_lockfile_with_existing_lockfile_is_ambiguous() -> Result<(), Error> {
fn test_native_nub_lockfile_is_ignored_by_detection() -> Result<(), Error> {
let (_dir, repo_root) = temp_repo_root()?;
// A native `lock.yaml` does not participate in detection (nub is
// field-only), so a co-present `pnpm-lock.yaml` resolves cleanly to pnpm
// rather than producing an ambiguous multi-manager result.
std::fs::write(
repo_root.join_component(nub::LOCKFILE).as_std_path(),
"lockfileVersion: '9.0'\nimporters:\n .: {}\n",
Expand All @@ -1889,10 +1895,28 @@ mod tests {
"lockfileVersion: '9.0'\nimporters:\n .: {}\n",
)?;

assert!(matches!(
PackageManager::detect_package_manager(&repo_root),
Err(Error::MultiplePackageManagers { .. })
));
assert_eq!(
PackageManager::detect_package_manager(&repo_root)?,
PackageManager::Pnpm
);
Ok(())
}

#[test]
fn test_nub_links_workspace_packages_unlike_underlying_pnpm() -> Result<(), Error> {
let (_dir, repo_root) = temp_repo_root()?;
// A pnpm9 manager with no workspace config defaults link-workspace-packages
// to false; nub links bare specifiers (`"*"`) to local workspace packages,
// so the Nub variant must report true regardless of its pnpm9 lockfile.
// Without this, every internal workspace edge declared without the
// `workspace:` protocol is dropped from the graph.
assert!(!PackageManager::Pnpm9.link_workspace_packages(&repo_root));
assert!(
PackageManager::Nub {
lockfile: Box::new(PackageManager::Pnpm9)
}
.link_workspace_packages(&repo_root)
);
Ok(())
}

Expand Down
58 changes: 35 additions & 23 deletions crates/turborepo-repository/src/package_manager/nub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
//! Node and ships a pnpm-compatible package-manager command surface. Unlike the
//! other supported package managers, nub does not define its own lockfile
//! format: it is lockfile-compatible with whatever the project already uses
//! (npm, pnpm, yarn, or bun). For that reason nub is recognized through the
//! `packageManager` field in `package.json` (`"nub@x.y.z"`) or nub's native
//! `lock.yaml`; a bare foreign lockfile alone does not imply nub.
//! (npm, pnpm, yarn, or bun). For that reason nub is recognized ONLY through
//! the `packageManager` field in `package.json` (`"nub@x.y.z"`) or
//! `devEngines.packageManager` — never from the presence of a lockfile. nub's
//! `lock.yaml` name is deliberately neutral, so its presence is not a reliable
//! nub signal; a bare lockfile (nub's or any other) alone does not imply nub.
//!
//! Because Turborepo needs a parsed lockfile for pruning and cache hashing, the
//! [`PackageManager::Nub`] variant carries the concrete package manager whose
//! lockfile is present in the repository, and lockfile-related operations
//! delegate to it.
//! delegate to it. That parsing happens once nub is detected via the field;
//! the lockfile name is never itself a detection signal.

use turbopath::AbsoluteSystemPath;

Expand Down Expand Up @@ -66,7 +69,34 @@ mod tests {
let detected = PackageManager::detect_package_manager(&repo_root).unwrap();
assert_eq!(detected, PackageManager::Npm);

// A foreign lockfile alone must not imply nub.
// The `packageManager` field is the nub signal.
let package_json = PackageJson {
package_manager: Some(Spanned::new("nub@0.1.0".to_string())),
..Default::default()
};
let pm = PackageManager::read_or_detect_package_manager(&package_json, &repo_root).unwrap();
assert!(matches!(pm, PackageManager::Nub { .. }));
}

#[test]
fn test_native_lockfile_without_field_is_not_nub() {
let dir = tempdir().unwrap();
let repo_root = AbsoluteSystemPathBuf::try_from(dir.path()).unwrap();

// A native `lock.yaml` with NO `packageManager` field must NOT be
// detected as nub: nub is recognized only via the field. With no other
// recognized lockfile present, detection finds no package manager.
repo_root
.join_component(LOCKFILE)
.create_with_contents("lockfileVersion: '9.0'\n")
.unwrap();
let detected = PackageManager::detect_package_manager(&repo_root);
assert!(
!matches!(detected, Ok(PackageManager::Nub { .. })),
"a bare lock.yaml must not be detected as nub, got {detected:?}"
);

// The same repo IS nub once the field is present.
let package_json = PackageJson {
package_manager: Some(Spanned::new("nub@0.1.0".to_string())),
..Default::default()
Expand Down Expand Up @@ -144,22 +174,4 @@ mod tests {
PackageManager::Pnpm9
);
}

#[test]
fn test_nub_detected_from_native_lockfile() {
let dir = tempdir().unwrap();
let repo_root = AbsoluteSystemPathBuf::try_from(dir.path()).unwrap();

repo_root
.join_component(LOCKFILE)
.create_with_contents("lockfileVersion: '9.0'\n")
.unwrap();

assert_eq!(
PackageManager::detect_package_manager(&repo_root).unwrap(),
PackageManager::Nub {
lockfile: Box::new(PackageManager::Pnpm9)
}
);
}
}
2 changes: 1 addition & 1 deletion packages/turbo-workspaces/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const PACKAGE_MANAGERS: Record<
command: "nub",
installArgs: ["install"],
version: "latest",
executable: "nub",
executable: "nub exec",
semver: "*",
default: true
}
Expand Down
Loading