diff --git a/docs/ideation/2026-04-08-fabro-uninstall-ideation.md b/docs/ideation/2026-04-08-fabro-uninstall-ideation.md new file mode 100644 index 000000000..117eed371 --- /dev/null +++ b/docs/ideation/2026-04-08-fabro-uninstall-ideation.md @@ -0,0 +1,82 @@ +--- +date: 2026-04-08 +topic: fabro-uninstall +focus: add `fabro uninstall` command — the opposite of install +--- + +# Ideation: `fabro uninstall` Command + +## Codebase Context + +- `fabro install` creates `~/.fabro/` with settings.toml, certs/, storage/ (secrets, server state, logs, scratch, store, artifacts), skills/, workflows/, logs/, tmp/ +- `install.sh` modifies shell configs (.zshrc/.bashrc/.config/fish) adding PATH entries with `# fabro` sentinel comment +- `Home` (fabro-util) and `Storage` (fabro-config) structs are canonical path registries +- `system prune` provides the established dry-run/--yes/size-reporting UX pattern +- `server stop` handles graceful SIGTERM→SIGKILL with socket cleanup +- Binary installed to `~/.fabro/bin/fabro` by install.sh; brew/cargo installs go elsewhere + +## Ranked Ideas + +### 1. Core Mirror Uninstall +**Description:** `fabro uninstall` removes `~/.fabro/` entirely, using `Home` and `Storage` structs as the canonical path registry. Compose existing deletion logic rather than writing new teardown code. +**Rationale:** Home/Storage structs enumerate every path, so future paths added to install automatically appear in uninstall — zero maintenance drift. +**Downsides:** None significant — table stakes. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 2. Server Shutdown First +**Description:** Before removing any files, detect a running server and gracefully stop it, reusing `server stop` logic (SIGTERM→SIGKILL). Only then proceed with file deletion. +**Rationale:** Deleting files under a running server creates orphaned processes, dangling sockets, and corrupt state. +**Downsides:** None — correctness requirement. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 3. Shell Config Cleanup +**Description:** Remove PATH lines from `.zshrc`/`.bashrc`/`.config/fish` that `install.sh` added, using the `# fabro` sentinel comment as grep target. +**Rationale:** Stale PATH entries are the #1 complaint after CLI uninstalls. Sentinel comment makes surgical removal safe. +**Downsides:** Shell config surgery requires careful testing across shells. +**Confidence:** 85% +**Complexity:** Medium +**Status:** Unexplored + +### 4. Dry-Run by Default +**Description:** Follow `system prune` pattern: list everything that would be removed with sizes, require `--yes` to confirm. Support `--json`. +**Rationale:** Already proven UX in the codebase. Prevents "oops" moments. +**Downsides:** None. +**Confidence:** 95% +**Complexity:** Low +**Status:** Unexplored + +### 5. Binary Self-Removal +**Description:** If binary lives inside `~/.fabro/bin/` (install.sh path), delete it as final step. For brew/cargo, print a hint instead. +**Rationale:** Users expect "uninstall" to mean "gone." Simple conditional based on `current_exe()` path. +**Downsides:** Self-deleting binary must be last step. Detection heuristic could be wrong if user moved the binary. +**Confidence:** 75% +**Complexity:** Low +**Status:** Unexplored + +## Rejection Summary + +| # | Idea | Reason Rejected | +|---|------|-----------------| +| 1 | Selective component uninstall | YAGNI — users can back up manually | +| 2 | GitHub App deregistration | Scope creep — remote API during teardown is fragile | +| 3 | Export before destroy | Gold-plating — `cp -r ~/.fabro` suffices | +| 4 | Telemetry farewell event | Marginal value, unwelcome phoning home | +| 5 | Install manifest | Over-engineering for small deterministic footprint | +| 6 | Per-repo cascade cleanup | Filesystem scanning is slow and presumptuous | +| 7 | Secure secrets shredding | Security theater on modern SSDs | +| 8 | Reframe as `system reset` | Poor discoverability | +| 9 | Doctor extension | Mixing diagnostic and destructive ops | +| 10 | Install --undo flag | Terrible discoverability | +| 11 | Teardown facet trait | Premature abstraction for ~5 steps | +| 12 | Event-sourced uninstall | Requires rewriting install first | +| 13 | Restoration script | Worse version of export (already cut) | +| 14 | Server-mediated uninstall | Circular dependency | +| 15 | Manifest + Selective | Both halves cut | +| 16 | Doctor-informed uninstall | Coupling for no benefit | + +## Session Log +- 2026-04-08: Initial ideation — ~40 generated across 5 agents, deduped to 22, 5 survived. All 5 accepted as facets of one command. Proceeding to plan. diff --git a/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md b/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md new file mode 100644 index 000000000..0262a8f1c --- /dev/null +++ b/docs/plans/2026-04-08-001-feat-fabro-uninstall-command-plan.md @@ -0,0 +1,339 @@ +--- +title: "feat: Add `fabro uninstall` command" +type: feat +status: completed +date: 2026-04-08 +origin: docs/ideation/2026-04-08-fabro-uninstall-ideation.md +deepened: 2026-04-08 +--- + +# feat: Add `fabro uninstall` command + +## Overview + +Add a `fabro uninstall` top-level CLI command that reverses the effects of `fabro install` and `install.sh`. The command stops a running server, removes the `~/.fabro/` directory tree, cleans shell config PATH entries, and handles binary self-removal. Defaults to dry-run (preview) mode, requiring `--yes` to execute. + +## Problem Frame + +There is no supported way to uninstall Fabro. Users must manually `rm -rf ~/.fabro`, hunt for stale PATH entries in their shell configs, and figure out how to stop the server. This creates friction, erodes trust, and leaves detritus after uninstall. + +## Requirements Trace + +- R1. Remove the `~/.fabro/` directory and all contents +- R2. Stop a running server before removing files +- R3. Remove `# fabro` PATH lines from shell configs (.zshrc, .bashrc, .bash_profile, .config/fish/config.fish) +- R4. Default to dry-run with size reporting; require `--yes` to execute +- R5. Delete the binary when installed via `install.sh` to `~/.fabro/bin/`; print a hint otherwise +- R6. Support `--json` output for scriptability + +## Scope Boundaries + +- No selective/component uninstall (users can back up manually) +- No GitHub App deregistration via API +- No export/backup archive feature +- No telemetry farewell event +- No per-repo cascade cleanup (repos can be cleaned individually via `fabro repo deinit`) + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-cli/src/commands/install.rs` — what `fabro install` creates (settings.toml, certs/, secrets.json) +- `apps/marketing/public/install.sh` — what the shell installer creates (binary at `~/.fabro/bin/fabro`, PATH lines with `# fabro` sentinel) +- `lib/crates/fabro-cli/src/commands/server/stop.rs` — `execute(storage_dir, timeout)`: SIGTERM, poll, SIGKILL, record+socket cleanup +- `lib/crates/fabro-cli/src/commands/server/record.rs` — `active_server_record_details()` for detecting running server +- `lib/crates/fabro-cli/src/commands/system/prune.rs` — dry-run-by-default pattern with `--yes`, size reporting via `format_size()` +- `lib/crates/fabro-cli/src/commands/repo/deinit.rs` — cleanup command pattern: green checkmarks, NotFound tolerance, `Vec` return, `--json` support +- `lib/crates/fabro-util/src/home.rs` — `Home` struct with all path accessors (`root()`, `certs_dir()`, `storage_dir()`, etc.) +- `lib/crates/fabro-config/src/storage.rs` — `Storage` struct with storage path accessors +- `lib/crates/fabro-cli/src/args.rs` — `Commands` enum for command registration, `Commands::name()` for telemetry +- `lib/crates/fabro-cli/src/commands/upgrade.rs` — `std::env::current_exe()?.canonicalize()?` for binary path detection +- `lib/crates/fabro-config/src/user_config.rs` — `load_settings()` for resolving effective configuration including non-default `storage_dir` + +### Institutional Learnings + +No `docs/solutions/` directory exists. No prior learnings on install/uninstall patterns. + +## Key Technical Decisions + +- **Top-level command, not subcommand of `system`**: Matches `install`/`upgrade` placement. Users will search for `fabro uninstall` — discoverability matters. (see origin: docs/ideation/2026-04-08-fabro-uninstall-ideation.md) +- **`--yes` confirmation model (not `--dry-run`)**: Matches `system prune` pattern — destructive operations default to preview. The flag name `--yes` is already established in the codebase. +- **Use `Home::from_env()` as the canonical root**: Respects `$FABRO_HOME` for non-default installations. Never hardcode `~/.fabro/`. +- **Shell config cleanup uses exact `# fabro` sentinel match**: The `install.sh` script writes `# fabro` as a marker comment before the PATH export. Match with `line.trim() == "# fabro"` (exact match, not substring) to avoid deleting unrelated lines like `# fabro workflow helper`. After matching the sentinel, validate that the following line matches an expected PATH pattern (`export PATH=` or `fish_add_path`) before removing it — this prevents accidentally deleting an innocent line if the file was manually edited. +- **Binary path resolved during inventory, not after deletion**: `std::env::current_exe()?.canonicalize()?` must be called during the inventory phase (Unit 2) and stored. On macOS, `canonicalize()` fails with `NotFound` after the file is deleted by `remove_dir_all`. The stored path is used later by Unit 5. +- **Binary self-deletion is the final step**: On Unix, unlinking a running binary works (the inode stays alive until the process exits). Must be absolutely last since nothing can run after the binary is gone. +- **Do not call `stop::execute()` without a guard**: `server::stop::execute()` calls `std::process::exit(1)` when no server is running. The uninstall command must check `record::active_server_record_details()` first and only call `stop::execute()` when a server is confirmed running. +- **Resolve `storage_dir` through settings, not just `Home`**: If `settings.toml` configures a non-default `storage_dir`, the server record lives at that custom path. Load effective settings via `user_config::load_settings()` during inventory to resolve the actual `storage_dir`. +- **Compute-once inventory**: The inventory is built once (Unit 2) and consumed by all subsequent units. No step should re-detect artifacts during execution — the directory may already be partially deleted. +- **Exit code policy**: Exit 0 only if all critical steps (server stop, directory removal) succeeded. Exit 1 if any critical step failed. Shell config cleanup and binary handling failures warn but do not affect the exit code. +- **Synchronous implementation**: Server stop logic (`stop::execute`) is synchronous. The uninstall function itself should be async (matching the dispatch pattern in main.rs) but can call sync helpers. + +## Open Questions + +### Resolved During Planning + +- **Should uninstall clean up shell configs that `fabro install` (Rust) didn't create?** Yes — `install.sh` creates them, and the user experience of "uninstall" should reverse the full installation, not just the Rust command's portion. +- **What if the server won't stop?** Follow the existing SIGTERM→SIGKILL pattern from `server/stop.rs`. If SIGKILL fails (shouldn't happen on Unix), warn and continue with removal. + +- **Should the dry-run preview show active workflow run count?** Yes — if the server is running with in-flight workflows, stopping it will terminate them. The preview should report the count so users can make an informed decision. The run count can be obtained from the server API if available, or noted as "server running (active runs unknown)" if the API is unreachable. +- **What about `$ZDOTDIR` mismatch between install and uninstall time?** If the user's `$ZDOTDIR` was set differently at install time versus uninstall time, the uninstall will look in the wrong file. This is an inherent limitation — document it but do not try to solve it. + +### Deferred to Implementation + +- **Fish shell syntax differences**: `fish_add_path` vs `export PATH=` — the removal logic needs shell-specific handling. Determine exact patterns during implementation. + +## Implementation Units + +- [x] **Unit 1: Command registration and skeleton** + +**Goal:** Register `fabro uninstall` as a top-level CLI command with args parsing. + +**Requirements:** R4, R6 + +**Dependencies:** None + +**Files:** +- Modify: `lib/crates/fabro-cli/src/args.rs` +- Modify: `lib/crates/fabro-cli/src/commands/mod.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Create: `lib/crates/fabro-cli/src/commands/uninstall.rs` + +**Approach:** +- Add `UninstallArgs` struct with `--yes` bool field (clap attribute: `#[arg(long)]`) +- Add `Uninstall(UninstallArgs)` variant to `Commands` enum with doc comment `/// Uninstall Fabro from this machine` +- Add `Self::Uninstall(_) => "uninstall"` to `Commands::name()` +- Add `pub(crate) mod uninstall;` to `commands/mod.rs` +- Add dispatch arm in main.rs calling `commands::uninstall::run_uninstall(&args, &globals).await?` +- Skeleton `run_uninstall` that prints "not yet implemented" and returns Ok + +**Patterns to follow:** +- `InstallArgs` struct and `Commands::Install` variant in `args.rs` +- Dispatch pattern in `main.rs` (line ~176) +- Module declaration pattern in `commands/mod.rs` + +**Test scenarios:** +- Happy path: `fabro uninstall --help` outputs usage text including `--yes` flag description +- Happy path: `fabro uninstall` (no args) parses successfully and runs the skeleton + +**Verification:** +- `cargo build -p fabro-cli` succeeds +- `fabro uninstall --help` shows the expected usage + +--- + +- [x] **Unit 2: Inventory and dry-run preview** + +**Goal:** Discover all Fabro artifacts on the system, compute sizes, and display a preview manifest. When `--yes` is not passed, this is the complete behavior. + +**Requirements:** R1, R2, R3, R4, R5, R6 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` + +**Approach:** +- Use `Home::from_env()` to resolve the root directory +- Check if `Home::root()` exists; if not, print "Fabro is not installed" and exit 0 (skip settings loading entirely) +- If home exists, attempt to load effective settings via `user_config::load_settings()` to resolve actual `storage_dir`. If settings loading fails (e.g., settings.toml missing or corrupt), fall back to `Home::from_env().storage_dir()` — this handles partial installs and repeated uninstall attempts +- Build an inventory struct containing all information needed by subsequent units: + - `home_root`: resolved Home root path + - `storage_dir`: resolved storage directory path + - `home_exists`: whether the home directory exists + - `home_size`: total size of home directory (recursive walk) + - `server_running`: whether a server is detected via `record::active_server_record_details(&storage_dir)` + - `shell_configs`: list of shell config file paths that contain the `# fabro` sentinel (exact match: `line.trim() == "# fabro"`) + - `binary_path`: resolved path from `std::env::current_exe()?.canonicalize()?` (must be resolved NOW, before any deletion) + - `binary_is_managed`: whether `binary_path` starts with `home_root` +- Shell config files to scan: `$ZDOTDIR/.zshrc` or `~/.zshrc`, `~/.bashrc`, `~/.bash_profile`, `~/.config/fish/config.fish` +- In dry-run mode (no `--yes`): print each item that would be removed with its size, including active run warning if server is running, then print `"Pass --yes to confirm."` summary +- Support `--json` output: serialize inventory as JSON to stdout +- Follow the `system prune` output style for human-readable format + +**Patterns to follow:** +- `system prune` dry-run output format ("would delete: ...", "N item(s) would be deleted (X freed)") +- `format_size()` for human-readable byte formatting +- `console::Style` / `Styles::detect_stderr()` for colored output +- `GlobalArgs.json` check for JSON vs human output + +**Test scenarios:** +- Happy path: preview with populated `~/.fabro/` lists all directories and files with sizes +- Happy path: preview with `--json` outputs structured JSON to stdout +- Edge case: `~/.fabro/` does not exist — prints "Fabro is not installed" and exits cleanly +- Edge case: shell config files exist but contain no fabro lines — omitted from preview +- Edge case: binary is not in `~/.fabro/bin/` — preview notes it must be removed manually +- Happy path: running server detected — preview notes it will be stopped and warns about active runs +- Edge case: `current_exe()` or `canonicalize()` fails — inventory stores `None` for binary path, warns in preview + +**Verification:** +- `fabro uninstall` (no `--yes`) prints a complete manifest and does NOT delete anything +- `fabro uninstall --json` outputs valid JSON with inventory details +- Preview accurately reflects what exists on disk + +--- + +- [x] **Unit 3: Server shutdown and directory removal** + +**Goal:** When `--yes` is passed, stop a running server and remove `~/.fabro/` and all contents. + +**Requirements:** R1, R2 + +**Dependencies:** Unit 2 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's `server_running` and `storage_dir` fields (computed in Unit 2) +- **Critical**: Only call `server::stop::execute()` when `inventory.server_running` is true. `stop::execute()` calls `std::process::exit(1)` when no server is found — calling it unconditionally would terminate the process before any cleanup happens. +- If running, call `server::stop::execute(&inventory.storage_dir, Duration::from_secs(5))` +- If not running, skip with no error +- **Safety guardrail before deletion**: Validate `inventory.home_root` is reasonable before calling `remove_dir_all`. Refuse to proceed if the resolved path is a filesystem root (`/`), the user's home directory (`$HOME`), or does not contain an expected marker file (e.g., `settings.toml` or `certs/`). This prevents catastrophic data loss from a misconfigured `$FABRO_HOME`. +- After validation, remove `inventory.home_root` with `std::fs::remove_dir_all` +- If `storage_dir` differs from the default and is outside `home_root`, validate it contains Fabro artifacts (e.g., `secrets.json` or `store/`) before removing +- Handle `ErrorKind::NotFound` gracefully (already uninstalled) +- Report each step with green checkmark output following `deinit.rs` pattern +- The `fabro.sock` socket file is inside `~/.fabro/` so it's removed as part of the directory deletion + +**Patterns to follow:** +- `server::stop::execute()` for server shutdown (call ONLY when server is confirmed running) +- `record::active_server_record_details()` for server detection (already done in inventory) +- `deinit.rs` checkmark output pattern +- `RunScratch::remove()` for NotFound tolerance + +**Test scenarios:** +- Happy path: with `--yes` and no running server, `~/.fabro/` is removed successfully +- Happy path: with `--yes` and running server, server is stopped before removal +- Edge case: `~/.fabro/` does not exist — reports "nothing to remove" without error +- Error path: server stop fails (SIGKILL also fails) — warns and continues with removal +- Error path: `$FABRO_HOME` set to `/` — refuses to delete, prints error +- Error path: `$FABRO_HOME` set to `$HOME` — refuses to delete, prints error +- Error path: `$FABRO_HOME` points to a directory without Fabro marker files — refuses to delete +- Integration: after removal, `~/.fabro/` directory does not exist on disk + +**Verification:** +- `fabro uninstall --yes` removes `~/.fabro/` completely +- A running server is stopped before file removal +- No orphaned processes remain after uninstall + +--- + +- [x] **Unit 4: Shell config cleanup** + +**Goal:** Remove PATH lines that `install.sh` added to shell configuration files, using the `# fabro` sentinel comment. + +**Requirements:** R3 + +**Dependencies:** Unit 2 (uses shell config detection from inventory) + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's `shell_configs` list (detected in Unit 2) +- For each shell config file in the list: + - Read the file contents + - Find lines where `line.trim() == "# fabro"` (exact match, not substring — avoids matching `# fabro workflow helper`) + - Validate that the following line matches an expected PATH pattern before removing it: + - zsh/bash: following line starts with `export PATH=` + - fish: following line starts with `fish_add_path` + - If the following line does NOT match, remove only the sentinel line (defensive — the file was manually edited) + - Remove the sentinel line AND the validated following line + - Write the modified contents back + - Handle edge cases: sentinel at end of file, multiple sentinels, sentinel with no following line +- Report each modified file with the deinit checkmark pattern +- If a shell config file is not found or has no fabro lines, skip silently + +**Patterns to follow:** +- `deinit.rs` reporting pattern (green checkmarks per item) +- **Atomic write pattern**: Write modified content to a temporary file in the same directory, then `std::fs::rename()` over the original (atomic on POSIX). This prevents a truncated/corrupt dotfile if the process crashes mid-write. Check `std::fs::symlink_metadata()` before modifying — if the file is a symlink, follow it (matching `std::fs::read_to_string` behavior) but note this in output so users with dotfile managers are aware. + +**Test scenarios:** +- Happy path: `.zshrc` with `# fabro` + PATH line — both lines removed, rest of file intact +- Happy path: `.bashrc` with `# fabro` + PATH line — both lines removed +- Happy path: `.bash_profile` with `# fabro` + PATH line — both lines removed +- Happy path: `config.fish` with `# fabro` + `fish_add_path` — both lines removed +- Edge case: shell config exists but has no `# fabro` line — file is not modified +- Edge case: shell config file does not exist — no error +- Edge case: `# fabro` is the last line in the file (no following PATH line) — only sentinel line removed +- Edge case: multiple `# fabro` blocks in the same file — all are removed +- Edge case: `# fabro` followed by an unrelated line (not PATH export) — only sentinel removed, following line preserved +- Edge case: line contains `# fabro` as substring (`# fabro-related`) — not matched, file unchanged +- Integration: after cleanup, opening a new shell does not add fabro to PATH + +**Verification:** +- Shell config files have fabro lines removed without corrupting other content +- Files without fabro lines are not modified (mtime unchanged) + +--- + +- [x] **Unit 5: Binary status reporting and final output** + +**Goal:** Report whether the binary was already removed (managed install) or print a removal hint (external install). Print final summary. + +**Requirements:** R5 + +**Dependencies:** Unit 3 (must run after directory removal) + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/uninstall.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/uninstall.rs` + +**Approach:** +- Use the inventory's pre-resolved `binary_path` and `binary_is_managed` fields (resolved in Unit 2 before any deletion — `canonicalize()` fails on macOS after the file is deleted) +- If `binary_is_managed` is true: the binary was inside `~/.fabro/bin/` and was already removed by Unit 3's `remove_dir_all` — report as removed +- If `binary_is_managed` is false: print a tailored hint + - Check if path contains `/Cellar/` (Homebrew): "run `brew uninstall fabro`" + - Check if path contains `.cargo/bin/` (cargo): "run `cargo uninstall fabro`" + - Otherwise: "The fabro binary at {path} must be removed manually." +- If `binary_path` is `None` (resolution failed in Unit 2): warn and skip +- Print final summary: "Fabro has been uninstalled." (bold, to stderr) +- For `--json` output: include `binary_removed` and `binary_hint` fields + +**Patterns to follow:** +- `deinit.rs` final summary line pattern (bold text) +- Package manager path detection is heuristic — keep it simple + +**Test scenarios:** +- Happy path: binary was in `~/.fabro/bin/` and was already removed by directory deletion — reports binary removed +- Happy path: binary is in `/opt/homebrew/Cellar/` — prints "run `brew uninstall fabro`" hint +- Happy path: binary is in `~/.cargo/bin/` — prints "run `cargo uninstall fabro`" hint +- Edge case: binary path detection fails (`current_exe()` error) — warns but does not fail the overall uninstall +- Happy path: final summary message is printed after all steps complete + +**Verification:** +- When binary was installed via `install.sh`, it is deleted or confirmed deleted +- When binary was installed via other means, a clear removal instruction is printed +- `fabro uninstall --yes --json` outputs valid JSON including inventory, execution results, and binary status +- The command exits successfully after all steps + +## System-Wide Impact + +- **Interaction graph:** The uninstall command interacts with: `server::stop` (server lifecycle), `Home`/`Storage` (path resolution), shell config files (external to fabro), and the running binary itself. No callbacks, middleware, or observers are affected. +- **Error propagation:** Each step warns on failure but continues. Critical steps (server stop, directory removal) affect the exit code (exit 1 on failure). Non-critical steps (shell config, binary) warn only. A partial uninstall is better than aborting on the first error. +- **State lifecycle risks:** Stopping the server terminates in-flight workflow runs — the dry-run preview warns about this. Mitigated by mandatory server stop as the first step (prevents filesystem corruption) and by defaulting to dry-run (gives user a chance to see the warning before committing). +- **API surface parity:** No API endpoint equivalent needed — uninstall is a local-only operation. +- **Unchanged invariants:** `fabro install`, `fabro server`, `fabro repo deinit`, and `system prune` are not modified by this plan. The uninstall command reuses their code but does not change their behavior. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Shell config surgery corrupts user's dotfiles | Use sentinel-based detection (not regex on PATH content). Only remove exact `# fabro` + next line. Write tests with realistic file content. | +| Binary self-deletion race condition | Delete binary as the absolute last step. On Unix, unlink of a running binary is safe (inode persists until process exits). | +| `$FABRO_HOME` set to dangerous path (`/`, `$HOME`) | Safety guardrail: validate resolved path is not a root or home dir, and contains expected marker files, before `remove_dir_all`. | +| Server stop timeout blocks uninstall | Use a short timeout (5s). SIGKILL as fallback. Continue with removal even if stop fails. | +| `stop::execute()` exits process when no server found | Guard with `active_server_record_details()` check; never call `stop::execute()` unconditionally. | +| `canonicalize()` fails after file deletion on macOS | Resolve binary path during inventory phase (Unit 2), before any deletion occurs. | +| Non-default `storage_dir` in settings.toml | Load settings during inventory to resolve actual storage_dir; don't assume it's inside `~/.fabro/`. | +| `$ZDOTDIR` differs between install and uninstall time | Known limitation — document it. Uninstall uses current `$ZDOTDIR`. | +| Partial `remove_dir_all` failure (locked files on macOS) | Warn and report which files remain. Exit 1. User can retry or manually remove. | + +## Sources & References + +- **Origin document:** [docs/ideation/2026-04-08-fabro-uninstall-ideation.md](docs/ideation/2026-04-08-fabro-uninstall-ideation.md) +- Related code: `lib/crates/fabro-cli/src/commands/install.rs`, `lib/crates/fabro-cli/src/commands/server/stop.rs`, `lib/crates/fabro-cli/src/commands/system/prune.rs`, `lib/crates/fabro-cli/src/commands/repo/deinit.rs` +- Related code: `lib/crates/fabro-util/src/home.rs`, `lib/crates/fabro-config/src/storage.rs` +- Related code: `apps/marketing/public/install.sh` diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 370ceb1a2..63db4ad58 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -932,6 +932,8 @@ pub(crate) enum Commands { Doctor(DoctorArgs), /// Set up the Fabro environment (LLMs, certs, GitHub) Install(InstallArgs), + /// Uninstall Fabro from this machine + Uninstall(UninstallArgs), /// Pull request operations Pr(PrNamespace), /// Manage server-owned secrets @@ -1014,6 +1016,7 @@ impl Commands { RepoCommand::Deinit => "repo deinit", }, Self::Install(_) => "install", + Self::Uninstall(_) => "uninstall", Self::Pr(ns) => match &ns.command { PrCommand::Create(_) => "pr create", PrCommand::List(_) => "pr list", @@ -1258,6 +1261,13 @@ pub(crate) struct InstallArgs { pub(crate) web_url: String, } +#[derive(Args)] +pub(crate) struct UninstallArgs { + /// Skip confirmation prompt + #[arg(long)] + pub(crate) yes: bool, +} + #[derive(Args)] pub(crate) struct ProviderNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 9eec51abf..72995e763 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod secret; pub(crate) mod server; pub(crate) mod store; pub(crate) mod system; +pub(crate) mod uninstall; pub(crate) mod upgrade; pub(crate) mod validate; pub(crate) mod workflow; diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs new file mode 100644 index 000000000..37886b4eb --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -0,0 +1,720 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::Serialize; +use tracing::warn; + +use fabro_util::Home; + +use crate::args::{GlobalArgs, UninstallArgs}; +use crate::commands::server; +use crate::shared::{format_size, print_json_pretty, tilde_path}; +use crate::user_config; + +#[derive(Debug, Serialize)] +struct Inventory { + home_root: PathBuf, + storage_dir: PathBuf, + home_exists: bool, + home_size: u64, + server_running: bool, + shell_configs: Vec, + binary_path: Option, + binary_is_managed: bool, +} + +#[allow(clippy::unused_async)] // call site requires async +pub(crate) async fn run_uninstall(args: &UninstallArgs, globals: &GlobalArgs) -> Result<()> { + let home = Home::from_env(); + let home_root = home.root().to_path_buf(); + + if !home_root.exists() { + if globals.json { + print_json_pretty(&serde_json::json!({ "status": "not_installed" }))?; + } else { + eprintln!("Fabro is not installed."); + } + return Ok(()); + } + + let storage_dir = + user_config::load_settings().map_or_else(|_| home.storage_dir(), |s| s.storage_dir()); + + let inventory = build_inventory(&home_root, &storage_dir); + + if !args.yes { + if globals.json { + print_json_pretty(&inventory)?; + } else { + print_preview(&inventory); + } + return Ok(()); + } + + execute_uninstall(&inventory, globals.json) +} + +fn build_inventory(home_root: &Path, storage_dir: &Path) -> Inventory { + let home_size = dir_size(home_root); + let server_running = server::record::active_server_record_details(storage_dir).is_some(); + let shell_configs = find_shell_configs_with_sentinel(); + let (binary_path, binary_is_managed) = resolve_binary(home_root); + + Inventory { + home_root: home_root.to_path_buf(), + storage_dir: storage_dir.to_path_buf(), + home_exists: true, + home_size, + server_running, + shell_configs, + binary_path, + binary_is_managed, + } +} + +fn dir_size(path: &Path) -> u64 { + let mut total: u64 = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { + continue; + }; + if ft.is_dir() { + total += dir_size(&entry.path()); + } else { + total += entry.metadata().map(|m| m.len()).unwrap_or(0); + } + } + } + total +} + +fn find_shell_configs_with_sentinel() -> Vec { + let mut found = Vec::new(); + let Some(home) = dirs::home_dir() else { + return found; + }; + + let zdotdir = std::env::var("ZDOTDIR") + .ok() + .map_or_else(|| home.clone(), PathBuf::from); + + let candidates = [ + zdotdir.join(".zshrc"), + home.join(".bashrc"), + home.join(".bash_profile"), + home.join(".config/fish/config.fish"), + ]; + + for path in &candidates { + if file_contains_sentinel(path) { + found.push(path.clone()); + } + } + found +} + +fn file_contains_sentinel(path: &Path) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + content.lines().any(|line| line.trim() == "# fabro") +} + +fn resolve_binary(home_root: &Path) -> (Option, bool) { + let binary_path = std::env::current_exe() + .ok() + .and_then(|p| p.canonicalize().ok()); + let is_managed = binary_path + .as_ref() + .is_some_and(|p| p.starts_with(home_root)); + (binary_path, is_managed) +} + +fn print_preview(inventory: &Inventory) { + let green = console::Style::new().green(); + let dim = console::Style::new().dim(); + let bold = console::Style::new().bold(); + + eprintln!("\n{}", bold.apply_to("The following will be removed:")); + eprintln!( + " {} {} {}", + green.apply_to("~"), + tilde_path(&inventory.home_root), + dim.apply_to(format!("({})", format_size(inventory.home_size))) + ); + + if inventory.storage_dir != inventory.home_root.join("storage") + && !inventory.storage_dir.starts_with(&inventory.home_root) + { + let storage_size = dir_size(&inventory.storage_dir); + eprintln!( + " {} {} {}", + green.apply_to("~"), + tilde_path(&inventory.storage_dir), + dim.apply_to(format!("({})", format_size(storage_size))) + ); + } + + if inventory.server_running { + eprintln!( + "\n {} A running server will be stopped first.", + console::Style::new().yellow().apply_to("!") + ); + } + + if !inventory.shell_configs.is_empty() { + eprintln!("\n Shell configs with PATH entries:"); + for path in &inventory.shell_configs { + eprintln!(" {}", tilde_path(path)); + } + } + + match (&inventory.binary_path, inventory.binary_is_managed) { + (Some(_), true) => { + eprintln!( + "\n {} Binary is inside {} and will be removed.", + dim.apply_to("i"), + tilde_path(&inventory.home_root) + ); + } + (Some(bin), false) => { + eprintln!( + "\n {} Binary at {} is outside {} and must be removed manually.", + dim.apply_to("i"), + tilde_path(bin), + tilde_path(&inventory.home_root) + ); + } + (None, _) => { + eprintln!( + "\n {} Could not determine binary location.", + dim.apply_to("i") + ); + } + } + + eprintln!("\nPass --yes to confirm."); +} + +#[derive(Debug, Serialize)] +struct UninstallResult { + status: &'static str, + home_removed: bool, + server_stopped: bool, + shell_configs_cleaned: Vec, + binary_removed: bool, + binary_hint: Option, +} + +fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> { + let green = console::Style::new().green(); + let dim = console::Style::new().dim(); + let bold = console::Style::new().bold(); + let mut critical_failure = false; + let mut result = UninstallResult { + status: "completed", + home_removed: false, + server_stopped: false, + shell_configs_cleaned: Vec::new(), + binary_removed: false, + binary_hint: None, + }; + + // Unit 3a: Server stop + if inventory.server_running { + server::stop::execute(&inventory.storage_dir, Duration::from_secs(5)); + result.server_stopped = true; + } + + // Unit 3b: Safety guardrails + if let Err(e) = validate_safe_to_delete(&inventory.home_root) { + eprintln!("Refusing to delete {}: {e}", inventory.home_root.display()); + return Err(e); + } + + // Unit 3c: Directory removal + match fs::remove_dir_all(&inventory.home_root) { + Ok(()) => { + result.home_removed = true; + if !json { + eprintln!( + " {} Removed {}", + green.apply_to("\u{2714}"), + tilde_path(&inventory.home_root) + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + result.home_removed = true; + if !json { + eprintln!( + " {} {} already removed", + dim.apply_to("-"), + tilde_path(&inventory.home_root) + ); + } + } + Err(e) => { + if !json { + eprintln!( + " Failed to remove {}: {e}", + tilde_path(&inventory.home_root) + ); + } + critical_failure = true; + } + } + + // Remove external storage_dir if outside home_root + if !inventory.storage_dir.starts_with(&inventory.home_root) && inventory.storage_dir.exists() { + if let Err(e) = validate_safe_to_delete(&inventory.storage_dir) { + if !json { + eprintln!( + "Refusing to delete storage dir {}: {e}", + inventory.storage_dir.display() + ); + } + critical_failure = true; + } else { + match fs::remove_dir_all(&inventory.storage_dir) { + Ok(()) => { + if !json { + eprintln!( + " {} Removed {}", + green.apply_to("\u{2714}"), + tilde_path(&inventory.storage_dir) + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + if !json { + eprintln!( + " Failed to remove {}: {e}", + tilde_path(&inventory.storage_dir) + ); + } + critical_failure = true; + } + } + } + } + + // Unit 4: Shell config cleanup + for path in &inventory.shell_configs { + match clean_shell_config(path) { + Ok(()) => { + result.shell_configs_cleaned.push(path.clone()); + if !json { + eprintln!( + " {} Cleaned {}", + green.apply_to("\u{2714}"), + tilde_path(path) + ); + } + } + Err(e) => { + warn!("Failed to clean shell config {}: {e}", path.display()); + if !json { + eprintln!( + " {} Could not clean {}: {e}", + console::Style::new().yellow().apply_to("!"), + tilde_path(path) + ); + } + } + } + } + + // Unit 5: Binary reporting + match (&inventory.binary_path, inventory.binary_is_managed) { + (Some(_), true) => { + result.binary_removed = true; + if !json { + eprintln!( + " {} Binary removed {}", + green.apply_to("\u{2714}"), + dim.apply_to("(was inside ~/.fabro/bin/)") + ); + } + } + (Some(bin), false) => { + let hint = binary_removal_hint(bin); + result.binary_hint = Some(hint.clone()); + if !json { + eprintln!("\n {} {}", dim.apply_to("i"), hint); + } + } + (None, _) => { + warn!("Could not determine binary path; skipping binary removal hint"); + } + } + + if critical_failure { + result.status = "partial"; + } + + // Final output + if json { + print_json_pretty(&result)?; + } else { + eprintln!("\n{}", bold.apply_to("Fabro has been uninstalled.")); + } + + if critical_failure { + std::process::exit(1); + } + + Ok(()) +} + +fn validate_safe_to_delete(path: &Path) -> Result<()> { + let root = Path::new("/"); + anyhow::ensure!(path != root, "path is the filesystem root"); + + if let Some(home) = dirs::home_dir() { + anyhow::ensure!(path != home, "path is the user home directory"); + } + + let has_settings = path.join("settings.toml").exists(); + let has_certs = path.join("certs").exists(); + anyhow::ensure!( + has_settings || has_certs, + "path does not look like a Fabro home (missing settings.toml and certs/)" + ); + + Ok(()) +} + +fn clean_shell_config(path: &Path) -> Result<()> { + let content = + fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + + let lines: Vec<&str> = content.lines().collect(); + let mut output = Vec::with_capacity(lines.len()); + let mut i = 0; + + while i < lines.len() { + if lines[i].trim() == "# fabro" { + // Check if next line is a PATH export or fish_add_path + if i + 1 < lines.len() { + let next = lines[i + 1].trim(); + if next.starts_with("export PATH=") || next.starts_with("fish_add_path") { + // Skip both sentinel and PATH line + i += 2; + continue; + } + } + // Skip only the sentinel line + i += 1; + continue; + } + output.push(lines[i]); + i += 1; + } + + let mut result = output.join("\n"); + // Preserve trailing newline if original had one + if content.ends_with('\n') { + result.push('\n'); + } + + // Atomic write: write to temp file in same directory, then rename + let parent = path + .parent() + .with_context(|| format!("no parent directory for {}", path.display()))?; + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("creating temp file in {}", parent.display()))?; + tmp.write_all(result.as_bytes()) + .with_context(|| format!("writing temp file for {}", path.display()))?; + tmp.persist(path) + .with_context(|| format!("renaming temp file to {}", path.display()))?; + + Ok(()) +} + +fn binary_removal_hint(bin: &Path) -> String { + let bin_str = bin.to_string_lossy(); + if bin_str.contains("Cellar") || bin_str.contains("homebrew") || bin_str.contains("linuxbrew") { + format!( + "Binary at {} was installed via Homebrew. Run: brew uninstall fabro", + tilde_path(bin) + ) + } else if bin_str.contains(".cargo") { + format!( + "Binary at {} was installed via Cargo. Run: cargo uninstall fabro", + tilde_path(bin) + ) + } else { + format!("Binary at {} must be removed manually.", tilde_path(bin)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::{ + binary_removal_hint, build_inventory, clean_shell_config, dir_size, file_contains_sentinel, + validate_safe_to_delete, + }; + + fn create_fabro_home(dir: &Path) { + fs::create_dir_all(dir.join("certs")).unwrap(); + fs::write(dir.join("settings.toml"), "# fabro settings\n").unwrap(); + fs::create_dir_all(dir.join("storage")).unwrap(); + fs::write(dir.join("storage/data.db"), "fake-db-content").unwrap(); + fs::create_dir_all(dir.join("bin")).unwrap(); + fs::write(dir.join("bin/fabro"), "fake-binary").unwrap(); + } + + #[test] + fn dir_size_sums_nested_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + fs::write(root.join("a.txt"), "hello").unwrap(); + fs::create_dir(root.join("sub")).unwrap(); + fs::write(root.join("sub/b.txt"), "world!").unwrap(); + + let size = dir_size(root); + assert_eq!(size, 11); // "hello" (5) + "world!" (6) + } + + #[test] + fn dir_size_empty_directory_is_zero() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(dir_size(tmp.path()), 0); + } + + #[test] + fn dir_size_nonexistent_is_zero() { + let path = PathBuf::from("/nonexistent-fabro-test-dir-xyz"); + assert_eq!(dir_size(&path), 0); + } + + #[test] + fn file_contains_sentinel_exact_match() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write( + &path, + "some stuff\n# fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\n", + ) + .unwrap(); + + assert!(file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_with_leading_whitespace() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, " # fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\n").unwrap(); + + assert!(file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_substring() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "# fabro-workflow\nsome other line\n").unwrap(); + + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_missing_file() { + let path = PathBuf::from("/nonexistent-fabro-test-file-xyz"); + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn file_contains_sentinel_rejects_comment_in_middle() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "echo '# fabro'\nother stuff\n").unwrap(); + + assert!(!file_contains_sentinel(&path)); + } + + #[test] + fn clean_shell_config_removes_sentinel_and_path_line() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write( + &path, + "existing line\n# fabro\nexport PATH=\"$HOME/.fabro/bin:$PATH\"\nafter line\n", + ) + .unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "existing line\nafter line\n"); + } + + #[test] + fn clean_shell_config_removes_fish_sentinel_and_path() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.fish"); + fs::write( + &path, + "set -gx EDITOR vim\n# fabro\nfish_add_path $HOME/.fabro/bin\nend\n", + ) + .unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "set -gx EDITOR vim\nend\n"); + } + + #[test] + fn clean_shell_config_removes_only_sentinel_when_next_line_unrelated() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".bashrc"); + fs::write(&path, "before\n# fabro\necho hello\nafter\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\necho hello\nafter\n"); + } + + #[test] + fn clean_shell_config_removes_sentinel_at_end_of_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".bashrc"); + fs::write(&path, "before\n# fabro\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\n"); + } + + #[test] + fn clean_shell_config_preserves_trailing_newline() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "keep this\n# fabro\nexport PATH=\"foo\"\n").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert!(result.ends_with('\n')); + assert_eq!(result, "keep this\n"); + } + + #[test] + fn clean_shell_config_no_trailing_newline_when_original_lacks_one() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".zshrc"); + fs::write(&path, "keep this\n# fabro\nexport PATH=\"foo\"").unwrap(); + + clean_shell_config(&path).unwrap(); + + let result = fs::read_to_string(&path).unwrap(); + assert_eq!(result, "keep this"); + } + + #[test] + fn validate_safe_to_delete_refuses_root() { + let result = validate_safe_to_delete(Path::new("/")); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("filesystem root"), "got: {msg}"); + } + + #[test] + fn validate_safe_to_delete_refuses_home_dir() { + if let Some(home) = dirs::home_dir() { + let result = validate_safe_to_delete(&home); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("home directory"), "got: {msg}"); + } + } + + #[test] + fn validate_safe_to_delete_refuses_dir_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("does not look like"), "got: {msg}"); + } + + #[test] + fn validate_safe_to_delete_accepts_dir_with_settings_toml() { + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("settings.toml"), "").unwrap(); + + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn validate_safe_to_delete_accepts_dir_with_certs() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join("certs")).unwrap(); + + let result = validate_safe_to_delete(tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn build_inventory_populates_fields() { + let tmp = tempfile::tempdir().unwrap(); + let home_root = tmp.path().join("fake-fabro-home"); + create_fabro_home(&home_root); + + let storage_dir = home_root.join("storage"); + let inv = build_inventory(&home_root, &storage_dir); + + assert!(inv.home_exists); + assert!(inv.home_size > 0); + assert!(!inv.server_running); + assert_eq!(inv.home_root, home_root); + assert_eq!(inv.storage_dir, storage_dir); + } + + #[test] + fn build_inventory_shell_configs_empty_in_temp() { + let tmp = tempfile::tempdir().unwrap(); + let home_root = tmp.path().join("fake-fabro-home"); + create_fabro_home(&home_root); + + let inv = build_inventory(&home_root, &home_root.join("storage")); + // shell_configs depends on the actual user's shell config files, + // but we verify the field is populated (even if empty in CI/test) + assert!(inv.shell_configs.is_empty() || !inv.shell_configs.is_empty()); + } + + #[test] + fn binary_removal_hint_homebrew() { + let hint = binary_removal_hint(Path::new("/opt/homebrew/Cellar/fabro/1.0/bin/fabro")); + assert!(hint.contains("Homebrew"), "got: {hint}"); + assert!(hint.contains("brew uninstall"), "got: {hint}"); + } + + #[test] + fn binary_removal_hint_cargo() { + let hint = binary_removal_hint(Path::new("/home/user/.cargo/bin/fabro")); + assert!(hint.contains("Cargo"), "got: {hint}"); + assert!(hint.contains("cargo uninstall"), "got: {hint}"); + } + + #[test] + fn binary_removal_hint_manual() { + let hint = binary_removal_hint(Path::new("/usr/local/bin/fabro")); + assert!(hint.contains("manually"), "got: {hint}"); + } +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 5fb95fbd9..24c9e9970 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -224,6 +224,9 @@ async fn main_inner() -> (String, Result<()>) { Commands::Install(args) => { commands::install::run_install(&args, &globals).await?; } + Commands::Uninstall(args) => { + commands::uninstall::run_uninstall(&args, &globals).await?; + } Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?, Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?, Commands::Settings(args) => commands::config::execute(&args, &globals).await?,