use miette::{IntoDiagnostic, WrapErr, miette};
use std::path::{Path, PathBuf};
const BUCKET: &str = "v12";
const SPEC: &str = "^12.0.0";
#[cfg(windows)]
const BINARY_NAMES: &[&str] = &["node-gyp.cmd", "node-gyp.exe", "node-gyp"];
#[cfg(not(windows))]
const BINARY_NAMES: &[&str] = &["node-gyp"];
fn node_gyp_on_path() -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
if node_gyp_bin_exists(&dir) {
return true;
}
}
false
}
pub(crate) fn node_gyp_bin_exists(bin_dir: &Path) -> bool {
BINARY_NAMES.iter().any(|name| bin_dir.join(name).exists())
}
fn primary_binary_name() -> &'static str {
BINARY_NAMES[0]
}
fn tool_root() -> miette::Result<PathBuf> {
let cache = aube_store::dirs::cache_dir()
.ok_or_else(|| miette!("could not resolve cache dir for node-gyp bootstrap"))?;
Ok(cache.join("tools").join("node-gyp"))
}
pub async fn ensure_cached(project_dir: &Path) -> miette::Result<PathBuf> {
let root = tool_root()?;
let tool_dir = root.join(BUCKET);
let bin_dir = tool_dir.join("node_modules").join(".bin");
if node_gyp_bin_exists(&bin_dir) {
return Ok(bin_dir);
}
let tool_dir_blocking = tool_dir.clone();
let project_npmrc = project_dir.join(".npmrc");
tokio::task::spawn_blocking(move || {
write_bootstrap_project(&tool_dir_blocking, &project_npmrc)
})
.await
.into_diagnostic()
.wrap_err("node-gyp bootstrap task panicked")??;
let lock = crate::commands::take_project_lock(&tool_dir)?;
if node_gyp_bin_exists(&bin_dir) {
return Ok(bin_dir);
}
tracing::info!("bootstrapping node-gyp {SPEC} into {}", tool_dir.display());
let mut opts = super::InstallOptions::with_mode(super::FrozenMode::Prefer);
opts.ignore_scripts = true;
opts.control = super::InstallControl::silent();
super::run_with_project_lock(opts, &lock)
.await
.wrap_err_with(|| {
format!(
"failed to bootstrap node-gyp {SPEC} into {} — \
pre-populate it or run `{}` once while online",
tool_dir.display(),
aube_util::cmd("install")
)
})?;
if !node_gyp_bin_exists(&bin_dir) {
return Err(miette!(
"node-gyp bootstrap into {} reported success but left no node-gyp binary in {}",
tool_dir.display(),
bin_dir.display()
));
}
Ok(bin_dir)
}
pub(crate) fn lazy_shim_bin_dir(project_bin_dir: &Path) -> miette::Result<Option<PathBuf>> {
if node_gyp_bin_exists(project_bin_dir) || node_gyp_on_path() {
return Ok(None);
}
let shim_dir = tool_root()?.join("lazy-bin");
write_lazy_shims(&shim_dir)?;
Ok(Some(shim_dir))
}
pub(crate) fn lazy_js_shim_path() -> miette::Result<PathBuf> {
let shim_dir = tool_root()?.join("lazy-bin");
write_lazy_shims(&shim_dir)?;
Ok(shim_dir.join("node-gyp.js"))
}
pub(crate) async fn print_bootstrapped_binary(project_dir: &Path) -> miette::Result<()> {
let bin_dir = ensure_cached(project_dir).await?;
println!("{}", bin_dir.join(primary_binary_name()).display());
Ok(())
}
const SH_SHIM: &str = r#"#!/usr/bin/env sh
set -eu
real="$("$AUBE_NODE_GYP_EXE" __node-gyp-bootstrap "$AUBE_NODE_GYP_PROJECT_DIR")"
exec "$real" "$@"
"#;
const JS_SHIM: &str = r#"#!/usr/bin/env node
"use strict";
// aube lazy node-gyp stand-in for npm_config_node_gyp. Resolves (and
// bootstraps on first use) aube's node-gyp, then forwards argv. Kept
// dependency-free; writing this file is free, the bootstrap only fires
// when something actually invokes it. Bare `require` (no `node:` prefix)
// so the shim runs under any Node the user drives, including pre-16.
const { execFileSync, spawnSync } = require("child_process");
const isWin = process.platform === "win32";
let real;
const exe = process.env.AUBE_NODE_GYP_EXE;
if (exe) {
const dir = process.env.AUBE_NODE_GYP_PROJECT_DIR || process.cwd();
real = execFileSync(exe, ["__node-gyp-bootstrap", dir], { encoding: "utf8" }).trim();
} else {
real = isWin ? "node-gyp.cmd" : "node-gyp";
}
const result = spawnSync(real, process.argv.slice(2), { stdio: "inherit", shell: isWin });
if (result.error) {
console.error("aube: failed to run node-gyp (" + real + "): " + result.error.message);
process.exit(1);
}
process.exit(result.status === null ? 1 : result.status);
"#;
#[cfg(windows)]
const CMD_SHIM: &str = r#"@echo off
for /f "usebackq delims=" %%i in (`"%AUBE_NODE_GYP_EXE%" __node-gyp-bootstrap "%AUBE_NODE_GYP_PROJECT_DIR%"`) do set "AUBE_REAL_NODE_GYP=%%i"
if not defined AUBE_REAL_NODE_GYP exit /b 1
"%AUBE_REAL_NODE_GYP%" %*
"#;
fn write_shim_if_stale(path: &Path, contents: &str) -> miette::Result<()> {
if shim_is_current(path, contents) {
return Ok(());
}
aube_util::fs_atomic::atomic_write(path, contents.as_bytes()).into_diagnostic()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(SHIM_MODE))
.into_diagnostic()?;
}
Ok(())
}
#[cfg(unix)]
const SHIM_MODE: u32 = 0o755;
fn shim_is_current(path: &Path, contents: &str) -> bool {
use std::io::Read;
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let Ok(meta) = f.metadata() else {
return false;
};
if !meta.is_file() || meta.len() != contents.len() as u64 {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o777 != SHIM_MODE {
return false;
}
}
let mut on_disk = Vec::with_capacity(contents.len());
f.read_to_end(&mut on_disk).is_ok() && on_disk == contents.as_bytes()
}
fn write_lazy_shims(shim_dir: &Path) -> miette::Result<()> {
write_shim_if_stale(&shim_dir.join("node-gyp"), SH_SHIM)?;
write_shim_if_stale(&shim_dir.join("node-gyp.js"), JS_SHIM)?;
#[cfg(windows)]
write_shim_if_stale(&shim_dir.join("node-gyp.cmd"), CMD_SHIM)?;
Ok(())
}
fn write_bootstrap_project(tool_dir: &Path, project_npmrc: &Path) -> miette::Result<()> {
std::fs::create_dir_all(tool_dir).into_diagnostic()?;
let manifest = format!(
r#"{{"name":"aube-tool-node-gyp","private":true,"dependencies":{{"node-gyp":"{SPEC}"}}}}"#
);
aube_util::fs_atomic::atomic_write(&tool_dir.join("package.json"), manifest.as_bytes())
.into_diagnostic()?;
let marker = aube_manifest::workspace::workspace_yaml_names()
.first()
.copied()
.unwrap_or("pnpm-workspace.yaml");
aube_util::fs_atomic::atomic_write(&tool_dir.join(marker), b"").into_diagnostic()?;
let tool_npmrc = tool_dir.join(".npmrc");
if project_npmrc.exists() {
std::fs::copy(project_npmrc, &tool_npmrc)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to propagate {} to node-gyp bootstrap dir",
project_npmrc.display()
)
})?;
} else if tool_npmrc.exists() {
let _ = std::fs::remove_file(&tool_npmrc);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"aube-gyp-shim-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn writes_shims_into_a_missing_dir() {
let dir = tempdir().join("lazy-bin");
write_lazy_shims(&dir).unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("node-gyp")).unwrap(),
SH_SHIM
);
assert_eq!(
std::fs::read_to_string(dir.join("node-gyp.js")).unwrap(),
JS_SHIM
);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn repeat_calls_do_not_rewrite() {
let dir = tempdir().join("lazy-bin");
write_lazy_shims(&dir).unwrap();
let sh = dir.join("node-gyp");
let js = dir.join("node-gyp.js");
let before = (
std::fs::metadata(&sh).unwrap().modified().unwrap(),
std::fs::metadata(&js).unwrap().modified().unwrap(),
);
assert!(shim_is_current(&sh, SH_SHIM));
assert!(shim_is_current(&js, JS_SHIM));
write_lazy_shims(&dir).unwrap();
let after = (
std::fs::metadata(&sh).unwrap().modified().unwrap(),
std::fs::metadata(&js).unwrap().modified().unwrap(),
);
assert_eq!(before, after, "shims were rewritten despite matching bytes");
let strays: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.filter(|n| n.contains(".tmp."))
.collect();
assert!(strays.is_empty(), "left temp files behind: {strays:?}");
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn stale_content_is_rewritten() {
let dir = tempdir().join("lazy-bin");
write_lazy_shims(&dir).unwrap();
let sh = dir.join("node-gyp");
std::fs::write(&sh, "#!/usr/bin/env sh\necho from an older aube\n").unwrap();
assert!(!shim_is_current(&sh, SH_SHIM));
write_lazy_shims(&dir).unwrap();
assert_eq!(std::fs::read_to_string(&sh).unwrap(), SH_SHIM);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn same_length_different_bytes_is_not_current() {
let dir = tempdir().join("lazy-bin");
write_lazy_shims(&dir).unwrap();
let sh = dir.join("node-gyp");
let mut drifted = SH_SHIM.as_bytes().to_vec();
*drifted.last_mut().unwrap() = b' ';
std::fs::write(&sh, &drifted).unwrap();
assert_eq!(drifted.len(), SH_SHIM.len());
assert!(!shim_is_current(&sh, SH_SHIM));
write_lazy_shims(&dir).unwrap();
assert_eq!(std::fs::read_to_string(&sh).unwrap(), SH_SHIM);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn missing_file_is_not_current() {
let dir = tempdir();
assert!(!shim_is_current(&dir.join("nope"), SH_SHIM));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn stripped_exec_bit_is_restored() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().join("lazy-bin");
write_lazy_shims(&dir).unwrap();
let sh = dir.join("node-gyp");
assert_eq!(
std::fs::metadata(&sh).unwrap().permissions().mode() & 0o777,
SHIM_MODE
);
std::fs::set_permissions(&sh, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(!shim_is_current(&sh, SH_SHIM));
write_lazy_shims(&dir).unwrap();
assert_eq!(
std::fs::metadata(&sh).unwrap().permissions().mode() & 0o777,
SHIM_MODE
);
assert_eq!(std::fs::read_to_string(&sh).unwrap(), SH_SHIM);
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
}
#[test]
fn directory_in_the_way_is_not_current() {
let dir = tempdir();
let path = dir.join("node-gyp");
std::fs::create_dir_all(&path).unwrap();
assert!(!shim_is_current(&path, SH_SHIM));
let _ = std::fs::remove_dir_all(dir);
}
}