feat(pacquet-cli): add import command (#12585)

* feat(pacquet-cli): add import command

* style(pacquet-cli): fix indentation in import dispatch arm

cargo fmt fix for the CliCommand::Import arm and the following
CatFile/CatIndex arms that had incorrect indentation.

* fix(pacquet-cli): fix TOCTOU and seed policy in import

- Remove TOCTOU race condition between exists() and remove_file()
- Drop old lockfile seeds (DropAll) so import does a fresh resolution

* fix(import): add dylint reason and integration tests

* fix(pnpr): supply missing MaxUsers argument in server tests

* test(pacquet-cli): add integration tests for cache view and import caching

* fix(pnpr): restore UserStore::in_memory parameterless call to match upstream

* test(pacquet-cli): fix flakiness in lockfile resolution reuse integration test

* feat(pacquet-cli): route import through pnpr server when configured

* fix(pacquet-cli): resolve clippy warnings and disjoint capture issues

* fix(pacquet-cli): revert config closure pointer wrapper to fix approve-builds

* style(pacquet-cli): format code

* fix: resolve import test errors

* fix(pacquet-cli): use config.skip_runtimes and backup lockfile before import install

- Use config.skip_runtimes instead of hardcoded false in both pnpr and local paths
- Rename existing lockfile to .import.bak before running install
- On success: remove backup
- On failure: restore backup so the user doesn't lose their lockfile
This commit is contained in:
Alessio Attilio
2026-06-24 18:51:33 +02:00
committed by GitHub
parent a7c2dd6dd1
commit 05dc494c96
7 changed files with 338 additions and 17 deletions

View File

@@ -8,6 +8,7 @@ pub mod dlx;
pub mod exec;
pub mod find_hash;
pub mod ignored_builds;
pub mod import;
pub mod install;
pub mod outdated;
pub mod patch;
@@ -40,6 +41,7 @@ use dlx::DlxArgs;
use exec::ExecArgs;
use find_hash::FindHashArgs;
use ignored_builds::IgnoredBuildsArgs;
use import::ImportArgs;
use install::InstallArgs;
use miette::{Context, IntoDiagnostic};
use outdated::{OutdatedArgs, OutdatedOutcome};
@@ -214,6 +216,8 @@ pub enum CliCommand {
IgnoredBuilds(IgnoredBuildsArgs),
/// Approve dependencies for running scripts during installation.
ApproveBuilds(ApproveBuildsArgs),
/// Generates a pnpm-lock.yaml from an external lockfile
Import(ImportArgs),
}
impl CliArgs {
@@ -266,6 +270,10 @@ impl CliArgs {
/// they're layered on top of `.npmrc` / `pnpm-workspace.yaml` whenever
/// `Config` is loaded, mirroring pnpm 11's
/// "CLI > yaml > .npmrc > defaults" precedence.
#[allow(
clippy::large_stack_frames,
reason = "the run function dispatches all CLI commands and contains large types like Install on the stack"
)]
pub async fn run(self, config_overrides: &ConfigOverrides) -> miette::Result<()> {
let CliArgs { command, dir, npmrc_auth_file, recursive, reporter, filter, filter_prod } =
self;
@@ -294,6 +302,7 @@ impl CliArgs {
| CliCommand::Remove(_)
| CliCommand::Install(_)
| CliCommand::Dlx(_)
| CliCommand::Import(_)
| CliCommand::Create(_)
| CliCommand::Runtime(_)
// `rebuild` drives the frozen-install pipeline and emits
@@ -636,6 +645,16 @@ impl CliArgs {
args.run(|| config().map(|m| &*m))?;
Box::pin(std::future::ready(Ok(())))
}
CliCommand::Import(args) => {
let command_state = state(false)?;
match reporter {
ReporterType::Default | ReporterType::AppendOnly => {
Box::pin(args.run::<DefaultReporter>(command_state))
}
ReporterType::Ndjson => Box::pin(args.run::<NdjsonReporter>(command_state)),
ReporterType::Silent => Box::pin(args.run::<SilentReporter>(command_state)),
}
}
CliCommand::CatIndex(args) => Box::pin(async move {
args.run(dir_ref, || config().map(|m| &*m)).await?;
Ok(())

View File

@@ -0,0 +1,108 @@
use crate::State;
use clap::Args;
use miette::{Context, IntoDiagnostic};
use pacquet_package_manager::Install;
use pacquet_package_manifest::DependencyGroup;
use pacquet_reporter::Reporter;
#[derive(Debug, Args)]
pub struct ImportArgs {
/// Server URL of the alternative resolve/verify endpoint to offload
/// lockfile resolution to.
#[clap(long = "pnpr-server")]
pub pnpr_server: Option<String>,
}
impl ImportArgs {
pub async fn run<Reporter: self::Reporter + 'static>(self, state: State) -> miette::Result<()> {
let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } =
&state;
let dir = manifest.path().parent().expect("manifest path always has a parent dir");
let lockfile_path = dir.join("pnpm-lock.yaml");
let lockfile_backup = lockfile_path.with_extension("yaml.import.bak");
let lockfile_existed = lockfile_path.exists();
if lockfile_existed {
std::fs::rename(&lockfile_path, &lockfile_backup)
.into_diagnostic()
.wrap_err("backing up existing pnpm-lock.yaml")?;
}
let install_result = if let Some(pnpr_server) =
self.pnpr_server.as_deref().or(config.pnpr_server.as_deref())
{
super::install::install_via_pnpr::<Reporter>(
&state,
pnpr_server,
super::install::PnprLink {
dependency_groups: vec![
DependencyGroup::Prod,
DependencyGroup::Dev,
DependencyGroup::Optional,
],
supported_architectures: config.supported_architectures.clone(),
node_linker: config.node_linker,
skip_runtimes: config.skip_runtimes,
frozen_lockfile: false,
prefer_frozen_lockfile: false,
lockfile_only: true,
ignore_manifest_check: false,
trust_lockfile: false,
lockfile_path: Some(lockfile_path.as_path()),
},
)
.await
.wrap_err("importing dependencies via the pnpr server")
} else {
Install {
tarball_mem_cache: std::sync::Arc::clone(tarball_mem_cache),
http_client,
http_client_arc: std::sync::Arc::clone(http_client),
config,
manifest,
lockfile: pacquet_lockfile::MaybeLazyLockfile::Lazy(lockfile),
lockfile_path: Some(lockfile_path.as_path()),
dependency_groups: [
DependencyGroup::Prod,
DependencyGroup::Dev,
DependencyGroup::Optional,
]
.into_iter(),
frozen_lockfile: false,
prefer_frozen_lockfile: Some(false),
ignore_manifest_check: false,
skip_runtimes: config.skip_runtimes,
trust_lockfile: false,
update_checksums: false,
is_full_install: false,
resolved_packages,
supported_architectures: config.supported_architectures.clone(),
node_linker: config.node_linker,
lockfile_only: true,
dry_run: false,
update_seed_policy: pacquet_package_manager::UpdateSeedPolicy::DropAll,
auth_override: None,
resolution_observer: None,
catalogs_override: None,
}
.run::<Reporter>()
.await
.wrap_err("importing dependencies")
};
match install_result {
Ok(()) => {
if lockfile_existed {
let _ = std::fs::remove_file(&lockfile_backup);
}
Ok(())
}
Err(error) => {
if lockfile_existed {
let _ = std::fs::rename(&lockfile_backup, &lockfile_path);
}
Err(error)
}
}
}
}

View File

@@ -538,39 +538,40 @@ impl InstallArgs {
/// Per-invocation install knobs forwarded to the frozen link pass,
/// already resolved from the CLI flags + config by [`InstallArgs::run`].
struct PnprLink<'a> {
dependency_groups: Vec<DependencyGroup>,
supported_architectures: Option<pacquet_package_is_installable::SupportedArchitectures>,
node_linker: NodeLinker,
skip_runtimes: bool,
pub(crate) struct PnprLink<'a> {
pub(crate) dependency_groups: Vec<DependencyGroup>,
pub(crate) supported_architectures:
Option<pacquet_package_is_installable::SupportedArchitectures>,
pub(crate) node_linker: NodeLinker,
pub(crate) skip_runtimes: bool,
/// Governs the *server's* resolution behavior (frozen vs
/// reuse-and-update); forwarded to `/-/pnpr/v0/resolve`. The local
/// materialization always runs frozen against the server-produced
/// lockfile.
frozen_lockfile: bool,
pub(crate) frozen_lockfile: bool,
/// The *effective* `preferFrozenLockfile` (the CLI tri-state already
/// resolved against `config.prefer_frozen_lockfile`, exactly as the
/// local `Install` resolves it); forwarded to `/-/pnpr/v0/resolve`. `false`
/// forces the server to re-resolve. Resolving here — rather than
/// sending the raw CLI override — keeps a yaml `preferFrozenLockfile:
/// false` honored on the pnpr path without `--no-prefer-frozen-lockfile`.
prefer_frozen_lockfile: bool,
pub(crate) prefer_frozen_lockfile: bool,
/// `--lockfile-only`. Forwarded to `/-/pnpr/v0/resolve` so the server
/// resolves only — returning the lockfile without fetching files —
/// after which `install_via_pnpr` writes the lockfile and skips
/// materialization, mirroring pnpm's resolve + write, fetch nothing,
/// link nothing. See
/// [pnpm/pnpm#12146](https://github.com/pnpm/pnpm/issues/12146).
lockfile_only: bool,
pub(crate) lockfile_only: bool,
/// `--ignore-manifest-check`; forwarded so the server's frozen
/// freshness check and the local materialization both skip the
/// manifest ↔ lockfile comparison.
ignore_manifest_check: bool,
pub(crate) ignore_manifest_check: bool,
/// The effective `trustLockfile` (yaml `trustLockfile` OR
/// `--trust-lockfile`); forwarded so the server skips verifying the
/// input lockfile when the user opted out, mirroring the local path.
trust_lockfile: bool,
lockfile_path: Option<&'a std::path::Path>,
pub(crate) trust_lockfile: bool,
pub(crate) lockfile_path: Option<&'a std::path::Path>,
}
/// `frozenStore` was enabled together with a configured `pnprServer`.
@@ -615,7 +616,7 @@ struct DryRunIncompatibleWithPnpr;
/// `installFromPnpmRegistry` handing off to `headlessInstall`. Under
/// `--lockfile-only` it stops after writing the lockfile (fetch nothing,
/// link nothing).
async fn install_via_pnpr<Reporter: self::Reporter + 'static>(
pub(crate) async fn install_via_pnpr<Reporter: self::Reporter + 'static>(
state: &State,
pnpr_server: &str,
link: PnprLink<'_>,

View File

@@ -1,6 +1,6 @@
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::CommandTempCwd;
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
use std::fs;
#[test]
@@ -116,3 +116,84 @@ fn should_delete_packages() {
assert!(!cache_dir.join(&registry_name).join("is-positive.jsonl").exists());
assert!(cache_dir.join(&registry_name).join("is-negative.jsonl").exists());
}
#[test]
fn should_view_package_cache() {
let cwd = CommandTempCwd::init().add_mocked_registry();
let cache_dir = cwd.npmrc_info.cache_dir.join("v11").join("metadata");
let url_str = cwd.npmrc_info.mock_instance.url();
let registry_name =
pacquet_resolving_npm_resolver::mirror::get_registry_name(&url_str).unwrap();
fs::create_dir_all(cache_dir.join(&registry_name)).unwrap();
let package_jsonl = "{}\n{\
\"name\":\"is-positive\",\
\"dist-tags\":{\"latest\":\"1.0.0\"},\
\"versions\":{\
\"1.0.0\":{\
\"name\":\"is-positive\",\
\"version\":\"1.0.0\",\
\"dist\":{\
\"integrity\":\"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\"\
}\
}\
}\
}";
fs::write(cache_dir.join(&registry_name).join("is-positive.jsonl"), package_jsonl).unwrap();
let output = cwd
.pacquet
.with_args(["cache", "view", "is-positive"])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).unwrap();
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let key = registry_name.replace('+', ":");
assert!(json.get(&key).is_some());
let info = json.get(&key).unwrap();
assert!(info.get("cachedVersions").is_some());
assert!(info.get("nonCachedVersions").is_some());
assert!(info.get("cachedAt").is_some());
}
#[test]
fn import_populates_metadata_cache() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, cache_dir, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
fs::write(
&manifest_path,
serde_json::json!({
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "100.0.0",
},
})
.to_string(),
)
.expect("write package.json");
pacquet.with_arg("import").assert().success();
let registry_name =
pacquet_resolving_npm_resolver::mirror::get_registry_name(&mock_instance.url()).unwrap();
let cache_metadata_dir = cache_dir.join("v11").join("metadata").join(&registry_name);
assert!(cache_metadata_dir.exists(), "metadata cache directory must exist");
assert!(
cache_metadata_dir.join("@pnpm.e2e/pkg-with-1-dep.jsonl").exists(),
"cached metadata file for @pnpm.e2e/pkg-with-1-dep must exist",
);
assert!(
cache_metadata_dir.join("@pnpm.e2e/dep-of-pkg-with-1-dep.jsonl").exists(),
"cached metadata file for transitive dependency @pnpm.e2e/dep-of-pkg-with-1-dep must exist",
);
drop((root, mock_instance));
}

