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
3 files src/commands.zig+50 −32src/exec.zig+64 −0src/output.zig+3 −7
src/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 {

view file

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.

view file

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 {

view file