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 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)
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
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,
Grafana,
Scylladb,
Nginx,
OpenCvRust,
Docker,
ElixirErlang,
PostgresDefault,
PostgresTools,
PostgresVersion(u8),
PostgresToolsVersion(u8),
Python,
PythonPip,
PythonDeps,
TriggerDotDev,
Iotop,
Uv,
Ultracite,
}
impl InstallTarget {
fn all() -> Vec<InstallTarget> {
let mut targets = vec![
InstallTarget::AzureCli,
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::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),
"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),
"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::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::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::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::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::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::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 docker".dimmed());
println!(" {}", "xbp install postgres17".dimmed());
println!(" {}", "xbp install pg-tools".dimmed());
println!(" {}", "xbp install python-pip".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_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(())
}
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("node") {
return Err(format!(
"Ultracite requires Node.js {}+ but `node` was not found on PATH.",
ULTRACITE_MIN_NODE_MAJOR
));
}
if !command_exists("npx") {
return Err(
"Ultracite requires `npx`, but it was not found on PATH. Install Node.js with npm first."
.to_string(),
);
}
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 < ULTRACITE_MIN_NODE_MAJOR {
return Err(format!(
"Ultracite requires Node.js {}+ but found {}.",
ULTRACITE_MIN_NODE_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("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);
}
}