From e57dc02b3b3039216d1ee3e466cd9c5bbac4724c Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Sat, 6 Jun 2026 16:30:48 +0800 Subject: [PATCH] feat(snapshot): scan bun global packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new capture step "Bun Global Packages" that runs `bun pm ls -g` and records installed bun globals in PackageSnapshot.Bun. The parser strips tree-drawing characters and ignores the path header line; bun itself and duplicates are filtered out. PackageSnapshot.UnmarshalJSON handles bun across all three accepted JSON shapes (structured object, rich object array, typed array), so snapshots produced or consumed by other tools round-trip cleanly. The server side (openbootdotdev/openboot.dev#14) accepts the new `packages.bun` field and buckets bun globals into the existing npm slot so the alias install endpoint installs them via `npm install -g` from the same registry — no downstream changes required yet. --- internal/snapshot/capture.go | 54 ++++++++++++++++++++++++ internal/snapshot/capture_exec_test.go | 8 ++++ internal/snapshot/capture_test.go | 58 ++++++++++++++++++++++++++ internal/snapshot/snapshot.go | 21 ++++++++-- internal/snapshot/snapshot_test.go | 8 +++- 5 files changed, 143 insertions(+), 6 deletions(-) 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) }) } }