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 )?;
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
724fn 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}