diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index 99098c5a46..6b1502417a 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -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::(command_state)) + } + ReporterType::Ndjson => Box::pin(args.run::(command_state)), + ReporterType::Silent => Box::pin(args.run::(command_state)), + } + } CliCommand::CatIndex(args) => Box::pin(async move { args.run(dir_ref, || config().map(|m| &*m)).await?; Ok(()) diff --git a/pacquet/crates/cli/src/cli_args/import.rs b/pacquet/crates/cli/src/cli_args/import.rs new file mode 100644 index 0000000000..7785336ecf --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/import.rs @@ -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, +} + +impl ImportArgs { + pub async fn run(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::( + &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::() + .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) + } + } + } +} diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 39ae94b5e5..6c477cc842 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -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, - supported_architectures: Option, - node_linker: NodeLinker, - skip_runtimes: bool, +pub(crate) struct PnprLink<'a> { + pub(crate) dependency_groups: Vec, + pub(crate) supported_architectures: + Option, + 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( +pub(crate) async fn install_via_pnpr( state: &State, pnpr_server: &str, link: PnprLink<'_>, diff --git a/pacquet/crates/cli/tests/cache.rs b/pacquet/crates/cli/tests/cache.rs index 9238106cf8..1925b4c8a9 100644 --- a/pacquet/crates/cli/tests/cache.rs +++ b/pacquet/crates/cli/tests/cache.rs @@ -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(®istry_name).join("is-positive.jsonl").exists()); assert!(cache_dir.join(®istry_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(®istry_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(®istry_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(®istry_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)); +} diff --git a/pacquet/crates/cli/tests/import.rs b/pacquet/crates/cli/tests/import.rs new file mode 100644 index 0000000000..d06b4bbff7 --- /dev/null +++ b/pacquet/crates/cli/tests/import.rs @@ -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)); +} diff --git a/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs b/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs index ab028198ff..33f2f8336d 100644 --- a/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs +++ b/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs @@ -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"); diff --git a/pacquet/crates/cli/tests/pnpr_install.rs b/pacquet/crates/cli/tests/pnpr_install.rs index 387e6f05ed..9686df5105 100644 --- a/pacquet/crates/cli/tests/pnpr_install.rs +++ b/pacquet/crates/cli/tests/pnpr_install.rs @@ -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)); +}