Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/snapshot/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type CaptureResults struct {
Casks []string
Taps []string
Npm []string
Bun []string
Prefs []MacOSPref
Git *GitSnapshot
Dotfiles *DotfilesSnapshot
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}
}
Expand All @@ -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,
Expand Down Expand Up @@ -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: `<name>@<version>` 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
Expand Down
8 changes: 8 additions & 0 deletions internal/snapshot/capture_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
58 changes: 58 additions & 0 deletions internal/snapshot/capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 17 additions & 4 deletions internal/snapshot/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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"`
Expand All @@ -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)
}
Expand All @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions internal/snapshot/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
},
},
Expand Down Expand Up @@ -139,6 +141,7 @@ func TestPackageSnapshot_MarshalJSON_RoundTrip(t *testing.T) {
Casks: []string{"docker"},
Taps: []string{"homebrew/core"},
Npm: []string{"typescript"},
Bun: []string{"prettier"},
},
},
{
Expand All @@ -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)
})
}
}
Expand Down
Loading