From 20b171daa0b59d270eea62e06c9cd8275e2212e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 17:55:30 +0000 Subject: [PATCH 1/4] Add support for installing Homebrew packages Introduces a new `homebrew:` package scheme so users can install Homebrew formulae through devbox, e.g.: devbox add homebrew:python@3.10 Implementation mirrors the existing `runx:` mechanism: - New pkgtype.Homebrew client that shells out to the `brew` CLI to install formulae, query versioned formulae, look up install prefixes, and search. - Package gains IsHomebrew()/HomebrewFormula() helpers; IsNix() now excludes both runx and homebrew packages so they're kept out of the generated flake and nix store install path. - When adding a versioned formula, devbox checks whether the base formula ships "versioned_formulae". If it doesn't, it warns that the package does not support versioned formulae (installing it anyway, with the caveat that installing a different version replaces the existing one). - On environment compute, HomebrewPaths installs the formulae and creates symlinks under .devbox/virtenv/homebrew/bin (similar to runx) which is added to PATH. - `devbox search homebrew:foo` dispatches to `brew search`. - Lockfile resolution records the homebrew formula identifier and version. https://claude.ai/code/session_01DE2BpiRpGFhZiCf8RTcPAE --- internal/boxcli/search.go | 22 ++++ internal/devbox/devbox.go | 50 ++++++++ internal/devbox/packages.go | 74 +++++++++++- internal/devpkg/package.go | 25 +++- internal/devpkg/package_test.go | 38 ++++++ internal/devpkg/pkgtype/flake.go | 2 +- internal/devpkg/pkgtype/homebrew.go | 146 +++++++++++++++++++++++ internal/devpkg/pkgtype/homebrew_test.go | 33 +++++ internal/lock/lockfile.go | 2 +- internal/lock/resolve.go | 13 ++ 10 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 internal/devpkg/pkgtype/homebrew.go create mode 100644 internal/devpkg/pkgtype/homebrew_test.go diff --git a/internal/boxcli/search.go b/internal/boxcli/search.go index cc62b0aea16..359b318062e 100644 --- a/internal/boxcli/search.go +++ b/internal/boxcli/search.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "go.jetify.com/devbox/internal/boxcli/usererr" + "go.jetify.com/devbox/internal/devpkg/pkgtype" "go.jetify.com/devbox/internal/searcher" "go.jetify.com/devbox/internal/ux" ) @@ -31,6 +32,9 @@ func searchCmd() *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { query := args[0] + if pkgtype.IsHomebrew(query) { + return searchHomebrew(cmd, query) + } name, version, isVersioned := searcher.ParseVersionedPackage(query) if !isVersioned { results, err := searcher.Client().Search(cmd.Context(), query) @@ -65,6 +69,24 @@ func searchCmd() *cobra.Command { return command } +func searchHomebrew(cmd *cobra.Command, query string) error { + formula := strings.TrimPrefix(query, pkgtype.HomebrewPrefix) + results, err := pkgtype.HomebrewClient().Search(cmd.Context(), formula) + if err != nil { + return err + } + w := cmd.OutOrStdout() + if len(results) == 0 { + fmt.Fprintf(w, "No homebrew formulae found for %q\n", formula) + return nil + } + fmt.Fprintf(w, "Found %d homebrew results for %q:\n\n", len(results), formula) + for _, name := range results { + fmt.Fprintf(w, "* %s%s\n", pkgtype.HomebrewPrefix, name) + } + return nil +} + func printSearchResults( w io.Writer, query string, diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 43ce1f2935c..6fa82fd4004 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -804,6 +804,12 @@ func (d *Devbox) computeEnv( } devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, runXPaths) + homebrewPaths, err := d.HomebrewPaths(ctx) + if err != nil { + return nil, err + } + devboxEnvPath = envpath.JoinPathLists(devboxEnvPath, homebrewPaths) + pathStack := envpath.Stack(env, originalEnv) pathStack.Push(env, d.ProjectDirHash(), devboxEnvPath, envOpts.PreservePathStack) env["PATH"] = pathStack.Path(env) @@ -1188,6 +1194,50 @@ func (d *Devbox) RunXPaths(ctx context.Context) (string, error) { return runxBinPath, nil } +// HomebrewPaths installs any homebrew: packages and returns a directory of +// symlinks to their executables that can be added to PATH. It mirrors how +// RunXPaths works for runx packages. It returns an empty string if the project +// has no homebrew packages. +func (d *Devbox) HomebrewPaths(ctx context.Context) (string, error) { + homebrewBinPath := filepath.Join(d.projectDir, ".devbox", "virtenv", "homebrew", "bin") + pkgs := lo.Filter(d.InstallablePackages(), devpkg.IsHomebrew) + if len(pkgs) == 0 { + // Clean up any stale symlinks from previously-removed homebrew packages + // and don't add anything to PATH. + return "", os.RemoveAll(homebrewBinPath) + } + + if err := os.RemoveAll(homebrewBinPath); err != nil { + return "", err + } + if err := os.MkdirAll(homebrewBinPath, 0o755); err != nil { + return "", err + } + + hb := pkgtype.HomebrewClient() + for _, pkg := range pkgs { + paths, err := hb.EnsureInstalled(ctx, pkg.HomebrewFormula()) + if err != nil { + return "", err + } + for _, path := range paths { + // create symlink to all files in path + files, err := os.ReadDir(path) + if err != nil { + return "", err + } + for _, file := range files { + src := filepath.Join(path, file.Name()) + dst := filepath.Join(homebrewBinPath, file.Name()) + if err := os.Symlink(src, dst); err != nil && !errors.Is(err, os.ErrExist) { + return "", err + } + } + } + } + return homebrewBinPath, nil +} + func validateEnvironment(environment string) (string, error) { if environment == "" { return "dev", nil diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index 5184682cb1e..03b69ec385f 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -35,6 +35,7 @@ import ( "go.jetify.com/devbox/internal/debug" "go.jetify.com/devbox/internal/nix" "go.jetify.com/devbox/internal/plugin" + "go.jetify.com/devbox/internal/searcher" "go.jetify.com/devbox/internal/ux" ) @@ -124,6 +125,19 @@ func (d *Devbox) Add(ctx context.Context, pkgsNames []string, opts devopt.AddOpt } } + // Homebrew packages are installed via the `brew` CLI rather than nix, so + // they don't go through the search/flake validation below. + if pkg.IsHomebrew() { + packageNameForConfig, err := d.resolveHomebrewPackageName(ctx, pkg) + if err != nil { + return err + } + ux.Finfof(d.stderr, "Adding package %q to devbox.json\n", packageNameForConfig) + d.cfg.PackageMutator().Add(packageNameForConfig) + addedPackageNames = append(addedPackageNames, packageNameForConfig) + continue + } + // validate that the versioned package exists in the search endpoint. // if not, fallback to legacy vanilla nix. versionedPkg := devpkg.PackageFromStringWithOptions(pkg.Versioned(), d.lockfile, opts) @@ -478,7 +492,11 @@ func (d *Devbox) installPackages(ctx context.Context, mode installMode) error { return err } - return d.InstallRunXPackages(ctx) + if err := d.InstallRunXPackages(ctx); err != nil { + return err + } + + return d.InstallHomebrewPackages(ctx) } func (d *Devbox) handleInstallFailure(ctx context.Context, mode installMode) error { @@ -508,6 +526,58 @@ func (d *Devbox) InstallRunXPackages(ctx context.Context) error { return nil } +func (d *Devbox) InstallHomebrewPackages(ctx context.Context) error { + pkgs := lo.Filter(d.InstallablePackages(), devpkg.IsHomebrew) + if len(pkgs) == 0 { + return nil + } + hb := pkgtype.HomebrewClient() + for _, pkg := range pkgs { + if _, err := hb.EnsureInstalled(ctx, pkg.HomebrewFormula()); err != nil { + return fmt.Errorf("error installing homebrew package %s: %w", pkg, err) + } + } + return nil +} + +// resolveHomebrewPackageName validates a homebrew package and returns the name +// that should be stored in devbox.json. If a version is requested but the +// formula doesn't support versioned formulae, it warns the user and falls back +// to the unversioned formula. +func (d *Devbox) resolveHomebrewPackageName( + ctx context.Context, + pkg *devpkg.Package, +) (string, error) { + formula := pkg.HomebrewFormula() + base, _, hasVersion := searcher.ParseVersionedPackage(formula) + if !hasVersion { + return pkg.Raw, nil + } + + hb := pkgtype.HomebrewClient() + // If brew isn't installed we can't check for versioned formulae. Store the + // package as-is; the install step will surface the missing-brew error. + if !hb.IsInstalled() { + return pkg.Raw, nil + } + + versionedFormulae, err := hb.VersionedFormulae(ctx, base) + if err != nil { + return "", err + } + if len(versionedFormulae) == 0 { + ux.Fwarningf( + d.stderr, + "Homebrew package %s does not support versioned formulae. devbox will "+ + "still install it, but if you try to install a different version it "+ + "will replace the existing version.\n", + base, + ) + return pkgtype.HomebrewPrefix + base, nil + } + return pkg.Raw, nil +} + // installNixPackagesToStore will install all the packages in the nix store, if // mode is install or update, and we're not in a devbox environment. // This is done by running `nix build` on the flake. We do this so that the @@ -726,7 +796,7 @@ func (d *Devbox) moveAllowInsecureFromLockfile(writer io.Writer, lockfile *lock. func (d *Devbox) FixMissingStorePaths(ctx context.Context) error { packages := d.InstallablePackages() for _, pkg := range packages { - if !pkg.IsDevboxPackage || pkg.IsRunX() { + if !pkg.IsDevboxPackage || !pkg.IsNix() { continue } existingStorePaths, err := pkg.GetResolvedStorePaths() diff --git a/internal/devpkg/package.go b/internal/devpkg/package.go index d1813f091ec..282bccbb40d 100644 --- a/internal/devpkg/package.go +++ b/internal/devpkg/package.go @@ -395,7 +395,7 @@ func (p *Package) normalizePackageAttributePath() (string, error) { // We prefer nix.Search over just trying to parse the package's "URL" because // nix.Search will guarantee that the package exists for the current system. var infos map[string]*nix.PkgInfo - if p.IsDevboxPackage && !p.IsRunX() { + if p.IsDevboxPackage && p.IsNix() { // Perf optimization: For queries of the form nixpkgs/#foo, we can // use a nix.Search cache. // @@ -657,6 +657,10 @@ func (p *Package) IsRunX() bool { return pkgtype.IsRunX(p.Raw) } +func (p *Package) IsHomebrew() bool { + return pkgtype.IsHomebrew(p.Raw) +} + func (p *Package) IsNix() bool { return IsNix(p, 0) } @@ -665,6 +669,13 @@ func (p *Package) RunXPath() string { return strings.TrimPrefix(p.Raw, pkgtype.RunXPrefix) } +// HomebrewFormula returns the Homebrew formula identifier for this package, +// i.e. the package string with the "homebrew:" prefix removed. For example, +// "homebrew:python@3.10" returns "python@3.10". +func (p *Package) HomebrewFormula() string { + return strings.TrimPrefix(p.Raw, pkgtype.HomebrewPrefix) +} + func (p *Package) String() string { if p.installable.AttrPath != "" { return p.installable.AttrPath @@ -680,18 +691,26 @@ func (p *Package) LockfileKey() string { } func IsNix(p *Package, _ int) bool { - return !p.IsRunX() + return !p.IsRunX() && !p.IsHomebrew() } func IsRunX(p *Package, _ int) bool { return p.IsRunX() } +func IsHomebrew(p *Package, _ int) bool { + return p.IsHomebrew() +} + func (p *Package) DocsURL() string { if p.IsRunX() { path, _, _ := strings.Cut(p.RunXPath(), "@") return fmt.Sprintf("https://www.github.com/%s", path) } + if p.IsHomebrew() { + name, _, _ := strings.Cut(p.HomebrewFormula(), "@") + return fmt.Sprintf("https://formulae.brew.sh/formula/%s", name) + } if p.IsDevboxPackage { return fmt.Sprintf("https://www.nixhub.io/packages/%s", p.CanonicalName()) } @@ -701,7 +720,7 @@ func (p *Package) DocsURL() string { // GetOutputNames returns the names of the nix package outputs. Outputs can be // specified in devbox.json package fields or as part of the flake reference. func (p *Package) GetOutputNames() ([]string, error) { - if p.IsRunX() { + if !p.IsNix() { return []string{}, nil } diff --git a/internal/devpkg/package_test.go b/internal/devpkg/package_test.go index b35912a0f12..f802c13eb0f 100644 --- a/internal/devpkg/package_test.go +++ b/internal/devpkg/package_test.go @@ -197,6 +197,8 @@ func TestCanonicalName(t *testing.T) { {"runx:golangci/golangci-lint@latest", "runx:golangci/golangci-lint"}, {"runx:golangci/golangci-lint@v0.0.2", "runx:golangci/golangci-lint"}, {"runx:golangci/golangci-lint", "runx:golangci/golangci-lint"}, + {"homebrew:python@3.10", "homebrew:python"}, + {"homebrew:wget", "homebrew:wget"}, {"github:NixOS/nixpkgs/12345", ""}, {"path:/to/my/file", ""}, } @@ -211,3 +213,39 @@ func TestCanonicalName(t *testing.T) { }) } } + +func TestHomebrewPackage(t *testing.T) { + tests := []struct { + pkgName string + isHomebrew bool + isNix bool + isRunX bool + homebrewFormula string + }{ + {"homebrew:python@3.10", true, false, false, "python@3.10"}, + {"homebrew:wget", true, false, false, "wget"}, + {"runx:golangci/golangci-lint@latest", false, false, true, ""}, + {"go@1.21", false, true, false, ""}, + {"hello", false, true, false, ""}, + } + + for _, tt := range tests { + t.Run(tt.pkgName, func(t *testing.T) { + pkg := PackageFromStringWithDefaults(tt.pkgName, &lockfile{}) + if got := pkg.IsHomebrew(); got != tt.isHomebrew { + t.Errorf("IsHomebrew() = %v, want %v", got, tt.isHomebrew) + } + if got := pkg.IsNix(); got != tt.isNix { + t.Errorf("IsNix() = %v, want %v", got, tt.isNix) + } + if got := pkg.IsRunX(); got != tt.isRunX { + t.Errorf("IsRunX() = %v, want %v", got, tt.isRunX) + } + if tt.isHomebrew { + if got := pkg.HomebrewFormula(); got != tt.homebrewFormula { + t.Errorf("HomebrewFormula() = %q, want %q", got, tt.homebrewFormula) + } + } + }) + } +} diff --git a/internal/devpkg/pkgtype/flake.go b/internal/devpkg/pkgtype/flake.go index b6ec9562a7d..de1576a9d12 100644 --- a/internal/devpkg/pkgtype/flake.go +++ b/internal/devpkg/pkgtype/flake.go @@ -7,7 +7,7 @@ import ( ) func IsFlake(s string) bool { - if IsRunX(s) { + if IsRunX(s) || IsHomebrew(s) { return false } parsed, err := flake.ParseInstallable(s) diff --git a/internal/devpkg/pkgtype/homebrew.go b/internal/devpkg/pkgtype/homebrew.go new file mode 100644 index 00000000000..6f7ad039655 --- /dev/null +++ b/internal/devpkg/pkgtype/homebrew.go @@ -0,0 +1,146 @@ +package pkgtype + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + HomebrewScheme = "homebrew" + HomebrewPrefix = HomebrewScheme + ":" +) + +// IsHomebrew returns true if the package string refers to a Homebrew formula, +// e.g. "homebrew:python@3.10". +func IsHomebrew(s string) bool { + return strings.HasPrefix(s, HomebrewPrefix) +} + +// HomebrewClient returns a client that shells out to the `brew` CLI to install +// and inspect Homebrew formulae. +func HomebrewClient() *Homebrew { + return &Homebrew{} +} + +type Homebrew struct{} + +// brewInfo is a partial representation of the JSON returned by +// `brew info --json=v2 `. We only decode the fields we need. +type brewInfo struct { + Formulae []brewFormula `json:"formulae"` +} + +type brewFormula struct { + Name string `json:"name"` + FullName string `json:"full_name"` + // VersionedFormulae lists the names of the versioned variants of this + // formula (e.g. python -> ["python@3.13", "python@3.12", ...]). It is + // empty for formulae that don't ship versioned variants. + VersionedFormulae []string `json:"versioned_formulae"` +} + +// IsInstalled reports whether the `brew` CLI is available on the user's PATH. +func (h *Homebrew) IsInstalled() bool { + _, err := exec.LookPath("brew") + return err == nil +} + +// VersionedFormulae returns the names of the versioned variants of the given +// base formula (e.g. "python" -> ["python@3.13", "python@3.12"]). It returns an +// empty slice for formulae that don't support versioned formulae. +func (h *Homebrew) VersionedFormulae(ctx context.Context, formula string) ([]string, error) { + info, err := h.info(ctx, formula) + if err != nil { + return nil, err + } + if len(info.Formulae) == 0 { + return nil, fmt.Errorf("homebrew formula %q not found", formula) + } + return info.Formulae[0].VersionedFormulae, nil +} + +// EnsureInstalled installs the formula if it isn't already installed and +// returns the directories that should be added to PATH (e.g. its bin dir). +func (h *Homebrew) EnsureInstalled(ctx context.Context, formula string) ([]string, error) { + prefix, err := h.prefix(ctx, formula) + if err != nil { + return nil, err + } + // `brew --prefix ` returns the opt path even when the formula is + // not installed yet, so we check whether that path exists on disk to decide + // if we need to install. + if _, statErr := os.Stat(prefix); statErr != nil { + if err := h.install(ctx, formula); err != nil { + return nil, err + } + } + + paths := []string{} + binPath := filepath.Join(prefix, "bin") + if _, err := os.Stat(binPath); err == nil { + paths = append(paths, binPath) + } + return paths, nil +} + +// Search returns the names of formulae that match the given query. +func (h *Homebrew) Search(ctx context.Context, query string) ([]string, error) { + out, err := h.run(ctx, "search", "--formula", query) + if err != nil { + return nil, err + } + results := []string{} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "==>") { + results = append(results, line) + } + } + return results, nil +} + +func (h *Homebrew) install(ctx context.Context, formula string) error { + cmd := exec.CommandContext(ctx, "brew", "install", formula) + // Stream brew's output so the user can follow along with the install. + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install homebrew formula %q: %w", formula, err) + } + return nil +} + +func (h *Homebrew) prefix(ctx context.Context, formula string) (string, error) { + out, err := h.run(ctx, "--prefix", formula) + if err != nil { + return "", fmt.Errorf("homebrew formula %q not found: %w", formula, err) + } + return strings.TrimSpace(string(out)), nil +} + +func (h *Homebrew) info(ctx context.Context, formula string) (*brewInfo, error) { + out, err := h.run(ctx, "info", "--json=v2", formula) + if err != nil { + return nil, err + } + info := &brewInfo{} + if err := json.Unmarshal(out, info); err != nil { + return nil, fmt.Errorf("failed to parse homebrew info for %q: %w", formula, err) + } + return info, nil +} + +func (h *Homebrew) run(ctx context.Context, args ...string) ([]byte, error) { + if !h.IsInstalled() { + return nil, fmt.Errorf( + "homebrew is required to use homebrew: packages, but `brew` was not " + + "found on your PATH. Install it from https://brew.sh", + ) + } + return exec.CommandContext(ctx, "brew", args...).Output() +} diff --git a/internal/devpkg/pkgtype/homebrew_test.go b/internal/devpkg/pkgtype/homebrew_test.go new file mode 100644 index 00000000000..b7a63eaef19 --- /dev/null +++ b/internal/devpkg/pkgtype/homebrew_test.go @@ -0,0 +1,33 @@ +package pkgtype + +import "testing" + +func TestIsHomebrew(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"homebrew:python@3.10", true}, + {"homebrew:wget", true}, + {"homebrew:", true}, + {"runx:golangci/golangci-lint", false}, + {"python@3.10", false}, + {"github:NixOS/nixpkgs/12345", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := IsHomebrew(tt.in); got != tt.want { + t.Errorf("IsHomebrew(%q) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestHomebrewIsNotFlake(t *testing.T) { + for _, s := range []string{"homebrew:python@3.10", "homebrew:wget"} { + if IsFlake(s) { + t.Errorf("IsFlake(%q) = true, want false", s) + } + } +} diff --git a/internal/lock/lockfile.go b/internal/lock/lockfile.go index c1f7cd34f01..9a1e3fa3564 100644 --- a/internal/lock/lockfile.go +++ b/internal/lock/lockfile.go @@ -81,7 +81,7 @@ func (f *File) Resolve(pkg string) (*Package, error) { locked := &Package{} _, _, versioned := searcher.ParseVersionedPackage(pkg) - if pkgtype.IsRunX(pkg) || versioned || pkgtype.IsFlake(pkg) { + if pkgtype.IsRunX(pkg) || pkgtype.IsHomebrew(pkg) || versioned || pkgtype.IsFlake(pkg) { resolved, err := f.FetchResolvedPackage(pkg, false) if err != nil { return nil, err diff --git a/internal/lock/resolve.go b/internal/lock/resolve.go index 2183d7c6024..1d352a12a42 100644 --- a/internal/lock/resolve.go +++ b/internal/lock/resolve.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log/slog" + "strings" "sync" "time" @@ -48,6 +49,18 @@ func (f *File) FetchResolvedPackage(pkg string, refresh bool) (*Package, error) }, nil } + if pkgtype.IsHomebrew(pkg) { + // Homebrew formulae are resolved and installed by shelling out to the + // `brew` CLI at environment-compute time. The lockfile only records the + // formula identifier (and version, if any) so the package is reproducible. + formula := strings.TrimPrefix(pkg, pkgtype.HomebrewPrefix) + _, version, _ := searcher.ParseVersionedPackage(formula) + return &Package{ + Resolved: pkg, + Version: version, + }, nil + } + name, version, _ := searcher.ParseVersionedPackage(pkg) if version == "" { return nil, usererr.New("No version specified for %q.", name) From 0a5e2e6f666387bea98fac29a8bd35ca47deaf41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 18:03:19 +0000 Subject: [PATCH 2/4] Auto-install Homebrew when missing If a homebrew: package is added or built and the `brew` CLI is not installed, devbox now offers to install Homebrew: - Interactive terminals get a Y/n confirmation prompt (defaults to yes). - Non-interactive sessions install automatically. Homebrew is installed via the official install script with NONINTERACTIVE=1, which works on both macOS and Linux. After installation devbox locates the `brew` binary in its default install locations (including /home/linuxbrew/.linuxbrew on Linux) and adds it to PATH for the current process, so it can be used immediately without restarting the shell. The brew client now resolves the `brew` binary from these known locations in addition to PATH, so devbox can drive it right after a fresh install. https://claude.ai/code/session_01DE2BpiRpGFhZiCf8RTcPAE --- internal/devbox/devbox.go | 4 + internal/devbox/packages.go | 48 +++++++++-- internal/devpkg/pkgtype/homebrew.go | 121 ++++++++++++++++++++++++++-- 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index 6fa82fd4004..7faeb4365c6 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -1214,6 +1214,10 @@ func (d *Devbox) HomebrewPaths(ctx context.Context) (string, error) { return "", err } + if err := d.ensureHomebrewInstalled(ctx); err != nil { + return "", err + } + hb := pkgtype.HomebrewClient() for _, pkg := range pkgs { paths, err := hb.EnsureInstalled(ctx, pkg.HomebrewFormula()) diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index 03b69ec385f..f2198a8633b 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -16,6 +16,8 @@ import ( "strings" "time" + "github.com/AlecAivazis/survey/v2" + "github.com/mattn/go-isatty" "github.com/pkg/errors" "github.com/samber/lo" "go.jetify.com/devbox/internal/devbox/devopt" @@ -531,6 +533,9 @@ func (d *Devbox) InstallHomebrewPackages(ctx context.Context) error { if len(pkgs) == 0 { return nil } + if err := d.ensureHomebrewInstalled(ctx); err != nil { + return err + } hb := pkgtype.HomebrewClient() for _, pkg := range pkgs { if _, err := hb.EnsureInstalled(ctx, pkg.HomebrewFormula()); err != nil { @@ -540,6 +545,40 @@ func (d *Devbox) InstallHomebrewPackages(ctx context.Context) error { return nil } +// ensureHomebrewInstalled makes sure the `brew` CLI is available, installing +// Homebrew if it isn't. When running interactively it asks the user for +// confirmation (defaulting to yes); when running non-interactively it installs +// automatically. Homebrew is supported on both macOS and Linux. +func (d *Devbox) ensureHomebrewInstalled(ctx context.Context) error { + hb := pkgtype.HomebrewClient() + if hb.IsInstalled() { + return nil + } + + install := true + if isatty.IsTerminal(os.Stdin.Fd()) { + prompt := &survey.Confirm{ + Message: "Homebrew is required for homebrew: packages but is not installed. " + + "Install it now?", + Default: true, + } + if err := survey.AskOne(prompt, &install); err != nil { + return errors.WithStack(err) + } + } else { + ux.Finfof(d.stderr, "Homebrew is not installed. Installing it automatically.\n") + } + + if !install { + return usererr.New( + "Homebrew is required to install homebrew: packages. " + + "Install it from https://brew.sh and try again.", + ) + } + + return hb.Bootstrap(ctx, d.stderr) +} + // resolveHomebrewPackageName validates a homebrew package and returns the name // that should be stored in devbox.json. If a version is requested but the // formula doesn't support versioned formulae, it warns the user and falls back @@ -554,13 +593,12 @@ func (d *Devbox) resolveHomebrewPackageName( return pkg.Raw, nil } - hb := pkgtype.HomebrewClient() - // If brew isn't installed we can't check for versioned formulae. Store the - // package as-is; the install step will surface the missing-brew error. - if !hb.IsInstalled() { - return pkg.Raw, nil + // We need brew to check for versioned formulae, so make sure it's installed. + if err := d.ensureHomebrewInstalled(ctx); err != nil { + return "", err } + hb := pkgtype.HomebrewClient() versionedFormulae, err := hb.VersionedFormulae(ctx, base) if err != nil { return "", err diff --git a/internal/devpkg/pkgtype/homebrew.go b/internal/devpkg/pkgtype/homebrew.go index 6f7ad039655..0e899e98460 100644 --- a/internal/devpkg/pkgtype/homebrew.go +++ b/internal/devpkg/pkgtype/homebrew.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -13,6 +15,10 @@ import ( const ( HomebrewScheme = "homebrew" HomebrewPrefix = HomebrewScheme + ":" + + // homebrewInstallScriptURL is the official Homebrew installer. It supports + // both macOS and Linux. + homebrewInstallScriptURL = "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" ) // IsHomebrew returns true if the package string refers to a Homebrew formula, @@ -44,10 +50,110 @@ type brewFormula struct { VersionedFormulae []string `json:"versioned_formulae"` } -// IsInstalled reports whether the `brew` CLI is available on the user's PATH. +// brewKnownPaths are the default locations Homebrew installs the `brew` binary +// on Linux and macOS. We check these so that devbox can find brew immediately +// after installing it, even before the user has updated their shell PATH. +func brewKnownPaths() []string { + paths := []string{ + "/home/linuxbrew/.linuxbrew/bin/brew", // Linux (default) + "/opt/homebrew/bin/brew", // macOS (Apple Silicon) + "/usr/local/bin/brew", // macOS (Intel) + } + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".linuxbrew", "bin", "brew")) + } + return paths +} + +// brewPath returns the path to the `brew` executable, looking first on PATH and +// then in the default install locations. It returns "" if brew is not found. +func (h *Homebrew) brewPath() string { + if path, err := exec.LookPath("brew"); err == nil { + return path + } + for _, path := range brewKnownPaths() { + if _, err := os.Stat(path); err == nil { + return path + } + } + return "" +} + +// IsInstalled reports whether the `brew` CLI is available, either on PATH or in +// one of the default Homebrew install locations. func (h *Homebrew) IsInstalled() bool { - _, err := exec.LookPath("brew") - return err == nil + return h.brewPath() != "" +} + +// Bootstrap installs Homebrew itself using the official install script. It runs +// non-interactively (the caller is responsible for confirming with the user +// first). It works on both macOS and Linux. Output is streamed to w. +func (h *Homebrew) Bootstrap(ctx context.Context, w io.Writer) error { + script, err := h.downloadInstallScript(ctx) + if err != nil { + return err + } + defer os.Remove(script) + + cmd := exec.CommandContext(ctx, "/bin/bash", script) + // NONINTERACTIVE tells the Homebrew installer not to prompt; we've already + // confirmed with the user (or are running non-interactively). + cmd.Env = append(os.Environ(), "NONINTERACTIVE=1") + cmd.Stdin = os.Stdin + cmd.Stdout = w + cmd.Stderr = w + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install homebrew: %w", err) + } + + if !h.IsInstalled() { + return fmt.Errorf( + "homebrew installation completed but `brew` could not be found in the " + + "expected locations", + ) + } + + // Make brew (and its installed formulae) available to the rest of this + // process by adding its bin directory to PATH, similar to sourcing + // `brew shellenv`. + if brewBin := filepath.Dir(h.brewPath()); brewBin != "" { + path := os.Getenv("PATH") + if !strings.Contains(path, brewBin) { + _ = os.Setenv("PATH", brewBin+string(os.PathListSeparator)+path) + } + } + return nil +} + +func (h *Homebrew) downloadInstallScript(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, homebrewInstallScriptURL, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to download homebrew installer: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "failed to download homebrew installer: unexpected status %s", resp.Status) + } + + f, err := os.CreateTemp("", "homebrew-install-*.sh") + if err != nil { + return "", err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil } // VersionedFormulae returns the names of the versioned variants of the given @@ -105,7 +211,7 @@ func (h *Homebrew) Search(ctx context.Context, query string) ([]string, error) { } func (h *Homebrew) install(ctx context.Context, formula string) error { - cmd := exec.CommandContext(ctx, "brew", "install", formula) + cmd := exec.CommandContext(ctx, h.brewPath(), "install", formula) // Stream brew's output so the user can follow along with the install. cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr @@ -136,11 +242,12 @@ func (h *Homebrew) info(ctx context.Context, formula string) (*brewInfo, error) } func (h *Homebrew) run(ctx context.Context, args ...string) ([]byte, error) { - if !h.IsInstalled() { + brew := h.brewPath() + if brew == "" { return nil, fmt.Errorf( "homebrew is required to use homebrew: packages, but `brew` was not " + - "found on your PATH. Install it from https://brew.sh", + "found. Install it from https://brew.sh", ) } - return exec.CommandContext(ctx, "brew", args...).Output() + return exec.CommandContext(ctx, brew, args...).Output() } From 9e54b3d3e5a78e36b5e37f7ad9e21941228def22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 18:27:00 +0000 Subject: [PATCH 3/4] Address review feedback for homebrew packages - Reject empty `homebrew:` formula at add time with a clear message instead of failing later in `brew --prefix ""`. - Validate that a requested versioned formula (e.g. python@3.10) is actually one of the formula's versioned variants, rejecting bogus versions during add rather than at install time. - Treat the synthetic `@latest` suffix as unversioned in homebrew lock resolution so unversioned homebrew packages don't appear perpetually outdated. - Split `brew search` output on whitespace so column-formatted results return individual formulae. - Fix golangci-lint varnamelen findings (rename hb -> client, f -> tmpFile). https://claude.ai/code/session_01DE2BpiRpGFhZiCf8RTcPAE --- internal/devbox/packages.go | 25 ++++++++++++++++++++----- internal/devpkg/pkgtype/homebrew.go | 21 ++++++++++++--------- internal/lock/resolve.go | 6 ++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/internal/devbox/packages.go b/internal/devbox/packages.go index f2198a8633b..f68ec808505 100644 --- a/internal/devbox/packages.go +++ b/internal/devbox/packages.go @@ -550,8 +550,8 @@ func (d *Devbox) InstallHomebrewPackages(ctx context.Context) error { // confirmation (defaulting to yes); when running non-interactively it installs // automatically. Homebrew is supported on both macOS and Linux. func (d *Devbox) ensureHomebrewInstalled(ctx context.Context) error { - hb := pkgtype.HomebrewClient() - if hb.IsInstalled() { + client := pkgtype.HomebrewClient() + if client.IsInstalled() { return nil } @@ -576,7 +576,7 @@ func (d *Devbox) ensureHomebrewInstalled(ctx context.Context) error { ) } - return hb.Bootstrap(ctx, d.stderr) + return client.Bootstrap(ctx, d.stderr) } // resolveHomebrewPackageName validates a homebrew package and returns the name @@ -588,6 +588,12 @@ func (d *Devbox) resolveHomebrewPackageName( pkg *devpkg.Package, ) (string, error) { formula := pkg.HomebrewFormula() + if formula == "" { + return "", usererr.New( + "No homebrew formula specified. Use the form %s, e.g. %spython@3.10.", + pkgtype.HomebrewPrefix, pkgtype.HomebrewPrefix, + ) + } base, _, hasVersion := searcher.ParseVersionedPackage(formula) if !hasVersion { return pkg.Raw, nil @@ -598,8 +604,8 @@ func (d *Devbox) resolveHomebrewPackageName( return "", err } - hb := pkgtype.HomebrewClient() - versionedFormulae, err := hb.VersionedFormulae(ctx, base) + client := pkgtype.HomebrewClient() + versionedFormulae, err := client.VersionedFormulae(ctx, base) if err != nil { return "", err } @@ -613,6 +619,15 @@ func (d *Devbox) resolveHomebrewPackageName( ) return pkgtype.HomebrewPrefix + base, nil } + // The formula does support versioned formulae, so make sure the requested + // version is actually one of them rather than letting install fail later. + if !slices.Contains(versionedFormulae, formula) { + return "", usererr.New( + "Homebrew package %s is not an available versioned formula for %s. "+ + "Available versions are: %s.", + formula, base, strings.Join(versionedFormulae, ", "), + ) + } return pkg.Raw, nil } diff --git a/internal/devpkg/pkgtype/homebrew.go b/internal/devpkg/pkgtype/homebrew.go index 0e899e98460..bc93b3cf7f9 100644 --- a/internal/devpkg/pkgtype/homebrew.go +++ b/internal/devpkg/pkgtype/homebrew.go @@ -140,20 +140,20 @@ func (h *Homebrew) downloadInstallScript(ctx context.Context) (string, error) { "failed to download homebrew installer: unexpected status %s", resp.Status) } - f, err := os.CreateTemp("", "homebrew-install-*.sh") + tmpFile, err := os.CreateTemp("", "homebrew-install-*.sh") if err != nil { return "", err } - if _, err := io.Copy(f, resp.Body); err != nil { - f.Close() - os.Remove(f.Name()) + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) return "", err } - if err := f.Close(); err != nil { - os.Remove(f.Name()) + if err := tmpFile.Close(); err != nil { + os.Remove(tmpFile.Name()) return "", err } - return f.Name(), nil + return tmpFile.Name(), nil } // VersionedFormulae returns the names of the versioned variants of the given @@ -203,9 +203,12 @@ func (h *Homebrew) Search(ctx context.Context, query string) ([]string, error) { results := []string{} for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { line = strings.TrimSpace(line) - if line != "" && !strings.HasPrefix(line, "==>") { - results = append(results, line) + if line == "" || strings.HasPrefix(line, "==>") { + continue } + // `brew search` may print multiple formula names per line, separated by + // whitespace, so split each line into individual formulae. + results = append(results, strings.Fields(line)...) } return results, nil } diff --git a/internal/lock/resolve.go b/internal/lock/resolve.go index 1d352a12a42..c518ba0da3a 100644 --- a/internal/lock/resolve.go +++ b/internal/lock/resolve.go @@ -55,6 +55,12 @@ func (f *File) FetchResolvedPackage(pkg string, refresh bool) (*Package, error) // formula identifier (and version, if any) so the package is reproducible. formula := strings.TrimPrefix(pkg, pkgtype.HomebrewPrefix) _, version, _ := searcher.ParseVersionedPackage(formula) + // "latest" is a synthetic version Devbox appends to unversioned packages + // (see Package.Versioned). Homebrew has no such version, so treat it as + // unversioned to match the lockfile entry for the stored formula. + if version == "latest" { + version = "" + } return &Package{ Resolved: pkg, Version: version, From a48186a5ade6c0574be7eb0bb3af678e6e5991c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 18:29:59 +0000 Subject: [PATCH 4/4] Fix varnamelen lint in homebrew package test Rename the table-test loop variable from tt to test; its usage spans more than golangci-lint's max-distance, which tripped varnamelen. https://claude.ai/code/session_01DE2BpiRpGFhZiCf8RTcPAE --- internal/devpkg/package_test.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/devpkg/package_test.go b/internal/devpkg/package_test.go index f802c13eb0f..907f8c8821e 100644 --- a/internal/devpkg/package_test.go +++ b/internal/devpkg/package_test.go @@ -229,21 +229,21 @@ func TestHomebrewPackage(t *testing.T) { {"hello", false, true, false, ""}, } - for _, tt := range tests { - t.Run(tt.pkgName, func(t *testing.T) { - pkg := PackageFromStringWithDefaults(tt.pkgName, &lockfile{}) - if got := pkg.IsHomebrew(); got != tt.isHomebrew { - t.Errorf("IsHomebrew() = %v, want %v", got, tt.isHomebrew) + for _, test := range tests { + t.Run(test.pkgName, func(t *testing.T) { + pkg := PackageFromStringWithDefaults(test.pkgName, &lockfile{}) + if got := pkg.IsHomebrew(); got != test.isHomebrew { + t.Errorf("IsHomebrew() = %v, want %v", got, test.isHomebrew) } - if got := pkg.IsNix(); got != tt.isNix { - t.Errorf("IsNix() = %v, want %v", got, tt.isNix) + if got := pkg.IsNix(); got != test.isNix { + t.Errorf("IsNix() = %v, want %v", got, test.isNix) } - if got := pkg.IsRunX(); got != tt.isRunX { - t.Errorf("IsRunX() = %v, want %v", got, tt.isRunX) + if got := pkg.IsRunX(); got != test.isRunX { + t.Errorf("IsRunX() = %v, want %v", got, test.isRunX) } - if tt.isHomebrew { - if got := pkg.HomebrewFormula(); got != tt.homebrewFormula { - t.Errorf("HomebrewFormula() = %q, want %q", got, tt.homebrewFormula) + if test.isHomebrew { + if got := pkg.HomebrewFormula(); got != test.homebrewFormula { + t.Errorf("HomebrewFormula() = %q, want %q", got, test.homebrewFormula) } } })