diff --git a/.changeset/pnpm-doctor-command.md b/.changeset/pnpm-doctor-command.md new file mode 100644 index 0000000000..d2be1c165f --- /dev/null +++ b/.changeset/pnpm-doctor-command.md @@ -0,0 +1,8 @@ +--- +"pnpm": minor +"pacquet": minor +--- + +Added `pnpm doctor`, which diagnoses the pnpm installation and the environment it runs in: the versions and install method, whether the global bin directory is on `PATH`, whether the store and cache are writable, which link strategies (reflink, hardlink, symlink) the store's filesystem supports, registry connectivity, and an offline `file:` install that exercises the resolve/store/link path end to end. Each check reports how to fix what it finds, and the command exits non-zero when any check fails. + +Use `--offline` to skip the checks that need network access, `--json` for machine-readable output, and `--benchmark` to time the filesystem and install checks. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a39eeb18c8..1854194400 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,6 +236,30 @@ jobs: pnpm_tarball=$(find "$release_tarballs/pnpm11_pnpm" -type f -name '*.tgz' -print -quit) test -n "$pnpm_tarball" + # dist/node_modules carries every runtime dependency, so the published + # manifest must declare no dependency field of any kind: a surviving + # `dependencies` or `optionalDependencies` is resolved and installed a + # second time, and `devDependencies` names workspace packages that are + # never published at all. .meta-updater/src/index.ts additionally + # forbids `optionalDependencies` and `peerDependencies` on this + # package outright. + # + # Assert on the packed tarball rather than on any of the strippers, + # because an npm publish cannot be taken back and only two of these + # fields are stripped at pack time: the .pnpmfile.cjs beforePacking + # hook deliberately leaves `optionalDependencies` alone (the v12 Rust + # wrapper needs them for its natives), which leaves meta-updater — a + # source normalizer, not a release gate — as the only thing between + # that field and the registry. + tar -xOf "$pnpm_tarball" package/package.json | node -e ' + const manifest = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const fields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] + const declared = fields.filter((field) => manifest[field] != null) + if (declared.length > 0) { + throw new Error("The published pnpm manifest must not declare " + declared.join(" or ") + " - every runtime dependency is bundled into dist/node_modules") + } + ' + smoke_dir=$(mktemp -d) tar -xzf "$pnpm_tarball" -C "$smoke_dir" command -v node diff --git a/.github/workflows/update-latest.yml b/.github/workflows/update-latest.yml index b13dcf3796..e59a7f84a3 100644 --- a/.github/workflows/update-latest.yml +++ b/.github/workflows/update-latest.yml @@ -10,19 +10,38 @@ on: description: Tag (use latest- when tagging an older release line) default: latest required: true + skip_upgrade_check: + description: Skip the upgrade check (use only when the release line's current version is itself broken) + required: false + type: boolean + default: false permissions: {} + +env: + # Passed to each command as `--registry`, which is the only form pnpm reads; + # it ignores `npm_config_registry`. + REGISTRY: https://registry.npmjs.org/ + # Keeps a runner-level .npmrc from redirecting any of this. + npm_config_userconfig: /dev/null + # The pnpm that operates this workflow, never the version being released: + # validate and verify-upgrade decide what the release is measured against, and + # tag-in-registry holds the publish token. Bump deliberately, to a version that + # has already proven itself as `latest`. + RELEASE_TOOL_PNPM_VERSION: 11.13.1 + jobs: - tag-in-registry: - name: Tagging ${{ github.event.inputs.version }} as ${{ github.event.inputs.tag }} - environment: release + validate: + name: Validate ${{ github.event.inputs.version }} and ${{ github.event.inputs.tag }} runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: {} steps: - - uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2.0.2 + - name: Install pnpm and Node + uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4 with: - api_token: ${{ secrets.GARNET_API_TOKEN }} - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + version: ${{ env.RELEASE_TOOL_PNPM_VERSION }} + runtime: node@26.5.0 + install: false # The latest- tag is derived from the version instead of being # hardcoded per release branch, so dispatching this workflow from the wrong # branch cannot point a newer line's tag at an older release @@ -32,8 +51,11 @@ jobs: VERSION: ${{ github.event.inputs.version }} TAG: ${{ github.event.inputs.tag }} run: | - if ! printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[.0-9A-Za-z-]+)?$'; then - echo "::error::Version must be an exact semver version like 11.2.0. Got: ${VERSION}" + set -euo pipefail + # Stable only: every dispatch moves latest- whatever `tag` says, + # so a prerelease cannot be tagged here without reaching everyone. + if ! printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version must be an exact, stable semver version like 11.2.0 (prereleases cannot be tagged). Got: ${VERSION}" exit 1 fi MAJOR="${VERSION%%.*}" @@ -45,25 +67,160 @@ jobs: fi ;; latest) - CURRENT_LATEST_MAJOR="$(npm view pnpm dist-tags.latest | cut -d . -f 1)" + CURRENT_LATEST_MAJOR="$(pn view pnpm dist-tags.latest --registry="$REGISTRY" | cut -d . -f 1)" + # A dist-tag that could not be read must not pass the guard: an + # empty value makes the comparison below error out with a non-zero + # status, which `if` swallows, silently skipping the check. + if [ -z "$CURRENT_LATEST_MAJOR" ]; then + echo "::error::Could not read the current latest version of pnpm from the registry." + exit 1 + fi if [ "$MAJOR" -lt "$CURRENT_LATEST_MAJOR" ]; then echo "::error::Refusing to move the latest tag back from v${CURRENT_LATEST_MAJOR} to ${VERSION}. Use latest-${MAJOR} to tag an older release line." exit 1 fi ;; esac + # Upgrading onto a version reads its published manifest and platform packages, + # so a release that is only broken as an upgrade target fails here and nowhere + # earlier — and the dist-tag move below is the last point at which it can still + # be held back. + # + # This job executes the release, so it holds no secrets: a lifecycle script + # could otherwise reach a later step through $GITHUB_PATH/$GITHUB_ENV and take + # the publish token with it. + verify-upgrade: + name: Verify upgrading onto ${{ github.event.inputs.version }} + needs: validate + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: {} + steps: + - name: Install pnpm and Node + uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4 + with: + version: ${{ env.RELEASE_TOOL_PNPM_VERSION }} + runtime: node@26.5.0 + install: false + - name: Verify upgrading onto the new version + if: ${{ !inputs.skip_upgrade_check }} + env: + VERSION: ${{ github.event.inputs.version }} + run: | + set -eu + # pnpm enforces the devEngines of whatever manifest it finds by walking + # up from the working directory, so every command here runs from a + # scratch dir rather than the caller's cwd. + cd "$(mktemp -d)" + + MAJOR="${VERSION%%.*}" + # Upgrade from what users on this release line are running today. A new + # major has no latest- yet, so fall back to the current latest. + if ! FROM=$(pn view "pnpm@latest-${MAJOR}" version --registry="$REGISTRY" 2>/dev/null) || [ -z "$FROM" ]; then + FROM=$(pn view pnpm dist-tags.latest --registry="$REGISTRY") + fi + echo "Upgrading from v${FROM} to v${VERSION}" + + # Both wrappers are exercised: self-update reinstalls the package it is + # running from, so `pnpm` and `@pnpm/exe` resolve different published + # manifests and a broken one surfaces on only that wrapper. + for package in pnpm @pnpm/exe; do + work=$(mktemp -d) + # Isolate the whole pnpm environment so the upgrade resolves and + # installs for real instead of reusing a warm store or global dir. + export PNPM_HOME="$work/home" + export XDG_DATA_HOME="$work/data" + export XDG_CACHE_HOME="$work/cache" + + cd "$work" + mkdir -p "$work/from" + printf '{"name":"pnpm-upgrade-check","version":"0.0.0","private":true}\n' >"$work/from/package.json" + # `--allow-build` is what makes this install the starting point rather + # than a fake one: pnpm blocks dependency build scripts by default, and + # `@pnpm/exe`'s setup.js is what swaps its placeholder bin for the real + # native. Without it every run reproduces the exact breakage this gate + # exists to catch (the placeholder surviving) and blames the release. + pn add "${package}@${FROM}" \ + --dir "$work/from" \ + --allow-build=@pnpm/exe \ + --registry="$REGISTRY" + # Drive through the bin shim rather than a path into the package: the + # entry point differs by major (pnpm.cjs in v10, pnpm.mjs in v11) and + # between the two wrappers, and the shim is what a user gets. + from_bin="$work/from/node_modules/.bin/pnpm" + + # Check the starting point separately so a broken current release + # fails naming its own version rather than looking like a fault in + # $VERSION. When the current release is the broken one, it also blocks + # its own fix — dispatch with skip_upgrade_check to promote that. + test "$("$from_bin" --version)" = "$FROM" + + "$from_bin" self-update "$VERSION" + + # self-update installs the target using the layout of the version + # doing the installing: v11 links global bins into $PNPM_HOME/bin, + # v10 links them directly into $PNPM_HOME. + if [ -x "$PNPM_HOME/bin/pnpm" ]; then + upgraded="$PNPM_HOME/bin/pnpm" + else + upgraded="$PNPM_HOME/pnpm" + fi + # Run the upgraded binary rather than trust self-update's exit code: + # `@pnpm/exe`'s preinstall leaves its placeholder bin in place when the + # platform package ships no native, so that failure is invisible until + # the binary is actually invoked. + test "$("$upgraded" --version)" = "$VERSION" + + # Hand the rest of the gate to the release itself, so what is gated is + # what ships rather than a reproduction of it maintained here. The + # command postdates the older lines this workflow can still tag, so + # skip it where it does not exist rather than fail a v10 promotion on + # a v11 command. + if "$upgraded" doctor --help >/dev/null 2>&1; then + "$upgraded" doctor --offline + else + echo "SKIP: ${package}@${VERSION} predates \`pnpm doctor\`; ran --version only" + fi + echo "OK: ${package}@${FROM} self-updated to v${VERSION}" + done + + # Tagging with the promoted version reads as free verification and must not be + # done: this job is deciding whether that artifact is fit to ship, and pnpm is + # published by OIDC rather than by this token, so handing it the token turns a + # compromised dependency into org-wide publish access. verify-upgrade runs the + # release with no secrets so this job never has to. + tag-in-registry: + name: Tagging ${{ github.event.inputs.version }} as ${{ github.event.inputs.tag }} + needs: verify-upgrade + environment: release + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: garnet-org/action@2b7fc9d79b54f551b43358c27424a36064b3e078 # v2.0.2 + with: + api_token: ${{ secrets.GARNET_API_TOKEN }} + - name: Install pnpm and Node + uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4 + with: + version: ${{ env.RELEASE_TOOL_PNPM_VERSION }} + runtime: node@26.5.0 + install: false - name: Update tag env: - "npm_config_//registry.npmjs.org/:_authToken": ${{ secrets.NPM_TOKEN }} + # Written to pnpm's config below, not passed as + # `npm_config_//registry.npmjs.org/:_authToken`, which pnpm ignores. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} VERSION: ${{ github.event.inputs.version }} TAG: ${{ github.event.inputs.tag }} run: | + set -euo pipefail + trap 'pn config delete "//registry.npmjs.org/:_authToken" || true' EXIT + pn config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}" + MAJOR="${VERSION%%.*}" - npm dist-tag add "pnpm@${VERSION}" "latest-${MAJOR}" - npm dist-tag add "@pnpm/exe@${VERSION}" "latest-${MAJOR}" + pn dist-tag add "pnpm@${VERSION}" "latest-${MAJOR}" --registry="$REGISTRY" + pn dist-tag add "@pnpm/exe@${VERSION}" "latest-${MAJOR}" --registry="$REGISTRY" if [ "$TAG" != "latest-${MAJOR}" ]; then - npm dist-tag add "pnpm@${VERSION}" "${TAG}" - npm dist-tag add "@pnpm/exe@${VERSION}" "${TAG}" + pn dist-tag add "pnpm@${VERSION}" "${TAG}" --registry="$REGISTRY" + pn dist-tag add "@pnpm/exe@${VERSION}" "${TAG}" --registry="$REGISTRY" fi publish-to-winget: diff --git a/Cargo.lock b/Cargo.lock index 1de5803b24..aa04278c8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3970,6 +3970,7 @@ dependencies = [ "pnpr", "pretty_assertions", "rayon", + "reflink-copy", "reqwest 0.13.4", "rmp-serde", "serde", diff --git a/pnpm/crates/cli/Cargo.toml b/pnpm/crates/cli/Cargo.toml index d976a2da29..d1fc97e809 100644 --- a/pnpm/crates/cli/Cargo.toml +++ b/pnpm/crates/cli/Cargo.toml @@ -75,6 +75,7 @@ p256 = { workspace = true } pathdiff = { workspace = true } pipe-trait = { workspace = true } rayon = { workspace = true } +reflink-copy = { workspace = true } reqwest = { workspace = true } rmp-serde = { workspace = true } serde = { workspace = true } diff --git a/pnpm/crates/cli/src/cli_args.rs b/pnpm/crates/cli/src/cli_args.rs index 0f307bf651..46eb53616b 100644 --- a/pnpm/crates/cli/src/cli_args.rs +++ b/pnpm/crates/cli/src/cli_args.rs @@ -19,6 +19,7 @@ pub mod deprecate; pub mod dist_tag; pub mod dlx; pub mod docs; +pub mod doctor; pub mod exec; pub mod fetch; pub mod find_hash; diff --git a/pnpm/crates/cli/src/cli_args/cli_command.rs b/pnpm/crates/cli/src/cli_args/cli_command.rs index 18028e5994..086c9dcc66 100644 --- a/pnpm/crates/cli/src/cli_args/cli_command.rs +++ b/pnpm/crates/cli/src/cli_args/cli_command.rs @@ -19,6 +19,7 @@ use super::{ dist_tag::DistTagArgs, dlx::DlxArgs, docs::DocsArgs, + doctor::DoctorArgs, exec::ExecArgs, fetch::FetchArgs, find_hash::FindHashArgs, @@ -313,6 +314,8 @@ pub enum CliCommand { DistTag(DistTagArgs), /// Test connectivity to the configured registry. Ping(PingArgs), + /// Run diagnostics on the pnpm installation and environment. + Doctor(DoctorArgs), /// Search for packages in the registry. #[clap(visible_aliases = ["s", "se", "find"])] Search(SearchArgs), diff --git a/pnpm/crates/cli/src/cli_args/dispatch.rs b/pnpm/crates/cli/src/cli_args/dispatch.rs index 9ef6a1a9be..8fad12bd29 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch.rs @@ -338,6 +338,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result dispatch_query::deprecate(ctx, args), CliCommand::Undeprecate(args) => dispatch_query::undeprecate(ctx, args), CliCommand::Ping(args) => dispatch_query::ping(ctx, args), + CliCommand::Doctor(args) => dispatch_query::doctor(ctx, args), CliCommand::Search(args) => dispatch_query::search(ctx, args), CliCommand::Rebuild(args) => dispatch_install::rebuild(ctx, args), CliCommand::Pack(args) => dispatch_query::pack(ctx, args), diff --git a/pnpm/crates/cli/src/cli_args/dispatch_query.rs b/pnpm/crates/cli/src/cli_args/dispatch_query.rs index 2afdbf9d3f..0d7708a940 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch_query.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch_query.rs @@ -13,6 +13,7 @@ use super::{ dispatch::{CommandFuture, RunCtx}, dist_tag::DistTagArgs, docs::DocsArgs, + doctor::{DoctorArgs, DoctorOutcome}, find_hash::FindHashArgs, ignored_builds::IgnoredBuildsArgs, lane::LaneArgs, @@ -305,6 +306,27 @@ pub(super) fn ping<'a>(ctx: &RunCtx<'a>, args: PingArgs) -> miette::Result(ctx: &RunCtx<'a>, args: DoctorArgs) -> miette::Result> { + let cfg: &Config = (ctx.config)()?; + Ok(Box::pin(async move { + let result = args.run(cfg).await?; + println!("{}", result.output); + if result.outcome == DoctorOutcome::Unhealthy { + #[expect( + clippy::exit, + reason = "`doctor` exits non-zero when a check fails, mirroring pnpm" + )] + std::process::exit(1); + } + Ok(()) + })) +} + // `pack` prints the tarball summary (or JSON) its handler returns; the // reporter type only affects the lifecycle-script output, so it's threaded // into `run` and the result printed here, mirroring pnpm's `handler` → CLI diff --git a/pnpm/crates/cli/src/cli_args/doctor.rs b/pnpm/crates/cli/src/cli_args/doctor.rs new file mode 100644 index 0000000000..53938de7cb --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/doctor.rs @@ -0,0 +1,442 @@ +//! `pacquet doctor` — diagnose the pnpm installation and the environment it +//! runs in. +//! +//! The checks are the ones that predict whether an install will work on this +//! machine and how fast it will be, plus one live check — an offline `file:` +//! install — that drives the resolve/store/link path end to end. The release +//! pipeline runs this same command against a freshly published version before +//! moving its dist-tags, so what gates a release is what ships to users. + +use crate::cli_args::ping::PingArgs; +use clap::Args; +use pacquet_config::Config; +use serde::Serialize; +use std::{ + fmt::Write as _, + fs, + path::{Path, PathBuf}, + process::Command, + time::Instant, +}; + +#[derive(Debug, Args)] +pub struct DoctorArgs { + /// Skip checks that need network access. + #[clap(long)] + pub offline: bool, + + /// Also time filesystem and install operations. + #[clap(long)] + pub benchmark: bool, + + /// Report the results as JSON. + #[clap(long)] + pub json: bool, +} + +/// Whether every check passed. The caller turns `Unhealthy` into a non-zero +/// exit; see `dispatch_query::doctor`. +#[derive(Debug, PartialEq, Eq)] +pub enum DoctorOutcome { + Healthy, + Unhealthy, +} + +/// What a check concluded. `Warn` reports something worth fixing that does not +/// stop pnpm from working, so it does not fail the command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +enum CheckStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CheckResult { + title: String, + status: CheckStatus, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + /// A concrete next step, shown when the check does not pass. + #[serde(skip_serializing_if = "Option::is_none")] + fix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, +} + +impl CheckResult { + fn pass(title: &str, detail: impl Into) -> Self { + CheckResult { + title: title.to_owned(), + status: CheckStatus::Pass, + detail: Some(detail.into()), + fix: None, + duration_ms: None, + } + } + + fn warn(title: &str, detail: impl Into, fix: impl Into) -> Self { + CheckResult { + title: title.to_owned(), + status: CheckStatus::Warn, + detail: Some(detail.into()), + fix: Some(fix.into()), + duration_ms: None, + } + } + + fn fail(title: &str, detail: impl Into, fix: impl Into) -> Self { + CheckResult { + title: title.to_owned(), + status: CheckStatus::Fail, + detail: Some(detail.into()), + fix: Some(fix.into()), + duration_ms: None, + } + } + + fn timed(mut self, benchmark: bool, started: Instant) -> Self { + if benchmark { + self.duration_ms = Some(started.elapsed().as_millis()); + } + self + } +} + +#[derive(Debug, Serialize)] +struct DoctorReport { + checks: Vec, +} + +/// The report to print and whether it should fail the command. +pub struct DoctorResult { + pub output: String, + pub outcome: DoctorOutcome, +} + +impl DoctorArgs { + /// Run every check and render the report. Returns the text (or JSON) to + /// print alongside the outcome, leaving printing and the exit status to + /// the caller. + pub async fn run(&self, config: &Config) -> miette::Result { + let mut checks = vec![check_versions(), check_install_method()]; + checks.push(check_global_bin_dir(config)); + checks.push(check_writable_dir("Cache directory", &config.cache_dir)); + checks.push(check_writable_dir("Store directory", config.store_dir.root())); + checks.push(check_filesystem_capabilities(config, self.benchmark)); + checks.push(self.check_connectivity(config).await); + checks.push(check_install_smoke_test(self.benchmark)); + + let outcome = if checks.iter().any(|check| check.status == CheckStatus::Fail) { + DoctorOutcome::Unhealthy + } else { + DoctorOutcome::Healthy + }; + + let report = DoctorReport { checks }; + let output = if self.json { + serde_json::to_string_pretty(&report).map_err(|error| { + miette::miette!("Failed to render the doctor report as JSON: {error}") + })? + } else { + render_report(&report) + }; + Ok(DoctorResult { output, outcome }) + } + + async fn check_connectivity(&self, config: &Config) -> CheckResult { + let title = "Registry connectivity"; + if self.offline { + return CheckResult::pass(title, "skipped (--offline)"); + } + let started = Instant::now(); + match (PingArgs { registry: None }).run(config).await { + Ok(_) => CheckResult::pass( + title, + format!("{} ({}ms)", config.registry, started.elapsed().as_millis()), + ), + Err(error) => CheckResult::fail( + title, + format!("could not reach {}: {error}", config.registry), + "Check your network, proxy, and registry configuration.", + ), + } + } +} + +fn check_versions() -> CheckResult { + let pnpm_version = env!("CARGO_PKG_VERSION"); + let detail = match node_version() { + Some(node_version) => format!("pnpm {pnpm_version}, Node.js {node_version}"), + None => format!("pnpm {pnpm_version}"), + }; + CheckResult::pass("Versions", detail) +} + +/// Report the Node.js that lifecycle scripts will run under. pacquet is a +/// native binary, so Node is not required for pnpm itself to work — its +/// absence is worth reporting, not failing on. +fn node_version() -> Option { + let output = Command::new("node").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let version = String::from_utf8(output.stdout).ok()?; + Some(version.trim().trim_start_matches('v').to_owned()) +} + +fn check_install_method() -> CheckResult { + let title = "Install method"; + if std::env::var_os("COREPACK_ROOT").is_some() { + return CheckResult::warn( + title, + "pnpm, run by Corepack", + r#"Corepack manages the pnpm version itself; "pnpm self-update" is unavailable under it."#, + ); + } + CheckResult::pass(title, "pnpm") +} + +/// Check the global executables directory — where the CLI links binaries and +/// which must be on `PATH` for them to run. The layout moved between majors +/// (v10 links into `PNPM_HOME` directly, v11 into `PNPM_HOME/bin`), so accept +/// whichever candidate `PATH` actually contains. +fn check_global_bin_dir(config: &Config) -> CheckResult { + let title = "Global bin directory"; + let candidates: Vec = + [config.global_bin_dir.clone(), config.global_dir.clone()].into_iter().flatten().collect(); + let Some(first) = candidates.first() else { + return CheckResult::pass(title, "not configured"); + }; + + let Some(path_var) = std::env::var_os("PATH") else { + return CheckResult::warn( + title, + "the PATH environment variable is not set", + r#"Run "pnpm setup" to add it to your shell configuration."#, + ); + }; + let path_dirs: Vec = std::env::split_paths(&path_var).collect(); + + let Some(bin_dir) = candidates.iter().find(|dir| dir_is_in_path(dir, &path_dirs)) else { + return CheckResult::warn( + title, + format!("{} is not in PATH", first.display()), + r#"Run "pnpm setup" to add it to your shell configuration."#, + ); + }; + if !can_write_to_dir(bin_dir) { + return CheckResult::fail( + title, + format!("no write access to {}", bin_dir.display()), + r#"Run "pnpm setup", or fix the directory permissions."#, + ); + } + CheckResult::pass(title, bin_dir.display().to_string()) +} + +fn dir_is_in_path(dir: &Path, path_dirs: &[PathBuf]) -> bool { + let canonical = dir.canonicalize(); + path_dirs.iter().any(|entry| { + entry == dir + || match (&canonical, entry.canonicalize()) { + (Ok(dir), Ok(entry)) => dir == &entry, + _ => false, + } + }) +} + +fn check_writable_dir(title: &str, dir: &Path) -> CheckResult { + if !can_write_to_dir(dir) { + return CheckResult::fail( + title, + format!("no write access to {}", dir.display()), + "Fix the directory permissions or point the setting at a writable path.", + ); + } + CheckResult::pass(title, dir.display().to_string()) +} + +/// Probe which link strategies work from the store's volume, since that is what +/// determines how packages land in `node_modules` and how fast an install is: a +/// reflink (copy-on-write) or hardlink is near-free, a plain copy is not. +fn check_filesystem_capabilities(config: &Config, benchmark: bool) -> CheckResult { + let title = "Filesystem"; + let started = Instant::now(); + let probe_dir = tempfile::tempdir_in(config.store_dir.root()).or_else(|_| tempfile::tempdir()); + let Ok(probe_dir) = probe_dir else { + return CheckResult::warn( + title, + "could not create a probe directory", + "Check that the store directory and the system temp directory are writable.", + ); + }; + + let Ok(capabilities) = probe_link_capabilities(probe_dir.path()) else { + return CheckResult::warn( + title, + "could not write a probe file", + "Check that the store directory is writable.", + ); + }; + + let available: Vec<&str> = + capabilities.iter().filter(|(_, supported)| *supported).map(|(name, _)| *name).collect(); + let has_cheap_link = capabilities + .iter() + .any(|(name, supported)| *supported && matches!(*name, "reflink" | "hardlink")); + + let result = if has_cheap_link { + CheckResult::pass(title, format!("available: {}", available.join(", "))) + } else { + CheckResult::warn( + title, + "only copying is available", + "Neither reflink nor hardlink works between the store and this project; installs will copy files and be slower. Put the store on the same filesystem as your projects.", + ) + }; + result.timed(benchmark, started) +} + +fn probe_link_capabilities(dir: &Path) -> std::io::Result<[(&'static str, bool); 3]> { + let source = dir.join("source"); + fs::write(&source, "pnpm-doctor")?; + Ok([ + ("reflink", reflink_copy::reflink(&source, dir.join("reflink")).is_ok()), + ("hardlink", fs::hard_link(&source, dir.join("hardlink")).is_ok()), + ("symlink", symlink_file(&source, &dir.join("symlink")).is_ok()), + ]) +} + +#[cfg(unix)] +fn symlink_file(source: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(source, link) +} + +#[cfg(windows)] +fn symlink_file(source: &Path, link: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_file(source, link) +} + +/// Install a throwaway package as a `file:` dependency, entirely offline, to +/// confirm this binary can resolve, fetch into the store, and link a dependency +/// end to end. Catches both classes of broken release the release gate exists +/// for: a binary that will not run at all, and one whose install path crashes. +fn check_install_smoke_test(benchmark: bool) -> CheckResult { + let title = "Install smoke test"; + let started = Instant::now(); + let Ok(base) = tempfile::tempdir() else { + return CheckResult::warn( + title, + "could not create a temporary directory", + "Check that the system temp directory is writable.", + ); + }; + match run_install_smoke_test(base.path()) { + Ok(()) => CheckResult::pass(title, r#"offline "file:" install linked its dependency"#) + .timed(benchmark, started), + Err(detail) => CheckResult::fail( + title, + detail, + r#"Run "pnpm install" in a scratch project to see the full error."#, + ), + } +} + +fn run_install_smoke_test(base: &Path) -> Result<(), String> { + let provider = base.join("provider"); + let consumer = base.join("consumer"); + let store = base.join("store"); + fs::create_dir_all(&provider).map_err(|error| error.to_string())?; + fs::create_dir_all(&consumer).map_err(|error| error.to_string())?; + fs::write(provider.join("package.json"), r#"{"name":"pnpm-doctor-fixture","version":"0.0.0"}"#) + .map_err(|error| error.to_string())?; + fs::write( + consumer.join("package.json"), + r#"{"name":"pnpm-doctor-consumer","version":"0.0.0","private":true,"dependencies":{"pnpm-doctor-fixture":"file:../provider"}}"#, + ) + .map_err(|error| error.to_string())?; + + // A throwaway store keeps the probe from writing into the real one. The + // fixture is a temp directory with no lockfile and no workspace above it, + // so nothing here depends on the lockfile or workspace flags. + let current_exe = std::env::current_exe().map_err(|error| error.to_string())?; + let output = Command::new(current_exe) + .current_dir(&consumer) + .args(["install", "--offline", "--ignore-scripts"]) + .arg(format!("--store-dir={}", store.display())) + .output() + .map_err(|error| error.to_string())?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let reason = last_line(stderr.trim()); + return Err(format!( + r#"offline "file:" install failed{}"#, + if reason.is_empty() { String::new() } else { format!(": {reason}") }, + )); + } + if !consumer.join("node_modules/pnpm-doctor-fixture/package.json").exists() { + return Err("install reported success but the dependency was not linked".to_owned()); + } + Ok(()) +} + +fn last_line(text: &str) -> String { + text.lines().rfind(|line| !line.trim().is_empty()).unwrap_or_default().to_owned() +} + +fn can_write_to_dir(dir: &Path) -> bool { + let probe = dir.join(format!(".pnpm-doctor-write-{}", std::process::id())); + let written = fs::write(&probe, b"").is_ok(); + let _ = fs::remove_file(&probe); + written +} + +fn render_report(report: &DoctorReport) -> String { + let mut lines: Vec = report + .checks + .iter() + .map(|check| { + let mut line = format!("{} {}", status_mark(check.status), check.title); + if let Some(detail) = &check.detail { + let _ = write!(line, ": {detail}"); + } + if let Some(duration) = check.duration_ms { + let _ = write!(line, " ({duration}ms)"); + } + if check.status != CheckStatus::Pass + && let Some(fix) = &check.fix + { + let _ = write!(line, "\n {fix}"); + } + line + }) + .collect(); + + let failed = report.checks.iter().filter(|check| check.status == CheckStatus::Fail).count(); + let warned = report.checks.iter().filter(|check| check.status == CheckStatus::Warn).count(); + let summary = if failed > 0 { + format!("{failed} check(s) failed") + } else if warned > 0 { + format!("All checks passed with {warned} warning(s)") + } else { + "All checks passed".to_owned() + }; + lines.push(String::new()); + lines.push(summary); + lines.join("\n") +} + +fn status_mark(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Pass => "✓", + CheckStatus::Warn => "‼", + CheckStatus::Fail => "✗", + } +} + +#[cfg(test)] +mod tests; diff --git a/pnpm/crates/cli/src/cli_args/doctor/tests.rs b/pnpm/crates/cli/src/cli_args/doctor/tests.rs new file mode 100644 index 0000000000..5f2f9f92ed --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/doctor/tests.rs @@ -0,0 +1,92 @@ +use super::{ + CheckResult, CheckStatus, DoctorReport, can_write_to_dir, last_line, probe_link_capabilities, + render_report, status_mark, +}; +use pretty_assertions::assert_eq; + +fn report(checks: Vec) -> DoctorReport { + DoctorReport { checks } +} + +#[test] +fn render_report_summarizes_a_clean_run() { + let output = render_report(&report(vec![CheckResult::pass("Versions", "pnpm 12.0.0")])); + dbg!(&output); + assert_eq!(output, "✓ Versions: pnpm 12.0.0\n\nAll checks passed"); +} + +/// A warning must not read as a failure, and its fix has to reach the user — +/// a check nobody can act on is noise. +#[test] +fn render_report_shows_the_fix_for_a_warning() { + let output = + render_report(&report(vec![CheckResult::warn("Filesystem", "only copying", "Move it.")])); + dbg!(&output); + assert_eq!( + output, + "‼ Filesystem: only copying\n Move it.\n\nAll checks passed with 1 warning(s)", + ); +} + +#[test] +fn render_report_counts_failures() { + let output = render_report(&report(vec![ + CheckResult::pass("Versions", "pnpm 12.0.0"), + CheckResult::fail("Store directory", "no write access to /nope", "Fix it."), + ])); + dbg!(&output); + assert!(output.ends_with("1 check(s) failed"), "{output}"); +} + +#[test] +fn status_marks_are_distinct() { + assert_eq!(status_mark(CheckStatus::Pass), "✓"); + assert_eq!(status_mark(CheckStatus::Warn), "‼"); + assert_eq!(status_mark(CheckStatus::Fail), "✗"); +} + +/// The JSON shape is what the release pipeline and any other tooling read, so +/// it is a contract: camelCase keys, and absent fields omitted rather than null. +#[test] +fn json_report_uses_camel_case_and_omits_empty_fields() { + let mut check = CheckResult::pass("Filesystem", "available: hardlink"); + check.duration_ms = Some(3); + let json = serde_json::to_string(&report(vec![check])).expect("serialize report"); + dbg!(&json); + assert_eq!( + json, + r#"{"checks":[{"title":"Filesystem","status":"pass","detail":"available: hardlink","durationMs":3}]}"#, + ); +} + +#[test] +fn probe_reports_the_links_a_normal_filesystem_supports() { + let dir = tempfile::tempdir().expect("create temp dir"); + let capabilities = probe_link_capabilities(dir.path()).expect("probe links"); + dbg!(&capabilities); + let supported = |name: &str| { + capabilities.iter().any(|(candidate, supported)| *candidate == name && *supported) + }; + assert!(supported("hardlink"), "a temp dir must support hardlinks"); + assert!(supported("symlink"), "a temp dir must support symlinks"); +} + +#[test] +fn can_write_to_dir_detects_a_writable_dir() { + let dir = tempfile::tempdir().expect("create temp dir"); + assert!(can_write_to_dir(dir.path())); +} + +#[test] +fn can_write_to_dir_rejects_a_missing_dir() { + let dir = tempfile::tempdir().expect("create temp dir"); + assert!(!can_write_to_dir(&dir.path().join("does-not-exist"))); +} + +/// The install smoke test surfaces the last meaningful stderr line, so a +/// trailing blank line must not swallow the actual error. +#[test] +fn last_line_skips_trailing_blanks() { + assert_eq!(last_line("first\nERR_PNPM_BROKEN it broke\n\n"), "ERR_PNPM_BROKEN it broke"); + assert_eq!(last_line(""), ""); +} diff --git a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs index 76267963c2..910a18dc82 100644 --- a/pnpm/crates/cli/src/cli_args/switch_cli_version.rs +++ b/pnpm/crates/cli/src/cli_args/switch_cli_version.rs @@ -322,6 +322,7 @@ fn should_skip_command(command: &CliCommand) -> bool { command, CliCommand::Completion(_) | CliCommand::CompletionServer(_) + | CliCommand::Doctor(_) | CliCommand::Runtime(_) | CliCommand::SelfUpdate(_) | CliCommand::Setup(_) @@ -335,6 +336,7 @@ fn should_skip_command_name(command: &str) -> bool { command, "completion" | "completion-server" + | "doctor" | "env" | "runtime" | "rt" @@ -476,6 +478,7 @@ fn command_name(command: &CliCommand) -> &'static str { CliCommand::Stars(_) => "stars", CliCommand::DistTag(_) => "dist-tag", CliCommand::Ping(_) => "ping", + CliCommand::Doctor(_) => "doctor", CliCommand::Search(_) => "search", CliCommand::Rebuild(_) => "rebuild", CliCommand::Pack(_) => "pack", diff --git a/pnpm11/pnpm/src/cmd/doctor.ts b/pnpm11/pnpm/src/cmd/doctor.ts new file mode 100644 index 0000000000..8bcd7a4271 --- /dev/null +++ b/pnpm11/pnpm/src/cmd/doctor.ts @@ -0,0 +1,435 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import util from 'node:util' + +import { detectIfCurrentPkgIsExecutable, getCurrentPackageName, isExecutedByCorepack, packageManager } from '@pnpm/cli.meta' +import { docsUrl } from '@pnpm/cli.utils' +import { types as allTypes } from '@pnpm/config.reader' +import chalk from 'chalk' +import { pick } from 'ramda' +import { renderHelp } from 'render-help' + +export const commandNames = ['doctor'] + +/** + * `pnpm doctor` runs read-only diagnostics that predict whether an install + * will work on this machine and how fast it will be, plus one live check — + * an offline `file:` install — that exercises the resolve → store → link + * path end to end. It runs the same checks whether a user invokes it or the + * release pipeline does before promoting a version, so the release gate tests + * exactly what ships. + */ +export const skipPackageManagerCheck = true + +export function rcOptionsTypes (): Record { + return pick(['offline'], allTypes) +} + +export function cliOptionsTypes (): Record { + return { + ...rcOptionsTypes(), + json: Boolean, + benchmark: Boolean, + } +} + +export function help (): string { + return renderHelp({ + description: 'Run diagnostics on the pnpm installation and environment.', + descriptionLists: [ + { + title: 'Options', + list: [ + { + description: 'Skip checks that need network access', + name: '--offline', + }, + { + description: 'Also time filesystem and install operations', + name: '--benchmark', + }, + { + description: 'Report the results as JSON', + name: '--json', + }, + ], + }, + ], + url: docsUrl('doctor'), + usages: ['pnpm doctor [--offline] [--benchmark] [--json]'], + }) +} + +type CheckStatus = 'pass' | 'warn' | 'fail' + +export interface CheckResult { + title: string + status: CheckStatus + detail?: string + /** A concrete next step shown when the check does not pass. */ + fix?: string + durationMs?: number +} + +export interface DoctorCommandOptions { + dir: string + cacheDir: string + pnpmHomeDir: string + globalBinDir?: string + storeDir?: string + registries?: Record + offline?: boolean + json?: boolean + benchmark?: boolean + /** + * The argv used to re-invoke pnpm for the install smoke test. Defaults to + * the running binary; tests point it at a built entry instead. + */ + pnpmCommand?: string[] +} + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org/' + +export async function handler (opts: DoctorCommandOptions): Promise<{ output: string, exitCode: number }> { + const pnpmCommand = opts.pnpmCommand ?? resolveSelfCommand() + + const checks: CheckResult[] = [ + checkVersions(), + checkInstallMethod(), + await checkGlobalBinDir(opts), + await checkWritableDir('Cache directory', opts.cacheDir), + ...(opts.storeDir ? [await checkWritableDir('Store directory', opts.storeDir)] : []), + await checkFilesystemCapabilities(opts), + await checkConnectivity(opts), + await checkInstallSmokeTest(pnpmCommand, opts), + ] + + const exitCode = checks.some((check) => check.status === 'fail') ? 1 : 0 + + if (opts.json) { + return { output: JSON.stringify({ checks }, undefined, 2), exitCode } + } + return { output: renderReport(checks), exitCode } +} + +function checkVersions (): CheckResult { + return { + title: 'Versions', + status: 'pass', + detail: `pnpm ${packageManager.version}, Node.js ${process.versions.node}`, + } +} + +function checkInstallMethod (): CheckResult { + const wrapper = getCurrentPackageName() + if (isExecutedByCorepack()) { + return { + title: 'Install method', + status: 'warn', + detail: `${wrapper}, run by Corepack`, + fix: 'Corepack manages the pnpm version itself; "pnpm self-update" is unavailable under it.', + } + } + return { title: 'Install method', status: 'pass', detail: wrapper } +} + +/** + * Check the global executables directory — where `pnpm setup` links binaries + * and which must be on PATH for them to run. Not `opts.bin`, which outside + * `--global` is the local `node_modules/.bin` and is never on PATH. The layout + * moved between majors (v10 links into PNPM_HOME directly, v11 into + * PNPM_HOME/bin), so accept whichever candidate PATH actually contains. + */ +async function checkGlobalBinDir (opts: DoctorCommandOptions): Promise { + const title = 'Global bin directory' + const candidates = [ + opts.globalBinDir, + path.join(opts.pnpmHomeDir, 'bin'), + opts.pnpmHomeDir, + ].filter((dir): dir is string => dir != null && dir !== '') + + const pathEnv = readPathEnv(process.env) + if (pathEnv == null) { + return { title, status: 'warn', detail: 'the PATH environment variable is not set' } + } + + const inPath = await Promise.all(candidates.map((candidate) => dirIsInPath(candidate, pathEnv))) + const binDir = candidates.find((_, index) => inPath[index]) + if (binDir == null) { + return { + title, + status: 'warn', + detail: `${candidates[0]} is not in PATH`, + fix: 'Run "pnpm setup" to add it to your shell configuration.', + } + } + if (!canWriteToDir(binDir)) { + return { + title, + status: 'fail', + detail: `no write access to ${binDir}`, + fix: 'Run "pnpm setup", or fix the directory permissions.', + } + } + return { title, status: 'pass', detail: binDir } +} + +async function checkWritableDir (title: string, dir: string): Promise { + if (!canWriteToDir(dir)) { + return { + title, + status: 'fail', + detail: `no write access to ${dir}`, + fix: 'Fix the directory permissions or point the setting at a writable path.', + } + } + return { title, status: 'pass', detail: dir } +} + +/** + * Probe which link strategies work from the store's volume, since that is + * what determines how packages land in `node_modules` and how fast an install + * is: a reflink (copy-on-write) or hardlink is near-free, a plain copy is not. + */ +async function checkFilesystemCapabilities (opts: DoctorCommandOptions): Promise { + const title = 'Filesystem' + const probeParent = opts.storeDir ?? opts.cacheDir + let probeDir: string + try { + probeDir = await fs.promises.mkdtemp(path.join(probeParent, '.pnpm-doctor-')) + } catch { + probeDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pnpm-doctor-')) + } + const started = Date.now() + try { + const capabilities = await probeLinkCapabilities(probeDir) + const available = Object.entries(capabilities).filter(([, ok]) => ok).map(([name]) => name) + const status: CheckStatus = capabilities.reflink || capabilities.hardlink ? 'pass' : 'warn' + return { + title, + status, + detail: available.length > 0 ? `available: ${available.join(', ')}` : 'only copying is available', + fix: status === 'warn' + ? 'Neither reflink nor hardlink works between the store and this project; installs will copy files and be slower. Put the store on the same filesystem as your projects.' + : undefined, + durationMs: opts.benchmark ? Date.now() - started : undefined, + } + } finally { + await fs.promises.rm(probeDir, { recursive: true, force: true }) + } +} + +async function checkConnectivity (opts: DoctorCommandOptions): Promise { + const title = 'Registry connectivity' + if (opts.offline) { + return { title, status: 'pass', detail: 'skipped (--offline)' } + } + const registry = opts.registries?.default ?? DEFAULT_REGISTRY + const pingUrl = new URL('./-/ping?write=true', registry.endsWith('/') ? registry : `${registry}/`) + const started = Date.now() + try { + const response = await fetch(pingUrl, { signal: AbortSignal.timeout(15_000) }) + if (!response.ok) { + return { + title, + status: 'fail', + detail: `${registry} responded ${response.status} ${response.statusText}`.trimEnd(), + fix: 'Check your registry, proxy, and auth configuration.', + } + } + return { title, status: 'pass', detail: `${registry} (${Date.now() - started}ms)` } + } catch (err: unknown) { + return { + title, + status: 'fail', + detail: `could not reach ${registry}: ${util.types.isNativeError(err) ? err.message : String(err)}`, + fix: 'Check your network, proxy, and registry configuration.', + } + } +} + +/** + * Install a throwaway package as a `file:` dependency, entirely offline, to + * confirm the running binary can resolve, fetch into the store, and link a + * dependency end to end. Catches both classes of broken release the CI gate + * exists for: a binary that will not run at all, and one whose install path + * crashes. + */ +export async function checkInstallSmokeTest ( + pnpmCommand: string[], + opts: Pick +): Promise { + const title = 'Install smoke test' + const base = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pnpm-doctor-install-')) + const started = Date.now() + try { + const provider = path.join(base, 'provider') + const consumer = path.join(base, 'consumer') + await fs.promises.mkdir(provider, { recursive: true }) + await fs.promises.mkdir(consumer, { recursive: true }) + await fs.promises.writeFile( + path.join(provider, 'package.json'), + JSON.stringify({ name: 'pnpm-doctor-fixture', version: '0.0.0' }) + ) + await fs.promises.writeFile( + path.join(consumer, 'package.json'), + JSON.stringify({ + name: 'pnpm-doctor-consumer', + version: '0.0.0', + private: true, + dependencies: { 'pnpm-doctor-fixture': 'file:../provider' }, + }) + ) + + const [command, ...baseArgs] = pnpmCommand + const args = [ + ...baseArgs, + 'install', + '--offline', + '--ignore-scripts', + '--ignore-workspace', + '--no-frozen-lockfile', + `--store-dir=${path.join(base, 'store')}`, + `--cache-dir=${path.join(base, 'cache')}`, + ] + const result = spawnSync(command, args, { cwd: consumer, encoding: 'utf8', timeout: 120_000 }) + + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim() + return { + title, + status: 'fail', + detail: `offline "file:" install failed${stderr ? `: ${lastLine(stderr)}` : ''}`, + fix: 'Run "pnpm install" in a scratch project to see the full error.', + } + } + const linked = path.join(consumer, 'node_modules', 'pnpm-doctor-fixture', 'package.json') + if (!fs.existsSync(linked)) { + return { title, status: 'fail', detail: 'install reported success but the dependency was not linked' } + } + return { + title, + status: 'pass', + detail: 'offline "file:" install linked its dependency', + durationMs: opts.benchmark ? Date.now() - started : undefined, + } + } finally { + await fs.promises.rm(base, { recursive: true, force: true }) + } +} + +function renderReport (checks: CheckResult[]): string { + const lines = checks.map((check) => { + const head = `${statusMark(check.status)} ${check.title}${check.detail ? `: ${check.detail}` : ''}` + if (check.status === 'pass' || check.fix == null) { + return check.durationMs != null ? `${head} (${check.durationMs}ms)` : head + } + return `${head}\n ${chalk.dim(check.fix)}` + }) + const failed = checks.filter((check) => check.status === 'fail').length + const warned = checks.filter((check) => check.status === 'warn').length + const summary = failed > 0 + ? chalk.red(`${failed} check(s) failed`) + : warned > 0 + ? chalk.yellow(`All checks passed with ${warned} warning(s)`) + : chalk.green('All checks passed') + return `${lines.join('\n')}\n\n${summary}` +} + +function statusMark (status: CheckStatus): string { + switch (status) { + case 'pass': return chalk.green('✓') + case 'warn': return chalk.yellow('‼') + case 'fail': return chalk.red('✗') + } +} + +/** + * Re-invoke the pnpm that is running now: `node ` for the bundled + * package, or the executable itself for the `@pnpm/exe` single-file build, + * whose `process.argv[1]` is the binary rather than a script. + */ +function resolveSelfCommand (): string[] { + if (detectIfCurrentPkgIsExecutable()) return [process.execPath] + const entry = process.argv[1] + if (!entry) return [process.execPath] + return [process.execPath, entry] +} + +async function probeLinkCapabilities (dir: string): Promise<{ reflink: boolean, hardlink: boolean, symlink: boolean }> { + const source = path.join(dir, 'source') + await fs.promises.writeFile(source, 'pnpm-doctor') + return { + reflink: canReflink(source, path.join(dir, 'reflink')), + hardlink: await canLink(() => fs.promises.link(source, path.join(dir, 'hardlink'))), + symlink: await canLink(() => fs.promises.symlink(source, path.join(dir, 'symlink'))), + } +} + +/** + * Clone exactly the way the importer does, so the answer describes the reflink + * pnpm would really attempt: Node cannot clone on macOS or Windows — its + * `COPYFILE_FICLONE_FORCE` fails there with ENOSYS even on a filesystem that + * supports cloning — so those platforms go through `@reflink/reflink`, as + * `@pnpm/fs.indexed-pkg-importer` does. + */ +function canReflink (source: string, dest: string): boolean { + try { + if (process.platform === 'darwin' || process.platform === 'win32') { + // eslint-disable-next-line + const { reflinkFileSync } = require('@reflink/reflink') as typeof import('@reflink/reflink') + reflinkFileSync(source, dest) + } else { + fs.copyFileSync(source, dest, fs.constants.COPYFILE_FICLONE_FORCE) + } + return true + } catch { + return false + } +} + +async function canLink (link: () => Promise): Promise { + try { + await link() + return true + } catch { + return false + } +} + +function readPathEnv (env: NodeJS.ProcessEnv): string | undefined { + if (process.platform !== 'win32') return env.PATH + const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH') + return key != null ? env[key] : undefined +} + +async function dirIsInPath (dir: string, pathEnv: string): Promise { + const dirs = pathEnv.split(path.delimiter) + if (dirs.some((entry) => areSameDir(dir, entry))) return true + try { + const real = await fs.promises.realpath(dir) + return dirs.some((entry) => areSameDir(real, entry)) + } catch { + return false + } +} + +const areSameDir = (a: string, b: string): boolean => a !== '' && b !== '' && path.relative(a, b) === '' + +function canWriteToDir (dir: string): boolean { + const probe = path.join(dir, `.pnpm-doctor-write-${process.pid}`) + try { + fs.writeFileSync(probe, '') + fs.rmSync(probe, { force: true }) + return true + } catch { + return false + } +} + +function lastLine (text: string): string { + const lines = text.split('\n').filter((line) => line.trim() !== '') + return lines[lines.length - 1] ?? text +} diff --git a/pnpm11/pnpm/src/cmd/index.ts b/pnpm11/pnpm/src/cmd/index.ts index eeff90f359..693e687f8d 100644 --- a/pnpm11/pnpm/src/cmd/index.ts +++ b/pnpm11/pnpm/src/cmd/index.ts @@ -31,6 +31,7 @@ import type { PnpmOptions } from '../types.js' import * as bin from './bin.js' import * as clean from './clean.js' import * as ci from './cleanInstall.js' +import * as doctor from './doctor.js' import { createHelp } from './help.js' import * as installTest from './installTest.js' import { NOT_IMPLEMENTED_COMMAND_SET, notImplementedCommandDefinitions } from './notImplemented.js' @@ -141,6 +142,7 @@ const commands: CommandDefinition[] = [ deprecate, deploy, distTag, + doctor, owner, dlx, docs,