Skip to content
Open
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
22 changes: 22 additions & 0 deletions internal/boxcli/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
127 changes: 125 additions & 2 deletions internal/devbox/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<formula>, e.g. %spython@3.10.",
pkgtype.HomebrewPrefix, pkgtype.HomebrewPrefix,
)
}
base, _, hasVersion := searcher.ParseVersionedPackage(formula)
if !hasVersion {
Comment thread
mikeland73 marked this conversation as resolved.
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
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 22 additions & 3 deletions internal/devpkg/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<commit>#foo, we can
// use a nix.Search cache.
//
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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())
}
Expand All @@ -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
}

Expand Down
38 changes: 38 additions & 0 deletions internal/devpkg/package_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""},
}
Expand All @@ -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)
}
}
})
}
}
Loading
Loading