Skip to main content

idt/
installers.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4use std::time::Duration;
5use std::time::Instant;
6
7use owo_colors::OwoColorize;
8use rootcause::bail;
9use rootcause::prelude::ResultExt;
10use ytil_cmd::Cmd;
11use ytil_cmd::CmdError;
12use ytil_cmd::CmdExt;
13
14pub mod alacritty;
15pub mod bash_language_server;
16pub mod cargo;
17pub mod commitlint;
18pub mod deno;
19pub mod docker_langserver;
20pub mod eslint_d;
21pub mod graphql_lsp;
22pub mod hadolint;
23pub mod helm_ls;
24pub mod lua_ls;
25pub mod marksman;
26pub mod nvim;
27pub mod opencode;
28pub mod prettierd;
29pub mod quicktype;
30pub mod rio;
31pub mod ruff_lsp;
32pub mod shellcheck;
33pub mod sql_language_server;
34pub mod sqruff;
35pub mod starship;
36pub mod terraform_ls;
37pub mod typescript_language_server;
38pub mod typos_lsp;
39pub mod vscode_langservers;
40pub mod yaml_language_server;
41pub mod zellij;
42
43/// Trait for installing development tools.
44pub trait Installer: Sync + Send {
45    /// Returns the binary name.
46    fn bin_name(&self) -> &'static str;
47
48    /// Installs the tool.
49    fn install(&self) -> rootcause::Result<()>;
50
51    /// Runs the installed binary to verify it is functional.
52    fn health_check(&self) -> Option<rootcause::Result<String>> {
53        let args = self.health_check_args()?;
54        let mut cmd = Command::new(self.bin_name());
55        cmd.args(args);
56
57        Some(run_health_check(cmd))
58    }
59
60    /// Execute install + optional health check with timing output.
61    ///
62    /// # Errors
63    /// - Install or health check phase fails.
64    fn run(&self) -> rootcause::Result<()> {
65        let start = Instant::now();
66
67        // Install phase
68        self.install().inspect_err(|err| {
69            eprintln!(
70                "{} error installing\n{}",
71                self.bin_name().red().bold(),
72                format!("{err:#?}").red()
73            );
74        })?;
75
76        let past_install = Instant::now();
77
78        // Health check phase (optional)
79        let mut health_check_duration = None;
80        let health_check_start = Instant::now();
81        let health_check_res = self.health_check();
82        if health_check_res.is_some() {
83            health_check_duration = Some(health_check_start.elapsed());
84        }
85
86        match health_check_res {
87            Some(Ok(health_check_output)) => {
88                let styled_bin_name = if self.should_verify_checksum() {
89                    self.bin_name().green().bold().to_string()
90                } else {
91                    self.bin_name().yellow().bold().to_string()
92                };
93                println!(
94                    "{styled_bin_name} {} health_check_output=\n{}",
95                    format_timing(start, past_install, health_check_duration),
96                    health_check_output.trim_matches(|c| c == '\n' || c == '\r')
97                );
98            }
99            Some(Err(err)) => {
100                eprintln!(
101                    "{} error in health check {}\n{}",
102                    self.bin_name().red(),
103                    format_timing(start, past_install, health_check_duration),
104                    format!("{err:#?}").red()
105                );
106                return Err(err);
107            }
108            None => {
109                let styled_bin_name = if self.should_verify_checksum() {
110                    self.bin_name().blue().bold().to_string()
111                } else {
112                    self.bin_name().magenta().bold().to_string()
113                };
114                println!(
115                    "{styled_bin_name} {}",
116                    format_timing(start, past_install, health_check_duration),
117                );
118            }
119        }
120
121        Ok(())
122    }
123
124    /// Returns arguments for the health check (e.g. `--version`).
125    fn health_check_args(&self) -> Option<&[&str]> {
126        Some(&["--version"])
127    }
128
129    /// Whether the download is checksum-verified. Defaults to `true`.
130    ///
131    /// Override to return `false` for curl-based installers whose releases do not publish checksums.
132    fn should_verify_checksum(&self) -> bool {
133        true
134    }
135}
136
137/// Runs a command used to verify an installed binary.
138pub fn run_health_check(mut cmd: Command) -> rootcause::Result<String> {
139    let output = cmd.exec()?;
140    let output = std::str::from_utf8(&output.stdout).map_err(|err| CmdError::Utf8 {
141        cmd: Cmd::from(&cmd),
142        source: err,
143    })?;
144    Ok(output.to_owned())
145}
146
147pub trait SystemDependent {
148    fn target_arch_and_os(&self) -> (&str, &str);
149}
150
151/// Common install pattern for npm-based tools: download via npm, symlink the binary, and make it executable.
152///
153/// # Errors
154/// - If npm download fails.
155/// - If symlink creation fails.
156/// - If chmod fails.
157pub fn install_npm_tool(
158    dev_tools_dir: &Path,
159    bin_dir: &Path,
160    bin_name: &str,
161    npm_name: &str,
162    packages: &[&str],
163) -> rootcause::Result<()> {
164    let target_dir = crate::downloaders::npm::run(dev_tools_dir, npm_name, packages)?;
165    let target = target_dir.join(bin_name);
166    ytil_sys::file::ln_sf(&target, &bin_dir.join(bin_name))?;
167    ytil_sys::file::chmod_x(target)?;
168    Ok(())
169}
170
171/// Common install pattern for macOS `.app` bundles built from source.
172///
173/// 1. symlink the binary into `bin_dir`
174/// 2. make it executable
175/// 3. copy the `.app` bundle into `/Applications` with an atomic swap
176///
177/// # Errors
178/// - If symlink creation fails.
179/// - If chmod fails.
180/// - If copy to `/Applications` fails.
181pub fn install_macos_app(app: &Path, bin_dir: &Path, bin_name: &str) -> rootcause::Result<()> {
182    let binary = app.join("Contents").join("MacOS").join(bin_name);
183
184    ytil_sys::file::ln_sf(&binary, &bin_dir.join(bin_name))?;
185    ytil_sys::file::chmod_x(&binary)?;
186
187    let Some(app_filename) = app.file_name().and_then(|n| n.to_str()) else {
188        bail!("app path has no valid UTF-8 file name: {}", app.display());
189    };
190
191    let applications_app = PathBuf::from(format!("/Applications/{app_filename}"));
192    let applications_app_old = PathBuf::from(format!("/Applications/{app_filename}.old"));
193
194    if applications_app_old.exists() {
195        std::fs::remove_dir_all(&applications_app_old)
196            .context("error removing old .app backup")
197            .attach_with(|| format!("path={}", applications_app_old.display()))?;
198    }
199
200    if applications_app.is_symlink() {
201        std::fs::remove_file(&applications_app)
202            .context("error removing .app symlink")
203            .attach_with(|| format!("path={}", applications_app.display()))?;
204    } else if applications_app.exists() {
205        std::fs::rename(&applications_app, &applications_app_old)
206            .context("error renaming existing .app to .old")
207            .attach_with(|| format!("from={}", applications_app.display()))
208            .attach_with(|| format!("to={}", applications_app_old.display()))?;
209    }
210
211    ytil_cmd::silent_cmd("cp")
212        .args(["-R", &app.display().to_string(), "/Applications/"])
213        .status()
214        .context("failed to spawn cp for .app bundle")?
215        .exit_ok()
216        .context("cp .app bundle to /Applications failed")
217        .attach_with(|| format!("app={}", app.display()))?;
218
219    if applications_app_old.exists() {
220        std::fs::remove_dir_all(&applications_app_old)
221            .context("error cleaning up old .app backup")
222            .attach_with(|| format!("path={}", applications_app_old.display()))?;
223    }
224
225    Ok(())
226}
227
228/// Format phase timing summary line.
229fn format_timing(start: Instant, past_install: Instant, health_check: Option<Duration>) -> String {
230    format!(
231        "install_time={:?} health_check_time={:?} total_time={:?}",
232        past_install.duration_since(start),
233        health_check,
234        start.elapsed()
235    )
236}