Skip to main content

zoi_cli/cmd/
exec.rs

1//! Implementation of the `zoi exec` command.
2//!
3//! This command allows running a binary from a package without explicitly
4//! installing it into the global or user scope. It handles temporary
5//! installation and cleanup.
6
7use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::path::PathBuf;
10use std::process::Command;
11use std::sync::Mutex;
12
13use anyhow::{Result, anyhow};
14use colored::Colorize;
15use indicatif::MultiProgress;
16use rayon::prelude::*;
17
18use crate::pkg::{install, local};
19
20/// Runs the 'exec' command.
21///
22/// Temporarily installs a package if necessary and executes a binary from it.
23/// Ephemeral packages are cleaned up after execution.
24///
25/// # Panics
26///
27/// This function may panic if internal mutexes are poisoned.
28///
29/// # Errors
30///
31/// Returns an error if:
32/// - Package resolution fails.
33/// - Installation of temporary packages fails.
34/// - Execution of the binary fails.
35/// - Cleanup of ephemeral packages fails.
36pub fn run(
37    source: &str,
38    bin: Option<String>,
39    args: &[String],
40    verbose: bool
41) -> Result<()> {
42    if verbose {
43        println!("{} Resolving package...", "::".bold().blue());
44    }
45
46    let installed_before: HashSet<String> = local::get_installed_packages()?
47        .into_iter()
48        .map(|m| local::installed_manifest_source(&m))
49        .collect();
50
51    let (graph, _non_zoi_deps) = install::resolver::resolve_dependency_graph(
52        &[source.to_string()],
53        None,
54        false,
55        true,
56        true,
57        None,
58        !verbose,
59        None
60    )?;
61
62    let install_plan =
63        install::plan::create_install_plan(&graph.nodes, None, false)?;
64    let stages = graph.toposort()?;
65
66    let mut session_installed = Vec::new();
67    if !install_plan.is_empty() {
68        if verbose {
69            println!("\n{} Preparing packages...", "::".bold().blue());
70        }
71        let m_prep = MultiProgress::new();
72        if !verbose {
73            m_prep.set_draw_target(indicatif::ProgressDrawTarget::hidden());
74        }
75        let prepared_nodes = Mutex::new(HashMap::new());
76
77        stages
78            .par_iter()
79            .flatten()
80            .try_for_each(|pkg_id| -> Result<()> {
81                let node = graph.nodes.get(pkg_id).ok_or_else(|| {
82                    anyhow!(
83                        "Package node missing from graph for '{pkg_id}' \
84                         during preparation"
85                    )
86                })?;
87                let action = install_plan.get(pkg_id).ok_or_else(|| {
88                    anyhow!(
89                        "Install action missing for package '{pkg_id}' during \
90                         preparation"
91                    )
92                })?;
93
94                let prepared = install::installer::prepare_node(
95                    node,
96                    action,
97                    Some(&m_prep),
98                    None,
99                    verbose,
100                    false
101                )?;
102
103                let mut lock = prepared_nodes.lock().map_err(|e| {
104                    anyhow!(
105                        "Prepared nodes mutex poisoned during preparation: {e}"
106                    )
107                })?;
108                lock.insert(pkg_id.clone(), prepared);
109                Ok(())
110            })?;
111
112        if verbose {
113            println!(
114                "{} Installing {} packages...",
115                "::".bold().blue(),
116                install_plan.len()
117            );
118        }
119        let m = indicatif::MultiProgress::new();
120        if !verbose {
121            m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
122        }
123        let session_installed_mutex = Mutex::new(Vec::new());
124
125        for stage in stages {
126            stage.into_par_iter().try_for_each(|pkg_id| -> Result<()> {
127                let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
128                    anyhow!("Package node missing from graph for '{pkg_id}'")
129                })?;
130
131                let prepared = {
132                    let lock = prepared_nodes.lock().map_err(|e| {
133                        anyhow!(
134                            "Prepared nodes mutex poisoned during install: {e}"
135                        )
136                    })?;
137                    lock.get(&pkg_id).cloned().ok_or_else(|| {
138                        anyhow!("Prepared node missing for: {pkg_id}")
139                    })?
140                };
141
142                let manifest = install::installer::install_prepared_node(
143                    node,
144                    &prepared,
145                    Some(&m),
146                    true,
147                    false,
148                    false,
149                    verbose
150                )?;
151
152                let mut session_lock =
153                    session_installed_mutex.lock().map_err(|e| {
154                        anyhow!("Session installed mutex poisoned: {e}")
155                    })?;
156                session_lock.push(manifest);
157                Ok(())
158            })?;
159        }
160        session_installed = session_installed_mutex
161            .into_inner()
162            .map_err(|e| anyhow!("Session installed mutex poisoned: {e}"))?;
163    }
164
165    let root_ids: Vec<&String> = graph
166        .adj
167        .get("$root")
168        .map(|s| s.iter().collect())
169        .unwrap_or_default();
170    let root_id = root_ids.first().ok_or_else(|| {
171        anyhow!("Could not find root package in dependency graph")
172    })?;
173    let node = graph
174        .nodes
175        .get(*root_id)
176        .ok_or_else(|| anyhow!("Root package node not found"))?;
177
178    let bin_name = if let Some(name) = bin {
179        name
180    } else {
181        let bins = node.pkg.bins.as_ref().ok_or_else(|| {
182            anyhow!("Package '{}' provides no binaries", node.pkg.name)
183        })?;
184        if bins.len() == 1 {
185            bins.first()
186                .ok_or_else(|| {
187                    anyhow!(
188                        "Package '{}' binaries list is empty",
189                        node.pkg.name
190                    )
191                })?
192                .clone()
193        } else {
194            return Err(anyhow!(
195                "Package '{}' provides multiple binaries ({}). Use --bin to \
196                 specify which to run.",
197                node.pkg.name,
198                bins.join(", ")
199            ));
200        }
201    };
202
203    let temp_dir = tempfile::Builder::new().prefix("zoi-exec-").tempdir()?;
204    let temp_bin_dir = temp_dir.path().join("bin");
205    fs::create_dir_all(&temp_bin_dir)?;
206
207    for gnode in graph.nodes.values() {
208        let Ok(pkg_dir) = local::get_package_dir(
209            gnode.pkg.scope,
210            &gnode.registry_handle,
211            &gnode.pkg.repo,
212            &gnode.pkg.name
213        ) else {
214            continue;
215        };
216        let version_dir = pkg_dir.join(&gnode.version);
217        let bin_dir = version_dir.join("bin");
218        if bin_dir.exists() {
219            for entry in fs::read_dir(bin_dir)? {
220                let entry = entry?;
221                let path = entry.path();
222                if (path.is_file() || path.is_symlink())
223                    && let Some(file_name) = path.file_name()
224                {
225                    let dest = temp_bin_dir.join(file_name);
226                    let _ = fs::remove_file(&dest);
227                    let _ = crate::utils::symlink_file(&path, &dest);
228                }
229            }
230        }
231    }
232
233    let package_dir = local::get_package_dir(
234        node.pkg.scope,
235        &node.registry_handle,
236        &node.pkg.repo,
237        &node.pkg.name
238    )?;
239    let version_dir = package_dir.join(&node.version);
240    let actual_bin_path = version_dir.join("bin").join(&bin_name);
241
242    if !actual_bin_path.exists() {
243        return Err(anyhow!(
244            "Binary '{}' not found in package '{}'",
245            bin_name,
246            node.pkg.name
247        ));
248    }
249
250    let mut new_path = temp_bin_dir.to_string_lossy().to_string();
251    if let Ok(old_path) = std::env::var("PATH") {
252        new_path = format!(
253            "{}{}{}",
254            new_path,
255            if cfg!(windows) { ";" } else { ":" },
256            old_path
257        );
258    }
259
260    if verbose {
261        println!(
262            "{} Running '{}' from '{}'...",
263            "::".bold().blue(),
264            bin_name.cyan(),
265            node.pkg.name.cyan()
266        );
267    }
268
269    let mut envs = HashMap::new();
270    envs.insert("PATH".to_string(), new_path);
271
272    #[cfg(target_os = "linux")]
273    let mut cmd = {
274        let sysroot = zoi_core::sysroot::get_sysroot();
275        if let Some(root) = sysroot {
276            if verbose {
277                println!(
278                    "{} Executing within sysroot: {}",
279                    "::".bold().yellow(),
280                    root.display()
281                );
282            }
283
284            let extra_binds = vec![(
285                temp_dir.path().to_path_buf(),
286                temp_dir.path().to_path_buf()
287            )];
288
289            let exe_inside_root = actual_bin_path
290                .strip_prefix(&root)
291                .map(PathBuf::from)
292                .unwrap_or(actual_bin_path.clone());
293
294            crate::sandbox::wrap_command_in_root(
295                &root,
296                &exe_inside_root,
297                args,
298                &envs,
299                &extra_binds,
300                false
301            )?
302        } else if let Some(sandbox_config) = &node.pkg.sandbox
303            && sandbox_config.enabled
304        {
305            if verbose {
306                println!("{} Sandboxing with bwrap.", "::".bold().yellow());
307            }
308            crate::sandbox::wrap_command(
309                &actual_bin_path,
310                args,
311                sandbox_config,
312                &version_dir
313            )?
314        } else {
315            let mut c = Command::new(&actual_bin_path);
316            c.args(args);
317            c.envs(&envs);
318            c
319        }
320    };
321
322    #[cfg(not(target_os = "linux"))]
323    let mut cmd = {
324        let mut c = Command::new(&actual_bin_path);
325        c.args(args);
326        c.envs(&envs);
327        c
328    };
329
330    let status = cmd.status()?;
331
332    if !session_installed.is_empty() {
333        if verbose {
334            println!(
335                "{} Cleaning up ephemeral packages...",
336                "::".bold().blue()
337            );
338        }
339        for manifest in session_installed {
340            let ident = crate::pkg::local::installed_manifest_source(&manifest);
341            if installed_before.contains(&ident) {
342                continue;
343            }
344            let version_dir = match get_version_dir_from_manifest(&manifest) {
345                Ok(d) => d,
346                Err(e) => {
347                    eprintln!(
348                        "Warning: failed to resolve path for {ident}: {e}"
349                    );
350                    continue;
351                }
352            };
353            if version_dir.exists()
354                && let Err(e) = fs::remove_dir_all(&version_dir)
355            {
356                eprintln!(
357                    "Warning: failed to cleanup ephemeral package {ident}: {e}"
358                );
359            }
360            let package_dir = version_dir
361                .parent()
362                .expect("Version directory must have a parent")
363                .to_path_buf();
364            if let Ok(mut entries) = fs::read_dir(&package_dir) {
365                let has_other_entries = entries.any(|e| {
366                    e.as_ref().is_ok_and(|e| {
367                        e.file_name() != "latest"
368                            && e.file_name() != "dependents"
369                    })
370                });
371                if !has_other_entries {
372                    let _ = fs::remove_dir_all(&package_dir);
373                }
374            }
375        }
376    }
377
378    if !status.success() {
379        std::process::exit(status.code().unwrap_or(1));
380    }
381
382    Ok(())
383}
384
385/// Resolves the version directory path from an install manifest.
386fn get_version_dir_from_manifest(
387    manifest: &zoi_core::types::InstallManifest
388) -> Result<PathBuf> {
389    crate::pkg::local::get_package_version_dir(
390        manifest.scope,
391        &manifest.registry_handle,
392        &manifest.repo,
393        &manifest.name,
394        &manifest.version
395    )
396}