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
45pub trait Installer: Sync + Send {
47 fn bin_name(&self) -> &'static str;
49
50 fn install(&self) -> rootcause::Result<()>;
52
53 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 fn run(&self) -> rootcause::Result<()> {
83 let start = Instant::now();
84
85 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 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 fn health_check_args(&self) -> Option<&[&str]> {
144 Some(&["--version"])
145 }
146
147 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
159pub 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
179pub 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
236fn 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}