diff --git a/crates/aube-ffi/src/lib.rs b/crates/aube-ffi/src/lib.rs index bf1163ba2..f158d9605 100644 --- a/crates/aube-ffi/src/lib.rs +++ b/crates/aube-ffi/src/lib.rs @@ -567,6 +567,8 @@ fn add_impl( ), osv_transitive_check: input.osv_transitive_check && !input.offline, control, + // C ABI hosts rely on ambient node; no host-supplied runtime. + node_bin_dir: None, }; embed::add(&project_dir, &packages, options) .await diff --git a/crates/aube-node/src/lib.rs b/crates/aube-node/src/lib.rs index e27fa8348..d0eef62c2 100644 --- a/crates/aube-node/src/lib.rs +++ b/crates/aube-node/src/lib.rs @@ -353,6 +353,8 @@ async fn run_install( dep_selection, osv_transitive_check, control: control.clone(), + // JS hosts rely on ambient node; no host-supplied runtime. + node_bin_dir: None, }, ) .await diff --git a/crates/aube/src/commands/add/mod.rs b/crates/aube/src/commands/add/mod.rs index 9329a8bfd..cf96780bb 100644 --- a/crates/aube/src/commands/add/mod.rs +++ b/crates/aube/src/commands/add/mod.rs @@ -45,6 +45,12 @@ pub struct AddToProjectOptions { pub osv_transitive_check: bool, /// Invocation-scoped output and cancellation controls for embedders. pub control: install::InstallControl, + /// Directory containing the `node` executable an embedding host wants + /// lifecycle scripts to run on (see + /// [`crate::commands::install::InstallOptions::embedder_node_bin_dir`]). + /// `None` keeps aube's own runtime resolution. Matches the + /// `node_bin_dir` field on [`crate::embed::InstallOptions`]. + pub node_bin_dir: Option, } /// Resolve, save, and install packages in an explicitly selected project. @@ -120,6 +126,7 @@ pub async fn add_to_project( options.dangerously_allow_all_builds, ); install_options.control = options.control; + install_options.embedder_node_bin_dir = options.node_bin_dir; if options.offline { install_options.network_mode = aube_registry::NetworkMode::Offline; } diff --git a/crates/aube/src/commands/ci.rs b/crates/aube/src/commands/ci.rs index facb4cf41..a1dcbf346 100644 --- a/crates/aube/src/commands/ci.rs +++ b/crates/aube/src/commands/ci.rs @@ -64,6 +64,7 @@ pub async fn run(args: CiArgs) -> miette::Result<()> { // Propagate --ignore-scripts so root lifecycle hooks are skipped. let opts = install::InstallOptions { control: install::InstallControl::default(), + embedder_node_bin_dir: None, project_dir: None, mode: install::FrozenMode::Frozen, dep_selection: install::DepSelection::from_flags(false, false, no_optional), diff --git a/crates/aube/src/commands/deploy/mod.rs b/crates/aube/src/commands/deploy/mod.rs index 5e7a2b11e..03db35c70 100644 --- a/crates/aube/src/commands/deploy/mod.rs +++ b/crates/aube/src/commands/deploy/mod.rs @@ -315,6 +315,7 @@ pub async fn run( }; let opts = InstallOptions { control: install::InstallControl::default(), + embedder_node_bin_dir: None, project_dir: Some(s.target.clone()), mode, dep_selection: dep_selection_for_args(&args), diff --git a/crates/aube/src/commands/install/args.rs b/crates/aube/src/commands/install/args.rs index 228768372..30f67c610 100644 --- a/crates/aube/src/commands/install/args.rs +++ b/crates/aube/src/commands/install/args.rs @@ -309,6 +309,7 @@ impl InstallArgs { // every install — neither needs the caller to opt in. osv_transitive_check: false, control: super::InstallControl::default(), + embedder_node_bin_dir: None, } } } @@ -444,6 +445,12 @@ pub struct InstallOptions { /// embedders can select structured events or silence independently for /// each concurrent install. pub control: super::InstallControl, + /// Directory containing the `node` executable an embedding host wants + /// lifecycle scripts to run on. When set, it seeds the install's runtime + /// slot ([`crate::runtime::seed_embedder_node`]) so scripts spawn on that + /// node and find it on PATH — aube does no runtime resolution of its own. + /// `None` keeps aube's normal runtime switching / PATH fallback. + pub embedder_node_bin_dir: Option, } impl InstallOptions { @@ -488,6 +495,7 @@ impl InstallOptions { // fallback) instead of an unconditional API hit. osv_transitive_check: false, control: super::InstallControl::default(), + embedder_node_bin_dir: None, } } } diff --git a/crates/aube/src/commands/install/mod.rs b/crates/aube/src/commands/install/mod.rs index d7dac1baa..a5150e0c7 100644 --- a/crates/aube/src/commands/install/mod.rs +++ b/crates/aube/src/commands/install/mod.rs @@ -596,6 +596,13 @@ async fn run_inner(opts: InstallOptions, cwd: std::path::PathBuf) -> miette::Res let lockfile_parse_options = aube_lockfile::ParseOptions { strict_store_integrity: strict_store_integrity_setting, }; + // An embedding host (mise) can supply the node runtime for lifecycle + // scripts. Seed it into the scoped runtime slot before `ensure` runs — + // `ensure` returns early when the slot is set, so aube skips its own + // runtime resolution and scripts spawn on the host's node. + if let Some(bin_dir) = &opts.embedder_node_bin_dir { + crate::runtime::seed_embedder_node(bin_dir.clone()).await; + } if !opts.dry_run { crate::runtime::ensure( &cwd, diff --git a/crates/aube/src/embed.rs b/crates/aube/src/embed.rs index 12ff2506c..78e31d871 100644 --- a/crates/aube/src/embed.rs +++ b/crates/aube/src/embed.rs @@ -50,6 +50,11 @@ pub struct InstallOptions { pub osv_transitive_check: bool, /// Invocation-scoped output, progress reporting, and cancellation. pub control: InstallControl, + /// Directory containing the `node` executable to run lifecycle scripts on. + /// Set this when the host manages its own node runtime (e.g. mise) so + /// scripts spawn on it and find it on PATH; `None` uses aube's own runtime + /// resolution / PATH fallback. + pub node_bin_dir: Option, } impl InstallOptions { @@ -69,6 +74,7 @@ impl InstallOptions { dangerously_allow_all_builds: false, osv_transitive_check: false, control: InstallControl::default(), + node_bin_dir: None, } } } @@ -112,6 +118,7 @@ pub async fn install(options: InstallOptions) -> Result<()> { command_options.dangerously_allow_all_builds = options.dangerously_allow_all_builds; command_options.osv_transitive_check = options.osv_transitive_check; command_options.control = options.control; + command_options.embedder_node_bin_dir = options.node_bin_dir; crate::commands::install::run(command_options).await } diff --git a/crates/aube/src/runtime.rs b/crates/aube/src/runtime.rs index 959791442..8f6b32e36 100644 --- a/crates/aube/src/runtime.rs +++ b/crates/aube/src/runtime.rs @@ -24,6 +24,9 @@ pub enum RuntimeSource { /// No requirement configured (or policy said keep the ambient /// node) — PATH is left alone. PathFallback, + /// A host embedding aube (e.g. mise) supplied the node runtime to + /// use for lifecycle scripts, rather than aube resolving one itself. + Embedder, } impl RuntimeSource { @@ -33,6 +36,7 @@ impl RuntimeSource { RuntimeSource::NodeVersionFile => ".node-version", RuntimeSource::Nvmrc => ".nvmrc", RuntimeSource::PathFallback => "PATH", + RuntimeSource::Embedder => "embedder", } } } @@ -127,6 +131,61 @@ pub fn current() -> Option> { } } +/// Seed the current install's runtime slot with a node binary supplied by an +/// embedding host (e.g. mise), so lifecycle scripts spawn on that node and +/// find it on PATH instead of relying on an ambient `node`. +/// +/// `bin_dir` is the directory containing the `node` executable; it is +/// prepended to the script PATH ([`path_entries`]) and its `node` is used as +/// [`node_program`]. Must be called inside a [`scope`] (the install task) and +/// before [`ensure`] runs — `ensure` returns early when the slot is already +/// set, so this override wins without aube probing for its own runtime. A +/// no-op outside a scope or if the slot is already populated. +pub async fn seed_embedder_node(bin_dir: PathBuf) { + // No-op outside an install scope, or when the slot is already seeded. + // Check first so the path/version probing below is skipped entirely in + // those cases (a later `set` would be a no-op anyway). + let should_seed = INSTALL_RUNTIME + .try_with(|slot| slot.get().is_none()) + .unwrap_or(false); + if !should_seed { + return; + } + // Absolutize: lifecycle scripts may run with a different working + // directory, so both `node_program()` and the prepended PATH entry must + // resolve independently of cwd. `absolute` doesn't require the dir to + // exist or touch symlinks; fall back to the input if it errors. + let bin_dir = std::path::absolute(&bin_dir).unwrap_or(bin_dir); + let node_exe = if cfg!(windows) { "node.exe" } else { "node" }; + let node_bin = bin_dir.join(node_exe); + // Probe the supplied node for its version so engine checks and + // virtual-store hashing key off the *same* node as lifecycle scripts, + // instead of `effective_node_version` falling back to an ambient `node`. + // Async spawn so the install task doesn't block a Tokio worker on the + // child process. + let version = tokio::process::Command::new(&node_bin) + .arg("--version") + .output() + .await + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|v| v.trim().trim_start_matches('v').to_string()) + .filter(|v| !v.is_empty()); + let ctx = RuntimeContext { + node_bin: Some(node_bin), + bin_dir: Some(bin_dir), + version, + requested: None, + source: RuntimeSource::Embedder, + provenance: RuntimeProvenance::Mise, + fresh_pin: None, + }; + let _ = INSTALL_RUNTIME.try_with(|slot| { + let _ = slot.set(Arc::new(ctx)); + }); +} + /// The node executable spawn sites should use: the switched runtime's /// binary when one resolved, otherwise bare `"node"` (PATH lookup at /// spawn time, today's behavior). @@ -753,6 +812,45 @@ mod tests { context } + #[tokio::test] + async fn seed_embedder_node_drives_node_program_and_path() { + scope(async { + assert!(current().is_none(), "slot starts empty"); + let bin_dir = PathBuf::from("/opt/mise/node/bin"); + seed_embedder_node(bin_dir.clone()).await; + + // The seed absolutizes the dir; compute the expected the same way + // so this holds on Windows (where `/opt/...` isn't absolute). + let expected = std::path::absolute(&bin_dir).unwrap_or(bin_dir); + let node_exe = if cfg!(windows) { "node.exe" } else { "node" }; + + let ctx = current().expect("slot seeded"); + assert_eq!(ctx.source, RuntimeSource::Embedder); + assert_eq!(ctx.bin_dir.as_deref(), Some(expected.as_path())); + assert_eq!(node_program(), expected.join(node_exe)); + assert_eq!(path_entries(), vec![expected.clone()]); + + // `ensure`-style early return: a second seed does not clobber the + // first (OnceCell is set once). + seed_embedder_node(PathBuf::from("/other")).await; + assert_eq!(node_program(), expected.join(node_exe)); + }) + .await; + } + + #[tokio::test] + async fn seed_embedder_node_is_a_noop_outside_scope() { + // No install scope active: the seed can't set a task-local slot, so it + // must not panic and must not leak into a later scope. Asserting via a + // fresh `scope` reads the (empty) task-local, not the process-wide + // `RUNTIME`, so this stays deterministic regardless of test order. + seed_embedder_node(PathBuf::from("/opt/mise/node/bin")).await; + scope(async { + assert!(current().is_none(), "seed outside a scope must not leak in"); + }) + .await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn install_runtime_is_isolated_and_propagated() { let barrier = Arc::new(tokio::sync::Barrier::new(2));