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
17fn 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
73fn 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
141fn 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
239pub 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
288fn 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
311fn 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
375pub 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
427pub 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 false
507 )?;
508
509 let mut session_lock = session_installed_mutex
510 .lock()
511 .expect("failed to lock session_installed_mutex");
512 session_lock.push(manifest);
513 Ok(())
514 })?;
515 }
516 session_installed = session_installed_mutex
517 .into_inner()
518 .expect("failed to get session_installed_mutex inner value");
519 }
520
521 let temp_dir = tempfile::Builder::new().prefix("zoi-shell-").tempdir()?;
522 let temp_bin_dir = temp_dir.path().join("bin");
523 fs::create_dir_all(&temp_bin_dir)?;
524
525 for node in graph.nodes.values() {
526 let handle = &node.registry_handle;
527 let pkg = &node.pkg;
528
529 let package_dir =
530 local::get_package_dir(pkg.scope, handle, &pkg.repo, &pkg.name)?;
531 let version_dir = package_dir.join(&node.version);
532 let bin_dir = version_dir.join("bin");
533
534 if bin_dir.exists() {
535 for entry in fs::read_dir(bin_dir)? {
536 let entry = entry?;
537 let path = entry.path();
538 if path.is_file() || path.is_symlink() {
539 let file_name = path.file_name().ok_or_else(|| {
540 anyhow!("Path has no file name: {}", path.display())
541 })?;
542 let dest = temp_bin_dir.join(file_name);
543 utils::symlink_file(&path, &dest)?;
544 }
545 }
546 }
547 }
548
549 let sep = if cfg!(windows) { ";" } else { ":" };
550 let mut new_path = temp_bin_dir.to_string_lossy().to_string();
551 if let Ok(old_path) = std::env::var("PATH") {
552 new_path = format!("{new_path}{sep}{old_path}");
553 }
554
555 let package_list = package_sources.join(",");
556
557 let shell_bin = zoi_core::utils::resolve_shell_binary()?;
560
561 let mut envs = HashMap::new();
562 envs.insert("PATH".to_string(), new_path);
563 envs.insert("ZOI_SHELL".to_string(), "ephemeral".to_string());
564 envs.insert("IN_ZOI_SHELL".to_string(), "ephemeral".to_string());
565 envs.insert("ZOI_SHELL_PACKAGES".to_string(), package_list);
566
567 #[cfg(target_os = "linux")]
568 let mut shell_command = {
569 use std::path::Path;
570 let sysroot = zoi_core::sysroot::get_sysroot();
571 if let Some(root) = sysroot {
572 if verbose {
573 println!(
574 "{} Entering shell within sysroot: {}",
575 "::".bold().yellow(),
576 root.display()
577 );
578 }
579
580 let extra_binds = vec![(
581 temp_dir.path().to_path_buf(),
582 temp_dir.path().to_path_buf()
583 )];
584
585 if let Some(cmd_str) = run_cmd {
586 let args = vec!["-c".to_string(), cmd_str];
587 crate::sandbox::wrap_command_in_root(
588 &root,
589 Path::new(&shell_bin),
590 &args,
591 &envs,
592 &extra_binds,
593 false
594 )?
595 } else {
596 crate::sandbox::wrap_command_in_root(
597 &root,
598 Path::new(&shell_bin),
599 &[],
600 &envs,
601 &extra_binds,
602 false
603 )?
604 }
605 } else if let Some(cmd_str) = run_cmd {
606 if verbose {
607 println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
608 }
609 let mut c = if cfg!(windows) {
610 Command::new("pwsh")
611 } else {
612 Command::new("bash")
613 };
614 if cfg!(windows) {
615 c.arg("-Command");
616 } else {
617 c.arg("-c");
618 }
619 c.arg(&cmd_str);
620 c.envs(&envs);
621 c
622 } else {
623 if verbose {
624 println!(
625 "{} Entering ephemeral shell (type 'exit' to leave)...",
626 "::".bold().green()
627 );
628 }
629 let mut c = Command::new(&shell_bin);
630 c.envs(&envs);
631 c
632 }
633 };
634
635 #[cfg(not(target_os = "linux"))]
636 let mut shell_command = {
637 if let Some(cmd_str) = run_cmd {
638 if verbose {
639 println!("{} Running: {}", "::".bold().blue(), cmd_str.cyan());
640 }
641 let mut c = if cfg!(windows) {
642 Command::new("pwsh")
643 } else {
644 Command::new("bash")
645 };
646 if !cfg!(windows) {
647 c.arg("-c");
648 } else {
649 c.arg("-Command");
650 }
651 c.arg(&cmd_str);
652 c.envs(&envs);
653 c
654 } else {
655 if verbose {
656 println!(
657 "{} Entering ephemeral shell (type 'exit' to leave)...",
658 "::".bold().green()
659 );
660 }
661 let mut c = Command::new(&shell_bin);
662 c.envs(&envs);
663 c
664 }
665 };
666
667 let status = shell_command.status()?;
668
669 if !session_installed.is_empty() {
670 if verbose {
671 println!(
672 "{} Cleaning up ephemeral packages...",
673 "::".bold().blue()
674 );
675 }
676 for manifest in session_installed {
677 let ident = local::installed_manifest_source(&manifest);
678 if installed_before.contains(&ident) {
679 continue;
680 }
681 let version_dir = match get_version_dir_from_manifest(&manifest) {
682 Ok(d) => d,
683 Err(e) => {
684 eprintln!(
685 "Warning: failed to resolve path for {ident}: {e}"
686 );
687 continue;
688 }
689 };
690 if version_dir.exists()
691 && let Err(e) = fs::remove_dir_all(&version_dir)
692 {
693 eprintln!(
694 "Warning: failed to cleanup ephemeral package {ident}: {e}"
695 );
696 }
697 let package_dir = version_dir
698 .parent()
699 .expect("version directory should have a parent");
700 if let Ok(mut entries) = fs::read_dir(package_dir) {
701 let has_other_entries = entries.any(|e| {
702 e.as_ref().is_ok_and(|e| {
703 e.file_name() != "latest"
704 && e.file_name() != "dependents"
705 })
706 });
707 if !has_other_entries {
708 let _ = fs::remove_dir_all(package_dir);
709 }
710 }
711 }
712 }
713
714 if !status.success() {
715 std::process::exit(status.code().unwrap_or(1));
716 }
717
718 Ok(())
719}
720
721fn get_version_dir_from_manifest(
723 manifest: &zoi_core::types::InstallManifest
724) -> Result<PathBuf> {
725 local::get_package_version_dir(
726 manifest.scope,
727 &manifest.registry_handle,
728 &manifest.repo,
729 &manifest.name,
730 &manifest.version
731 )
732}