use super::AddArgs;
use super::spec::{ParsedPkgSpec, parse_pkg_spec};
use crate::commands::catalogs::{
CatalogRewrite, CatalogUpsert, decide_add_rewrite, range_compatible,
};
use miette::{Context, IntoDiagnostic, miette};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Clone)]
pub(super) struct AddManifestOptions {
pub(super) save_dev: bool,
pub(super) save_exact: bool,
pub(super) save_optional: bool,
pub(super) save_peer: bool,
pub(super) network_mode: aube_registry::NetworkMode,
pub(super) save_catalog: Option<String>,
pub(super) workspace_protocol_override: Option<bool>,
}
impl AddManifestOptions {
pub(super) 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,
network_mode: aube_registry::NetworkMode::Online,
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,
),
}
}
}
pub(super) 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
}
}
pub(super) async fn update_manifest_for_add(
cwd: &Path,
packages: &[String],
opts: AddManifestOptions,
print_updated: bool,
) -> miette::Result<()> {
let (default_tag, default_prefix, catalog_mode) =
crate::commands::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) =
crate::commands::with_settings_ctx(&workspace_settings_cwd, |ctx| {
(
aube_settings::resolved::link_workspace_packages(ctx),
aube_settings::resolved::save_workspace_protocol(ctx),
)
});
let workspace_catalogs = crate::commands::load_workspace_catalogs(cwd)?;
let default_catalog = workspace_catalogs.get("default");
let manifest_path = cwd.join("package.json");
let mut manifest = crate::commands::load_manifest(&manifest_path)?;
let mut catalog_upserts: Vec<CatalogUpsert> = Vec::new();
let client =
std::sync::Arc::new(crate::commands::make_client(cwd).with_network_mode(opts.network_mode));
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 !matches!(
link_workspace_packages,
aube_settings::resolved::LinkWorkspacePackages::False
) || 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 = tokio::task::JoinSet::new();
let packument_cache_dir = crate::commands::packument_cache_dir_for_cwd(cwd);
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 cache_dir = packument_cache_dir.clone();
let name = spec.name.clone();
handles.spawn(async move {
let packument = client
.fetch_packument_cached(&name, &cache_dir)
.await
.map_err(|e| miette!("failed to fetch {name}: {e}"))?;
Ok::<_, miette::Report>((name, packument))
});
}
let mut packuments = BTreeMap::new();
while let Some(handle) = handles.join_next().await {
let (name, packument) = handle.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)
.expect("packument missing for non-skipped registry spec");
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
format!("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."
));
}
}
};
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
format!(" + {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);
}
}
crate::commands::write_manifest_dep_sections(&manifest_path, &manifest)?;
if print_updated {
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
"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);
crate::commands::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()
));
};
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
format!(
" + {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(())
}
pub(super) 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}")
}
};
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
format!(" + {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,
) {
crate::commands::install::control::output(
crate::commands::install::InstallOutputLevel::Info,
None,
format!(" + {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: &crate::commands::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())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn offline_add_resolves_from_packument_cache() {
let project = tempfile::tempdir().unwrap();
let cache_root = project.path().join("cache");
std::fs::write(project.path().join("package.json"), "{}\n").unwrap();
std::fs::write(
project.path().join(".npmrc"),
format!("cache-dir={}\n", cache_root.display()),
)
.unwrap();
let packument: aube_registry::Packument = serde_json::from_value(serde_json::json!({
"name": "cached-only",
"dist-tags": { "latest": "1.0.0" },
"versions": {
"1.0.0": {
"name": "cached-only",
"version": "1.0.0"
}
}
}))
.unwrap();
let cache_dir = crate::commands::packument_cache_dir_for_cwd(project.path());
crate::commands::make_client(project.path()).seed_packument_cache(
"cached-only",
&cache_dir,
&packument,
None,
None,
true,
);
update_manifest_for_add(
project.path(),
&["cached-only".to_string()],
AddManifestOptions {
save_dev: false,
save_exact: false,
save_optional: false,
save_peer: false,
network_mode: aube_registry::NetworkMode::Offline,
save_catalog: None,
workspace_protocol_override: None,
},
false,
)
.await
.unwrap();
let manifest = std::fs::read_to_string(project.path().join("package.json")).unwrap();
assert!(manifest.contains(r#""cached-only": "^1.0.0""#));
}
}