diff --git a/internal/nix/install.go b/internal/nix/install.go index d714b8b5646..3916c7a8a29 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -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() { diff --git a/nix/nix.go b/nix/nix.go index 0e37730ef89..c59df3589d9 100644 --- a/nix/nix.go +++ b/nix/nix.go @@ -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 . // 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(`^(.+) \(.+\) ((?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:(?:-|pre)(?P(?: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[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`) +var versionRegexp = regexp.MustCompile(`^(.+) \((.+)\) ((?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:(?:-|pre)(?P(?: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[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`) // preReleaseRegexp matches Nix prerelease version strings, which are not valid // semvers. @@ -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 @@ -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 { @@ -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 diff --git a/nix/nix_test.go b/nix/nix_test.go index 1604edf39a6..ef051c8236e 100644 --- a/nix/nix_test.go +++ b/nix/nix_test.go @@ -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) } @@ -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) } @@ -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) + } + }) + } +}