From 6d97ab8204482b3df77059ee44986972360f9107 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 14:08:03 +0000 Subject: [PATCH 1/2] fix(nix): detect incompatible Lix and print a clear hint (#2946) Lix 2.95 removed `builtins.fetchClosure`, which Devbox relies on to install packages from a binary cache. On such an installation Devbox failed with a cryptic `attribute 'fetchClosure' missing` error. Capture the implementation descriptor from `nix --version` output (e.g. "Lix, like Nix") so we can identify the Lix fork, and add `Info.IsLix` and `Info.SupportsFetchClosure` helpers. `EnsureNixInstalled` now checks this up front and returns a friendly compatibility hint pointing users to Nix or a Lix version < 2.95, instead of letting the flake evaluation fail later with an opaque message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PfDiNJ4vhy94wszC6vRR8d --- internal/nix/install.go | 16 +++++++++ nix/nix.go | 38 +++++++++++++++++++-- nix/nix_test.go | 73 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) 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) + } + }) + } +} From 0d95c2fe2f9e63bcc67e2727c10b035800e814a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:11:50 +0000 Subject: [PATCH 2/2] fix(nix): address review feedback on Lix compatibility check (#2946) - Treat Lix 2.95 prereleases as unsupported. Comparing against a plain "2.95.0" boundary let prerelease builds (which already dropped fetchClosure) slip through, since semver sorts a prerelease below its release. Compare against the lowest prerelease ("2.95.0-0") instead. - Correct the compatibility error message: it now states that Lix removed fetchClosure in 2.95 (LixVersionWithoutFetchClosure) rather than implying the user's installed version is where it was removed, and reuses the already-fetched info.Version instead of re-calling Version(). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PfDiNJ4vhy94wszC6vRR8d --- internal/nix/install.go | 10 +++++----- nix/nix.go | 10 +++++++--- nix/nix_test.go | 7 +++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/nix/install.go b/internal/nix/install.go index 7f3c0f9c6b7..2d6654b517c 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -88,11 +88,11 @@ func EnsureNixInstalled(ctx context.Context, writer io.Writer, withDaemonFunc fu // "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(), + "Devbox is not compatible with your Nix installation (Lix %s). "+ + "Lix removed `builtins.fetchClosure` in version %s, which "+ + "Devbox relies on to install packages. Please switch to Nix "+ + "(https://nixos.org/download), or downgrade to Lix < %[2]s.\n", + info.Version, nix.LixVersionWithoutFetchClosure, ) return diff --git a/nix/nix.go b/nix/nix.go index 83f281e6dfd..f793b606f0d 100644 --- a/nix/nix.go +++ b/nix/nix.go @@ -378,10 +378,14 @@ func (i Info) IsLix() bool { // 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) + if !i.IsLix() { + return true } - return true + // Compare against the lowest possible prerelease of the removal version + // (e.g. "2.95.0-0") so that Lix 2.95 prereleases, which have also dropped + // fetchClosure, are treated as unsupported. A plain "2.95.0" boundary would + // let them through, since semver sorts a prerelease below its release. + return !i.AtLeast(LixVersionWithoutFetchClosure + "-0") } // sourceProfileMutex guards against multiple goroutines attempting to source diff --git a/nix/nix_test.go b/nix/nix_test.go index 4df9cd6cb0a..8e7ab336b38 100644 --- a/nix/nix_test.go +++ b/nix/nix_test.go @@ -277,6 +277,13 @@ func TestInfoSupportsFetchClosure(t *testing.T) { info: Info{Implementation: "Lix, like Nix", Version: "2.95.0"}, want: false, }, + { + // A 2.95 prerelease has also dropped fetchClosure and must be + // treated as unsupported, even though semver sorts it below 2.95.0. + name: "lix 2.95 prerelease", + info: Info{Implementation: "Lix, like Nix", Version: "2.95.0-beta.1"}, + want: false, + }, { name: "lix after 2.95", info: Info{Implementation: "Lix, like Nix", Version: "2.95.2"},