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 home = dirs::home_dir()
44            .ok_or_else(|| anyhow!("Home directory not found"))?;
45        Ok(match shell {
46            Shell::Bash => {
47                home.join(".local/share/bash-completion/completions/zoi")
48            }
49            Shell::Zsh => home.join(".zsh/completions/_zoi"),
50            Shell::Fish => home.join(".config/fish/completions/zoi.fish"),
51            Shell::Elvish => home.join(".config/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 => {
292            let home = dirs::home_dir()
293                .ok_or_else(|| anyhow!("Home directory not found"))?;
294            Ok(home.join(".zoi/pkgs/shell").join(shell))
295        }
296        SetupScope::System => {
297            if cfg!(target_os = "windows") {
298                Ok(PathBuf::from(format!(
299                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}"
300                )))
301            } else {
302                let base = match shell {
303                    "bash" => "/usr/share/bash-completion/completions",
304                    "zsh" => "/usr/share/zsh/site-functions",
305                    "fish" => "/usr/share/fish/vendor_completions.d",
306                    "elvish" => "/usr/share/elvish/lib",
307                    _ => "/usr/local/share/zoi/completions"
308                };
309                Ok(PathBuf::from(base))
310            }
311        }
312    }
313}
314
315/// Installs the package-specific completion directories for a given shell and
316/// scope.
317fn install_package_completions(shell: Shell, scope: SetupScope) -> Result<()> {
318    let shell_name = match shell {
319        Shell::Bash => "bash",
320        Shell::Zsh => "zsh",
321        Shell::Fish => "fish",
322        Shell::Elvish => "elvish",
323        _ => return Ok(())
324    };
325
326    let completions_dir = get_completions_dir(scope, shell_name)?;
327    fs::create_dir_all(&completions_dir)?;
328
329    match shell {
330        Shell::Zsh => {
331            let fpath_entry = completions_dir.to_string_lossy().to_string();
332            println!(
333                "{} Add this to your .zshrc to load package completions:",
334                "::".bold().blue()
335            );
336            println!("  fpath=({fpath_entry:?} $fpath)");
337            println!("  autoload -Uz compinit && compinit");
338        }
339        Shell::Bash => {
340            let bash_completion_dir = match scope {
341                SetupScope::User => {
342                    let home = dirs::home_dir()
343                        .ok_or_else(|| anyhow!("Home directory not found"))?;
344                    home.join(".local/share/bash-completion/completions")
345                }
346                SetupScope::System => {
347                    PathBuf::from("/usr/share/bash-completion/completions")
348                }
349            };
350            if bash_completion_dir.exists() {
351                println!(
352                    "{} Package completions directory: {}",
353                    "::".bold().blue(),
354                    completions_dir.display()
355                );
356                println!(
357                    "  Completions from installed packages will be available \
358                     automatically."
359                );
360            }
361        }
362        Shell::Fish => {
363            println!(
364                "{} Package completions directory: {}",
365                "::".bold().blue(),
366                completions_dir.display()
367            );
368            println!(
369                "  Completions from installed packages will be available \
370                 automatically."
371            );
372        }
373        _ => {}
374    }
375
376    Ok(())
377}
378
379/// Prints the shell hook script for a given shell.
380///
381/// # Errors
382///
383/// Returns an error if the shell hook is not supported for the given shell.
384pub fn print_hook(shell: Shell) -> Result<()> {
385    match shell {
386        Shell::Bash => {
387            println!(
388                r#"
389_zoi_hook() {{
390  local previous_exit_status=$?;
391  eval "$(zoi env --export-shell bash)";
392  return $previous_exit_status;
393}};
394if [[ ";${{PROMPT_COMMAND[*]:-}};" != *";_zoi_hook;"* ]]; then
395  if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
396    PROMPT_COMMAND=(_zoi_hook "${{PROMPT_COMMAND[@]}}")
397  else
398    PROMPT_COMMAND="_zoi_hook${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}"
399  fi
400fi
401"#
402            );
403        }
404        Shell::Zsh => {
405            println!(
406                r#"
407_zoi_hook() {{
408  eval "$(zoi env --export-shell zsh)";
409}};
410typeset -ag precmd_functions;
411if [[ -z "${{precmd_functions[(r)_zoi_hook]}}" ]]; then
412  precmd_functions+=(_zoi_hook);
413fi
414"#
415            );
416        }
417        Shell::Fish => {
418            println!(
419                r"
420function _zoi_hook --on-variable PWD
421  zoi env --export-shell fish | source
422end
423"
424            );
425        }
426        _ => return Err(anyhow!("Shell hook not supported for {shell:?}"))
427    }
428    Ok(())
429}
430
431/// Enters an ephemeral shell with the given packages installed.
432///
433/// # Errors
434///
435/// Returns an error if:
436/// - Dependency resolution fails.
437/// - Package installation fails.
438/// - Temporary directory creation fails.
439/// - Symlinking binary files fails.
440/// - Executing the shell command fails.
441///
442/// # Panics
443///
444/// Panics if a mutex lock is poisoned.
445pub fn enter_ephemeral_shell(
446    package_sources: &[String],
447    run_cmd: Option<String>,
448    verbose: bool,
449    _plugin_manager: Option<&plugin::PluginManager>
450) -> Result<()> {
451    if verbose {
452        println!("{} Resolving ephemeral environment...", "::".bold().blue());
453    }
454
455    let installed_before: HashSet<String> = local::get_installed_packages()?
456        .into_iter()
457        .map(|m| local::installed_manifest_source(&m))
458        .collect();
459
460    let (graph, _non_zoi_deps) = install::resolver::resolve_dependency_graph(
461        package_sources,
462        None,
463        false,
464        true,
465        true,
466        None,
467        !verbose,
468        None
469    )?;
470
471    let install_plan =
472        install::plan::create_install_plan(&graph.nodes, None, false)?;
473    let stages = graph.toposort()?;
474
475    let mut session_installed = Vec::new();
476
477    if !install_plan.is_empty() {
478        if verbose {
479            println!(
480                "{} Preparing {} ephemeral dependencies...",
481                "::".bold().blue(),
482                install_plan.len()
483            );
484        }
485        let m = indicatif::MultiProgress::new();
486        if !verbose {
487            m.set_draw_target(indicatif::ProgressDrawTarget::hidden());
488        }
489        let session_installed_mutex = Mutex::new(Vec::new());
490
491        for stage in stages {
492            use rayon::prelude::*;
493            stage.into_par_iter().try_for_each(|pkg_id| -> Result<()> {
494                let node = graph.nodes.get(&pkg_id).ok_or_else(|| {
495                    anyhow!("Package node missing from graph for '{pkg_id}'")
496                })?;
497                let action = install_plan.get(&pkg_id).ok_or_else(|| {
498                    anyhow!("Install action missing for package '{pkg_id}'")
499                })?;
500
501                let manifest = install::installer::install_node(
502                    node,
503                    action,
504                    Some(&m),
505                    None,
506                    true,
507                    false,
508                    false,
509                    verbose
510                )?;
511
512                let mut session_lock = session_installed_mutex
513                    .lock()
514                    .expect("failed to lock session_installed_mutex");
515                session_lock.push(manifest);
516                Ok(())
517            })?;
518        }
519        session_installed = session_installed_mutex
520            .into_inner()
521            .expect("failed to get session_installed_mutex inner value");
522    }
523
524    let temp_dir = tempfile::Builder::new().prefix("zoi-shell-").tempdir()?;
525    let temp_bin_dir = temp_dir.path().join("bin");
526    fs::create_dir_all(&temp_bin_dir)?;
527
528    for node in graph.nodes.values() {
529        let handle = &node.registry_handle;
530        let pkg = &node.pkg;
531
532        let package_dir =
533            local::get_package_dir(pkg.scope, handle, &pkg.repo, &pkg.name)?;
534        let version_dir = package_dir.join(&node.version);
535        let bin_dir = version_dir.join("bin");
536
537        if bin_dir.exists() {
538            for entry in fs::read_dir(bin_dir)? {
539                let entry = entry?;
540                let path = entry.path();
541                if path.is_file() || path.is_symlink() {
542                    let file_name = path.file_name().ok_or_else(|| {
543                        anyhow!("Path has no file name: {}", path.display())
544                    })?;
545                    let dest = temp_bin_dir.join(file_name);
546                    utils::symlink_file(&path, &dest)?;
547                }
548            }
549        }
550    }
551
552    let sep = if cfg!(windows) { ";" } else { ":" };
553    let mut new_path = temp_bin_dir.to_string_lossy().to_string();
554    if let Ok(old_path) = std::env::var("PATH") {
555        new_path = format!("{new_path}{sep}{old_path}");
556    }
557
558    let package_list = package_sources.join(",");
559
560    let shell_bin = std::env::var("SHELL").unwrap_or_else(|_| {
561        if cfg!(windows) {
562            "pwsh".to_string()
563        } else {
564            "bash".to_string()
565        }
566    });
567
568    let mut envs = HashMap::new();
569    envs.insert("PATH".to_string(), new_path);
570    envs.insert("ZOI_SHELL".to_string(), "ephemeral".to_string());
571    envs.insert("IN_ZOI_SHELL".to_string(), "ephemeral".to_string());
572    envs.insert("ZOI_SHELL_PACKAGES".to_string(), package_list);
573
574    #[cfg(target_os = "linux")]
575    let mut shell_command = {
576        use std::path::Path;
577        let sysroot = zoi_core::sysroot::get_sysroot();
578        if let Some(root) = sysroot {
579            if verbose {
580                println!(
581                    "{} Entering shell within sysroot: {}",
582                    "::".bold().yellow(),
583                    root.display()
584                );
585            }
586
587            let extra_binds = vec![(
588                temp_dir.path().to_path_buf(),
589                temp_dir.path().to_path_buf()
590            )];
591
592            if let Some(cmd_str) = run_cmd {
593                let args = vec!["-c".to_string(), cmd_str];
594                crate::sandbox::wrap_command_in_root(
595                    &root,
596                    Path::new(&shell_bin),
597                    &args,
598                    &envs,
599                    &extra_binds,
600                    false
601                )?
602            } else {
603                crate::sandbox::wrap_command_in_root(
604                    &root,
605                    Path::new(&shell_bin),
606                    &[],
607                    &envs,
608                    &extra_binds,
609                    false
610                )?
611            }
612        } else if let Some(cmd_str) = run_cmd {
613            if verbose {
614                println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
615            }
616            let mut c = if cfg!(windows) {
617                Command::new("pwsh")
618            } else {
619                Command::new("bash")
620            };
621            if cfg!(windows) {
622                c.arg("-Command");
623            } else {
624                c.arg("-c");
625            }
626            c.arg(&cmd_str);
627            c.envs(&envs);
628            c
629        } else {
630            if verbose {
631                println!(
632                    "{} Entering ephemeral shell (type 'exit' to leave)...",
633                    "::".bold().green()
634                );
635            }
636            let mut c = Command::new(&shell_bin);
637            c.envs(&envs);
638            c
639        }
640    };
641
642    #[cfg(not(target_os = "linux"))]
643    let mut shell_command = {
644        if let Some(cmd_str) = run_cmd {
645            if verbose {
646                println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
647            }
648            let mut c = if cfg!(windows) {
649                Command::new("pwsh")
650            } else {
651                Command::new("bash")
652            };
653            if !cfg!(windows) {
654                c.arg("-c");
655            } else {
656                c.arg("-Command");
657            }
658            c.arg(&cmd_str);
659            c.envs(&envs);
660            c
661        } else {
662            if verbose {
663                println!(
664                    "{} Entering ephemeral shell (type 'exit' to leave)...",
665                    "::".bold().green()
666                );
667            }
668            let mut c = Command::new(&shell_bin);
669            c.envs(&envs);
670            c
671        }
672    };
673
674    let status = shell_command.status()?;
675
676    if !session_installed.is_empty() {
677        if verbose {
678            println!(
679                "{} Cleaning up ephemeral packages...",
680                "::".bold().blue()
681            );
682        }
683        for manifest in session_installed {
684            let ident = local::installed_manifest_source(&manifest);
685            if installed_before.contains(&ident) {
686                continue;
687            }
688            let version_dir = match get_version_dir_from_manifest(&manifest) {
689                Ok(d) => d,
690                Err(e) => {
691                    eprintln!(
692                        "Warning: failed to resolve path for {ident}: {e}"
693                    );
694                    continue;
695                }
696            };
697            if version_dir.exists()
698                && let Err(e) = fs::remove_dir_all(&version_dir)
699            {
700                eprintln!(
701                    "Warning: failed to cleanup ephemeral package {ident}: {e}"
702                );
703            }
704            let package_dir = version_dir
705                .parent()
706                .expect("version directory should have a parent");
707            if let Ok(mut entries) = fs::read_dir(package_dir) {
708                let has_other_entries = entries.any(|e| {
709                    e.as_ref().is_ok_and(|e| {
710                        e.file_name() != "latest"
711                            && e.file_name() != "dependents"
712                    })
713                });
714                if !has_other_entries {
715                    let _ = fs::remove_dir_all(package_dir);
716                }
717            }
718        }
719    }
720
721    if !status.success() {
722        std::process::exit(status.code().unwrap_or(1));
723    }
724
725    Ok(())
726}
727
728/// Returns the version directory for a given install manifest.
729fn get_version_dir_from_manifest(
730    manifest: &zoi_core::types::InstallManifest
731) -> Result<PathBuf> {
732    local::get_package_version_dir(
733        manifest.scope,
734        &manifest.registry_handle,
735        &manifest.repo,
736        &manifest.name,
737        &manifest.version
738    )
739}