diff --git a/internal/snapshot/capture.go b/internal/snapshot/capture.go index 47edebe..48a923c 100644 --- a/internal/snapshot/capture.go +++ b/internal/snapshot/capture.go @@ -36,6 +36,7 @@ type CaptureResults struct { Casks []string Taps []string Npm []string + Bun []string Prefs []MacOSPref Git *GitSnapshot Dotfiles *DotfilesSnapshot @@ -70,6 +71,11 @@ var captureSteps = []captureStep{ r.Npm = v return err }, func(r *CaptureResults) int { return len(r.Npm) }}, + {"Bun Global Packages", func(r *CaptureResults) error { + v, err := CaptureBun() + r.Bun = v + return err + }, func(r *CaptureResults) int { return len(r.Bun) }}, {"macOS Preferences", func(r *CaptureResults) error { v, err := CaptureMacOSPrefs() r.Prefs = v @@ -120,6 +126,9 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) if r.Npm == nil { r.Npm = []string{} } + if r.Bun == nil { + r.Bun = []string{} + } if r.Prefs == nil { r.Prefs = []MacOSPref{} } @@ -145,6 +154,7 @@ func assembleSnapshot(r *CaptureResults, failedSteps []string, hostname string) Casks: r.Casks, Taps: r.Taps, Npm: r.Npm, + Bun: r.Bun, }, MacOSPrefs: r.Prefs, Shell: *r.Shell, @@ -192,6 +202,50 @@ func CaptureWithProgress(callback func(step ScanStep)) (*Snapshot, error) { return assembleSnapshot(results, failedSteps, hostname), nil } +// bunListEntryRe matches a single `bun pm ls -g` entry line, after tree-drawing +// characters are stripped. Format: `@` where version starts with +// a digit or `v`. Naming this way (rather than a more permissive pattern) keeps +// the path header line out of the result. +var bunListEntryRe = regexp.MustCompile(`^([@a-zA-Z0-9._/-]+)@[0-9vV]`) + +func parseBunList(output string) []string { + packages := []string{} + seen := map[string]bool{} + for _, line := range strings.Split(output, "\n") { + // Strip tree-drawing characters (├ └ │ ─) and surrounding whitespace. + line = strings.TrimSpace(line) + line = strings.TrimLeft(line, "├└│─ \t") + line = strings.TrimSpace(line) + if line == "" { + continue + } + m := bunListEntryRe.FindStringSubmatch(line) + if m == nil { + continue + } + name := m[1] + if name == "" || name == "bun" || seen[name] { + continue + } + seen[name] = true + packages = append(packages, name) + } + return packages +} + +func CaptureBun() ([]string, error) { + if _, err := exec.LookPath("bun"); err != nil { + return []string{}, nil + } + + output, err := system.RunCommandOutput("bun", "pm", "ls", "-g") + if err != nil { + return []string{}, nil + } + + return parseBunList(output), nil +} + func CaptureNpm() ([]string, error) { if _, err := exec.LookPath("npm"); err != nil { return []string{}, nil diff --git a/internal/snapshot/capture_exec_test.go b/internal/snapshot/capture_exec_test.go index 3387cf8..0e4d1cc 100644 --- a/internal/snapshot/capture_exec_test.go +++ b/internal/snapshot/capture_exec_test.go @@ -93,6 +93,14 @@ func TestCaptureNpm_NoPanic(t *testing.T) { assert.NotNil(t, packages) } +// TestCaptureBun_NoPanic ensures CaptureBun does not panic. Mirrors CaptureNpm: +// returns ([]string{}, nil) when bun is absent, installed globals otherwise. +func TestCaptureBun_NoPanic(t *testing.T) { + packages, err := CaptureBun() + require.NoError(t, err) + assert.NotNil(t, packages) +} + // --------------------------------------------------------------------------- // CaptureMacOSPrefs // --------------------------------------------------------------------------- diff --git a/internal/snapshot/capture_test.go b/internal/snapshot/capture_test.go index c32c4cb..1f43834 100644 --- a/internal/snapshot/capture_test.go +++ b/internal/snapshot/capture_test.go @@ -145,6 +145,64 @@ func TestParseVersion(t *testing.T) { } } +// TestParseBunList covers the `bun pm ls -g` parser. The input mimics real +// bun output: header line with the global path, then tree-drawn entries. +func TestParseBunList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "empty output", + input: "", + expected: []string{}, + }, + { + name: "single entry", + input: "/Users/x/.bun/install/global node_modules (1)\n" + + "└── prettier@3.2.5\n", + expected: []string{"prettier"}, + }, + { + name: "scoped and unscoped entries", + input: "/Users/x/.bun/install/global node_modules (3)\n" + + "├── @anthropic-ai/claude-code@1.0.5\n" + + "├── prettier@3.2.5\n" + + "└── typescript@5.4.3\n", + expected: []string{"@anthropic-ai/claude-code", "prettier", "typescript"}, + }, + { + name: "bun itself is excluded", + input: "/Users/x/.bun/install/global node_modules (2)\n" + + "├── bun@1.1.0\n" + + "└── prettier@3.2.5\n", + expected: []string{"prettier"}, + }, + { + name: "duplicates collapsed", + input: "/Users/x/.bun/install/global node_modules (2)\n" + + "├── prettier@3.2.5\n" + + "└── prettier@3.2.5\n", + expected: []string{"prettier"}, + }, + { + name: "lines without a version are skipped", + input: "/Users/x/.bun/install/global node_modules\n" + + "├── not-a-package-line\n" + + "└── prettier@3.2.5\n", + expected: []string{"prettier"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseBunList(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + // TestSanitizePath tests the sanitizePath function. func TestSanitizePath(t *testing.T) { tests := []struct { diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 024ea28..750ed13 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -35,11 +35,12 @@ type PackageSnapshot struct { Casks []string `json:"casks"` Taps []string `json:"taps"` Npm []string `json:"npm"` + Bun []string `json:"bun,omitempty"` Descriptions map[string]string `json:"-"` // populated during unmarshal, not serialised } // UnmarshalJSON accepts three formats: -// - Structured object: {"formulae":[],"casks":[],"taps":[],"npm":[]} +// - Structured object: {"formulae":[],"casks":[],"taps":[],"npm":[],"bun":[]} // - Typed object array: [{"name":"git","type":"formula"},{"name":"docker","type":"cask"}] // - Flat string array: ["git","curl"] (all treated as formulae) func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error { //nolint:gocyclo // parses multiple legacy JSON shapes; each branch is a distinct schema variant @@ -66,9 +67,13 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error { //nolint:gocyclo / Name string `json:"name"` Desc string `json:"desc"` } `json:"npm"` + Bun []struct { + Name string `json:"name"` + Desc string `json:"desc"` + } `json:"bun"` } if err := json.Unmarshal(data, &richObj); err == nil && - (len(richObj.Formulae) > 0 || len(richObj.Casks) > 0 || len(richObj.Npm) > 0) { + (len(richObj.Formulae) > 0 || len(richObj.Casks) > 0 || len(richObj.Npm) > 0 || len(richObj.Bun) > 0) { ps.Descriptions = make(map[string]string) for _, p := range richObj.Formulae { ps.Formulae = append(ps.Formulae, p.Name) @@ -89,10 +94,16 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error { //nolint:gocyclo / ps.Descriptions[p.Name] = p.Desc } } + for _, p := range richObj.Bun { + ps.Bun = append(ps.Bun, p.Name) + if p.Desc != "" { + ps.Descriptions[p.Name] = p.Desc + } + } return nil } - // Try typed object array: [{"name":"x","type":"formula|cask|tap|npm","desc":"..."}] + // Try typed object array: [{"name":"x","type":"formula|cask|tap|npm|bun","desc":"..."}] var typed []struct { Name string `json:"name"` Type string `json:"type"` @@ -108,6 +119,8 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error { //nolint:gocyclo / ps.Taps = append(ps.Taps, p.Name) case "npm": ps.Npm = append(ps.Npm, p.Name) + case "bun": + ps.Bun = append(ps.Bun, p.Name) default: ps.Formulae = append(ps.Formulae, p.Name) } @@ -125,7 +138,7 @@ func (ps *PackageSnapshot) UnmarshalJSON(data []byte) error { //nolint:gocyclo / return nil } - return fmt.Errorf("packages must be an object {formulae,casks,taps,npm} or an array") + return fmt.Errorf("packages must be an object {formulae,casks,taps,npm,bun} or an array") } // MarshalJSON always outputs the canonical format: plain string arrays. diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go index 715c45a..bf4ffb3 100644 --- a/internal/snapshot/snapshot_test.go +++ b/internal/snapshot/snapshot_test.go @@ -17,22 +17,24 @@ func TestPackageSnapshot_UnmarshalJSON(t *testing.T) { }{ { name: "object format", - input: `{"formulae":["git","go"],"casks":["docker"],"taps":["homebrew/core"],"npm":["typescript"]}`, + input: `{"formulae":["git","go"],"casks":["docker"],"taps":["homebrew/core"],"npm":["typescript"],"bun":["@anthropic-ai/claude-code"]}`, expected: PackageSnapshot{ Formulae: []string{"git", "go"}, Casks: []string{"docker"}, Taps: []string{"homebrew/core"}, Npm: []string{"typescript"}, + Bun: []string{"@anthropic-ai/claude-code"}, }, }, { name: "typed object array", - input: `[{"name":"git","type":"formula"},{"name":"docker","type":"cask"},{"name":"homebrew/core","type":"tap"},{"name":"typescript","type":"npm"}]`, + input: `[{"name":"git","type":"formula"},{"name":"docker","type":"cask"},{"name":"homebrew/core","type":"tap"},{"name":"typescript","type":"npm"},{"name":"prettier","type":"bun"}]`, expected: PackageSnapshot{ Formulae: []string{"git"}, Casks: []string{"docker"}, Taps: []string{"homebrew/core"}, Npm: []string{"typescript"}, + Bun: []string{"prettier"}, Descriptions: map[string]string{}, }, }, @@ -139,6 +141,7 @@ func TestPackageSnapshot_MarshalJSON_RoundTrip(t *testing.T) { Casks: []string{"docker"}, Taps: []string{"homebrew/core"}, Npm: []string{"typescript"}, + Bun: []string{"prettier"}, }, }, { @@ -161,6 +164,7 @@ func TestPackageSnapshot_MarshalJSON_RoundTrip(t *testing.T) { assert.Equal(t, tt.original.Casks, restored.Casks) assert.Equal(t, tt.original.Taps, restored.Taps) assert.Equal(t, tt.original.Npm, restored.Npm) + assert.Equal(t, tt.original.Bun, restored.Bun) }) } }