diff --git a/crates/aube-store/src/index.rs b/crates/aube-store/src/index.rs index e049629c2..5219ce720 100644 --- a/crates/aube-store/src/index.rs +++ b/crates/aube-store/src/index.rs @@ -11,6 +11,7 @@ pub struct StoredFile { /// The hex hash of the file content. pub hex_hash: String, /// The path within the store. + #[serde(with = "stored_path")] pub store_path: PathBuf, /// Whether the file is executable. pub executable: bool, @@ -19,6 +20,86 @@ pub struct StoredFile { pub size: Option, } +mod stored_path { + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + use std::path::{Path, PathBuf}; + + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + enum NativePath { + UnixBytes(Vec), + WindowsWide(Vec), + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum StoredPath { + Utf8(String), + Native(NativePath), + } + + pub(super) fn serialize(path: &Path, serializer: S) -> Result + where + S: Serializer, + { + if let Some(path) = path.to_str() { + return serializer.serialize_str(path); + } + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + NativePath::UnixBytes(path.as_os_str().as_bytes().to_vec()).serialize(serializer) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + NativePath::WindowsWide(path.as_os_str().encode_wide().collect()).serialize(serializer) + } + #[cfg(not(any(unix, windows)))] + { + Err(serde::ser::Error::custom( + "path contains characters unsupported by this platform", + )) + } + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match StoredPath::deserialize(deserializer)? { + StoredPath::Utf8(path) => Ok(PathBuf::from(path)), + StoredPath::Native(NativePath::UnixBytes(bytes)) => { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes))) + } + #[cfg(not(unix))] + { + let _ = bytes; + Err(de::Error::custom("Unix path cache read on a non-Unix host")) + } + } + StoredPath::Native(NativePath::WindowsWide(wide)) => { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + Ok(PathBuf::from(std::ffi::OsString::from_wide(&wide))) + } + #[cfg(not(windows))] + { + let _ = wide; + Err(de::Error::custom( + "Windows path cache read on a non-Windows host", + )) + } + } + } + } +} + /// Index of all files in a package, keyed by relative path within the package. /// /// Backed by `FxMap` (foldhash) rather than `BTreeMap`: the linker @@ -228,3 +309,27 @@ impl Store { } } } + +#[cfg(all(test, unix))] +mod tests { + use super::StoredFile; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn stored_file_round_trips_non_utf8_store_path() { + let stored = StoredFile { + hex_hash: "abc123".into(), + store_path: std::path::PathBuf::from(std::ffi::OsString::from_vec( + b"/store/path-\xff".to_vec(), + )), + executable: false, + size: Some(3), + }; + + let json = serde_json::to_string(&stored).unwrap(); + let decoded: StoredFile = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.store_path, stored.store_path); + assert!(json.contains("unixBytes")); + } +} diff --git a/crates/aube/src/commands/add/filtered.rs b/crates/aube/src/commands/add/filtered.rs index cb16f9264..956529a1e 100644 --- a/crates/aube/src/commands/add/filtered.rs +++ b/crates/aube/src/commands/add/filtered.rs @@ -44,6 +44,7 @@ pub(super) async fn run( &args.packages, args.allow_low_downloads, crate::commands::add_supply_chain::LowDownloadPrompt::Terminal, + &[], ) .await?; diff --git a/crates/aube/src/commands/add/manifest.rs b/crates/aube/src/commands/add/manifest.rs index 22104abc2..924680261 100644 --- a/crates/aube/src/commands/add/manifest.rs +++ b/crates/aube/src/commands/add/manifest.rs @@ -26,6 +26,8 @@ pub(super) struct AddManifestOptions { /// forces a registry-style spec even when `linkWorkspacePackages` /// matched a sibling. pub(super) workspace_protocol_override: Option, + /// Highest-precedence settings supplied by an embedding host. + pub(super) setting_overrides: Vec<(String, String)>, } impl AddManifestOptions { @@ -47,6 +49,7 @@ impl AddManifestOptions { args.save_workspace_protocol, args.no_save_workspace_protocol, ), + setting_overrides: Vec::new(), } } } @@ -89,43 +92,50 @@ pub(super) async fn update_manifest_for_add( // own dir so a sub-project's `.npmrc` still wins — switching the // entire context to the workspace root would silently drop those // overrides, since `load_npmrc_entries` doesn't walk up. - let (default_tag, default_prefix, catalog_mode, minimum_release_age, registry_supports_time) = - 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); - // Validate: only ^, ~, or empty are valid prefixes. - 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 ( + default_tag, + default_prefix, + catalog_mode, + minimum_release_age, + registry_supports_time, + cache_dir, + ) = crate::commands::with_settings_ctx_and_cli(cwd, &opts.setting_overrides, |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); + // Validate: only ^, ~, or empty are valid prefixes. + 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); - // The version this function writes into the manifest must - // honor `minimumReleaseAge` the same way full resolution - // does: dist-tag adds and `--save-exact` pin a concrete - // version here, and a pinned fresh version would sail past - // the resolver's gate via its lenient exact-range fallback. - let minimum_release_age = - crate::commands::install::resolve_minimum_release_age(ctx, None); - let registry_supports_time_field = - aube_settings::resolved::registry_supports_time_field(ctx); - ( - tag, - prefix, - catalog_mode, - minimum_release_age, - registry_supports_time_field, - ) - }); + } + }; + let catalog_mode = aube_settings::resolved::catalog_mode(ctx); + // The version this function writes into the manifest must + // honor `minimumReleaseAge` the same way full resolution + // does: dist-tag adds and `--save-exact` pin a concrete + // version here, and a pinned fresh version would sail past + // the resolver's gate via its lenient exact-range fallback. + let minimum_release_age = crate::commands::install::resolve_minimum_release_age(ctx, None); + let registry_supports_time_field = + aube_settings::resolved::registry_supports_time_field(ctx); + let cache_dir = crate::commands::resolved_cache_dir_with_ctx(cwd, ctx); + ( + tag, + prefix, + catalog_mode, + minimum_release_age, + registry_supports_time_field, + cache_dir, + ) + }); 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()); @@ -247,8 +257,8 @@ pub(super) async fn update_manifest_for_add( // `registrySupportsTimeField` keeps the cheaper abbreviated path hot // when the registry inlines `time` in corgi payloads. let needs_time = minimum_release_age.is_some() && !registry_supports_time; - let corgi_cache_dir = crate::commands::packument_cache_dir_for_cwd(cwd); - let full_cache_dir = crate::commands::packument_full_cache_dir_for_cwd(cwd); + let corgi_cache_dir = cache_dir.join("packuments-v1"); + let full_cache_dir = cache_dir.join("packuments-full-v1"); let offline = opts.network_mode == aube_registry::NetworkMode::Offline; for spec in &parsed { if aube_util::pkg::is_workspace_spec(&spec.range) @@ -1050,6 +1060,7 @@ mod tests { network_mode: aube_registry::NetworkMode::Offline, save_catalog: None, workspace_protocol_override: None, + setting_overrides: Vec::new(), }, false, ) @@ -1108,6 +1119,7 @@ mod tests { network_mode: aube_registry::NetworkMode::Offline, save_catalog: None, workspace_protocol_override: None, + setting_overrides: Vec::new(), }, false, ) diff --git a/crates/aube/src/commands/add/mod.rs b/crates/aube/src/commands/add/mod.rs index 9a4b71839..62f5a7c48 100644 --- a/crates/aube/src/commands/add/mod.rs +++ b/crates/aube/src/commands/add/mod.rs @@ -66,6 +66,21 @@ pub async fn add_to_project( project_dir: &std::path::Path, packages: &[String], options: AddToProjectOptions, +) -> miette::Result<()> { + add_to_project_with_overrides( + project_dir, + packages, + options, + install::EmbedderInstallOverrides::default(), + ) + .await +} + +pub(crate) async fn add_to_project_with_overrides( + project_dir: &std::path::Path, + packages: &[String], + options: AddToProjectOptions, + overrides: install::EmbedderInstallOverrides, ) -> miette::Result<()> { if packages.is_empty() { return Ok(()); @@ -75,6 +90,8 @@ pub async fn add_to_project( // Serialize on the same root lock as the CLI add path. let lock = super::take_install_project_lock(project_dir)?; options.control.check_cancelled()?; + let mut setting_overrides = Vec::new(); + overrides.append_to(&mut setting_overrides); let manifest_path = project_dir.join("package.json"); let original_manifest = std::fs::read(&manifest_path) .into_diagnostic() @@ -95,6 +112,7 @@ pub async fn add_to_project( crate::commands::add_supply_chain::LowDownloadPrompt::Host( options.control.clone(), ), + &setting_overrides, ) .await?; } @@ -113,6 +131,7 @@ pub async fn add_to_project( }, save_catalog: None, workspace_protocol_override: None, + setting_overrides: setting_overrides.clone(), }, false, ) @@ -138,6 +157,7 @@ pub async fn add_to_project( ); install_options.control = options.control; install_options.embedder_runtime = options.runtime; + install_options.cli_flags.extend(setting_overrides); if options.offline { install_options.network_mode = aube_registry::NetworkMode::Offline; } @@ -528,6 +548,7 @@ pub async fn run( packages, allow_low_downloads, crate::commands::add_supply_chain::LowDownloadPrompt::Terminal, + &[], ) .await?; @@ -545,6 +566,7 @@ pub async fn run( save_workspace_protocol, no_save_workspace_protocol, ), + setting_overrides: Vec::new(), }, !no_save, ) diff --git a/crates/aube/src/commands/add/supply_chain.rs b/crates/aube/src/commands/add/supply_chain.rs index 278e6ebe5..79e58c356 100644 --- a/crates/aube/src/commands/add/supply_chain.rs +++ b/crates/aube/src/commands/add/supply_chain.rs @@ -8,6 +8,7 @@ pub(super) async fn run_cli_name_gates( packages: &[String], allow_low_downloads: bool, prompt: crate::commands::add_supply_chain::LowDownloadPrompt, + setting_overrides: &[(String, String)], ) -> miette::Result<()> { let project_dir = supply_chain_project_dir(cwd); let manifest = crate::commands::load_manifest_or_default(&project_dir)?; @@ -18,7 +19,8 @@ pub(super) async fn run_cli_name_gates( minimum_package_age_minutes, mut allowed_unpopular, lockfile_dir, - ) = crate::commands::with_settings_ctx(&project_dir, |ctx| { + cache_dir, + ) = crate::commands::with_settings_ctx_and_cli(&project_dir, setting_overrides, |ctx| { let policy = if aube_settings::resolved::paranoid(ctx) { aube_settings::resolved::AdvisoryCheck::Required } else { @@ -30,6 +32,7 @@ pub(super) async fn run_cli_name_gates( aube_settings::resolved::minimum_package_age(ctx), aube_settings::resolved::allowed_unpopular_packages(ctx).unwrap_or_default(), crate::commands::install::resolve_active_lockfile_dir(&project_dir, &manifest, ctx)?, + crate::commands::resolved_cache_dir_with_ctx(&project_dir, ctx), )) })?; let locked_registry_names = locked_registry_names(&lockfile_dir, &manifest); @@ -39,7 +42,7 @@ pub(super) async fn run_cli_name_gates( .map(|name| glob::Pattern::escape(name)), ); let registry_client = crate::commands::make_client(&project_dir); - let full_packument_cache = crate::commands::packument_full_cache_dir_for_cwd(&project_dir); + let full_packument_cache = cache_dir.join("packuments-full-v1"); crate::commands::add_supply_chain::run_gates( ®istry_inputs.name_only_advisory_names, ®istry_inputs.exact_advisory_pairs, diff --git a/crates/aube/src/commands/install/args.rs b/crates/aube/src/commands/install/args.rs index 8e1442a4d..bec5d2a6f 100644 --- a/crates/aube/src/commands/install/args.rs +++ b/crates/aube/src/commands/install/args.rs @@ -316,6 +316,31 @@ impl InstallArgs { } } +/// Host-owned setting overrides for one embedded install. +/// +/// The materialization toggle is injected at explicit-command precedence. +/// Storage paths remain native `PathBuf`s in an invocation task-local so Unix +/// paths do not lose filesystem identity. Standalone CLI commands leave this +/// empty. +#[derive(Debug, Clone, Default)] +pub struct EmbedderInstallOverrides { + /// Use aube's shared global virtual store. `Some(false)` materializes + /// packages inside the project so the host owns their complete lifecycle. + pub use_global_virtual_store: Option, + /// Directory for regenerable metadata caches. + pub cache_dir: Option, + /// Root of the content-addressable package store. + pub store_dir: Option, +} + +impl EmbedderInstallOverrides { + pub(crate) fn append_to(&self, settings: &mut Vec<(String, String)>) { + if let Some(enabled) = self.use_global_virtual_store { + settings.push(("enableGlobalVirtualStore".to_string(), enabled.to_string())); + } + } +} + /// Aggregated options for `install::run`. Grouped into a struct so we can add /// more flags (`--no-optional`, `--offline`, etc.) without changing every caller. #[derive(Debug, Clone)] @@ -521,3 +546,51 @@ impl From for InstallOptions { Self::with_mode(mode) } } + +#[cfg(test)] +mod embedder_override_tests { + use super::EmbedderInstallOverrides; + + #[test] + fn overrides_beat_environment_and_project_settings() { + let cache_dir = std::path::PathBuf::from("/host/cache"); + let store_dir = std::path::PathBuf::from("/host/store"); + let overrides = EmbedderInstallOverrides { + use_global_virtual_store: Some(false), + cache_dir: Some(cache_dir.clone()), + store_dir: Some(store_dir.clone()), + }; + let mut cli = Vec::new(); + overrides.append_to(&mut cli); + let env = vec![ + ( + "npm_config_enable_global_virtual_store".to_string(), + "true".to_string(), + ), + ("npm_config_cache_dir".to_string(), "/env/cache".to_string()), + ("npm_config_store_dir".to_string(), "/env/store".to_string()), + ]; + let npmrc = vec![ + ("enableGlobalVirtualStore".to_string(), "true".to_string()), + ("cacheDir".to_string(), "/project/cache".to_string()), + ("storeDir".to_string(), "/project/store".to_string()), + ]; + let workspace = std::collections::BTreeMap::new(); + let ctx = aube_settings::ResolveCtx { + managed_aube_config: &[], + project_aube_config: &[], + project_npmrc: &npmrc, + user_aube_config: &[], + user_npmrc: &[], + workspace_yaml: &workspace, + env: &env, + cli: &cli, + embedder_defaults: &[], + }; + + assert_eq!( + aube_settings::resolved::enable_global_virtual_store(&ctx), + Some(false) + ); + } +} diff --git a/crates/aube/src/commands/install/fetch.rs b/crates/aube/src/commands/install/fetch.rs index 060ff31df..cb5ff304b 100644 --- a/crates/aube/src/commands/install/fetch.rs +++ b/crates/aube/src/commands/install/fetch.rs @@ -424,6 +424,7 @@ pub(in crate::commands) async fn fetch_packages( progress, &cwd, &aube_dir, + &packument_cache_dir(), /*materialize_tx=*/ None, /*skip_already_linked_shortcut=*/ true, /*force_index_dep_paths=*/ &std::collections::BTreeSet::new(), @@ -460,6 +461,7 @@ pub(super) async fn fetch_packages_with_root( progress: Option<&InstallProgress>, project_root: &std::path::Path, aube_dir: &std::path::Path, + packument_cache_dir: &std::path::Path, // Some streams every successful (dep_path, index) so a concurrent // GVS-prewarm materializer can start reflinks before the full // batch finishes. None keeps batch-then-return for `aube fetch`. @@ -504,6 +506,7 @@ pub(super) async fn fetch_packages_with_root( where F: FnOnce() -> std::sync::Arc, { + let packument_cache_dir = packument_cache_dir.to_path_buf(); // No-op fast path: for every package whose per-project // `node_modules/.aube/` entry already resolves to an // existing target, skip the package-index load entirely. The @@ -788,6 +791,7 @@ where let sem = semaphore.clone(); let store = store.clone(); let client = client.clone(); + let packument_cache_dir = packument_cache_dir.clone(); let row = progress.map(|p| p.start_fetch(&display_name, &version)); let bytes_progress = progress.cloned(); @@ -805,8 +809,14 @@ where .clone() .unwrap_or_else(|| client.tarball_url(®istry_name, &version)); if let Some(lockfile_url) = tarball_url_override.as_deref() { - verify_lockfile_tarball_url(&client, ®istry_name, &version, lockfile_url) - .await?; + verify_lockfile_tarball_url( + &client, + ®istry_name, + &version, + lockfile_url, + &packument_cache_dir, + ) + .await?; } let dl_start = std::time::Instant::now(); @@ -1018,9 +1028,10 @@ async fn verify_lockfile_tarball_url( registry_name: &str, version: &str, lockfile_url: &str, + packument_cache_dir: &std::path::Path, ) -> miette::Result<()> { let packument = client - .fetch_packument_cached(registry_name, &packument_cache_dir()) + .fetch_packument_cached(registry_name, packument_cache_dir) .await .map_err(|e| { miette!( diff --git a/crates/aube/src/commands/install/gvs.rs b/crates/aube/src/commands/install/gvs.rs index d23e017e3..c348dc1fe 100644 --- a/crates/aube/src/commands/install/gvs.rs +++ b/crates/aube/src/commands/install/gvs.rs @@ -499,8 +499,10 @@ pub(super) fn reset_on_mode_change( aube_dir: &Path, modules_dir_name: &str, planned_gvs: bool, + settings_ctx: &aube_settings::ResolveCtx<'_>, ) -> miette::Result<()> { - let global_virtual_store = crate::commands::global_virtual_store_dir(cwd); + let global_virtual_store = + crate::commands::global_virtual_store_dir_with_ctx(cwd, settings_ctx); let Some(existing_gvs) = detect_existing_global_virtual_store( cwd, aube_dir, diff --git a/crates/aube/src/commands/install/lifecycle.rs b/crates/aube/src/commands/install/lifecycle.rs index 25d9824b4..43a3aa822 100644 --- a/crates/aube/src/commands/install/lifecycle.rs +++ b/crates/aube/src/commands/install/lifecycle.rs @@ -296,7 +296,7 @@ pub(super) fn resolve_link_strategy( // handle. `open_store` performs lockfile + IO work; a second // call to fetch `virtual_store_dir` would repeat that on the // hot path of every `auto`-mode install. - let store = super::super::open_store(cwd).ok(); + let store = super::super::open_store_with_ctx(cwd, ctx).ok(); let store_dir = store.as_ref().map(|s| s.root().to_path_buf()); // Probe against the GVS dir when GVS is on. The GVS dir won't // exist yet on a cold install, so create it before the probe diff --git a/crates/aube/src/commands/install/mod.rs b/crates/aube/src/commands/install/mod.rs index 8e6aeb8b2..0fcbcd9d5 100644 --- a/crates/aube/src/commands/install/mod.rs +++ b/crates/aube/src/commands/install/mod.rs @@ -33,7 +33,7 @@ mod unreviewed_builds; mod workspace; use advisory::resolve_osv_routing_settings; -pub use args::{InstallArgs, InstallOptions}; +pub use args::{EmbedderInstallOverrides, InstallArgs, InstallOptions}; pub(crate) use bin_linking::{PkgJsonCache, link_dep_bins, materialized_pkg_dir}; pub use control::{ InstallControl, InstallEvent, InstallOutputLevel, InstallOutputMode, InstallPhase, @@ -271,6 +271,7 @@ fn apply_computed_integrities( async fn validate_lockfile_trust_policy( cwd: &std::path::Path, + settings_ctx: &aube_settings::ResolveCtx<'_>, graph: &aube_lockfile::LockfileGraph, network_mode: aube_registry::NetworkMode, policy: &aube_resolver::DependencyPolicy, @@ -279,13 +280,14 @@ async fn validate_lockfile_trust_policy( else { return Ok(()); }; - if trust_policy_validation_cache_hit(cwd, &cache_key) { + let cache_dir = super::resolved_cache_dir_with_ctx(cwd, settings_ctx); + if trust_policy_validation_cache_hit(&cache_dir, &cache_key) { tracing::debug!("trustPolicy=no-downgrade: reused lockfile validation cache"); return Ok(()); } let client = std::sync::Arc::new(make_client(cwd).with_network_mode(network_mode)); - let full_cache_dir = super::packument_full_cache_dir(); + let full_cache_dir = cache_dir.join("packuments-full-v1"); let concurrency = default_lockfile_network_concurrency().max(1); let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency)); let mut seen = std::collections::BTreeSet::new(); @@ -341,7 +343,7 @@ async fn validate_lockfile_trust_policy( result.map_err(miette::Report::new)?; } - record_lockfile_trust_policy_validation(cwd, &cache_key); + record_lockfile_trust_policy_validation(&cache_dir, &cache_key); Ok(()) } @@ -410,14 +412,17 @@ fn trust_policy_validation_cache_key( Some(hasher.finalize().to_hex().to_string()) } -fn trust_policy_validation_cache_path(cwd: &std::path::Path, key: &str) -> std::path::PathBuf { - super::resolved_cache_dir(cwd) +fn trust_policy_validation_cache_path( + cache_dir: &std::path::Path, + key: &str, +) -> std::path::PathBuf { + cache_dir .join(TRUST_POLICY_VALIDATION_CACHE_DIR) .join(format!("{key}.json")) } -fn trust_policy_validation_cache_hit(cwd: &std::path::Path, key: &str) -> bool { - let path = trust_policy_validation_cache_path(cwd, key); +fn trust_policy_validation_cache_hit(cache_dir: &std::path::Path, key: &str) -> bool { + let path = trust_policy_validation_cache_path(cache_dir, key); let Ok(bytes) = std::fs::read(path) else { return false; }; @@ -436,7 +441,7 @@ fn trust_policy_validation_cache_hit(cwd: &std::path::Path, key: &str) -> bool { age_secs <= TRUST_POLICY_VALIDATION_CACHE_TTL.as_secs() } -fn record_lockfile_trust_policy_validation(cwd: &std::path::Path, cache_key: &str) { +fn record_lockfile_trust_policy_validation(cache_dir: &std::path::Path, cache_key: &str) { let Some(validated_at_secs) = unix_time_secs() else { return; }; @@ -447,7 +452,7 @@ fn record_lockfile_trust_policy_validation(cwd: &std::path::Path, cache_key: &st let Ok(bytes) = serde_json::to_vec(&stamp) else { return; }; - let path = trust_policy_validation_cache_path(cwd, cache_key); + let path = trust_policy_validation_cache_path(cache_dir, cache_key); if let Err(e) = aube_util::fs_atomic::atomic_write(&path, &bytes) { tracing::debug!("failed to write trust-policy validation cache: {e}"); } @@ -455,12 +460,14 @@ fn record_lockfile_trust_policy_validation(cwd: &std::path::Path, cache_key: &st fn maybe_record_lockfile_trust_policy_validation( cwd: &std::path::Path, + settings_ctx: &aube_settings::ResolveCtx<'_>, graph: &aube_lockfile::LockfileGraph, network_mode: aube_registry::NetworkMode, policy: &aube_resolver::DependencyPolicy, ) { if let Some(cache_key) = trust_policy_validation_cache_key(cwd, graph, network_mode, policy) { - record_lockfile_trust_policy_validation(cwd, &cache_key); + let cache_dir = super::resolved_cache_dir_with_ctx(cwd, settings_ctx); + record_lockfile_trust_policy_validation(&cache_dir, &cache_key); } } @@ -591,6 +598,9 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res // order. let workspace_catalogs = super::discover_catalogs(&cwd)?; let settings_ctx = files.ctx(&raw_workspace, &opts.env_snapshot, &opts.cli_flags); + let packument_cache_dir = + super::resolved_cache_dir_with_ctx(&cwd, &settings_ctx).join("packuments-v1"); + let explicit_store_dir_override = has_explicit_store_dir_override(&opts.cli_flags); let dependency_policy = resolve_dependency_policy(&manifest, &settings_ctx); // Resolve the project's Node runtime before anything can spawn // node: the root `preinstall` hooks below must already run on the @@ -929,11 +939,17 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res let planned_gvs = gvs::planned_global_virtual_store(use_global_virtual_store_override, &opts.env_snapshot); - gvs::reset_on_mode_change(&cwd, &aube_dir, &modules_dir_name, planned_gvs)?; + gvs::reset_on_mode_change( + &cwd, + &aube_dir, + &modules_dir_name, + planned_gvs, + &settings_ctx, + )?; // 3. Parse or resolve lockfile, streaming tarball fetches during resolution let phase_start = std::time::Instant::now(); - let store = std::sync::Arc::new(super::open_store(&cwd)?); + let store = std::sync::Arc::new(super::open_store_with_ctx(&cwd, &settings_ctx)?); // Pre-create all 256 two-char shard directories in the CAS root. // `import_bytes` is called once per stored file (~7.5k for a medium // install) and previously did `mkdirp(parent)` per call — a stat @@ -1071,8 +1087,14 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res &ws_config_shared, &settings_ctx, )?; - validate_lockfile_trust_policy(&cwd, &graph, opts.network_mode, &dependency_policy) - .await?; + validate_lockfile_trust_policy( + &cwd, + &settings_ctx, + &graph, + opts.network_mode, + &dependency_policy, + ) + .await?; control::check_cancelled()?; let source_label = resolve::lockfile_source_label(kind); tracing::debug!( @@ -1174,8 +1196,10 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res prog_ref, &cwd, &aube_dir, + &packument_cache_dir, Some(lock_materialize_tx), - /*skip_already_linked_shortcut=*/ has_workspace, + /*skip_already_linked_shortcut=*/ + has_workspace || explicit_store_dir_override, &lock_project_local_dep_paths, virtual_store_dir_max_length, opts.ignore_scripts, @@ -2218,8 +2242,10 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res prog_ref, &cwd, &aube_dir, + &packument_cache_dir, /*materialize_tx=*/ None, - /*skip_already_linked_shortcut=*/ has_workspace, + /*skip_already_linked_shortcut=*/ + has_workspace || explicit_store_dir_override, &project_local_dep_paths, virtual_store_dir_max_length, opts.ignore_scripts, @@ -2500,6 +2526,7 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res ) { maybe_record_lockfile_trust_policy_validation( &cwd, + &settings_ctx, &graph, opts.network_mode, &dependency_policy, @@ -2508,6 +2535,11 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res Ok(()) } +fn has_explicit_store_dir_override(cli_flags: &[(String, String)]) -> bool { + super::has_embedder_store_override() + || aube_settings::values::string_from_cli("storeDir", cli_flags).is_some() +} + /// Run pnpm's root-only pre-resolution hook from the workspace/lockfile root. /// /// Kept as a command-level boundary because `update` resolves before chaining @@ -2750,3 +2782,24 @@ mod computed_integrity_tests { ); } } + +#[cfg(test)] +mod explicit_store_dir_override_tests { + use super::has_explicit_store_dir_override; + + #[test] + fn recognizes_canonical_and_kebab_case_cli_keys() { + assert!(has_explicit_store_dir_override(&[( + "storeDir".into(), + "/store".into(), + )])); + assert!(has_explicit_store_dir_override(&[( + "store-dir".into(), + "/store".into(), + )])); + assert!(!has_explicit_store_dir_override(&[( + "cache-dir".into(), + "/cache".into(), + )])); + } +} diff --git a/crates/aube/src/commands/install/settings.rs b/crates/aube/src/commands/install/settings.rs index 1dd6552e2..726c498b1 100644 --- a/crates/aube/src/commands/install/settings.rs +++ b/crates/aube/src/commands/install/settings.rs @@ -1,4 +1,3 @@ -use super::super::{packument_cache_dir, packument_full_cache_dir}; use super::version_from_dep_path; use miette::{Context, IntoDiagnostic, miette}; use std::collections::BTreeMap; @@ -958,11 +957,12 @@ pub(crate) fn configure_resolver( } let git_shallow_hosts = resolve_git_shallow_hosts(settings_ctx); let packument_concurrency = resolve_network_concurrency(settings_ctx); + let cache_dir = crate::commands::resolved_cache_dir_with_ctx(cwd, settings_ctx); let mut resolver = resolver .with_packument_network_concurrency(packument_concurrency) - .with_packument_cache(packument_cache_dir()); + .with_packument_cache(cache_dir.join("packuments-v1")); if cache_full_packuments { - resolver = resolver.with_packument_full_cache(packument_full_cache_dir()); + resolver = resolver.with_packument_full_cache(cache_dir.join("packuments-full-v1")); } let mut resolver = resolver .with_auto_install_peers(auto_install_peers) diff --git a/crates/aube/src/commands/install/startup.rs b/crates/aube/src/commands/install/startup.rs index cdc285839..92787213c 100644 --- a/crates/aube/src/commands/install/startup.rs +++ b/crates/aube/src/commands/install/startup.rs @@ -92,7 +92,7 @@ fn install_fast_path_eligible( // consulted), leaving `aube install -v` silent on repeat-install loops // that originate from state drift rather than lockfile drift. match state::check_needs_install_with_flags(cwd, &opts.cli_flags) { - None => compatibility_metadata_is_current(cwd), + None => compatibility_metadata_is_current(cwd, opts), Some(reason) => { tracing::debug!("install warm path skipped: {reason}"); false @@ -100,7 +100,7 @@ fn install_fast_path_eligible( } } -fn compatibility_metadata_is_current(cwd: &Path) -> bool { +fn compatibility_metadata_is_current(cwd: &Path, opts: &InstallOptions) -> bool { let Some(layout) = state::read_state_layout(cwd) else { return false; }; @@ -110,7 +110,10 @@ fn compatibility_metadata_is_current(cwd: &Path) -> bool { let expected = match layout.linker { state::InstallLayoutMode::Hoisted => Some(aube_dir), state::InstallLayoutMode::Isolated => { - let global_virtual_store = super::super::global_virtual_store_dir(cwd); + let files = super::super::FileSources::load(cwd); + let raw_workspace = aube_manifest::workspace::load_raw(cwd).unwrap_or_default(); + let ctx = files.ctx(&raw_workspace, &opts.env_snapshot, &opts.cli_flags); + let global_virtual_store = super::super::global_virtual_store_dir_with_ctx(cwd, &ctx); match super::gvs::detect_existing_global_virtual_store( cwd, &aube_dir, diff --git a/crates/aube/src/commands/mod.rs b/crates/aube/src/commands/mod.rs index 44a5537c1..c246ebbd5 100644 --- a/crates/aube/src/commands/mod.rs +++ b/crates/aube/src/commands/mod.rs @@ -138,16 +138,18 @@ pub(crate) use script_settings::{configure_script_settings, configure_script_set pub(crate) use settings_context::{ FileSources, GlobalOutputFlags, build_resolver, chained_frozen_mode, ensure_registry_auth_for_package, expand_setting_path, global_frozen_override, - global_output_flags, global_virtual_store_dir, global_virtual_store_flags, load_npm_config, - make_client, open_store, packument_cache_dir, packument_cache_dir_for_cwd, + global_output_flags, global_virtual_store_dir, global_virtual_store_dir_with_ctx, + global_virtual_store_flags, has_embedder_store_override, load_npm_config, make_client, + open_store, open_store_with_ctx, packument_cache_dir, packument_cache_dir_for_cwd, packument_full_cache_dir, packument_full_cache_dir_for_cwd, project_modules_dir, resolve_fetch_policy, resolve_modules_dir_name_for_cwd, resolve_virtual_store_dir, resolve_virtual_store_dir_for_cwd, resolve_virtual_store_dir_max_length, - resolve_virtual_store_dir_max_length_for_cwd, resolved_cache_dir, resolved_store_dir, - run_pnpmfile_pre_resolution, set_fetch_cli_overrides, set_global_frozen_override, + resolve_virtual_store_dir_max_length_for_cwd, resolved_cache_dir, resolved_cache_dir_with_ctx, + resolved_store_dir, resolved_store_dir_with_ctx, run_pnpmfile_pre_resolution, + scope_embedder_install_overrides, set_fetch_cli_overrides, set_global_frozen_override, set_global_output_flags, set_global_virtual_store_flags, set_registry_override, set_skip_auto_install_on_package_manager_mismatch, - skip_auto_install_on_package_manager_mismatch, with_settings_ctx, + skip_auto_install_on_package_manager_mismatch, with_settings_ctx, with_settings_ctx_and_cli, }; pub(crate) use workspace_helpers::{ collect_dep_closure, find_workspace_root, finish_filtered_workspace, load_graph, diff --git a/crates/aube/src/commands/settings_context.rs b/crates/aube/src/commands/settings_context.rs index a7eac8fac..6a7b742f0 100644 --- a/crates/aube/src/commands/settings_context.rs +++ b/crates/aube/src/commands/settings_context.rs @@ -40,6 +40,43 @@ pub(crate) struct GlobalOutputFlags { static GLOBAL_OUTPUT: OnceLock = OnceLock::new(); +tokio::task_local! { + static EMBEDDER_INSTALL_OVERRIDES: install::EmbedderInstallOverrides; +} + +/// Scope native storage paths to one embedded add/install invocation. Keeping +/// them as `PathBuf`s avoids routing filesystem identity through UTF-8 setting +/// strings while preserving task isolation between concurrent embedders. +pub(crate) async fn scope_embedder_install_overrides( + overrides: install::EmbedderInstallOverrides, + future: F, +) -> F::Output { + EMBEDDER_INSTALL_OVERRIDES.scope(overrides, future).await +} + +pub(crate) fn has_embedder_store_override() -> bool { + EMBEDDER_INSTALL_OVERRIDES + .try_with(|overrides| overrides.store_dir.is_some()) + .unwrap_or(false) +} + +fn embedder_storage_path( + cwd: &std::path::Path, + select: impl FnOnce(&install::EmbedderInstallOverrides) -> Option<&std::path::PathBuf>, +) -> Option { + EMBEDDER_INSTALL_OVERRIDES + .try_with(|overrides| select(overrides).cloned()) + .ok() + .flatten() + .map(|path| { + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }) +} + pub(crate) fn set_registry_override(url: Option) { *REGISTRY_OVERRIDE.write().expect("registry lock poisoned") = url.map(|u| aube_registry::config::normalize_registry_url_pub(&u)); @@ -212,15 +249,27 @@ pub(crate) fn ensure_registry_auth_for_package( /// across versions of aube and never collides with a pnpm store rooted /// at the same path. pub(crate) fn open_store(cwd: &std::path::Path) -> miette::Result { - let root = match resolved_store_dir(cwd) { + with_settings_ctx(cwd, |ctx| open_store_with_ctx(cwd, ctx)) +} + +/// Open the content store using an already-resolved invocation context. +/// Embedded installs use this so their per-call overrides are not lost by a +/// second file/environment-only settings load. +pub(crate) fn open_store_with_ctx( + cwd: &std::path::Path, + ctx: &aube_settings::ResolveCtx<'_>, +) -> miette::Result { + let root = match resolved_store_dir_with_ctx(cwd, ctx) { Some(custom) => custom.join("v1").join("files"), None => aube_store::dirs::store_dir() .ok_or(aube_store::Error::NoHome) .into_diagnostic() .wrap_err("failed to open store")?, }; - Ok(aube_store::Store::with_dirs(root, resolved_cache_dir(cwd)) - .with_virtual_store_dir(global_virtual_store_dir(cwd))) + Ok( + aube_store::Store::with_dirs(root, resolved_cache_dir_with_ctx(cwd, ctx)) + .with_virtual_store_dir(global_virtual_store_dir_with_ctx(cwd, ctx)), + ) } /// Resolve the configured `storeDir` for `cwd`, returning `None` if @@ -231,10 +280,18 @@ pub(crate) fn open_store(cwd: &std::path::Path) -> miette::Result Option { - with_settings_ctx(cwd, |ctx| { - let raw = aube_settings::resolved::store_dir(ctx)?; - expand_setting_path(&raw, cwd) - }) + with_settings_ctx(cwd, |ctx| resolved_store_dir_with_ctx(cwd, ctx)) +} + +pub(crate) fn resolved_store_dir_with_ctx( + cwd: &std::path::Path, + ctx: &aube_settings::ResolveCtx<'_>, +) -> Option { + if let Some(path) = embedder_storage_path(cwd, |overrides| overrides.store_dir.as_ref()) { + return Some(path); + } + let raw = aube_settings::resolved::store_dir(ctx)?; + expand_setting_path(&raw, cwd) } /// Expand a path-typed setting value. `~` -> home dir, relative -> @@ -271,6 +328,14 @@ fn home_dir_os() -> Option { pub(crate) fn with_settings_ctx( cwd: &std::path::Path, f: impl FnOnce(&aube_settings::ResolveCtx<'_>) -> T, +) -> T { + with_settings_ctx_and_cli(cwd, &[], f) +} + +pub(crate) fn with_settings_ctx_and_cli( + cwd: &std::path::Path, + cli: &[(String, String)], + f: impl FnOnce(&aube_settings::ResolveCtx<'_>) -> T, ) -> T { let files = FileSources::load(cwd); let raw_workspace = aube_manifest::workspace::load_raw(cwd).unwrap_or_default(); @@ -279,7 +344,7 @@ pub(crate) fn with_settings_ctx( // builds a ResolveCtx (the typical path hits this 5+ times per // `aube run`). let env = aube_settings::values::process_env(); - let ctx = files.ctx(&raw_workspace, env, &[]); + let ctx = files.ctx(&raw_workspace, env, cli); f(&ctx) } @@ -442,12 +507,22 @@ pub(crate) fn resolve_fetch_policy(cwd: &std::path::Path) -> aube_registry::conf /// overrides it — the global virtual store hang off this path, so a /// user who moves the cache to another volume moves them together. pub(crate) fn resolved_cache_dir(cwd: &std::path::Path) -> std::path::PathBuf { + with_settings_ctx(cwd, |ctx| resolved_cache_dir_with_ctx(cwd, ctx)) +} + +pub(crate) fn resolved_cache_dir_with_ctx( + cwd: &std::path::Path, + ctx: &aube_settings::ResolveCtx<'_>, +) -> std::path::PathBuf { + if let Some(path) = embedder_storage_path(cwd, |overrides| overrides.cache_dir.as_ref()) { + return path; + } let platform_default = || aube_store::dirs::cache_dir().unwrap_or_else(|| std::env::temp_dir().join("aube")); - with_settings_ctx(cwd, |ctx| match aube_settings::resolved::cache_dir(ctx) { + match aube_settings::resolved::cache_dir(ctx) { Some(raw) => expand_setting_path(&raw, cwd).unwrap_or_else(platform_default), None => platform_default(), - }) + } } /// Absolute path of the global virtual store — the shared tree of @@ -466,11 +541,18 @@ pub(crate) fn resolved_cache_dir(cwd: &std::path::Path) -> std::path::PathBuf { /// relocated store makes them look at an empty directory and silently /// re-materialize. pub(crate) fn global_virtual_store_dir(cwd: &std::path::Path) -> std::path::PathBuf { - let from_setting = with_settings_ctx(cwd, |ctx| { - let raw = aube_settings::resolved::global_virtual_store_dir(ctx)?; - expand_setting_path(&raw, cwd) - }); - from_setting.unwrap_or_else(|| resolved_cache_dir(cwd).join(aube_store::VIRTUAL_STORE_SUBDIR)) + with_settings_ctx(cwd, |ctx| global_virtual_store_dir_with_ctx(cwd, ctx)) +} + +pub(crate) fn global_virtual_store_dir_with_ctx( + cwd: &std::path::Path, + ctx: &aube_settings::ResolveCtx<'_>, +) -> std::path::PathBuf { + aube_settings::resolved::global_virtual_store_dir(ctx) + .and_then(|raw| expand_setting_path(&raw, cwd)) + .unwrap_or_else(|| { + resolved_cache_dir_with_ctx(cwd, ctx).join(aube_store::VIRTUAL_STORE_SUBDIR) + }) } /// Resolve the `virtualStoreDirMaxLength` setting, falling back to the diff --git a/crates/aube/src/embed.rs b/crates/aube/src/embed.rs index 23eaac458..d54e43b7f 100644 --- a/crates/aube/src/embed.rs +++ b/crates/aube/src/embed.rs @@ -10,9 +10,9 @@ use std::path::{Path, PathBuf}; pub use crate::commands::add::AddToProjectOptions; pub use crate::commands::install::{ - DepSelection, FrozenMode, InstallControl, InstallEvent, InstallOutputLevel, InstallOutputMode, - InstallPhase, InstallProgressSnapshot, InstallPrompt, InstallPromptFuture, - InstallPromptHandler, InstallReporter, + DepSelection, EmbedderInstallOverrides, FrozenMode, InstallControl, InstallEvent, + InstallOutputLevel, InstallOutputMode, InstallPhase, InstallProgressSnapshot, InstallPrompt, + InstallPromptFuture, InstallPromptHandler, InstallReporter, }; pub use crate::runtime::{EmbedderRuntime, set_embedder_runtime}; pub use aube_manifest::{Error as ManifestError, PackageJson, Workspaces}; @@ -135,6 +135,18 @@ pub fn discover_workspace_packages( /// Install the dependencies declared by a project. pub async fn install(options: InstallOptions) -> Result<()> { + install_with_overrides(options, EmbedderInstallOverrides::default()).await +} + +/// Install dependencies with host-owned storage and materialization policy. +/// +/// Unlike process-wide defaults registered by [`initialize`], these overrides +/// apply only to this invocation and win over environment variables and +/// project/user configuration. +pub async fn install_with_overrides( + options: InstallOptions, + overrides: EmbedderInstallOverrides, +) -> Result<()> { let mut command_options = crate::commands::install::InstallOptions::with_mode(options.frozen_mode); command_options.project_dir = Some(options.project_dir); @@ -151,7 +163,12 @@ pub async fn install(options: InstallOptions) -> Result<()> { command_options.osv_transitive_check = options.osv_transitive_check; command_options.control = options.control; command_options.embedder_runtime = options.runtime; - crate::commands::install::run(command_options).await + overrides.append_to(&mut command_options.cli_flags); + crate::commands::scope_embedder_install_overrides( + overrides, + crate::commands::install::run(command_options), + ) + .await } /// Add packages to a project's manifest and install the resulting graph. @@ -166,7 +183,35 @@ pub async fn add( packages: &[String], options: AddToProjectOptions, ) -> Result<()> { - crate::commands::add::add_to_project(project_dir, packages, options).await + add_with_overrides( + project_dir, + packages, + options, + EmbedderInstallOverrides::default(), + ) + .await +} + +/// Add packages with host-owned storage and materialization policy. +/// +/// The overrides apply to the chained install only, at higher precedence than +/// environment variables and project/user configuration. +pub async fn add_with_overrides( + project_dir: &Path, + packages: &[String], + options: AddToProjectOptions, + overrides: EmbedderInstallOverrides, +) -> Result<()> { + crate::commands::scope_embedder_install_overrides( + overrides.clone(), + crate::commands::add::add_to_project_with_overrides( + project_dir, + packages, + options, + overrides, + ), + ) + .await } /// Run a package's script (`package.json` `scripts.`) in `project_dir`, diff --git a/crates/aube/src/state.rs b/crates/aube/src/state.rs index 6657cbe55..ecf990ec0 100644 --- a/crates/aube/src/state.rs +++ b/crates/aube/src/state.rs @@ -1292,6 +1292,16 @@ fn hash_settings(project_dir: &Path, cli_flags: &[(String, String)]) -> String { hasher.update(b"enable_gvs="); hasher.update(format!("{enable_gvs:?}").as_bytes()); hasher.update(b"\0"); + let cache_dir = crate::commands::resolved_cache_dir_with_ctx(project_dir, &ctx); + hasher.update(b"cache_dir="); + hasher.update(cache_dir.as_os_str().as_encoded_bytes()); + hasher.update(b"\0"); + let store_dir = crate::commands::resolved_store_dir_with_ctx(project_dir, &ctx); + hasher.update(b"store_dir="); + if let Some(store_dir) = store_dir { + hasher.update(store_dir.as_os_str().as_encoded_bytes()); + } + hasher.update(b"\0"); let lockfile_enabled = aube_settings::resolved::lockfile(&ctx); hasher.update(format!("lockfile={lockfile_enabled}\0").as_bytes()); // additional tree shape settings. cover enable_modules_dir flip @@ -1984,4 +1994,35 @@ mod tests { "removing the pnpmfile must restore the baseline hash" ); } + + #[test] + fn settings_hash_busts_warm_path_on_storage_override_change() { + let dir = temp_project_dir("settings-hash-storage-overrides"); + std::fs::write(dir.join("package.json"), r#"{"name":"x"}"#).unwrap(); + + let first = hash_settings( + &dir, + &[ + ("cacheDir".to_string(), "/host/cache-a".to_string()), + ("storeDir".to_string(), "/host/store-a".to_string()), + ], + ); + let changed_cache = hash_settings( + &dir, + &[ + ("cacheDir".to_string(), "/host/cache-b".to_string()), + ("storeDir".to_string(), "/host/store-a".to_string()), + ], + ); + let changed_store = hash_settings( + &dir, + &[ + ("cacheDir".to_string(), "/host/cache-a".to_string()), + ("storeDir".to_string(), "/host/store-b".to_string()), + ], + ); + + assert_ne!(first, changed_cache); + assert_ne!(first, changed_store); + } } diff --git a/crates/aube/tests/embed.rs b/crates/aube/tests/embed.rs index a9c8202ce..7a8fcb4d4 100644 --- a/crates/aube/tests/embed.rs +++ b/crates/aube/tests/embed.rs @@ -65,6 +65,77 @@ fn workspace_fixture() -> (tempfile::TempDir, PathBuf) { (workspace, app) } +fn seed_cached_registry_package(cache_dir: &std::path::Path, store_dir: &std::path::Path) { + let packument_cache_dir = cache_dir.join("packuments-v1"); + let full_packument_cache_dir = cache_dir.join("packuments-full-v1"); + std::fs::create_dir_all(&packument_cache_dir).unwrap(); + std::fs::create_dir_all(&full_packument_cache_dir).unwrap(); + let package = tempfile::tempdir().unwrap(); + std::fs::write( + package.path().join("package.json"), + r#"{"name":"cached-only","version":"1.0.0"} +"#, + ) + .unwrap(); + + let store = aube_store::Store::with_dirs(store_dir.join("v1/files"), cache_dir.to_path_buf()); + let index = store.import_directory(package.path()).unwrap(); + store + .save_index("cached-only", "1.0.0", None, &index) + .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", + "dist": { + "tarball": "https://registry.npmjs.org/cached-only/-/cached-only-1.0.0.tgz" + } + } + } + })) + .unwrap(); + let client = aube_registry::client::RegistryClient::new("https://registry.npmjs.org/"); + client.seed_packument_cache( + "cached-only", + &packument_cache_dir, + &packument, + None, + None, + true, + ); + client.seed_full_packument_cache( + "cached-only", + &full_packument_cache_dir, + &packument, + None, + None, + true, + ); + assert!( + client + .cached_packument_lookup("cached-only", &packument_cache_dir) + .packument + .is_some() + ); +} + +fn cached_package_materialization(project: &std::path::Path) -> PathBuf { + project.join("node_modules/cached-only/package.json") +} + +fn assert_cached_package_is_project_local( + importer: &std::path::Path, + install_root: &std::path::Path, +) { + let package = std::fs::canonicalize(cached_package_materialization(importer)).unwrap(); + let virtual_store = std::fs::canonicalize(install_root.join("node_modules/.testhost")).unwrap(); + assert!(package.starts_with(virtual_store)); +} + struct CancelOnOutput(Mutex>); impl aube::embed::InstallReporter for CancelOnOutput { @@ -94,6 +165,98 @@ async fn facade_initializes_host_and_runs_install() { assert!(project.path().join("testhost-lock.yaml").is_file()); } +#[tokio::test] +async fn facade_install_accepts_host_storage_overrides() { + initialize_test_host(); + let project = tempfile::tempdir().unwrap(); + let host_cache = project.path().join("host-cache"); + let host_store = project.path().join("host-store"); + seed_cached_registry_package(&host_cache, &host_store); + std::fs::write( + project.path().join("package.json"), + r#"{"dependencies":{"cached-only":"1.0.0"}} +"#, + ) + .unwrap(); + + let mut options = InstallOptions::new(project.path()); + options.ignore_scripts = true; + options.network_mode = aube::embed::NetworkMode::Offline; + options.control = InstallControl::silent(); + aube::embed::install_with_overrides( + options, + aube::embed::EmbedderInstallOverrides { + use_global_virtual_store: Some(false), + cache_dir: Some(host_cache.clone()), + store_dir: Some(host_store.clone()), + }, + ) + .await + .unwrap(); + + assert!(host_store.join("v1/files").is_dir()); + assert_cached_package_is_project_local(project.path(), project.path()); + assert!(!host_cache.join("virtual-store").exists()); + + let replacement_store = project.path().join("replacement-store"); + let mut options = InstallOptions::new(project.path()); + options.ignore_scripts = true; + options.network_mode = aube::embed::NetworkMode::Offline; + options.control = InstallControl::silent(); + let error = aube::embed::install_with_overrides( + options, + aube::embed::EmbedderInstallOverrides { + use_global_virtual_store: Some(false), + cache_dir: Some(host_cache), + store_dir: Some(replacement_store), + }, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("offline")); +} + +#[cfg(unix)] +#[tokio::test] +async fn facade_install_preserves_non_utf8_storage_paths() { + use std::os::unix::ffi::OsStringExt; + + initialize_test_host(); + let project = tempfile::tempdir().unwrap(); + let host_cache = project + .path() + .join(std::ffi::OsString::from_vec(b"host-cache-\xff".to_vec())); + let host_store = project + .path() + .join(std::ffi::OsString::from_vec(b"host-store-\xff".to_vec())); + seed_cached_registry_package(&host_cache, &host_store); + std::fs::write( + project.path().join("package.json"), + r#"{"dependencies":{"cached-only":"1.0.0"}} +"#, + ) + .unwrap(); + + let mut options = InstallOptions::new(project.path()); + options.ignore_scripts = true; + options.network_mode = aube::embed::NetworkMode::Offline; + options.control = InstallControl::silent(); + aube::embed::install_with_overrides( + options, + aube::embed::EmbedderInstallOverrides { + use_global_virtual_store: Some(false), + cache_dir: Some(host_cache.clone()), + store_dir: Some(host_store.clone()), + }, + ) + .await + .unwrap(); + + assert!(host_store.join("v1/files").is_dir()); + assert!(host_cache.join("packuments-v1").is_dir()); + assert_cached_package_is_project_local(project.path(), project.path()); +} + #[tokio::test] async fn facade_adds_local_package_to_workspace_member() { initialize_test_host(); @@ -120,6 +283,37 @@ async fn facade_adds_local_package_to_workspace_member() { assert!(!app.join("testhost-lock.yaml").exists()); } +#[tokio::test] +async fn facade_add_honors_host_storage_and_materialization_overrides() { + initialize_test_host(); + let (workspace, app) = workspace_fixture(); + let host_cache = workspace.path().join("host-cache"); + let host_store = workspace.path().join("host-store"); + seed_cached_registry_package(&host_cache, &host_store); + + aube::embed::add_with_overrides( + &app, + &["cached-only".to_string()], + aube::embed::AddToProjectOptions { + ignore_scripts: true, + offline: true, + control: InstallControl::silent(), + ..Default::default() + }, + aube::embed::EmbedderInstallOverrides { + use_global_virtual_store: Some(false), + cache_dir: Some(host_cache.clone()), + store_dir: Some(host_store.clone()), + }, + ) + .await + .unwrap(); + + assert!(host_store.join("v1/files").is_dir()); + assert_cached_package_is_project_local(&app, workspace.path()); + assert!(!host_cache.join("virtual-store").exists()); +} + #[tokio::test] async fn facade_add_runs_root_dev_preinstall() { initialize_test_host();