1pub mod autoremove;
8
9use std::fs;
10use std::io::Write;
11use std::path::PathBuf;
12
13use anyhow::anyhow;
14use colored::Colorize;
15use mlua::Lua;
16use zoi_core::{recorder, sysroot, types, utils as core_utils};
17use zoi_db as db;
18use zoi_deps as dependencies;
19use zoi_hooks as hooks;
20use zoi_resolver::{local, resolve};
21use zoi_telemetry as telemetry;
22
23fn get_bin_root(scope: types::Scope) -> anyhow::Result<PathBuf> {
25 match scope {
26 types::Scope::User => core_utils::get_user_bin_dir(),
27 types::Scope::System => Ok(core_utils::get_system_bin_dir()),
28 types::Scope::Project => {
29 let current_dir = std::env::current_dir()?;
30 Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
31 }
32 }
33}
34
35fn get_completions_root(
38 scope: types::Scope,
39 shell: &str
40) -> anyhow::Result<PathBuf> {
41 match scope {
42 types::Scope::User => core_utils::get_user_completions_dir(shell),
43 types::Scope::System => {
44 if cfg!(target_os = "windows") {
45 Ok(sysroot::apply_sysroot(PathBuf::from(format!(
46 "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}",
47 ))))
48 } else {
49 let base = match shell {
50 "bash" => "/usr/share/bash-completion/completions",
51 "zsh" => "/usr/share/zsh/site-functions",
52 "fish" => "/usr/share/fish/vendor_completions.d",
53 "elvish" => "/usr/share/elvish/lib",
54 _ => "/usr/local/share/zoi/completions"
55 };
56 Ok(sysroot::apply_sysroot(PathBuf::from(base)))
57 }
58 }
59 types::Scope::Project => {
60 let current_dir = std::env::current_dir()?;
61 Ok(current_dir
62 .join(".zoi")
63 .join("pkgs")
64 .join("shell")
65 .join(shell))
66 }
67 }
68}
69
70fn cleanup_service(
72 package_name: &str,
73 scope: types::Scope
74) -> anyhow::Result<()> {
75 let service_name = format!("zoi-{package_name}");
76 let is_user = scope != types::Scope::System;
77
78 match std::env::consts::OS {
79 "linux" => {
80 let unit_path = if is_user {
81 let home = core_utils::get_user_home()
82 .ok_or_else(|| anyhow!("Could not find home directory"))?;
83 sysroot::apply_sysroot(
84 home.join(".config/systemd/user")
85 .join(format!("{service_name}.service"))
86 )
87 } else {
88 sysroot::apply_sysroot(PathBuf::from(format!(
89 "/etc/systemd/system/{service_name}.service",
90 )))
91 };
92 if unit_path.exists() {
93 println!("Removing service unit file: {}", unit_path.display());
94 fs::remove_file(&unit_path).map_err(|e| {
95 anyhow!(
96 "Failed to remove unit file: {}: {}",
97 unit_path.display(),
98 e
99 )
100 })?;
101 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
102 let mut cmd = std::process::Command::new("systemctl");
103 if is_user {
104 cmd.arg("--user");
105 }
106 cmd.arg("daemon-reload").status().map_err(|e| {
107 anyhow!("Failed to run systemctl daemon-reload: {e}")
108 })?;
109 }
110 }
111 }
112 "macos" => {
113 let plist_path = if is_user {
114 let home = core_utils::get_user_home()
115 .ok_or_else(|| anyhow!("Could not find home directory"))?;
116 sysroot::apply_sysroot(
117 home.join("Library/LaunchAgents")
118 .join(format!("{service_name}.plist"))
119 )
120 } else {
121 sysroot::apply_sysroot(PathBuf::from(format!(
122 "/Library/LaunchDaemons/{service_name}.plist",
123 )))
124 };
125 if plist_path.exists() {
126 println!(
127 "Removing service plist file: {}",
128 plist_path.display()
129 );
130 fs::remove_file(&plist_path).map_err(|e| {
131 anyhow!(
132 "Failed to remove plist file: {}: {}",
133 plist_path.display(),
134 e
135 )
136 })?;
137 }
138 }
139 "windows" => {
140 let exists = {
141 let output = std::process::Command::new("sc")
142 .arg("query")
143 .arg(&service_name)
144 .output()
145 .map_err(|e| anyhow!("Failed to run sc query: {e}"))?;
146 output.status.success()
147 };
148 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err()
149 && exists
150 {
151 println!("Removing Windows service: {service_name}");
152 std::process::Command::new("sc")
153 .arg("delete")
154 .arg(&service_name)
155 .status()
156 .map_err(|e| anyhow!("Failed to run sc delete: {e}"))?;
157 }
158 }
159 _ => {}
160 }
161
162 Ok(())
163}
164
165fn uninstall_collection(
167 pkg: &types::Package,
168 manifest: &types::InstallManifest,
169 scope: types::Scope,
170 registry_handle: Option<&str>,
171 yes: bool,
172 quiet: bool,
173 dry_run: bool
174) -> anyhow::Result<types::InstallManifest> {
175 if !quiet {
176 println!("Uninstalling collection '{}'...", pkg.name.bold());
177 }
178
179 if dry_run {
180 return Ok(manifest.clone());
181 }
182
183 let dependencies_to_uninstall = &manifest.installed_dependencies;
184
185 if dependencies_to_uninstall.is_empty() {
186 if !quiet {
187 println!("Collection has no dependencies to uninstall.");
188 }
189 } else {
190 if !quiet {
191 println!("Uninstalling dependencies of the collection...");
192 }
193 for dep_str in dependencies_to_uninstall {
194 let dep = dependencies::parse_dependency_string(dep_str)?;
195
196 if dep.manager == "zoi" {
197 if !quiet {
198 println!(
199 "\n{} Uninstalling zoi dependency: {}...",
200 "::".bold().blue(),
201 dep_str.bold()
202 );
203 }
204 } else {
205 let prompt = format!(
206 "Uninstall native dependency '{}' ({})?",
207 dep.package.cyan(),
208 dep.manager.yellow()
209 );
210 let warning = "Warning: Zoi cannot track if other non-Zoi \
211 applications depend on this package.";
212
213 if yes {
214 if !quiet {
215 println!(
216 "\n{} Uninstalling native dependency: {}...",
217 "::".bold().blue(),
218 dep_str.bold()
219 );
220 println!("{}: {}", "Note".yellow(), warning);
221 }
222 } else if core_utils::ask_for_confirmation(
223 &format!("{}\n {}", prompt, warning.dimmed()),
224 false
225 ) {
226 if !quiet {
227 println!(
228 "\n{} Uninstalling dependency: {}...",
229 "::".bold().blue(),
230 dep_str.bold()
231 );
232 }
233 } else {
234 if !quiet {
235 println!(
236 "Skipping uninstallation of native dependency: {}",
237 dep.package.yellow()
238 );
239 }
240 continue;
241 }
242 }
243
244 if let Err(e) =
245 dependencies::uninstall_dependency(dep_str, &move |name| {
246 run(name, Some(scope), yes, quiet, dry_run).map(|_| ())
247 })
248 && !quiet
249 {
250 eprintln!(
251 "Warning: Could not uninstall dependency '{dep_str}': {e}"
252 );
253 }
254 }
255 }
256
257 let handle = registry_handle.unwrap_or("local");
258 let package_dir =
259 local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
260 if package_dir.exists() {
261 let _ = cleanup_service(&pkg.name, scope);
262 fs::remove_dir_all(&package_dir)?;
263 }
264 if let Err(e) = recorder::remove_package_from_record(manifest)
265 && !quiet
266 {
267 eprintln!(
268 "{} Failed to remove package from lockfile: {}",
269 "Warning:".yellow(),
270 e
271 );
272 }
273
274 if let Ok(conn) = db::open_connection("local") {
275 let _ =
276 db::delete_package(&conn, &pkg.name, None, &pkg.repo, Some(scope));
277 }
278
279 if let Ok(true) = telemetry::posthog_capture_event(
280 "uninstall",
281 pkg,
282 env!("CARGO_PKG_VERSION"),
283 registry_handle.unwrap_or("local"),
284 None
285 ) && !quiet
286 {
287 println!("{} telemetry queued", "Info:".green());
288 }
289
290 Ok(manifest.clone())
291}
292
293fn find_active_manifest(
301 request: &resolve::PackageRequest,
302 scope_override: Option<types::Scope>
303) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
304 let mut unversioned = resolve::PackageRequest {
305 handle: request.handle.clone(),
306 repo: request.repo.clone(),
307 name: request.name.clone(),
308 sub_package: request.sub_package.clone(),
309 version_spec: None
310 };
311 let _ = &mut unversioned;
312 find_installed_manifest(&unversioned, scope_override)
313}
314
315fn list_stored_version_manifests(
321 package_dir: &std::path::Path,
322 sub_package: Option<&str>
323) -> Vec<(String, types::InstallManifest)> {
324 let mut out = Vec::new();
325 let Ok(entries) = fs::read_dir(package_dir) else {
326 return out;
327 };
328 for entry in entries.flatten() {
329 let name = entry.file_name().to_string_lossy().to_string();
330 if name == "latest" || name == "dependents" {
331 continue;
332 }
333 let version_dir = entry.path();
334 if !version_dir.is_dir() {
335 continue;
336 }
337 let manifest_path =
338 local::find_store_manifest(&version_dir, sub_package);
339 let Some(manifest_path) = manifest_path else {
340 continue;
341 };
342 if let Ok(manifest) = local::read_store_manifest(&manifest_path)
343 && manifest.sub_package.as_deref() == sub_package
344 {
345 out.push((name, manifest));
346 }
347 }
348 out.sort_by(|a, b| compare_versions(&a.0, &b.0));
349 out
350}
351
352pub fn find_stored_version_anywhere(
360 request: &resolve::PackageRequest,
361 scope_override: Option<types::Scope>
362) -> Option<(types::InstallManifest, types::Scope)> {
363 let version = request.version_spec.as_deref()?;
364 let scopes = if let Some(scope) = scope_override {
365 vec![scope]
366 } else {
367 vec![
368 types::Scope::Project,
369 types::Scope::User,
370 types::Scope::System,
371 ]
372 };
373 for scope in scopes {
374 let Ok(store_root) = local::get_store_base_dir(scope) else {
375 continue;
376 };
377 if !store_root.exists() {
378 continue;
379 }
380 let Ok(entries) = fs::read_dir(&store_root) else {
381 continue;
382 };
383 for entry in entries.flatten() {
384 let package_dir = entry.path();
385 if !package_dir.is_dir() {
386 continue;
387 }
388 for (stored_version, manifest) in list_stored_version_manifests(
389 &package_dir,
390 request.sub_package.as_deref()
391 ) {
392 if stored_version != version {
393 continue;
394 }
395 if !manifest.name.eq_ignore_ascii_case(&request.name) {
396 continue;
397 }
398 if request.handle.as_ref().is_some_and(|handle| {
399 !manifest.registry_handle.eq_ignore_ascii_case(handle)
400 }) {
401 continue;
402 }
403 if request.repo.as_ref().is_some_and(|repo| {
404 !manifest.repo.eq_ignore_ascii_case(repo)
405 }) {
406 continue;
407 }
408 return Some((manifest, scope));
409 }
410 }
411 }
412 None
413}
414
415fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
417 match (
418 semver::Version::parse(a.trim_start_matches('v')),
419 semver::Version::parse(b.trim_start_matches('v'))
420 ) {
421 (Ok(va), Ok(vb)) => va.cmp(&vb),
422 _ => a.cmp(b)
423 }
424}
425
426fn remove_stored_version_dir(
429 package_dir: &std::path::Path,
430 version: &str,
431 sub_package: Option<&str>,
432 quiet: bool
433) -> anyhow::Result<types::InstallManifest> {
434 let version_dir = package_dir.join(version);
435 let manifest_path = local::find_store_manifest(&version_dir, sub_package)
436 .ok_or_else(|| {
437 anyhow!(
438 "Version '{version}' has no install manifest in {}",
439 version_dir.display()
440 )
441 })?;
442 let manifest = local::read_store_manifest(&manifest_path)?;
443 if manifest_path.exists() {
444 fs::remove_file(&manifest_path)?;
445 }
446 let legacy_path = version_dir.join(format!(
447 "manifest{}.yaml",
448 sub_package.map_or_else(String::new, |sub| format!("-{sub}"))
449 ));
450 if legacy_path != manifest_path && legacy_path.exists() {
451 fs::remove_file(&legacy_path)?;
452 }
453 let source_path = version_dir.join("package.pkg.lua");
455 if source_path.exists() {
456 let _ = fs::remove_file(&source_path);
457 }
458 if version_dir.exists() {
459 let is_empty =
460 fs::read_dir(&version_dir).is_ok_and(|mut e| e.next().is_none());
461 if is_empty {
462 fs::remove_dir_all(&version_dir)?;
463 } else if !quiet {
464 println!(
465 "Keeping non-empty version directory: {}",
466 version_dir.display()
467 );
468 }
469 }
470 Ok(manifest)
471}
472
473fn load_stored_package(
479 version_dir: &std::path::Path,
480 manifest: &types::InstallManifest
481) -> types::Package {
482 let stored_source = version_dir.join("package.pkg.lua");
483 if stored_source.exists()
484 && let Some(path_str) = stored_source.to_str()
485 && let Ok(mut pkg) = zoi_lua::parser::parse_lua_package(
486 path_str,
487 Some(&manifest.version),
488 Some(manifest.scope),
489 true
490 )
491 {
492 pkg.repo.clone_from(&manifest.repo);
493 pkg.scope = manifest.scope;
494 pkg.registry_handle = Some(manifest.registry_handle.clone());
495 pkg.sub_package.clone_from(&manifest.sub_package);
496 return pkg;
497 }
498 let mut pkg = manifest.clone().into_package();
499 pkg.description.clone_from(&manifest.description);
500 pkg.registry_handle = Some(manifest.registry_handle.clone());
501 pkg
502}
503
504fn restore_version_shims(
506 manifest: &types::InstallManifest,
507 scope: types::Scope,
508 quiet: bool
509) {
510 let bins: Vec<String> = manifest.bins.clone().unwrap_or_else(|| {
511 if manifest.sub_package.is_none() {
512 vec![manifest.name.clone()]
513 } else {
514 Vec::new()
515 }
516 });
517 if bins.is_empty() {
518 return;
519 }
520 let Ok(bin_root) = get_bin_root(scope) else {
521 return;
522 };
523 if let Err(e) = fs::create_dir_all(&bin_root) {
524 if !quiet {
525 eprintln!(
526 "{} could not create bin dir {}: {}",
527 "Warning:".yellow(),
528 bin_root.display(),
529 e
530 );
531 }
532 return;
533 }
534 let Ok(zoi_exe) = std::env::current_exe() else {
535 return;
536 };
537 for bin in &bins {
538 let link_path = bin_root.join(bin);
539 if let Err(e) = core_utils::symlink_file(&zoi_exe, &link_path) {
540 if !quiet {
541 eprintln!(
542 "{} failed to restore shim for {bin}: {}",
543 "Warning:".yellow(),
544 e
545 );
546 }
547 } else if !quiet {
548 println!("Restored shim for {}...", bin.cyan());
549 }
550 }
551}
552
553fn restore_version_completions(
555 manifest: &types::InstallManifest,
556 version_dir: &std::path::Path,
557 scope: types::Scope,
558 quiet: bool
559) {
560 let Some(completions) = &manifest.completions else {
561 return;
562 };
563 for completion in completions {
564 let candidates = [
567 version_dir
568 .join("shell")
569 .join(&completion.shell)
570 .join(&completion.filename),
571 version_dir
572 .join("data")
573 .join("shell")
574 .join(&completion.shell)
575 .join(&completion.filename)
576 ];
577 let Some(store_path) = candidates.iter().find(|p| p.exists()) else {
578 if !quiet {
579 eprintln!(
580 "{} completion source for {} not found in store, skipping.",
581 "Warning:".yellow(),
582 completion.filename.cyan()
583 );
584 }
585 continue;
586 };
587 let Ok(completions_root) =
588 get_completions_root(scope, &completion.shell)
589 else {
590 continue;
591 };
592 let pkg_dir = completions_root.join(&manifest.name);
593 let link_path = pkg_dir.join(&completion.filename);
594 if let Some(parent) = link_path.parent()
595 && let Err(e) = fs::create_dir_all(parent)
596 && !quiet
597 {
598 eprintln!(
599 "{} could not create completions dir {}: {}",
600 "Warning:".yellow(),
601 parent.display(),
602 e
603 );
604 }
605 #[cfg(unix)]
606 let mut link_result =
607 std::os::unix::fs::symlink(store_path, &link_path)
608 .map_err(|e| anyhow!(e.to_string()));
609 #[cfg(windows)]
610 let mut link_result =
611 std::os::windows::fs::symlink_file(store_path, &link_path)
612 .map_err(|e| anyhow!(e.to_string()));
613 if link_result.is_err() {
615 let _ = fs::remove_file(&link_path);
616 #[cfg(unix)]
617 {
618 link_result =
619 std::os::unix::fs::symlink(store_path, &link_path)
620 .map_err(|e| anyhow!(e.to_string()));
621 }
622 #[cfg(windows)]
623 {
624 link_result =
625 std::os::windows::fs::symlink_file(store_path, &link_path)
626 .map_err(|e| anyhow!(e.to_string()));
627 }
628 }
629 if let Err(e) = link_result
630 && !quiet
631 {
632 eprintln!(
633 "{} failed to restore completion {}: {}",
634 "Warning:".yellow(),
635 completion.filename.cyan(),
636 e
637 );
638 }
639 }
640}
641
642fn activate_stored_version(
650 package_dir: &std::path::Path,
651 scope: types::Scope,
652 version: &str,
653 manifest: &types::InstallManifest,
654 quiet: bool
655) -> anyhow::Result<()> {
656 let version_dir = package_dir.join(version);
657 if let Err(e) =
659 core_utils::symlink_dir(&version_dir, &package_dir.join("latest"))
660 {
661 return Err(anyhow!(
662 "Failed to point 'latest' at version '{version}': {e}"
663 ));
664 }
665 if let Err(e) = local::write_manifest(manifest)
668 && !quiet
669 {
670 eprintln!(
671 "{} failed to rewrite manifest for version '{version}': {}",
672 "Warning:".yellow(),
673 e
674 );
675 }
676 restore_version_shims(manifest, scope, quiet);
677 restore_version_completions(manifest, &version_dir, scope, quiet);
678
679 let pkg = load_stored_package(&version_dir, manifest);
680 if let Ok(conn) = db::open_connection("local")
681 && let Ok(pkg_id) = db::update_package(
682 &conn,
683 &pkg,
684 &manifest.registry_handle,
685 Some(scope),
686 manifest.sub_package.as_deref(),
687 Some(&manifest.reason)
688 )
689 {
690 let _ = db::clear_package_files(&conn, pkg_id);
691 let _ =
692 db::index_package_files(&conn, pkg_id, &manifest.installed_files);
693 }
694 if let Err(e) = recorder::record_package(
695 &pkg,
696 &manifest.reason,
697 &manifest.installed_dependencies,
698 &manifest.registry_handle,
699 &manifest.repo_type,
700 &manifest.chosen_options,
701 &manifest.chosen_optionals,
702 manifest.sub_package.as_deref()
703 ) && !quiet
704 {
705 eprintln!(
706 "{} failed to record rolled-back version '{version}': {}",
707 "Warning:".yellow(),
708 e
709 );
710 }
711 if !quiet {
712 println!(
713 "{} Activated previous version {} from the store.",
714 "::".bold().green(),
715 version.green()
716 );
717 }
718 Ok(())
719}
720
721fn find_installed_manifest(
723 request: &resolve::PackageRequest,
724 scope_override: Option<types::Scope>
725) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
726 let scopes = if let Some(scope) = scope_override {
727 vec![scope]
728 } else {
729 vec![
730 types::Scope::Project,
731 types::Scope::User,
732 types::Scope::System,
733 ]
734 };
735
736 for scope in scopes {
737 let mut matches =
738 local::find_installed_manifests_matching(request, scope)?;
739 match matches.len() {
740 0 => {}
741 1 => return Ok((matches.remove(0), scope)),
742 _ => {
743 return Err(anyhow!(
744 "Package '{}' is ambiguous in {:?} scope. Use an explicit \
745 source like '#handle@repo/name[:sub]@version'.",
746 request.name,
747 scope
748 ));
749 }
750 }
751 }
752
753 if scope_override.is_some() {
754 Err(anyhow!(
755 "Package '{}' is not installed in the specified scope.",
756 request.name
757 ))
758 } else {
759 Err(anyhow!(
760 "Package '{}' is not installed by Zoi.",
761 request.name
762 ))
763 }
764}
765
766fn load_installed_package(
769 manifest: &types::InstallManifest,
770 yes: bool
771) -> anyhow::Result<(types::Package, PathBuf)> {
772 let installed_source_path = local::get_package_source_path(manifest)?;
773 if installed_source_path.exists() {
774 let path = installed_source_path.to_str().ok_or_else(|| {
775 anyhow!("Stored package source path contains invalid UTF-8")
776 })?;
777 let mut pkg = zoi_lua::parser::parse_lua_package(
778 path,
779 Some(&manifest.version),
780 Some(manifest.scope),
781 true
782 )?;
783 pkg.repo.clone_from(&manifest.repo);
784 pkg.scope = manifest.scope;
785 pkg.registry_handle = Some(manifest.registry_handle.clone());
786 pkg.sub_package.clone_from(&manifest.sub_package);
787 return Ok((pkg, installed_source_path));
788 }
789
790 let source = local::installed_manifest_source(manifest);
791 let (mut pkg, _, _, pkg_lua_path, _, _, _) =
792 resolve::resolve_package_and_version(
793 &source,
794 Some(manifest.scope),
795 true,
796 yes
797 )?;
798 pkg.scope = manifest.scope;
799 pkg.sub_package.clone_from(&manifest.sub_package);
800 Ok((pkg, pkg_lua_path))
801}
802
803pub fn run(
831 package_name: &str,
832 scope_override: Option<types::Scope>,
833 yes: bool,
834 quiet: bool,
835 dry_run: bool
836) -> anyhow::Result<types::InstallManifest> {
837 let request = resolve::parse_source_string(package_name)?;
838 let (manifest, scope) = match find_active_manifest(&request, scope_override)
843 {
844 Ok(found) => found,
845 Err(active_err) => {
846 if let Some(version) = request.version_spec.as_deref() {
849 if let Some(found) =
850 find_stored_version_anywhere(&request, scope_override)
851 {
852 found
853 } else {
854 return Err(anyhow!(
855 "Version '{version}' of package '{}' is not \
856 installed. {}",
857 request.name,
858 active_err
859 ));
860 }
861 } else {
862 return Err(active_err);
863 }
864 }
865 };
866 if let Some(version) = request.version_spec.clone()
870 && version != manifest.version
871 {
872 let handle = manifest.registry_handle.as_str();
873 let package_dir = local::get_package_dir(
874 scope,
875 handle,
876 &manifest.repo,
877 &manifest.name
878 )?;
879 let stored = list_stored_version_manifests(
880 &package_dir,
881 manifest.sub_package.as_deref()
882 );
883 if stored.iter().any(|(v, _)| v == &version) {
884 if dry_run {
885 return Ok(manifest);
886 }
887 let dependents = local::get_dependents(&package_dir)?;
888 if !dependents.is_empty() {
889 return Err(anyhow::anyhow!(
890 "Cannot uninstall '{}' because other packages depend on \
891 it:\n -{}\n\nPlease uninstall these packages first.",
892 manifest.name,
893 dependents.join("\n - ")
894 ));
895 }
896 let removed = remove_stored_version_dir(
897 &package_dir,
898 &version,
899 manifest.sub_package.as_deref(),
900 quiet
901 )?;
902 if !quiet {
903 println!(
904 "Removed stored version {} of '{}'. Active version {} is \
905 unchanged.",
906 version.cyan(),
907 manifest.name.bold(),
908 manifest.version.green()
909 );
910 }
911 return Ok(removed);
912 }
913 let available: Vec<String> =
914 stored.iter().map(|(v, _)| v.clone()).collect();
915 if available.is_empty() {
916 return Err(anyhow!(
917 "Version '{version}' of package '{}' is not installed.",
918 request.name
919 ));
920 }
921 return Err(anyhow!(
922 "Version '{version}' of package '{}' is not installed. Stored \
923 versions: {}.",
924 request.name,
925 available.join(", ")
926 ));
927 }
928 let sub_package_to_uninstall = manifest.sub_package.clone();
929 let registry_handle = Some(manifest.registry_handle.clone());
930 let (pkg, pkg_lua_path) = load_installed_package(&manifest, yes)?;
931
932 if pkg.package_type == types::PackageType::Collection {
933 return uninstall_collection(
934 &pkg,
935 &manifest,
936 scope,
937 registry_handle.as_deref(),
938 yes,
939 quiet,
940 dry_run
941 );
942 }
943
944 if dry_run {
945 return Ok(manifest);
946 }
947
948 let handle = manifest.registry_handle.as_str();
949 let package_dir =
950 local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
951 let version_dir = package_dir.join(&manifest.version);
952
953 let dependents = local::get_dependents(&package_dir)?;
954 if !dependents.is_empty() {
955 return Err(anyhow::anyhow!(
956 "Cannot uninstall '{}' because other packages depend on it:\n \
957 -{}\n\nPlease uninstall these packages first.",
958 pkg.name,
959 dependents.join("\n - ")
960 ));
961 }
962
963 let needs_escalation =
964 scope == types::Scope::System && !core_utils::is_admin();
965
966 if needs_escalation {
967 let escalator =
968 core_utils::get_privilege_escalator().ok_or_else(|| {
969 anyhow!(
970 "Root privileges required to remove system package, but \
971 neither 'sudo' nor 'doas' was found."
972 )
973 })?;
974
975 if !quiet {
976 println!(
977 "{} Escalating to root via {} to remove system package...",
978 "::".bold().blue(),
979 escalator
980 );
981 }
982 let manifest_json = serde_json::to_string(&manifest)?;
983 let mut temp_file = tempfile::NamedTempFile::new()?;
984 temp_file.write_all(manifest_json.as_bytes())?;
985 let temp_path = temp_file.path();
986
987 let mut cmd = std::process::Command::new(escalator);
988 cmd.arg(std::env::current_exe()?);
989 cmd.arg("helper").arg("elevate-uninstall");
990 cmd.arg("--manifest-json").arg(temp_path);
991 if yes {
992 cmd.arg("--yes");
993 }
994
995 let status = cmd.status().map_err(|e| {
996 anyhow::anyhow!("Failed to spawn privilege escalator: {e}")
997 })?;
998 if !status.success() {
999 return Err(anyhow::anyhow!("Escalated uninstallation failed."));
1000 }
1001 } else {
1002 if let Some(hooks) = &pkg.hooks
1003 && let Err(e) =
1004 hooks::run_hooks(hooks, hooks::HookType::PreRemove, scope)
1005 {
1006 return Err(anyhow::anyhow!("Pre-remove hook failed: {e}"));
1007 }
1008
1009 let lua = Lua::new();
1010 zoi_lua::functions::setup_lua_environment(
1011 &lua,
1012 &core_utils::get_platform()?,
1013 Some(&manifest.version),
1014 pkg_lua_path.to_str(),
1015 None,
1016 None,
1017 None,
1018 sub_package_to_uninstall.as_deref(),
1019 Some(scope),
1020 None,
1021 true
1022 )
1023 .map_err(|e| anyhow!(e.to_string()))?;
1024 let lua_code = fs::read_to_string(pkg_lua_path)?;
1025 lua.load(&lua_code)
1026 .exec()
1027 .map_err(|e| anyhow!(e.to_string()))?;
1028
1029 if let Ok(uninstall_fn) =
1030 lua.globals().get::<mlua::Function>("uninstall")
1031 {
1032 if !quiet {
1033 println!("Running uninstall() script...");
1034 }
1035 uninstall_fn
1036 .call::<()>(())
1037 .map_err(|e| anyhow!(e.to_string()))?;
1038 }
1039
1040 if let Ok(uninstall_ops) =
1041 lua.globals().get::<mlua::Table>("__ZoiUninstallOperations")
1042 {
1043 for op in uninstall_ops.sequence_values::<mlua::Table>() {
1044 let op = op.map_err(|e| anyhow!(e.to_string()))?;
1045 if let Ok(op_type) = op.get::<String>("op")
1046 && op_type == "zrm"
1047 {
1048 let mut path_to_remove: String =
1049 op.get("path").map_err(|e| anyhow!(e.to_string()))?;
1050
1051 path_to_remove = path_to_remove
1052 .replace("${pkgstore}", &version_dir.to_string_lossy());
1053
1054 if let Some(home_dir) = core_utils::get_user_home() {
1055 path_to_remove = path_to_remove
1056 .replace("${usrhome}", &home_dir.to_string_lossy());
1057 }
1058 path_to_remove = path_to_remove.replace(
1059 "${usrroot}",
1060 &sysroot::apply_sysroot(PathBuf::from("/"))
1061 .to_string_lossy()
1062 );
1063
1064 let path = std::path::PathBuf::from(path_to_remove);
1065 if path.exists() {
1066 if !quiet {
1067 println!("Removing {}...", path.display());
1068 }
1069 if path.is_dir() {
1070 fs::remove_dir_all(path)?;
1071 } else {
1072 fs::remove_file(path)?;
1073 }
1074 }
1075 }
1076 }
1077 }
1078
1079 if let Some(backup_files) = &manifest.backup {
1080 if !quiet {
1081 println!("Saving configuration files...");
1082 }
1083 for backup_file_rel in backup_files {
1084 let expanded_path = zoi_core::utils::expand_placeholders(
1085 backup_file_rel,
1086 &version_dir,
1087 manifest.scope
1088 )?;
1089 let backup_src = PathBuf::from(expanded_path);
1090
1091 if backup_src.exists() {
1092 let backup_filename = backup_src
1093 .file_name()
1094 .ok_or_else(|| anyhow!("Invalid backup source name"))?
1095 .to_string_lossy();
1096 let backup_dest = version_dir
1097 .parent()
1098 .ok_or_else(|| {
1099 anyhow!(
1100 "version_dir should have a parent \
1101 (package_dir)"
1102 )
1103 })?
1104 .join(format!("{backup_filename}.zoisave"));
1105
1106 if let Some(p) = backup_dest.parent()
1107 && let Err(e) = fs::create_dir_all(p)
1108 {
1109 if !quiet {
1110 eprintln!(
1111 "Warning: could not create backup directory \
1112 {}: {}",
1113 p.display(),
1114 e
1115 );
1116 }
1117 continue;
1118 }
1119 if !quiet {
1120 println!(
1121 "Saving {} to {}",
1122 backup_src.display(),
1123 backup_dest.display()
1124 );
1125 }
1126 if let Err(e) = fs::copy(&backup_src, &backup_dest) {
1128 if !quiet {
1129 eprintln!(
1130 "Warning: failed to copy backup {}: {}",
1131 backup_src.display(),
1132 e
1133 );
1134 }
1135 } else {
1136 let _ = fs::remove_file(&backup_src);
1137 }
1138 }
1139 }
1140 }
1141
1142 if !quiet {
1143 println!(
1144 "Uninstalling '{}'...",
1145 if let Some(sub) = &manifest.sub_package {
1146 format!("{}:{}", pkg.name, sub)
1147 } else {
1148 pkg.name.clone()
1149 }
1150 .bold()
1151 );
1152 }
1153
1154 if let Some(bins) = &manifest.bins {
1155 let bin_root = get_bin_root(scope)?;
1156 for bin in bins {
1157 let symlink_path = bin_root.join(bin);
1158 if symlink_path.is_symlink() || symlink_path.exists() {
1159 let other_providers = db::find_provides("local", bin)?;
1160 let still_provided =
1161 other_providers.iter().any(|(p, _)| {
1162 p.name != pkg.name
1163 || (p.sub_package != manifest.sub_package)
1164 });
1165
1166 if still_provided {
1167 if !quiet {
1168 println!(
1169 "Keeping shim for {} as it is still provided \
1170 by other packages.",
1171 bin.cyan()
1172 );
1173 }
1174 } else {
1175 if !quiet {
1176 println!(
1177 "Removing shim for {} from {}...",
1178 bin.cyan(),
1179 symlink_path.display()
1180 );
1181 }
1182 fs::remove_file(&symlink_path)?;
1183 }
1184 }
1185 }
1186 } else if manifest.sub_package.is_none() {
1187 let bin = &pkg.name;
1188 let symlink_path = get_bin_root(scope)?.join(bin);
1189 if symlink_path.is_symlink() || symlink_path.exists() {
1190 let other_providers = db::find_provides("local", bin)?;
1191 let still_provided = other_providers.iter().any(|(p, _)| {
1192 p.name != pkg.name
1193 || (p.sub_package != manifest.sub_package)
1194 });
1195
1196 if !still_provided {
1197 if !quiet {
1198 println!(
1199 "Removing shim for {} from {}...",
1200 bin.cyan(),
1201 symlink_path.display()
1202 );
1203 }
1204 fs::remove_file(symlink_path)?;
1205 }
1206 }
1207 }
1208
1209 if let Some(completions) = &manifest.completions {
1210 for completion in completions {
1211 let completions_root =
1212 get_completions_root(scope, &completion.shell)?;
1213 let pkg_dir = completions_root.join(&pkg.name);
1214 let symlink_path = pkg_dir.join(&completion.filename);
1215 if symlink_path.is_symlink() || symlink_path.exists() {
1216 let other_providers =
1217 db::find_provides("local", &completion.filename)?;
1218 let still_provided =
1219 other_providers.iter().any(|(p, _)| {
1220 p.name != pkg.name
1221 || (p.sub_package != manifest.sub_package)
1222 });
1223
1224 if !still_provided {
1225 if !quiet {
1226 println!(
1227 "Removing {} completion for {} from {}...",
1228 completion.shell.cyan(),
1229 completion.filename.cyan(),
1230 symlink_path.display()
1231 );
1232 }
1233 fs::remove_file(&symlink_path)?;
1234 } else if !quiet {
1235 println!(
1236 "Keeping {} completion for {} as it is still \
1237 provided by other packages.",
1238 completion.shell.cyan(),
1239 completion.filename.cyan()
1240 );
1241 }
1242 }
1243 }
1244
1245 let shells: std::collections::HashSet<String> =
1246 completions.iter().map(|c| c.shell.clone()).collect();
1247 for shell_name in shells {
1248 let pkg_dir =
1249 get_completions_root(scope, &shell_name)?.join(&pkg.name);
1250 if pkg_dir.exists()
1251 && fs::read_dir(&pkg_dir)
1252 .is_ok_and(|mut e| e.next().is_none())
1253 {
1254 let _ = fs::remove_dir(&pkg_dir);
1255 }
1256 }
1257 }
1258
1259 let pkg_id_opt = if let Ok(conn) = db::open_connection("local") {
1260 db::get_package_id(
1261 &conn,
1262 &pkg.name,
1263 manifest.sub_package.as_deref(),
1264 &pkg.repo,
1265 handle
1266 )
1267 .ok()
1268 } else {
1269 None
1270 };
1271
1272 for file_path_str in &manifest.installed_files {
1273 let expanded = core_utils::expand_placeholders(
1274 file_path_str,
1275 &version_dir,
1276 scope
1277 )?;
1278 let file_path = PathBuf::from(&expanded);
1279
1280 if let Some(pkg_id) = pkg_id_opt
1281 && let Ok(conn) = db::open_connection("local")
1282 && let Ok(true) =
1283 db::has_other_owners(&conn, file_path_str, pkg_id)
1284 {
1285 if !quiet {
1286 println!(
1287 "Keeping {} as it is still owned by other packages.",
1288 file_path_str.dimmed()
1289 );
1290 }
1291 continue;
1292 }
1293
1294 let Ok(meta) = fs::symlink_metadata(&file_path) else {
1297 continue;
1298 };
1299
1300 if let Some(pkg_id) = pkg_id_opt
1301 && let Ok(conn) = db::open_connection("local")
1302 && let Ok(true) =
1303 db::has_other_owners(&conn, file_path_str, pkg_id)
1304 {
1305 if !quiet {
1306 println!(
1307 "Keeping {} as it is still owned by other packages.",
1308 file_path_str.dimmed()
1309 );
1310 }
1311 continue;
1312 }
1313
1314 if meta.file_type().is_symlink() {
1315 let _ = fs::remove_file(&file_path);
1316 } else if meta.is_dir() {
1317 if fs::read_dir(&file_path)
1319 .is_ok_and(|mut e| e.next().is_none())
1320 {
1321 let _ = fs::remove_dir(&file_path);
1322 }
1323 } else {
1324 let _ = fs::remove_file(&file_path);
1325 }
1326 }
1327
1328 let manifest_path = version_dir.join(local::manifest_filename(
1329 sub_package_to_uninstall.as_deref()
1330 ));
1331 if manifest_path.exists() {
1332 fs::remove_file(&manifest_path)?;
1333 }
1334 let legacy_path = version_dir.join(format!(
1336 "manifest{}.yaml",
1337 sub_package_to_uninstall
1338 .as_deref()
1339 .map_or_else(String::new, |sub| format!("-{sub}"))
1340 ));
1341 if legacy_path != manifest_path && legacy_path.exists() {
1342 fs::remove_file(legacy_path)?;
1343 }
1344
1345 if version_dir.exists() {
1346 let mut has_other_manifests = false;
1347 if let Ok(entries) = fs::read_dir(&version_dir) {
1348 for entry in entries.flatten() {
1349 let name = entry.file_name().to_string_lossy().to_string();
1350 if name.starts_with("manifest")
1351 && std::path::Path::new(&name).extension().is_some_and(
1352 |ext| {
1353 ext.eq_ignore_ascii_case("json")
1354 || ext.eq_ignore_ascii_case("yaml")
1355 }
1356 )
1357 {
1358 has_other_manifests = true;
1359 break;
1360 }
1361 }
1362 }
1363 if !has_other_manifests {
1364 if !quiet {
1365 println!(
1366 "Removing empty version directory: {}",
1367 version_dir.display()
1368 );
1369 }
1370 fs::remove_dir_all(&version_dir)?;
1371 }
1372 }
1373
1374 if package_dir.exists() {
1375 let mut has_other_versions = false;
1376 if let Ok(entries) = fs::read_dir(&package_dir) {
1377 for entry in entries.flatten() {
1378 let name = entry.file_name().to_string_lossy().to_string();
1379 if name != "latest" && name != "dependents" {
1380 has_other_versions = true;
1381 break;
1382 }
1383 }
1384 }
1385 if !has_other_versions {
1386 let _ = cleanup_service(&pkg.name, scope);
1387 if !quiet {
1388 println!(
1389 "Removing package store: {}",
1390 package_dir.display()
1391 );
1392 }
1393 fs::remove_dir_all(&package_dir)?;
1394 }
1395 }
1396
1397 let parent_id = format!(
1398 "#{}@{}/{}@{}",
1399 manifest.registry_handle,
1400 manifest.repo,
1401 manifest.name,
1402 manifest.version
1403 );
1404 for dep_str in &manifest.installed_dependencies {
1405 if let Ok(dep) = dependencies::parse_dependency_string(dep_str)
1406 && dep.manager == "zoi"
1407 {
1408 let dep_req = resolve::parse_source_string(dep.package)?;
1409 let dep_matches =
1410 local::find_installed_manifests_matching(&dep_req, scope)?;
1411 if dep_matches.len() == 1 {
1412 let dep_manifest =
1413 dep_matches.first().expect("Already checked length");
1414 match local::get_package_dir(
1415 dep_manifest.scope,
1416 &dep_manifest.registry_handle,
1417 &dep_manifest.repo,
1418 &dep_manifest.name
1419 ) {
1420 Ok(dep_pkg_dir) => {
1421 if let Err(e) = local::remove_dependent(
1422 &dep_pkg_dir,
1423 &parent_id
1424 ) && !quiet
1425 {
1426 eprintln!(
1427 "Warning: failed to remove dependent link \
1428 for {}: {}",
1429 dep.package, e
1430 );
1431 }
1432 }
1433 Err(e) => {
1434 if !quiet {
1435 eprintln!(
1436 "Warning: failed to get package dir for \
1437 {}: {}",
1438 dep.package, e
1439 );
1440 }
1441 }
1442 }
1443 }
1444 }
1445 }
1446
1447 if let Some(hooks) = &pkg.hooks
1448 && let Err(e) =
1449 hooks::run_hooks(hooks, hooks::HookType::PostRemove, scope)
1450 && !quiet
1451 {
1452 eprintln!("{} post-remove hook failed: {}", "Warning:".yellow(), e);
1453 }
1454 }
1455
1456 let remaining = if package_dir.exists() {
1463 list_stored_version_manifests(
1464 &package_dir,
1465 sub_package_to_uninstall.as_deref()
1466 )
1467 } else {
1468 Vec::new()
1469 };
1470 if !remaining.is_empty() {
1471 if request.version_spec.is_some() {
1472 let (prev_version, prev_manifest) =
1473 remaining.last().expect("remaining is not empty").clone();
1474 if !quiet {
1475 println!(
1476 "Version {} still available in the store. Rolling back \
1477 '{}' to {}...",
1478 prev_version.cyan(),
1479 pkg.name.bold(),
1480 prev_version.green()
1481 );
1482 }
1483 match activate_stored_version(
1484 &package_dir,
1485 scope,
1486 &prev_version,
1487 &prev_manifest,
1488 quiet
1489 ) {
1490 Ok(()) => {
1491 if let Ok(true) = telemetry::posthog_capture_event(
1492 "uninstall",
1493 &pkg,
1494 env!("CARGO_PKG_VERSION"),
1495 &manifest.registry_handle,
1496 None
1497 ) && !quiet
1498 {
1499 println!("{} telemetry queued", "Info:".green());
1500 }
1501 return Ok(manifest);
1502 }
1503 Err(e) => {
1504 if needs_escalation {
1505 eprintln!(
1506 "{} rollback activation needs root and failed \
1507 ({}); the old version stays in the store.",
1508 "Warning:".yellow(),
1509 e
1510 );
1511 } else {
1512 return Err(e);
1513 }
1514 }
1515 }
1516 } else {
1517 for (stored_version, _) in &remaining {
1521 if let Err(e) = remove_stored_version_dir(
1522 &package_dir,
1523 stored_version,
1524 sub_package_to_uninstall.as_deref(),
1525 quiet
1526 ) && !quiet
1527 {
1528 eprintln!(
1529 "{} failed to remove stored version \
1530 '{stored_version}': {}",
1531 "Warning:".yellow(),
1532 e
1533 );
1534 }
1535 }
1536 if package_dir.exists() {
1537 let mut has_versions = false;
1538 if let Ok(entries) = fs::read_dir(&package_dir) {
1539 for entry in entries.flatten() {
1540 let name =
1541 entry.file_name().to_string_lossy().to_string();
1542 if name != "latest" && name != "dependents" {
1543 has_versions = true;
1544 break;
1545 }
1546 }
1547 }
1548 if !has_versions {
1549 let _ = cleanup_service(&pkg.name, scope);
1550 let latest_link = package_dir.join("latest");
1551 if latest_link.is_symlink() {
1552 let _ = fs::remove_file(&latest_link);
1553 }
1554 let mut leftovers = 0;
1557 if let Ok(entries) = fs::read_dir(&package_dir) {
1558 for entry in entries.flatten() {
1559 let name =
1560 entry.file_name().to_string_lossy().to_string();
1561 if name == "dependents" {
1562 let empty = entry
1563 .path()
1564 .read_dir()
1565 .is_ok_and(|mut e| e.next().is_none());
1566 if empty {
1567 continue;
1568 }
1569 }
1570 leftovers += 1;
1571 }
1572 }
1573 if leftovers == 0 {
1574 if !quiet {
1575 println!(
1576 "Removing package store: {}",
1577 package_dir.display()
1578 );
1579 }
1580 let _ = fs::remove_dir_all(&package_dir);
1581 }
1582 }
1583 }
1584 }
1585 }
1586
1587 if let Err(e) = recorder::remove_package_from_record(&manifest)
1588 && !quiet
1589 {
1590 eprintln!(
1591 "{} Failed to remove package from lockfile: {}",
1592 "Warning:".yellow(),
1593 e
1594 );
1595 }
1596
1597 if let Ok(conn) = db::open_connection("local") {
1598 let _ = db::delete_package(
1599 &conn,
1600 &pkg.name,
1601 sub_package_to_uninstall.as_deref(),
1602 &pkg.repo,
1603 Some(scope)
1604 );
1605 }
1606
1607 if !quiet {
1608 println!("Removed manifest for '{}'.", pkg.name);
1609 }
1610
1611 if let Ok(true) = telemetry::posthog_capture_event(
1612 "uninstall",
1613 &pkg,
1614 env!("CARGO_PKG_VERSION"),
1615 &manifest.registry_handle,
1616 None
1617 ) && !quiet
1618 {
1619 println!("{} telemetry queued", "Info:".green());
1620 }
1621
1622 Ok(manifest)
1623}