983675ccedb4

fix goodbye/update: detect nina element by storePath when element key is 'default' (tarball install via default output)

time
2026-06-07 17:20 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
goodbye profile element fix
parent
6f3f265f176a
2 files src/commands.zig+14 −6src/exec.zig+112 −38
src/commands.zig+14 −6
@@ -288,13 +288,18 @@288288     // element, so check the profile to avoid a no-op `upgrade` — it updates with289289     // the next nixos-rebuild instead.290290     if (std.mem.startsWith(u8, install_path, "/nix/store/")) {291-        if (!exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine)) {291+        const nina_element = exec.profileNinaElement(ctx.gpa, ctx.io, &ctx.machine);292+        defer {293+            if (nina_element) |n| ctx.gpa.free(n);294+        }295+        if (nina_element == null) {292296             output.updateSelfNixSystemManaged();293297             return;294298         }299+        const element = nina_element.?;295300         output.updateSelfNixManaged(remote_ver);296301         if (ctx.cfg.confirm and !output.confirm("")) return;297-        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", "nina" }) catch {302+        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", element }) catch {298303             output.updateSelfNixFailed();299304             return;300305         };@@ -372,13 +377,16 @@372377     // separately flag the system module. We never auto-remove the system one:373378     // editing /etc/nixos needs sudo, and deleting ~/nina.nix while it's still374379     // imported breaks the next rebuild.375-    const in_profile = exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine);380+    const nina_element = exec.profileNinaElement(ctx.gpa, ctx.io, &ctx.machine);381+    defer {382+        if (nina_element) |n| ctx.gpa.free(n);383+    }376384     const system_managed = ninaNixExists(ctx);377385 378-    if (in_profile) {386+    if (nina_element) |element| {379387         output.goodbyeNix();380388         if (ctx.cfg.confirm and !output.confirm("")) return;381-        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", "nina" }) catch {389+        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", element }) catch {382390             output.goodbyeFailed("nix profile remove failed");383391             return;384392         };@@ -390,7 +398,7 @@390398 391399     // System module present, or nothing in the profile and not a plain binary392400     // (so it must be system-managed) — instruct rather than touch /etc/nixos.393-    if (system_managed or !in_profile) {401+    if (system_managed or nina_element == null) {394402         output.goodbyeNixSystemManaged();395403     }396404 

view file

