From ec2eacb3b02d8d81a8f0e52c6101bd945322c94a Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:15:30 -0700 Subject: [PATCH] fix(repository): field-only nub detection + link workspace packages for nub Detect nub only through the packageManager field / devEngines.packageManager, not the presence of its lock.yaml. nub's lockfile name is 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 once nub is detected via the field is unchanged. Return true from link_workspace_packages for the Nub variant. nub links a local workspace package for a bare version specifier ("pkg": "*"), like npm/yarn/bun, so delegating to the underlying pnpm lockfile format (which defaults to false on pnpm 9) dropped every internal workspace edge declared without the workspace: protocol, causing stale cache and turbo prune --docker to drop internal deps. Also: set the nub executable to 'nub exec' so the post-scaffold remote-cache hint reads 'nub exec turbo login' (turbo is a local binary; nub runs local binaries via 'nub exec', not as a bare 'nub ' subcommand). --- .../src/package_manager/mod.rs | 56 +++++++++++++----- .../src/package_manager/nub.rs | 58 +++++++++++-------- packages/turbo-workspaces/src/install.ts | 2 +- 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/crates/turborepo-repository/src/package_manager/mod.rs b/crates/turborepo-repository/src/package_manager/mod.rs index a09f8e38a071b..d8fa73f838bca 100644 --- a/crates/turborepo-repository/src/package_manager/mod.rs +++ b/crates/turborepo-repository/src/package_manager/mod.rs @@ -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)) @@ -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()) @@ -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", @@ -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(()) } diff --git a/crates/turborepo-repository/src/package_manager/nub.rs b/crates/turborepo-repository/src/package_manager/nub.rs index 0f27316d7a272..961e4ee30f8dc 100644 --- a/crates/turborepo-repository/src/package_manager/nub.rs +++ b/crates/turborepo-repository/src/package_manager/nub.rs @@ -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; @@ -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() @@ -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) - } - ); - } } diff --git a/packages/turbo-workspaces/src/install.ts b/packages/turbo-workspaces/src/install.ts index 665e5dab13e34..1de38b09b6c55 100644 --- a/packages/turbo-workspaces/src/install.ts +++ b/packages/turbo-workspaces/src/install.ts @@ -87,7 +87,7 @@ export const PACKAGE_MANAGERS: Record< command: "nub", installArgs: ["install"], version: "latest", - executable: "nub", + executable: "nub exec", semver: "*", default: true }