1use anyhow::{Context, Result, anyhow, bail};
2use semver::Version;
3use std::env;
4use std::ffi::OsString;
5use std::path::{Path, PathBuf};
6use std::process::{Command, ExitStatus, Stdio};
7
8pub fn resolve_binary(name: &str) -> Result<PathBuf> {
10 let locale = crate::i18n::select_locale(None);
11 let env_key = format!("GREENTIC_DEV_BIN_{}", name.replace('-', "_").to_uppercase());
12 if let Ok(path) = env::var(&env_key) {
13 let pb = PathBuf::from(path);
14 if pb.exists() {
15 return Ok(pb);
16 }
17 bail!(
18 "{}",
19 crate::i18n::tf(
20 &locale,
21 "runtime.passthrough.error.env_binary_missing",
22 &[
23 ("env_key", env_key.clone()),
24 ("path", pb.display().to_string()),
25 ],
26 )
27 );
28 }
29
30 if let Ok(path) = which::which(name) {
31 return Ok(path);
32 }
33
34 bail!(
35 "{}",
36 crate::i18n::tf(
37 &locale,
38 "runtime.passthrough.error.binary_not_found",
39 &[("name", name.to_string()), ("env_key", env_key)],
40 )
41 )
42}
43
44pub fn run_passthrough(bin: &Path, args: &[OsString], verbose: bool) -> Result<ExitStatus> {
45 let locale = crate::i18n::select_locale(None);
46 if verbose {
47 eprintln!(
48 "{}",
49 crate::i18n::tf(
50 &locale,
51 "runtime.passthrough.debug.exec",
52 &[
53 ("bin", bin.display().to_string()),
54 ("args", format!("{args:?}")),
55 ],
56 )
57 );
58 let _ = Command::new(bin)
59 .arg("--version")
60 .stdout(Stdio::inherit())
61 .stderr(Stdio::inherit())
62 .status();
63 }
64
65 Command::new(bin)
66 .args(args)
67 .stdin(Stdio::inherit())
68 .stdout(Stdio::inherit())
69 .stderr(Stdio::inherit())
70 .status()
71 .map_err(|e| {
72 anyhow!(crate::i18n::tf(
73 &locale,
74 "runtime.passthrough.error.execute",
75 &[("bin", bin.display().to_string()), ("error", e.to_string())],
76 ))
77 })
78}
79
80#[derive(Clone, Copy)]
81struct InstallSpec {
82 crate_name: &'static str,
83 bin_name: &'static str,
84}
85
86const DELEGATED_INSTALL_SPECS: [InstallSpec; 8] = [
87 InstallSpec {
88 crate_name: "greentic-component",
89 bin_name: "greentic-component",
90 },
91 InstallSpec {
92 crate_name: "greentic-flow",
93 bin_name: "greentic-flow",
94 },
95 InstallSpec {
96 crate_name: "greentic-pack",
97 bin_name: "greentic-pack",
98 },
99 InstallSpec {
100 crate_name: "greentic-runner",
101 bin_name: "greentic-runner",
102 },
103 InstallSpec {
104 crate_name: "greentic-runner",
105 bin_name: "greentic-runner-cli",
106 },
107 InstallSpec {
108 crate_name: "greentic-gui",
109 bin_name: "greentic-gui",
110 },
111 InstallSpec {
112 crate_name: "greentic-secrets",
113 bin_name: "greentic-secrets",
114 },
115 InstallSpec {
116 crate_name: "greentic-mcp",
117 bin_name: "greentic-mcp",
118 },
119];
120
121pub fn install_all_delegated_tools(latest: bool, locale: &str) -> Result<()> {
122 ensure_cargo_binstall()?;
123 for spec in DELEGATED_INSTALL_SPECS {
124 install_with_binstall(spec, latest, locale)?;
125 }
126 Ok(())
127}
128
129fn install_with_binstall(spec: InstallSpec, force_latest: bool, locale: &str) -> Result<()> {
130 eprintln!(
131 "{}",
132 crate::i18n::tf(
133 locale,
134 "runtime.tools.install.installing",
135 &[
136 ("bin_name", spec.bin_name.to_string()),
137 ("crate_name", spec.crate_name.to_string()),
138 ],
139 )
140 );
141
142 let mut cmd = Command::new("cargo");
143 cmd.arg("binstall")
144 .arg("-y")
145 .arg("--locked")
146 .arg(spec.crate_name)
147 .arg("--bin")
148 .arg(spec.bin_name);
149 if force_latest {
150 cmd.arg("--force");
151 }
152
153 let status = cmd
154 .stdin(Stdio::inherit())
155 .stdout(Stdio::inherit())
156 .stderr(Stdio::inherit())
157 .status()
158 .with_context(|| crate::i18n::t(locale, "runtime.tools.install.error.execute_binstall"))?;
159
160 if status.success() {
161 Ok(())
162 } else {
163 bail!(
164 "{}",
165 crate::i18n::tf(
166 locale,
167 "runtime.tools.install.error.binstall_failed",
168 &[
169 ("bin_name", spec.bin_name.to_string()),
170 ("crate_name", spec.crate_name.to_string()),
171 ("exit_code", format!("{:?}", status.code())),
172 ],
173 )
174 );
175 }
176}
177
178fn ensure_cargo_binstall() -> Result<()> {
179 let locale = crate::i18n::select_locale(None);
180 let installed_version = installed_cargo_binstall_version()?;
181 if installed_version.is_none() {
182 eprintln!(
183 "{}",
184 crate::i18n::t(&locale, "runtime.tools.install.installing_binstall")
185 );
186 return install_cargo_binstall();
187 }
188
189 let installed_version = installed_version.expect("checked is_some above");
190 match latest_cargo_binstall_version() {
191 Ok(latest_version) => {
192 if installed_version >= latest_version {
193 return Ok(());
194 }
195
196 eprintln!(
197 "{}",
198 crate::i18n::tf(
199 &locale,
200 "runtime.tools.install.updating_binstall",
201 &[
202 ("installed_version", installed_version.to_string()),
203 ("latest_version", latest_version.to_string()),
204 ],
205 )
206 );
207 install_cargo_binstall()
208 }
209 Err(err) => {
210 eprintln!(
211 "{}",
212 crate::i18n::tf(
213 &locale,
214 "runtime.tools.install.warn.latest_check_failed",
215 &[
216 ("error", err.to_string()),
217 ("installed_version", installed_version.to_string()),
218 ],
219 )
220 );
221 Ok(())
222 }
223 }
224}
225
226fn install_cargo_binstall() -> Result<()> {
227 let status = Command::new("cargo")
228 .arg("install")
229 .arg("cargo-binstall")
230 .arg("--locked")
231 .stdin(Stdio::inherit())
232 .stdout(Stdio::inherit())
233 .stderr(Stdio::inherit())
234 .status()
235 .with_context(|| {
236 crate::i18n::t(
237 &crate::i18n::select_locale(None),
238 "runtime.tools.install.error.execute_install_binstall",
239 )
240 })?;
241
242 if status.success() {
243 Ok(())
244 } else {
245 let locale = crate::i18n::select_locale(None);
246 bail!(
247 "{}",
248 crate::i18n::tf(
249 &locale,
250 "runtime.tools.install.error.install_binstall_failed",
251 &[("exit_code", format!("{:?}", status.code()))],
252 )
253 );
254 }
255}
256
257fn installed_cargo_binstall_version() -> Result<Option<Version>> {
258 let output = Command::new("cargo")
259 .arg("binstall")
260 .arg("-V")
261 .stdin(Stdio::null())
262 .stderr(Stdio::null())
263 .output();
264 let output = match output {
265 Ok(output) => output,
266 Err(_) => return Ok(None),
267 };
268 if !output.status.success() {
269 return Ok(None);
270 }
271
272 let stdout =
273 String::from_utf8(output.stdout).context("`cargo binstall -V` returned non-UTF8 output")?;
274 parse_installed_cargo_binstall_version(&stdout)
275}
276
277fn latest_cargo_binstall_version() -> Result<Version> {
278 let output = Command::new("cargo")
279 .arg("search")
280 .arg("cargo-binstall")
281 .arg("--limit")
282 .arg("1")
283 .stdin(Stdio::null())
284 .stderr(Stdio::null())
285 .output()
286 .with_context(|| "failed to execute `cargo search cargo-binstall --limit 1`")?;
287 if !output.status.success() {
288 bail!(
289 "`cargo search cargo-binstall --limit 1` failed with exit code {:?}",
290 output.status.code()
291 );
292 }
293
294 let stdout = String::from_utf8(output.stdout)
295 .context("`cargo search cargo-binstall --limit 1` returned non-UTF8 output")?;
296 parse_latest_cargo_binstall_version(&stdout)
297}
298
299fn parse_installed_cargo_binstall_version(stdout: &str) -> Result<Option<Version>> {
300 let line = stdout.lines().next().unwrap_or_default();
301 let maybe_version = line
302 .split_whitespace()
303 .find_map(|token| Version::parse(token.trim_start_matches('v')).ok());
304 Ok(maybe_version)
305}
306
307fn parse_latest_cargo_binstall_version(stdout: &str) -> Result<Version> {
308 let first_line = stdout
309 .lines()
310 .find(|line| !line.trim().is_empty())
311 .ok_or_else(|| anyhow!("`cargo search cargo-binstall --limit 1` returned no results"))?;
312 let (_, rhs) = first_line
313 .split_once('=')
314 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
315 let quoted = rhs
316 .split('#')
317 .next()
318 .map(str::trim)
319 .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
320 let version_text = quoted.trim_matches('"');
321 Version::parse(version_text)
322 .with_context(|| format!("failed to parse cargo-binstall version from `{first_line}`"))
323}
324
325#[cfg(test)]
326mod tests {
327 use super::{
328 DELEGATED_INSTALL_SPECS, parse_installed_cargo_binstall_version,
329 parse_latest_cargo_binstall_version,
330 };
331
332 #[test]
333 fn delegated_install_specs_include_runner_cli() {
334 let found = DELEGATED_INSTALL_SPECS.iter().any(|spec| {
335 spec.bin_name == "greentic-runner-cli" && spec.crate_name == "greentic-runner"
336 });
337 assert!(found);
338 }
339
340 #[test]
341 fn parse_installed_binstall_version_line() {
342 let parsed = parse_installed_cargo_binstall_version("cargo-binstall 1.15.7\n")
343 .expect("parse should succeed")
344 .expect("version should exist");
345 assert_eq!(parsed.to_string(), "1.15.7");
346 }
347
348 #[test]
349 fn parse_latest_binstall_version_line() {
350 let parsed = parse_latest_cargo_binstall_version(
351 "cargo-binstall = \"1.15.7\" # Binary installation for rust projects\n",
352 )
353 .expect("parse should succeed");
354 assert_eq!(parsed.to_string(), "1.15.7");
355 }
356}