Skip to main content

idt/
installers.rs

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