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..7faeb4365c6 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,54 @@ 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 + } + + if err := d.ensureHomebrewInstalled(ctx); 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..f68ec808505 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" @@ -35,6 +37,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 +127,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 +494,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 +528,109 @@ 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 + } + 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 { + return fmt.Errorf("error installing homebrew package %s: %w", pkg, err) + } + } + 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 { + client := pkgtype.HomebrewClient() + if client.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 client.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 +// to the unversioned formula. +func (d *Devbox) resolveHomebrewPackageName( + ctx context.Context, + 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 + } + + // We need brew to check for versioned formulae, so make sure it's installed. + if err := d.ensureHomebrewInstalled(ctx); err != nil { + return "", err + } + + client := pkgtype.HomebrewClient() + versionedFormulae, err := client.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 + } + // 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 +} + // 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 +849,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..907f8c8821e 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 _, 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 != test.isNix { + t.Errorf("IsNix() = %v, want %v", got, test.isNix) + } + if got := pkg.IsRunX(); got != test.isRunX { + t.Errorf("IsRunX() = %v, want %v", got, test.isRunX) + } + if test.isHomebrew { + if got := pkg.HomebrewFormula(); got != test.homebrewFormula { + t.Errorf("HomebrewFormula() = %q, want %q", got, test.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..bc93b3cf7f9 --- /dev/null +++ b/internal/devpkg/pkgtype/homebrew.go @@ -0,0 +1,256 @@ +package pkgtype + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +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, +// 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"` +} + +// 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 { + 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) + } + + tmpFile, err := os.CreateTemp("", "homebrew-install-*.sh") + if err != nil { + return "", err + } + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return "", err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + return tmpFile.Name(), 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, "==>") { + 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 +} + +func (h *Homebrew) install(ctx context.Context, formula string) error { + 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 + 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) { + brew := h.brewPath() + if brew == "" { + return nil, fmt.Errorf( + "homebrew is required to use homebrew: packages, but `brew` was not " + + "found. 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..c518ba0da3a 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,24 @@ 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) + // "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, + }, nil + } + name, version, _ := searcher.ParseVersionedPackage(pkg) if version == "" { return nil, usererr.New("No version specified for %q.", name)