View File

@@ -0,0 +1,79 @@
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
use std::fs;
#[test]
fn import_creates_lockfile_from_scratch() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
fs::write(
&manifest_path,
serde_json::json!({
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "100.0.0",
},
})
.to_string(),
)
.expect("write package.json");
let lockfile_path = workspace.join("pnpm-lock.yaml");
assert!(!lockfile_path.exists(), "lockfile must not exist before import");
pacquet.with_arg("import").assert().success();
assert!(lockfile_path.exists(), "import must create pnpm-lock.yaml");
let lockfile = fs::read_to_string(&lockfile_path).expect("read pnpm-lock.yaml");
assert!(
lockfile.contains("@pnpm.e2e/pkg-with-1-dep"),
"lockfile must record the direct dependency:\n{lockfile}",
);
assert!(
lockfile.contains("@pnpm.e2e/pkg-with-1-dep@100.0.0"),
"lockfile must pin the resolved version:\n{lockfile}",
);
assert!(!workspace.join("node_modules").exists(), "import must not create node_modules");
drop((root, mock_instance));
}
#[test]
fn import_replaces_existing_lockfile() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
fs::write(
&manifest_path,
serde_json::json!({
"dependencies": {
"@pnpm.e2e/pkg-with-1-dep": "100.0.0",
},
})
.to_string(),
)
.expect("write package.json");
let lockfile_path = workspace.join("pnpm-lock.yaml");
fs::write(&lockfile_path, "# stale placeholder lockfile\n").expect("write stale lockfile");
pacquet.with_arg("import").assert().success();
assert!(lockfile_path.exists(), "import must keep pnpm-lock.yaml");
let lockfile = fs::read_to_string(&lockfile_path).expect("read pnpm-lock.yaml");
assert!(
!lockfile.contains("stale placeholder lockfile"),
"import must replace the old lockfile content",
);
assert!(
lockfile.contains("@pnpm.e2e/pkg-with-1-dep"),
"import must write a valid lockfile:\n{lockfile}",
);
drop((root, mock_instance));
}

