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
16 changes: 16 additions & 0 deletions internal/nix/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ func EnsureNixInstalled(ctx context.Context, writer io.Writer, withDaemonFunc fu
)
return
}

// Lix 2.95 removed builtins.fetchClosure, which Devbox relies on to
// install packages from a binary cache. Detect it early and print a
// clear compatibility hint instead of a cryptic
// "attribute 'fetchClosure' missing" error later on.
if info, infoErr := nix.Default.Info(); infoErr == nil && !info.SupportsFetchClosure() {
err = usererr.New(
"Devbox is not compatible with your Nix installation. Lix %s "+
"removed `builtins.fetchClosure`, which Devbox relies on to "+
"install packages. Please switch to Nix "+
"(https://nixos.org/download), or downgrade to Lix < %s.\n",
nix.Version(),
nix.LixVersionWithoutFetchClosure,
)
return
}
}()

if BinaryInstalled() {
Expand Down
38 changes: 35 additions & 3 deletions nix/nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,19 @@ const (
MinVersion = Version2_18
)

// LixVersionWithoutFetchClosure is the first Lix version that removed
// builtins.fetchClosure, which Devbox relies on to install packages from a
// binary cache. Devbox is not compatible with Lix at or above this version.
//
// See https://lix.systems/blog/2026-03-25-lix-2.95-release/.
const LixVersionWithoutFetchClosure = "2.95.0"

// versionRegexp matches the first line of "nix --version" output.
//
// The semantic component is sourced from <https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string>.
// It's been modified to tolerate Nix prerelease versions, which don't have a
// hyphen before the prerelease component and contain underscores.
var versionRegexp = regexp.MustCompile(`^(.+) \(.+\) ((?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:(?:-|pre)(?P<prerelease>(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`)
var versionRegexp = regexp.MustCompile(`^(.+) \((.+)\) ((?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:(?:-|pre)(?P<prerelease>(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`)

// preReleaseRegexp matches Nix prerelease version strings, which are not valid
// semvers.
Expand All @@ -192,6 +199,11 @@ type Info struct {
// also be a fork like "lix".
Name string

// Implementation is the parenthetical descriptor from the first line of
// "nix --version" output. It is "Nix" for upstream Nix and something
// like "Lix, like Nix" for the Lix fork.
Implementation string

// Version is the semantic Nix version string.
Version string

Expand Down Expand Up @@ -251,11 +263,12 @@ func parseInfo(data []byte) (Info, error) {

lines := strings.Split(string(data), "\n")
matches := versionRegexp.FindStringSubmatch(lines[0])
if len(matches) < 3 {
if len(matches) < 4 {
return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0]))
}
info.Name = matches[1]
info.Version = matches[2]
info.Implementation = matches[2]
info.Version = matches[3]
for _, line := range lines {
name, value, found := strings.Cut(line, ": ")
if !found {
Expand Down Expand Up @@ -306,6 +319,25 @@ func (i Info) AtLeast(version string) bool {
return semver.Compare("v"+prerelease, version) >= 0
}

// IsLix reports whether the Nix installation is the Lix fork, which identifies
// itself as "Lix, like Nix" in the parenthetical of its "nix --version" output.
func (i Info) IsLix() bool {
return strings.Contains(strings.ToLower(i.Implementation), "lix")
}

// SupportsFetchClosure reports whether the Nix installation provides
// builtins.fetchClosure, which Devbox relies on to install packages from a
// binary cache. The Lix fork removed fetchClosure in version 2.95, so Devbox is
// not compatible with it (see LixVersionWithoutFetchClosure). When the version
// cannot be determined, this returns true to avoid blocking on a false
// positive.
func (i Info) SupportsFetchClosure() bool {
if i.IsLix() {
return !i.AtLeast(LixVersionWithoutFetchClosure)
}
return true
}

// sourceProfileMutex guards against multiple goroutines attempting to source
// the Nix profile scripts concurrently.
var sourceProfileMutex sync.Mutex
Expand Down
73 changes: 73 additions & 0 deletions nix/nix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share
if got, want := info.Name, "nix"; got != want {
t.Errorf("got Name = %q, want %q", got, want)
}
if got, want := info.Implementation, "Nix"; got != want {
t.Errorf("got Implementation = %q, want %q", got, want)
}
if got, want := info.Version, "2.21.2"; got != want {
t.Errorf("got Version = %q, want %q", got, want)
}
Expand Down Expand Up @@ -73,6 +76,9 @@ Data directory: /nix/store/12asl5a17ffj78njcy2fj31v59rdmanx-lix-2.90-beta.1/shar
if got, want := info.Name, "nix"; got != want {
t.Errorf("got Name = %q, want %q", got, want)
}
if got, want := info.Implementation, "Lix, like Nix"; got != want {
t.Errorf("got Implementation = %q, want %q", got, want)
}
if got, want := info.Version, "2.90.0-beta.1"; got != want {
t.Errorf("got Version = %q, want %q", got, want)
}
Expand Down Expand Up @@ -204,3 +210,70 @@ func TestVersionInfoAtLeast(t *testing.T) {
info.AtLeast(v)
})
}

func TestInfoIsLix(t *testing.T) {
cases := []struct {
implementation string
want bool
}{
{"Nix", false},
{"Lix, like Nix", true},
{"lix", true},
{"", false},
}
for _, tt := range cases {
t.Run(tt.implementation, func(t *testing.T) {
info := Info{Implementation: tt.implementation}
if got := info.IsLix(); got != tt.want {
t.Errorf("Info{Implementation:%q}.IsLix() = %v, want %v", tt.implementation, got, tt.want)
}
})
}
}

func TestInfoSupportsFetchClosure(t *testing.T) {
cases := []struct {
name string
info Info
want bool
}{
{
name: "nix",
info: Info{Implementation: "Nix", Version: "2.34.6"},
want: true,
},
{
name: "lix before 2.95",
info: Info{Implementation: "Lix, like Nix", Version: "2.94.0"},
want: true,
},
{
name: "lix prerelease before 2.95",
info: Info{Implementation: "Lix, like Nix", Version: "2.90.0-beta.1"},
want: true,
},
{
name: "lix 2.95",
info: Info{Implementation: "Lix, like Nix", Version: "2.95.0"},
want: false,
},
{
name: "lix after 2.95",
info: Info{Implementation: "Lix, like Nix", Version: "2.95.2"},
want: false,
},
{
// Unknown version: assume support to avoid a false positive.
name: "lix unknown version",
info: Info{Implementation: "Lix, like Nix"},
want: true,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if got := tt.info.SupportsFetchClosure(); got != tt.want {
t.Errorf("SupportsFetchClosure() = %v, want %v", got, tt.want)
}
})
}
}
Loading