mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-19 04:02:32 -04:00
ci(release): gate releases on a publishable manifest, a real upgrade, and pnpm doctor (#13072)
Two published v11 releases were broken in ways nothing in the pipeline checked, and both only surfaced once users upgraded onto them. 11.12.0 was packed by a pnpm that ignores the .pnpmfile.cjs beforePacking hook, so the bundled dependency fields survived into the published manifest. Resolving node-gyp then pulled a peer-suffixed snapshot into the env lockfile, and every upgrade onto 11.12.0 died in buildLockfileFromEnvLockfile (pnpm/pnpm#12955, pnpm/pnpm#12959). 11.12.0 and 11.13.0 also shipped `@pnpm/exe` platform packages with no native binary; setup.js exits 0 when the binary is missing, so the placeholder bin survived and only a real invocation caught it. Assert on the packed tarball that the published pnpm manifest declares no dependency fields, rather than trusting either stripper, since npm publishes are immutable. Then, in the Tag workflow, upgrade from the release line's current version onto the new one for both the `pnpm` and `@pnpm/exe` wrappers and run the resulting binary. The release workflow has already published under next-<major> at that point, so this reads the real registry artifact, and it runs before the dist-tags move — the last gate before a version reaches everyone. Verified against the registry: the assert rejects pnpm@11.12.0 and accepts 11.11.0/11.13.1; the upgrade gate passes 11.13.0 -> 11.13.1 and catches both `pnpm@11.12.0` and `@pnpm/exe@11.12.0`/11.13.0. Related to pnpm/pnpm#12959.
This commit is contained in:
8
.changeset/pnpm-doctor-command.md
Normal file
8
.changeset/pnpm-doctor-command.md
Normal file
@@ -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.
|
||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
187
.github/workflows/update-latest.yml
vendored
187
.github/workflows/update-latest.yml
vendored
@@ -10,19 +10,38 @@ on:
|
||||
description: Tag (use latest-<major> 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-<major> 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-<major> 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-<major> 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:
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3970,6 +3970,7 @@ dependencies = [
|
||||
"pnpr",
|
||||
"pretty_assertions",
|
||||
"rayon",
|
||||
"reflink-copy",
|
||||
"reqwest 0.13.4",
|
||||
"rmp-serde",
|
||||
"serde",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -338,6 +338,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result<CommandFut
|
||||
CliCommand::Deprecate(args) => 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),
|
||||
|
||||
@@ -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<Comma
|
||||
}))
|
||||
}
|
||||
|
||||
// `doctor` reports on the installation and its environment, so it needs config
|
||||
// resolved but no lockfile or install pipeline. It returns the rendered report
|
||||
// rather than printing it, mirroring pnpm's handler → CLI print split, and the
|
||||
// exit lives here because a failing check must fail the command — that is what
|
||||
// lets the release pipeline gate a promotion on it.
|
||||
pub(super) fn doctor<'a>(ctx: &RunCtx<'a>, args: DoctorArgs) -> miette::Result<CommandFuture<'a>> {
|
||||
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
|
||||
|
||||
442
pnpm/crates/cli/src/cli_args/doctor.rs
Normal file
442
pnpm/crates/cli/src/cli_args/doctor.rs
Normal file
@@ -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<String>,
|
||||
/// A concrete next step, shown when the check does not pass.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fix: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u128>,
|
||||
}
|
||||
|
||||
impl CheckResult {
|
||||
fn pass(title: &str, detail: impl Into<String>) -> 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<String>, fix: impl Into<String>) -> 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<String>, fix: impl Into<String>) -> 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<CheckResult>,
|
||||
}
|
||||
|
||||
/// 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<DoctorResult> {
|
||||
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<String> {
|
||||
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<PathBuf> =
|
||||
[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<PathBuf> = 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<String> = 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;
|
||||
92
pnpm/crates/cli/src/cli_args/doctor/tests.rs
Normal file
92
pnpm/crates/cli/src/cli_args/doctor/tests.rs
Normal file
@@ -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<CheckResult>) -> 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(""), "");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
435
pnpm11/pnpm/src/cmd/doctor.ts
Normal file
435
pnpm11/pnpm/src/cmd/doctor.ts
Normal file
@@ -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<string, unknown> {
|
||||
return pick(['offline'], allTypes)
|
||||
}
|
||||
|
||||
export function cliOptionsTypes (): Record<string, unknown> {
|
||||
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<string, string>
|
||||
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<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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<DoctorCommandOptions, 'benchmark'>
|
||||
): Promise<CheckResult> {
|
||||
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 <entry>` 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<void>): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user