1use super::build::*;
2pub use super::build_error::ProtoBuildError;
3pub use super::install_error::ProtoInstallError;
4use crate::checksum::*;
5use crate::env::ProtoConsole;
6use crate::helpers::{extract_filename_from_url, is_archive_file, is_offline};
7use crate::lockfile::*;
8use crate::tool::Tool;
9use crate::utils::archive;
10use crate::utils::log::LogWriter;
11use proto_pdk_api::*;
12use starbase_styles::color;
13use starbase_utils::net::DownloadOptions;
14use starbase_utils::{fs, net, path};
15use std::path::Path;
16use std::sync::Arc;
17use system_env::System;
18use tracing::{debug, instrument};
19
20#[derive(Clone, Debug)]
23pub enum InstallPhase {
24 Native,
25 Download { url: String, file: String },
26 Verify { url: String, file: String },
27 Unpack { file: String },
28 InstallDeps,
29 CheckRequirements,
30 ExecuteInstructions,
31 CloneRepository { url: String },
32}
33
34pub use starbase_utils::net::OnChunkFn;
35pub type OnPhaseFn = Arc<dyn Fn(InstallPhase) + Send + Sync>;
36
37#[derive(Default)]
38pub struct InstallOptions {
39 pub console: Option<ProtoConsole>,
40 pub force: bool,
41 pub log_writer: Option<LogWriter>,
42 pub on_download_chunk: Option<OnChunkFn>,
43 pub on_phase_change: Option<OnPhaseFn>,
44 pub skip_prompts: bool,
45 pub skip_ui: bool,
46 pub strategy: InstallStrategy,
47}
48
49impl Tool {
50 pub fn is_installed(&self) -> bool {
53 let dir = self.get_product_dir();
54
55 self.version.as_ref().is_some_and(|v| {
56 !v.is_latest() && self.inventory.manifest.installed_versions.contains(v)
57 }) && dir.exists()
58 && !fs::is_dir_locked(dir)
59 }
60
61 #[instrument(skip(self))]
64 pub async fn verify_checksum(
65 &self,
66 checksum_file: &Path,
67 download_file: &Path,
68 checksum_public_key: Option<&str>,
69 ) -> Result<Checksum, ProtoInstallError> {
70 debug!(
71 tool = self.context.as_str(),
72 download_file = ?download_file,
73 checksum_file = ?checksum_file,
74 "Verifying checksum of downloaded file",
75 );
76
77 let checksum = generate_checksum(download_file, checksum_file, checksum_public_key)?;
78 let verified;
79
80 if self.plugin.has_func(PluginFunction::VerifyChecksum).await {
82 let output: VerifyChecksumOutput = self
83 .plugin
84 .call_func_with(
85 PluginFunction::VerifyChecksum,
86 VerifyChecksumInput {
87 checksum_file: self.to_virtual_path(checksum_file),
88 download_file: self.to_virtual_path(download_file),
89 download_checksum: Some(checksum.clone()),
90 context: self.create_plugin_context(),
91 },
92 )
93 .await?;
94
95 verified = output.verified;
96 }
97 else {
99 verified = verify_checksum(download_file, checksum_file, &checksum)?;
100 }
101
102 if verified {
103 debug!(
104 tool = self.context.as_str(),
105 "Successfully verified, checksum matches"
106 );
107
108 return Ok(checksum);
109 }
110
111 Err(ProtoInstallError::InvalidChecksum {
112 checksum: checksum_file.to_path_buf(),
113 download: download_file.to_path_buf(),
114 })
115 }
116
117 #[instrument(skip(self, options))]
120 async fn build_from_source(
121 &self,
122 install_dir: &Path,
123 temp_dir: &Path,
124 options: InstallOptions,
125 ) -> Result<LockRecord, ProtoInstallError> {
126 debug!(
127 tool = self.context.as_str(),
128 "Installing tool by building from source"
129 );
130
131 if !self
132 .plugin
133 .has_func(PluginFunction::BuildInstructions)
134 .await
135 {
136 return Err(ProtoInstallError::UnsupportedBuildFromSource {
137 tool: self.get_name().to_owned(),
138 });
139 }
140
141 let output: BuildInstructionsOutput = self
142 .plugin
143 .cache_func_with(
144 PluginFunction::BuildInstructions,
145 BuildInstructionsInput {
146 context: self.create_plugin_context(),
147 install_dir: self.to_virtual_path(install_dir),
148 },
149 )
150 .await?;
151
152 let mut system = System::default();
153 let config = self.proto.load_config()?;
154
155 if let Some(pm) = config.settings.build.system_package_manager.get(&system.os) {
156 if let Some(pm) = pm {
157 system.manager = Some(*pm);
158
159 debug!(
160 tool = self.context.as_str(),
161 "Overwriting system package manager to {} for {}", pm, system.os
162 );
163 } else {
164 system.manager = None;
165
166 debug!(
167 tool = self.context.as_str(),
168 "Disabling system package manager because {} was disabled for {}",
169 color::property("settings.build.system-package-manager"),
170 system.os
171 );
172 }
173 }
174
175 let mut builder = Builder::new(BuilderOptions {
176 config,
177 console: options
178 .console
179 .as_ref()
180 .expect("Console required for builder!"),
181 install_dir,
182 http_client: self.proto.get_plugin_loader()?.get_http_client()?,
183 log_writer: options
184 .log_writer
185 .as_ref()
186 .expect("Logger required for builder!"),
187 on_phase_change: options.on_phase_change.clone(),
188 skip_prompts: options.skip_prompts,
189 skip_ui: options.skip_ui,
190 system,
191 temp_dir,
192 version: self.get_resolved_version(),
193 });
194
195 unsafe { std::env::set_var(format!("{}_VERSION", self.get_env_var_prefix()), "*") };
198
199 let mut record = self.create_locked_record();
200
201 log_build_information(&mut builder, &output)?;
203
204 if config.settings.build.install_system_packages {
206 install_system_dependencies(&mut builder, &output).await?;
207 } else {
208 debug!(
209 tool = self.context.as_str(),
210 "Not installing system dependencies because {} was disabled",
211 color::property("settings.build.install-system-packages"),
212 );
213 }
214
215 check_requirements(&mut builder, &output).await?;
217
218 download_sources(&mut builder, &output, &mut record).await?;
220
221 execute_instructions(&mut builder, &output, &self.proto).await?;
223
224 Ok(record)
225 }
226
227 #[instrument(skip(self, options))]
230 async fn install_from_prebuilt(
231 &self,
232 install_dir: &Path,
233 temp_dir: &Path,
234 options: InstallOptions,
235 ) -> Result<LockRecord, ProtoInstallError> {
236 debug!(
237 tool = self.context.as_str(),
238 "Installing tool by downloading a pre-built archive"
239 );
240
241 if !self.plugin.has_func(PluginFunction::DownloadPrebuilt).await {
242 return Err(ProtoInstallError::UnsupportedDownloadPrebuilt {
243 tool: self.get_name().to_owned(),
244 });
245 }
246
247 let client = self.proto.get_plugin_loader()?.get_http_client()?;
248 let config = self.proto.load_config()?;
249
250 let output: DownloadPrebuiltOutput = self
251 .plugin
252 .cache_func_with(
253 PluginFunction::DownloadPrebuilt,
254 DownloadPrebuiltInput {
255 context: self.create_plugin_context(),
256 install_dir: self.to_virtual_path(install_dir),
257 },
258 )
259 .await?;
260
261 let mut record = self.create_locked_record();
262
263 let download_url = config.rewrite_url(output.download_url);
265 let download_filename = match output.download_name {
266 Some(name) => name,
267 None => extract_filename_from_url(&download_url),
268 };
269 let download_file = temp_dir.join(&download_filename);
270
271 record.source = Some(download_url.clone());
272 options.on_phase_change.as_ref().inspect(|func| {
273 func(InstallPhase::Download {
274 url: download_url.clone(),
275 file: download_filename.clone(),
276 });
277 });
278
279 debug!(tool = self.context.as_str(), "Downloading tool archive");
280
281 net::download_from_url_with_options(
282 &download_url,
283 &download_file,
284 DownloadOptions {
285 downloader: Some(Box::new(client.create_downloader())),
286 on_chunk: options.on_download_chunk.clone(),
287 },
288 )
289 .await?;
290
291 if let Some(checksum_url) = output.checksum_url {
293 let checksum_url = config.rewrite_url(checksum_url);
294 let checksum_filename = match output.checksum_name {
295 Some(name) => name,
296 None => extract_filename_from_url(&checksum_url),
297 };
298 let checksum_file = temp_dir.join(&checksum_filename);
299
300 options.on_phase_change.as_ref().inspect(|func| {
301 func(InstallPhase::Verify {
302 url: checksum_url.clone(),
303 file: checksum_filename.clone(),
304 });
305 });
306
307 debug!(tool = self.context.as_str(), "Downloading tool checksum");
308
309 net::download_from_url_with_options(
310 &checksum_url,
311 &checksum_file,
312 DownloadOptions {
313 downloader: Some(Box::new(client.create_downloader())),
314 on_chunk: None,
315 },
316 )
317 .await?;
318
319 record.checksum = Some(
320 self.verify_checksum(
321 &checksum_file,
322 &download_file,
323 output.checksum_public_key.as_deref(),
324 )
325 .await?,
326 );
327 }
328 else if let Some(checksum) = output.checksum {
330 let checksum_file =
331 temp_dir.join(format!("CHECKSUM.{:?}", checksum.algo).to_lowercase());
332
333 fs::write_file(&checksum_file, checksum.hash.as_deref().unwrap_or_default())?;
334
335 debug!(
336 tool = self.context.as_str(),
337 checksum = checksum.to_string(),
338 "Using provided checksum"
339 );
340
341 record.checksum = Some(
342 self.verify_checksum(
343 &checksum_file,
344 &download_file,
345 output
346 .checksum_public_key
347 .as_deref()
348 .or(checksum.key.as_deref()),
349 )
350 .await?,
351 );
352 }
353 else {
355 record.checksum = Some(Checksum::sha256(hash_file_contents_sha256(&download_file)?));
356 }
357
358 debug!(
360 tool = self.context.as_str(),
361 download_file = ?download_file,
362 install_dir = ?install_dir,
363 "Attempting to unpack archive",
364 );
365
366 if self.plugin.has_func(PluginFunction::UnpackArchive).await {
367 options.on_phase_change.as_ref().inspect(|func| {
368 func(InstallPhase::Unpack {
369 file: download_filename.clone(),
370 });
371 });
372
373 self.plugin
374 .call_func_without_output(
375 PluginFunction::UnpackArchive,
376 UnpackArchiveInput {
377 input_file: self.to_virtual_path(&download_file),
378 output_dir: self.to_virtual_path(install_dir),
379 context: self.create_plugin_context(),
380 },
381 )
382 .await?;
383 }
384 else if is_archive_file(&download_file) {
386 options.on_phase_change.as_ref().inspect(|func| {
387 func(InstallPhase::Unpack {
388 file: download_filename.clone(),
389 });
390 });
391
392 let (ext, unpacked_path) = archive::unpack_raw(
393 install_dir,
394 &download_file,
395 output.archive_prefix.as_deref(),
396 )?;
397
398 if ext == "gz" && unpacked_path.is_file() {
401 fs::update_perms(unpacked_path, None)?;
402 }
403 }
404 else {
406 let install_path =
407 install_dir.join(path::exe_name(path::encode_component(self.get_file_name())));
408
409 fs::rename(&download_file, &install_path)?;
410 fs::update_perms(install_path, None)?;
411 }
412
413 Ok(record)
414 }
415
416 #[instrument(skip(self, options))]
419 pub async fn install(
420 &mut self,
421 options: InstallOptions,
422 ) -> Result<Option<LockRecord>, ProtoInstallError> {
423 if self.is_installed() && !options.force {
424 debug!(
425 tool = self.context.as_str(),
426 "Tool already installed, continuing"
427 );
428
429 return Ok(None);
430 }
431
432 if is_offline() {
433 return Err(ProtoInstallError::RequiredInternetConnection);
434 }
435
436 let temp_dir = self.get_temp_dir();
437 let install_dir = self.get_product_dir();
438
439 let mut install_lock = fs::lock_directory(temp_dir)?;
443
444 if self.plugin.has_func(PluginFunction::NativeInstall).await {
447 debug!(tool = self.context.as_str(), "Installing tool natively");
448
449 options.on_phase_change.as_ref().inspect(|func| {
450 func(InstallPhase::Native);
451 });
452
453 fs::create_dir_all(install_dir)?;
454
455 let output: NativeInstallOutput = self
456 .plugin
457 .call_func_with(
458 PluginFunction::NativeInstall,
459 NativeInstallInput {
460 context: self.create_plugin_context(),
461 install_dir: self.to_virtual_path(install_dir),
462 force: options.force,
463 },
464 )
465 .await?;
466
467 if output.installed {
468 let mut record = self.create_locked_record();
469 record.checksum = output.checksum;
470
471 self.verify_locked_record(&record)?;
473
474 return Ok(Some(record));
475 }
476
477 if !output.skip_install {
478 return Err(ProtoInstallError::FailedInstall {
479 tool: self.get_name().to_owned(),
480 error: output.error.unwrap_or_default(),
481 });
482 }
483 }
484
485 let result = if matches!(options.strategy, InstallStrategy::BuildFromSource) {
487 self.build_from_source(install_dir, temp_dir, options).await
488 }
489 else {
491 self.install_from_prebuilt(install_dir, temp_dir, options)
492 .await
493 };
494
495 match result {
496 Ok(record) => {
497 self.verify_locked_record(&record)?;
499
500 debug!(
501 tool = self.context.as_str(),
502 install_dir = ?install_dir,
503 "Successfully installed tool",
504 );
505
506 Ok(Some(record))
507 }
508
509 Err(error) => {
511 debug!(
512 tool = self.context.as_str(),
513 install_dir = ?install_dir,
514 "Failed to install tool, cleaning up",
515 );
516
517 install_lock.unlock()?;
518
519 fs::remove_dir_all(install_dir)?;
520 fs::remove_dir_all(temp_dir)?;
521
522 Err(error)
523 }
524 }
525 }
526
527 #[instrument(skip_all)]
529 pub async fn uninstall(&self) -> Result<bool, ProtoInstallError> {
530 let install_dir = self.get_product_dir();
531
532 if !install_dir.exists() {
533 debug!(
534 tool = self.context.as_str(),
535 "Tool has not been installed, aborting"
536 );
537
538 return Ok(false);
539 }
540
541 if self.plugin.has_func(PluginFunction::NativeUninstall).await {
542 debug!(tool = self.context.as_str(), "Uninstalling tool natively");
543
544 let output: NativeUninstallOutput = self
545 .plugin
546 .call_func_with(
547 PluginFunction::NativeUninstall,
548 NativeUninstallInput {
549 context: self.create_plugin_context(),
550 uninstall_dir: self.to_virtual_path(install_dir),
551 },
552 )
553 .await?;
554
555 if !output.uninstalled && !output.skip_uninstall {
556 return Err(ProtoInstallError::FailedUninstall {
557 tool: self.get_name().to_owned(),
558 error: output.error.unwrap_or_default(),
559 });
560 }
561 }
562
563 debug!(
564 tool = self.context.as_str(),
565 install_dir = ?install_dir,
566 "Deleting install directory"
567 );
568
569 fs::remove_dir_all(install_dir)?;
570
571 debug!(
572 tool = self.context.as_str(),
573 "Successfully uninstalled tool"
574 );
575
576 Ok(true)
577 }
578}