Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions crates/cli/src/cli_args/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ impl AddDependencyOptions {

#[derive(Debug, Args)]
pub struct AddArgs {
/// Name of the package
pub package_name: String, // TODO: 1. support version range, 2. multiple arguments, 3. name this `packages`
/// Names of the packages to add.
#[clap(required = true)]
pub packages: Vec<String>,
/// --save-prod, --save-dev, --save-optional, --save-peer
#[clap(flatten)]
pub dependency_options: AddDependencyOptions,
Expand All @@ -88,20 +89,21 @@ impl AddArgs {
let State { tarball_mem_cache, http_client, config, manifest, lockfile, resolved_packages } =
&mut state;

let packages: Vec<&str> = self.packages.iter().map(String::as_str).collect();
Add {
tarball_mem_cache,
http_client,
config,
manifest,
lockfile: lockfile.as_ref(),
list_dependency_groups: || self.dependency_options.dependency_groups(),
package_name: &self.package_name,
packages: &packages,
save_exact: self.save_exact,
resolved_packages,
}
.run()
.await
.wrap_err("adding a new package")
.wrap_err("adding packages")
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/package-manager/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ node-semver = { workspace = true }
pipe-trait = { workspace = true }
rayon = { workspace = true }
reflink-copy = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
miette = { workspace = true }
walkdir = { workspace = true }

[dev-dependencies]
pacquet-registry-mock = { workspace = true }
Expand All @@ -42,4 +44,3 @@ pretty_assertions = { workspace = true }
serde-saphyr = { workspace = true }
ssri = { workspace = true }
tempfile = { workspace = true }
walkdir = { workspace = true }
126 changes: 109 additions & 17 deletions crates/package-manager/src/add.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use crate::{Install, InstallError, ResolvedPackages};
use derive_more::{Display, Error};
use miette::Diagnostic;
use node_semver::{Range, Version};
use pacquet_lockfile::Lockfile;
use pacquet_network::ThrottledClient;
use pacquet_npmrc::Npmrc;
use pacquet_package_manifest::PackageManifestError;
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
use pacquet_registry::{PackageTag, PackageVersion};
use pacquet_registry::{PackageTag, PackageVersion, RegistryError};
use pacquet_tarball::MemCache;

/// This subroutine does everything `pacquet add` is supposed to do.
Expand All @@ -23,13 +24,15 @@ where
pub manifest: &'a mut PackageManifest,
pub lockfile: Option<&'a Lockfile>,
pub list_dependency_groups: ListDependencyGroups, // must be a function because it is called multiple times
pub package_name: &'a str, // TODO: 1. support version range, 2. multiple arguments, 3. name this `packages`
pub save_exact: bool, // TODO: add `save-exact` to `.npmrc`, merge configs, and remove this
pub packages: &'a [&'a str],
pub save_exact: bool, // TODO: add `save-exact` to `.npmrc`, merge configs, and remove this
}

/// Error type of [`Add`].
#[derive(Debug, Display, Error, Diagnostic)]
pub enum AddError {
#[display("Failed to fetch version for package: {_0}")]
FetchVersion(#[error(source)] RegistryError),
Comment on lines +34 to +35
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FetchVersion's display text says "for package" but it only formats the underlying RegistryError, so the message doesn't include which package/specifier failed and can be misleading. Consider storing the package name (and maybe the requested specifier/tag) on this error variant and include it in the display string (while keeping RegistryError as the source).

Copilot uses AI. Check for mistakes.
#[display("Failed to add package to manifest: {_0}")]
AddDependencyToManifest(#[error(source)] PackageManifestError),
#[display("Failed save the manifest file: {_0}")]
Expand All @@ -38,6 +41,20 @@ pub enum AddError {
Install(#[error(source)] InstallError),
}

/// Split a package argument into name and version specifier.
///
/// Handles scoped packages: `@scope/name@1.0.0` splits into `("@scope/name", "1.0.0")`.
fn parse_pkg_arg(arg: &str) -> (&str, &str) {
let start = usize::from(arg.starts_with('@'));
match arg[start..].find('@') {
Some(pos) => {
let split = start + pos;
(&arg[..split], &arg[split + 1..])
}
None => (arg, ""),
}
}

impl<'a, ListDependencyGroups, DependencyGroupList>
Add<'a, ListDependencyGroups, DependencyGroupList>
where
Expand All @@ -52,25 +69,60 @@ where
manifest,
lockfile,
list_dependency_groups,
package_name,
packages,
save_exact,
resolved_packages,
} = self;

let latest_version = PackageVersion::fetch_from_registry(
package_name,
PackageTag::Latest, // TODO: add support for specifying tags
http_client,
&config.registry,
)
.await
.expect("resolve latest tag"); // TODO: properly propagate this error
for &pkg in packages {
let (name, specifier) = parse_pkg_arg(pkg);

let version_range = latest_version.serialize(save_exact);
for dependency_group in list_dependency_groups() {
manifest
.add_dependency(package_name, &version_range, dependency_group)
.map_err(AddError::AddDependencyToManifest)?;
// Resolve the version specifier to a range string to save in package.json.
// For tags (no specifier or dist-tag), we fetch the resolved version first so
// we can save a pinned semver range rather than a mutable tag name.
let version_to_save = if specifier.is_empty() || specifier == "latest" {
let version = PackageVersion::fetch_from_registry(
name,
PackageTag::Latest,
http_client,
&config.registry,
)
.await
.map_err(AddError::FetchVersion)?;
version.serialize(save_exact)
} else if let Ok(v) = specifier.parse::<Version>() {
// Exact semver version: fetch to validate, then save with ^ unless --save-exact.
PackageVersion::fetch_from_registry(
name,
PackageTag::Version(v),
http_client,
&config.registry,
)
.await
.map_err(AddError::FetchVersion)?;
if save_exact { specifier.to_owned() } else { format!("^{specifier}") }
} else if specifier.parse::<Range>().is_ok() {
// Semver range (e.g. `^18`, `~1.0.0`, `>=1 <2`): save as-is and let
// the install step resolve the best matching version.
specifier.to_owned()
} else {
// Named dist-tag (e.g. `next`, `beta`): resolve to a concrete version.
let version = PackageVersion::fetch_from_registry(
name,
PackageTag::Tag(specifier.to_owned()),
http_client,
&config.registry,
)
Comment on lines +110 to +115
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code passes the parsed name directly into PackageVersion::fetch_from_registry, which currently constructs URLs as {registry}{name}/{tag} (see crates/registry/src/package_version.rs:33). For scoped packages (@scope/pkg), npm registries typically require URL-encoding the / as %2F (e.g. @scope%2Fpkg); without encoding, scoped installs can fail (404) depending on registry/proxy routing. Consider encoding the package name when building registry URLs (in the registry crate) so scoped packages work end-to-end.

Copilot uses AI. Check for mistakes.
.await
.map_err(AddError::FetchVersion)?;
version.serialize(save_exact)
};

for dependency_group in list_dependency_groups() {
manifest
.add_dependency(name, &version_to_save, dependency_group)
.map_err(AddError::AddDependencyToManifest)?;
}
}

Install {
Expand All @@ -92,3 +144,43 @@ where
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_pkg_arg_no_specifier() {
assert_eq!(parse_pkg_arg("react"), ("react", ""));
}

#[test]
fn parse_pkg_arg_with_version() {
assert_eq!(parse_pkg_arg("react@18.2.0"), ("react", "18.2.0"));
}

#[test]
fn parse_pkg_arg_with_range() {
assert_eq!(parse_pkg_arg("react@^18"), ("react", "^18"));
}

#[test]
fn parse_pkg_arg_with_tag() {
assert_eq!(parse_pkg_arg("react@next"), ("react", "next"));
}

#[test]
fn parse_pkg_arg_scoped_no_specifier() {
assert_eq!(parse_pkg_arg("@scope/pkg"), ("@scope/pkg", ""));
}

#[test]
fn parse_pkg_arg_scoped_with_version() {
assert_eq!(parse_pkg_arg("@scope/pkg@1.0.0"), ("@scope/pkg", "1.0.0"));
}

#[test]
fn parse_pkg_arg_scoped_with_range() {
assert_eq!(parse_pkg_arg("@scope/pkg@^1.0.0"), ("@scope/pkg", "^1.0.0"));
}
}
Loading
Loading