Skip to main content

xbp_cli/commands/
setup.rs

1//! Setup command module
2//!
3//! Installs common system packages required by deployments. The command runs a
4//! curated `apt install` invocation and reports progress and errors. Intended
5//! for Ubuntu/Debian systems.
6use std::process::Output;
7use std::time::Instant;
8use tokio::process::Command;
9use tracing::{debug, info};
10
11use crate::logging::{log_timed, LogLevel};
12use crate::utils::command_exists;
13
14/// Execute the `setup` command.
15///
16/// Runs a non-interactive set of package installs. When `debug` is true, prints
17/// the command and raw output for diagnostics.
18pub async fn run_setup(debug: bool) -> Result<(), String> {
19    let (program, args, description) = build_setup_command()?;
20    if debug {
21        debug!("Running setup command: {} {}", program, args.join(" "));
22    }
23    let start: Instant = Instant::now();
24    let output: Output = Command::new(&program)
25        .args(&args)
26        .output()
27        .await
28        .map_err(|e| format!("Failed to execute setup command: {}", e))?;
29
30    let elapsed = start.elapsed();
31
32    if debug {
33        debug!("Setup command output: {:?}", output);
34        debug!("Setup command took: {:.2?}", elapsed);
35    }
36
37    if !output.status.success() {
38        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
39        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
40        let details = if !stderr.is_empty() { stderr } else { stdout };
41        return Err(format!(
42            "Setup failed while running '{} {}': {}",
43            program,
44            args.join(" "),
45            details
46        ));
47    }
48
49    let _ = log_timed(
50        LogLevel::Success,
51        "setup",
52        &format!("Setup completed with {}", description),
53        elapsed.as_millis() as u64,
54    )
55    .await;
56
57    if !output.stdout.is_empty() {
58        info!("Setup output: {}", String::from_utf8_lossy(&output.stdout));
59    }
60
61    info!("Setup completed successfully!");
62    Ok(())
63}
64
65fn build_setup_command() -> Result<(String, Vec<String>, String), String> {
66    let target_os = if cfg!(target_os = "macos") {
67        "macos"
68    } else if cfg!(target_os = "linux") {
69        "linux"
70    } else if cfg!(target_os = "windows") {
71        "windows"
72    } else {
73        "unsupported"
74    };
75
76    build_setup_command_for(target_os, command_exists)
77}
78
79fn build_setup_command_for<F>(
80    target_os: &str,
81    command_exists: F,
82) -> Result<(String, Vec<String>, String), String>
83where
84    F: Fn(&str) -> bool,
85{
86    if target_os == "macos" {
87        if !command_exists("brew") {
88            return Err(
89                "Homebrew is required on macOS. Install it first from https://brew.sh/."
90                    .to_string(),
91            );
92        }
93
94        return Ok((
95            "brew".to_string(),
96            vec![
97                "install".to_string(),
98                "nginx".to_string(),
99                "pkg-config".to_string(),
100                "openssl@3".to_string(),
101                "findutils".to_string(),
102                "inetutils".to_string(),
103                "certbot".to_string(),
104                "python".to_string(),
105            ],
106            "Homebrew".to_string(),
107        ));
108    }
109
110    if target_os == "linux" {
111        let package_manager = if command_exists("apt-get") {
112            "apt-get"
113        } else if command_exists("apt") {
114            "apt"
115        } else {
116            return Err(
117                "Unsupported Linux package manager. Install dependencies manually or add apt/apt-get.".to_string(),
118            );
119        };
120
121        let mut args = Vec::new();
122        let program = if command_exists("sudo") {
123            args.push(package_manager.to_string());
124            "sudo".to_string()
125        } else {
126            package_manager.to_string()
127        };
128
129        args.extend(
130            [
131                "install",
132                "-y",
133                "net-tools",
134                "nginx",
135                "pkg-config",
136                "libssl-dev",
137                "build-essential",
138                "plocate",
139                "sshpass",
140                "neofetch",
141                "certbot",
142                "python3-certbot-nginx",
143            ]
144            .into_iter()
145            .map(str::to_string),
146        );
147
148        return Ok((program, args, package_manager.to_string()));
149    }
150
151    if target_os == "windows" {
152        if command_exists("winget") {
153            let args = vec![
154                "install".to_string(),
155                "Git.Git".to_string(),
156                "OpenJS.NodeJS.LTS".to_string(),
157                "Python.Python.3.12".to_string(),
158                "ShiningLight.OpenSSL".to_string(),
159                "nginx.nginx".to_string(),
160                "--accept-package-agreements".to_string(),
161                "--accept-source-agreements".to_string(),
162            ];
163            return Ok(("winget".to_string(), args, "winget".to_string()));
164        }
165        if command_exists("choco") {
166            let packages = ["git", "nodejs-lts", "python", "openssl", "nginx"];
167            let mut args = vec!["install".to_string(), "-y".to_string()];
168            args.extend(packages.iter().map(|s| s.to_string()));
169            return Ok(("choco".to_string(), args, "Chocolatey".to_string()));
170        }
171        return Err(
172            "Windows setup requires winget (built-in on Windows 10/11) or Chocolatey.\n\
173             Install Chocolatey from https://chocolatey.org/ if winget is unavailable."
174                .to_string(),
175        );
176    }
177
178    Err("Setup is currently supported on Linux (apt), macOS (Homebrew), and Windows (winget/Chocolatey).".to_string())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::build_setup_command_for;
184
185    #[test]
186    fn linux_prefers_apt_get_and_sudo_when_available() {
187        let command = build_setup_command_for("linux", |cmd| matches!(cmd, "apt-get" | "sudo"))
188            .expect("linux setup command should build");
189
190        assert_eq!(command.0, "sudo");
191        assert_eq!(command.1[0], "apt-get");
192        assert_eq!(command.1[1], "install");
193        assert_eq!(command.2, "apt-get");
194    }
195
196    #[test]
197    fn linux_falls_back_to_apt_without_sudo() {
198        let command =
199            build_setup_command_for("linux", |cmd| cmd == "apt").expect("apt should be accepted");
200
201        assert_eq!(command.0, "apt");
202        assert_eq!(command.1[0], "install");
203        assert_eq!(command.2, "apt");
204    }
205
206    #[test]
207    fn linux_errors_without_supported_package_manager() {
208        let error = build_setup_command_for("linux", |_| false)
209            .expect_err("linux setup should fail without apt");
210
211        assert!(error.contains("Unsupported Linux package manager"));
212    }
213
214    #[test]
215    fn macos_requires_brew() {
216        let error = build_setup_command_for("macos", |_| false)
217            .expect_err("macOS setup should require brew");
218
219        assert!(error.contains("Homebrew is required on macOS"));
220    }
221
222    #[test]
223    fn macos_uses_brew_install_command() {
224        let command =
225            build_setup_command_for("macos", |cmd| cmd == "brew").expect("brew should be accepted");
226
227        assert_eq!(command.0, "brew");
228        assert_eq!(command.1[0], "install");
229        assert!(command.1.iter().any(|arg| arg == "python"));
230        assert_eq!(command.2, "Homebrew");
231    }
232}