From 8e2637f1e3ae7ebe2874f75a28779281edacc5ec Mon Sep 17 00:00:00 2001 From: Youngsup Oh Date: Sat, 18 Jul 2026 12:20:21 +0900 Subject: [PATCH] feat(scanner): register global cache prune commands as vendor cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add VendorCleanups to the global scanner so `clean --vendor-cleanup` runs each package manager's native cache prune alongside path deletion. - brew cleanup -s, npm/yarn cache clean, pnpm store prune, pip/uv cache prune — all non-destructive (regenerable caches only) - tools absent from PATH are skipped, so only installed managers run; pip falls back to pip3 when pip is not on PATH - LookPath is an injectable field (mirrors ProcessRunning) for deterministic tests The destructive-command gate (docker system prune) is deferred to #17. Closes #20 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013SX3vRs78zFYA2kTRZAtg6 --- CLAUDE.md | 2 +- docs/architecture.md | 3 +- docs/commands.md | 10 +++++ internal/scanner/global.go | 65 +++++++++++++++++++++++++++++++++ internal/scanner/global_test.go | 50 +++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a83c0d2..53c343a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Pipeline: **Scan → Classify → Filter/Sort → Output/Clean** - **Clean flow**: scan → interactive multiselect (protected hidden) → trash/force choice → per-artifact results - **AI agent friendly**: all interactions via CLI flags (`--yes`, `--json`), no interactive prompts required - **Metadata enrichment**: scanners populate `ScanResult.Label` / `Recommendation` so users can decide without decoding paths (UUID → "iPhone 17 Pro · iOS 26.3", "superseded by newer build", "runtime unavailable"). First applied in xcode; reusable for any ecosystem with opaque identifiers -- **Vendor cleanup**: scanners may implement the optional `VendorCleaner` interface to register ecosystem-native cleanup commands (e.g. `xcrun simctl delete unavailable`). `devclean clean --vendor-cleanup` runs them alongside path-based deletion so vendor internal state stays consistent — natural fit for Docker (`system prune`), Homebrew (`cleanup`), Gradle, etc. +- **Vendor cleanup**: scanners may implement the optional `VendorCleaner` interface to register ecosystem-native cleanup commands. `devclean clean --vendor-cleanup` runs them alongside path-based deletion so vendor internal state stays consistent. Implemented: `xcode` (`simctl delete unavailable`) and `global` (brew/npm/yarn/pnpm/pip/uv cache prunes, skipping tools absent from PATH). Destructive commands (Docker `system prune`) are deferred to a future `--include-destructive` gate. - **Deletion strategy**: `ScanResult.Delete` (`model.DeleteMethod`: kind path/command/api + display + Run closure) expresses non-path reclaims per item; nil = path removal. Cleaner applies protected/dry-run gates uniformly, then delegates to the method or falls back to trash/force. `VendorCleanup` embeds the same `DeleteMethod` — bulk (ecosystem-level) vs per-item are two uses of one execution contract ## Documentation Rules diff --git a/docs/architecture.md b/docs/architecture.md index 67837f2..8e2a447 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,8 +140,9 @@ type VendorCleanup struct { Current implementations: - `xcode`: `xcrun simctl delete unavailable` +- `global`: `brew cleanup`, `npm`/`yarn cache clean`, `pnpm store prune`, `pip`/`uv cache prune` — tools absent from PATH are skipped, so only installed managers are offered -Natural future fits: Docker `system prune`, Homebrew `cleanup`, Gradle `--stop`, pip cache purge. +Natural future fits: Docker `system prune`, Gradle `--stop`. ## Safety Model diff --git a/docs/commands.md b/docs/commands.md index af35417..b8a0e70 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -142,6 +142,16 @@ They are scoped to the ecosystems you target: the `--eco` selection, or — when | Ecosystem | Command | What it does | |-----------|---------|--------------| | xcode | `xcrun simctl delete unavailable` | Removes simulator devices whose iOS/watchOS/tvOS runtime was uninstalled. | +| global | `brew cleanup -s` | Removes stale Homebrew downloads and old versions. | +| global | `npm cache clean --force` | Clears the npm package cache. | +| global | `yarn cache clean` | Clears the Yarn cache. | +| global | `pnpm store prune` | Removes unreferenced packages from the pnpm store. | +| global | `pip cache purge` | Removes all wheels from the pip cache. | +| global | `uv cache prune` | Removes outdated entries from the uv cache. | + +Commands for tools not installed on the machine are skipped (detected via PATH +lookup). The `global` ecosystem runs every installed manager's prune together; +individual tools can't be targeted separately since they share one ecosystem. `--dry-run` prints the commands without executing. `--vendor-cleanup` is additive — combine with `--safe`, `--status`, `--yes` as usual. diff --git a/internal/scanner/global.go b/internal/scanner/global.go index 9fc54ba..58cfb56 100644 --- a/internal/scanner/global.go +++ b/internal/scanner/global.go @@ -129,12 +129,17 @@ type GlobalScanner struct { // is currently running (default: pgrep -x). A field so tests can stub // browser run state. ProcessRunning func(processName string) bool + // LookPath resolves an executable in PATH (default: exec.LookPath). A field + // so VendorCleanups only offers commands for tools actually installed, and + // tests can stub which tools are present. + LookPath func(file string) (string, error) } func NewGlobalScanner() *GlobalScanner { return &GlobalScanner{ TmpRoot: "/private/var/folders", ProcessRunning: processRunning, + LookPath: exec.LookPath, } } @@ -148,6 +153,66 @@ func processRunning(name string) bool { func (s *GlobalScanner) Name() string { return "global" } func (s *GlobalScanner) Ecosystem() model.Ecosystem { return model.EcoGlobal } +// globalVendorCleanup describes a package manager's native cache-prune command. +// tools lists candidate executables in preference order (first one found in PATH +// is used), so pip3-only machines still match the pip entry. +type globalVendorCleanup struct { + id string + tools []string + args []string + desc string +} + +// globalVendorCleanups are vendor-native prune commands for the global caches. +// All are non-destructive — they only reclaim regenerable download/store caches, +// so no destructive-action gate is needed here. +var globalVendorCleanups = []globalVendorCleanup{ + {"brew-cleanup", []string{"brew"}, []string{"cleanup", "-s"}, "Remove stale Homebrew downloads and old versions"}, + {"npm-cache-clean", []string{"npm"}, []string{"cache", "clean", "--force"}, "Clear the npm package cache"}, + {"yarn-cache-clean", []string{"yarn"}, []string{"cache", "clean"}, "Clear the Yarn cache"}, + {"pnpm-store-prune", []string{"pnpm"}, []string{"store", "prune"}, "Remove unreferenced packages from the pnpm store"}, + {"pip-cache-purge", []string{"pip", "pip3"}, []string{"cache", "purge"}, "Remove all wheels from the pip cache"}, + {"uv-cache-prune", []string{"uv"}, []string{"cache", "prune"}, "Remove outdated entries from the uv cache"}, +} + +// VendorCleanups returns prune commands for the package managers installed on +// this machine. Tools absent from PATH are skipped so the offer only lists what +// can actually run. Since every global cache shares the one ecosystem, these run +// together whenever the global ecosystem is in a --vendor-cleanup scope. +func (s *GlobalScanner) VendorCleanups() []VendorCleanup { + lookPath := s.LookPath + if lookPath == nil { + lookPath = exec.LookPath + } + + var out []VendorCleanup + for _, v := range globalVendorCleanups { + var tool string + for _, cand := range v.tools { + if _, err := lookPath(cand); err == nil { + tool = cand + break + } + } + if tool == "" { + continue // none of the candidate executables are installed + } + args := v.args + out = append(out, VendorCleanup{ + ID: v.id, + Description: v.desc, + DeleteMethod: model.DeleteMethod{ + Kind: model.DeleteKindCommand, + Display: tool + " " + strings.Join(args, " "), + Run: func(ctx context.Context) error { + return exec.CommandContext(ctx, tool, args...).Run() + }, + }, + }) + } + return out +} + func (s *GlobalScanner) Scan(ctx context.Context, root string) ([]model.ScanResult, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/scanner/global_test.go b/internal/scanner/global_test.go index 9bc39a4..5431775 100644 --- a/internal/scanner/global_test.go +++ b/internal/scanner/global_test.go @@ -2,6 +2,7 @@ package scanner_test import ( "context" + "os/exec" "path/filepath" "runtime" "strings" @@ -362,3 +363,52 @@ func TestGlobalScanner_SkipsMissingPaths(t *testing.T) { t.Fatalf("expected no results for empty home, got %d", len(results)) } } + +// TestGlobalScanner_VendorCleanups stubs PATH lookup so only brew and pip3 are +// "installed", and verifies VendorCleanups offers exactly those — including the +// pip3 fallback surfacing in the displayed command. +func TestGlobalScanner_VendorCleanups(t *testing.T) { + s := scanner.NewGlobalScanner() + s.LookPath = func(file string) (string, error) { + if file == "brew" || file == "pip3" { + return "/usr/local/bin/" + file, nil + } + return "", exec.ErrNotFound + } + + vc, ok := any(s).(scanner.VendorCleaner) + if !ok { + t.Fatal("GlobalScanner should implement VendorCleaner") + } + actions := vc.VendorCleanups() + if len(actions) != 2 { + t.Fatalf("expected 2 actions (brew, pip3), got %d", len(actions)) + } + + byID := make(map[string]scanner.VendorCleanup, len(actions)) + for _, a := range actions { + if a.Kind != model.DeleteKindCommand { + t.Errorf("%s: Kind should be command, got %q", a.ID, a.Kind) + } + if a.Display == "" || a.Run == nil { + t.Errorf("%s: Display/Run must be populated", a.ID) + } + byID[a.ID] = a + } + + if _, ok := byID["brew-cleanup"]; !ok { + t.Error("expected brew-cleanup action") + } + pip, ok := byID["pip-cache-purge"] + if !ok { + t.Fatal("expected pip-cache-purge action via pip3 fallback") + } + if pip.Display != "pip3 cache purge" { + t.Errorf("pip entry should use the pip3 fallback executable, got %q", pip.Display) + } + for _, absent := range []string{"npm-cache-clean", "yarn-cache-clean", "pnpm-store-prune", "uv-cache-prune"} { + if _, present := byID[absent]; present { + t.Errorf("uninstalled tool %s must be skipped", absent) + } + } +}