src/exec.zig+112 −38
@@ -359,68 +359,142 @@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;362+// Returns the profile element name to pass to `nix profile remove/upgrade`363+// (e.g. "nina" or "default"), or null if nina is not found. Caller owns the364+// returned slice. A NixOS system-package install also resolves into /nix/store365+// so the store path alone can't distinguish it from a profile install — the366+// profile list can. A missing/empty profile (or no nix) returns null.367+pub fn profileNinaElement(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ?[]const u8 {368+    const js = profileListJson(gpa, io, machine) catch return null;370369     defer gpa.free(js);371-    return profileJsonHasNina(gpa, js);370+    return profileJsonNinaElement(gpa, js);372371 }373372 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;373+pub fn profileHasNina(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) bool {374+    const name = profileNinaElement(gpa, io, machine) orelse return false;375+    gpa.free(name);376+    return true;377+}378+379+// True when a nix store path belongs to the nina package. Store paths have the380+// form /nix/store/<32-char-hash>-<pname>-<version>[/<subpath>]. We check that381+// the 33 chars after /nix/store/ are the hash+dash, and the pname that follows382+// is exactly "nina" (followed by "-" or end-of-component).383+fn storePathIsNina(path: []const u8) bool {384+    const prefix = "/nix/store/";385+    if (!std.mem.startsWith(u8, path, prefix)) return false;386+    const rel = path[prefix.len..];387+    // The store entry name ends at the first "/" (if any sub-path follows).388+    const entry = rel[0 .. std.mem.indexOf(u8, rel, "/") orelse rel.len];389+    if (entry.len < 34) return false; // 32-char hash + dash + at least one char390+    const pname_ver = entry[33..]; // skip hash + dash391+    return std.mem.startsWith(u8, pname_ver, "nina-") or std.mem.eql(u8, pname_ver, "nina");392+}393+394+// Pure parse of `nix profile list --json` for a nina element, returning its395+// element name (the key to pass to `nix profile remove/upgrade`) or null.396+// Newer nix (2.18+) keys elements by pname ("nina"). Older nix uses an array397+// with an "attrPath" field. Tarball installs via the `default` output may use398+// the key "default" instead of "nina"; we detect those via storePaths.399+fn profileJsonNinaElement(gpa: std.mem.Allocator, js: []const u8) ?[]const u8 {400+    var parsed = std.json.parseFromSlice(std.json.Value, gpa, js, .{}) catch return null;380401     defer parsed.deinit();381402     const root = parsed.value;382-    if (root != .object) return false;383-    const elements = root.object.get("elements") orelse return false;403+    if (root != .object) return null;404+    const elements = root.object.get("elements") orelse return null;384405     switch (elements) {385-        .object => |obj| return obj.contains("nina"),406+        .object => |obj| {407+            // Fast path: exact "nina" key (nix 2.18+ with nixpkgs#nina install).408+            if (obj.contains("nina")) return gpa.dupe(u8, "nina") catch null;409+            // Fallback: scan storePaths. Handles tarball installs where the default410+            // output is selected and nix names the element "default" (older nix) or411+            // some other key derived from the attrPath rather than the pname.412+            var it = obj.iterator();413+            while (it.next()) |kv| {414+                const val = kv.value_ptr.*;415+                if (val != .object) continue;416+                const sps = val.object.get("storePaths") orelse continue;417+                if (sps != .array) continue;418+                for (sps.array.items) |sp| {419+                    if (sp == .string and storePathIsNina(sp.string))420+                        return gpa.dupe(u8, kv.key_ptr.*) catch null;421+                }422+            }423+            return null;424+        },386425         .array => |arr| {426+            // Older nix array format. Element name is the last component of attrPath.387427             for (arr.items) |el| {388428                 if (el != .object) continue;389429                 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;430+                if (ap != .string) continue;431+                if (std.mem.eql(u8, ap.string, "nina") or432+                    std.mem.endsWith(u8, ap.string, ".nina"))433+                    return gpa.dupe(u8, "nina") catch null;434+                // Tarball default-output install: attrPath ends in ".default" but435+                // storePaths point to nina. Return "default" as the element name.436+                const sps = el.object.get("storePaths") orelse continue;437+                if (sps != .array) continue;438+                for (sps.array.items) |sp| {439+                    if (sp != .string) continue;440+                    if (!storePathIsNina(sp.string)) continue;441+                    const dot = std.mem.lastIndexOf(u8, ap.string, ".") orelse 0;442+                    const el_name = if (dot > 0) ap.string[dot + 1 ..] else ap.string;443+                    return gpa.dupe(u8, el_name) catch null;444+                }392445             }393-            return false;446+            return null;394447         },395-        else => return false,448+        else => return null,396449     }397450 }398451 399-test "profileJsonHasNina matches element name, not store-path substrings" {452+test "profileJsonNinaElement finds element name from profile JSON" {400453     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}454+455+    // Newer nix: elements keyed by pname. "nina" key present.456+    const named =457+        \\{"elements":{"bat":{"storePaths":["/nix/store/h0000000000000000000000000000000-bat-0.26.1"]},458+        \\"nina":{"attrPath":"packages.x86_64-linux.nina","storePaths":["/nix/store/a0000000000000000000000000000000-nina-3.0.15"]}},"version":3}405459     ;406-    try std.testing.expect(profileJsonHasNina(a, has));460+    const r1 = profileJsonNinaElement(a, named);461+    defer if (r1) |n| a.free(n);462+    try std.testing.expectEqualStrings("nina", r1.?);407463 408-    // No nina element, but another package's store path contains "nina" —409-    // the old substring check would false-positive here.464+    // Tarball install via default output: element keyed as "default", storePath is nina.465+    const default_key =466+        \\{"elements":{"default":{"attrPath":"packages.x86_64-linux.default","storePaths":["/nix/store/w0000000000000000000000000000000-nina-3.0.16"]}},"version":3}467+    ;468+    const r2 = profileJsonNinaElement(a, default_key);469+    defer if (r2) |n| a.free(n);470+    try std.testing.expectEqualStrings("default", r2.?);471+472+    // Decoy: storePath of another package contains "nina" as a substring but is not nina.410473     const decoy =411-        \\{"elements":{"luminance":{"storePaths":["/nix/store/x-luminance-nina-1.0"]}},"version":3}474+        \\{"elements":{"luminance":{"storePaths":["/nix/store/x0000000000000000000000000000000-luminance-nina-1.0"]}},"version":3}412475     ;413-    try std.testing.expect(!profileJsonHasNina(a, decoy));476+    const r3 = profileJsonNinaElement(a, decoy);477+    try std.testing.expectEqual(@as(?[]const u8, null), r3);414478 415-    // Older nix array schema.479+    // Older nix array schema, attrPath ending in ".nina".416480     const arr =417-        \\{"elements":[{"attrPath":"legacyPackages.x86_64-linux.nina","storePaths":["/nix/store/a-nina-3.0.15"]}],"version":2}481+        \\{"elements":[{"attrPath":"legacyPackages.x86_64-linux.nina","storePaths":["/nix/store/a0000000000000000000000000000000-nina-3.0.15"]}],"version":2}418482     ;419-    try std.testing.expect(profileJsonHasNina(a, arr));483+    const r4 = profileJsonNinaElement(a, arr);484+    defer if (r4) |n| a.free(n);485+    try std.testing.expectEqualStrings("nina", r4.?);420486 421-    // Empty / malformed → not present.422-    try std.testing.expect(!profileJsonHasNina(a, "{\"elements\":{},\"version\":3}"));423-    try std.testing.expect(!profileJsonHasNina(a, "not json"));487+    // Older nix array schema, tarball default-output install.488+    const arr_default =489+        \\{"elements":[{"attrPath":"packages.x86_64-linux.default","storePaths":["/nix/store/w0000000000000000000000000000000-nina-3.0.16"]}],"version":2}490+    ;491+    const r5 = profileJsonNinaElement(a, arr_default);492+    defer if (r5) |n| a.free(n);493+    try std.testing.expectEqualStrings("default", r5.?);494+495+    // Empty / malformed → null.496+    try std.testing.expectEqual(@as(?[]const u8, null), profileJsonNinaElement(a, "{\"elements\":{},\"version\":3}"));497+    try std.testing.expectEqual(@as(?[]const u8, null), profileJsonNinaElement(a, "not json"));424498 }425499 426500 // The store paths that make up the running system environment. Each reference is

view file