use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::str;
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use prek_consts::env_vars::EnvVars;
use prek_consts::prepend_paths;
use semver::Version;
use tracing::debug;
use url::Url;
use crate::cli::reporter::HookInstallReporter;
use crate::git::GitCommandExt;
use crate::hook::InstalledHook;
use crate::hook::{Hook, InstallInfo};
use crate::languages::node::NodeRequest;
use crate::languages::node::installer::{
NodeInstaller, bin_dir, lib_dir, query_node_version_cached,
};
use crate::languages::node::version::EXTRA_KEY_LTS;
use crate::languages::{ExecutionEnvironment, LanguageBackend};
use crate::process::Cmd;
use crate::store::{CacheBucket, Store, ToolBucket};
#[derive(Debug, Copy, Clone)]
pub(crate) struct Node;
const NPM_CONFIG_PREFIX_ENV: &str = "npm_config_prefix";
const NPM_CONFIG_CACHE_ENV: &str = "npm_config_cache";
const NPM_CONFIG_ENVS_TO_REMOVE: &[&str] = &[
"NPM_CONFIG_PREFIX",
"npm_config_prefix",
"NPM_CONFIG_GLOBAL_PREFIX",
"npm_config_global_prefix",
"NPM_CONFIG_LOCAL_PREFIX",
"npm_config_local_prefix",
"NPM_CONFIG_CACHE",
"npm_config_cache",
];
#[async_trait::async_trait(?Send)]
impl LanguageBackend for Node {
async fn install(
&self,
store: &Store,
hook: Arc<Hook>,
reporter: &HookInstallReporter,
) -> Result<InstalledHook> {
let progress = reporter.on_install_start(&hook);
let node_dir = store.tools_path(ToolBucket::Node);
let installer = NodeInstaller::new(node_dir);
let node_request: &NodeRequest = hook.language_request.version();
let node = installer
.install(store, node_request, hook.language_request.allows_download())
.await
.context("Failed to install node")?;
let mut info = InstallInfo::new(&hook, &store.hooks_dir())?;
let lts = serde_json::to_string(&node.version().lts).context("Failed to serialize LTS")?;
info.with_toolchain(node.node().to_path_buf());
info.with_language_version(node.version().version.clone());
info.with_extra(EXTRA_KEY_LTS, <s);
let bin_dir = bin_dir(&info.env_path);
let lib_dir = lib_dir(&info.env_path);
fs_err::tokio::create_dir_all(&bin_dir).await?;
fs_err::tokio::create_dir_all(&lib_dir).await?;
let hook_repo = node_hook_repo_spec(&hook)?;
if hook_repo.is_none() && hook.additional_dependencies.is_empty() {
debug!("No dependencies to install");
} else {
let node_bin = node.node().parent().expect("Node binary must have parent");
let new_path = prepend_paths(&[&bin_dir, node_bin]).context("Failed to join PATH")?;
let npm_cache = store.cache_path(CacheBucket::Npm);
Npm {
executable: node.npm(),
path: &new_path,
node_path: &lib_dir,
prefix: &info.env_path,
cache: &npm_cache,
}
.install_dependencies(hook_repo.as_deref(), &hook.additional_dependencies)
.await?;
}
info.persist_env_path();
reporter.on_install_complete(progress);
Ok(InstalledHook::Installed {
hook,
info: Arc::new(info),
})
}
async fn check_health(&self, info: &InstallInfo) -> Result<()> {
let version = query_node_version_cached(&info.toolchain)
.await
.context("Failed to query node version")?;
if version.version != info.language_version {
anyhow::bail!(
"Node version mismatch: expected {}, found {}",
info.language_version,
version.version
);
}
Ok(())
}
fn execution_environment(
&self,
store: &Store,
hook: &InstalledHook,
) -> Result<ExecutionEnvironment> {
let env_dir = hook.env_path().expect("Node must have env path");
let node_bin = hook.toolchain_dir().expect("Node binary must have parent");
let new_path =
prepend_paths(&[&bin_dir(env_dir), node_bin]).context("Failed to join PATH")?;
let npm_cache = store.cache_path(CacheBucket::Npm);
let mut environment = ExecutionEnvironment::new();
environment
.set_path(&new_path)
.env(EnvVars::NODE_PATH, lib_dir(env_dir));
for key in NPM_CONFIG_ENVS_TO_REMOVE {
environment.env_remove(key);
}
environment
.env(NPM_CONFIG_PREFIX_ENV, env_dir)
.env(NPM_CONFIG_CACHE_ENV, &npm_cache);
Ok(environment)
}
}
fn node_hook_repo_spec(hook: &Hook) -> Result<Option<String>> {
hook.repo_path()
.map(|repo_path| {
let file_url = Url::from_file_path(repo_path).map_err(|()| {
anyhow!(
"Failed to convert Node hook repository path to a file URL: {}",
repo_path.display()
)
})?;
Ok(format!("git+{file_url}"))
})
.transpose()
}
struct PackedNodeHook {
_temp_dir: tempfile::TempDir,
archive: PathBuf,
}
enum HookInstall<'a> {
None,
Git(&'a str),
Tarball(PackedNodeHook),
}
struct Npm<'a> {
executable: &'a Path,
path: &'a OsStr,
node_path: &'a Path,
prefix: &'a Path,
cache: &'a Path,
}
impl Npm<'_> {
async fn install_dependencies(
&self,
hook_repo: Option<&str>,
additional_dependencies: &[String],
) -> Result<()> {
let hook_install = match hook_repo {
Some(hook_repo) if self.version().await?.major >= 12 => HookInstall::Git(hook_repo),
Some(hook_repo) => HookInstall::Tarball(self.pack_hook(hook_repo).await?),
None => HookInstall::None,
};
let mut cmd = self.command();
cmd.arg("install")
.arg("-g")
.arg("--no-progress")
.arg("--no-save")
.arg("--no-fund")
.arg("--no-audit");
match &hook_install {
HookInstall::None => {}
HookInstall::Git(hook_repo) => {
cmd.arg("--allow-git=root").arg(hook_repo);
}
HookInstall::Tarball(packed_hook) => {
cmd.arg(&packed_hook.archive);
}
}
cmd.args(additional_dependencies);
cmd.check(true).output().await?;
Ok(())
}
async fn pack_hook(&self, hook_repo: &str) -> Result<PackedNodeHook> {
let temp_dir = tempfile::tempdir().context("Failed to create npm pack directory")?;
let mut cmd = self.command();
cmd.arg("pack")
.arg("--global=false")
.arg("--pack-destination")
.arg(temp_dir.path())
.arg(hook_repo);
cmd.check(true)
.output()
.await
.context("Failed to pack Node hook repository")?;
let mut archives = fs_err::read_dir(temp_dir.path())
.context("Failed to read npm pack directory")?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<std::io::Result<Vec<_>>>()?;
archives.retain(|path| path.extension().is_some_and(|extension| extension == "tgz"));
let archive = match archives.as_slice() {
[archive] => archive.clone(),
_ => {
return Err(anyhow!(
"npm pack produced {} package archives; expected exactly one",
archives.len()
));
}
};
Ok(PackedNodeHook {
_temp_dir: temp_dir,
archive,
})
}
async fn version(&self) -> Result<Version> {
let output = Cmd::new(self.executable)
.arg("--version")
.env(EnvVars::PATH, self.path)
.check(true)
.output()
.await?;
Version::parse(str::from_utf8(&output.stdout)?.trim())
.context("Failed to parse npm version")
}
fn command(&self) -> Cmd {
let mut cmd = Cmd::new(self.executable);
cmd.sanitize_git_repo_env()
.env(EnvVars::PATH, self.path)
.env(EnvVars::NODE_PATH, self.node_path);
for key in NPM_CONFIG_ENVS_TO_REMOVE {
cmd.env_remove(key);
}
cmd.env(NPM_CONFIG_PREFIX_ENV, self.prefix);
cmd.env(NPM_CONFIG_CACHE_ENV, self.cache);
cmd
}
}