Skip to content

feat(cli): uffs --uninstall — guided, complete UFFS removal (M0-M11)#495

Merged
githubrobbi merged 13 commits into
mainfrom
feat/uninstall
Jun 29, 2026
Merged

feat(cli): uffs --uninstall — guided, complete UFFS removal (M0-M11)#495
githubrobbi merged 13 commits into
mainfrom
feat/uninstall

Conversation

@githubrobbi

Copy link
Copy Markdown
Collaborator

Summary

A single command, uffs --uninstall, that removes UFFS and all of its data from the machine, as carefully as uffs --update. Implements the full plan (M0–M11), one milestone per commit.

It analyzes the install (every binary shown in OS-resolution order, ACTIVE copy flagged), inventories every artifact with sizes, runs a deep sweep (UFFS searching for itself), prints an itemized ordered plan, and only removes after explicit consent (or --yes), then verifies the result.

Highlights

  • Elevation-aware + frugal. Refuses up front (before any effect) when a removal needs privilege the run lacks. macOS/Linux user installs need no sudo — a real access(W_OK) writability check (added to uffs_mft::platform, keeping the CLI unsafe-free) decides per root; only root-owned locations / the Windows broker service / machine installs need elevation.
  • Channel-aware. WinGet roots are delegated to winget uninstall, never hand-deleted.
  • Safe + idempotent. --dry-run reviews without changing anything; removal is best-effort (a locked item is reported, the rest proceed) and idempotent (re-run to finish an interrupted one). Conservative PATH edits (only entries pointing exactly at a removed root); strays listed, never auto-deleted; running binary self-deletes on exit; post-removal verify.

Design

  • Reuses the self-update Phase-A detection scanner (one scanner, not two).
  • The plan carries structured targets — single source of truth for both rendering (--json + human) and execution.
  • Every side effect is behind an injected Effects trait → orchestration unit-tested with a recording fake (zero real deletions in tests), plus a live-path test on throwaway temp files.

Quality

  • 120 uffs-cli tests; strict clippy clean (incl. cargo xwin clippy --target x86_64-pc-windows-msvc -D warnings for the cfg(windows) paths); full lint-pre-push gate green.
  • docs/user-manual/uninstall.md (linked from the manual index) + CHANGELOG Unreleased entry.

Flags: --dry-run, --yes, --keep-config, --no-deep-sweep, --no-path, --scope, --json.

🤖 Generated with Claude Code

githubrobbi and others added 13 commits June 29, 2026 11:19
Wire the --uninstall management command and its argument surface, per
docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md (M0: tasks U-01, U-03).

- dispatch.rs: add Command::Uninstall, the token map, the command-suggest
  list, the dispatch arm, and a from_token test.
- commands/uninstall/args.rs: UninstallArgs + UninstallScope parser
  (--dry-run / --yes / --keep-config / --no-deep-sweep / --no-path / --scope /
  --json / --help). Pure, no IO, 9 unit tests.
- commands/uninstall/mod.rs: run_uninstall entry + --help text; a temporary M0
  scaffold notice until the analysis/removal phases land.

Build + strict clippy clean; all uffs-cli tests pass. Analysis, plan, consent,
and removal phases (M1+) follow as further commits on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read-only analysis: reuse the self-update Phase-A detection and show every
discovered UFFS binary in OS search order, with the copy a bare command runs
flagged ACTIVE (the rest shadowed / off-path). Tasks U-02, U-10, U-12 (partial).

- update/mod.rs: widen detect() / model / binaries / procinfo to pub(crate) so
  uninstall reuses the one scanner (behavior-preserving; update tests stay green).
- uninstall/resolve_order.rs: pure resolution ordering (Candidate ->
  ResolvedBinary, case-insensitive PATH rank, ACTIVE / shadowed / off-path),
  group_and_resolve by stem; 6 unit tests.
- uninstall/analyze.rs: flatten the DetectionReport into candidates + build the
  OS executable search-dir list (process dir, system dirs, cwd, PATH).
- uninstall/render.rs: print the resolution table.
- uninstall/mod.rs: wire `uffs --uninstall` to run the analysis.

Artifact inventory (U-11) and --json (U-12) follow on this branch. Build +
strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the read-only analysis (tasks U-11, U-12).

- uninstall/inventory.rs: resolve every non-binary trace — the data
  (%LOCALAPPDATA%\uffs), cache (secure_cache_dir), legacy cache
  (%TEMP%\uffs_index_cache), and per-user config dirs — each with existence +
  recursive size, plus broker-service state via uffs_winsvc::is_installed.
  Dedupes by path (config == data on macOS) so removal never double-counts.
