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;
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
triggerdotdev Background job framework
iotop I/O monitoring tool
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,
TriggerDotDev,
Iotop,
Ultracite,
}
impl InstallTarget {
fn all() -> &'static [InstallTarget] {
&[
InstallTarget::AzureCli,
InstallTarget::Grafana,
InstallTarget::Scylladb,
InstallTarget::Nginx,
InstallTarget::OpenCvRust,
InstallTarget::Docker,
InstallTarget::ElixirErlang,
InstallTarget::TriggerDotDev,
InstallTarget::Iotop,
InstallTarget::Ultracite,
]
}
fn parse(input: &str) -> Option<Self> {
match normalize_install_target(input).as_str() {
"azure-cli" => Some(Self::AzureCli),
"grafana" => Some(Self::Grafana),
"scylladb" => Some(Self::Scylladb),
"nginx" | "nginx-full" => Some(Self::Nginx),
"opencv-rust" => Some(Self::OpenCvRust),
"elixir-erlang" => Some(Self::ElixirErlang),
"docker" => Some(Self::Docker),
"triggerdotdev" | "trigger-dot-dev" => Some(Self::TriggerDotDev),
"iotop" => Some(Self::Iotop),
"ultracite" => Some(Self::Ultracite),
_ => None,
}
}
fn display_name(self) -> &'static str {
match self {
Self::AzureCli => "azure-cli",
Self::Grafana => "grafana",
Self::Scylladb => "scylladb",
Self::Nginx => "nginx",
Self::OpenCvRust => "opencv-rust",
Self::Docker => "docker",
Self::ElixirErlang => "elixir-erlang",
Self::TriggerDotDev => "triggerdotdev",
Self::Iotop => "iotop",
Self::Ultracite => "ultracite",
}
}
fn description(self) -> &'static str {
match self {
Self::AzureCli => "Azure CLI (cross-platform: winget/Linux script)",
Self::Grafana => "Monitoring and observability platform",
Self::Scylladb => "High-performance NoSQL database",
Self::Nginx => "Full NGINX installation with modules",
Self::OpenCvRust => "OpenCV bindings for Rust",
Self::Docker => "Container platform",
Self::ElixirErlang => "Elixir and Erlang runtime",
Self::TriggerDotDev => "Background job framework",
Self::Iotop => "I/O monitoring tool",
Self::Ultracite => "Project-local JS/TS linting setup via `npx ultracite init`",
}
}
fn script_name(self) -> Option<&'static str> {
match self {
Self::AzureCli | Self::Ultracite => None,
Self::Grafana => Some("grafana"),
Self::Scylladb => Some("scylladb"),
Self::Nginx => Some("nginx_full"),
Self::OpenCvRust => Some("opencv-rust"),
Self::Docker => Some("docker"),
Self::ElixirErlang => Some("elixir_erlang"),
Self::TriggerDotDev => Some("triggerdotdev"),
Self::Iotop => Some("iotop"),
}
}
async fn install(self, debug: bool) -> Result<(), String> {
match self {
Self::AzureCli => install_azure_cli(debug).await,
Self::Ultracite => install_ultracite(debug).await,
_ => {
execute_install_script(
self.script_name().expect("script-backed target"),
self.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 nginx".dimmed());
println!(" {}", "xbp install ultracite".dimmed());
println!();
println!(
"{} {}",
"Tip:".bright_yellow().bold(),
"Use `xbp install --list`, `xbp install ls`, or `xbp <command> -h` for more detail."
);
}
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('_', "-")
}
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("trigger-dot-dev"),
Some(InstallTarget::TriggerDotDev)
);
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);
}
}