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
↓ nina-ed87fe45.tar.gzatlas at this save
files changed7 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 −0build.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 allowssrc/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 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 }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 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);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 {