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