- uninstall/render.rs: print the inventory (integer-math human byte sizes, no
  float casts) + the broker service line; full --json (pure analysis_json,
  unit-tested) of binaries + artifacts + broker state.
- uninstall/mod.rs: collect the inventory; --json emits JSON and returns early.

`uffs --uninstall` and `--json` now show the complete removal surface,
read-only. Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (M2, M3)

Build the ordered removal plan from the analysis and gate it on elevation —
still entirely read-only (no removal engine yet). Tasks U-20, U-21, U-30.

- uninstall/plan.rs: pure build_plan(report, inventory, args) -> RemovalPlan.
  Groups in safe order (Services -> Processes -> Binaries -> Data/cache/config);
  WinGet roots become a `winget uninstall` delegation, never a hand-delete;
  per-item needs_elevation + coarse scope; honors --keep-config / --scope.
  6 unit tests (winget-delegated, machine-needs-elevation, service-first,
  keep-config, scope-user-excludes-service, process-stop).
- uninstall/render.rs: print_plan (numbered consent surface + reclaimed bytes),
  print_elevation_refusal, and the plan in --json (pure plan_json).
- uninstall/mod.rs: build the plan; --dry-run prints it and stops; the M3
  elevation gate refuses before any effect when the plan needs Administrator
  the current process lacks (uffs_winsvc::is_elevated).

`uffs --uninstall --dry-run` now prints the full ordered plan; --json carries
it too. Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the uninstall plan's `Action` enum + string `description` with a
structured `PlanTarget` enum that holds the execution data (process pid,
service name, root dir + binary stems, winget package id + scope, dir to
delete). This is the single source of truth that both the renderer
(description / --json) and the upcoming removal executor (M4b) consume, so what
is shown is exactly what gets removed.

- plan.rs: PlanTarget { StopProcess | RemoveService | DeleteBinaries |
  DelegateWinget | DeleteDir } with action_label() + describe(); PlanItem now
  holds `target` instead of action + description; RemovalPlan::items() exposed.
  Tests match on target variants.
- render.rs: derive the plan description + json action from item.target.

Output is equivalent (the winget item now also shows its scope). Build + strict
clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On Mac/Linux the uninstall now runs at user level unless a binary root is
actually unwritable by the user. A root-owned /usr/local/bin install is flagged
before the executor tries; ~/bin, ~/.cargo/bin, and dev builds need no sudo.
Answers "do we really need sudo on posix?": no, unless ownership says so.

- uffs-mft platform::system: dir_user_writable(dir) — POSIX access(W_OK) probe
  (unix-only), exported via uffs_mft::platform. Lives beside is_elevated /
  geteuid, keeping the libc/unsafe in the platform crate (uffs-cli stays
  unsafe-free).
- uninstall/plan.rs: binaries_need_escalation(scope, dir) — Windows: machine
  scope; Unix: !dir_user_writable. cfg-gated fns (idiomatic, no unused-param
  noise). binary_item derives needs_elevation from it. +2 cfg-specific tests.
- uninstall/mod.rs: the elevation gate now uses cross-platform
  uffs_mft::platform::is_elevated (Windows token / Unix euid == 0) instead of
  the Windows-only uffs_winsvc stub (which is false off Windows).
- uninstall/render.rs: refusal names sudo (posix) / elevated shell (windows).

Verified: dev root under $HOME → access(W_OK) ok → no escalation. Build +
strict clippy clean (uffs-mft + uffs-cli); all tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the destructive engine behind the consent + elevation gates.

- uninstall/remove.rs: Effects trait + execute() — walks the ordered plan,
  dispatches each structured target to the injected sink, and records a per-item
  outcome. Best-effort: a failing item is recorded and the rest still run (one
  locked file never strands the cleanup). RecordingEffects fake + 2 executor
  tests (group order; failure recorded while others continue) — zero real
  deletions in tests.
- uninstall/effects.rs: live SystemEffects — std::fs deletes (idempotent via a
  try_exists "confirmed absent" check), process stop (kill/taskkill), service
  removal (uffs_winsvc::stop + sc delete), winget uninstall delegation. Shells
  out rather than via libc, so the CLI stays unsafe-free.
- uninstall/render.rs: print_outcome (counts + failures + retry hint).
- uninstall/mod.rs: after the elevation gate, prompt for consent (default No;
  --yes skips), then execute and report. Empty plan / declined → clean no-op.

`uffs --uninstall --dry-run` stays read-only; the real path runs only on
explicit confirmation. Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offer to drop PATH entries that point at a removed UFFS root — only an exact
(case-insensitive) match to an unmanaged/dev root we deleted, so it is provably
UFFS and safe. WinGet roots and mixed entries are never touched; --no-path
skips the group entirely.

