proto_core/flow/
install.rs

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// Prebuilt: Download -> Verify -> Unpack
21// Build: InstallDeps -> CheckRequirements -> ExecuteInstructions -> ...
22#[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    /// Return true if the tool has been installed. This is less accurate than `is_setup`,
51    /// as it only checks for the existence of the inventory directory.
52    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    /// Verify the downloaded file using the checksum strategy for the tool.
62    /// Common strategies are SHA256 and MD5.
63    #[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        // Allow plugin to provide their own checksum verification method
81        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        // Otherwise attempt to verify it ourselves
98        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    /// Build the tool from source using a set of requirements and instructions
118    /// into the `~/.proto/tools/<version>` folder.
119    #[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        // The build process may require using itself to build itself,
196        // so allow proto to use any available version instead of failing
197        unsafe { std::env::set_var(format!("{}_VERSION", self.get_env_var_prefix()), "*") };
198
199        let mut record = self.create_locked_record();
200
201        // Step 0
202        log_build_information(&mut builder, &output)?;
203
204        // Step 1
205        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        // Step 2
216        check_requirements(&mut builder, &output).await?;
217
218        // Step 3
219        download_sources(&mut builder, &output, &mut record).await?;
220
221        // Step 4
222        execute_instructions(&mut builder, &output, &self.proto).await?;
223
224        Ok(record)
225    }
226
227    /// Download the tool (as an archive) from its distribution registry
228    /// into the `~/.proto/tools/<version>` folder, and optionally verify checksums.
229    #[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        // Download the prebuilt
264        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        // Verify against a URL that contains the checksum
292        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        // Verify against an explicitly provided checksum
329        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        // No available checksum, so generate one ourselves for the lockfile
354        else {
355            record.checksum = Some(Checksum::sha256(hash_file_contents_sha256(&download_file)?));
356        }
357
358        // Attempt to unpack the archive
359        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        // Is an archive, unpack it
385        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 the archive was `.gz` without tar or other formats,
399            // it's a single file, so assume a file and update perms
400            if ext == "gz" && unpacked_path.is_file() {
401                fs::update_perms(unpacked_path, None)?;
402            }
403        }
404        // Not an archive, assume a file and copy
405        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    /// Install a tool into proto, either by downloading and unpacking
417    /// a pre-built archive, or by using a native installation method.
418    #[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        // Lock the temporary directory instead of the install directory,
440        // because the latter needs to be clean for "build from source",
441        // and the `.lock` file breaks that contract
442        let mut install_lock = fs::lock_directory(temp_dir)?;
443
444        // If this function is defined, it acts like an escape hatch and
445        // takes precedence over all other install strategies
446        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                // Verify against lockfile
472                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        // Build the tool from source
486        let result = if matches!(options.strategy, InstallStrategy::BuildFromSource) {
487            self.build_from_source(install_dir, temp_dir, options).await
488        }
489        // Install from a prebuilt archive
490        else {
491            self.install_from_prebuilt(install_dir, temp_dir, options)
492                .await
493        };
494
495        match result {
496            Ok(record) => {
497                // Verify against lockfile
498                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            // Clean up if the install failed
510            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    /// Uninstall the tool by deleting the current install directory.
528    #[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}