Skip to main content

zoi_uninstall/
lib.rs

1pub mod autoremove;
2
3use anyhow::anyhow;
4use colored::*;
5use mlua::Lua;
6use std::fs;
7use std::path::PathBuf;
8use zoi_core::{recorder, sysroot, types, utils as core_utils};
9use zoi_db as db;
10use zoi_deps as dependencies;
11use zoi_hooks as hooks;
12use zoi_resolver::{local, resolve};
13use zoi_telemetry as telemetry;
14
15fn get_bin_root(scope: types::Scope) -> anyhow::Result<PathBuf> {
16    match scope {
17        types::Scope::User => {
18            let home_dir = core_utils::get_user_home()
19                .ok_or_else(|| anyhow!("Could not find home directory."))?;
20            Ok(sysroot::apply_sysroot(home_dir.join(".zoi/pkgs/bin")))
21        }
22        types::Scope::System => {
23            if cfg!(target_os = "windows") {
24                Ok(sysroot::apply_sysroot(PathBuf::from(
25                    "C:\\ProgramData\\zoi\\pkgs\\bin",
26                )))
27            } else {
28                Ok(sysroot::apply_sysroot(PathBuf::from("/usr/local/bin")))
29            }
30        }
31        types::Scope::Project => {
32            let current_dir = std::env::current_dir()?;
33            Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
34        }
35    }
36}
37
38fn get_completions_root(scope: types::Scope, shell: &str) -> anyhow::Result<PathBuf> {
39    match scope {
40        types::Scope::User => {
41            let home_dir = core_utils::get_user_home()
42                .ok_or_else(|| anyhow!("Could not find home directory."))?;
43            Ok(sysroot::apply_sysroot(
44                home_dir.join(".zoi/pkgs/shell").join(shell),
45            ))
46        }
47        types::Scope::System => {
48            if cfg!(target_os = "windows") {
49                Ok(sysroot::apply_sysroot(PathBuf::from(format!(
50                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{}",
51                    shell
52                ))))
53            } else {
54                let base = match shell {
55                    "bash" => "/usr/share/bash-completion/completions",
56                    "zsh" => "/usr/share/zsh/site-functions",
57                    "fish" => "/usr/share/fish/vendor_completions.d",
58                    "elvish" => "/usr/share/elvish/lib",
59                    _ => "/usr/local/share/zoi/completions",
60                };
61                Ok(sysroot::apply_sysroot(PathBuf::from(base)))
62            }
63        }
64        types::Scope::Project => {
65            let current_dir = std::env::current_dir()?;
66            Ok(current_dir
67                .join(".zoi")
68                .join("pkgs")
69                .join("shell")
70                .join(shell))
71        }
72    }
73}
74
75fn cleanup_service(package_name: &str, scope: types::Scope) -> anyhow::Result<()> {
76    let service_name = format!("zoi-{}", package_name);
77    let is_user = scope != types::Scope::System;
78
79    match std::env::consts::OS {
80        "linux" => {
81            let unit_path = if is_user {
82                let home = core_utils::get_user_home()
83                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
84                sysroot::apply_sysroot(
85                    home.join(".config/systemd/user")
86                        .join(format!("{}.service", service_name)),
87                )
88            } else {
89                sysroot::apply_sysroot(PathBuf::from(format!(
90                    "/etc/systemd/system/{}.service",
91                    service_name
92                )))
93            };
94            if unit_path.exists() {
95                println!("Removing service unit file: {}", unit_path.display());
96                fs::remove_file(&unit_path).map_err(|e| {
97                    anyhow!("Failed to remove unit file: {}: {}", unit_path.display(), e)
98                })?;
99                if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
100                    let mut cmd = std::process::Command::new("systemctl");
101                    if is_user {
102                        cmd.arg("--user");
103                    }
104                    cmd.arg("daemon-reload")
105                        .status()
106                        .map_err(|e| anyhow!("Failed to run systemctl daemon-reload: {}", e))?;
107                }
108            }
109        }
110        "macos" => {
111            let plist_path = if is_user {
112                let home = core_utils::get_user_home()
113                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
114                sysroot::apply_sysroot(
115                    home.join("Library/LaunchAgents")
116                        .join(format!("{}.plist", service_name)),
117                )
118            } else {
119                sysroot::apply_sysroot(PathBuf::from(format!(
120                    "/Library/LaunchDaemons/{}.plist",
121                    service_name
122                )))
123            };
124            if plist_path.exists() {
125                println!("Removing service plist file: {}", plist_path.display());
126                fs::remove_file(&plist_path).map_err(|e| {
127                    anyhow!(
128                        "Failed to remove plist file: {}: {}",
129                        plist_path.display(),
130                        e
131                    )
132                })?;
133            }
134        }
135        "windows" => {
136            let exists = {
137                let output = std::process::Command::new("sc")
138                    .arg("query")
139                    .arg(&service_name)
140                    .output()
141                    .map_err(|e| anyhow!("Failed to run sc query: {}", e))?;
142                output.status.success()
143            };
144            if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() && exists {
145                println!("Removing Windows service: {}", service_name);
146                std::process::Command::new("sc")
147                    .arg("delete")
148                    .arg(&service_name)
149                    .status()
150                    .map_err(|e| anyhow!("Failed to run sc delete: {}", e))?;
151            }
152        }
153        _ => {}
154    }
155
156    Ok(())
157}
158
159fn uninstall_collection(
160    pkg: &types::Package,
161    manifest: &types::InstallManifest,
162    scope: types::Scope,
163    registry_handle: Option<String>,
164    yes: bool,
165    quiet: bool,
166    dry_run: bool,
167) -> anyhow::Result<types::InstallManifest> {
168    if !quiet {
169        println!("Uninstalling collection '{}'...", pkg.name.bold());
170    }
171
172    if dry_run {
173        return Ok(manifest.clone());
174    }
175
176    let dependencies_to_uninstall = &manifest.installed_dependencies;
177
178    if dependencies_to_uninstall.is_empty() {
179        if !quiet {
180            println!("Collection has no dependencies to uninstall.");
181        }
182    } else {
183        if !quiet {
184            println!("Uninstalling dependencies of the collection...");
185        }
186        for dep_str in dependencies_to_uninstall {
187            let dep = dependencies::parse_dependency_string(dep_str)?;
188
189            if dep.manager != "zoi" {
190                let prompt = format!(
191                    "Uninstall native dependency '{}' ({})?",
192                    dep.package.cyan(),
193                    dep.manager.yellow()
194                );
195                let warning = "Warning: Zoi cannot track if other non-Zoi applications depend on this package.";
196
197                if yes {
198                    if !quiet {
199                        println!(
200                            "\n{} Uninstalling native dependency: {}...",
201                            "::".bold().blue(),
202                            dep_str.bold()
203                        );
204                        println!("{}: {}", "Note".yellow(), warning);
205                    }
206                } else if core_utils::ask_for_confirmation(
207                    &format!("{}\n   {}", prompt, warning.dimmed()),
208                    false,
209                ) {
210                    if !quiet {
211                        println!(
212                            "\n{} Uninstalling dependency: {}...",
213                            "::".bold().blue(),
214                            dep_str.bold()
215                        );
216                    }
217                } else {
218                    if !quiet {
219                        println!(
220                            "Skipping uninstallation of native dependency: {}",
221                            dep.package.yellow()
222                        );
223                    }
224                    continue;
225                }
226            } else {
227                if !quiet {
228                    println!(
229                        "\n{} Uninstalling zoi dependency: {}...",
230                        "::".bold().blue(),
231                        dep_str.bold()
232                    );
233                }
234            }
235
236            if let Err(e) = dependencies::uninstall_dependency(dep_str, &move |name| {
237                run(name, Some(scope), yes, quiet, dry_run).map(|_| ())
238            }) && !quiet
239            {
240                eprintln!(
241                    "Warning: Could not uninstall dependency '{}': {}",
242                    dep_str, e
243                );
244            }
245        }
246    }
247
248    let handle = registry_handle.as_deref().unwrap_or("local");
249    let package_dir = local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
250    if package_dir.exists() {
251        let _ = cleanup_service(&pkg.name, scope);
252        fs::remove_dir_all(&package_dir)?;
253    }
254    if let Err(e) = recorder::remove_package_from_record(manifest)
255        && !quiet
256    {
257        eprintln!(
258            "{} Failed to remove package from lockfile: {}",
259            "Warning:".yellow(),
260            e
261        );
262    }
263
264    if let Ok(conn) = db::open_connection("local") {
265        let _ = db::delete_package(&conn, &pkg.name, None, &pkg.repo, Some(scope));
266    }
267
268    if let Ok(true) = telemetry::posthog_capture_event(
269        "uninstall",
270        pkg,
271        env!("CARGO_PKG_VERSION"),
272        registry_handle.as_deref().unwrap_or("local"),
273        None,
274    ) && !quiet
275    {
276        println!("{} telemetry sent", "Info:".green());
277    }
278
279    Ok(manifest.clone())
280}
281
282fn find_installed_manifest(
283    request: &resolve::PackageRequest,
284    scope_override: Option<types::Scope>,
285) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
286    let scopes = if let Some(scope) = scope_override {
287        vec![scope]
288    } else {
289        vec![
290            types::Scope::Project,
291            types::Scope::User,
292            types::Scope::System,
293        ]
294    };
295
296    for scope in scopes {
297        let mut matches = local::find_installed_manifests_matching(request, scope)?;
298        match matches.len() {
299            0 => continue,
300            1 => return Ok((matches.remove(0), scope)),
301            _ => {
302                return Err(anyhow!(
303                    "Package '{}' is ambiguous in {:?} scope. Use an explicit source like '#handle@repo/name[:sub]@version'.",
304                    request.name,
305                    scope
306                ));
307            }
308        }
309    }
310
311    if scope_override.is_some() {
312        Err(anyhow!(
313            "Package '{}' is not installed in the specified scope.",
314            request.name
315        ))
316    } else {
317        Err(anyhow!(
318            "Package '{}' is not installed by Zoi.",
319            request.name
320        ))
321    }
322}
323
324fn load_installed_package(
325    manifest: &types::InstallManifest,
326    yes: bool,
327) -> anyhow::Result<(types::Package, PathBuf)> {
328    let installed_source_path = local::get_package_source_path(manifest)?;
329    if installed_source_path.exists() {
330        let path = installed_source_path
331            .to_str()
332            .ok_or_else(|| anyhow!("Stored package source path contains invalid UTF-8"))?;
333        let mut pkg = zoi_lua::parser::parse_lua_package(
334            path,
335            Some(&manifest.version),
336            Some(manifest.scope),
337            true,
338        )?;
339        pkg.repo = manifest.repo.clone();
340        pkg.scope = manifest.scope;
341        pkg.registry_handle = Some(manifest.registry_handle.clone());
342        pkg.sub_package = manifest.sub_package.clone();
343        return Ok((pkg, installed_source_path));
344    }
345
346    let source = local::installed_manifest_source(manifest);
347    let (mut pkg, _, _, pkg_lua_path, _, _, _) =
348        resolve::resolve_package_and_version(&source, Some(manifest.scope), true, yes)?;
349    pkg.scope = manifest.scope;
350    pkg.sub_package = manifest.sub_package.clone();
351    Ok((pkg, pkg_lua_path))
352}
353
354/// Uninstalls one or more packages from the system.
355///
356/// This is a complex multi-stage operation:
357/// - Dependent Check: Verifies if any other package requires this one
358///   (via the `dependents/` directory). Blocks if busy.
359/// - Hook Execution: Runs the `pre_remove` hook defined in `.pkg.lua`.
360/// - Lua Cleanup: Executes the `uninstall()` function and `zrm` operations.
361/// - File Removal: Deletes every file recorded in the package's `InstallManifest`.
362/// - Shim/Completion Cleanup: Unlinks binaries and completions if no other
363///   package provides them (ref-counting via the database).
364///
365/// If `recursive` is true, Zoi also attempts to uninstall any dependencies
366/// that are no longer needed by any other package.
367pub fn run(
368    package_name: &str,
369    scope_override: Option<types::Scope>,
370    yes: bool,
371    quiet: bool,
372    dry_run: bool,
373) -> anyhow::Result<types::InstallManifest> {
374    let request = resolve::parse_source_string(package_name)?;
375    let (manifest, scope) = find_installed_manifest(&request, scope_override)?;
376    let sub_package_to_uninstall = manifest.sub_package.clone();
377    let registry_handle = Some(manifest.registry_handle.clone());
378    let (pkg, pkg_lua_path) = load_installed_package(&manifest, yes)?;
379
380    if pkg.package_type == types::PackageType::Collection {
381        return uninstall_collection(
382            &pkg,
383            &manifest,
384            scope,
385            registry_handle.clone(),
386            yes,
387            quiet,
388            dry_run,
389        );
390    }
391
392    if dry_run {
393        return Ok(manifest);
394    }
395
396    let handle = manifest.registry_handle.as_str();
397    let package_dir = local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
398    let version_dir = package_dir.join(&manifest.version);
399
400    let dependents = local::get_dependents(&package_dir)?;
401    if !dependents.is_empty() {
402        return Err(anyhow::anyhow!(
403            "Cannot uninstall '{}' because other packages depend on it:\n  -{}\n\nPlease uninstall these packages first.",
404            pkg.name,
405            dependents.join("\n  - ")
406        ));
407    }
408
409    let needs_escalation = scope == types::Scope::System && !core_utils::is_admin();
410
411    if needs_escalation {
412        if !quiet {
413            println!(
414                "{} Escalating to root to remove system package...",
415                "::".bold().blue()
416            );
417        }
418        let manifest_json = serde_json::to_string(&manifest)?;
419        let mut temp_file = tempfile::NamedTempFile::new()?;
420        use std::io::Write;
421        temp_file.write_all(manifest_json.as_bytes())?;
422        let temp_path = temp_file.path();
423
424        let mut cmd = std::process::Command::new("sudo");
425        cmd.arg(std::env::current_exe()?);
426        cmd.arg("helper").arg("elevate-uninstall");
427        cmd.arg("--manifest-json").arg(temp_path);
428        if yes {
429            cmd.arg("--yes");
430        }
431
432        let status = cmd
433            .status()
434            .map_err(|e| anyhow::anyhow!("Failed to spawn sudo: {}", e))?;
435        if !status.success() {
436            return Err(anyhow::anyhow!("Escalated uninstallation failed."));
437        }
438    } else {
439        if let Some(hooks) = &pkg.hooks
440            && let Err(e) = hooks::run_hooks(hooks, hooks::HookType::PreRemove, scope)
441        {
442            return Err(anyhow::anyhow!("Pre-remove hook failed: {}", e));
443        }
444
445        let lua = Lua::new();
446        zoi_lua::functions::setup_lua_environment(
447            &lua,
448            &core_utils::get_platform()?,
449            Some(&manifest.version),
450            pkg_lua_path.to_str(),
451            None,
452            None,
453            None,
454            sub_package_to_uninstall.as_deref(),
455            Some(scope),
456            None,
457            true,
458        )
459        .map_err(|e| anyhow!(e.to_string()))?;
460        let lua_code = fs::read_to_string(pkg_lua_path)?;
461        lua.load(&lua_code)
462            .exec()
463            .map_err(|e| anyhow!(e.to_string()))?;
464
465        if let Ok(uninstall_fn) = lua.globals().get::<mlua::Function>("uninstall") {
466            if !quiet {
467                println!("Running uninstall() script...");
468            }
469            uninstall_fn
470                .call::<()>(())
471                .map_err(|e| anyhow!(e.to_string()))?;
472        }
473
474        if let Ok(uninstall_ops) = lua.globals().get::<mlua::Table>("__ZoiUninstallOperations") {
475            for op in uninstall_ops.sequence_values::<mlua::Table>() {
476                let op = op.map_err(|e| anyhow!(e.to_string()))?;
477                if let Ok(op_type) = op.get::<String>("op")
478                    && op_type == "zrm"
479                {
480                    let mut path_to_remove: String =
481                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
482
483                    path_to_remove =
484                        path_to_remove.replace("${pkgstore}", &version_dir.to_string_lossy());
485
486                    if let Some(home_dir) = core_utils::get_user_home() {
487                        path_to_remove =
488                            path_to_remove.replace("${usrhome}", &home_dir.to_string_lossy());
489                    }
490                    path_to_remove = path_to_remove.replace(
491                        "${usrroot}",
492                        &sysroot::apply_sysroot(PathBuf::from("/")).to_string_lossy(),
493                    );
494
495                    let path = std::path::PathBuf::from(path_to_remove);
496                    if path.exists() {
497                        if !quiet {
498                            println!("Removing {}...", path.display());
499                        }
500                        if path.is_dir() {
501                            fs::remove_dir_all(path)?;
502                        } else {
503                            fs::remove_file(path)?;
504                        }
505                    }
506                }
507            }
508        }
509
510        if let Some(backup_files) = &manifest.backup {
511            if !quiet {
512                println!("Saving configuration files...");
513            }
514            for backup_file_rel in backup_files {
515                let expanded_path = zoi_core::utils::expand_placeholders(
516                    backup_file_rel,
517                    &version_dir,
518                    manifest.scope,
519                )?;
520                let backup_src = PathBuf::from(expanded_path);
521
522                if backup_src.exists() {
523                    let backup_filename = backup_src
524                        .file_name()
525                        .ok_or_else(|| anyhow!("Invalid backup source name"))?
526                        .to_string_lossy();
527                    let backup_dest = version_dir
528                        .parent()
529                        .ok_or_else(|| anyhow!("version_dir should have a parent (package_dir)"))?
530                        .join(format!("{}.zoisave", backup_filename));
531
532                    if let Some(p) = backup_dest.parent()
533                        && let Err(e) = fs::create_dir_all(p)
534                    {
535                        if !quiet {
536                            eprintln!(
537                                "Warning: could not create backup directory {}: {}",
538                                p.display(),
539                                e
540                            );
541                        }
542                        continue;
543                    }
544                    if !quiet {
545                        println!(
546                            "Saving {} to {}",
547                            backup_src.display(),
548                            backup_dest.display()
549                        );
550                    }
551                    // Use copy + remove for potential cross-device moves
552                    if let Err(e) = fs::copy(&backup_src, &backup_dest) {
553                        if !quiet {
554                            eprintln!(
555                                "Warning: failed to copy backup {}: {}",
556                                backup_src.display(),
557                                e
558                            );
559                        }
560                    } else {
561                        let _ = fs::remove_file(&backup_src);
562                    }
563                }
564            }
565        }
566
567        if !quiet {
568            println!(
569                "Uninstalling '{}'...",
570                if let Some(sub) = &manifest.sub_package {
571                    format!("{}:{}", pkg.name, sub)
572                } else {
573                    pkg.name.clone()
574                }
575                .bold()
576            );
577        }
578
579        if let Some(bins) = &manifest.bins {
580            let bin_root = get_bin_root(scope)?;
581            for bin in bins {
582                let symlink_path = bin_root.join(bin);
583                if symlink_path.is_symlink() || symlink_path.exists() {
584                    let other_providers = db::find_provides("local", bin)?;
585                    let still_provided = other_providers.iter().any(|(p, _)| {
586                        p.name != pkg.name || (p.sub_package != manifest.sub_package)
587                    });
588
589                    if !still_provided {
590                        if !quiet {
591                            println!(
592                                "Removing shim for {} from {}...",
593                                bin.cyan(),
594                                symlink_path.display()
595                            );
596                        }
597                        fs::remove_file(&symlink_path)?;
598                    } else {
599                        if !quiet {
600                            println!(
601                                "Keeping shim for {} as it is still provided by other packages.",
602                                bin.cyan()
603                            );
604                        }
605                    }
606                }
607            }
608        } else if manifest.sub_package.is_none() {
609            let bin = &pkg.name;
610            let symlink_path = get_bin_root(scope)?.join(bin);
611            if symlink_path.is_symlink() || symlink_path.exists() {
612                let other_providers = db::find_provides("local", bin)?;
613                let still_provided = other_providers
614                    .iter()
615                    .any(|(p, _)| p.name != pkg.name || (p.sub_package != manifest.sub_package));
616
617                if !still_provided {
618                    if !quiet {
619                        println!(
620                            "Removing shim for {} from {}...",
621                            bin.cyan(),
622                            symlink_path.display()
623                        );
624                    }
625                    fs::remove_file(symlink_path)?;
626                }
627            }
628        }
629
630        if let Some(completions) = &manifest.completions {
631            for completion in completions {
632                let completions_root = get_completions_root(scope, &completion.shell)?;
633                let pkg_dir = completions_root.join(&pkg.name);
634                let symlink_path = pkg_dir.join(&completion.filename);
635                if symlink_path.is_symlink() || symlink_path.exists() {
636                    let other_providers = db::find_provides("local", &completion.filename)?;
637                    let still_provided = other_providers.iter().any(|(p, _)| {
638                        p.name != pkg.name || (p.sub_package != manifest.sub_package)
639                    });
640
641                    if !still_provided {
642                        if !quiet {
643                            println!(
644                                "Removing {} completion for {} from {}...",
645                                completion.shell.cyan(),
646                                completion.filename.cyan(),
647                                symlink_path.display()
648                            );
649                        }
650                        fs::remove_file(&symlink_path)?;
651                    } else if !quiet {
652                        println!(
653                            "Keeping {} completion for {} as it is still provided by other packages.",
654                            completion.shell.cyan(),
655                            completion.filename.cyan()
656                        );
657                    }
658                }
659            }
660
661            let shells: std::collections::HashSet<String> =
662                completions.iter().map(|c| c.shell.clone()).collect();
663            for shell_name in shells {
664                let pkg_dir = get_completions_root(scope, &shell_name)?.join(&pkg.name);
665                if pkg_dir.exists()
666                    && fs::read_dir(&pkg_dir)
667                        .map(|mut e| e.next().is_none())
668                        .unwrap_or(false)
669                {
670                    let _ = fs::remove_dir(&pkg_dir);
671                }
672            }
673        }
674
675        let pkg_id_opt = if let Ok(conn) = db::open_connection("local") {
676            db::get_package_id(
677                &conn,
678                &pkg.name,
679                manifest.sub_package.as_deref(),
680                &pkg.repo,
681                handle,
682            )
683            .ok()
684        } else {
685            None
686        };
687
688        for file_path_str in &manifest.installed_files {
689            let expanded = core_utils::expand_placeholders(file_path_str, &version_dir, scope)?;
690            let file_path = PathBuf::from(&expanded);
691
692            if let Some(pkg_id) = pkg_id_opt
693                && let Ok(conn) = db::open_connection("local")
694                && let Ok(true) = db::has_other_owners(&conn, file_path_str, pkg_id)
695            {
696                if !quiet {
697                    println!(
698                        "Keeping {} as it is still owned by other packages.",
699                        file_path_str.dimmed()
700                    );
701                }
702                continue;
703            }
704
705            if file_path.exists() {
706                if file_path.is_dir() {
707                    // Only remove if empty to be safe
708                    if fs::read_dir(&file_path)
709                        .map(|mut e| e.next().is_none())
710                        .unwrap_or(false)
711                    {
712                        let _ = fs::remove_dir_all(&file_path);
713                    }
714                } else {
715                    let _ = fs::remove_file(&file_path);
716                }
717            }
718        }
719
720        let manifest_filename = if let Some(sub) = &manifest.sub_package {
721            format!("manifest-{}.yaml", sub)
722        } else {
723            "manifest.yaml".to_string()
724        };
725        let manifest_path = version_dir.join(manifest_filename);
726        if manifest_path.exists() {
727            fs::remove_file(manifest_path)?;
728        }
729
730        if version_dir.exists() {
731            let mut has_other_manifests = false;
732            if let Ok(entries) = fs::read_dir(&version_dir) {
733                for entry in entries.flatten() {
734                    let name = entry.file_name().to_string_lossy().to_string();
735                    if name.starts_with("manifest") && name.ends_with(".yaml") {
736                        has_other_manifests = true;
737                        break;
738                    }
739                }
740            }
741            if !has_other_manifests {
742                if !quiet {
743                    println!(
744                        "Removing empty version directory: {}",
745                        version_dir.display()
746                    );
747                }
748                fs::remove_dir_all(&version_dir)?;
749            }
750        }
751
752        if package_dir.exists() {
753            let _ = cleanup_service(&pkg.name, scope);
754            let mut has_other_versions = false;
755            if let Ok(entries) = fs::read_dir(&package_dir) {
756                for entry in entries.flatten() {
757                    let name = entry.file_name().to_string_lossy().to_string();
758                    if name != "latest" && name != "dependents" {
759                        has_other_versions = true;
760                        break;
761                    }
762                }
763            }
764            if !has_other_versions {
765                if !quiet {
766                    println!("Removing package store: {}", package_dir.display());
767                }
768                fs::remove_dir_all(&package_dir)?;
769            }
770        }
771
772        let parent_id = format!(
773            "#{}@{}/{}@{}",
774            manifest.registry_handle, manifest.repo, manifest.name, manifest.version
775        );
776        for dep_str in &manifest.installed_dependencies {
777            if let Ok(dep) = dependencies::parse_dependency_string(dep_str)
778                && dep.manager == "zoi"
779            {
780                let dep_req = resolve::parse_source_string(dep.package)?;
781                let dep_matches = local::find_installed_manifests_matching(&dep_req, scope)?;
782                if dep_matches.len() == 1 {
783                    let dep_manifest = &dep_matches[0];
784                    match local::get_package_dir(
785                        dep_manifest.scope,
786                        &dep_manifest.registry_handle,
787                        &dep_manifest.repo,
788                        &dep_manifest.name,
789                    ) {
790                        Ok(dep_pkg_dir) => {
791                            if let Err(e) = local::remove_dependent(&dep_pkg_dir, &parent_id)
792                                && !quiet
793                            {
794                                eprintln!(
795                                    "Warning: failed to remove dependent link for {}: {}",
796                                    dep.package, e
797                                );
798                            }
799                        }
800                        Err(e) => {
801                            if !quiet {
802                                eprintln!(
803                                    "Warning: failed to get package dir for {}: {}",
804                                    dep.package, e
805                                );
806                            }
807                        }
808                    }
809                }
810            }
811        }
812
813        if let Some(hooks) = &pkg.hooks
814            && let Err(e) = hooks::run_hooks(hooks, hooks::HookType::PostRemove, scope)
815            && !quiet
816        {
817            eprintln!("{} post-remove hook failed: {}", "Warning:".yellow(), e);
818        }
819    }
820
821    if let Err(e) = recorder::remove_package_from_record(&manifest)
822        && !quiet
823    {
824        eprintln!(
825            "{} Failed to remove package from lockfile: {}",
826            "Warning:".yellow(),
827            e
828        );
829    }
830
831    if let Ok(conn) = db::open_connection("local") {
832        let _ = db::delete_package(
833            &conn,
834            &pkg.name,
835            sub_package_to_uninstall.as_deref(),
836            &pkg.repo,
837            Some(scope),
838        );
839    }
840
841    if !quiet {
842        println!("Removed manifest for '{}'.", pkg.name);
843    }
844
845    if let Ok(true) = telemetry::posthog_capture_event(
846        "uninstall",
847        &pkg,
848        env!("CARGO_PKG_VERSION"),
849        &manifest.registry_handle,
850        None,
851    ) && !quiet
852    {
853        println!("{} telemetry sent", "Info:".green());
854    }
855
856    Ok(manifest)
857}