- plan.rs: PlanTarget::RemovePathEntry; build_plan takes the live PATH and adds
  a PATH group for matching removed roots (machine-scope PATH flagged elevated).
  +1 test (offered on match, suppressed by --no-path, untouched on no match).
- analyze.rs: path_entries() — the split PATH.
- remove.rs: Effects::remove_path_entry + dispatch + recording fake.
- effects.rs: Windows edits the persisted user + machine PATH via PowerShell
  (each scope guarded so a write — and thus elevation — happens only when that
  scope has the entry; SetEnvironmentVariable broadcasts WM_SETTINGCHANGE).
  Unix writes a manual-cleanup hint (the shell owns PATH).

Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eat our own dog food: while the daemon is up, ask UFFS itself to find stray
family files (uffs*.exe, *_compact.uffs, *_usn.cursor, ...) anywhere on the
indexed drives, beyond the known install roots. Strays are REPORTED for review,
never auto-removed — a uffs.exe under Downloads may be the user's own copy.

- sweep.rs: Search trait + find_strays (pure: per-pattern search, drop hits
  already under a planned dir, sort + dedup; separator-aware prefix so
  /opt/uffs never matches /opt/uffs-other). DaemonSearch live backend via
  uffs_client search_cli_raw, best-effort (no daemon → no hits, never fails)
  with defensive recursive JSON path extraction. 3 tests (dedup/filter,
  sibling-prefix, json extraction).
- render.rs: print_strays (review-only listing).
- mod.rs: run the sweep after the plan (skipped by --no-deep-sweep); plan_dirs
  helper feeds the known-dir filter.

Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- effects.rs: schedule_self_delete — the running uffs.exe (+ uffs-update.exe)
  cannot delete their own image on Windows, so spawn a detached cmd that waits
  for this process to exit then deletes them (the classic self-delete, no FFI).
  Unix unlinks them directly.
- verify.rs: still_present — re-stat the targeted locations after removal and
  report any that survived (daemon-free; the search service is gone by then).
  2 tests.
- render.rs: print_verification (clean / leftovers) + print_self_delete_warning.
- mod.rs: after execute, schedule the self-delete (warn honestly if even that
  fails), then verify the remaining locations (excluding the reboot-deferred
  self-binaries).

Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Right-sized resume. The uninstall is idempotent (deletes are try_exists-guarded,
service/winget removals no-op when gone, the self-delete is reboot-deferred), so
"resume" is just re-running it — re-detection finds and removes whatever is
left. That is the key difference from the non-idempotent self-update swaps that
genuinely need a full replay journal.

So M9 is a small in-progress marker written to the system temp dir (which
survives the lifecycle-dir deletion):
- journal.rs: begin / finish / was_interrupted over a temp-dir marker;
  idempotent clear; round-trip test.
- mod.rs: on launch, note an interrupted prior run; mark in-progress before
  removal; clear on a clean finish — each warning surfaced honestly, never
  blocking the uninstall.
- render.rs: print_resumed_note + print_journal_warning.

Build + strict clippy clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…test (M11)

- docs/user-manual/uninstall.md: full user guide (steps, elevation table,
  WinGet delegation, flags, what-gets-removed, safety). Linked from the manual
  index next to Updating.
- CHANGELOG: ## [Unreleased] entry for `uffs --uninstall`.
- effects.rs: U-112 — a real test of the live SystemEffects delete path on
  throwaway temp files (idempotent delete_binaries + remove_dir); no UFFS
  install is touched.

Completes the uninstall implementation plan (M0-M11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cfg(windows) uninstall code is only linted by the cross (xwin) clippy in the
pre-push gate, which flagged four nits macOS clippy never sees:

- effects.rs schedule_self_delete: build the del-command string via map→Vec→join
  instead of format!-collect (format_collect).
- effects.rs remove_windows_service: discard the best-effort `uffs_winsvc::stop`
  result with an explicit `match { Ok(()) | Err(_) => {} }` (no non-binding
  `let _` on a must-use).
- plan.rs binaries_need_escalation (Windows variant): make it `const fn`.

macOS + `cargo xwin clippy --target x86_64-pc-windows-msvc -D warnings` both
clean; all uffs-cli tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@githubrobbi githubrobbi enabled auto-merge June 29, 2026 22:06
@githubrobbi githubrobbi added this pull request to the merge queue Jun 29, 2026
Merged via the queue into main with commit 964837e Jun 29, 2026
21 checks passed
@githubrobbi githubrobbi deleted the feat/uninstall branch June 29, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant