use aube_lockfile::dep_path_filename::dep_path_to_filename;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub(crate) type PkgJsonCache = BTreeMap<String, Option<serde_json::Value>>;
pub(crate) type WsPkgJsonCache = BTreeMap<PathBuf, Option<serde_json::Value>>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ManagedBinEntry {
File(Vec<u8>),
Symlink(PathBuf),
Other,
}
#[derive(Debug, Default)]
pub(crate) struct ManagedBinLinks {
entries: BTreeMap<PathBuf, BTreeMap<String, BTreeMap<PathBuf, ManagedBinEntry>>>,
seen: BTreeMap<PathBuf, BTreeSet<String>>,
capture: bool,
}
impl ManagedBinLinks {
pub(crate) fn capturing() -> Self {
Self {
capture: true,
..Default::default()
}
}
}
pub(crate) type PreservedBinLinks = BTreeMap<PathBuf, BTreeSet<String>>;
pub(crate) struct LinkDepBinsInput<'a> {
pub(crate) aube_dir: &'a Path,
pub(crate) graph: &'a aube_lockfile::LockfileGraph,
pub(crate) virtual_store_dir_max_length: usize,
pub(crate) placements: Option<&'a aube_linker::HoistedPlacements>,
pub(crate) shim_opts: aube_linker::BinShimOptions<'a>,
pub(crate) cache: &'a mut PkgJsonCache,
pub(crate) managed: &'a mut ManagedBinLinks,
pub(crate) preserved: Option<&'a PreservedBinLinks>,
}
pub(super) struct LinkAllBinsInput<'a> {
pub(super) project_dir: &'a Path,
pub(super) settings_ctx: &'a aube_settings::ResolveCtx<'a>,
pub(super) modules_dir_name: &'a str,
pub(super) aube_dir: &'a Path,
pub(super) graph: &'a aube_lockfile::LockfileGraph,
pub(super) virtual_store_dir_max_length: usize,
pub(super) placements: Option<&'a aube_linker::HoistedPlacements>,
pub(super) ws_dirs: &'a BTreeMap<String, PathBuf>,
pub(super) manifests: &'a [(String, aube_manifest::PackageJson)],
pub(super) manifest: &'a aube_manifest::PackageJson,
pub(super) node_linker: aube_linker::NodeLinker,
pub(super) has_workspace: bool,
pub(super) link_dependency_bins: bool,
pub(super) capture_managed: bool,
pub(super) preserved: Option<&'a PreservedBinLinks>,
}
pub(crate) fn materialized_pkg_dir(
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> std::path::PathBuf {
if let Some(placements) = placements
&& let Some(p) = placements.package_dir(dep_path)
{
return p.to_path_buf();
}
aube_dir
.join(dep_path_to_filename(dep_path, virtual_store_dir_max_length))
.join("node_modules")
.join(name)
}
pub(crate) fn dep_modules_dir_for(package_dir: &std::path::Path, name: &str) -> std::path::PathBuf {
if name.starts_with('@') {
package_dir
.parent()
.and_then(std::path::Path::parent)
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| package_dir.to_path_buf())
} else {
package_dir
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| package_dir.to_path_buf())
}
}
fn read_materialized_pkg_json(
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> miette::Result<Option<serde_json::Value>> {
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
);
let pkg_json_path = pkg_dir.join("package.json");
let content = match std::fs::read_to_string(&pkg_json_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(miette!(
"failed to read package.json for {name} at {}: {e}",
pkg_json_path.display()
));
}
};
let value = aube_manifest::parse_json::<serde_json::Value>(&pkg_json_path, content)
.map_err(miette::Report::new)
.wrap_err_with(|| format!("failed to parse package.json for {name}"))?;
Ok(Some(value))
}
#[allow(clippy::too_many_arguments)]
fn read_materialized_pkg_json_cached(
cache: &mut PkgJsonCache,
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> miette::Result<Option<serde_json::Value>> {
if let Some(value) = cache.get(dep_path) {
return Ok(value.clone());
}
let value = read_materialized_pkg_json(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
)?;
cache.insert(dep_path.to_string(), value.clone());
Ok(value)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn link_bins_for_dep(
cache: &mut PkgJsonCache,
aube_dir: &std::path::Path,
bin_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
shim_opts: aube_linker::BinShimOptions,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
);
if let Some(pkg_json) = read_materialized_pkg_json_cached(
cache,
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
)? && let Some(bin) = pkg_json.get("bin")
{
link_bin_entries(
bin_dir,
&pkg_dir,
Some(name),
bin,
shim_opts,
managed,
preserved,
)?;
}
link_bundled_bins(
bin_dir, &pkg_dir, graph, dep_path, shim_opts, managed, preserved,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn link_bins(
project_dir: &std::path::Path,
modules_dir_name: &str,
aube_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
shim_opts: aube_linker::BinShimOptions,
cache: &mut PkgJsonCache,
ws_dirs: Option<&BTreeMap<String, PathBuf>>,
ws_cache: &mut WsPkgJsonCache,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
let bin_dir = project_dir.join(modules_dir_name).join(".bin");
std::fs::create_dir_all(&bin_dir).into_diagnostic()?;
for dep in graph.root_deps() {
if let Some(ws_dir) = ws_dirs.and_then(|m| m.get(&dep.name)) {
link_bins_for_workspace_dep(
ws_cache, &bin_dir, ws_dir, &dep.name, shim_opts, managed, preserved,
)?;
} else {
link_bins_for_dep(
cache,
aube_dir,
&bin_dir,
graph,
&dep.dep_path,
&dep.name,
virtual_store_dir_max_length,
placements,
shim_opts,
managed,
preserved,
)?;
}
}
Ok(())
}
pub(super) fn link_bins_for_workspace_dep(
cache: &mut WsPkgJsonCache,
bin_dir: &Path,
ws_dir: &Path,
name: &str,
shim_opts: aube_linker::BinShimOptions,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
let pkg_json = if let Some(cached) = cache.get(ws_dir) {
cached.clone()
} else {
let pkg_json_path = ws_dir.join("package.json");
let parsed = match std::fs::read_to_string(&pkg_json_path) {
Ok(content) => Some(
aube_manifest::parse_json::<serde_json::Value>(&pkg_json_path, content)
.map_err(miette::Report::new)
.wrap_err_with(|| {
format!("failed to parse package.json for workspace dep {name}")
})?,
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(miette!(
"failed to read package.json for workspace dep {name} at {}: {e}",
pkg_json_path.display()
));
}
};
cache.insert(ws_dir.to_path_buf(), parsed.clone());
parsed
};
if let Some(pkg_json) = pkg_json
&& let Some(bin) = pkg_json.get("bin")
{
link_bin_entries(
bin_dir,
ws_dir,
Some(name),
bin,
shim_opts,
managed,
preserved,
)?;
}
Ok(())
}
pub(crate) fn link_dep_bins(input: LinkDepBinsInput<'_>) -> miette::Result<()> {
let LinkDepBinsInput {
aube_dir,
graph,
virtual_store_dir_max_length,
placements,
shim_opts,
cache,
managed,
preserved,
} = input;
if placements.is_some() {
return Ok(());
}
for (dep_path, pkg) in &graph.packages {
if pkg.dependencies.is_empty() {
continue;
}
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
&pkg.name,
virtual_store_dir_max_length,
placements,
);
if !pkg_dir.exists() {
continue;
}
let dep_modules_dir = dep_modules_dir_for(&pkg_dir, &pkg.name);
let bin_dir = dep_modules_dir.join(".bin");
for (child_name, child_version) in &pkg.dependencies {
let child_dep_path = format!("{child_name}@{child_version}");
if child_dep_path == *dep_path && child_name == &pkg.name {
continue;
}
link_bins_for_dep(
cache,
aube_dir,
&bin_dir,
graph,
&child_dep_path,
child_name,
virtual_store_dir_max_length,
placements,
shim_opts,
managed,
preserved,
)?;
}
}
Ok(())
}
pub(super) fn link_all_bins(input: LinkAllBinsInput<'_>) -> miette::Result<ManagedBinLinks> {
let LinkAllBinsInput {
project_dir,
settings_ctx,
modules_dir_name,
aube_dir,
graph,
virtual_store_dir_max_length,
placements,
ws_dirs,
manifests,
manifest,
node_linker,
has_workspace,
link_dependency_bins,
capture_managed,
preserved,
} = input;
let extend_node_path = aube_settings::resolved::extend_node_path(settings_ctx);
let isolated = !matches!(node_linker, aube_linker::NodeLinker::Hoisted);
let prefer_symlinked_executables =
aube_settings::resolved::prefer_symlinked_executables(settings_ctx)
.or(isolated.then_some(false));
let hidden_modules_dir = aube_dir.join("node_modules");
let shim_opts = aube_linker::BinShimOptions {
extend_node_path,
prefer_symlinked_executables,
hidden_modules_dir: isolated.then_some(hidden_modules_dir.as_path()),
};
let mut pkg_json_cache = PkgJsonCache::new();
let mut ws_pkg_json_cache = WsPkgJsonCache::new();
let mut managed = if capture_managed {
ManagedBinLinks::capturing()
} else {
ManagedBinLinks::default()
};
let ws_dirs_for_bins = has_workspace.then_some(ws_dirs);
link_bins(
project_dir,
modules_dir_name,
aube_dir,
graph,
virtual_store_dir_max_length,
placements,
shim_opts,
&mut pkg_json_cache,
ws_dirs_for_bins,
&mut ws_pkg_json_cache,
&mut managed,
preserved,
)?;
if let Some(bin) = manifest.extra.get("bin") {
let root_bin_dir = project_dir.join(modules_dir_name).join(".bin");
let self_shim_opts = aube_linker::BinShimOptions {
prefer_symlinked_executables: Some(false),
..shim_opts
};
link_bin_entries(
&root_bin_dir,
project_dir,
manifest.name.as_deref(),
bin,
self_shim_opts,
&mut managed,
preserved,
)?;
}
if has_workspace {
for (importer_path, deps) in &graph.importers {
if importer_path == "." || !aube_linker::is_physical_importer(importer_path) {
continue;
}
let pkg_dir = project_dir.join(importer_path);
let bin_dir = pkg_dir.join(modules_dir_name).join(".bin");
std::fs::create_dir_all(&bin_dir).into_diagnostic()?;
for dep in deps {
if let Some(ws_dir) = ws_dirs.get(&dep.name) {
link_bins_for_workspace_dep(
&mut ws_pkg_json_cache,
&bin_dir,
ws_dir,
&dep.name,
shim_opts,
&mut managed,
preserved,
)?;
} else {
link_bins_for_dep(
&mut pkg_json_cache,
aube_dir,
&bin_dir,
graph,
&dep.dep_path,
&dep.name,
virtual_store_dir_max_length,
placements,
shim_opts,
&mut managed,
preserved,
)?;
}
}
if let Some((_, member_manifest)) =
manifests.iter().find(|(path, _)| path == importer_path)
&& let Some(bin) = member_manifest.extra.get("bin")
{
let self_shim_opts = aube_linker::BinShimOptions {
prefer_symlinked_executables: Some(false),
..shim_opts
};
link_bin_entries(
&bin_dir,
&pkg_dir,
member_manifest.name.as_deref(),
bin,
self_shim_opts,
&mut managed,
preserved,
)?;
}
}
}
if link_dependency_bins {
link_dep_bins(LinkDepBinsInput {
aube_dir,
graph,
virtual_store_dir_max_length,
placements,
shim_opts,
cache: &mut pkg_json_cache,
managed: &mut managed,
preserved,
})?;
}
Ok(managed)
}
pub(crate) fn remove_managed_bin_links(
managed: &ManagedBinLinks,
) -> miette::Result<PreservedBinLinks> {
let mut preserved = PreservedBinLinks::new();
for (bin_dir, entries) in &managed.entries {
for (name, expected_files) in entries {
let mut matching = Vec::new();
let mut replaced = false;
for (path, expected) in expected_files {
match read_managed_bin_entry(path)? {
Some(current) if current == *expected => matching.push(path),
Some(_) | None => replaced = true,
}
}
if replaced {
preserved
.entry(bin_dir.clone())
.or_default()
.insert(name.clone());
} else {
for path in matching {
std::fs::remove_file(path).into_diagnostic()?;
}
}
}
}
Ok(preserved)
}
pub(crate) fn remove_unclaimed_preserved_bin_links(
managed: &ManagedBinLinks,
preserved: &PreservedBinLinks,
relinked: &ManagedBinLinks,
) -> miette::Result<()> {
for (bin_dir, names) in preserved {
for name in names {
if relinked
.seen
.get(bin_dir)
.is_some_and(|seen| seen.contains(name))
{
continue;
}
let Some(expected_files) = managed
.entries
.get(bin_dir)
.and_then(|entries| entries.get(name))
else {
continue;
};
for path in expected_files.keys() {
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
std::fs::remove_dir_all(path).into_diagnostic()?;
}
Ok(_) => std::fs::remove_file(path).into_diagnostic()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).into_diagnostic(),
}
}
}
}
Ok(())
}
fn read_managed_bin_entry(path: &Path) -> miette::Result<Option<ManagedBinEntry>> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).into_diagnostic(),
};
if metadata.file_type().is_symlink() {
return std::fs::read_link(path)
.map(ManagedBinEntry::Symlink)
.map(Some)
.into_diagnostic();
}
if metadata.is_file() {
return std::fs::read(path)
.map(ManagedBinEntry::File)
.map(Some)
.into_diagnostic();
}
Ok(Some(ManagedBinEntry::Other))
}
fn bin_link_paths(bin_dir: &Path, name: &str) -> Vec<PathBuf> {
let link = bin_dir.join(name);
#[cfg(windows)]
return vec![
link,
bin_dir.join(format!("{name}.cmd")),
bin_dir.join(format!("{name}.ps1")),
];
#[cfg(not(windows))]
vec![link]
}
fn link_bundled_bins(
bin_dir: &std::path::Path,
pkg_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
dep_path: &str,
shim_opts: aube_linker::BinShimOptions,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
let Some(locked) = graph.get_package(dep_path) else {
return Ok(());
};
for bundled in &locked.bundled_dependencies {
let bundled_dir = pkg_dir.join("node_modules").join(bundled);
let bundled_pkg_json_path = bundled_dir.join("package.json");
let Ok(content) = std::fs::read_to_string(&bundled_pkg_json_path) else {
continue;
};
let Ok(bundled_pkg_json) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let Some(bin) = bundled_pkg_json.get("bin") else {
continue;
};
link_bin_entries(
bin_dir,
&bundled_dir,
Some(bundled),
bin,
shim_opts,
managed,
preserved,
)?;
}
Ok(())
}
pub(super) fn link_bin_entries(
bin_dir: &std::path::Path,
pkg_dir: &std::path::Path,
pkg_name: Option<&str>,
bin: &serde_json::Value,
shim_opts: aube_linker::BinShimOptions,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
match bin {
serde_json::Value::String(bin_path) => {
let Some(name) = pkg_name else {
return Ok(());
};
let bin_name = name.split('/').next_back().unwrap_or(name);
if aube_linker::validate_bin_name(bin_name).is_ok()
&& aube_linker::validate_bin_target(bin_path).is_ok()
{
create_bin_link(
bin_dir,
bin_name,
&pkg_dir.join(bin_path),
shim_opts,
managed,
preserved,
)?;
}
}
serde_json::Value::Object(bins) => {
for (bin_name, path) in bins {
if let Some(path_str) = path.as_str()
&& aube_linker::validate_bin_name(bin_name).is_ok()
&& aube_linker::validate_bin_target(path_str).is_ok()
{
create_bin_link(
bin_dir,
bin_name,
&pkg_dir.join(path_str),
shim_opts,
managed,
preserved,
)?;
}
}
}
_ => {}
}
Ok(())
}
fn create_bin_link(
bin_dir: &std::path::Path,
name: &str,
target: &std::path::Path,
shim_opts: aube_linker::BinShimOptions,
managed: &mut ManagedBinLinks,
preserved: Option<&PreservedBinLinks>,
) -> miette::Result<()> {
if let Some(preserved) = preserved {
managed
.seen
.entry(bin_dir.to_path_buf())
.or_default()
.insert(name.to_string());
if preserved
.get(bin_dir)
.is_some_and(|names| names.contains(name))
{
return Ok(());
}
}
#[cfg(windows)]
let mkdir_root_owned = bin_dir.parent().and_then(|parent| {
let leaf = bin_dir.file_name()?;
let canon = crate::dirs::canonicalize(parent).ok()?;
Some(canon.join(leaf))
});
#[cfg(windows)]
let mkdir_root: &std::path::Path = mkdir_root_owned.as_deref().unwrap_or(bin_dir);
#[cfg(not(windows))]
let mkdir_root = bin_dir;
let mkdir_link_path = mkdir_root.join(name);
let mkdir_target = mkdir_link_path.parent().unwrap_or(mkdir_root);
if let Err(e) = std::fs::create_dir_all(mkdir_target) {
let tolerated = e.kind() == std::io::ErrorKind::AlreadyExists && mkdir_target.is_dir();
if !tolerated {
return Err(e)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create bin directory {}", bin_dir.display()));
}
}
aube_linker::create_bin_shim(bin_dir, name, target, shim_opts)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to link bin `{name}` at {} -> {}",
bin_dir.join(name).display(),
target.display()
)
})?;
if !managed.capture {
return Ok(());
}
let mut files = BTreeMap::new();
for path in bin_link_paths(bin_dir, name) {
if let Some(entry) = read_managed_bin_entry(&path)? {
files.insert(path, entry);
}
}
managed
.entries
.entry(bin_dir.to_path_buf())
.or_default()
.insert(name.to_string(), files);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
fn locked(name: &str, version: &str, bin: BTreeMap<String, String>) -> LockedPackage {
LockedPackage {
name: name.to_string(),
version: version.to_string(),
dep_path: format!("{name}@{version}"),
bin,
..Default::default()
}
}
#[test]
fn managed_bin_cleanup_removes_owned_shims_and_preserves_replacements() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let removed_target = pkg_dir.join("removed.js");
let replaced_target = pkg_dir.join("replaced.js");
std::fs::write(&removed_target, "#!/usr/bin/env node\n").unwrap();
std::fs::write(&replaced_target, "#!/usr/bin/env node\n").unwrap();
let opts = aube_linker::BinShimOptions {
prefer_symlinked_executables: Some(false),
..Default::default()
};
let mut managed = ManagedBinLinks::capturing();
create_bin_link(
&bin_dir,
"removed",
&removed_target,
opts,
&mut managed,
None,
)
.unwrap();
create_bin_link(
&bin_dir,
"replaced",
&replaced_target,
opts,
&mut managed,
None,
)
.unwrap();
std::fs::write(bin_dir.join("replaced"), "#!/bin/sh\necho custom\n").unwrap();
let preserved = remove_managed_bin_links(&managed).unwrap();
create_bin_link(
&bin_dir,
"replaced",
&replaced_target,
opts,
&mut ManagedBinLinks::default(),
Some(&preserved),
)
.unwrap();
assert!(!bin_dir.join("removed").exists());
assert_eq!(
std::fs::read_to_string(bin_dir.join("replaced")).unwrap(),
"#!/bin/sh\necho custom\n"
);
}
#[test]
fn managed_bin_cleanup_preserves_siblings_of_a_replaced_launcher() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let launcher = bin_dir.join("tool");
let sibling = bin_dir.join("tool.cmd");
std::fs::write(&launcher, "generated launcher\n").unwrap();
std::fs::write(&sibling, "generated sibling\n").unwrap();
let mut expected_files = BTreeMap::new();
expected_files.insert(
launcher.clone(),
read_managed_bin_entry(&launcher).unwrap().unwrap(),
);
expected_files.insert(
sibling.clone(),
read_managed_bin_entry(&sibling).unwrap().unwrap(),
);
let mut commands = BTreeMap::new();
commands.insert("tool".to_string(), expected_files);
let mut managed = ManagedBinLinks::capturing();
managed.entries.insert(bin_dir.clone(), commands);
std::fs::write(&launcher, "lifecycle replacement\n").unwrap();
let preserved = remove_managed_bin_links(&managed).unwrap();
assert!(preserved[&bin_dir].contains("tool"));
assert_eq!(
std::fs::read_to_string(launcher).unwrap(),
"lifecycle replacement\n"
);
assert_eq!(
std::fs::read_to_string(sibling).unwrap(),
"generated sibling\n"
);
}
#[test]
fn managed_bin_cleanup_preserves_siblings_of_a_deleted_launcher() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let launcher = bin_dir.join("tool");
let sibling = bin_dir.join("tool.cmd");
std::fs::write(&launcher, "generated launcher\n").unwrap();
std::fs::write(&sibling, "generated sibling\n").unwrap();
let mut expected_files = BTreeMap::new();
expected_files.insert(
launcher.clone(),
read_managed_bin_entry(&launcher).unwrap().unwrap(),
);
expected_files.insert(
sibling.clone(),
read_managed_bin_entry(&sibling).unwrap().unwrap(),
);
let mut commands = BTreeMap::new();
commands.insert("tool".to_string(), expected_files);
let mut managed = ManagedBinLinks::capturing();
managed.entries.insert(bin_dir.clone(), commands);
std::fs::remove_file(&launcher).unwrap();
let preserved = remove_managed_bin_links(&managed).unwrap();
let mut relinked = ManagedBinLinks::default();
create_bin_link(
&bin_dir,
"tool",
dir.path().join("target.js").as_path(),
Default::default(),
&mut relinked,
Some(&preserved),
)
.unwrap();
remove_unclaimed_preserved_bin_links(&managed, &preserved, &relinked).unwrap();
assert!(preserved[&bin_dir].contains("tool"));
assert!(!launcher.exists());
assert_eq!(
std::fs::read_to_string(sibling).unwrap(),
"generated sibling\n"
);
}
#[test]
fn post_lifecycle_relink_removes_deleted_bin_declaration() {
let dir = tempfile::tempdir().unwrap();
let project_dir = dir.path();
let aube_dir = project_dir.join("node_modules/.aube");
let dep_path = "removes-bin@1.0.0";
let pkg_dir = materialized_pkg_dir(&aube_dir, dep_path, "removes-bin", 120, None);
std::fs::create_dir_all(&pkg_dir).unwrap();
std::fs::write(pkg_dir.join("cli.js"), "#!/usr/bin/env node\n").unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"removes-bin","version":"1.0.0","bin":{"removed-bin":"cli.js"}}"#,
)
.unwrap();
let mut packages = BTreeMap::new();
packages.insert(
dep_path.to_string(),
locked("removes-bin", "1.0.0", BTreeMap::new()),
);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "removes-bin".to_string(),
dep_path: dep_path.to_string(),
dep_type: DepType::Production,
specifier: Some("1.0.0".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
let opts = aube_linker::BinShimOptions {
prefer_symlinked_executables: Some(false),
..Default::default()
};
let mut managed = ManagedBinLinks::capturing();
link_bins(
project_dir,
"node_modules",
&aube_dir,
&graph,
120,
None,
opts,
&mut PkgJsonCache::new(),
None,
&mut WsPkgJsonCache::new(),
&mut managed,
None,
)
.unwrap();
let shim = project_dir.join("node_modules/.bin/removed-bin");
assert!(shim.exists());
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"removes-bin","version":"1.0.0"}"#,
)
.unwrap();
std::fs::remove_file(&shim).unwrap();
let preserved = remove_managed_bin_links(&managed).unwrap();
let mut relinked = ManagedBinLinks::default();
link_bins(
project_dir,
"node_modules",
&aube_dir,
&graph,
120,
None,
opts,
&mut PkgJsonCache::new(),
None,
&mut WsPkgJsonCache::new(),
&mut relinked,
Some(&preserved),
)
.unwrap();
remove_unclaimed_preserved_bin_links(&managed, &preserved, &relinked).unwrap();
assert!(!shim.exists());
}
#[test]
fn link_bins_reads_manifest_when_lockfile_metadata_is_mixed() {
let dir = tempfile::tempdir().unwrap();
let project_dir = dir.path();
let aube_dir = project_dir.join("node_modules/.aube");
let dep_path = "vitepress@1.6.4";
let pkg_dir = materialized_pkg_dir(&aube_dir, dep_path, "vitepress", 120, None);
std::fs::create_dir_all(pkg_dir.join("bin")).unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"vitepress","bin":{"vitepress":"bin/vitepress.js"}}"#,
)
.unwrap();
std::fs::write(pkg_dir.join("bin/vitepress.js"), "#!/usr/bin/env node\n").unwrap();
let mut semver_bin = BTreeMap::new();
semver_bin.insert("semver".to_string(), "bin/semver.js".to_string());
let mut packages = BTreeMap::new();
packages.insert(
dep_path.to_string(),
locked("vitepress", "1.6.4", BTreeMap::new()),
);
packages.insert(
"semver@7.7.4".to_string(),
locked("semver", "7.7.4", semver_bin),
);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "vitepress".to_string(),
dep_path: dep_path.to_string(),
dep_type: DepType::Dev,
specifier: Some("^1.5.0".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
link_bins(
project_dir,
"node_modules",
&aube_dir,
&graph,
120,
None,
aube_linker::BinShimOptions::default(),
&mut PkgJsonCache::new(),
None,
&mut WsPkgJsonCache::new(),
&mut ManagedBinLinks::default(),
None,
)
.unwrap();
assert!(project_dir.join("node_modules/.bin/vitepress").exists());
}
#[cfg(windows)]
#[test]
fn create_bin_link_surface_relative_path_when_dep_dir_is_a_junction() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let aube_dir = project.join("node_modules/.aube");
std::fs::create_dir_all(&aube_dir).unwrap();
let gvs = project.join("gvs");
let gvs_dep = gvs.join("node-liblzma@2.2.0/node_modules");
std::fs::create_dir_all(&gvs_dep).unwrap();
aube_linker::create_dir_link(
&gvs.join("node-liblzma@2.2.0"),
&aube_dir.join("node-liblzma@2.2.0"),
)
.unwrap();
let target_pkg = aube_dir.join("prebuild-install@7.1.3/node_modules/prebuild-install");
std::fs::create_dir_all(&target_pkg).unwrap();
let target = target_pkg.join("bin.js");
std::fs::write(&target, "#!/usr/bin/env node\n").unwrap();
let bin_dir = aube_dir.join("node-liblzma@2.2.0/node_modules/.bin");
create_bin_link(
&bin_dir,
"prebuild-install",
&target,
aube_linker::BinShimOptions::default(),
&mut ManagedBinLinks::default(),
None,
)
.unwrap();
let cmd = std::fs::read_to_string(bin_dir.join("prebuild-install.cmd")).unwrap();
let expected = r"..\..\..\prebuild-install@7.1.3\node_modules\prebuild-install\bin.js";
assert!(
cmd.contains(expected),
".cmd shim should embed surface-tree relative path `{expected}`; got:\n{cmd}"
);
assert!(
!cmd.contains(r"..\..\..\..\"),
".cmd shim should not climb above the `.aube/` root; got:\n{cmd}"
);
}
#[cfg(windows)]
#[test]
fn create_bin_link_creates_scoped_parent_when_dep_dir_is_a_junction() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path();
let aube_dir = project.join("node_modules/.aube");
std::fs::create_dir_all(&aube_dir).unwrap();
let gvs = project.join("gvs");
let gvs_dep = gvs.join("node-liblzma@2.2.0/node_modules");
std::fs::create_dir_all(&gvs_dep).unwrap();
aube_linker::create_dir_link(
&gvs.join("node-liblzma@2.2.0"),
&aube_dir.join("node-liblzma@2.2.0"),
)
.unwrap();
let target_pkg = aube_dir.join("@scope+tool@1.0.0/node_modules/@scope/tool");
std::fs::create_dir_all(&target_pkg).unwrap();
let target = target_pkg.join("cli.js");
std::fs::write(&target, "#!/usr/bin/env node\n").unwrap();
let bin_dir = aube_dir.join("node-liblzma@2.2.0/node_modules/.bin");
create_bin_link(
&bin_dir,
"@scope/tool",
&target,
aube_linker::BinShimOptions::default(),
&mut ManagedBinLinks::default(),
None,
)
.unwrap();
assert!(
bin_dir.join("@scope").is_dir(),
"scoped parent `@scope/` should be pre-created through the junction"
);
let cmd = std::fs::read_to_string(bin_dir.join("@scope/tool.cmd")).unwrap();
let expected = r"..\..\..\..\@scope+tool@1.0.0\node_modules\@scope\tool\cli.js";
assert!(
cmd.contains(expected),
".cmd shim should embed surface-tree relative path `{expected}`; got:\n{cmd}"
);
assert!(
!cmd.contains(r"..\..\..\..\..\"),
".cmd shim should not climb above the `.aube/` root; got:\n{cmd}"
);
}
}