use super::catalogs::{CatalogRewrite, CatalogUpsert, decide_add_rewrite, range_compatible};
use super::install;
use clap::Args;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Args)]
pub struct AddArgs {
pub packages: Vec<String>,
#[arg(short = 'D', long)]
pub save_dev: bool,
#[arg(short = 'E', long)]
pub save_exact: bool,
#[arg(short = 'g', long)]
pub global: bool,
#[arg(short = 'O', long)]
pub save_optional: bool,
#[arg(
long = "allow-build",
value_name = "PKG",
conflicts_with = "no_save",
require_equals = true,
value_parser = parse_allow_build_value,
)]
pub allow_build: Vec<String>,
#[arg(long)]
pub allow_low_downloads: bool,
#[arg(long, hide = true)]
pub ignore_scripts: bool,
#[arg(long, conflicts_with = "global")]
pub no_save: bool,
#[arg(long, overrides_with = "save_workspace_protocol")]
pub no_save_workspace_protocol: bool,
#[arg(long, conflicts_with_all = ["save_catalog_name", "no_save"])]
pub save_catalog: bool,
#[arg(long, value_name = "NAME", conflicts_with = "no_save")]
pub save_catalog_name: Option<String>,
#[arg(long, conflicts_with = "save_optional")]
pub save_peer: bool,
#[arg(long, overrides_with = "no_save_workspace_protocol")]
pub save_workspace_protocol: bool,
#[arg(short = 'w', long, conflicts_with = "global")]
pub workspace: bool,
#[arg(short = 'W', long)]
pub ignore_workspace_root_check: bool,
#[command(flatten)]
pub lockfile: crate::cli_args::LockfileArgs,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
#[command(flatten)]
pub virtual_store: crate::cli_args::VirtualStoreArgs,
}
#[cfg_attr(test, derive(Debug))]
struct ParsedPkgSpec {
alias: Option<String>,
name: String,
jsr_name: Option<String>,
range: String,
has_explicit_range: bool,
git_spec: Option<String>,
local_spec: Option<String>,
linked_workspace_version: Option<String>,
}
fn parse_pkg_spec(spec: &str) -> miette::Result<ParsedPkgSpec> {
if aube_lockfile::parse_git_spec(spec).is_some() {
return parse_git_pkg_spec(spec, None);
}
if let Some((alias, rest)) = split_git_alias(spec)
&& aube_lockfile::parse_git_spec(rest).is_some()
{
return parse_git_pkg_spec(rest, Some(alias.to_string()));
}
if let Some((alias, rest)) = split_scoped_alias(spec) {
if aube_lockfile::parse_git_spec(rest).is_some() {
return parse_git_pkg_spec(rest, Some(alias.to_string()));
}
if is_local_path_spec(rest) {
return parse_local_pkg_spec(rest, Some(alias.to_string()));
}
}
if is_local_path_spec(spec) {
return parse_local_pkg_spec(spec, None);
}
if let Some((alias, rest)) = split_local_alias(spec)
&& is_local_path_spec(rest)
{
return parse_local_pkg_spec(rest, Some(alias.to_string()));
}
if let Some(jsr_idx) = spec.find("@jsr:") {
let before = &spec[..jsr_idx];
let after_jsr = &spec[jsr_idx + 5..]; let alias = if before.is_empty() {
None
} else {
Some(before.to_string())
};
return parse_jsr_name_range(after_jsr, alias);
}
if let Some(rest) = spec.strip_prefix("jsr:") {
return parse_jsr_name_range(rest, None);
}
if let Some(npm_idx) = spec.find("@npm:") {
let before = &spec[..npm_idx];
let after_npm = &spec[npm_idx + 5..];
let alias = if before.is_empty() {
None
} else {
Some(before.to_string())
};
return Ok(parse_name_range(after_npm, alias));
}
if let Some(rest) = spec.strip_prefix("npm:") {
return Ok(parse_name_range(rest, None));
}
Ok(parse_name_range(spec, None))
}
fn split_git_alias(spec: &str) -> Option<(&str, &str)> {
split_protocol_alias(spec)
}
fn is_local_path_spec(spec: &str) -> bool {
if spec.starts_with("link:") {
return true;
}
if spec.starts_with("file:") {
return aube_lockfile::parse_git_spec(spec).is_none();
}
looks_like_path(spec)
}
fn looks_like_path(s: &str) -> bool {
if s.starts_with("./")
|| s.starts_with("../")
|| s.starts_with('/')
|| s.starts_with("~/")
|| s.starts_with("~\\")
|| s.starts_with('\\')
|| s.starts_with(".\\")
|| s.starts_with("..\\")
{
return true;
}
let bytes = s.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\')
}
fn is_tarball_suffix(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
lower.ends_with(".tgz") || lower.ends_with(".tar.gz") || lower.ends_with(".tar")
}
fn expand_tilde(s: &str) -> miette::Result<String> {
let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) else {
return Ok(s.to_string());
};
let home = aube_util::env::home_dir().ok_or_else(|| {
miette!(
"cannot expand `~/` in `{s}` — $HOME is not set; \
pass an absolute path or set $HOME"
)
})?;
Ok(home.join(rest).to_string_lossy().into_owned())
}
fn prefix_bare_local_path(spec: &str) -> miette::Result<String> {
if spec.starts_with("file:") || spec.starts_with("link:") {
return Ok(spec.to_string());
}
let expanded = expand_tilde(spec)?;
let prefix = if is_tarball_suffix(&expanded) {
"file:"
} else {
"link:"
};
Ok(format!("{prefix}{expanded}"))
}
fn split_local_alias(spec: &str) -> Option<(&str, &str)> {
split_protocol_alias(spec)
}
fn split_protocol_alias(spec: &str) -> Option<(&str, &str)> {
let at = spec.find('@')?;
if at == 0 {
return None;
}
let alias = &spec[..at];
if alias.contains(':') {
return None;
}
Some((alias, &spec[at + 1..]))
}
fn split_scoped_alias(spec: &str) -> Option<(&str, &str)> {
if !spec.starts_with('@') {
return None;
}
let slash = spec.find('/')?;
let after_slash = &spec[slash + 1..];
let at_in_after = after_slash.find('@')?;
let alias_end = slash + 1 + at_in_after;
if alias_end == 0 {
return None;
}
Some((&spec[..alias_end], &spec[alias_end + 1..]))
}
fn parse_git_pkg_spec(verbatim: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let (clone_url, _committish, _subpath) = aube_lockfile::parse_git_spec(verbatim)
.ok_or_else(|| miette!("expected git spec, got `{verbatim}`"))?;
let name = match &alias {
Some(a) => a.clone(),
None => repo_name_from_clone_url(&clone_url).ok_or_else(|| {
miette!(
"could not derive a package name from git URL `{clone_url}`; \
pass an alias (e.g. `my-name@{verbatim}`)"
)
})?,
};
Ok(ParsedPkgSpec {
alias,
name,
jsr_name: None,
range: verbatim.to_string(),
has_explicit_range: true,
git_spec: Some(verbatim.to_string()),
local_spec: None,
linked_workspace_version: None,
})
}
fn parse_local_pkg_spec(input: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let verbatim = prefix_bare_local_path(input)?;
let path = verbatim
.strip_prefix("file:")
.or_else(|| verbatim.strip_prefix("link:"))
.ok_or_else(|| miette!("expected file:/link: spec, got `{verbatim}`"))?;
let name = match &alias {
Some(a) => a.clone(),
None => basename_from_local_path(path).ok_or_else(|| {
miette!(
"could not derive a package name from local spec `{verbatim}`; \
pass an alias (e.g. `my-name@{verbatim}`)"
)
})?,
};
Ok(ParsedPkgSpec {
alias,
name,
jsr_name: None,
range: verbatim.clone(),
has_explicit_range: true,
git_spec: None,
local_spec: Some(verbatim),
linked_workspace_version: None,
})
}
fn repo_name_from_clone_url(url: &str) -> Option<String> {
let body = url.split_once('?').map(|(b, _)| b).unwrap_or(url);
let body = body.split_once('#').map(|(b, _)| b).unwrap_or(body);
let last = body.rsplit('/').next()?;
let stripped = last.strip_suffix(".git").unwrap_or(last);
if stripped.is_empty() {
return None;
}
Some(stripped.to_string())
}
fn basename_from_local_path(path: &str) -> Option<String> {
let trimmed = path.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
return None;
}
let last = trimmed.rsplit(['/', '\\']).next()?;
let stripped = last
.strip_suffix(".tar.gz")
.or_else(|| last.strip_suffix(".tgz"))
.or_else(|| last.strip_suffix(".tar"))
.unwrap_or(last);
if stripped.is_empty() || stripped == "." || stripped == ".." {
return None;
}
Some(stripped.to_string())
}
fn parse_name_range(s: &str, alias: Option<String>) -> ParsedPkgSpec {
if s.starts_with('@') {
if let Some(slash_idx) = s.find('/') {
let after_slash = &s[slash_idx + 1..];
if let Some(at_idx) = after_slash.find('@') {
return ParsedPkgSpec {
alias,
name: s[..slash_idx + 1 + at_idx].to_string(),
jsr_name: None,
range: after_slash[at_idx + 1..].to_string(),
has_explicit_range: true,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
};
}
}
return ParsedPkgSpec {
alias,
name: s.to_string(),
jsr_name: None,
range: "latest".to_string(),
has_explicit_range: false,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
};
}
if let Some(at_idx) = s.find('@') {
ParsedPkgSpec {
alias,
name: s[..at_idx].to_string(),
jsr_name: None,
range: s[at_idx + 1..].to_string(),
has_explicit_range: true,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
}
} else {
ParsedPkgSpec {
alias,
name: s.to_string(),
jsr_name: None,
range: "latest".to_string(),
has_explicit_range: false,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
}
}
}
fn parse_jsr_name_range(s: &str, alias: Option<String>) -> miette::Result<ParsedPkgSpec> {
let inner = parse_name_range(s, None);
let jsr_name = inner.name.clone();
let npm_name = aube_registry::jsr::jsr_to_npm_name(&jsr_name).ok_or_else(|| {
miette!(
"invalid jsr: spec — expected `jsr:@scope/name[@range]`, got `jsr:{s}` \
(JSR packages must be scoped, e.g. `jsr:@std/collections`)"
)
})?;
let final_alias = alias.or_else(|| Some(jsr_name.clone()));
Ok(ParsedPkgSpec {
alias: final_alias,
name: npm_name,
jsr_name: Some(jsr_name),
range: inner.range,
has_explicit_range: inner.has_explicit_range,
git_spec: None,
local_spec: None,
linked_workspace_version: None,
})
}
pub async fn run(
args: AddArgs,
filter: aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
args.network.install_overrides();
args.lockfile.install_overrides();
args.virtual_store.install_overrides();
if !filter.is_empty() && !args.global && !args.workspace {
return run_filtered(args, &filter).await;
}
let AddArgs {
packages,
global,
save_dev,
save_optional,
save_exact,
save_peer,
save_workspace_protocol,
no_save_workspace_protocol,
workspace,
ignore_scripts: _,
no_save,
ignore_workspace_root_check,
save_catalog,
save_catalog_name,
allow_build,
allow_low_downloads,
lockfile,
network,
virtual_store,
} = args;
let save_catalog_target = save_catalog_name.or_else(|| {
if save_catalog {
Some("default".to_string())
} else {
None
}
});
let packages = &packages[..];
if packages.is_empty() {
return Err(miette!("no packages specified"));
}
if global {
return run_global(
packages,
allow_build,
allow_low_downloads,
lockfile,
network,
virtual_store,
)
.await;
}
if workspace {
let start = std::env::current_dir()
.into_diagnostic()
.wrap_err("failed to read current dir")?;
let root = super::find_workspace_root(&start).wrap_err("--workspace")?;
if root != start {
std::env::set_current_dir(&root)
.into_diagnostic()
.wrap_err_with(|| format!("failed to chdir into {}", root.display()))?;
}
crate::dirs::set_cwd(&root)?;
}
let initial_cwd = crate::dirs::cwd()?;
if crate::dirs::find_project_root(&initial_cwd).is_none() {
std::fs::write(initial_cwd.join("package.json"), "{}\n")
.into_diagnostic()
.wrap_err("failed to create package.json")?;
}
let cwd = crate::dirs::project_root()?;
if !ignore_workspace_root_check && !workspace {
let ws = aube_manifest::WorkspaceConfig::load(&cwd)
.into_diagnostic()
.wrap_err("failed to read workspace config")?;
let yaml_has_packages = !ws.packages.is_empty();
let pkg_json_has_workspaces =
aube_manifest::PackageJson::from_path(&cwd.join("package.json"))
.ok()
.and_then(|m| m.workspaces)
.is_some_and(|w| !w.patterns().is_empty());
if yaml_has_packages || pkg_json_has_workspaces {
return Err(miette!(
"refusing to add dependencies to the workspace root. \
If this is intentional, pass --ignore-workspace-root-check (-W)."
));
}
}
let _lock = super::take_project_lock(&cwd)?;
let manifest_path = cwd.join("package.json");
let lockfile_path = lockfile_path_for_project(&cwd);
let no_save_snapshot = if no_save {
let manifest_bytes = std::fs::read(&manifest_path)
.into_diagnostic()
.wrap_err("failed to snapshot package.json for --no-save")?;
let lockfile_bytes = match std::fs::read(&lockfile_path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e)
.into_diagnostic()
.wrap_err("failed to snapshot lockfile for --no-save");
}
};
Some(NoSaveSnapshot {
manifest_bytes,
lockfile_bytes,
})
} else {
None
};
if !allow_build.is_empty() {
apply_allow_build_flags(&cwd, &allow_build)?;
}
let registry_names = registry_bound_names_for_supply_chain(&cwd, packages);
let (advisory_check, low_download_threshold, allowed_unpopular) =
super::with_settings_ctx(&cwd, |ctx| {
let policy = if aube_settings::resolved::paranoid(ctx) {
aube_settings::resolved::AdvisoryCheck::Required
} else {
aube_settings::resolved::advisory_check(ctx)
};
(
policy,
aube_settings::resolved::low_download_threshold(ctx),
aube_settings::resolved::allowed_unpopular_packages(ctx).unwrap_or_default(),
)
});
super::add_supply_chain::run_gates(
®istry_names,
advisory_check,
low_download_threshold,
allow_low_downloads,
&allowed_unpopular,
)
.await?;
update_manifest_for_add(
&cwd,
packages,
AddManifestOptions {
save_dev,
save_exact,
save_optional,
save_peer,
save_catalog: save_catalog_target,
workspace_protocol_override: workspace_protocol_override_from_flags(
save_workspace_protocol,
no_save_workspace_protocol,
),
},
!no_save,
)
.await?;
let mut install_opts =
install::InstallOptions::with_mode(super::chained_frozen_mode(install::FrozenMode::Fix));
install_opts.osv_transitive_check = true;
let pipeline_result: miette::Result<()> = install::run(install_opts).await;
let restore_errors = if let Some(snapshot) = no_save_snapshot {
let mut errors: Vec<miette::Report> = Vec::new();
if let Err(e) = aube_util::fs_atomic::atomic_write(&manifest_path, &snapshot.manifest_bytes)
{
errors.push(
Result::<(), _>::Err(e)
.into_diagnostic()
.wrap_err("failed to restore original package.json after --no-save")
.unwrap_err(),
);
}
let lockfile_restore = match &snapshot.lockfile_bytes {
Some(bytes) => aube_util::fs_atomic::atomic_write(&lockfile_path, bytes),
None => match std::fs::remove_file(&lockfile_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
},
};
if let Err(e) = lockfile_restore {
errors.push(
Result::<(), _>::Err(e)
.into_diagnostic()
.wrap_err("failed to restore original lockfile after --no-save")
.unwrap_err(),
);
}
if errors.is_empty() {
eprintln!("Restored package.json and lockfile (--no-save)");
}
errors
} else {
Vec::new()
};
pipeline_result?;
if let Some(first) = restore_errors.into_iter().next() {
return Err(first);
}
Ok(())
}
struct NoSaveSnapshot {
manifest_bytes: Vec<u8>,
lockfile_bytes: Option<Vec<u8>>,
}
#[derive(Clone)]
struct AddManifestOptions {
save_dev: bool,
save_exact: bool,
save_optional: bool,
save_peer: bool,
save_catalog: Option<String>,
workspace_protocol_override: Option<bool>,
}
impl AddManifestOptions {
fn from_args(args: &AddArgs) -> Self {
Self {
save_dev: args.save_dev,
save_exact: args.save_exact,
save_optional: args.save_optional,
save_peer: args.save_peer,
save_catalog: args.save_catalog_name.clone().or_else(|| {
if args.save_catalog {
Some("default".to_string())
} else {
None
}
}),
workspace_protocol_override: workspace_protocol_override_from_flags(
args.save_workspace_protocol,
args.no_save_workspace_protocol,
),
}
}
}
fn workspace_protocol_override_from_flags(save: bool, no_save: bool) -> Option<bool> {
if save {
Some(true)
} else if no_save {
Some(false)
} else {
None
}
}
async fn update_manifest_for_add(
cwd: &Path,
packages: &[String],
opts: AddManifestOptions,
print_updated: bool,
) -> miette::Result<()> {
let (default_tag, default_prefix, catalog_mode) = super::with_settings_ctx(cwd, |ctx| {
let tag = aube_settings::resolved::tag(ctx);
let prefix = if opts.save_exact {
String::new()
} else {
let raw = aube_settings::resolved::save_prefix(ctx);
match raw.as_str() {
"^" | "~" | "" => raw,
_ => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_INVALID_SAVE_PREFIX,
"ignoring invalid save-prefix={raw:?}, falling back to ^"
);
"^".to_string()
}
}
};
let catalog_mode = aube_settings::resolved::catalog_mode(ctx);
(tag, prefix, catalog_mode)
});
let workspace_settings_cwd = crate::dirs::find_workspace_yaml_root(cwd)
.or_else(|| crate::dirs::find_workspace_root(cwd))
.unwrap_or_else(|| cwd.to_path_buf());
let (link_workspace_packages, save_workspace_protocol_setting) =
super::with_settings_ctx(&workspace_settings_cwd, |ctx| {
(
aube_settings::resolved::link_workspace_packages(ctx),
aube_settings::resolved::save_workspace_protocol(ctx),
)
});
let workspace_catalogs = super::load_workspace_catalogs(cwd)?;
let default_catalog = workspace_catalogs.get("default");
let manifest_path = cwd.join("package.json");
let mut manifest = super::load_manifest(&manifest_path)?;
let mut catalog_upserts: Vec<CatalogUpsert> = Vec::new();
let client = std::sync::Arc::new(super::make_client(cwd));
let mut parsed: Vec<_> = packages
.iter()
.map(|s| {
let mut spec = parse_pkg_spec(s)?;
if !spec.has_explicit_range && default_tag != "latest" {
spec.range = default_tag.clone();
}
Ok::<_, miette::Report>(spec)
})
.collect::<miette::Result<Vec<_>>>()?;
if link_workspace_packages || matches!(opts.workspace_protocol_override, Some(true)) {
let workspace_versions = collect_workspace_versions(cwd);
for spec in &mut parsed {
if spec.linked_workspace_version.is_some() {
continue;
}
if aube_util::pkg::is_workspace_spec(&spec.range)
|| aube_util::pkg::is_catalog_spec(&spec.range)
|| aube_util::pkg::is_npm_spec(&spec.range)
|| aube_util::pkg::is_jsr_spec(&spec.range)
|| spec.git_spec.is_some()
|| spec.local_spec.is_some()
|| spec.jsr_name.is_some()
|| spec.alias.is_some()
{
continue;
}
let Some(version) = workspace_versions.get(&spec.name) else {
continue;
};
if spec.has_explicit_range
&& let (Ok(parsed_version), Ok(parsed_range)) = (
node_semver::Version::parse(version),
node_semver::Range::parse(&spec.range),
)
&& !parsed_version.satisfies(&parsed_range)
{
continue;
}
spec.linked_workspace_version = Some(version.clone());
}
}
let mut handles = Vec::new();
for spec in &parsed {
if aube_util::pkg::is_workspace_spec(&spec.range)
|| spec.git_spec.is_some()
|| spec.local_spec.is_some()
|| spec.linked_workspace_version.is_some()
{
continue;
}
let client = client.clone();
let name = spec.name.clone();
let handle = tokio::spawn(async move {
let packument = client
.fetch_packument(&name)
.await
.map_err(|e| miette!("failed to fetch {name}: {e}"))?;
Ok::<_, miette::Report>((name, packument))
});
handles.push(handle);
}
let mut packuments = BTreeMap::new();
for handle in handles {
let (name, packument) = handle.await.into_diagnostic()??;
packuments.insert(name, packument);
}
for (spec, orig) in parsed.iter().zip(packages.iter()) {
let pkg_name_for_manifest = spec.alias.as_deref().unwrap_or(&spec.name);
if aube_util::pkg::is_workspace_spec(&spec.range) {
apply_workspace_spec_to_manifest(
cwd,
&mut manifest,
spec,
pkg_name_for_manifest,
&opts,
)?;
continue;
}
if let Some(verbatim) = spec.git_spec.as_deref() {
apply_git_spec_to_manifest(&mut manifest, pkg_name_for_manifest, verbatim, &opts);
continue;
}
if let Some(verbatim) = spec.local_spec.as_deref() {
apply_local_spec_to_manifest(&mut manifest, pkg_name_for_manifest, verbatim, &opts);
continue;
}
if let Some(version) = spec.linked_workspace_version.as_deref() {
apply_linked_workspace_to_manifest(
&mut manifest,
pkg_name_for_manifest,
version,
save_workspace_protocol_setting,
opts.workspace_protocol_override,
&default_prefix,
&opts,
);
continue;
}
let packument = packuments.get(&spec.name).unwrap();
eprintln!("Resolving {}@{}...", spec.name, spec.range);
let effective_range = if let Some(tagged_version) = packument.dist_tags.get(&spec.range) {
tagged_version.clone()
} else {
spec.range.clone()
};
let mut parsed_versions: Vec<(&String, node_semver::Version)> = packument
.versions
.keys()
.filter_map(|v| node_semver::Version::parse(v).ok().map(|p| (v, p)))
.collect();
parsed_versions.sort_by(|a, b| b.1.cmp(&a.1));
let highest_satisfying = |range_str: &str| -> Option<String> {
let range = node_semver::Range::parse(range_str).ok()?;
if let Some(latest) = packument.dist_tags.get("latest")
&& let Ok(parsed_latest) = node_semver::Version::parse(latest)
&& parsed_latest.satisfies(&range)
&& packument.versions.contains_key(latest)
{
return Some(latest.clone());
}
parsed_versions
.iter()
.find(|(_, parsed)| parsed.satisfies(&range))
.map(|(raw, _)| (*raw).clone())
};
let resolved_version = highest_satisfying(&effective_range)
.ok_or_else(|| miette!("no version of {} matches {effective_range}", spec.name))?;
let is_jsr = spec.jsr_name.is_some();
let needs_npm_prefix = !is_jsr && (spec.alias.is_some() || orig.starts_with("npm:"));
let prefix = &default_prefix;
let pin_to_resolved = spec.range == default_tag
|| packument.dist_tags.contains_key(&spec.range)
|| opts.save_exact;
let manual_specifier = if let Some(jsr_name) = spec.jsr_name.as_deref() {
let effective_range = if pin_to_resolved {
format!("{prefix}{resolved_version}")
} else {
spec.range.clone()
};
let alias_matches_jsr_name =
spec.alias.as_deref() == Some(jsr_name) || spec.alias.is_none();
if alias_matches_jsr_name {
format!("jsr:{effective_range}")
} else {
format!("jsr:{jsr_name}@{effective_range}")
}
} else if pin_to_resolved {
if needs_npm_prefix {
format!("npm:{}@{prefix}{resolved_version}", spec.name)
} else {
format!("{prefix}{resolved_version}")
}
} else if needs_npm_prefix {
format!("npm:{}@{}", spec.name, spec.range)
} else {
spec.range.clone()
};
let exclude_from_catalog = needs_npm_prefix
|| is_jsr
|| aube_util::pkg::is_workspace_spec(&spec.range)
|| aube_util::pkg::is_catalog_spec(&spec.range);
let (specifier, display_version) = if let Some(target) = opts.save_catalog.as_deref() {
decide_save_catalog(
target,
&workspace_catalogs,
spec,
exclude_from_catalog,
&manual_specifier,
&resolved_version,
&mut catalog_upserts,
highest_satisfying,
)
} else {
match decide_add_rewrite(
catalog_mode,
default_catalog,
&spec.name,
&spec.range,
spec.has_explicit_range,
&resolved_version,
needs_npm_prefix || is_jsr,
) {
CatalogRewrite::Manual => (manual_specifier, resolved_version.clone()),
CatalogRewrite::UseDefaultCatalog => {
let cat_range = default_catalog
.and_then(|c| c.get(&spec.name))
.cloned()
.unwrap_or_default();
let catalog_version = highest_satisfying(&cat_range).unwrap_or_else(|| {
tracing::debug!(
"catalog range {cat_range:?} for {} did not match any packument version; \
falling back to user-resolved version for display",
spec.name
);
resolved_version.clone()
});
("catalog:".to_string(), catalog_version)
}
CatalogRewrite::StrictMismatch {
pkg,
catalog_range,
user_range,
} => {
return Err(miette!(
"catalogMode=strict: {pkg}@{user_range} does not match the \
default catalog entry `{catalog_range}`. Update the catalog \
or rerun with the catalog range."
));
}
}
};
eprintln!(" + {pkg_name_for_manifest}@{display_version} (specifier: {specifier})");
manifest.dependencies.remove(pkg_name_for_manifest);
manifest.optional_dependencies.remove(pkg_name_for_manifest);
if !opts.save_peer {
manifest.peer_dependencies.remove(pkg_name_for_manifest);
}
if !(opts.save_peer && opts.save_dev) {
manifest.dev_dependencies.remove(pkg_name_for_manifest);
}
let dep_name = pkg_name_for_manifest.to_string();
if opts.save_peer {
manifest
.peer_dependencies
.insert(dep_name.clone(), specifier.clone());
if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
}
} else if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
} else if opts.save_optional {
manifest.optional_dependencies.insert(dep_name, specifier);
} else {
manifest.dependencies.insert(dep_name, specifier);
}
}
super::write_manifest_dep_sections(&manifest_path, &manifest)?;
if print_updated {
eprintln!("Updated package.json");
}
if !catalog_upserts.is_empty() {
let yaml_root = crate::dirs::find_workspace_yaml_root(cwd)
.or_else(|| crate::dirs::find_workspace_root(cwd))
.unwrap_or_else(|| cwd.to_path_buf());
let yaml_path = aube_manifest::workspace::workspace_yaml_target(&yaml_root);
super::catalogs::upsert_catalog_entries(&yaml_path, &catalog_upserts)?;
}
Ok(())
}
fn apply_workspace_spec_to_manifest(
cwd: &Path,
manifest: &mut aube_manifest::PackageJson,
spec: &ParsedPkgSpec,
pkg_name_for_manifest: &str,
opts: &AddManifestOptions,
) -> miette::Result<()> {
let workspace_root = crate::dirs::find_workspace_yaml_root(cwd)
.or_else(|| crate::dirs::find_workspace_root(cwd))
.unwrap_or_else(|| cwd.to_path_buf());
let workspace_pkg_dirs = aube_workspace::find_workspace_packages(&workspace_root)
.into_diagnostic()
.wrap_err("failed to discover workspace packages")?;
let mut found_version: Option<String> = None;
for dir in &workspace_pkg_dirs {
let pkg_manifest = match aube_manifest::PackageJson::from_path(&dir.join("package.json")) {
Ok(m) => m,
Err(_) => continue,
};
if pkg_manifest.name.as_deref() == Some(spec.name.as_str()) {
found_version = Some(pkg_manifest.version.unwrap_or_else(|| "0.0.0".to_string()));
break;
}
}
let Some(workspace_version) = found_version else {
return Err(miette!(
"no workspace package named `{}` found at or above {}; \
`workspace:` specs only resolve against local workspace packages",
spec.name,
workspace_root.display()
));
};
eprintln!(
" + {pkg_name_for_manifest}@{workspace_version} (specifier: {})",
spec.range
);
manifest.dependencies.remove(pkg_name_for_manifest);
manifest.optional_dependencies.remove(pkg_name_for_manifest);
if !opts.save_peer {
manifest.peer_dependencies.remove(pkg_name_for_manifest);
}
if !(opts.save_peer && opts.save_dev) {
manifest.dev_dependencies.remove(pkg_name_for_manifest);
}
let dep_name = pkg_name_for_manifest.to_string();
let specifier = spec.range.clone();
if opts.save_peer {
manifest
.peer_dependencies
.insert(dep_name.clone(), specifier.clone());
if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
}
} else if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
} else if opts.save_optional {
manifest.optional_dependencies.insert(dep_name, specifier);
} else {
manifest.dependencies.insert(dep_name, specifier);
}
Ok(())
}
fn collect_workspace_versions(cwd: &Path) -> std::collections::HashMap<String, String> {
let workspace_root = match crate::dirs::find_workspace_yaml_root(cwd)
.or_else(|| crate::dirs::find_workspace_root(cwd))
{
Some(root) => root,
None => return std::collections::HashMap::new(),
};
let mut out = std::collections::HashMap::new();
let dirs = match aube_workspace::find_workspace_packages(&workspace_root) {
Ok(d) => d,
Err(_) => return out,
};
for dir in dirs {
let Ok(pkg) = aube_manifest::PackageJson::from_path(&dir.join("package.json")) else {
continue;
};
if let Some(name) = pkg.name {
out.insert(name, pkg.version.unwrap_or_else(|| "0.0.0".to_string()));
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn apply_linked_workspace_to_manifest(
manifest: &mut aube_manifest::PackageJson,
pkg_name_for_manifest: &str,
workspace_version: &str,
save_workspace_protocol: aube_settings::resolved::SaveWorkspaceProtocol,
workspace_protocol_override: Option<bool>,
save_prefix: &str,
opts: &AddManifestOptions,
) {
use aube_settings::resolved::SaveWorkspaceProtocol;
let effective = match workspace_protocol_override {
Some(false) => SaveWorkspaceProtocol::False,
Some(true) if matches!(save_workspace_protocol, SaveWorkspaceProtocol::False) => {
SaveWorkspaceProtocol::Rolling
}
_ => save_workspace_protocol,
};
let specifier = match effective {
SaveWorkspaceProtocol::Rolling => {
let sigil = if save_prefix.is_empty() {
"*"
} else {
save_prefix
};
format!("workspace:{sigil}")
}
SaveWorkspaceProtocol::True => {
format!("workspace:{save_prefix}{workspace_version}")
}
SaveWorkspaceProtocol::False => {
format!("{save_prefix}{workspace_version}")
}
};
eprintln!(" + {pkg_name_for_manifest}@{workspace_version} (specifier: {specifier})");
manifest.dependencies.remove(pkg_name_for_manifest);
manifest.optional_dependencies.remove(pkg_name_for_manifest);
if !opts.save_peer {
manifest.peer_dependencies.remove(pkg_name_for_manifest);
}
if !(opts.save_peer && opts.save_dev) {
manifest.dev_dependencies.remove(pkg_name_for_manifest);
}
let dep_name = pkg_name_for_manifest.to_string();
if opts.save_peer {
manifest
.peer_dependencies
.insert(dep_name.clone(), specifier.clone());
if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
}
} else if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
} else if opts.save_optional {
manifest.optional_dependencies.insert(dep_name, specifier);
} else {
manifest.dependencies.insert(dep_name, specifier);
}
}
fn apply_git_spec_to_manifest(
manifest: &mut aube_manifest::PackageJson,
pkg_name_for_manifest: &str,
verbatim_spec: &str,
opts: &AddManifestOptions,
) {
eprintln!(" + {pkg_name_for_manifest} (specifier: {verbatim_spec})");
manifest.dependencies.remove(pkg_name_for_manifest);
manifest.optional_dependencies.remove(pkg_name_for_manifest);
if !opts.save_peer {
manifest.peer_dependencies.remove(pkg_name_for_manifest);
}
if !(opts.save_peer && opts.save_dev) {
manifest.dev_dependencies.remove(pkg_name_for_manifest);
}
let dep_name = pkg_name_for_manifest.to_string();
let specifier = verbatim_spec.to_string();
if opts.save_peer {
manifest
.peer_dependencies
.insert(dep_name.clone(), specifier.clone());
if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
}
} else if opts.save_dev {
manifest.dev_dependencies.insert(dep_name, specifier);
} else if opts.save_optional {
manifest.optional_dependencies.insert(dep_name, specifier);
} else {
manifest.dependencies.insert(dep_name, specifier);
}
}
fn apply_local_spec_to_manifest(
manifest: &mut aube_manifest::PackageJson,
pkg_name_for_manifest: &str,
verbatim_spec: &str,
opts: &AddManifestOptions,
) {
apply_git_spec_to_manifest(manifest, pkg_name_for_manifest, verbatim_spec, opts);
}
#[allow(clippy::too_many_arguments)]
fn decide_save_catalog(
target: &str,
workspace_catalogs: &super::CatalogMap,
spec: &ParsedPkgSpec,
exclude_from_catalog: bool,
manual_specifier: &str,
resolved_version: &str,
upserts: &mut Vec<CatalogUpsert>,
highest_satisfying: impl Fn(&str) -> Option<String>,
) -> (String, String) {
if exclude_from_catalog {
return (manual_specifier.to_string(), resolved_version.to_string());
}
let manifest_specifier = if target == "default" {
"catalog:".to_string()
} else {
format!("catalog:{target}")
};
let target_catalog = workspace_catalogs.get(target);
if let Some(existing_range) = target_catalog.and_then(|c| c.get(&spec.name)) {
let compatible = range_compatible(
&spec.range,
spec.has_explicit_range,
existing_range,
resolved_version,
);
if compatible {
let catalog_version = highest_satisfying(existing_range).unwrap_or_else(|| {
tracing::debug!(
"catalog range {existing_range:?} for {} did not match any \
packument version; falling back to user-resolved version for display",
spec.name
);
resolved_version.to_string()
});
return (manifest_specifier, catalog_version);
}
return (manual_specifier.to_string(), resolved_version.to_string());
}
upserts.push(CatalogUpsert {
catalog: target.to_string(),
package: spec.name.clone(),
range: manual_specifier.to_string(),
});
(manifest_specifier, resolved_version.to_string())
}
fn parse_allow_build_value(s: &str) -> Result<String, String> {
if s.is_empty() {
Err("The --allow-build flag is missing a package name. \
Please specify the package name(s) that are allowed to run installation scripts."
.to_string())
} else {
Ok(s.to_string())
}
}
fn apply_allow_build_flags(cwd: &std::path::Path, names: &[String]) -> miette::Result<()> {
aube_manifest::workspace::add_to_allow_builds(cwd, names)
.into_diagnostic()
.wrap_err("failed to write --allow-build entries")?;
Ok(())
}
fn registry_bound_names_for_supply_chain(cwd: &Path, packages: &[String]) -> Vec<String> {
let mut names = Vec::with_capacity(packages.len());
let workspace_versions = collect_workspace_versions(cwd);
let npm_config = aube_registry::config::NpmConfig::load(cwd);
for raw in packages {
let Ok(spec) = parse_pkg_spec(raw) else {
continue;
};
if spec.git_spec.is_some()
|| spec.local_spec.is_some()
|| spec.jsr_name.is_some()
|| aube_util::pkg::is_workspace_spec(&spec.range)
|| aube_util::pkg::is_catalog_spec(&spec.range)
{
continue;
}
if workspace_versions.contains_key(&spec.name) {
continue;
}
if !npm_config.is_public_npmjs(&spec.name) {
tracing::debug!(
"skipping supply-chain gates for {}: routes through non-public registry {}",
spec.name,
aube_util::url::redact_url(npm_config.registry_for(&spec.name))
);
continue;
}
names.push(spec.name);
}
names.sort();
names.dedup();
names
}
fn lockfile_path_for_project(project_dir: &std::path::Path) -> std::path::PathBuf {
use aube_lockfile::LockfileKind;
let kind =
aube_lockfile::detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
let filename = match kind {
LockfileKind::Aube => aube_lockfile::aube_lock_filename(project_dir),
LockfileKind::Pnpm => aube_lockfile::pnpm_lock_filename(project_dir),
other => other.filename().to_string(),
};
project_dir.join(filename)
}
async fn run_filtered(
args: AddArgs,
filter: &aube_workspace::selector::EffectiveFilter,
) -> miette::Result<()> {
if args.packages.is_empty() {
return Err(miette!("no packages specified"));
}
let cwd = crate::dirs::cwd()?;
let (root, matched) = super::select_workspace_packages(&cwd, filter, "add")?;
let _lock = super::take_project_lock(&root)?;
if !args.allow_build.is_empty() {
apply_allow_build_flags(&root, &args.allow_build)?;
}
let registry_names = registry_bound_names_for_supply_chain(&root, &args.packages);
let (advisory_check, low_download_threshold, allowed_unpopular) =
super::with_settings_ctx(&root, |ctx| {
let policy = if aube_settings::resolved::paranoid(ctx) {
aube_settings::resolved::AdvisoryCheck::Required
} else {
aube_settings::resolved::advisory_check(ctx)
};
(
policy,
aube_settings::resolved::low_download_threshold(ctx),
aube_settings::resolved::allowed_unpopular_packages(ctx).unwrap_or_default(),
)
});
super::add_supply_chain::run_gates(
®istry_names,
advisory_check,
low_download_threshold,
args.allow_low_downloads,
&allowed_unpopular,
)
.await?;
let mut snapshots = Vec::new();
let lockfile_path = lockfile_path_for_project(&root);
let root_lockfile_snapshot = if args.no_save {
match std::fs::read(&lockfile_path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e)
.into_diagnostic()
.wrap_err("failed to snapshot lockfile for --no-save");
}
}
} else {
None
};
let result: miette::Result<()> = async {
for pkg in &matched {
let manifest_path = pkg.dir.join("package.json");
if args.no_save {
let manifest_bytes = std::fs::read(&manifest_path)
.into_diagnostic()
.wrap_err("failed to snapshot package.json for --no-save")?;
snapshots.push((manifest_path.clone(), manifest_bytes));
}
update_manifest_for_add(
&pkg.dir,
&args.packages,
AddManifestOptions::from_args(&args),
!args.no_save,
)
.await?;
}
let mut install_opts = install::InstallOptions::with_mode(super::chained_frozen_mode(
install::FrozenMode::Fix,
));
install_opts.workspace_filter = filter.clone();
install_opts.osv_transitive_check = true;
install::run(install_opts).await?;
Ok(())
}
.await;
let restore_errors = if args.no_save {
let mut errors: Vec<miette::Report> = Vec::new();
let restored = snapshots.len();
for (manifest_path, manifest_bytes) in snapshots {
if let Err(e) = aube_util::fs_atomic::atomic_write(&manifest_path, &manifest_bytes) {
errors.push(
Result::<(), _>::Err(e)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to restore original package.json after --no-save at {}",
manifest_path.display()
)
})
.unwrap_err(),
);
}
}
let lockfile_restore = match &root_lockfile_snapshot {
Some(bytes) => aube_util::fs_atomic::atomic_write(&lockfile_path, bytes),
None => match std::fs::remove_file(&lockfile_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
},
};
if let Err(e) = lockfile_restore {
errors.push(
Result::<(), _>::Err(e)
.into_diagnostic()
.wrap_err("failed to restore original lockfile after --no-save")
.unwrap_err(),
);
}
if errors.is_empty() {
eprintln!(
"Restored {} and lockfile (--no-save)",
pluralizer::pluralize("package.json file", restored as isize, true)
);
}
errors
} else {
Vec::new()
};
result?;
if let Some(first) = restore_errors.into_iter().next() {
return Err(first);
}
Ok(())
}
async fn run_global(
packages: &[String],
allow_build: Vec<String>,
allow_low_downloads: bool,
lockfile: crate::cli_args::LockfileArgs,
network: crate::cli_args::NetworkArgs,
virtual_store: crate::cli_args::VirtualStoreArgs,
) -> miette::Result<()> {
use super::global;
let mut layout = global::GlobalLayout::resolve()?;
let install_dir_raw = global::create_install_dir(&layout.pkg_dir)?;
let install_dir = crate::dirs::canonicalize(&install_dir_raw)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to canonicalize install dir {}",
install_dir_raw.display()
)
})?;
if let Ok(canon) = crate::dirs::canonicalize(&layout.pkg_dir) {
layout.pkg_dir = canon;
}
let before: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&layout.pkg_dir)
.ok()
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_type().map(|t| t.is_symlink()).unwrap_or(false))
.map(|e| e.path())
.collect();
let result = run_global_inner(
packages,
allow_build,
allow_low_downloads,
&layout,
&install_dir,
lockfile,
network,
virtual_store,
)
.await;
if result.is_err() {
let _ = std::fs::remove_dir_all(&install_dir);
if let Ok(entries) = std::fs::read_dir(&layout.pkg_dir) {
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_symlink() {
continue;
}
let path = entry.path();
if before.contains(&path) {
continue;
}
if let Ok(target) = crate::dirs::canonicalize(&path)
&& target == install_dir
{
let _ = std::fs::remove_file(&path);
}
}
}
}
result
}
#[allow(clippy::too_many_arguments)]
async fn run_global_inner(
packages: &[String],
allow_build: Vec<String>,
allow_low_downloads: bool,
layout: &super::global::GlobalLayout,
install_dir: &std::path::Path,
lockfile: crate::cli_args::LockfileArgs,
network: crate::cli_args::NetworkArgs,
virtual_store: crate::cli_args::VirtualStoreArgs,
) -> miette::Result<()> {
use super::global;
let seed = serde_json::json!({
"name": "aube-global",
"version": "0.0.0",
"private": true,
});
let seed_str = serde_json::to_string_pretty(&seed)
.into_diagnostic()
.wrap_err("failed to serialize seed package.json")?;
aube_util::fs_atomic::atomic_write(
&install_dir.join("package.json"),
format!("{seed_str}\n").as_bytes(),
)
.into_diagnostic()
.wrap_err("failed to write seed package.json")?;
std::env::set_current_dir(install_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to chdir into {}", install_dir.display()))?;
crate::dirs::set_cwd(install_dir)?;
let npm_config = aube_registry::config::NpmConfig::load(install_dir);
let mut registries: BTreeMap<String, String> = BTreeMap::new();
registries.insert("default".to_string(), npm_config.registry.clone());
for (scope, url) in &npm_config.scoped_registries {
registries.insert(scope.clone(), url.clone());
}
let inner = AddArgs {
packages: packages.to_vec(),
save_dev: false,
save_exact: true,
global: false,
save_optional: false,
ignore_scripts: false,
no_save: false,
save_peer: false,
save_workspace_protocol: false,
no_save_workspace_protocol: false,
ignore_workspace_root_check: true,
workspace: false,
save_catalog: false,
save_catalog_name: None,
allow_build,
allow_low_downloads,
lockfile,
network,
virtual_store,
};
Box::pin(run(
inner,
aube_workspace::selector::EffectiveFilter::default(),
))
.await?;
let manifest_raw = std::fs::read_to_string(install_dir.join("package.json"))
.into_diagnostic()
.wrap_err("failed to re-read install dir package.json")?;
let manifest_json: serde_json::Value = serde_json::from_str(&manifest_raw)
.into_diagnostic()
.wrap_err("failed to parse install dir package.json")?;
let aliases: Vec<String> = manifest_json
.get("dependencies")
.and_then(|d| d.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
let hash = global::cache_key(&aliases, ®istries);
let hash_ptr = global::hash_link(&layout.pkg_dir, &hash);
let mut priors: Vec<global::GlobalPackageInfo> = Vec::new();
if let Ok(existing_target) = crate::dirs::canonicalize(&hash_ptr)
&& existing_target != install_dir
{
priors.extend(
global::scan_packages(&layout.pkg_dir)
.into_iter()
.filter(|p| p.install_dir == existing_target),
);
}
for alias in &aliases {
if let Some(existing) = global::find_package(&layout.pkg_dir, alias)
&& existing.install_dir != install_dir
&& existing.hash != hash
&& !priors.iter().any(|p| p.hash == existing.hash)
{
priors.push(existing);
}
}
global::symlink_force(install_dir, &hash_ptr)?;
let shim_opts = super::with_settings_ctx(install_dir, |ctx| aube_linker::BinShimOptions {
extend_node_path: aube_settings::resolved::extend_node_path(ctx),
prefer_symlinked_executables: aube_settings::resolved::prefer_symlinked_executables(ctx),
hidden_modules_dir: None,
});
let linked = global::link_bins(install_dir, &layout.bin_dir, &aliases, shim_opts)?;
for prior in &priors {
let res = if prior.hash == hash {
let bins = global::bin_names_for(&prior.install_dir, &prior.aliases);
global::unlink_bins(&prior.install_dir, &layout.bin_dir, &bins);
std::fs::remove_dir_all(&prior.install_dir)
.or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
}
})
.map_err(|e| {
miette::miette!(
code = aube_codes::errors::ERR_AUBE_REMOVE_PRIOR_INSTALL_DIR,
"failed to remove prior install dir: {e}"
)
})
} else {
global::remove_package(prior, layout)
};
if let Err(e) = res {
eprintln!("warning: failed to remove prior global install: {e}");
}
}
if !linked.is_empty() {
eprintln!(
"Linked {} into {}",
pluralizer::pluralize("bin", linked.len() as isize, true),
layout.bin_dir.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pkg_spec_name_only() {
let s = parse_pkg_spec("lodash").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
assert!(s.alias.is_none());
assert!(s.jsr_name.is_none());
}
#[test]
fn test_parse_pkg_spec_with_version() {
let s = parse_pkg_spec("lodash@^4.17.0").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "^4.17.0");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_exact_version() {
let s = parse_pkg_spec("lodash@4.17.21").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "4.17.21");
}
#[test]
fn test_parse_pkg_spec_scoped() {
let s = parse_pkg_spec("@babel/core").unwrap();
assert_eq!(s.name, "@babel/core");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_scoped_with_version() {
let s = parse_pkg_spec("@babel/core@^7.24.0").unwrap();
assert_eq!(s.name, "@babel/core");
assert_eq!(s.range, "^7.24.0");
}
#[test]
fn test_parse_pkg_spec_dist_tag() {
let s = parse_pkg_spec("lodash@latest").unwrap();
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_npm_bare() {
let s = parse_pkg_spec("npm:string-width@^4.2.0").unwrap();
assert_eq!(s.name, "string-width");
assert_eq!(s.range, "^4.2.0");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_npm_alias_full() {
let s = parse_pkg_spec("string-width-cjs@npm:string-width@^4.2.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("string-width-cjs"));
assert_eq!(s.name, "string-width");
assert_eq!(s.range, "^4.2.0");
}
#[test]
fn test_parse_pkg_spec_npm_alias_scoped() {
let s = parse_pkg_spec("my-react@npm:@preact/compat@^17.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("my-react"));
assert_eq!(s.name, "@preact/compat");
assert_eq!(s.range, "^17.0.0");
}
#[test]
fn test_parse_pkg_spec_npm_alias_no_version() {
let s = parse_pkg_spec("my-lodash@npm:lodash").unwrap();
assert_eq!(s.alias.as_deref(), Some("my-lodash"));
assert_eq!(s.name, "lodash");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_jsr_bare_no_range() {
let s = parse_pkg_spec("jsr:@std/collections").unwrap();
assert_eq!(s.alias.as_deref(), Some("@std/collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "latest");
assert!(!s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_jsr_bare_with_range() {
let s = parse_pkg_spec("jsr:@std/collections@^1.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("@std/collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "^1.0.0");
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_jsr_aliased() {
let s = parse_pkg_spec("collections@jsr:@std/collections@^1.0.0").unwrap();
assert_eq!(s.alias.as_deref(), Some("collections"));
assert_eq!(s.name, "@jsr/std__collections");
assert_eq!(s.jsr_name.as_deref(), Some("@std/collections"));
assert_eq!(s.range, "^1.0.0");
}
#[test]
fn test_parse_pkg_spec_jsr_rejects_unscoped() {
let err = parse_pkg_spec("jsr:collections").unwrap_err();
assert!(
err.to_string().contains("JSR packages must be scoped"),
"unexpected error: {err}"
);
}
#[test]
fn test_parse_pkg_spec_git_bare_github_shorthand() {
let s = parse_pkg_spec("kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.name, "is-negative");
assert_eq!(s.range, "kevva/is-negative");
assert!(s.alias.is_none());
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_git_github_protocol() {
let s = parse_pkg_spec("github:user/repo").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("github:user/repo"));
assert_eq!(s.name, "repo");
assert_eq!(s.range, "github:user/repo");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_git_url_with_committish() {
let spec = "git+https://github.com/owner/repo.git#tag/with/slash";
let s = parse_pkg_spec(spec).unwrap();
assert_eq!(s.git_spec.as_deref(), Some(spec));
assert_eq!(s.name, "repo");
assert_eq!(s.range, spec);
}
#[test]
fn test_parse_pkg_spec_git_alias() {
let s = parse_pkg_spec("my-alias@kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "kevva/is-negative");
}
#[test]
fn test_parse_pkg_spec_git_alias_skips_url_derivation() {
let s = parse_pkg_spec("my-alias@git+https://example.com/").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("git+https://example.com/"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
}
#[test]
fn test_parse_pkg_spec_file_relative() {
let s = parse_pkg_spec("file:./local/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./local/pkg"));
assert_eq!(s.name, "pkg");
assert_eq!(s.range, "file:./local/pkg");
assert!(s.alias.is_none());
assert!(s.has_explicit_range);
}
#[test]
fn test_parse_pkg_spec_link_relative() {
let s = parse_pkg_spec("link:./local/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:./local/pkg"));
assert_eq!(s.name, "pkg");
assert_eq!(s.range, "link:./local/pkg");
assert!(s.alias.is_none());
}
#[test]
fn test_parse_pkg_spec_file_absolute() {
let s = parse_pkg_spec("file:/abs/pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:/abs/pkg"));
assert_eq!(s.name, "pkg");
}
#[test]
fn test_parse_pkg_spec_file_tarball_strips_extension() {
let s = parse_pkg_spec("file:./bundle.tgz").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./bundle.tgz"));
assert_eq!(s.name, "bundle");
}
#[test]
fn test_parse_pkg_spec_file_alias() {
let s = parse_pkg_spec("my-alias@file:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./pkg"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "file:./pkg");
}
#[test]
fn test_parse_pkg_spec_link_alias() {
let s = parse_pkg_spec("my-alias@link:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:./pkg"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
assert_eq!(s.range, "link:./pkg");
}
#[test]
fn test_parse_pkg_spec_local_alias_skips_basename_derivation() {
let s = parse_pkg_spec("my-alias@file:").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:"));
assert_eq!(s.alias.as_deref(), Some("my-alias"));
assert_eq!(s.name, "my-alias");
}
#[test]
fn test_parse_pkg_spec_scoped_not_git_or_local() {
let s = parse_pkg_spec("@scope/pkg").unwrap();
assert!(s.git_spec.is_none());
assert!(s.local_spec.is_none());
assert_eq!(s.name, "@scope/pkg");
assert_eq!(s.range, "latest");
}
#[test]
fn test_parse_pkg_spec_bare_user_repo_not_local() {
let s = parse_pkg_spec("kevva/is-negative").unwrap();
assert!(s.local_spec.is_none());
assert!(s.git_spec.is_some());
}
#[test]
fn test_parse_pkg_spec_scoped_alias_for_local() {
let s = parse_pkg_spec("@my-scope/alias@file:./pkg").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./pkg"));
assert_eq!(s.alias.as_deref(), Some("@my-scope/alias"));
assert_eq!(s.name, "@my-scope/alias");
}
#[test]
fn test_parse_pkg_spec_scoped_alias_for_git() {
let s = parse_pkg_spec("@my-scope/alias@kevva/is-negative").unwrap();
assert_eq!(s.git_spec.as_deref(), Some("kevva/is-negative"));
assert_eq!(s.alias.as_deref(), Some("@my-scope/alias"));
assert_eq!(s.name, "@my-scope/alias");
}
#[test]
fn test_parse_pkg_spec_file_uncompressed_tarball_strips_extension() {
let s = parse_pkg_spec("file:./bundle.tar").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("file:./bundle.tar"));
assert_eq!(s.name, "bundle");
}
#[test]
fn test_parse_pkg_spec_bare_absolute_path() {
let s = parse_pkg_spec("/path/to/library-foo/").unwrap();
assert_eq!(s.local_spec.as_deref(), Some("link:/path/to/library-foo/"));
assert_eq!(s.name, "library-foo");
}
#[test]
fn test_parse_pkg_spec_bare_relative_path() {
for input in ["./lib", "../lib", "../../foo/bar"] {
let s = parse_pkg_spec(input).unwrap();
let local = s.local_spec.expect("relative path should detect as local");
assert_eq!(local, format!("link:{input}"));
}
}
#[test]
fn test_parse_pkg_spec_bare_tilde_path_expands() {
let s = parse_pkg_spec("~/proj/lib").unwrap();
let local = s.local_spec.expect("~/ path should detect as local");
assert!(
local.starts_with("link:"),
"expected link: prefix in `{local}`"
);
assert!(
!local.contains('~'),
"tilde must be expanded eagerly, got `{local}`"
);
assert_eq!(s.name, "lib");
}
#[test]
fn test_parse_pkg_spec_bare_tarball_uses_file_protocol() {
let s = parse_pkg_spec("./vendor/local-helper-1.0.0.tgz").unwrap();
assert_eq!(
s.local_spec.as_deref(),
Some("file:./vendor/local-helper-1.0.0.tgz")
);
assert_eq!(s.name, "local-helper-1.0.0");
}
#[test]
fn test_parse_pkg_spec_short_alias_not_drive_letter() {
let s = parse_pkg_spec("a:1.0.0").unwrap();
assert!(s.local_spec.is_none());
assert!(s.git_spec.is_none());
}
#[test]
fn test_parse_pkg_spec_windows_drive_letter() {
for input in ["C:/projects/lib", "c:\\projects\\lib"] {
let s = parse_pkg_spec(input).unwrap();
let local = s
.local_spec
.expect("drive-letter path should detect as local");
assert_eq!(local, format!("link:{input}"));
assert_eq!(s.name, "lib");
}
}
#[test]
fn test_parse_pkg_spec_windows_backslash_relative() {
for input in ["..\\lib", ".\\lib"] {
let s = parse_pkg_spec(input).unwrap();
assert!(s.local_spec.is_some(), "`{input}` should detect as local");
assert_eq!(s.name, "lib", "wrong basename for `{input}`");
}
}
}