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
↓ nina-983675cc.tar.gzatlas at this save
files changed2 files
src/commands.zig+14 −6src/exec.zig+112 −38src/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 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