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                )?;
101
102                let mut lock = prepared_nodes.lock().map_err(|e| {
103                    anyhow!(
104                        "Prepared nodes mutex poisoned during preparation: {e}"
105                    )
106                })?;
107                lock.insert(pkg_id.clone(), prepared);
108                Ok(())
109            })?;
110
111        if verbose {
112            println!(
113                "{} Installing {} packages...",
114                "::".bold().blue(),
115                install_plan.len()
116            );
117        }
118        let m = indicatif::MultiProgress::new();
119        if !verbose {
120            m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
121        }
122        let session_installed_mutex = Mutex::new(Vec::new());
123
124        for stage in stages {
125            stage.into_par_iter().try_for_each(|pkg_id| -> Result<()> {
126                let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
127                    anyhow!("Package node missing from graph for '{pkg_id}'")
128                })?;
129
130                let prepared = {
131                    let lock = prepared_nodes.lock().map_err(|e| {
132                        anyhow!(
133                            "Prepared nodes mutex poisoned during install: {e}"
134                        )
135                    })?;
136                    lock.get(&pkg_id).cloned().ok_or_else(|| {
137                        anyhow!("Prepared node missing for: {pkg_id}")
138                    })?
139                };
140
141                let manifest = install::installer::install_prepared_node(
142                    node,
143                    &prepared,
144                    Some(&m),
145                    true,
146                    false,
147                    false,
148                    verbose
149                )?;
150
151                let mut session_lock =
152                    session_installed_mutex.lock().map_err(|e| {
153                        anyhow!("Session installed mutex poisoned: {e}")
154                    })?;
155                session_lock.push(manifest);
156                Ok(())
157            })?;
158        }
159        session_installed = session_installed_mutex
160            .into_inner()
161            .map_err(|e| anyhow!("Session installed mutex poisoned: {e}"))?;
162    }
163
164    let root_ids: Vec<&String> = graph
165        .adj
166        .get("$root")
167        .map(|s| s.iter().collect())
168        .unwrap_or_default();
169    let root_id = root_ids.first().ok_or_else(|| {
170        anyhow!("Could not find root package in dependency graph")
171    })?;
172    let node = graph
173        .nodes
174        .get(*root_id)
175        .ok_or_else(|| anyhow!("Root package node not found"))?;
176
177    let bin_name = if let Some(name) = bin {
178        name
179    } else {
180        let bins = node.pkg.bins.as_ref().ok_or_else(|| {
181            anyhow!("Package '{}' provides no binaries", node.pkg.name)
182        })?;
183        if bins.len() == 1 {
184            bins.first()
185                .ok_or_else(|| {
186                    anyhow!(
187                        "Package '{}' binaries list is empty",
188                        node.pkg.name
189                    )
190                })?
191                .clone()
192        } else {
193            return Err(anyhow!(
194                "Package '{}' provides multiple binaries ({}). Use --bin to \
195                 specify which to run.",
196                node.pkg.name,
197                bins.join(", ")
198            ));
199        }
200    };
201
202    let temp_dir = tempfile::Builder::new().prefix("zoi-exec-").tempdir()?;
203    let temp_bin_dir = temp_dir.path().join("bin");
204    fs::create_dir_all(&temp_bin_dir)?;
205
206    for gnode in graph.nodes.values() {
207        let Ok(pkg_dir) = local::get_package_dir(
208            gnode.pkg.scope,
209            &gnode.registry_handle,
210            &gnode.pkg.repo,
211            &gnode.pkg.name
212        ) else {
213            continue;
214        };
215        let version_dir = pkg_dir.join(&gnode.version);
216        let bin_dir = version_dir.join("bin");
217        if bin_dir.exists() {
218            for entry in fs::read_dir(bin_dir)? {
219                let entry = entry?;
220                let path = entry.path();
221                if (path.is_file() || path.is_symlink())
222                    && let Some(file_name) = path.file_name()
223                {
224                    let dest = temp_bin_dir.join(file_name);
225                    let _ = fs::remove_file(&dest);
226                    let _ = crate::utils::symlink_file(&path, &dest);
227                }
228            }
229        }
230    }
231
232    let package_dir = local::get_package_dir(
233        node.pkg.scope,
234        &node.registry_handle,
235        &node.pkg.repo,
236        &node.pkg.name
237    )?;
238    let version_dir = package_dir.join(&node.version);
239    let actual_bin_path = version_dir.join("bin").join(&bin_name);
240
241    if !actual_bin_path.exists() {
242        return Err(anyhow!(
243            "Binary '{}' not found in package '{}'",
244            bin_name,
245            node.pkg.name
246        ));
247    }
248
249    let mut new_path = temp_bin_dir.to_string_lossy().to_string();
250    if let Ok(old_path) = std::env::var("PATH") {
251        new_path = format!(
252            "{}{}{}",
253            new_path,
254            if cfg!(windows) { ";" } else { ":" },
255            old_path
256        );
257    }
258
259    if verbose {
260        println!(
261            "{} Running '{}' from '{}'...",
262            "::".bold().blue(),
263            bin_name.cyan(),
264            node.pkg.name.cyan()
265        );
266    }
267
268    let mut envs = HashMap::new();
269    envs.insert("PATH".to_string(), new_path);
270
271    #[cfg(target_os = "linux")]
272    let mut cmd = {
273        let sysroot = zoi_core::sysroot::get_sysroot();
274        if let Some(root) = sysroot {
275            if verbose {
276                println!(
277                    "{} Executing within sysroot: {}",
278                    "::".bold().yellow(),
279                    root.display()
280                );
281            }
282
283            let extra_binds = vec![(
284                temp_dir.path().to_path_buf(),
285                temp_dir.path().to_path_buf()
286            )];
287
288            let exe_inside_root = actual_bin_path
289                .strip_prefix(&root)
290                .map(PathBuf::from)
291                .unwrap_or(actual_bin_path.clone());
292
293            crate::sandbox::wrap_command_in_root(
294                &root,
295                &exe_inside_root,
296                args,
297                &envs,
298                &extra_binds,
299                false
300            )?
301        } else if let Some(sandbox_config) = &node.pkg.sandbox
302            && sandbox_config.enabled
303        {
304            if verbose {
305                println!("{} Sandboxing with bwrap.", "::".bold().yellow());
306            }
307            crate::sandbox::wrap_command(
308                &actual_bin_path,
309                args,
310                sandbox_config,
311                &version_dir
312            )?
313        } else {
314            let mut c = Command::new(&actual_bin_path);
315            c.args(args);
316            c.envs(&envs);
317            c
318        }
319    };
320
321    #[cfg(not(target_os = "linux"))]
322    let mut cmd = {
323        let mut c = Command::new(&actual_bin_path);
324        c.args(args);
325        c.envs(&envs);
326        c
327    };
328
329    let status = cmd.status()?;
330
331    if !session_installed.is_empty() {
332        if verbose {
333            println!(
334                "{} Cleaning up ephemeral packages...",
335                "::".bold().blue()
336            );
337        }
338        for manifest in session_installed {
339            let ident = crate::pkg::local::installed_manifest_source(&manifest);
340            if installed_before.contains(&ident) {
341                continue;
342            }
343            let version_dir = match get_version_dir_from_manifest(&manifest) {
344                Ok(d) => d,
345                Err(e) => {
346                    eprintln!(
347                        "Warning: failed to resolve path for {ident}: {e}"
348                    );
349                    continue;
350                }
351            };
352            if version_dir.exists()
353                && let Err(e) = fs::remove_dir_all(&version_dir)
354            {
355                eprintln!(
356                    "Warning: failed to cleanup ephemeral package {ident}: {e}"
357                );
358            }
359            let package_dir = version_dir
360                .parent()
361                .expect("Version directory must have a parent")
362                .to_path_buf();
363            if let Ok(mut entries) = fs::read_dir(&package_dir) {
364                let has_other_entries = entries.any(|e| {
365                    e.as_ref().is_ok_and(|e| {
366                        e.file_name() != "latest"
367                            && e.file_name() != "dependents"
368                    })
369                });
370                if !has_other_entries {
371                    let _ = fs::remove_dir_all(&package_dir);
372                }
373            }
374        }
375    }
376
377    if !status.success() {
378        std::process::exit(status.code().unwrap_or(1));
379    }
380
381    Ok(())
382}
383
384/// Resolves the version directory path from an install manifest.
385fn get_version_dir_from_manifest(
386    manifest: &zoi_core::types::InstallManifest
387) -> Result<PathBuf> {
388    crate::pkg::local::get_package_version_dir(
389        manifest.scope,
390        &manifest.registry_handle,
391        &manifest.repo,
392        &manifest.name,
393        &manifest.version
394    )
395}