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
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 `pnpm 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`
";
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),
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::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),
"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::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::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 `pnpm 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 => {
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, debug: bool) -> Result<(), String> {
match self {
Self::AzureCli => install_azure_cli(debug).await,
Self::Cloudflared => install_cloudflared_cli(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,
}
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, 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(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() {
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() {
list_available_packages();
println!();
println!("{}", "Examples:".bright_blue().bold());
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 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());
println!();
println!(
"{} Use `xbp install --list`, `xbp install ls`, or `xbp <command> -h` for more detail.",
"Tip:".bright_yellow().bold()
);
}
fn list_available_packages() {
println!("{}", "Available packages:".bright_blue().bold());
println!();
for target in InstallTarget::all() {
println!(
" {} - {}",
target.display_name().cyan(),
target.description()
);
}
println!();
println!(
"{} {}",
"Usage:".bright_blue(),
"xbp install <package>".yellow()
);
println!(
"{} {}",
"Discover:".bright_blue(),
"Run `xbp install`, `xbp install --list`, or `xbp install ls` to inspect targets.".yellow()
);
}
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_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: pnpm {}", args.join(" "));
}
let output = Command::new("pnpm")
.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("pnpm", &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 pnpm 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 via pnpm requires `node` on PATH.".to_string());
}
if !command_exists("pnpm") {
return Err("Vercel CLI install requires `pnpm` on PATH.".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("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 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);
}
}