68fb95c07a2f
fix nina goodbye for nix profile + system-module installs: detect install method via parsed 'nix profile list --json' element names (new profileHasNina/profileJsonHasNina) instead of assuming /nix/store means a profile install; remove by element name 'nina' (was the unmatched regex '.*nina.*'); when system-managed via ~/nina.nix, instruct instead of editing /etc/nixos or deleting an imported file; apply same detection to update's self-upgrade path; add profileJsonHasNina unit test incl store-path decoy; build+tests green on darwin and x86_64-linux
- time
- 2026-06-06 01:04 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- fix goodbye uninstall
- parent
- b519d45f7a23
↓ nina-68fb95c0.tar.gzatlas at this save
files changed3 files
src/commands.zig+50 −32src/exec.zig+64 −0src/output.zig+3 −7src/commands.zig+50 −32
@@ -284,14 +284,21 @@284284 285285 // When running from the nix store, defer to nix profile upgrade so nix286286 // manages the transition (the store is read-only; binary replacement fails).287+ // A NixOS system install also resolves into the store but isn't a profile288+ // element, so check the profile to avoid a no-op `upgrade` — it updates with289+ // the next nixos-rebuild instead.287290 if (std.mem.startsWith(u8, install_path, "/nix/store/")) {291+ if (!exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine)) {292+ output.updateSelfNixSystemManaged();293+ return;294+ }288295 output.updateSelfNixManaged(remote_ver);289296 if (ctx.cfg.confirm and !output.confirm("")) return;290- const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {297+ const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", "nina" }) catch {291298 output.updateSelfNixFailed();292299 return;293300 };294- if (ec != 0) output.updateSelfNixSystemManaged();301+ if (ec != 0) output.updateSelfNixFailed();295302 return;296303 }297304 @@ -335,7 +342,9 @@335342 output.updateSelfDone(version.VERSION, remote_ver);336343 }337344 338-// nina goodbye — clean uninstall from nix profile or binary location.345+// nina goodbye — clean uninstall. Handles the three install methods, and the346+// case where more than one is present at once (the installer can add nina to347+// the nix profile AND wire ~/nina.nix into the system config).339348 pub fn goodbye(ctx: Ctx) !void {340349 output.goodbyeStart();341350 @@ -345,48 +354,57 @@345354 return error.Internal;346355 };347356 348- if (std.mem.startsWith(u8, install_path, "/nix/store/")) {357+ // A binary outside the nix store is a plain install — just delete it.358+ if (!std.mem.startsWith(u8, install_path, "/nix/store/")) {359+ output.goodbyeFile(install_path);360+ if (ctx.cfg.confirm and !output.confirm("")) return;361+ std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {362+ errors.error_info.setMessage("could not remove {s}", .{install_path});363+ return e;364+ };365+ output.goodbyeDone();366+ return;367+ }368+369+ // In the nix store: a `nix profile` install, a NixOS system package370+ // (programs.nina.enable via ~/nina.nix), or both — they're independent and371+ // both resolve into the store. Remove the profile element if present, and372+ // separately flag the system module. We never auto-remove the system one:373+ // editing /etc/nixos needs sudo, and deleting ~/nina.nix while it's still374+ // imported breaks the next rebuild.375+ const in_profile = exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine);376+ const system_managed = ninaNixExists(ctx);377+378+ if (in_profile) {349379 output.goodbyeNix();350380 if (ctx.cfg.confirm and !output.confirm("")) return;351- const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {381+ const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", "nina" }) catch {352382 output.goodbyeFailed("nix profile remove failed");353383 return;354384 };355385 if (ec != 0) {356- output.goodbyeNixSystemManaged();386+ output.goodbyeFailed("nix profile remove failed");357387 return;358388 }359- } else {360- output.goodbyeFile(install_path);361- if (ctx.cfg.confirm and !output.confirm("")) return;362- std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {363- errors.error_info.setMessage("could not remove {s}", .{install_path});364- return e;365- };366389 }367390 368- // Offer to remove ~/nina.nix if it exists (generated by the installer).369- if (ctx.environ.get("HOME")) |home| {370- const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;371- if (nina_nix) |path| {372- defer ctx.gpa.free(path);373- const exists = blk: {374- const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;375- ctx.gpa.free(bytes);376- break :blk true;377- };378- if (exists) {379- output.goodbyeNixFileFound(path);380- if (!ctx.cfg.confirm or output.confirm("")) {381- std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {382- output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");383- };384- }385- }386- }391+ // System module present, or nothing in the profile and not a plain binary392+ // (so it must be system-managed) — instruct rather than touch /etc/nixos.393+ if (system_managed or !in_profile) {394+ output.goodbyeNixSystemManaged();387395 }388396 389397 output.goodbyeDone();398+}399+400+// Read-only check for the installer's ~/nina.nix system-config snippet.401+fn ninaNixExists(ctx: Ctx) bool {402+ const home = ctx.environ.get("HOME") orelse return false;403+ const path = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch return false;404+ defer ctx.gpa.free(path);405+ const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch return false;406+ ctx.gpa.free(bytes);407+ return true;390408 }391409 392410 fn channelUpdateCmd(ctx: Ctx) !void {src/exec.zig+64 −0
@@ -359,6 +359,70 @@359359 return r.stdout;360360 }361361 362+// Whether nina is an element of the nix profile. A NixOS system-package install363+// (programs.nina.enable) also resolves the binary into /nix/store, so the store364+// path alone can't distinguish it from a `nix profile` install — the profile365+// list can. Parses element names rather than substring-matching the raw JSON,366+// since another package's store path could contain "nina". A missing/empty367+// profile (or no nix) means not-in-profile.368+pub fn profileHasNina(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) bool {369+ const js = profileListJson(gpa, io, machine) catch return false;370+ defer gpa.free(js);371+ return profileJsonHasNina(gpa, js);372+}373+374+// Pure parse of `nix profile list --json` for a nina element. Element names,375+// not a raw substring search — another package's store path could contain376+// "nina". Newer nix keys elements by name (e.g. "nina"); older nix uses an377+// array of objects with an "attrPath" like "...#nina".378+fn profileJsonHasNina(gpa: std.mem.Allocator, js: []const u8) bool {379+ var parsed = std.json.parseFromSlice(std.json.Value, gpa, js, .{}) catch return false;380+ defer parsed.deinit();381+ const root = parsed.value;382+ if (root != .object) return false;383+ const elements = root.object.get("elements") orelse return false;384+ switch (elements) {385+ .object => |obj| return obj.contains("nina"),386+ .array => |arr| {387+ for (arr.items) |el| {388+ if (el != .object) continue;389+ const ap = el.object.get("attrPath") orelse continue;390+ if (ap == .string and (std.mem.eql(u8, ap.string, "nina") or391+ std.mem.endsWith(u8, ap.string, ".nina"))) return true;392+ }393+ return false;394+ },395+ else => return false,396+ }397+}398+399+test "profileJsonHasNina matches element name, not store-path substrings" {400+ const a = std.testing.allocator;401+ // Newer nix schema: elements keyed by name. nina present.402+ const has =403+ \\{"elements":{"bat":{"storePaths":["/nix/store/h-bat-0.26.1"]},404+ \\"nina":{"attrPath":"packages.x86_64-linux.nina","storePaths":["/nix/store/a-nina-3.0.15"]}},"version":3}405+ ;406+ try std.testing.expect(profileJsonHasNina(a, has));407+408+ // No nina element, but another package's store path contains "nina" —409+ // the old substring check would false-positive here.410+ const decoy =411+ \\{"elements":{"luminance":{"storePaths":["/nix/store/x-luminance-nina-1.0"]}},"version":3}412+ ;413+ try std.testing.expect(!profileJsonHasNina(a, decoy));414+415+ // Older nix array schema.416+ const arr =417+ \\{"elements":[{"attrPath":"legacyPackages.x86_64-linux.nina","storePaths":["/nix/store/a-nina-3.0.15"]}],"version":2}418+ ;419+ try std.testing.expect(profileJsonHasNina(a, arr));420+421+ // Empty / malformed → not present.422+ try std.testing.expect(!profileJsonHasNina(a, "{\"elements\":{},\"version\":3}"));423+ try std.testing.expect(!profileJsonHasNina(a, "not json"));424+}425+362426 // The store paths that make up the running system environment. Each reference is363427 // a <hash>-<name>-<version> path, so the caller can list system packages with364428 // versions — there is no cleaner per-package list for environment.systemPackages.src/output.zig+3 −7
@@ -791,17 +791,13 @@791791 792792 pub fn goodbyeNix() void {793793 p("{s}will remove nina from nix profile\n", .{step()});794- p("{s}command: nix profile remove '.*nina.*'\n", .{step()});794+ p("{s}command: nix profile remove nina\n", .{step()});795795 }796796 797797 pub fn goodbyeNixSystemManaged() void {798798 p("{s}nina is managed by your NixOS system configuration\n", .{hdr()});799- p("{s}to remove: delete ~/nina.nix, then run sudo nixos-rebuild switch\n", .{step()});800- p("{s}or remove the nina import from /etc/nixos/configuration.nix directly\n", .{step()});801-}802-803-pub fn goodbyeNixFileFound(path: []const u8) void {804- p("{s}found {s} (NixOS config snippet from the installer)\n", .{ step(), path });799+ p("{s}remove the nina import from /etc/nixos/configuration.nix first\n", .{step()});800+ p("{s}then delete ~/nina.nix and run sudo nixos-rebuild switch\n", .{step()});805801 }806802 807803 pub fn goodbyeDone() void {