use aube_manifest::PackageJson;
use aube_settings::ResolveCtx;
use miette::miette;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeSource {
DevEngines,
NodeVersionFile,
Nvmrc,
PathFallback,
Embedder,
}
impl RuntimeSource {
pub fn label(self) -> &'static str {
match self {
RuntimeSource::DevEngines => "devEngines.runtime",
RuntimeSource::NodeVersionFile => ".node-version",
RuntimeSource::Nvmrc => ".nvmrc",
RuntimeSource::PathFallback => "PATH",
RuntimeSource::Embedder => "embedder",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeProvenance {
Mise,
AubeManaged,
System,
}
impl RuntimeProvenance {
pub fn label(self) -> &'static str {
match self {
RuntimeProvenance::Mise => "mise",
RuntimeProvenance::AubeManaged => aube_util::embedder().name,
RuntimeProvenance::System => "system",
}
}
}
#[derive(Debug, Clone)]
pub struct RuntimeContext {
pub bin_dir: Option<PathBuf>,
pub node_bin: Option<PathBuf>,
pub version: Option<String>,
pub requested: Option<String>,
pub source: RuntimeSource,
pub provenance: RuntimeProvenance,
pub fresh_pin: Option<aube_runtime::PinnedNode>,
}
impl RuntimeContext {
fn path_fallback() -> RuntimeContext {
RuntimeContext {
bin_dir: None,
node_bin: None,
version: None,
requested: None,
source: RuntimeSource::PathFallback,
provenance: RuntimeProvenance::System,
fresh_pin: None,
}
}
}
static RUNTIME: tokio::sync::OnceCell<Arc<RuntimeContext>> = tokio::sync::OnceCell::const_new();
type RuntimeSlot = Arc<tokio::sync::OnceCell<Arc<RuntimeContext>>>;
tokio::task_local! {
static INSTALL_RUNTIME: RuntimeSlot;
}
pub async fn scope<F: Future>(future: F) -> F::Output {
INSTALL_RUNTIME
.scope(Arc::new(tokio::sync::OnceCell::new()), future)
.await
}
pub fn scope_current<F: Future>(future: F) -> impl Future<Output = F::Output> {
let runtime = INSTALL_RUNTIME.try_with(Arc::clone).ok();
async move {
match runtime {
Some(runtime) => INSTALL_RUNTIME.scope(runtime, future).await,
None => future.await,
}
}
}
pub fn current() -> Option<Arc<RuntimeContext>> {
match INSTALL_RUNTIME.try_with(|runtime| runtime.get().map(Arc::clone)) {
Ok(runtime) => runtime,
Err(_) => RUNTIME.get().map(Arc::clone),
}
}
pub async fn seed_embedder_node(bin_dir: PathBuf) {
let should_seed = INSTALL_RUNTIME
.try_with(|slot| slot.get().is_none())
.unwrap_or(false);
if !should_seed {
return;
}
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);
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));
});
}
pub fn node_program() -> PathBuf {
current()
.and_then(|c| c.node_bin.clone())
.unwrap_or_else(|| PathBuf::from("node"))
}
pub fn path_entries() -> Vec<PathBuf> {
current()
.and_then(|c| c.bin_dir.clone())
.into_iter()
.collect()
}
pub fn apply_child_env(cmd: &mut tokio::process::Command) {
let node_bin = current()
.and_then(|ctx| ctx.node_bin.clone())
.or_else(aube_runtime::node_on_path);
if let Some(node_bin) = node_bin {
cmd.env("npm_node_execpath", &node_bin);
cmd.env("NODE", &node_bin);
}
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeSettings {
pub installer: aube_runtime::InstallerMode,
pub on_fail_override: Option<aube_manifest::OnFail>,
pub mirror: Option<String>,
pub network: aube_runtime::NetworkMode,
pub switching: bool,
}
impl RuntimeSettings {
pub fn from_ctx(ctx: &ResolveCtx<'_>) -> Self {
let installer = match aube_settings::resolved::runtime_installer(ctx) {
aube_settings::resolved::RuntimeInstaller::Auto => aube_runtime::InstallerMode::Auto,
aube_settings::resolved::RuntimeInstaller::Mise => aube_runtime::InstallerMode::Mise,
aube_settings::resolved::RuntimeInstaller::Aube => aube_runtime::InstallerMode::Aube,
};
let on_fail_override =
aube_settings::resolved::runtime_on_fail(ctx).map(|forced| match forced {
aube_settings::resolved::RuntimeOnFail::Download => aube_manifest::OnFail::Download,
aube_settings::resolved::RuntimeOnFail::Error => aube_manifest::OnFail::Error,
aube_settings::resolved::RuntimeOnFail::Warn => aube_manifest::OnFail::Warn,
aube_settings::resolved::RuntimeOnFail::Ignore => aube_manifest::OnFail::Ignore,
});
RuntimeSettings {
installer,
on_fail_override,
mirror: release_mirror(ctx),
network: aube_runtime::NetworkMode::Online,
switching: aube_util::embedder().runtime_switching,
}
}
}
pub(crate) fn lockfile_node_pin(
project_dir: &Path,
manifest: &PackageJson,
parse_options: aube_lockfile::ParseOptions,
) -> Option<aube_lockfile::RuntimePin> {
let pinned = [aube_util::embedder().lockfile_basename, "pnpm-lock.yaml"]
.iter()
.any(|name| {
std::fs::read_to_string(project_dir.join(name))
.map(|s| s.contains("specifier: runtime:"))
.unwrap_or(false)
});
if !pinned {
return None;
}
let (graph, _) =
aube_lockfile::parse_lockfile_with_kind_and_options(project_dir, manifest, parse_options)
.ok()?;
graph.runtimes.get("node").cloned()
}
pub async fn ensure_for_cwd(cwd: &Path) -> miette::Result<Arc<RuntimeContext>> {
if let Some(ctx) = current() {
return Ok(ctx);
}
let project_dir = crate::dirs::find_project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
let manifest =
aube_manifest::PackageJson::from_path_cached(&project_dir.join("package.json")).ok();
let settings = crate::commands::with_settings_ctx(&project_dir, RuntimeSettings::from_ctx);
let parse_options =
crate::commands::with_settings_ctx(&project_dir, |ctx| aube_lockfile::ParseOptions {
strict_store_integrity: aube_settings::resolved::strict_store_integrity(ctx)
|| aube_settings::resolved::paranoid(ctx),
});
let pin = manifest
.as_deref()
.and_then(|m| lockfile_node_pin(&project_dir, m, parse_options));
ensure(&project_dir, manifest.as_deref(), settings, pin.as_ref()).await
}
pub async fn ensure(
project_dir: &Path,
manifest: Option<&PackageJson>,
settings: RuntimeSettings,
lock_pin: Option<&aube_lockfile::RuntimePin>,
) -> miette::Result<Arc<RuntimeContext>> {
let lock_pin = lock_pin.cloned();
let project_dir = project_dir.to_path_buf();
let manifest = manifest.cloned();
if let Ok(runtime) = INSTALL_RUNTIME.try_with(Arc::clone) {
return runtime
.get_or_try_init(|| async {
resolve_context(project_dir, manifest, settings, lock_pin)
.await
.map(Arc::new)
})
.await
.map(Arc::clone);
}
RUNTIME
.get_or_try_init(|| async {
resolve_context(project_dir, manifest, settings, lock_pin)
.await
.map(Arc::new)
})
.await
.map(Arc::clone)
}
async fn resolve_context(
project_dir: PathBuf,
manifest: Option<PackageJson>,
settings: RuntimeSettings,
lock_pin: Option<aube_lockfile::RuntimePin>,
) -> miette::Result<RuntimeContext> {
if !settings.switching {
return Ok(RuntimeContext::path_fallback());
}
let project_dir = project_dir.as_path();
let manifest = manifest.as_ref();
let lock_pin = lock_pin.as_ref();
let dev_engines = manifest
.and_then(|m| m.dev_engines.as_ref())
.and_then(|d| d.node_runtime())
.and_then(|r| {
r.version
.as_deref()
.map(|v| (v, r.on_fail, project_dir.join("package.json")))
});
if let Some(unsupported) = manifest
.and_then(|m| m.dev_engines.as_ref())
.map(|d| d.unsupported_runtimes())
.filter(|u| !u.is_empty())
{
tracing::debug!(
runtimes = ?unsupported,
"ignoring non-node devEngines.runtime entries"
);
}
let request = aube_runtime::effective_request(
dev_engines.as_ref().map(|(v, f, p)| (*v, *f, p.as_path())),
project_dir,
)
.map_err(|e| miette!(code = e.code(), "{e}"))?;
let Some(mut request) = request else {
return Ok(RuntimeContext::path_fallback());
};
if let Some(forced) = settings.on_fail_override {
request.on_fail = forced;
}
let cfg = aube_runtime::RuntimeConfig {
installer: settings.installer,
mirror: settings.mirror.clone(),
network: settings.network,
retries: 2,
};
let source = match request.source {
aube_runtime::RequestSource::DevEngines => RuntimeSource::DevEngines,
aube_runtime::RequestSource::NodeVersionFile => RuntimeSource::NodeVersionFile,
aube_runtime::RequestSource::Nvmrc => RuntimeSource::Nvmrc,
};
let requested = request.raw.clone();
let pinned = lock_pin
.filter(|pin| pin.specifier == requested)
.map(pinned_from_lockfile)
.transpose()
.map_err(|e| miette!("{e}"))?;
let runtime = aube_runtime::NodeRuntime::new(cfg);
let resolved = runtime
.resolve(&request, pinned.as_ref(), &CliProgress::node())
.await
.map_err(|e| miette!(code = e.code(), "{e}"))?;
Ok(match resolved {
None => {
let mut ctx = RuntimeContext::path_fallback();
ctx.requested = Some(requested);
ctx.source = source;
ctx
}
Some(res) => RuntimeContext {
bin_dir: res.bin_dir.clone(),
node_bin: Some(res.node_bin.clone()),
version: Some(res.version.to_string()),
requested: Some(requested),
source,
provenance: match res.from {
aube_runtime::ResolvedFrom::PathEnv => RuntimeProvenance::System,
aube_runtime::ResolvedFrom::Installed(origin)
| aube_runtime::ResolvedFrom::FreshInstall(origin) => match origin {
aube_runtime::InstallOrigin::Mise => RuntimeProvenance::Mise,
aube_runtime::InstallOrigin::Aube => RuntimeProvenance::AubeManaged,
},
},
fresh_pin: res.fresh_pin,
},
})
}
fn release_mirror(ctx: &ResolveCtx<'_>) -> Option<String> {
let yaml_serde::Value::Mapping(map) = ctx.workspace_yaml.get("nodeDownloadMirrors")? else {
return None;
};
map.iter().find_map(|(k, v)| match (k, v) {
(yaml_serde::Value::String(key), yaml_serde::Value::String(url))
if key == "release" && !url.trim().is_empty() =>
{
Some(url.trim().to_string())
}
_ => None,
})
}
fn pinned_from_lockfile(
pin: &aube_lockfile::RuntimePin,
) -> Result<aube_runtime::PinnedNode, aube_runtime::Error> {
let version = node_semver::Version::parse(&pin.version).map_err(|e| {
aube_runtime::Error::NoMatchingVersion {
requested: format!("lockfile pin {}: {e}", pin.version),
platform_note: String::new(),
}
})?;
let mut variants = Vec::new();
for v in &pin.variants {
for t in &v.targets {
variants.push(aube_runtime::PinnedVariant {
os: t.os.clone(),
cpu: t.cpu.clone(),
libc: t.libc.clone(),
archive: v.archive.clone(),
url: v.url.clone(),
integrity_sri: v.integrity.clone(),
bin: v.bin.clone(),
prefix: v.prefix.clone(),
});
}
}
Ok(aube_runtime::PinnedNode { version, variants })
}
pub async fn refresh_lockfile_pin(
graph: &mut aube_lockfile::LockfileGraph,
manifest: &PackageJson,
settings: RuntimeSettings,
write_kind: aube_lockfile::LockfileKind,
) -> miette::Result<()> {
let declared = manifest
.dev_engines
.as_ref()
.and_then(|d| d.node_runtime())
.and_then(|r| r.version.clone());
let Some(range) = declared else {
graph.runtimes.remove("node");
return Ok(());
};
if !matches!(
write_kind,
aube_lockfile::LockfileKind::Aube | aube_lockfile::LockfileKind::Pnpm
) {
if !graph.runtimes.contains_key("node") {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_RUNTIME_PIN_NOT_RECORDED,
format = ?write_kind,
"devEngines.runtime resolved but this lockfile format cannot record a runtime pin; subsequent runs re-resolve the range"
);
}
return Ok(());
}
let Some(version) = current().and_then(|c| c.version.clone()) else {
return Ok(());
};
if graph
.runtimes
.get("node")
.is_some_and(|p| p.specifier == range && p.version == version)
{
return Ok(());
}
let fresh = current().and_then(|c| c.fresh_pin.clone());
let pin = match fresh.filter(|p| p.version.to_string() == version) {
Some(p) => p,
None => {
let cfg = aube_runtime::RuntimeConfig {
installer: settings.installer,
mirror: settings.mirror.clone(),
network: aube_runtime::NetworkMode::Online,
retries: 2,
};
let spec = aube_runtime::NodeSpec::parse(&version)
.map_err(|e| miette!(code = e.code(), "{e}"))?;
match aube_runtime::NodeRuntime::new(cfg)
.resolve_for_lockfile(&spec)
.await
{
Ok(p) => p,
Err(e) => {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_RUNTIME_PIN_NOT_RECORDED,
error = %e,
"could not fetch runtime checksums to record the lockfile pin"
);
return Ok(());
}
}
}
};
graph
.runtimes
.insert("node".to_string(), lockfile_pin_from(&pin, &range));
Ok(())
}
pub fn lockfile_pin_from(
pin: &aube_runtime::PinnedNode,
specifier: &str,
) -> aube_lockfile::RuntimePin {
aube_lockfile::RuntimePin {
specifier: specifier.to_string(),
version: pin.version.to_string(),
dev: true,
has_bin: true,
variants: pin
.variants
.iter()
.map(|v| aube_lockfile::RuntimeVariant {
targets: vec![aube_lockfile::RuntimeTarget {
os: v.os.clone(),
cpu: v.cpu.clone(),
libc: v.libc.clone(),
}],
archive: v.archive.clone(),
url: v.url.clone(),
integrity: v.integrity_sri.clone(),
bin: v.bin.clone(),
bin_is_bare_string: false,
prefix: v.prefix.clone(),
})
.collect(),
}
}
pub(crate) struct CliProgress {
tool: &'static str,
state: std::sync::Mutex<CliProgressState>,
}
#[derive(Default)]
struct CliProgressState {
version: Option<String>,
job: Option<std::sync::Arc<clx::progress::ProgressJob>>,
announced: bool,
downloaded: u64,
total: Option<u64>,
paused_for_tool: bool,
}
impl CliProgress {
pub(crate) fn node() -> Self {
Self::for_tool("Node.js")
}
pub(crate) fn aube() -> Self {
Self::for_tool(aube_util::embedder().name)
}
fn for_tool(tool: &'static str) -> Self {
CliProgress {
tool,
state: std::sync::Mutex::new(CliProgressState::default()),
}
}
fn fancy_output() -> bool {
use std::io::IsTerminal;
clx::progress::output() != clx::progress::ProgressOutput::Text
&& std::io::stderr().is_terminal()
}
fn label(&self, version: &str, phase: &str) -> String {
if phase.is_empty() {
format!("{} v{version}", self.tool)
} else {
format!("{} v{version} ({phase})", self.tool)
}
}
fn bytes_prop(state: &CliProgressState) -> String {
match state.total {
Some(total) if total > 0 => format!(
"{} / {}",
crate::progress::format_bytes(state.downloaded),
crate::progress::format_bytes(total)
),
_ => crate::progress::format_bytes(state.downloaded),
}
}
}
impl aube_runtime::DownloadProgress for CliProgress {
fn on_phase(&self, version: Option<&node_semver::Version>, phase: aube_runtime::InstallPhase) {
use aube_runtime::InstallPhase;
let mut state = self.state.lock().unwrap();
if let Some(v) = version {
state.version = Some(v.to_string());
}
let version = state.version.clone().unwrap_or_default();
match phase {
InstallPhase::Resolving => {}
InstallPhase::Downloading => {
if !Self::fancy_output() && !state.announced {
state.announced = true;
crate::progress::safe_eprintln(&format!(
"Downloading {} v{version}…",
self.tool
));
}
}
InstallPhase::Verifying => {
if let Some(job) = &state.job {
job.prop("label", &self.label(&version, "verifying…"));
}
}
InstallPhase::Extracting => {
if let Some(job) = &state.job {
job.prop("label", &self.label(&version, "extracting…"));
}
}
}
}
fn on_download_start(&self, total_bytes: Option<u64>) {
if !Self::fancy_output() {
return;
}
let mut state = self.state.lock().unwrap();
state.total = total_bytes;
let version = state.version.clone().unwrap_or_default();
let builder = clx::progress::ProgressJobBuilder::new()
.body("{{spinner()}} {{label}} {{progress_bar(flex=true)}} {{bytes}}")
.body_text(Some("{{label}} {{bytes}}"))
.prop("label", &self.label(&version, ""))
.prop("bytes", "")
.status(clx::progress::ProgressStatus::Running)
.progress_current(0)
.progress_total(total_bytes.unwrap_or(1).max(1) as usize);
state.job = Some(builder.start());
}
fn on_download_chunk(&self, bytes: u64) {
let mut state = self.state.lock().unwrap();
state.downloaded += bytes;
let bytes_text = Self::bytes_prop(&state);
if let Some(job) = &state.job {
if state.total.is_some() {
job.progress_current(state.downloaded as usize);
}
job.prop("bytes", &bytes_text);
}
}
fn on_done(&self) {
let state = self.state.lock().unwrap();
if let Some(job) = &state.job {
job.set_status(clx::progress::ProgressStatus::Done);
} else if state.announced {
crate::progress::safe_eprintln(&format!(
"{} v{} installed",
self.tool,
state.version.clone().unwrap_or_default()
));
}
}
fn on_external_tool_start(&self) {
let mut state = self.state.lock().unwrap();
if !clx::progress::is_paused() {
clx::progress::pause();
state.paused_for_tool = true;
}
}
fn on_external_tool_end(&self) {
let mut state = self.state.lock().unwrap();
if state.paused_for_tool {
clx::progress::resume();
state.paused_for_tool = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_context(requested: &str) -> RuntimeContext {
let mut context = RuntimeContext::path_fallback();
context.requested = Some(requested.to_string());
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;
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()]);
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() {
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));
let first_barrier = Arc::clone(&barrier);
let second_barrier = Arc::clone(&barrier);
let first = scope(async move {
INSTALL_RUNTIME.with(|runtime| runtime.set(Arc::new(test_context("first"))).unwrap());
first_barrier.wait().await;
tokio::spawn(scope_current(async {
current().and_then(|runtime| runtime.requested.clone())
}))
.await
.unwrap()
});
let second = scope(async move {
INSTALL_RUNTIME.with(|runtime| runtime.set(Arc::new(test_context("second"))).unwrap());
second_barrier.wait().await;
tokio::spawn(scope_current(async {
current().and_then(|runtime| runtime.requested.clone())
}))
.await
.unwrap()
});
let (first, second) = tokio::join!(first, second);
assert_eq!(first.as_deref(), Some("first"));
assert_eq!(second.as_deref(), Some("second"));
}
#[test]
fn lockfile_pin_round_trip_shapes() {
let pin = aube_runtime::PinnedNode {
version: "24.4.1".parse().unwrap(),
variants: vec![aube_runtime::PinnedVariant {
os: "darwin".into(),
cpu: "arm64".into(),
libc: None,
archive: "tarball".into(),
url: "https://nodejs.org/download/release/v24.4.1/node-v24.4.1-darwin-arm64.tar.gz"
.into(),
integrity_sri: "sha256-AAAA".into(),
bin: [("node".to_string(), "bin/node".to_string())].into(),
prefix: None,
}],
};
let lf = lockfile_pin_from(&pin, "^24.4.0");
assert_eq!(lf.specifier, "^24.4.0");
assert_eq!(lf.version, "24.4.1");
assert!(lf.dev);
let back = pinned_from_lockfile(&lf).unwrap();
assert_eq!(back, pin);
}
}