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