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 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
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 => {
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
315fn 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
379pub 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
431pub 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
728fn 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}