Skip to main content

zoi_cli/cmd/
shell.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::Write;
4use std::path::PathBuf;
5use std::process::Command;
6use std::sync::Mutex;
7
8use anyhow::{Result, anyhow};
9use clap::CommandFactory;
10use clap_complete::{Shell, generate};
11use colored::Colorize;
12
13use crate::cli::{Cli, SetupScope};
14use crate::pkg::{install, local, plugin, types};
15use crate::utils;
16
17/// Returns the path to the completion script for a given shell and scope.
18fn get_completion_path(shell: Shell, scope: SetupScope) -> Result<PathBuf> {
19    if scope == SetupScope::System {
20        if !utils::is_admin() {
21            return Err(anyhow!(
22                "System-wide installation requires root privileges. Please \
23                 run with sudo or as an administrator.",
24            ));
25        }
26        Ok(match shell {
27            Shell::Bash => {
28                PathBuf::from("/usr/share/bash-completion/completions/zoi")
29            }
30            Shell::Elvish => PathBuf::from("/usr/share/elvish/lib/zoi.elv"),
31            Shell::Fish => {
32                PathBuf::from("/usr/share/fish/vendor_completions.d/zoi.fish")
33            }
34            Shell::Zsh => PathBuf::from("/usr/share/zsh/site-functions/_zoi"),
35            _ => {
36                return Err(anyhow!(
37                    "System-wide completion installation not supported for \
38                     this shell.",
39                ));
40            }
41        })
42    } else {
43        let config_dir = crate::pkg::utils::get_user_config_dir()?;
44        let data_dir = crate::pkg::utils::get_user_data_dir()?;
45        let home = dirs::home_dir()
46            .ok_or_else(|| anyhow!("Home directory not found"))?;
47        Ok(match shell {
48            Shell::Bash => data_dir.join("bash-completion/completions/zoi"),
49            Shell::Zsh => home.join(".zsh/completions/_zoi"),
50            Shell::Fish => config_dir.join("fish/completions/zoi.fish"),
51            Shell::Elvish => config_dir.join("elvish/completions/zoi.elv"),
52            Shell::PowerShell => {
53                if cfg!(windows) {
54                    home.join(
55                        "Documents/PowerShell/Microsoft.PowerShell_profile.ps1"
56                    )
57                } else {
58                    home.join(
59                        ".config/powershell/Microsoft.PowerShell_profile.ps1"
60                    )
61                }
62            }
63            _ => {
64                return Err(anyhow!(
65                    "User-level completion installation not supported for \
66                     this shell.",
67                ));
68            }
69        })
70    }
71}
72
73/// Installs the completion script for a given shell and scope.
74fn install_completions(
75    shell: Shell,
76    scope: SetupScope,
77    cmd: &mut clap::Command
78) -> Result<()> {
79    if cfg!(windows) && scope == SetupScope::System {
80        return Err(anyhow!(
81            "System-wide shell setup is not supported on Windows.",
82        ));
83    }
84
85    let path = get_completion_path(shell, scope)?;
86    if let Some(parent) = path.parent() {
87        fs::create_dir_all(parent)?;
88    }
89
90    if shell == Shell::PowerShell {
91        let mut file = fs::OpenOptions::new()
92            .append(true)
93            .create(true)
94            .open(&path)?;
95        writeln!(file)?;
96        let mut script_buf = Vec::new();
97        generate(shell, cmd, "zoi", &mut script_buf);
98        let script = post_process_completions(
99            shell,
100            String::from_utf8_lossy(&script_buf).to_string()
101        );
102        file.write_all(script.as_bytes())?;
103        println!(
104            "PowerShell completion script appended to your profile: {}",
105            path.display()
106        );
107        println!(
108            "Please restart your shell or run '. $PROFILE' to activate it."
109        );
110    } else {
111        let mut script_buf = Vec::new();
112        generate(shell, cmd, "zoi", &mut script_buf);
113        let script = post_process_completions(
114            shell,
115            String::from_utf8_lossy(&script_buf).to_string()
116        );
117        let mut file = fs::File::create(&path)?;
118        file.write_all(script.as_bytes())?;
119        println!("{shell} completions installed in: {}", path.display());
120    }
121
122    if shell == Shell::Zsh && scope == SetupScope::User {
123        println!(
124            "Ensure the directory is in your $fpath. Add this to your .zshrc \
125             if it's not:"
126        );
127        println!(
128            "  fpath=({} $fpath)",
129            path.parent()
130                .ok_or_else(|| anyhow!(
131                    "Path should have a parent directory: {}",
132                    path.display()
133                ))?
134                .display()
135        );
136    }
137
138    Ok(())
139}
140
141/// Post-processes a completion script for a given shell.
142fn post_process_completions(shell: Shell, mut script: String) -> String {
143    match shell {
144        Shell::Zsh => {
145            let helper = r#"
146_zoi_packages() {
147    local -a entries
148    local line
149    while IFS= read -r line; do
150        [[ -z "$line" ]] && continue
151        entries+=("$line")
152    done < <(zoi complete zsh $CURRENT "${words[@]}" 2>/dev/null)
153    _describe -t packages 'packages' entries
154}
155
156_zoi_all_packages() {
157    _zoi_packages
158}
159
160_zoi_installed_packages() {
161    _zoi_packages
162}
163"#;
164            let mut parts = script.splitn(2, '\n');
165            let header = parts.next().unwrap_or("");
166            let body = parts.next().unwrap_or("");
167
168            script = format!("{header}\n{helper}\n{body}");
169
170            script = script
171                .replace("':ALL_SOURCES: '", "':package:(_zoi_packages)'");
172            script = script
173                .replace("':ALL_PACKAGES: '", "':package:(_zoi_packages)'");
174            script = script
175                .replace("':INST_PACKAGES: '", "':package:(_zoi_packages)'");
176
177            let desc_marker =
178                " -- Package identifier (e.g. @repo/name, path, or URL):";
179            let mut search_start = 0;
180            while let Some(pos) = script[search_start..].find(desc_marker) {
181                let abs_pos = search_start + pos;
182                let after_colon = abs_pos + desc_marker.len();
183                if let Some(quote_pos) = script[after_colon..].find('\'') {
184                    let action_end = after_colon + quote_pos;
185                    script.replace_range(
186                        after_colon..action_end,
187                        ":_zoi_packages"
188                    );
189                }
190                search_start = after_colon;
191            }
192        }
193        Shell::Bash => {
194            let helpers = r#"
195_zoi_all_packages_comp() {
196    local cur=${COMP_WORDS[COMP_CWORD]}
197    local pkgs=$(zoi list -a --names 2>/dev/null)
198    COMPREPLY=( $(compgen -W "${pkgs}" -- "$cur") )
199}
200
201_zoi_installed_packages_comp() {
202    local cur=${COMP_WORDS[COMP_CWORD]}
203    local pkgs=$(zoi list --names 2>/dev/null)
204    COMPREPLY=( $(compgen -W "${pkgs}" -- "$cur") )
205}
206
207_zoi_wrapper() {
208    local cur="${COMP_WORDS[COMP_CWORD]}"
209    local prev="${COMP_WORDS[COMP_CWORD-1]}"
210    local cmd="${COMP_WORDS[1]}"
211
212    if [[ "$prev" == -* ]]; then
213        _zoi
214        return 0
215    fi
216
217    case $cmd in
218        install|i|in|add|show|exec|x|create|clone|use|tree|man|shell)
219            _zoi_all_packages_comp
220            return 0
221            ;;
222        uninstall|un|rm|remove|mark|m|update|up|why|files|pin|unpin|downgrade|dg|rollback)
223            _zoi_installed_packages_comp
224            return 0
225            ;;
226    esac
227
228    _zoi
229}
230complete -F _zoi_wrapper zoi
231"#;
232            script = format!("{script}\n{helpers}");
233        }
234        _ => {}
235    }
236    script
237}
238
239/// Runs the shell setup command.
240///
241/// # Errors
242///
243/// Returns an error if:
244/// - Root privileges are required for system-wide setup but elevation fails.
245/// - The shell completion installation fails.
246/// - The system path setup fails.
247pub fn run(shell: Shell, scope: SetupScope) -> Result<()> {
248    if scope == SetupScope::System && !utils::is_admin() {
249        let exe = std::env::current_exe()?;
250        let args: Vec<String> = std::env::args().collect();
251        let escalator = crate::pkg::utils::get_privilege_escalator()
252            .ok_or_else(|| {
253                anyhow!(
254                    "Root privileges required for system-wide setup, but \
255                     neither 'sudo' nor 'doas' was found."
256                )
257            })?;
258
259        let status = Command::new(escalator)
260            .arg(&exe)
261            .args(args.get(1..).unwrap_or(&[]))
262            .status()
263            .map_err(|e| anyhow!("Failed to elevate privileges: {e}"))?;
264        std::process::exit(status.code().unwrap_or(1));
265    }
266
267    println!(
268        "{} Setting up shell: {}...",
269        "::".bold().blue(),
270        shell.to_string().cyan()
271    );
272
273    let mut cmd = Cli::command();
274    install_completions(shell, scope, &mut cmd)?;
275
276    install_package_completions(shell, scope)?;
277
278    println!();
279
280    let scope_to_pass = match scope {
281        SetupScope::User => types::Scope::User,
282        SetupScope::System => types::Scope::System
283    };
284    utils::setup_path(scope_to_pass)?;
285    Ok(())
286}
287
288/// Returns the directory for package completions for a given scope and shell.
289fn get_completions_dir(scope: SetupScope, shell: &str) -> Result<PathBuf> {
290    match scope {
291        SetupScope::User => crate::pkg::utils::get_user_completions_dir(shell),
292        SetupScope::System => {
293            if cfg!(target_os = "windows") {
294                Ok(PathBuf::from(format!(
295                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}"
296                )))
297            } else {
298                let base = match shell {
299                    "bash" => "/usr/share/bash-completion/completions",
300                    "zsh" => "/usr/share/zsh/site-functions",
301                    "fish" => "/usr/share/fish/vendor_completions.d",
302                    "elvish" => "/usr/share/elvish/lib",
303                    _ => "/usr/local/share/zoi/completions"
304                };
305                Ok(PathBuf::from(base))
306            }
307        }
308    }
309}
310
311/// Installs the package-specific completion directories for a given shell and
312/// scope.
313fn install_package_completions(shell: Shell, scope: SetupScope) -> Result<()> {
314    let shell_name = match shell {
315        Shell::Bash => "bash",
316        Shell::Zsh => "zsh",
317        Shell::Fish => "fish",
318        Shell::Elvish => "elvish",
319        _ => return Ok(())
320    };
321
322    let completions_dir = get_completions_dir(scope, shell_name)?;
323    fs::create_dir_all(&completions_dir)?;
324
325    match shell {
326        Shell::Zsh => {
327            let fpath_entry = completions_dir.to_string_lossy().to_string();
328            println!(
329                "{} Add this to your .zshrc to load package completions:",
330                "::".bold().blue()
331            );
332            println!("  fpath=({fpath_entry:?} $fpath)");
333            println!("  autoload -Uz compinit && compinit");
334        }
335        Shell::Bash => {
336            let bash_completion_dir = match scope {
337                SetupScope::User => {
338                    let home = dirs::home_dir()
339                        .ok_or_else(|| anyhow!("Home directory not found"))?;
340                    home.join(".local/share/bash-completion/completions")
341                }
342                SetupScope::System => {
343                    PathBuf::from("/usr/share/bash-completion/completions")
344                }
345            };
346            if bash_completion_dir.exists() {
347                println!(
348                    "{} Package completions directory: {}",
349                    "::".bold().blue(),
350                    completions_dir.display()
351                );
352                println!(
353                    "  Completions from installed packages will be available \
354                     automatically."
355                );
356            }
357        }
358        Shell::Fish => {
359            println!(
360                "{} Package completions directory: {}",
361                "::".bold().blue(),
362                completions_dir.display()
363            );
364            println!(
365                "  Completions from installed packages will be available \
366                 automatically."
367            );
368        }
369        _ => {}
370    }
371
372    Ok(())
373}
374
375/// Prints the shell hook script for a given shell.
376///
377/// # Errors
378///
379/// Returns an error if the shell hook is not supported for the given shell.
380pub fn print_hook(shell: Shell) -> Result<()> {
381    match shell {
382        Shell::Bash => {
383            println!(
384                r#"
385_zoi_hook() {{
386  local previous_exit_status=$?;
387  eval "$(zoi env --export-shell bash)";
388  return $previous_exit_status;
389}};
390if [[ ";${{PROMPT_COMMAND[*]:-}};" != *";_zoi_hook;"* ]]; then
391  if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
392    PROMPT_COMMAND=(_zoi_hook "${{PROMPT_COMMAND[@]}}")
393  else
394    PROMPT_COMMAND="_zoi_hook${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}"
395  fi
396fi
397"#
398            );
399        }
400        Shell::Zsh => {
401            println!(
402                r#"
403_zoi_hook() {{
404  eval "$(zoi env --export-shell zsh)";
405}};
406typeset -ag precmd_functions;
407if [[ -z "${{precmd_functions[(r)_zoi_hook]}}" ]]; then
408  precmd_functions+=(_zoi_hook);
409fi
410"#
411            );
412        }
413        Shell::Fish => {
414            println!(
415                r"
416function _zoi_hook --on-variable PWD
417  zoi env --export-shell fish | source
418end
419"
420            );
421        }
422        _ => return Err(anyhow!("Shell hook not supported for {shell:?}"))
423    }
424    Ok(())
425}
426
427/// Enters an ephemeral shell with the given packages installed.
428///
429/// # Errors
430///
431/// Returns an error if:
432/// - Dependency resolution fails.
433/// - Package installation fails.
434/// - Temporary directory creation fails.
435/// - Symlinking binary files fails.
436/// - Executing the shell command fails.
437///
438/// # Panics
439///
440/// Panics if a mutex lock is poisoned.
441pub fn enter_ephemeral_shell(
442    package_sources: &[String],
443    run_cmd: Option<String>,
444    verbose: bool,
445    _plugin_manager: Option<&plugin::PluginManager>
446) -> Result<()> {
447    if verbose {
448        println!("{} Resolving ephemeral environment...", "::".bold().blue());
449    }
450
451    let installed_before: HashSet<String> = local::get_installed_packages()?
452        .into_iter()
453        .map(|m| local::installed_manifest_source(&m))
454        .collect();
455
456    let (graph, _non_zoi_deps) = install::resolver::resolve_dependency_graph(
457        package_sources,
458        None,
459        false,
460        true,
461        true,
462        None,
463        !verbose,
464        None
465    )?;
466
467    let install_plan =
468        install::plan::create_install_plan(&graph.nodes, None, false)?;
469    let stages = graph.toposort()?;
470
471    let mut session_installed = Vec::new();
472
473    if !install_plan.is_empty() {
474        if verbose {
475            println!(
476                "{} Preparing {} ephemeral dependencies...",
477                "::".bold().blue(),
478                install_plan.len()
479            );
480        }
481        let m = indicatif::MultiProgress::new();
482        if !verbose {
483            m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
484        }
485        let session_installed_mutex = Mutex::new(Vec::new());
486
487        for stage in stages {
488            use rayon::prelude::*;
489            stage.into_par_iter().try_for_each(|pkg_id| -> Result<()> {
490                let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
491                    anyhow!("Package node missing from graph for '{pkg_id}'")
492                })?;
493                let action = install_plan.get(&pkg_id).ok_or_else(|| {
494                    anyhow!("Install action missing for package '{pkg_id}'")
495                })?;
496
497                let manifest = install::installer::install_node(
498                    node,
499                    action,
500                    Some(&m),
501                    None,
502                    true,
503                    false,
504                    false,
505                    verbose
506                )?;
507
508                let mut session_lock = session_installed_mutex
509                    .lock()
510                    .expect("failed to lock session_installed_mutex");
511                session_lock.push(manifest);
512                Ok(())
513            })?;
514        }
515        session_installed = session_installed_mutex
516            .into_inner()
517            .expect("failed to get session_installed_mutex inner value");
518    }
519
520    let temp_dir = tempfile::Builder::new().prefix("zoi-shell-").tempdir()?;
521    let temp_bin_dir = temp_dir.path().join("bin");
522    fs::create_dir_all(&temp_bin_dir)?;
523
524    for node in graph.nodes.values() {
525        let handle = &node.registry_handle;
526        let pkg = &node.pkg;
527
528        let package_dir =
529            local::get_package_dir(pkg.scope, handle, &pkg.repo, &pkg.name)?;
530        let version_dir = package_dir.join(&node.version);
531        let bin_dir = version_dir.join("bin");
532
533        if bin_dir.exists() {
534            for entry in fs::read_dir(bin_dir)? {
535                let entry = entry?;
536                let path = entry.path();
537                if path.is_file() || path.is_symlink() {
538                    let file_name = path.file_name().ok_or_else(|| {
539                        anyhow!("Path has no file name: {}", path.display())
540                    })?;
541                    let dest = temp_bin_dir.join(file_name);
542                    utils::symlink_file(&path, &dest)?;
543                }
544            }
545        }
546    }
547
548    let sep = if cfg!(windows) { ";" } else { ":" };
549    let mut new_path = temp_bin_dir.to_string_lossy().to_string();
550    if let Ok(old_path) = std::env::var("PATH") {
551        new_path = format!("{new_path}{sep}{old_path}");
552    }
553
554    let package_list = package_sources.join(",");
555
556    let shell_bin = std::env::var("SHELL").unwrap_or_else(|_| {
557        if cfg!(windows) {
558            "pwsh".to_string()
559        } else {
560            "bash".to_string()
561        }
562    });
563
564    let mut envs = HashMap::new();
565    envs.insert("PATH".to_string(), new_path);
566    envs.insert("ZOI_SHELL".to_string(), "ephemeral".to_string());
567    envs.insert("IN_ZOI_SHELL".to_string(), "ephemeral".to_string());
568    envs.insert("ZOI_SHELL_PACKAGES".to_string(), package_list);
569
570    #[cfg(target_os = "linux")]
571    let mut shell_command = {
572        use std::path::Path;
573        let sysroot = zoi_core::sysroot::get_sysroot();
574        if let Some(root) = sysroot {
575            if verbose {
576                println!(
577                    "{} Entering shell within sysroot: {}",
578                    "::".bold().yellow(),
579                    root.display()
580                );
581            }
582
583            let extra_binds = vec![(
584                temp_dir.path().to_path_buf(),
585                temp_dir.path().to_path_buf()
586            )];
587
588            if let Some(cmd_str) = run_cmd {
589                let args = vec!["-c".to_string(), cmd_str];
590                crate::sandbox::wrap_command_in_root(
591                    &root,
592                    Path::new(&shell_bin),
593                    &args,
594                    &envs,
595                    &extra_binds,
596                    false
597                )?
598            } else {
599                crate::sandbox::wrap_command_in_root(
600                    &root,
601                    Path::new(&shell_bin),
602                    &[],
603                    &envs,
604                    &extra_binds,
605                    false
606                )?
607            }
608        } else if let Some(cmd_str) = run_cmd {
609            if verbose {
610                println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
611            }
612            let mut c = if cfg!(windows) {
613                Command::new("pwsh")
614            } else {
615                Command::new("bash")
616            };
617            if cfg!(windows) {
618                c.arg("-Command");
619            } else {
620                c.arg("-c");
621            }
622            c.arg(&cmd_str);
623            c.envs(&envs);
624            c
625        } else {
626            if verbose {
627                println!(
628                    "{} Entering ephemeral shell (type 'exit' to leave)...",
629                    "::".bold().green()
630                );
631            }
632            let mut c = Command::new(&shell_bin);
633            c.envs(&envs);
634            c
635        }
636    };
637
638    #[cfg(not(target_os = "linux"))]
639    let mut shell_command = {
640        if let Some(cmd_str) = run_cmd {
641            if verbose {
642                println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
643            }
644            let mut c = if cfg!(windows) {
645                Command::new("pwsh")
646            } else {
647                Command::new("bash")
648            };
649            if !cfg!(windows) {
650                c.arg("-c");
651            } else {
652                c.arg("-Command");
653            }
654            c.arg(&cmd_str);
655            c.envs(&envs);
656            c
657        } else {
658            if verbose {
659                println!(
660                    "{} Entering ephemeral shell (type 'exit' to leave)...",
661                    "::".bold().green()
662                );
663            }
664            let mut c = Command::new(&shell_bin);
665            c.envs(&envs);
666            c
667        }
668    };
669
670    let status = shell_command.status()?;
671
672    if !session_installed.is_empty() {
673        if verbose {
674            println!(
675                "{} Cleaning up ephemeral packages...",
676                "::".bold().blue()
677            );
678        }
679        for manifest in session_installed {
680            let ident = local::installed_manifest_source(&manifest);
681            if installed_before.contains(&ident) {
682                continue;
683            }
684            let version_dir = match get_version_dir_from_manifest(&manifest) {
685                Ok(d) => d,
686                Err(e) => {
687                    eprintln!(
688                        "Warning: failed to resolve path for {ident}: {e}"
689                    );
690                    continue;
691                }
692            };
693            if version_dir.exists()
694                && let Err(e) = fs::remove_dir_all(&version_dir)
695            {
696                eprintln!(
697                    "Warning: failed to cleanup ephemeral package {ident}: {e}"
698                );
699            }
700            let package_dir = version_dir
701                .parent()
702                .expect("version directory should have a parent");
703            if let Ok(mut entries) = fs::read_dir(package_dir) {
704                let has_other_entries = entries.any(|e| {
705                    e.as_ref().is_ok_and(|e| {
706                        e.file_name() != "latest"
707                            && e.file_name() != "dependents"
708                    })
709                });
710                if !has_other_entries {
711                    let _ = fs::remove_dir_all(package_dir);
712                }
713            }
714        }
715    }
716
717    if !status.success() {
718        std::process::exit(status.code().unwrap_or(1));
719    }
720
721    Ok(())
722}
723
724/// Returns the version directory for a given install manifest.
725fn get_version_dir_from_manifest(
726    manifest: &zoi_core::types::InstallManifest
727) -> Result<PathBuf> {
728    local::get_package_version_dir(
729        manifest.scope,
730        &manifest.registry_handle,
731        &manifest.repo,
732        &manifest.name,
733        &manifest.version
734    )
735}