ed87fe45ff11

auto-retry nix search + profile add with NIXPKGS_ALLOW_UNFREE=1 --impure when unfree error detected; bump v3.4.3

time
2026-06-17 14:16 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-4-6
session
unfree install fix
parent
12e8a2b5e5f0
7 files VERSION+1 −1build.zig.zon+1 −1src/api.zig+44 −3src/commands.zig+14 −9src/exec.zig+42 −1src/search.zig+8 −7src/types.zig+3 −0
VERSION+1 −1
@@ -1 +1 @@1-3.4.21+3.4.3

view file

build.zig.zon+1 −1
@@ -9,7 +9,7 @@99     .name = .nina,1010     // This is a [Semantic Version](https://semver.org/).1111     // In a future version of Zig it will be used for package deduplication.12-    .version = "3.4.2",12+    .version = "3.4.3",1313     // Together with name, this represents a globally unique package1414     // identifier. This field is generated by the Zig toolchain when the1515     // package is first created, and then *never changes*. This allows

view file

src/api.zig+44 −3
@@ -36,7 +36,7 @@3636     file.close(io);3737 }3838 39-pub fn searchPackages(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]types.NixPackage {39+pub fn searchPackages(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, query: []const u8) ![]types.NixPackage {4040     const result = std.process.run(gpa, io, .{4141         .argv = &.{ "nix", "search", "nixpkgs", query, "--json", "--no-update-lock-file" },4242     }) catch {@@ -48,16 +48,56 @@4848 4949     switch (result.term) {5050         .exited => |code| if (code != 0) {51+            // nix evaluates nixpkgs.legacyPackages during search and rejects unfree52+            // packages even when the user has allowUnfree set system-wide. Retry53+            // with NIXPKGS_ALLOW_UNFREE=1 so the package appears in results, and54+            // tag it so the install step can pass the same override.55+            if (isSearchUnfreeError(result.stderr)) {56+                return searchPackagesUnfree(gpa, io, environ, query);57+            }5158             errors.error_info.setSuggestion("run nix-channel --update and try again", .{});5259             return error.SearchFailed;5360         },5461         else => return error.SearchFailed,5562     }5663 57-    return parseSearchJson(gpa, result.stdout);64+    return parseSearchJson(gpa, result.stdout, false);5865 }5966 60-fn parseSearchJson(gpa: std.mem.Allocator, json_str: []const u8) ![]types.NixPackage {67+fn isSearchUnfreeError(stderr: []const u8) bool {68+    return std.mem.indexOf(u8, stderr, "requires unfree license") != null or69+        std.mem.indexOf(u8, stderr, "has an unfree license") != null;70+}71+72+fn searchPackagesUnfree(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, query: []const u8) ![]types.NixPackage {73+    var env = try environ.clone(gpa);74+    defer env.deinit();75+    try env.put("NIXPKGS_ALLOW_UNFREE", "1");76+77+    const result = std.process.run(gpa, io, .{78+        // --impure allows builtins.getEnv in nixpkgs's unfree check to read79+        // NIXPKGS_ALLOW_UNFREE from the environment; pure flake eval ignores it.80+        .argv = &.{ "nix", "search", "nixpkgs", query, "--json", "--no-update-lock-file", "--impure" },81+        .environ_map = &env,82+    }) catch {83+        errors.error_info.setSuggestion("run nix-channel --update and try again", .{});84+        return error.SearchFailed;85+    };86+    defer gpa.free(result.stderr);87+    defer gpa.free(result.stdout);88+89+    switch (result.term) {90+        .exited => |code| if (code != 0) {91+            errors.error_info.setSuggestion("run nix-channel --update and try again", .{});92+            return error.SearchFailed;93+        },94+        else => return error.SearchFailed,95+    }96+97+    return parseSearchJson(gpa, result.stdout, true);98+}99+100+fn parseSearchJson(gpa: std.mem.Allocator, json_str: []const u8, unfree: bool) ![]types.NixPackage {61101     const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;62102     defer parsed.deinit();63103 @@ -94,6 +134,7 @@94134             .pname = try gpa.dupe(u8, pname),95135             .version = try gpa.dupe(u8, version),96136             .description = try gpa.dupe(u8, description),137+            .unfree = unfree,97138         });98139     }99140 

view file

src/commands.zig+14 −9
@@ -958,14 +958,14 @@958958 pub fn searchCmd(ctx: Ctx) !void {959959     const query = if (ctx.args.len > 0) ctx.args[0] else "";960960     api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);961-    const selected = try search_tui.run(ctx.gpa, ctx.io, query, .packages);961+    const selected = try search_tui.run(ctx.gpa, ctx.io, query, .packages, ctx.environ);962962     if (selected) |npkg| try selectAndInstall(ctx, npkg);963963 }964964 965965 pub fn optionCmd(ctx: Ctx) !void {966966     const query = if (ctx.args.len > 0) ctx.args[0] else "";967967     api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);968-    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);968+    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options, ctx.environ);969969 }970970 971971 pub fn install(ctx: Ctx) !void {@@ -974,7 +974,7 @@974974     api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);975975 976976     // Search for the package to get the attr977-    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {977+    const pkgs = api.searchPackages(ctx.gpa, ctx.io, ctx.environ, pkg_name) catch {978978         errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});979979         errors.error_info.setSuggestion("nina search {s}", .{pkg_name});980980         return error.PackageNotFound;@@ -1009,9 +1009,9 @@10091009 // search and rank by edit distance; return the best only if it's typo-close.10101010 fn closestPackage(ctx: Ctx, query: []const u8) ?types.NixPackage {10111011     if (query.len < 2) return null;1012-    var candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..@min(query.len, 4)]) catch &.{};1012+    var candidates = api.searchPackages(ctx.gpa, ctx.io, ctx.environ, query[0..@min(query.len, 4)]) catch &.{};10131013     if (candidates.len == 0 and query.len > 2) {1014-        candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..2]) catch &.{};1014+        candidates = api.searchPackages(ctx.gpa, ctx.io, ctx.environ, query[0..2]) catch &.{};10151015     }10161016     if (candidates.len == 0) return null;10171017 @@ -1088,9 +1088,14 @@10881088 10891089 fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {10901090     output.installStart(npkg.pname, .profile);1091-    output.printSubstep("nix profile add nixpkgs#{s}", .{npkg.attr});1092-    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile add nixpkgs#{s}", .{npkg.attr}));1093-    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);1091+    if (npkg.unfree) {1092+        output.printSubstep("NIXPKGS_ALLOW_UNFREE=1 nix profile add --impure nixpkgs#{s}", .{npkg.attr});1093+        output.printTeach(try std.fmt.allocPrint(ctx.gpa, "NIXPKGS_ALLOW_UNFREE=1 nix profile add --impure nixpkgs#{s}", .{npkg.attr}));1094+    } else {1095+        output.printSubstep("nix profile add nixpkgs#{s}", .{npkg.attr});1096+        output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile add nixpkgs#{s}", .{npkg.attr}));1097+    }1098+    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, ctx.environ, npkg.attr, npkg.unfree);10941099     if (code != 0) return error.ProcessFailed;10951100     output.installDone(npkg.pname);10961101 }@@ -1840,7 +1845,7 @@18401845     }18411846     if (std.mem.eql(u8, ctx.sub, "install")) {18421847         const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1843-        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);1848+        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, ctx.environ, attr, false);18441849         output.done();18451850         return;18461851     }

view file

src/exec.zig+42 −1
@@ -649,11 +649,52 @@649649 650650 // --- Profile ---651651 652-pub fn profileInstall(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: []const u8) !u8 {652+// Stream with one extra environment variable injected alongside the inherited env.653+// Used for cases like NIXPKGS_ALLOW_UNFREE=1 where we need a single override654+// without replacing the full parent environment.655+fn streamWithEnvVar(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, cmd: []const []const u8, base_env: *const std.process.Environ.Map, key: []const u8, val: []const u8) !u8 {656+    output.flush();657+    const argv = try buildArgv(gpa, machine, cmd);658+    defer if (!machine.local) gpa.free(argv);659+660+    var env = try base_env.clone(gpa);661+    defer env.deinit();662+    try env.put(key, val);663+664+    var child = std.process.spawn(io, .{665+        .argv = argv,666+        .stdin = .inherit,667+        .stdout = .inherit,668+        .stderr = .inherit,669+        .environ_map = &env,670+    }) catch |e| {671+        errors.error_info.setMessage("failed to spawn: {s}", .{cmd[0]});672+        return e;673+    };674+675+    const term = child.wait(io) catch |e| {676+        errors.error_info.setMessage("process wait failed", .{});677+        return e;678+    };679+680+    return switch (term) {681+        .exited => |c| c,682+        else => 1,683+    };684+}685+686+pub fn profileInstall(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, environ: *const std.process.Environ.Map, attr: []const u8, allow_unfree: bool) !u8 {653687     const pkg_ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{attr});654688     defer gpa.free(pkg_ref);655689     // `nix profile add` is the current verb; `install` is a deprecated alias that656690     // prints a warning on modern nix.691+    if (allow_unfree) {692+        // The package is unfree and the user's system-wide allowUnfree setting693+        // doesn't propagate to imperative nix commands. Pass NIXPKGS_ALLOW_UNFREE=1694+        // and --impure so the profile add succeeds: --impure allows builtins.getEnv695+        // to read the env var inside the nixpkgs flake's pure evaluation context.696+        return streamWithEnvVar(io, machine, gpa, &.{ "nix", "profile", "add", "--impure", pkg_ref }, environ, "NIXPKGS_ALLOW_UNFREE", "1");697+    }657698     return stream(io, machine, gpa, &.{ "nix", "profile", "add", pkg_ref });658699 }659700 

view file

src/search.zig+8 −7
@@ -38,11 +38,12 @@3838     io: std.Io,3939     initial_query: []const u8,4040     mode: Mode,41+    environ: *const std.process.Environ.Map,4142 ) !?types.NixPackage {4243     // Non-interactive fallback: when stdin isn't a terminal (piped, scripted,4344     // CI), print plain results and return instead of the raw-mode widget, which4445     // needs a real tty and would block waiting for keys.45-    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode);46+    if (!stdinIsTty(io)) return runPlain(gpa, io, initial_query, mode, environ);4647 4748     var saved: std.posix.termios = undefined;4849     try rawModeEnable(STDIN_FD, &saved);@@ -70,7 +71,7 @@7071 7172     // Initial search7273     _ = arena.reset(.free_all);73-    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);74+    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);7475 7576     // Initial render (prints WIDGET_LINES lines)7677     renderWidget(query_buf[0..query_len], packages, opts, selection, mode, false, .collapsed);@@ -106,7 +107,7 @@106107             output.flush();107108 108109             _ = arena.reset(.free_all);109-            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);110+            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts, environ);110111             // New result set: reset to the top and drop cached meta (it belonged to111112             // the previous results). Navigating during the window could otherwise112113             // leave selection past the new end and renderWidget would index OOB.@@ -207,7 +208,7 @@207208 208209 pub const Mode = enum { packages, options };209210 210-fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {211+fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption, environ: *const std.process.Environ.Map) []types.NixPackage {211212     // An empty query matches the entire catalog (100k+ rows) — a multi-second212213     // fetch that would freeze the widget. Show nothing until there's a query.213214     if (std.mem.trim(u8, query, " \t").len == 0) {@@ -215,7 +216,7 @@215216         return &.{};216217     }217218     switch (mode) {218-        .packages => return api.searchPackages(arena, io, query) catch &.{},219+        .packages => return api.searchPackages(arena, io, environ, query) catch &.{},219220         .options => {220221             opts.* = api.searchOptions(arena, io, query) catch &.{};221222             return &.{};@@ -236,7 +237,7 @@236237 237238 // Plain, non-interactive rendering for non-tty stdin. Same pink :: header and238239 // result count the widget shows, then one line per result; no cursor control.239-fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode) !?types.NixPackage {240+fn runPlain(gpa: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, environ: *const std.process.Environ.Map) !?types.NixPackage {240241     // An empty query matches the entire catalog (100k+ rows); refuse it cleanly.241242     if (std.mem.trim(u8, query, " \t").len == 0) {242243         output.searchEmptyQuery();@@ -246,7 +247,7 @@246247     var arena = std.heap.ArenaAllocator.init(gpa);247248     defer arena.deinit();248249     var opts: []api.NixOption = &.{};249-    const packages = doSearch(arena.allocator(), io, query, mode, &opts);250+    const packages = doSearch(arena.allocator(), io, query, mode, &opts, environ);250251     const count = resultCount(packages, opts, mode);251252     const mode_str = if (mode == .packages) "nixpkgs" else "options";252253     output.searchPlainHeader(mode_str, query, count);

view file

src/types.zig+3 −0
@@ -5,6 +5,9 @@55     pname: []const u8,66     version: []const u8,77     description: []const u8,8+    // true when the search required NIXPKGS_ALLOW_UNFREE=1 to return this package.9+    // The install step uses this to pass the same env so the profile add succeeds.10+    unfree: bool = false,811 };912 1013 pub const Machine = struct {

view file