use crate::utils::{command_exists, find_xbp_config_upwards};
use colored::Colorize;
use serde_json::Value;
use std::collections::BTreeSet;
use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Output;
use tokio::process::Command;
use tracing::{debug, info};
use walkdir::{DirEntry, WalkDir};
const ULTRACITE_MIN_NODE_MAJOR: u64 = 20;
const RAILWAY_MIN_NODE_MAJOR: u64 = 16;
const SUPPORTED_POSTGRES_VERSIONS: &[u8] = &[13, 14, 15, 16, 17, 18];
pub const INSTALL_COMMAND_AFTER_HELP: &str = "\
List installable targets:
xbp install
xbp install --list
xbp install -l
xbp install ls
Available targets:
azure-cli Azure CLI (cross-platform: winget/Linux script)
cloudflared Cloudflare Tunnel client via winget, Homebrew, or the Cloudflare apt repo
grafana Monitoring and observability platform
scylladb High-performance NoSQL database
nginx Full NGINX installation with modules
opencv-rust OpenCV bindings for Rust
docker Container platform
elixir-erlang Elixir and Erlang runtime
postgres Latest supported PostgreSQL server/meta-package on Linux
pg-tools PostgreSQL client tools (`psql`, `pg_dump`, `pg_restore`) on Linux
postgres13 PostgreSQL 13 server on Linux
postgres14 PostgreSQL 14 server on Linux
postgres15 PostgreSQL 15 server on Linux
postgres16 PostgreSQL 16 server on Linux
postgres17 PostgreSQL 17 server on Linux
postgres18 PostgreSQL 18 server on Linux
pg-tools13 PostgreSQL 13 client tools on Linux
pg-tools14 PostgreSQL 14 client tools on Linux
pg-tools15 PostgreSQL 15 client tools on Linux
pg-tools16 PostgreSQL 16 client tools on Linux
pg-tools17 PostgreSQL 17 client tools on Linux
pg-tools18 PostgreSQL 18 client tools on Linux
nodejs Node.js runtime and npm on Debian/Ubuntu-style Linux hosts
bun Bun runtime via the official installer
nx Nx CLI via Chocolatey, Homebrew, apt, or npm
python Python 3 runtime and `python` alias on Linux
python-pip pip for Python 3 on Linux, including the `pip` command
python-deps Common Python packages (`boto3`, `rembg`, `requests`, `tqdm`) on Linux
railway Railway CLI via `npm install -g @railway/cli`
vercel Vercel CLI via `npm i -g vercel`
triggerdotdev Background job framework
iotop I/O monitoring tool
uv uv Python package manager via Snap on Linux or the official fallback installer
ultracite Project-local JS/TS linting setup via `npx ultracite init`
Nx method aliases:
nx-npm Force npm global install method
nx-brew Force Homebrew install method on macOS/Linux
nx-choco Force Chocolatey install method on Windows
nx-apt Force Ubuntu apt install method on Linux
Use `--force` with an explicit Nx method alias to override OS method selection.
";
const PACKAGE_JSON_SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
".next",
"dist",
"build",
"coverage",
"target",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallTarget {
AzureCli,
Cloudflared,
Grafana,
Scylladb,
Nginx,
OpenCvRust,
Docker,
ElixirErlang,
PostgresDefault,
PostgresTools,
PostgresVersion(u8),
PostgresToolsVersion(u8),
NodeJs,
Bun,
Nx(Option<NxInstallMethod>),
Python,
PythonPip,
PythonDeps,
Railway,
Vercel,
TriggerDotDev,
Iotop,
Uv,
Ultracite,
}
impl InstallTarget {
fn all() -> Vec<InstallTarget> {
let mut targets = vec![
InstallTarget::AzureCli,
InstallTarget::Cloudflared,
InstallTarget::Grafana,
InstallTarget::Nginx,
InstallTarget::OpenCvRust,
InstallTarget::Docker,
InstallTarget::ElixirErlang,
InstallTarget::PostgresDefault,
InstallTarget::PostgresTools,
];
targets.extend(
SUPPORTED_POSTGRES_VERSIONS
.iter()
.copied()
.map(InstallTarget::PostgresVersion),
);
targets.extend(
SUPPORTED_POSTGRES_VERSIONS
.iter()
.copied()
.map(InstallTarget::PostgresToolsVersion),
);
targets.extend([
InstallTarget::Scylladb,
InstallTarget::NodeJs,
InstallTarget::Bun,
InstallTarget::Nx(None),
InstallTarget::Python,
InstallTarget::PythonPip,
InstallTarget::PythonDeps,
InstallTarget::Railway,
InstallTarget::Vercel,
InstallTarget::TriggerDotDev,
InstallTarget::Iotop,
InstallTarget::Uv,
InstallTarget::Ultracite,
]);
targets
}
fn parse(input: &str) -> Option<Self> {
let normalized = normalize_install_target(input);
match normalized.as_str() {
"azure-cli" => Some(Self::AzureCli),
"cloudflared" | "cloudflared-cli" | "cloudflare-tunnel" => Some(Self::Cloudflared),
"grafana" => Some(Self::Grafana),
"scylladb" | "scylla" => Some(Self::Scylladb),
"nginx" | "nginx-full" => Some(Self::Nginx),
"opencv-rust" => Some(Self::OpenCvRust),
"elixir-erlang" => Some(Self::ElixirErlang),
"docker" => Some(Self::Docker),
"postgres" | "postgresql" => Some(Self::PostgresDefault),
"pg-tools" | "postgres-tools" | "postgresql-tools" | "postgres-client"
| "postgresql-client" | "pg-client" | "psql" => Some(Self::PostgresTools),
"nodejs" | "node" => Some(Self::NodeJs),
"bun" => Some(Self::Bun),
"nx" | "nx-cli" => Some(Self::Nx(None)),
"nx-npm" => Some(Self::Nx(Some(NxInstallMethod::Npm))),
"nx-brew" | "nx-homebrew" => Some(Self::Nx(Some(NxInstallMethod::Homebrew))),
"nx-choco" | "nx-chocolatey" => Some(Self::Nx(Some(NxInstallMethod::Chocolatey))),
"nx-apt" | "nx-ubuntu" => Some(Self::Nx(Some(NxInstallMethod::Apt))),
"python" | "python3" => Some(Self::Python),
"python-pip" | "python3-pip" | "pip" | "pip3" => Some(Self::PythonPip),
"python-deps" | "python-packages" => Some(Self::PythonDeps),
"railway" | "railway-cli" | "@railway/cli" => Some(Self::Railway),
"vercel" | "vercel-cli" => Some(Self::Vercel),
"triggerdotdev" | "trigger-dot-dev" => Some(Self::TriggerDotDev),
"iotop" => Some(Self::Iotop),
"uv" => Some(Self::Uv),
"ultracite" => Some(Self::Ultracite),
_ => parse_postgres_version_target(&normalized),
}
}
fn display_name(self) -> String {
match self {
Self::AzureCli => "azure-cli".to_string(),
Self::Cloudflared => "cloudflared".to_string(),
Self::Grafana => "grafana".to_string(),
Self::Scylladb => "scylladb".to_string(),
Self::Nginx => "nginx".to_string(),
Self::OpenCvRust => "opencv-rust".to_string(),
Self::Docker => "docker".to_string(),
Self::ElixirErlang => "elixir-erlang".to_string(),
Self::PostgresDefault => "postgres".to_string(),
Self::PostgresTools => "pg-tools".to_string(),
Self::PostgresVersion(version) => format!("postgres{}", version),
Self::PostgresToolsVersion(version) => format!("pg-tools{}", version),
Self::NodeJs => "nodejs".to_string(),
Self::Bun => "bun".to_string(),
Self::Nx(None) => "nx".to_string(),
Self::Nx(Some(method)) => format!("nx-{}", method.alias()),
Self::Python => "python".to_string(),
Self::PythonPip => "python-pip".to_string(),
Self::PythonDeps => "python-deps".to_string(),
Self::Railway => "railway".to_string(),
Self::Vercel => "vercel".to_string(),
Self::TriggerDotDev => "triggerdotdev".to_string(),
Self::Iotop => "iotop".to_string(),
Self::Uv => "uv".to_string(),
Self::Ultracite => "ultracite".to_string(),
}
}
fn description(self) -> String {
match self {
Self::AzureCli => "Azure CLI (cross-platform: winget/Linux script)".to_string(),
Self::Cloudflared => {
"Cloudflare Tunnel client via winget, Homebrew, or the Cloudflare apt repo"
.to_string()
}
Self::Grafana => "Monitoring and observability platform".to_string(),
Self::Scylladb => "High-performance NoSQL database".to_string(),
Self::Nginx => "Full NGINX installation with modules".to_string(),
Self::OpenCvRust => "OpenCV bindings for Rust".to_string(),
Self::Docker => "Container platform".to_string(),
Self::ElixirErlang => "Elixir and Erlang runtime".to_string(),
Self::PostgresDefault => {
"Latest supported PostgreSQL server/meta-package on Linux".to_string()
}
Self::PostgresTools => {
"PostgreSQL client tools (`psql`, `pg_dump`, `pg_restore`) on Linux".to_string()
}
Self::PostgresVersion(version) => format!("PostgreSQL {} server on Linux", version),
Self::PostgresToolsVersion(version) => format!(
"PostgreSQL {} client tools (`psql`, `pg_dump`, `pg_restore`) on Linux",
version
),
Self::NodeJs => {
"Node.js runtime and npm on Debian/Ubuntu-style Linux hosts".to_string()
}
Self::Bun => "Bun runtime via the official installer".to_string(),
Self::Nx(None) => "Nx CLI via Chocolatey, Homebrew, apt, or npm".to_string(),
Self::Nx(Some(method)) => format!("Nx CLI via {}", method.label()),
Self::Python => "Python 3 runtime and `python` alias on Linux".to_string(),
Self::PythonPip => "pip for Python 3 on Linux, including the `pip` command".to_string(),
Self::PythonDeps => {
"Common Python packages (`boto3`, `rembg`, `requests`, `tqdm`) on Linux".to_string()
}
Self::Railway => "Railway CLI via `npm install -g @railway/cli`".to_string(),
Self::Vercel => "Vercel CLI via `npm i -g vercel`".to_string(),
Self::TriggerDotDev => "Background job framework".to_string(),
Self::Iotop => "I/O monitoring tool".to_string(),
Self::Uv => {
"uv Python package manager via Snap on Linux or the official fallback installer"
.to_string()
}
Self::Ultracite => {
"Project-local JS/TS linting setup via `npx ultracite init`".to_string()
}
}
}
fn script_name(self) -> Option<String> {
match self {
Self::AzureCli
| Self::Cloudflared
| Self::Railway
| Self::Vercel
| Self::Ultracite
| Self::NodeJs => None,
Self::Bun => None,
Self::Nx(_) => None,
Self::Grafana => Some("grafana".to_string()),
Self::Scylladb => Some("scylladb".to_string()),
Self::Nginx => Some("nginx_full".to_string()),
Self::OpenCvRust => Some("opencv-rust".to_string()),
Self::Docker => Some("docker".to_string()),
Self::ElixirErlang => Some("elixir_erlang".to_string()),
Self::PostgresDefault => Some("postgres".to_string()),
Self::PostgresTools => Some("postgres_tools".to_string()),
Self::PostgresVersion(version) => Some(format!("postgres_{}", version)),
Self::PostgresToolsVersion(version) => Some(format!("pg_tools_{}", version)),
Self::Python => Some("python".to_string()),
Self::PythonPip => Some("python_pip".to_string()),
Self::PythonDeps => Some("python_deps".to_string()),
Self::TriggerDotDev => Some("triggerdotdev".to_string()),
Self::Iotop => Some("iotop".to_string()),
Self::Uv => Some("uv".to_string()),
}
}
async fn install(self, force: bool, debug: bool) -> Result<(), String> {
match self {
Self::AzureCli => install_azure_cli(debug).await,
Self::Cloudflared => install_cloudflared_cli(debug).await,
Self::NodeJs => install_nodejs(debug).await,
Self::Bun => install_bun(debug).await,
Self::Nx(method) => install_nx_cli(method, force, debug).await,
Self::Railway => install_railway_cli(debug).await,
Self::Vercel => install_vercel_cli(debug).await,
Self::Ultracite => install_ultracite(debug).await,
_ => {
let script_name = self.script_name().expect("script-backed target");
let display_name = self.display_name();
execute_install_script(&script_name, &display_name, debug).await
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NodePackageManager {
Pnpm,
Bun,
Yarn,
Npm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NxInstallMethod {
Npm,
Homebrew,
Chocolatey,
Apt,
}
impl NxInstallMethod {
fn alias(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Homebrew => "brew",
Self::Chocolatey => "choco",
Self::Apt => "apt",
}
}
fn label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Homebrew => "Homebrew",
Self::Chocolatey => "Chocolatey",
Self::Apt => "apt",
}
}
fn is_native_for_current_os(self) -> bool {
match self {
Self::Npm => true,
Self::Homebrew => cfg!(any(target_os = "macos", target_os = "linux")),
Self::Chocolatey => cfg!(target_os = "windows"),
Self::Apt => cfg!(target_os = "linux"),
}
}
}
impl NodePackageManager {
fn as_flag(self) -> &'static str {
match self {
Self::Pnpm => "pnpm",
Self::Bun => "bun",
Self::Yarn => "yarn",
Self::Npm => "npm",
}
}
}
pub async fn install_package(package_name: &str, force: bool, debug: bool) -> Result<(), String> {
if package_name.trim().is_empty() || is_install_listing_request(package_name) {
print_install_targets_help();
return Ok(());
}
let Some(target) = InstallTarget::parse(package_name) else {
eprintln!("{} '{}'", "Unknown install target:".red(), package_name);
println!();
print_install_targets_help();
return Err(format!("Package '{}' is not supported", package_name));
};
target.install(force, debug).await
}
pub fn is_install_listing_request(package_name: &str) -> bool {
matches!(
normalize_install_target(package_name).as_str(),
"--help" | "help" | "list" | "ls"
)
}
pub fn print_install_empty_state() {
crate::cli::ui::configure_color_output();
println!();
println!("{}", "xbp install".bright_magenta().bold());
crate::cli::ui::divider(28);
println!("{}", "No install target selected.".bright_yellow().bold());
println!(
"{}",
"Choose one of the supported targets below, or run `xbp install <target>` directly."
.dimmed()
);
println!();
print_install_targets_help();
}
pub fn print_install_targets_help() {
crate::cli::ui::configure_color_output();
list_available_packages();
crate::cli::ui::section("Examples");
println!(" {}", "xbp install azure-cli".dimmed());
println!(" {}", "xbp install cloudflared".dimmed());
println!(" {}", "xbp install docker".dimmed());
println!(" {}", "xbp install postgres17".dimmed());
println!(" {}", "xbp install pg-tools".dimmed());
println!(" {}", "xbp install nodejs".dimmed());
println!(" {}", "xbp install bun".dimmed());
println!(" {}", "xbp install nx".dimmed());
println!(" {}", "xbp install python-pip".dimmed());
println!(" {}", "xbp install railway".dimmed());
println!(" {}", "xbp install vercel".dimmed());
println!(" {}", "xbp install uv".dimmed());
println!(" {}", "xbp install nginx".dimmed());
println!(" {}", "xbp install ultracite".dimmed());
crate::cli::ui::tip(
"Use `xbp install --list`, `xbp install ls`, or `xbp install -h` for the full command reference.",
);
}
fn list_available_packages() {
crate::cli::ui::section("Available packages");
for target in InstallTarget::all() {
println!(
" {:<14} {}",
target.display_name().cyan().bold(),
target.description().bright_black()
);
}
println!();
println!(
"{} {}",
"Usage:".bright_cyan().bold(),
"xbp install <package>".bright_white()
);
}
fn normalize_install_target(input: &str) -> String {
input.trim().to_ascii_lowercase().replace('_', "-")
}
fn parse_postgres_version_target(input: &str) -> Option<InstallTarget> {
parse_supported_version_suffix(input, &["postgres", "postgresql"])
.map(InstallTarget::PostgresVersion)
.or_else(|| {
parse_supported_version_suffix(
input,
&[
"pg-tools",
"postgres-tools",
"postgresql-tools",
"postgres-client",
"postgresql-client",
"pg-client",
"psql",
],
)
.map(InstallTarget::PostgresToolsVersion)
})
}
fn parse_supported_version_suffix(input: &str, prefixes: &[&str]) -> Option<u8> {
prefixes.iter().find_map(|prefix| {
let rest = input.strip_prefix(prefix)?;
let version = rest.strip_prefix('-').unwrap_or(rest);
if version.is_empty() || !version.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
let parsed = version.parse::<u8>().ok()?;
SUPPORTED_POSTGRES_VERSIONS
.contains(&parsed)
.then_some(parsed)
})
}
async fn install_nodejs(_debug: bool) -> Result<(), String> {
if command_exists("node") && command_exists("npm") {
let node_output = Command::new("node")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Node.js version: {}", e))?;
let npm_output = Command::new("npm")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check npm version: {}", e))?;
let node_version = primary_output_line(&node_output);
let npm_version = primary_output_line(&npm_output);
if node_output.status.success()
&& npm_output.status.success()
&& !node_version.is_empty()
&& !npm_version.is_empty()
{
info!(
"{}",
format!(
"Node.js is already installed: {} (npm {})",
node_version, npm_version
)
.green()
);
return Ok(());
}
}
#[cfg(target_os = "linux")]
{
if !command_exists("apt-get") {
return Err(
"Node.js install on Linux currently supports Debian/Ubuntu-style hosts with `apt-get`."
.to_string(),
);
}
let script = r#"set -euo pipefail
sudo apt-get install -y curl
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v"#;
let args = vec!["-lc".to_string(), script.to_string()];
if _debug {
debug!("Running Node.js install via bash -lc with the NodeSource 24.x setup script");
}
let output = Command::new("bash")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute Node.js installer: {}", e))?;
if _debug {
debug!("Node.js install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("bash", &args, &output));
}
if command_exists("node") && command_exists("npm") {
let node_output = Command::new("node")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify Node.js installation: {}", e))?;
let npm_output = Command::new("npm")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify npm installation: {}", e))?;
let node_version = primary_output_line(&node_output);
let npm_version = primary_output_line(&npm_output);
if node_output.status.success()
&& npm_output.status.success()
&& !node_version.is_empty()
&& !npm_version.is_empty()
{
info!(
"{}",
format!(
"Node.js installation completed successfully: {} (npm {})",
node_version, npm_version
)
.green()
);
return Ok(());
}
}
info!("{}", "Node.js installation completed successfully.".green());
info!(
"{}",
"If `node` or `npm` is not on PATH yet, open a new shell and ensure the Node.js bin directory is available."
.dimmed()
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
Err(
"Node.js install is currently supported on Debian/Ubuntu-style Linux hosts only."
.to_string(),
)
}
}
async fn install_bun(debug: bool) -> Result<(), String> {
if command_exists("bun") {
let version_output = Command::new("bun")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Bun version: {}", e))?;
let revision_output = Command::new("bun")
.arg("--revision")
.output()
.await
.map_err(|e| format!("Failed to check Bun revision: {}", e))?;
let version = primary_output_line(&version_output);
let revision = primary_output_line(&revision_output);
if version_output.status.success()
&& revision_output.status.success()
&& !version.is_empty()
&& !revision.is_empty()
{
info!(
"{}",
format!("Bun is already installed: {} ({})", version, revision).green()
);
return Ok(());
}
}
#[cfg(target_os = "windows")]
{
let shell = if command_exists("powershell") {
"powershell"
} else if command_exists("pwsh") {
"pwsh"
} else {
return Err(
"Bun install on Windows requires PowerShell (`powershell` or `pwsh`) on PATH."
.to_string(),
);
};
let args = vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-Command".to_string(),
"irm bun.sh/install.ps1 | iex".to_string(),
];
if debug {
debug!(
"Running Bun install via {} -NoProfile -ExecutionPolicy Bypass -Command irm bun.sh/install.ps1 | iex",
shell
);
}
let output = Command::new(shell)
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute Bun installer: {}", e))?;
if debug {
debug!("Bun install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure(shell, &args, &output));
}
}
#[cfg(not(target_os = "windows"))]
{
if !command_exists("bash") {
return Err("Bun install on Unix-like systems requires `bash` on PATH.".to_string());
}
if !command_exists("curl") {
return Err("Bun install on Unix-like systems requires `curl` on PATH.".to_string());
}
let script = r#"set -euo pipefail
curl -fsSL https://bun.sh/install | bash"#;
let args = vec!["-lc".to_string(), script.to_string()];
if debug {
debug!("Running Bun install via bash -lc with the official Bun installer");
}
let output = Command::new("bash")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute Bun installer: {}", e))?;
if debug {
debug!("Bun install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("bash", &args, &output));
}
}
if command_exists("bun") {
let version_output = Command::new("bun")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify Bun installation: {}", e))?;
let revision_output = Command::new("bun")
.arg("--revision")
.output()
.await
.map_err(|e| format!("Failed to verify Bun installation: {}", e))?;
let version = primary_output_line(&version_output);
let revision = primary_output_line(&revision_output);
if version_output.status.success()
&& revision_output.status.success()
&& !version.is_empty()
&& !revision.is_empty()
{
info!(
"{}",
format!(
"Bun installation completed successfully: {} ({})",
version, revision
)
.green()
);
return Ok(());
}
}
info!("{}", "Bun installation completed successfully.".green());
info!(
"{}",
"If `bun` is not on PATH yet, open a new shell so Bun's bin directory is picked up."
.dimmed()
);
Ok(())
}
async fn install_nx_cli(
requested_method: Option<NxInstallMethod>,
force: bool,
debug: bool,
) -> Result<(), String> {
if !force && command_exists("nx") {
let output = Command::new("nx")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Nx version: {}", e))?;
let version = primary_output_line(&output);
if output.status.success() && !version.is_empty() {
info!(
"{}",
format!("Nx CLI is already installed: {}", version).green()
);
return Ok(());
}
}
let method = match requested_method {
Some(method) => {
if !force && !method.is_native_for_current_os() {
return Err(format!(
"`xbp install nx-{}` is not the default method for this OS. Re-run with `--force` to override OS method selection.",
method.alias()
));
}
method
}
None => select_default_nx_install_method()?,
};
info!("Installing Nx CLI via {}...", method.label());
run_nx_install_method(method, debug).await?;
if command_exists("nx") {
let version_output = Command::new("nx")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify Nx installation: {}", e))?;
let version = primary_output_line(&version_output);
if version_output.status.success() && !version.is_empty() {
info!(
"{}",
format!("Nx CLI installation completed successfully: {}", version).green()
);
return Ok(());
}
}
info!("{}", "Nx CLI installation completed successfully.".green());
info!(
"{}",
"If `nx` is not on PATH yet, open a new shell and try `nx --version` again.".dimmed()
);
Ok(())
}
fn select_default_nx_install_method() -> Result<NxInstallMethod, String> {
if cfg!(target_os = "windows") {
if command_exists("choco") {
return Ok(NxInstallMethod::Chocolatey);
}
if command_exists("npm") {
return Ok(NxInstallMethod::Npm);
}
return Err(
"Nx install on Windows requires Chocolatey (`choco`) or npm on PATH.".to_string(),
);
}
if cfg!(target_os = "macos") {
if command_exists("brew") {
return Ok(NxInstallMethod::Homebrew);
}
if command_exists("npm") {
return Ok(NxInstallMethod::Npm);
}
return Err("Nx install on macOS requires Homebrew or npm on PATH.".to_string());
}
if cfg!(target_os = "linux") {
if command_exists("apt-get") {
return Ok(NxInstallMethod::Apt);
}
if command_exists("brew") {
return Ok(NxInstallMethod::Homebrew);
}
if command_exists("npm") {
return Ok(NxInstallMethod::Npm);
}
return Err("Nx install on Linux requires apt, Homebrew, or npm on PATH.".to_string());
}
if command_exists("npm") {
return Ok(NxInstallMethod::Npm);
}
Err("Nx install on this OS requires npm on PATH.".to_string())
}
async fn run_nx_install_method(method: NxInstallMethod, debug: bool) -> Result<(), String> {
match method {
NxInstallMethod::Npm => {
if !command_exists("npm") {
return Err("Nx install via npm requires `npm` on PATH.".to_string());
}
let args = vec!["add".to_string(), "--global".to_string(), "nx".to_string()];
run_install_command("npm", &args, "Nx npm install", debug).await
}
NxInstallMethod::Homebrew => {
if !command_exists("brew") {
return Err("Nx install via Homebrew requires `brew` on PATH.".to_string());
}
let args = vec!["install".to_string(), "nx".to_string()];
run_install_command("brew", &args, "Nx Homebrew install", debug).await
}
NxInstallMethod::Chocolatey => {
if !command_exists("choco") {
return Err("Nx install via Chocolatey requires `choco` on PATH.".to_string());
}
let args = vec!["install".to_string(), "nx".to_string(), "-y".to_string()];
run_install_command("choco", &args, "Nx Chocolatey install", debug).await
}
NxInstallMethod::Apt => {
if !command_exists("bash") {
return Err("Nx install via apt requires `bash` on PATH.".to_string());
}
if !command_exists("apt-get") {
return Err("Nx install via apt requires `apt-get` on PATH.".to_string());
}
let script = r#"set -euo pipefail
sudo add-apt-repository -y ppa:nrwl/nx
sudo apt update
sudo apt install -y nx"#;
let args = vec!["-lc".to_string(), script.to_string()];
run_install_command("bash", &args, "Nx apt install", debug).await
}
}
}
async fn run_install_command(
command: &str,
args: &[String],
label: &str,
debug: bool,
) -> Result<(), String> {
if debug {
debug!("Running {}: {} {}", label, command, args.join(" "));
}
let output = Command::new(command)
.args(args)
.output()
.await
.map_err(|e| format!("Failed to execute {}: {}", label, e))?;
if debug {
debug!("{} output: {:?}", label, output);
}
if !output.status.success() {
return Err(format_command_failure(command, args, &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
info!("{}", stdout.trim());
}
Ok(())
}
async fn install_azure_cli(_debug: bool) -> Result<(), String> {
if command_exists("az") {
info!("{}", "Azure CLI is already installed.".green());
return Ok(());
}
#[cfg(target_os = "windows")]
{
if !command_exists("winget") {
return Err(
"winget is required. Install it from the Microsoft Store or use Windows 10/11."
.to_string(),
);
}
info!("Installing Azure CLI via winget...");
let output = Command::new("winget")
.args([
"install",
"--exact",
"--id",
"Microsoft.AzureCLI",
"--accept-package-agreements",
"--accept-source-agreements",
])
.output()
.await
.map_err(|e| format!("winget failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("Azure CLI install failed: {}{}", stdout, stderr));
}
info!(
"{}",
"Azure CLI installed. Restart your terminal to use 'az'.".green()
);
Ok(())
}
#[cfg(target_os = "macos")]
{
if !command_exists("brew") {
return Err("Homebrew is required. Install from https://brew.sh/".to_string());
}
info!("Installing Azure CLI via Homebrew...");
let output = Command::new("brew")
.args(["install", "azure-cli"])
.output()
.await
.map_err(|e| format!("brew install failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Azure CLI install failed: {}", stderr));
}
info!("{}", "Azure CLI installed successfully.".green());
Ok(())
}
#[cfg(target_os = "linux")]
{
info!("Installing Azure CLI via official script...");
let script = "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash";
let output = Command::new("sh")
.args(["-c", script])
.output()
.await
.map_err(|e| format!("Azure CLI install failed: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Azure CLI install failed: {}", stderr));
}
info!("{}", "Azure CLI installed successfully.".green());
Ok(())
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
Err("Azure CLI install is supported on Windows, Linux, and macOS only.".to_string())
}
}
async fn install_cloudflared_cli(debug: bool) -> Result<(), String> {
if command_exists("cloudflared") {
let output = Command::new("cloudflared")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check cloudflared version: {}", e))?;
let version = primary_output_line(&output);
if output.status.success() && !version.is_empty() {
info!(
"{}",
format!("cloudflared is already installed: {}", version).green()
);
return Ok(());
}
info!("{}", "cloudflared is already installed.".green());
return Ok(());
}
#[cfg(target_os = "windows")]
{
if !command_exists("winget") {
return Err(
"cloudflared install on Windows requires the `winget` command on PATH.".to_string(),
);
}
let args = vec![
"install".to_string(),
"--exact".to_string(),
"--id".to_string(),
"Cloudflare.cloudflared".to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
];
if debug {
debug!("Running cloudflared install: winget {}", args.join(" "));
}
let output = Command::new("winget")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute cloudflared installer: {}", e))?;
if debug {
debug!("cloudflared install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("winget", &args, &output));
}
}
#[cfg(target_os = "macos")]
{
if !command_exists("brew") {
return Err(
"cloudflared install on macOS requires Homebrew. Install it from https://brew.sh/."
.to_string(),
);
}
let args = vec!["install".to_string(), "cloudflared".to_string()];
if debug {
debug!("Running cloudflared install: brew {}", args.join(" "));
}
let output = Command::new("brew")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute cloudflared installer: {}", e))?;
if debug {
debug!("cloudflared install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("brew", &args, &output));
}
}
#[cfg(target_os = "linux")]
{
if !command_exists("apt-get") {
return Err(
"cloudflared install on Linux currently supports Debian/Ubuntu-style hosts with `apt-get`."
.to_string(),
);
}
if !command_exists("curl") {
return Err("cloudflared install on Linux requires `curl` on PATH.".to_string());
}
let script = r#"set -euo pipefail
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list >/dev/null
sudo apt-get update
sudo apt-get install -y cloudflared"#;
let args = vec!["-lc".to_string(), script.to_string()];
if debug {
debug!(
"Running cloudflared install via bash -lc with the stable Cloudflare apt repository"
);
}
let output = Command::new("bash")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute cloudflared installer: {}", e))?;
if debug {
debug!("cloudflared install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("bash", &args, &output));
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
return Err(
"cloudflared install is supported on Windows, Linux, and macOS only.".to_string(),
);
}
if command_exists("cloudflared") {
let version_output = Command::new("cloudflared")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify cloudflared installation: {}", e))?;
let version = primary_output_line(&version_output);
if version_output.status.success() && !version.is_empty() {
info!(
"{}",
format!(
"cloudflared installation completed successfully: {}",
version
)
.green()
);
return Ok(());
}
}
info!(
"{}",
"cloudflared installation completed successfully.".green()
);
info!(
"{}",
"If `cloudflared` is not on PATH yet, open a new shell and try `cloudflared --version` again."
.dimmed()
);
Ok(())
}
async fn install_ultracite(debug: bool) -> Result<(), String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to resolve current directory: {}", e))?;
let project_dir = resolve_ultracite_project_dir(¤t_dir)?;
ensure_ultracite_prerequisites(debug).await?;
let package_manager = detect_node_package_manager(&project_dir);
let frameworks = infer_ultracite_frameworks(&project_dir);
let args = build_ultracite_init_args(package_manager, &frameworks);
info!(
"Initializing Ultracite in project root: {}",
project_dir.display()
);
info!(
"Using package manager preset: {}",
package_manager.as_flag()
);
if !frameworks.is_empty() {
info!("Detected frameworks: {}", frameworks.join(", "));
}
if debug {
debug!("Running Ultracite init: npx {}", args.join(" "));
}
let output = Command::new("npx")
.args(&args)
.current_dir(&project_dir)
.output()
.await
.map_err(|e| format!("Failed to execute Ultracite installer: {}", e))?;
if debug {
debug!("Ultracite init output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("npx", &args, &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
info!("{}", stdout.trim());
}
info!("{}", "Ultracite initialized successfully.".green());
info!(
"{}",
"Tip: run `npx ultracite doctor` to validate the generated setup.".dimmed()
);
Ok(())
}
async fn install_railway_cli(debug: bool) -> Result<(), String> {
if command_exists("railway") {
let output = Command::new("railway")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Railway CLI version: {}", e))?;
let version = primary_output_line(&output);
if output.status.success() && !version.is_empty() {
info!(
"{}",
format!("Railway CLI is already installed: {}", version).green()
);
return Ok(());
}
info!("{}", "Railway CLI is already installed.".green());
return Ok(());
}
ensure_railway_prerequisites(debug).await?;
let args = vec![
"install".to_string(),
"-g".to_string(),
"@railway/cli".to_string(),
];
if debug {
debug!("Running Railway CLI install: npm {}", args.join(" "));
}
let output = Command::new("npm")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute Railway CLI installer: {}", e))?;
if debug {
debug!("Railway CLI install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("npm", &args, &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
info!("{}", stdout.trim());
}
if command_exists("railway") {
let version_output = Command::new("railway")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify Railway CLI installation: {}", e))?;
let version = primary_output_line(&version_output);
if version_output.status.success() && !version.is_empty() {
info!(
"{}",
format!(
"Railway CLI installation completed successfully: {}",
version
)
.green()
);
return Ok(());
}
}
info!(
"{}",
"Railway CLI installation completed successfully.".green()
);
info!(
"{}",
"If `railway` is not on PATH yet, open a new shell and ensure `~/.railway/bin` is available."
.dimmed()
);
Ok(())
}
async fn install_vercel_cli(debug: bool) -> Result<(), String> {
if command_exists("vercel") {
let output = Command::new("vercel")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Vercel CLI version: {}", e))?;
let version = primary_output_line(&output);
if output.status.success() && !version.is_empty() {
info!(
"{}",
format!("Vercel CLI is already installed: {}", version).green()
);
return Ok(());
}
info!("{}", "Vercel CLI is already installed.".green());
return Ok(());
}
ensure_vercel_prerequisites().await?;
let args = vec!["i".to_string(), "-g".to_string(), "vercel".to_string()];
if debug {
debug!("Running Vercel CLI install: npm {}", args.join(" "));
}
let output = Command::new("npm")
.args(&args)
.output()
.await
.map_err(|e| format!("Failed to execute Vercel CLI installer: {}", e))?;
if debug {
debug!("Vercel CLI install output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure("npm", &args, &output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
info!("{}", stdout.trim());
}
if command_exists("vercel") {
let version_output = Command::new("vercel")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to verify Vercel CLI installation: {}", e))?;
let version = primary_output_line(&version_output);
if version_output.status.success() && !version.is_empty() {
info!(
"{}",
format!(
"Vercel CLI installation completed successfully: {}",
version
)
.green()
);
return Ok(());
}
}
info!(
"{}",
"Vercel CLI installation completed successfully.".green()
);
info!(
"{}",
"If `vercel` is not on PATH yet, open a new shell and ensure your npm global bin directory is available."
.dimmed()
);
Ok(())
}
fn resolve_ultracite_project_dir(current_dir: &Path) -> Result<PathBuf, String> {
if let Some(found) = find_xbp_config_upwards(current_dir) {
let project_root = found.project_root;
if project_root.join("package.json").exists() {
return Ok(project_root);
}
if let Some(package_dir) =
find_nearest_package_dir_ancestor(current_dir, Some(project_root.as_path()))
{
return Ok(package_dir);
}
let nested_package_dirs = find_nested_package_dirs(&project_root);
if nested_package_dirs.len() == 1 {
return Ok(nested_package_dirs[0].clone());
}
if nested_package_dirs.len() > 1 {
let candidates = nested_package_dirs
.iter()
.take(4)
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"Ultracite requires a package.json-backed Node project. The XBP root `{}` has multiple nested package roots ({}). Run `xbp install ultracite` from the package you want to configure.",
project_root.display(),
candidates
));
}
return Err(format!(
"Ultracite requires a package.json-backed Node project. No package.json was found in the current XBP project root `{}`.",
project_root.display()
));
}
if let Some(package_dir) = find_nearest_package_dir_ancestor(current_dir, None) {
return Ok(package_dir);
}
Err(
"Ultracite requires a JavaScript or TypeScript project with a package.json file."
.to_string(),
)
}
fn find_nearest_package_dir_ancestor(start: &Path, stop_at: Option<&Path>) -> Option<PathBuf> {
for dir in start.ancestors() {
if dir.join("package.json").exists() {
return Some(dir.to_path_buf());
}
if stop_at == Some(dir) {
break;
}
}
None
}
fn find_nested_package_dirs(root: &Path) -> Vec<PathBuf> {
let mut dirs = BTreeSet::new();
for path in collect_package_json_files(root) {
if let Some(parent) = path.parent() {
dirs.insert(parent.to_path_buf());
}
}
dirs.into_iter().collect()
}
fn collect_package_json_files(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(should_walk_package_tree)
{
let Ok(entry) = entry else {
continue;
};
if entry.file_type().is_file() && entry.file_name() == OsStr::new("package.json") {
files.push(entry.into_path());
}
}
files.sort();
files
}
fn should_walk_package_tree(entry: &DirEntry) -> bool {
if !entry.file_type().is_dir() {
return true;
}
let name = entry.file_name().to_string_lossy();
!PACKAGE_JSON_SKIP_DIRS.contains(&name.as_ref())
}
async fn ensure_ultracite_prerequisites(debug: bool) -> Result<(), String> {
if !command_exists("npx") {
return Err(
"Ultracite requires `npx`, but it was not found on PATH. Install Node.js with npm first."
.to_string(),
);
}
ensure_node_min_major(ULTRACITE_MIN_NODE_MAJOR, "Ultracite", debug).await
}
async fn ensure_railway_prerequisites(debug: bool) -> Result<(), String> {
if !command_exists("npm") {
return Err(
"Railway CLI install via npm requires `npm` on PATH. Install Node.js 16+ first."
.to_string(),
);
}
ensure_node_min_major(RAILWAY_MIN_NODE_MAJOR, "Railway CLI", debug).await
}
async fn ensure_vercel_prerequisites() -> Result<(), String> {
if !command_exists("node") {
return Err(
"Vercel CLI install requires `node` on PATH. Install Node.js first.".to_string(),
);
}
if !command_exists("npm") {
return Err(
"Vercel CLI install requires `npm` on PATH. Install Node.js first.".to_string(),
);
}
Ok(())
}
async fn ensure_node_min_major(min_major: u64, label: &str, debug: bool) -> Result<(), String> {
if !command_exists("node") {
return Err(format!(
"{} requires Node.js {}+ but `node` was not found on PATH.",
label, min_major
));
}
let output = Command::new("node")
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to check Node.js version: {}", e))?;
if debug {
debug!("Node.js version probe output: {:?}", output);
}
if !output.status.success() {
return Err(format_command_failure(
"node",
&["--version".to_string()],
&output,
));
}
let version = primary_output_line(&output);
let major = parse_node_major_version(&version).ok_or_else(|| {
format!(
"Failed to parse Node.js version from `node --version` output: {}",
version
)
})?;
if major < min_major {
return Err(format!(
"{} requires Node.js {}+ but found {}.",
label, min_major, version
));
}
Ok(())
}
fn detect_node_package_manager(project_dir: &Path) -> NodePackageManager {
let lockfile_order = [
("pnpm-lock.yaml", NodePackageManager::Pnpm),
("bun.lockb", NodePackageManager::Bun),
("bun.lock", NodePackageManager::Bun),
("yarn.lock", NodePackageManager::Yarn),
("package-lock.json", NodePackageManager::Npm),
("npm-shrinkwrap.json", NodePackageManager::Npm),
("pnpm-workspace.yaml", NodePackageManager::Pnpm),
];
for (file, package_manager) in lockfile_order {
if project_dir.join(file).exists() {
return package_manager;
}
}
detect_package_manager_from_package_json(&project_dir.join("package.json"))
.unwrap_or(NodePackageManager::Npm)
}
fn detect_package_manager_from_package_json(path: &Path) -> Option<NodePackageManager> {
let content = std::fs::read_to_string(path).ok()?;
let json: Value = serde_json::from_str(&content).ok()?;
let package_manager = json.get("packageManager")?.as_str()?;
let name = package_manager
.split('@')
.next()?
.trim()
.to_ascii_lowercase();
match name.as_str() {
"pnpm" => Some(NodePackageManager::Pnpm),
"bun" => Some(NodePackageManager::Bun),
"yarn" => Some(NodePackageManager::Yarn),
"npm" => Some(NodePackageManager::Npm),
_ => None,
}
}
fn infer_ultracite_frameworks(project_dir: &Path) -> Vec<String> {
let mut dependency_names = BTreeSet::new();
for package_json in collect_package_json_files(project_dir) {
dependency_names.extend(read_dependency_names(&package_json));
}
let has_any = |names: &[&str]| names.iter().any(|name| dependency_names.contains(*name));
let mut frameworks = Vec::new();
if has_any(&["react", "react-dom", "next"]) {
frameworks.push("react".to_string());
}
if has_any(&["next"]) {
frameworks.push("next".to_string());
}
if has_any(&["solid-js", "solid-start"]) {
frameworks.push("solid".to_string());
}
if has_any(&["vue", "nuxt", "nuxt3"]) {
frameworks.push("vue".to_string());
}
if has_any(&["svelte", "@sveltejs/kit"]) {
frameworks.push("svelte".to_string());
}
if has_any(&["@builder.io/qwik", "@builder.io/qwik-city"]) {
frameworks.push("qwik".to_string());
}
if has_any(&["remix", "@remix-run/dev", "@remix-run/react"]) {
frameworks.push("remix".to_string());
}
if has_any(&[
"@tanstack/router-plugin",
"@tanstack/react-router",
"@tanstack/react-start",
"@tanstack/start",
]) {
frameworks.push("tanstack".to_string());
}
if has_any(&["@angular/core"]) {
frameworks.push("angular".to_string());
}
if has_any(&["astro"]) {
frameworks.push("astro".to_string());
}
if has_any(&["@nestjs/core", "@nestjs/common"]) {
frameworks.push("nestjs".to_string());
}
frameworks
}
fn read_dependency_names(package_json_path: &Path) -> BTreeSet<String> {
let mut names = BTreeSet::new();
let Ok(content) = std::fs::read_to_string(package_json_path) else {
return names;
};
let Ok(json) = serde_json::from_str::<Value>(&content) else {
return names;
};
for key in [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
] {
if let Some(map) = json.get(key).and_then(Value::as_object) {
names.extend(map.keys().cloned());
}
}
names
}
fn build_ultracite_init_args(
package_manager: NodePackageManager,
frameworks: &[String],
) -> Vec<String> {
let mut args = vec![
"--yes".to_string(),
"ultracite".to_string(),
"init".to_string(),
"--quiet".to_string(),
"--linter".to_string(),
"biome".to_string(),
"--pm".to_string(),
package_manager.as_flag().to_string(),
];
if !frameworks.is_empty() {
args.push("--frameworks".to_string());
args.extend(frameworks.iter().cloned());
}
args
}
fn parse_node_major_version(version: &str) -> Option<u64> {
version
.trim()
.trim_start_matches('v')
.split('.')
.next()?
.parse()
.ok()
}
fn primary_output_line(output: &Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
fn format_command_failure(program: &str, args: &[String], output: &Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let details = match (stdout.is_empty(), stderr.is_empty()) {
(false, false) => format!("stdout: {}\nstderr: {}", stdout, stderr),
(false, true) => format!("stdout: {}", stdout),
(true, false) => format!("stderr: {}", stderr),
(true, true) => "no command output was captured".to_string(),
};
format!(
"Command failed: {} {}\n{}",
program,
args.join(" "),
details
)
}
async fn execute_install_script(
script_name: &str,
display_name: &str,
debug: bool,
) -> Result<(), String> {
let script_path = format!("./package_install_scripts/{}.sh", script_name);
let script_pathbuf = PathBuf::from(&script_path);
if debug {
debug!("Looking for install script at: {}", script_path);
}
if !script_pathbuf.exists() {
return Err(format!("Install script not found: {}", script_path));
}
if debug {
debug!("Making script executable: {}", script_path);
}
let chmod_output = Command::new("chmod")
.arg("+x")
.arg(&script_path)
.output()
.await
.map_err(|e| format!("Failed to execute chmod command: {}", e))?;
if !chmod_output.status.success() {
return Err(format!(
"Failed to make script executable: {}",
String::from_utf8_lossy(&chmod_output.stderr)
));
}
if debug {
debug!("Executing install script: {}", script_path);
}
let script_output = Command::new("bash")
.arg(&script_path)
.output()
.await
.map_err(|e| format!("Failed to execute install script: {}", e))?;
if debug {
debug!("Script output: {:?}", script_output);
}
if !script_output.status.success() {
return Err(format!(
"Install script failed: {}",
String::from_utf8_lossy(&script_output.stderr)
));
}
let stdout = String::from_utf8_lossy(&script_output.stdout);
if !stdout.trim().is_empty() {
info!("{}", stdout);
}
info!("{} installation completed successfully!", display_name);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use uuid::Uuid;
fn temp_dir(label: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("xbp-install-{}-{}", label, Uuid::new_v4()));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn write_json(path: &Path, value: Value) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(
path,
serde_json::to_string_pretty(&value).expect("serialize json"),
)
.expect("write json");
}
#[test]
fn install_target_parser_accepts_documented_aliases() {
assert_eq!(InstallTarget::parse("nginx"), Some(InstallTarget::Nginx));
assert_eq!(
InstallTarget::parse("nginx_full"),
Some(InstallTarget::Nginx)
);
assert_eq!(
InstallTarget::parse("elixir_erlang"),
Some(InstallTarget::ElixirErlang)
);
assert_eq!(
InstallTarget::parse("postgresql"),
Some(InstallTarget::PostgresDefault)
);
assert_eq!(
InstallTarget::parse("postgres-17"),
Some(InstallTarget::PostgresVersion(17))
);
assert_eq!(
InstallTarget::parse("postgresql18"),
Some(InstallTarget::PostgresVersion(18))
);
assert_eq!(
InstallTarget::parse("pg-tools-17"),
Some(InstallTarget::PostgresToolsVersion(17))
);
assert_eq!(
InstallTarget::parse("psql16"),
Some(InstallTarget::PostgresToolsVersion(16))
);
assert_eq!(InstallTarget::parse("python3"), Some(InstallTarget::Python));
assert_eq!(InstallTarget::parse("pip"), Some(InstallTarget::PythonPip));
assert_eq!(
InstallTarget::parse("python-packages"),
Some(InstallTarget::PythonDeps)
);
assert_eq!(InstallTarget::parse("nodejs"), Some(InstallTarget::NodeJs));
assert_eq!(InstallTarget::parse("node"), Some(InstallTarget::NodeJs));
assert_eq!(InstallTarget::parse("bun"), Some(InstallTarget::Bun));
assert_eq!(InstallTarget::parse("nx"), Some(InstallTarget::Nx(None)));
assert_eq!(
InstallTarget::parse("nx-cli"),
Some(InstallTarget::Nx(None))
);
assert_eq!(
InstallTarget::parse("nx-npm"),
Some(InstallTarget::Nx(Some(NxInstallMethod::Npm)))
);
assert_eq!(
InstallTarget::parse("nx-homebrew"),
Some(InstallTarget::Nx(Some(NxInstallMethod::Homebrew)))
);
assert_eq!(
InstallTarget::parse("nx-chocolatey"),
Some(InstallTarget::Nx(Some(NxInstallMethod::Chocolatey)))
);
assert_eq!(
InstallTarget::parse("nx-ubuntu"),
Some(InstallTarget::Nx(Some(NxInstallMethod::Apt)))
);
assert_eq!(
InstallTarget::parse("railway-cli"),
Some(InstallTarget::Railway)
);
assert_eq!(
InstallTarget::parse("cloudflared-cli"),
Some(InstallTarget::Cloudflared)
);
assert_eq!(
InstallTarget::parse("cloudflare-tunnel"),
Some(InstallTarget::Cloudflared)
);
assert_eq!(
InstallTarget::parse("@railway/cli"),
Some(InstallTarget::Railway)
);
assert_eq!(
InstallTarget::parse("vercel-cli"),
Some(InstallTarget::Vercel)
);
assert_eq!(
InstallTarget::parse("trigger-dot-dev"),
Some(InstallTarget::TriggerDotDev)
);
assert_eq!(
InstallTarget::parse("scylla"),
Some(InstallTarget::Scylladb)
);
assert_eq!(InstallTarget::parse("uv"), Some(InstallTarget::Uv));
assert_eq!(
InstallTarget::parse("ultracite"),
Some(InstallTarget::Ultracite)
);
}
#[test]
fn install_listing_request_aliases_are_recognized() {
assert!(is_install_listing_request("help"));
assert!(is_install_listing_request("--help"));
assert!(is_install_listing_request("list"));
assert!(is_install_listing_request("ls"));
assert!(!is_install_listing_request("docker"));
}
#[test]
fn install_target_catalog_includes_bun() {
assert!(InstallTarget::all().contains(&InstallTarget::Bun));
}
#[test]
fn install_target_catalog_includes_nx() {
assert!(InstallTarget::all().contains(&InstallTarget::Nx(None)));
}
#[test]
fn ultracite_project_resolution_prefers_xbp_root_package_json() {
let dir = temp_dir("xbp-root-package");
let current_dir = dir.join("apps").join("web").join("src");
fs::create_dir_all(¤t_dir).expect("create nested dir");
fs::create_dir_all(dir.join(".xbp")).expect("create xbp dir");
fs::write(dir.join(".xbp").join("xbp.yaml"), "services: []\n").expect("write xbp yaml");
write_json(&dir.join("package.json"), json!({ "name": "root-app" }));
write_json(
&dir.join("apps").join("web").join("package.json"),
json!({ "name": "nested-app" }),
);
let resolved = resolve_ultracite_project_dir(¤t_dir).expect("resolve project dir");
assert_eq!(resolved, dir);
let _ = fs::remove_dir_all(&resolved);
}
#[test]
fn ultracite_project_resolution_uses_single_nested_package_when_needed() {
let dir = temp_dir("xbp-single-nested");
fs::create_dir_all(dir.join(".xbp")).expect("create xbp dir");
fs::write(dir.join(".xbp").join("xbp.yaml"), "services: []\n").expect("write xbp yaml");
write_json(
&dir.join("apps").join("web").join("package.json"),
json!({ "name": "web-app" }),
);
let resolved = resolve_ultracite_project_dir(&dir).expect("resolve nested package dir");
assert_eq!(resolved, dir.join("apps").join("web"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn package_manager_detection_prefers_lockfiles() {
let dir = temp_dir("pm-detection");
write_json(
&dir.join("package.json"),
json!({ "name": "app", "packageManager": "npm@10.0.0" }),
);
fs::write(dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").expect("write lock");
assert_eq!(detect_node_package_manager(&dir), NodePackageManager::Pnpm);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn framework_inference_scans_workspace_package_json_files() {
let dir = temp_dir("framework-detection");
write_json(
&dir.join("package.json"),
json!({
"name": "workspace",
"workspaces": ["apps/*", "packages/*"]
}),
);
write_json(
&dir.join("apps").join("web").join("package.json"),
json!({
"name": "web",
"dependencies": {
"next": "15.0.0"
}
}),
);
write_json(
&dir.join("packages").join("api").join("package.json"),
json!({
"name": "api",
"dependencies": {
"@nestjs/core": "11.0.0"
}
}),
);
let frameworks = infer_ultracite_frameworks(&dir);
assert_eq!(
frameworks,
vec![
"react".to_string(),
"next".to_string(),
"nestjs".to_string()
]
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ultracite_init_args_are_non_interactive_and_biome_default() {
let args = build_ultracite_init_args(
NodePackageManager::Pnpm,
&["react".to_string(), "next".to_string()],
);
assert_eq!(
args,
vec![
"--yes".to_string(),
"ultracite".to_string(),
"init".to_string(),
"--quiet".to_string(),
"--linter".to_string(),
"biome".to_string(),
"--pm".to_string(),
"pnpm".to_string(),
"--frameworks".to_string(),
"react".to_string(),
"next".to_string(),
]
);
}
#[test]
fn node_major_version_parser_handles_v_prefix() {
assert_eq!(parse_node_major_version("v22.14.0"), Some(22));
assert_eq!(parse_node_major_version("20.11.1"), Some(20));
assert_eq!(parse_node_major_version("not-a-version"), None);
}
}