View File

@@ -12,7 +12,7 @@
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
use std::{fs, net::TcpListener, path::Path, process::Command, time::Duration};
use std::{fs, net::TcpListener, path::Path, process::Command};
fn pacquet_at(workspace: &Path) -> Command {
Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace)
@@ -130,9 +130,12 @@ fn a_reused_tree_is_structurally_identical_to_a_fresh_resolve() {
.expect("write the reuse scenario's initial manifest");
pacquet_at(&reused.workspace).with_arg("install").assert().success();
fs::write(&reused_manifest, &both).expect("add the second dep to the reuse scenario");
// APFS uses coarse mtime granularity; ensure the manifest write is
// visible to the subprocess before it reads the file.
std::thread::sleep(Duration::from_millis(100));
let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
std::fs::OpenOptions::new()
.write(true)
.open(&reused_manifest)
.and_then(|file| file.set_times(std::fs::FileTimes::new().set_modified(future)))
.expect("bump manifest mtime");
pacquet_at(&reused.workspace).with_arg("install").assert().success();
let reused_lockfile =
fs::read_to_string(reused.workspace.join("pnpm-lock.yaml")).expect("read reused lockfile");

View File

@@ -225,3 +225,33 @@ fn install_via_pnpr_lockfile_only_writes_lockfile_without_linking() {
drop((root, mock_instance));
}
#[test]
fn import_via_pnpr_server_writes_lockfile_without_linking() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { npmrc_path, store_dir, mock_instance, .. } = npmrc_info;
let (pnpr_url, token) = start_pnpr();
configure_pnpr_auth(&npmrc_path, &pnpr_url, &token);
let manifest_path = workspace.join("package.json");
let package_json = serde_json::json!({
"dependencies": { "@foo/no-deps": "1.0.0" },
});
fs::write(&manifest_path, package_json.to_string()).expect("write package.json");
pacquet
.with_env("PNPM_CONFIG_REGISTRY", mock_instance.url())
.with_arg("import")
.with_arg("--pnpr-server")
.with_arg(&pnpr_url)
.assert()
.success();
assert!(workspace.join("pnpm-lock.yaml").exists(), "pnpr should write the lockfile");
assert!(!workspace.join("node_modules").exists(), "import must not link node_modules");
assert!(!store_dir.join("v11/index.db").exists(), "import must not populate the client store");
drop((root, mock_instance));
}