perf: avoid reinstalling unchanged global packages (#15029)

Avoid replacing global package groups when a lockfile-only update resolves to the same graph and the group still carries the modules manifest an install leaves behind. Preserve update depth, downgrade protection, pending build approvals, policy reporting, and one command-level completion summary.

Fixes pnpm/pnpm#12002.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Peder BerganandZoltan Kochan authored and GitHub committed 2026-09-19 01:30:30 +02:00
1 parent 7e7b899daf
commit f2bbfcb432
12 files changed
+709 -75

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
"@pnpm/global.commands": patch
"pnpm": patch
"pacquet": patch
---
`pnpm update --global` no longer reinstalls a global package when its dependency graph resolves to what is already installed. It reports `Already up to date` [pnpm/pnpm#12002](https://github.com/pnpm/pnpm/issues/12002).
+6
View File
@@ -5107,6 +5107,12 @@ importers:
'@pnpm/installing.deps-installer':
specifier: workspace:*
version: link:../../installing/deps-installer
'@pnpm/installing.modules-yaml':
specifier: workspace:*
version: link:../../installing/modules-yaml
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/pkg-manifest.reader':
specifier: workspace:*
version: link:../../pkg-manifest/reader
+53 -2
View File
@@ -64,7 +64,7 @@ use pnpm_package_is_installable::SupportedArchitectures;
use pnpm_package_manifest::{DependencyGroup, safe_read_package_json_from_dir};
use pnpm_package_name::is_valid_old_npm_package_name;
use pnpm_registry::RangeSpecStyle;
use pnpm_reporter::{GlobalLog, LogEvent, LogLevel, Reporter};
use pnpm_reporter::{GlobalLog, LogEvent, LogLevel, PnpmLog, Reporter, SummaryLog};
use pnpm_resolving_parse_wanted_dependency::parse_wanted_dependency;
use remove::{
@@ -84,9 +84,46 @@ use shims::{
use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fs, io,
marker::PhantomData,
path::{Path, PathBuf},
};
/// Forward resolution diagnostics while hiding install-tree events from the
/// lockfile-only comparison pass.
struct GlobalUpdateResolutionReporter<Sink>(PhantomData<Sink>);
impl<Sink: Reporter> Reporter for GlobalUpdateResolutionReporter<Sink> {
fn emit(event: &LogEvent) {
let is_terminal_up_to_date = matches!(
event,
LogEvent::Pnpm(PnpmLog { message, .. }) if message == "Already up to date",
);
if !is_terminal_up_to_date
&& !matches!(
event,
LogEvent::PackageManifest(_)
| LogEvent::Root(_)
| LogEvent::Stats(_)
| LogEvent::Summary(_),
)
{
Sink::emit(event);
}
}
}
/// `update -g` closes with one completion summary of its own, so the groups it
/// materializes along the way must not each close with theirs.
struct GlobalUpdateMaterializationReporter<Sink>(PhantomData<Sink>);
impl<Sink: Reporter> Reporter for GlobalUpdateMaterializationReporter<Sink> {
fn emit(event: &LogEvent) {
if !matches!(event, LogEvent::Summary(_)) {
Sink::emit(event);
}
}
}
/// Errors specific to global package management, carrying the
/// `ERR_PNPM_`-prefixed codes.
#[derive(Debug, Display, Error, Diagnostic)]
@@ -239,8 +276,9 @@ pub async fn handle_global_update<Reporter: self::Reporter + 'static>(
global_pkg_dir: &global_pkg_dir,
global_bin_dir: &global_bin_dir,
};
let mut changed = false;
for pkg in &to_update {
target.update_group::<Reporter>(
changed |= target.update_group::<Reporter>(
pkg,
latest,
range_spec_style,
@@ -248,9 +286,22 @@ pub async fn handle_global_update<Reporter: self::Reporter + 'static>(
)
.await?;
}
emit_global_update_result::<Reporter>(&global_pkg_dir, changed);
Ok(())
}
fn emit_global_update_result<Reporter: self::Reporter>(global_pkg_dir: &Path, changed: bool) {
if !changed {
Reporter::emit(&LogEvent::Pnpm(PnpmLog {
level: LogLevel::Info,
message: "Already up to date".to_string(),
prefix: String::new(),
}));
}
let prefix = global_pkg_dir.to_string_lossy().into_owned();
Reporter::emit(&LogEvent::Summary(SummaryLog { level: LogLevel::Debug, prefix }));
}
/// Surface a non-fatal problem on the `pnpm:global` channel, matching
/// the TypeScript CLI's `globalWarn`.
fn warn_global<Reporter: self::Reporter>(message: &str) {
+170 -30
View File
@@ -1,16 +1,19 @@
use super::{
ActivationBinSets, ArtifactCleanupError, CmdShimHost, Config, Context, GlobalInstallTarget,
GlobalPackageInfo, GroupActivation, GroupInstall, HashSet, IntoDiagnostic, PackageBinSource,
Path, RangeSpecStyle, ReplacedGlobalBinPlan, Reporter, SupportedArchitectures,
GlobalPackageInfo, GlobalUpdateMaterializationReporter, GlobalUpdateResolutionReporter,
GroupActivation, GroupInstall, HashSet, IntoDiagnostic, Lockfile, PackageBinSource, Path,
PathBuf, RangeSpecStyle, ReplacedGlobalBinPlan, Reporter, SupportedArchitectures,
acquire_global_bin_lock, activate_global_install_with_extra_bin_names,
bin_names_of_other_groups, check_global_bin_conflicts, check_virtual_shim_conflicts,
cleanup_replaced_global_installs, collect_existing_global_installs, create_global_cache_key,
create_install_dir, discard_install_dir_on_error, get_actual_bin_names, get_hash_link,
hash_linked_packages, link_global_bins, pins_for_downgrades, plan_replaced_global_bins,
read_direct_dependencies, read_installed_packages, registries_with_default,
replacement_aliases, restore_virtual_shims, run_group_install, should_replace_existing_package,
snapshot_global_package, update_selectors, warn_global,
create_install_dir, discard_install_dir_on_error, fs, get_actual_bin_names, get_hash_link,
global_group_config, hash_linked_packages, link_global_bins, pins_for_downgrades,
plan_replaced_global_bins, prompt_approve_install_builds, read_direct_dependencies,
read_installed_packages, registries_with_default, replacement_aliases, restore_virtual_shims,
run_group_install, should_replace_existing_package, snapshot_global_package, update_selectors,
warn_global,
};
use pnpm_modules_yaml::{Host as ModulesHost, read_modules_manifest};
impl GlobalInstallTarget<'_> {
/// Install one `add -g` group and activate it over the groups it
@@ -93,35 +96,154 @@ impl GlobalInstallTarget<'_> {
latest: bool,
range_spec_style: RangeSpecStyle,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<()> {
let install_dir = create_install_dir(self.global_pkg_dir)
.into_diagnostic()
.wrap_err("create global install dir")?;
let pins = Box::pin(pins_for_downgrades::<Reporter>(
self.base_config,
self.global_pkg_dir,
&install_dir,
) -> miette::Result<bool> {
let (install_dir, selectors) = self.prepare_update_candidate::<Reporter>(
pkg,
latest,
range_spec_style,
supported_architectures.clone(),
))
.await?;
Box::pin(run_group_install::<Reporter>(GroupInstall {
base_config: self.base_config,
global_pkg_dir: self.global_pkg_dir,
install_dir: &install_dir,
selectors: &update_selectors(&pkg.dependencies, latest, &pins),
range_spec_style,
supported_architectures,
// `update -g` takes no `--allow-build`; the build policy comes
// from the global `allowBuilds` loaded in `run_group_install`.
allow_build: &[],
lockfile_only: false,
}))
)
.await?;
self.activate_updated_group::<Reporter>(&install_dir, pkg)
if self.discard_unchanged_update::<Reporter>(
pkg,
&install_dir,
supported_architectures.clone(),
)
.await?
{
return Ok(false);
}
self.materialize_update::<Reporter>(
pkg,
&install_dir,
&selectors,
range_spec_style,
supported_architectures,
)
.await?;
Ok(true)
}
async fn prepare_update_candidate<Reporter: self::Reporter + 'static>(
&self,
pkg: &GlobalPackageInfo,
latest: bool,
range_spec_style: RangeSpecStyle,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<(PathBuf, Vec<String>)> {
let install_dir = create_install_dir(self.global_pkg_dir)
.into_diagnostic()
.wrap_err("create global install dir")?;
let resolved = Box::pin(self.resolve_update_candidate::<Reporter>(
pkg,
&install_dir,
latest,
range_spec_style,
supported_architectures,
))
.await;
let selectors = discard_install_dir_on_error(&install_dir, resolved)?;
Ok((install_dir, selectors))
}
/// Seed the candidate with the group's manifest and resolve the update into
/// its lockfile, leaving `node_modules` absent. The selectors it returns are
/// the ones the materializing install repeats.
async fn resolve_update_candidate<Reporter: self::Reporter + 'static>(
&self,
pkg: &GlobalPackageInfo,
install_dir: &Path,
latest: bool,
range_spec_style: RangeSpecStyle,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<Vec<String>> {
fs::copy(pkg.install_dir.join("package.json"), install_dir.join("package.json"))
.into_diagnostic()
.wrap_err("seed global update manifest")?;
let downgrade_check =
Box::pin(pins_for_downgrades::<GlobalUpdateResolutionReporter<Reporter>>(
self.base_config,
self.global_pkg_dir,
install_dir,
pkg,
latest,
range_spec_style,
supported_architectures.clone(),
))
.await?;
let selectors = update_selectors(&pkg.dependencies, latest, &downgrade_check.pins);
if !downgrade_check.candidate_resolved || !downgrade_check.pins.is_empty() {
Box::pin(run_group_install::<GlobalUpdateResolutionReporter<Reporter>>(GroupInstall {
base_config: self.base_config,
global_pkg_dir: self.global_pkg_dir,
install_dir,
selectors: &selectors,
range_spec_style,
supported_architectures,
allow_build: &[],
lockfile_only: true,
}))
.await?;
}
Ok(selectors)
}
async fn discard_unchanged_update<Reporter: self::Reporter + 'static>(
&self,
pkg: &GlobalPackageInfo,
install_dir: &Path,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<bool> {
let unchanged =
is_materialized(&pkg.install_dir) && lockfiles_are_equal(&pkg.install_dir, install_dir);
if !unchanged {
return Ok(false);
}
fs::remove_dir_all(install_dir)
.into_diagnostic()
.wrap_err("remove unchanged global install candidate")?;
let active_config = Config::leak(global_group_config(
self.base_config,
&pkg.install_dir,
self.global_pkg_dir,
supported_architectures,
)?);
prompt_approve_install_builds::<Reporter>(
active_config,
&pkg.install_dir,
self.global_pkg_dir,
)
.await?;
Ok(true)
}
async fn materialize_update<Reporter: self::Reporter + 'static>(
&self,
pkg: &GlobalPackageInfo,
install_dir: &Path,
selectors: &[String],
range_spec_style: RangeSpecStyle,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<()> {
Box::pin(run_group_install::<GlobalUpdateMaterializationReporter<Reporter>>(
GroupInstall {
base_config: self.base_config,
global_pkg_dir: self.global_pkg_dir,
install_dir,
selectors,
range_spec_style,
supported_architectures,
// `update -g` takes no `--allow-build`; the build policy comes
// from the global `allowBuilds` loaded in `run_group_install`.
allow_build: &[],
lockfile_only: false,
},
))
.await?;
self.activate_updated_group::<Reporter>(install_dir, pkg)
}
fn activate_updated_group<Reporter: self::Reporter>(
@@ -254,6 +376,24 @@ impl GlobalInstallTarget<'_> {
}
}
/// The modules manifest is what an install leaves behind, so it answers what
/// equal lockfiles cannot: whether the tree they describe is still on disk.
fn is_materialized(install_dir: &Path) -> bool {
read_modules_manifest::<ModulesHost>(&install_dir.join("node_modules"))
.is_ok_and(|manifest| manifest.is_some())
}
fn lockfiles_are_equal(active_dir: &Path, candidate_dir: &Path) -> bool {
let Ok(Some(active)) = Lockfile::load_from_path(&active_dir.join(Lockfile::FILE_NAME)) else {
return false;
};
let Ok(Some(candidate)) = Lockfile::load_from_path(&candidate_dir.join(Lockfile::FILE_NAME))
else {
return false;
};
active == candidate
}
fn warn_on_leftover<Reporter: self::Reporter>(leftover: Option<&ArtifactCleanupError>) {
if let Some(leftover) = leftover {
warn_global::<Reporter>(&leftover.to_string());
+11 -5
View File
@@ -13,6 +13,11 @@ pub(super) struct GlobalInstallTarget<'a> {
pub(super) global_bin_dir: &'a Path,
}
pub(super) struct DowngradeCheck {
pub(super) candidate_resolved: bool,
pub(super) pins: HashMap<String, String>,
}
/// A freshly installed group, ready to take over the global bins of the
/// groups it replaces.
pub(super) struct GroupActivation<'a> {
@@ -47,12 +52,12 @@ pub(super) async fn pins_for_downgrades<Reporter: self::Reporter + 'static>(
latest: bool,
range_spec_style: RangeSpecStyle,
supported_architectures: Option<SupportedArchitectures>,
) -> miette::Result<HashMap<String, String>> {
) -> miette::Result<DowngradeCheck> {
// Only `--latest` can pick a version outside the recorded range, and only a
// plain version spec is dropped for it. Everything else resolves within a
// range the installed version already satisfies.
if !latest {
return Ok(HashMap::new());
return Ok(DowngradeCheck { candidate_resolved: false, pins: HashMap::new() });
}
let versions_before = installed_versions(&pkg.install_dir);
// Nothing to compare a resolution against, so nothing to resolve.
@@ -60,7 +65,7 @@ pub(super) async fn pins_for_downgrades<Reporter: self::Reporter + 'static>(
.iter()
.any(|(alias, spec)| is_plain_version_spec(spec) && versions_before.contains_key(alias))
{
return Ok(HashMap::new());
return Ok(DowngradeCheck { candidate_resolved: false, pins: HashMap::new() });
}
run_group_install::<Reporter>(GroupInstall {
base_config,
@@ -75,7 +80,7 @@ pub(super) async fn pins_for_downgrades<Reporter: self::Reporter + 'static>(
.await?;
let resolved = resolved_direct_versions(install_dir);
Ok(pkg.dependencies
let pins = pkg.dependencies
.iter()
.filter(|(_, spec)| is_plain_version_spec(spec))
.filter_map(|(alias, _)| {
@@ -83,7 +88,8 @@ pub(super) async fn pins_for_downgrades<Reporter: self::Reporter + 'static>(
let now = resolved.get(alias)?;
(*now < before).then(|| (alias.clone(), before.to_string()))
})
.collect())
.collect();
Ok(DowngradeCheck { candidate_resolved: true, pins })
}
/// The version each direct dependency resolved to, read from the lockfile the
+156
View File
@@ -909,6 +909,162 @@ fn global_update_latest_keeps_a_package_that_latest_would_downgrade() {
drop((root, npmrc_info));
}
#[cfg(unix)]
#[test]
fn unchanged_global_update_reports_already_up_to_date_without_replacing_the_group() {
use assert_cmd::assert::OutputAssertExt;
let CommandTempCwd { root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let pnpm_home = root.path().join("pnpm-home");
prepare_global_home(&pnpm_home, &npmrc_info);
global_command(&workspace, &pnpm_home)
.with_args(["add", "-g", "@foo/touch-file-one-bin"])
.assert()
.success();
let global_dir = pnpm_home.join("global").join("v11");
let links_before: Vec<_> = symlink_entries(&global_dir)
.into_iter()
.map(|link| {
let target = fs::read_link(&link).expect("read global hash link");
(link, target)
})
.collect();
let output = global_command(&workspace, &pnpm_home)
.with_args(["update", "-g"])
.output()
.expect("run unchanged global update");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Already up to date"), "{stdout}");
assert!(!stdout.contains("dependencies:\n+"), "{stdout}");
let links_after: Vec<_> = symlink_entries(&global_dir)
.into_iter()
.map(|link| {
let target = fs::read_link(&link).expect("read global hash link");
(link, target)
})
.collect();
assert_eq!(links_after, links_before);
drop((root, npmrc_info));
}
#[cfg(unix)]
#[test]
fn unchanged_global_update_still_approves_a_pending_build() {
use assert_cmd::assert::OutputAssertExt;
let CommandTempCwd { root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let pnpm_home = root.path().join("pnpm-home");
prepare_global_home(&pnpm_home, &npmrc_info);
global_command(&workspace, &pnpm_home)
.with_args(["add", "-g", "@pnpm.e2e/install-script-example@1.0.0"])
.assert()
.success();
let global_dir = pnpm_home.join("global").join("v11");
let install_before =
pnpm_global::find_global_package(&global_dir, "@pnpm.e2e/install-script-example")
.expect("scan global packages")
.expect("find install-script group");
let build_artifact = install_before.install_dir.join(
"node_modules/@pnpm.e2e/install-script-example/generated-by-install.js",
);
assert!(!build_artifact.exists());
let output = global_command(&workspace, &pnpm_home)
.with_env("PNPM_AUTO_APPROVE_BUILDS_FOR_TESTS", "1")
.with_args(["update", "-g"])
.output()
.expect("run unchanged global update with pending build approval");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Already up to date"), "{stdout}");
assert!(build_artifact.exists());
let install_after =
pnpm_global::find_global_package(&global_dir, "@pnpm.e2e/install-script-example")
.expect("scan global packages")
.expect("find install-script group");
assert_eq!(install_after.install_dir, install_before.install_dir);
drop((root, npmrc_info));
}
/// A group whose `node_modules` was removed holds nothing to run, so an
/// unchanged resolution must not report it as current. The update cannot put
/// the tree back either: activation reads the manifests of the group it
/// replaces, and those went with the tree. It says so instead of claiming the
/// group is up to date.
#[cfg(unix)]
#[test]
fn global_update_does_not_call_a_group_without_node_modules_up_to_date() {
use assert_cmd::assert::OutputAssertExt;
let CommandTempCwd { root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let pnpm_home = root.path().join("pnpm-home");
prepare_global_home(&pnpm_home, &npmrc_info);
global_command(&workspace, &pnpm_home)
.with_args(["add", "-g", "@foo/touch-file-one-bin"])
.assert()
.success();
let global_dir = pnpm_home.join("global").join("v11");
let install_before = pnpm_global::find_global_package(&global_dir, "@foo/touch-file-one-bin")
.expect("scan global packages")
.expect("find the touch-file group");
fs::remove_dir_all(install_before.install_dir.join("node_modules"))
.expect("remove the group's node_modules");
let output = global_command(&workspace, &pnpm_home)
.with_args(["update", "-g"])
.output()
.expect("run global update over a removed tree");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stdout.contains("Already up to date"), "{stdout}");
assert!(!output.status.success(), "{stdout}\n{stderr}");
assert!(stderr.contains("ERR_PNPM_PACKAGE_MANIFEST_IO_ERROR"), "{stderr}");
drop((root, npmrc_info));
}
#[cfg(unix)]
#[test]
fn global_update_renders_both_changed_groups_with_one_completion_summary() {
use assert_cmd::assert::OutputAssertExt;
let CommandTempCwd { root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry_with_own_storage();
let pnpm_home = root.path().join("pnpm-home");
prepare_global_home(&pnpm_home, &npmrc_info);
npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-a", "1.0.0", "latest");
npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-b", "3.0.0", "latest");
global_command(&workspace, &pnpm_home)
.with_args(["add", "-g", "@pnpm.e2e/multi-version-a", "@pnpm.e2e/multi-version-b"])
.assert()
.success();
npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-a", "2.1.0", "latest");
npmrc_info.set_dist_tag("@pnpm.e2e/multi-version-b", "3.1.0", "latest");
let output = global_command(&workspace, &pnpm_home)
.with_args(["update", "-g", "--latest"])
.output()
.expect("run two-group global update");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("@pnpm.e2e/multi-version-a"), "{stdout}");
assert!(stdout.contains("@pnpm.e2e/multi-version-b"), "{stdout}");
assert_eq!(stdout.matches("Done in ").count(), 1, "{stdout}");
drop((root, npmrc_info));
}
mod shims;
mod ownership;
+2
View File
@@ -44,6 +44,8 @@
"@pnpm/error": "workspace:*",
"@pnpm/global.packages": "workspace:*",
"@pnpm/installing.deps-installer": "workspace:*",
"@pnpm/installing.modules-yaml": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/pkg-manifest.reader": "workspace:*",
"@pnpm/pkg-manifest.utils": "workspace:*",
"@pnpm/resolving.parse-wanted-dependency": "workspace:*",
+73 -15
View File
@@ -1,4 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import type { CommandHandlerMap } from '@pnpm/cli.command'
import { summaryLogger } from '@pnpm/core-loggers'
@@ -10,7 +12,11 @@ import {
type GlobalPackageInfo,
scanGlobalPackages,
} from '@pnpm/global.packages'
import { readModulesManifest } from '@pnpm/installing.modules-yaml'
import { readWantedLockfile } from '@pnpm/lockfile.fs'
import { logger } from '@pnpm/logger'
import type { CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
import type { ProjectManifest } from '@pnpm/types'
import semver from 'semver'
import { getGlobalBinOwnership } from './binOwnership.js'
@@ -33,7 +39,7 @@ export type GlobalUpdateOptions = CreateStoreControllerOptions & {
allowBuilds?: Record<string, string | boolean>
saveExact?: boolean
savePrefix?: string
rootProjectManifest?: unknown
rootProjectManifest?: ProjectManifest
handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>
updateResolutionPolicyManifest?: (violations: readonly ResolutionPolicyViolation[], dir: string) => Promise<void>
selectedPackageHashes?: Set<string>
@@ -76,8 +82,12 @@ export async function handleGlobalUpdate (
// Update each package group sequentially to avoid overwhelming the system
let changed = false
for (const pkg of packagesToUpdate) {
await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg, commands) // eslint-disable-line no-await-in-loop
changed = await updateGlobalPackageGroup(opts, globalDir, globalBinDir, pkg, commands) || changed // eslint-disable-line no-await-in-loop
}
if (!changed) {
logger.info({ message: 'Already up to date', prefix: opts.dir })
}
summaryLogger.debug({ prefix: globalDir })
return undefined
@@ -89,11 +99,35 @@ async function updateGlobalPackageGroup (
globalBinDir: string,
pkg: GlobalPackageInfo,
commands: CommandHandlerMap
): Promise<void> {
): Promise<boolean> {
const installDir = createInstallDir(globalDir)
const pins = await pinsForDowngrades(opts, installDir, pkg)
const { ignoredBuilds, resolutionPolicyViolations } =
await installGroup(opts, installDir, depSpecsForUpdate(pkg.dependencies, opts.latest, pins))
const downgradeCheck = await pinsForDowngrades(opts, installDir, pkg)
const depSpecs = depSpecsForUpdate(pkg.dependencies, opts.latest, downgradeCheck.pins)
const comparison = downgradeCheck.candidate != null && downgradeCheck.pins.size === 0
? downgradeCheck.candidate
: await installGroup(
{ ...opts, lockfileOnly: true, groupDependencies: pkg.dependencies },
installDir,
depSpecs
)
// Equal lockfiles mean no new packages, not that the tree they describe is
// still on disk. The modules manifest is what an install leaves behind.
const activeModules = await readModulesManifest(path.join(pkg.installDir, 'node_modules'))
if (activeModules != null && await lockfilesAreEqual(pkg.installDir, installDir)) {
await fs.promises.rm(installDir, { recursive: true, force: true })
await promptApproveGlobalBuilds({
globalPkgDir: globalDir,
installDir: pkg.installDir,
ignoredBuilds: activeModules.ignoredBuilds,
allowBuilds: opts.allowBuilds ?? {},
inheritedOpts: opts,
}, commands)
await opts.updateResolutionPolicyManifest?.(comparison.resolutionPolicyViolations, globalDir)
return false
}
const { ignoredBuilds } = await installGroup(opts, installDir, depSpecs)
await promptApproveGlobalBuilds({
globalPkgDir: globalDir,
@@ -146,16 +180,27 @@ async function updateGlobalPackageGroup (
activatedBins,
protectedBins: ownership.protectedBins,
})
await opts.updateResolutionPolicyManifest?.(resolutionPolicyViolations, globalDir)
await opts.updateResolutionPolicyManifest?.(comparison.resolutionPolicyViolations, globalDir)
return true
}
type InstallGroupOptions = GlobalUpdateOptions & {
lockfileOnly?: boolean
groupDependencies?: Record<string, string>
}
/**
* Installs `depSpecs` into `installDir`, which the caller has already created
* under the global packages dir. The manifest and lockfile are written there;
* with `lockfileOnly` nothing else is, so `node_modules` stays absent.
*
* `groupDependencies` is the manifest the install starts from. The first call
* into a fresh `installDir` passes the group's recorded dependencies, without
* which the lockfile would carry no specifiers to compare against the group's
* own. Omitting it starts from the manifest written by an earlier call.
*/
async function installGroup (
opts: GlobalUpdateOptions & { lockfileOnly?: boolean },
opts: InstallGroupOptions,
installDir: string,
depSpecs: string[]
): Promise<InstallGlobalPackagesResult> {
@@ -171,7 +216,7 @@ async function installGroup (
dir: installDir,
lockfileDir: installDir,
rootProjectManifestDir: installDir,
rootProjectManifest: undefined,
rootProjectManifest: opts.groupDependencies == null ? undefined : { dependencies: opts.groupDependencies },
saveProd: true,
saveDev: false,
saveOptional: false,
@@ -222,26 +267,27 @@ async function pinsForDowngrades (
opts: GlobalUpdateOptions,
installDir: string,
pkg: GlobalPackageInfo
): Promise<Map<string, string>> {
): Promise<{ candidate?: InstallGlobalPackagesResult, pins: Map<string, string> }> {
const pins = new Map<string, string>()
// Only `--latest` can pick a version outside the recorded range, and only a
// plain version spec is dropped for it. Everything else resolves within a
// range the installed version already satisfies, so nothing below — not even
// reading the group's installed versions — is worth doing.
if (opts.latest !== true) return pins
if (opts.latest !== true) return { pins }
const versionsBefore = new Map(
(await getGlobalPackageDetails(pkg))
.filter(({ alias }) => isPlainVersionSpec(pkg.dependencies[alias] ?? ''))
.map(({ alias, version }) => [alias, version])
)
// Nothing to compare a resolution against, so nothing to resolve.
if (versionsBefore.size === 0) return pins
if (versionsBefore.size === 0) return { pins }
const { resolvedVersions } = await installGroup(
{ ...opts, lockfileOnly: true },
const candidate = await installGroup(
{ ...opts, lockfileOnly: true, groupDependencies: pkg.dependencies },
installDir,
depSpecsForUpdate(pkg.dependencies, opts.latest)
)
const { resolvedVersions } = candidate
for (const [alias, before] of versionsBefore) {
const resolved = resolvedVersions[alias]
if (semver.valid(before) == null || semver.valid(resolved) == null) continue
@@ -249,7 +295,19 @@ async function pinsForDowngrades (
pins.set(alias, before)
}
}
return pins
return { candidate, pins }
}
async function lockfilesAreEqual (activeDir: string, candidateDir: string): Promise<boolean> {
try {
const [active, candidate] = await Promise.all([
readWantedLockfile(activeDir, { ignoreIncompatible: false }),
readWantedLockfile(candidateDir, { ignoreIncompatible: false }),
])
return active != null && candidate != null && isDeepStrictEqual(active, candidate)
} catch {
return false
}
}
// Only a plain version range may be dropped in favor of the bare alias.
@@ -2,7 +2,7 @@ import { tryReadProjectManifest } from '@pnpm/cli.utils'
import { mutateModulesInSingleProject } from '@pnpm/installing.deps-installer'
import { getRangeSpecStyle } from '@pnpm/pkg-manifest.utils'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
import type { IgnoredBuilds, IncludedDependencies, ProjectId, ProjectRootDir } from '@pnpm/types'
import type { IgnoredBuilds, IncludedDependencies, ProjectId, ProjectManifest, ProjectRootDir } from '@pnpm/types'
export interface ResolutionPolicyViolation {
name: string
@@ -32,7 +32,7 @@ export interface InstallGlobalPackagesOptions extends CreateStoreControllerOptio
include: IncludedDependencies
includeDirect?: IncludedDependencies
omitSummaryLog?: boolean
rootProjectManifest?: unknown
rootProjectManifest?: ProjectManifest
rootProjectManifestDir?: string
saveDev?: boolean
saveExact?: boolean
@@ -50,10 +50,8 @@ export async function installGlobalPackages (
params: string[]
): Promise<InstallGlobalPackagesResult> {
const store = await createStoreController(opts)
let { manifest, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts)
if (manifest == null) {
manifest = {}
}
const { manifest: manifestFromDisk, writeProjectManifest } = await tryReadProjectManifest(opts.dir, opts)
const manifest = opts.rootProjectManifest ?? manifestFromDisk ?? {}
const installOpts = {
...opts,
allowBuilds: { ...opts.allowBuilds },
+172 -17
View File
@@ -11,13 +11,16 @@ const createInstallDir = jest.fn()
const getHashLink = jest.fn()
const getGlobalPackageDetails = jest.fn<(pkg: unknown) => Promise<Array<{ alias: string, version: string }>>>().mockResolvedValue([])
const getInstalledBinNames = jest.fn<(pkg: GlobalPackageInfo) => Promise<string[]>>().mockResolvedValue([])
const readModulesManifest = jest.fn<() => Promise<{ ignoredBuilds?: Set<string> } | null>>().mockResolvedValue(null)
const readWantedLockfile = jest.fn<() => Promise<unknown>>().mockResolvedValue(null)
const scanGlobalPackages = jest.fn()
const checkGlobalBinConflicts = jest.fn<() => Promise<Set<string>>>().mockResolvedValue(new Set())
const installGlobalPackages = jest.fn<(...args: unknown[]) => Promise<{ ignoredBuilds: undefined, resolutionPolicyViolations: [], resolvedVersions: Record<string, string> }>>()
const installGlobalPackages = jest.fn<(...args: unknown[]) => Promise<{ ignoredBuilds: undefined, resolutionPolicyViolations: Array<{ name: string, version: string, code: string, reason: string }>, resolvedVersions: Record<string, string> }>>()
.mockResolvedValue({ ignoredBuilds: undefined, resolutionPolicyViolations: [], resolvedVersions: {} })
const promptApproveGlobalBuilds = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
const promptApproveGlobalBuilds = jest.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined)
const readInstalledPackages = jest.fn<(installDir: string) => Promise<Array<{ alias: string, manifest: { name: string, version: string } }>>>().mockResolvedValue([])
const summaryDebug = jest.fn()
const info = jest.fn()
const activateGlobalInstall = jest.fn<(opts: unknown) => Promise<Set<string>>>().mockResolvedValue(new Set(['fresh']))
const cleanupReplacedGlobalInstalls = jest.fn<(opts: unknown) => Promise<void>>().mockResolvedValue(undefined)
const getActualBinNames = jest.fn<(opts: unknown) => Promise<Set<string>>>().mockResolvedValue(new Set(['fresh']))
@@ -31,6 +34,9 @@ jest.unstable_mockModule('@pnpm/global.packages', () => ({
getInstalledBinNames,
scanGlobalPackages,
}))
jest.unstable_mockModule('@pnpm/installing.modules-yaml', () => ({ readModulesManifest }))
jest.unstable_mockModule('@pnpm/lockfile.fs', () => ({ readWantedLockfile }))
jest.unstable_mockModule('@pnpm/logger', () => ({ logger: { info } }))
jest.unstable_mockModule('../src/checkGlobalBinConflicts.js', () => ({ checkGlobalBinConflicts }))
jest.unstable_mockModule('../src/globalActivation.js', () => ({
activateGlobalInstall,
@@ -49,6 +55,8 @@ beforeEach(() => {
cleanupReplacedGlobalInstalls.mockResolvedValue(undefined)
getGlobalPackageDetails.mockResolvedValue([])
getInstalledBinNames.mockResolvedValue([])
readModulesManifest.mockResolvedValue(null)
readWantedLockfile.mockResolvedValue(null)
installGlobalPackages.mockResolvedValue({
ignoredBuilds: undefined,
resolutionPolicyViolations: [],
@@ -60,7 +68,7 @@ beforeEach(() => {
})
test('global update emits a single summary after updating all isolated groups', async () => {
const updateResolutionPolicyManifest = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise<void>>().mockResolvedValue(undefined)
createInstallDir
.mockReturnValueOnce('/global/v11/install-1')
.mockReturnValueOnce('/global/v11/install-2')
@@ -87,22 +95,26 @@ test('global update emits a single summary after updating all isolated groups',
updateResolutionPolicyManifest,
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenCalledTimes(4)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
dir: '/global/v11/install-1',
global: false,
lockfileOnly: true,
omitSummaryLog: true,
rootProjectManifest: { dependencies: { foo: '^1.0.0' } },
}),
['foo@^1.0.0']
)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
2,
3,
expect.objectContaining({
dir: '/global/v11/install-2',
global: false,
lockfileOnly: true,
omitSummaryLog: true,
rootProjectManifest: { dependencies: { bar: '^2.0.0' } },
}),
['bar@^2.0.0']
)
@@ -151,6 +163,134 @@ test('global update emits a single summary after updating all isolated groups',
expect(summaryDebug).toHaveBeenCalledWith({ prefix: '/global/v11' })
})
test('global update reports already up to date without replacing an equal candidate', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-unchanged-'))
const globalDir = path.join(root, 'global')
const oldInstallDir = path.join(globalDir, 'old-install')
const candidateDir = path.join(globalDir, 'candidate')
fs.mkdirSync(oldInstallDir, { recursive: true })
fs.mkdirSync(candidateDir, { recursive: true })
createInstallDir.mockReturnValue(candidateDir)
scanGlobalPackages.mockReturnValue([
{ dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir },
])
const lockfile = { importers: { '.': { dependencies: { foo: '1.0.0' } } }, lockfileVersion: '9.0' }
readWantedLockfile.mockResolvedValue(lockfile)
const violation = { name: 'foo', version: '1.0.0', code: 'policy', reason: 'test' }
const ignoredBuilds = new Set(['foo@1.0.0'])
readModulesManifest.mockResolvedValue({ ignoredBuilds })
installGlobalPackages.mockResolvedValue({
ignoredBuilds: undefined,
resolutionPolicyViolations: [violation],
resolvedVersions: { foo: '1.0.0' },
})
const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise<void>>().mockResolvedValue(undefined)
try {
const output = await handleGlobalUpdate({
dir: root,
bin: path.join(root, 'bin'),
globalPkgDir: globalDir,
updateResolutionPolicyManifest,
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(output).toBeUndefined()
expect(info).toHaveBeenCalledWith({ message: 'Already up to date', prefix: root })
expect(installGlobalPackages).toHaveBeenCalledTimes(1)
expect(installGlobalPackages).toHaveBeenCalledWith(
expect.objectContaining({ lockfileOnly: true, rootProjectManifest: { dependencies: { foo: '^1.0.0' } } }),
['foo@^1.0.0']
)
expect(fs.existsSync(candidateDir)).toBe(false)
expect(fs.existsSync(oldInstallDir)).toBe(true)
expect(activateGlobalInstall).not.toHaveBeenCalled()
expect(promptApproveGlobalBuilds).toHaveBeenCalledWith({
globalPkgDir: globalDir,
installDir: oldInstallDir,
ignoredBuilds,
allowBuilds: {},
inheritedOpts: expect.objectContaining({ dir: root }),
}, {})
expect(updateResolutionPolicyManifest).toHaveBeenCalledWith([violation], globalDir)
expect(summaryDebug).toHaveBeenCalledTimes(1)
expect(summaryDebug).toHaveBeenCalledWith({ prefix: globalDir })
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('global update does not report already up to date when the active group lost its node_modules', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-no-modules-'))
const globalDir = path.join(root, 'global')
const oldInstallDir = path.join(globalDir, 'old-install')
const candidateDir = path.join(globalDir, 'candidate')
fs.mkdirSync(oldInstallDir, { recursive: true })
fs.mkdirSync(candidateDir, { recursive: true })
createInstallDir.mockReturnValue(candidateDir)
getHashLink.mockReturnValue(path.join(globalDir, 'hash-foo'))
scanGlobalPackages.mockReturnValue([
{ dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir },
])
readWantedLockfile.mockResolvedValue({ importers: { '.': { dependencies: { foo: '1.0.0' } } }, lockfileVersion: '9.0' })
readModulesManifest.mockResolvedValue(null)
try {
await handleGlobalUpdate({
dir: root,
bin: path.join(root, 'bin'),
globalPkgDir: globalDir,
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(info).not.toHaveBeenCalled()
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ dir: candidateDir, lockfileOnly: false }),
['foo@^1.0.0']
)
expect(fs.existsSync(candidateDir)).toBe(true)
expect(activateGlobalInstall).toHaveBeenCalled()
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('global update fails without replacing the active group when equal-candidate cleanup fails', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'global-update-unchanged-cleanup-'))
const globalDir = path.join(root, 'global')
const oldInstallDir = path.join(globalDir, 'old-install')
const candidateDir = path.join(globalDir, 'candidate')
const oldMarker = path.join(oldInstallDir, 'marker')
fs.mkdirSync(oldInstallDir, { recursive: true })
fs.mkdirSync(candidateDir, { recursive: true })
fs.writeFileSync(oldMarker, 'active\n')
createInstallDir.mockReturnValue(candidateDir)
scanGlobalPackages.mockReturnValue([
{ dependencies: { foo: '^1.0.0' }, hash: 'hash-foo', installDir: oldInstallDir },
])
readWantedLockfile.mockResolvedValue({ importers: {}, lockfileVersion: '9.0' })
readModulesManifest.mockResolvedValue({})
const cleanupError = Object.assign(new Error('candidate cleanup failed'), { code: 'EACCES' })
const realRm = fs.promises.rm.bind(fs.promises)
const rmSpy = jest.spyOn(fs.promises, 'rm').mockImplementation(async (targetPath, options) => {
if (path.resolve(String(targetPath)) === path.resolve(candidateDir)) throw cleanupError
await realRm(targetPath, options)
})
try {
await expect(handleGlobalUpdate({
bin: path.join(root, 'bin'),
globalPkgDir: globalDir,
} as any, [], {})).rejects.toBe(cleanupError) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(fs.readFileSync(oldMarker, 'utf8')).toBe('active\n')
expect(activateGlobalInstall).not.toHaveBeenCalled()
expect(cleanupReplacedGlobalInstalls).not.toHaveBeenCalled()
} finally {
rmSpy.mockRestore()
fs.rmSync(root, { recursive: true, force: true })
}
})
test('global update ignores incomplete survivors when every replaced bin is retained', async () => {
const target: GlobalPackageInfo = {
dependencies: { foo: '^1.0.0' },
@@ -355,9 +495,10 @@ test('global update only updates interactively selected groups', async () => {
selectedPackageHashes: new Set(['hash-foo']),
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(installGlobalPackages).toHaveBeenCalledTimes(1)
expect(installGlobalPackages).toHaveBeenCalledWith(
expect.objectContaining({ dir: '/global/v11/install-1' }),
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }),
['foo@^1.0.0']
)
})
@@ -369,7 +510,7 @@ test('global update does not clean up or persist policy when activation fails',
installDir: '/global/v11/old-foo',
}
const activationError = new Error('activation failed')
const updateResolutionPolicyManifest = jest.fn<() => Promise<void>>().mockResolvedValue(undefined)
const updateResolutionPolicyManifest = jest.fn<(violations: unknown[], dir: string) => Promise<void>>().mockResolvedValue(undefined)
createInstallDir.mockReturnValue('/global/v11/install-1')
getHashLink.mockReturnValue('/global/v11/hash-foo')
scanGlobalPackages.mockReturnValue([group])
@@ -411,11 +552,13 @@ test('global update --latest drops the spec only of plain version dependencies',
latest: true,
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(installGlobalPackages).toHaveBeenCalledTimes(1)
expect(installGlobalPackages).toHaveBeenCalledWith(
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
dir: '/global/v11/install-3',
global: false,
lockfileOnly: true,
omitSummaryLog: true,
}),
[
@@ -462,9 +605,10 @@ test('global update leaves the pnpm CLI to self-update', async () => {
latest: true,
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(installGlobalPackages).toHaveBeenCalledTimes(1)
expect(installGlobalPackages).toHaveBeenCalledWith(
expect.objectContaining({ dir: '/global/v11/install-1' }),
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }),
['foo']
)
})
@@ -529,6 +673,11 @@ test('global update --latest holds a package that latest would downgrade', async
// Only the one that went backwards is held; the other keeps its update.
expect(installGlobalPackages).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }),
['prerelease@2.0.0', 'stable']
)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: false }),
['prerelease@2.0.0', 'stable']
)
@@ -537,7 +686,7 @@ test('global update --latest holds a package that latest would downgrade', async
)
})
test('global update without --latest resolves nothing up front', async () => {
test('global update without --latest resolves once before materialization', async () => {
createInstallDir.mockReturnValueOnce('/global/v11/install-1')
getHashLink.mockReturnValue('/global/v11/hash-foo')
scanGlobalPackages.mockReturnValue([
@@ -550,8 +699,14 @@ test('global update without --latest resolves nothing up front', async () => {
globalPkgDir: '/global/v11',
} as any, [], {}) // eslint-disable-line @typescript-eslint/no-explicit-any
expect(installGlobalPackages).toHaveBeenCalledTimes(1)
expect(installGlobalPackages).toHaveBeenCalledWith(
expect(installGlobalPackages).toHaveBeenCalledTimes(2)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: true }),
['foo@^1.0.0']
)
expect(installGlobalPackages).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ dir: '/global/v11/install-1', lockfileOnly: false }),
['foo@^1.0.0']
)
+6
View File
@@ -48,6 +48,12 @@
{
"path": "../../installing/deps-installer"
},
{
"path": "../../installing/modules-yaml"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../pkg-manifest/reader"
},
+49
View File
@@ -407,6 +407,55 @@ test('global update to latest', async () => {
expect(pkgJson.version).toBe('3.1.0')
})
test('unchanged global update reports already up to date without replacing the group', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = { [PATH_NAME]: path.join(pnpmHome, 'bin'), PNPM_HOME: pnpmHome, XDG_DATA_HOME: global }
await execPnpm(['add', '--global', 'is-positive@3.1.0'], { env })
const globalDir = globalPkgDir(pnpmHome)
const installDirBefore = findGlobalPkg(globalDir, 'is-positive')
const result = execPnpmSync(['update', '--global'], { env, expectSuccess: true })
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
expect(output).toContain('Already up to date')
expect(output).not.toMatch(/Packages:\s+\+\d/)
expect(output).not.toContain('+ is-positive')
expect(output.match(/Done in /g)).toHaveLength(1)
expect(findGlobalPkg(globalDir, 'is-positive')).toBe(installDirBefore)
})
test('unchanged global update still approves a pending build', async () => {
prepare()
const global = path.resolve('..', 'global')
const pnpmHome = path.join(global, 'pnpm')
fs.mkdirSync(global)
const env = {
[PATH_NAME]: `${path.join(pnpmHome, 'bin')}${path.delimiter}${process.env[PATH_NAME]!}`,
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: global,
}
await execPnpm(['add', '--global', '@pnpm.e2e/install-script-example@1.0.0'], { env })
const globalDir = globalPkgDir(pnpmHome)
const installBefore = findGlobalPkgInstall(globalDir, '@pnpm.e2e/install-script-example')!
const buildArtifact = path.join(installBefore.pkgPath, 'generated-by-install.js')
expect(fs.existsSync(buildArtifact)).toBe(false)
const result = execPnpmSync(['update', '--global'], {
env: { ...env, PNPM_AUTO_APPROVE_BUILDS_FOR_TESTS: '1' },
expectSuccess: true,
})
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
expect(output).toContain('Already up to date')
expect(fs.existsSync(buildArtifact)).toBe(true)
expect(findGlobalPkgInstall(globalDir, '@pnpm.e2e/install-script-example')?.installDir).toBe(installBefore.installDir)
})
test('global update should not crash if there are no global packages', async () => {
prepare()
const global = path.resolve('..', 'global')