ee5a921daea4

home apply: standalone switch + module detection — new 'nina home' command group (multi-word, dispatched in main); exec.detectHomeManager returns standalone/module/none (home-manager on PATH, else ~/.config/home-manager/home.nix); home apply runs 'home-manager switch' (or '--flake <dir>#$USER' when flake.nix present), --dry runs 'home-manager build'; module mode redirects to 'nina apply'; none shows 'not detected'; parseHomeGenerations + homeCurrentGen for the gen line; build+tests green, help+detection smoke-verified on mac (no HM)

time
2026-06-10 01:25 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
flake + home manager support
parent
a3e1e6fb62c2
4 files src/commands.zig+143 −0src/exec.zig+125 −0src/main.zig+2 −1src/output.zig+74 −0
src/commands.zig+143 −0
@@ -839,6 +839,149 @@839839     try tryPkg(ctx, shell_pkg);840840 }841841 842+// --- Home Manager commands ---843+844+const home_help = [_][2][]const u8{845+    .{ "apply", "apply home manager configuration" },846+    .{ "apply --dry", "preview changes" },847+    .{ "back", "roll back one generation" },848+    .{ "history", "list all generations" },849+    .{ "edit", "open home.nix" },850+    .{ "check", "validate without applying" },851+    .{ "diff", "changes between generations" },852+    .{ "packages", "list managed packages" },853+};854+855+const HomeGen = struct {856+    number: u32,857+    date: []const u8,858+    time: []const u8,859+    path: []const u8,860+};861+862+// `home-manager generations` lines read:863+//   2026-06-01 11:56 : id 765 -> /nix/store/<hash>-home-manager-generation864+// Newest first, so index 0 is the current generation.865+fn parseHomeGenerations(gpa: std.mem.Allocator, out_str: []const u8) ![]HomeGen {866+    var gens: std.ArrayList(HomeGen) = .empty;867+    var lines = std.mem.splitScalar(u8, out_str, '\n');868+    while (lines.next()) |line| {869+        const t = std.mem.trim(u8, line, " \t\r");870+        if (t.len == 0) continue;871+        var it = std.mem.tokenizeAny(u8, t, " \t");872+        const date = it.next() orelse continue;873+        const time = it.next() orelse continue;874+        var number: ?u32 = null;875+        var path: []const u8 = "";876+        while (it.next()) |tok| {877+            if (std.mem.eql(u8, tok, "id")) {878+                if (it.next()) |n| number = std.fmt.parseInt(u32, n, 10) catch null;879+            } else if (std.mem.startsWith(u8, tok, "/nix/store/")) {880+                path = tok;881+            }882+        }883+        const num = number orelse continue;884+        try gens.append(gpa, .{885+            .number = num,886+            .date = try gpa.dupe(u8, date),887+            .time = try gpa.dupe(u8, time),888+            .path = try gpa.dupe(u8, path),889+        });890+    }891+    return gens.toOwnedSlice(gpa);892+}893+894+fn freeHomeGens(gpa: std.mem.Allocator, gens: []HomeGen) void {895+    for (gens) |g| {896+        gpa.free(g.date);897+        gpa.free(g.time);898+        gpa.free(g.path);899+    }900+    gpa.free(gens);901+}902+903+test "parseHomeGenerations reads id, date and store path" {904+    const a = std.testing.allocator;905+    const sample =906+        \\2026-06-01 11:56 : id 765 -> /nix/store/aaaa-home-manager-generation907+        \\2026-05-31 10:29 : id 764 -> /nix/store/bbbb-home-manager-generation908+    ;909+    const gens = try parseHomeGenerations(a, sample);910+    defer freeHomeGens(a, gens);911+    try std.testing.expectEqual(@as(usize, 2), gens.len);912+    try std.testing.expectEqual(@as(u32, 765), gens[0].number);913+    try std.testing.expectEqualStrings("2026-06-01", gens[0].date);914+    try std.testing.expectEqualStrings("11:56", gens[0].time);915+    try std.testing.expectEqualStrings("/nix/store/aaaa-home-manager-generation", gens[0].path);916+    try std.testing.expectEqual(@as(u32, 764), gens[1].number);917+}918+919+// Current (newest) home generation number, or 0 if unavailable.920+fn homeCurrentGen(ctx: Ctx) u32 {921+    const out = exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine) catch return 0;922+    defer ctx.gpa.free(out);923+    const gens = parseHomeGenerations(ctx.gpa, out) catch return 0;924+    defer freeHomeGens(ctx.gpa, gens);925+    return if (gens.len > 0) gens[0].number else 0;926+}927+928+pub fn home(ctx: Ctx) !void {929+    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help"))930+        return output.subcommandHelp("home", &home_help, null);931+932+    const mode = exec.detectHomeManager(ctx.gpa, ctx.io, ctx.environ);933+    if (mode == .none) {934+        output.homeNotInstalled();935+        return;936+    }937+938+    // apply is the only command meaningful in module mode (it redirects to939+    // `nina apply`); everything else needs the standalone home-manager CLI.940+    if (std.mem.eql(u8, ctx.sub, "apply")) return homeApply(ctx, mode);941+942+    if (mode == .module) {943+        output.homeModuleManaged();944+        return;945+    }946+947+    return output.subcommandHelp("home", &home_help, null);948+}949+950+fn homeApply(ctx: Ctx, mode: exec.HomeManagerMode) !void {951+    // NixOS/flake module: home config is rebuilt by nixos-rebuild — redirect.952+    if (mode == .module) {953+        output.homeModuleManaged();954+        return;955+    }956+    if (ctx.dry) {957+        output.homeApplyDry(ctx.machine.name);958+        const code = try exec.homeManagerBuild(ctx.io, &ctx.machine, ctx.gpa);959+        if (code != 0) return error.BuildFailed;960+        output.homeApplyDryDone();961+        return;962+    }963+964+    output.homeApplyStart(ctx.machine.name);965+    const flake_ref = exec.homeFlakeRef(ctx.gpa, ctx.io, ctx.environ);966+    defer if (flake_ref) |r| ctx.gpa.free(r);967+    if (flake_ref) |ref| {968+        output.printSubstep("home-manager switch --flake {s}", .{ref});969+    } else {970+        output.printSubstep("home-manager switch", .{});971+    }972+973+    const start = std.Io.Clock.awake.now(ctx.io);974+    const code = if (flake_ref) |ref|975+        try exec.homeManagerSwitchFlake(ctx.io, &ctx.machine, ctx.gpa, ref)976+    else977+        try exec.homeManagerSwitch(ctx.io, &ctx.machine, ctx.gpa);978+    if (code != 0) return error.BuildFailed;979+980+    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());981+    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));982+    output.homeApplyDone(homeCurrentGen(ctx), elapsed);983+}984+842985 // --- Service commands ---843986 844987 const service_help = [_][2][]const u8{

view file

src/exec.zig+125 −0
@@ -1048,6 +1048,131 @@10481048     return checks.toOwnedSlice(gpa);10491049 }10501050 1051+// --- Home Manager ---1052+//1053+// Home Manager has three usage modes. nina detects which one is in play before1054+// any `home` command so it can either drive the standalone CLI or redirect the1055+// user to `nina apply` (when HM is a NixOS/flake module activated by rebuild).1056+1057+pub const HomeManagerMode = enum {1058+    // home-manager binary on PATH — manages the user env on its own.1059+    standalone,1060+    // No binary, but a home.nix exists — HM is a NixOS/flake module rebuilt by1061+    // nixos-rebuild. `nina home apply` redirects to `nina apply`.1062+    module,1063+    none,1064+};1065+1066+fn homeConfigDir(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ?[]const u8 {1067+    const home = environ.get("HOME") orelse return null;1068+    return std.fmt.allocPrint(gpa, "{s}/.config/home-manager", .{home}) catch null;1069+}1070+1071+// Path to the standalone home.nix. Caller frees.1072+pub fn homeNixPath(gpa: std.mem.Allocator, environ: *const std.process.Environ.Map) ?[]const u8 {1073+    const dir = homeConfigDir(gpa, environ) orelse return null;1074+    defer gpa.free(dir);1075+    return std.fmt.allocPrint(gpa, "{s}/home.nix", .{dir}) catch null;1076+}1077+1078+fn pathExists(io: std.Io, path: []const u8) bool {1079+    const f = std.Io.Dir.openFile(.cwd(), io, path, .{}) catch return false;1080+    f.close(io);1081+    return true;1082+}1083+1084+// `command -v <name>` — true if the binary resolves on PATH. Silent (captured),1085+// unlike streamLocal which would inherit stdio.1086+fn commandExists(gpa: std.mem.Allocator, io: std.Io, name: []const u8) bool {1087+    var buf: [256]u8 = undefined;1088+    const cmd = std.fmt.bufPrint(&buf, "command -v {s} >/dev/null 2>&1", .{name}) catch return false;1089+    const r = std.process.run(gpa, io, .{ .argv = &.{ "sh", "-c", cmd } }) catch return false;1090+    defer gpa.free(r.stdout);1091+    defer gpa.free(r.stderr);1092+    return switch (r.term) {1093+        .exited => |code| code == 0,1094+        else => false,1095+    };1096+}1097+1098+pub fn detectHomeManager(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) HomeManagerMode {1099+    if (commandExists(gpa, io, "home-manager")) return .standalone;1100+    if (homeNixPath(gpa, environ)) |p| {1101+        defer gpa.free(p);1102+        if (pathExists(io, p)) return .module;1103+    }1104+    return .none;1105+}1106+1107+// If the home-manager config dir holds a flake.nix, returns the flake ref1108+// "<dir>#<user>" for `home-manager switch --flake`. Username comes from $USER.1109+// Caller frees. null when there's no flake.nix (channel-based home config).1110+pub fn homeFlakeRef(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) ?[]const u8 {1111+    const dir = homeConfigDir(gpa, environ) orelse return null;1112+    defer gpa.free(dir);1113+    const flake = std.fmt.allocPrint(gpa, "{s}/flake.nix", .{dir}) catch return null;1114+    defer gpa.free(flake);1115+    if (!pathExists(io, flake)) return null;1116+    const user = environ.get("USER") orelse "default";1117+    return std.fmt.allocPrint(gpa, "{s}#{s}", .{ dir, user }) catch null;1118+}1119+1120+pub fn homeManagerSwitch(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1121+    return stream(io, machine, gpa, &.{ "home-manager", "switch" });1122+}1123+1124+pub fn homeManagerSwitchFlake(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, flake_ref: []const u8) !u8 {1125+    return stream(io, machine, gpa, &.{ "home-manager", "switch", "--flake", flake_ref });1126+}1127+1128+// home-manager has no system-profile rollback; it re-activates the previous1129+// generation via `switch --rollback`.1130+pub fn homeManagerRollback(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1131+    return stream(io, machine, gpa, &.{ "home-manager", "switch", "--rollback" });1132+}1133+1134+// Build (and validate) the home config without activating it — used by `home1135+// check` and `home apply --dry`.1136+pub fn homeManagerBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1137+    return stream(io, machine, gpa, &.{ "home-manager", "build" });1138+}1139+1140+pub fn homeGenerations(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {1141+    const r = try capture(gpa, io, machine, &.{ "home-manager", "generations" });1142+    defer gpa.free(r.stderr);1143+    if (r.exit_code != 0) {1144+        gpa.free(r.stdout);1145+        return error.ProcessFailed;1146+    }1147+    return r.stdout;1148+}1149+1150+// Store paths referenced by a home generation — used to list managed packages.1151+pub fn homeGenerationReferences(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, gen_path: []const u8) ![]u8 {1152+    const r = try capture(gpa, io, machine, &.{ "nix-store", "-q", "--references", gen_path });1153+    defer gpa.free(r.stderr);1154+    if (r.exit_code != 0) {1155+        gpa.free(r.stdout);1156+        return error.ProcessFailed;1157+    }1158+    return r.stdout;1159+}1160+1161+// diff-closures between two arbitrary store paths (home generations), mirroring1162+// diffClosures' tty/piped color handling.1163+pub fn diffClosuresPaths(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, a_path: []const u8, b_path: []const u8) !u8 {1164+    if (output.colorEnabled()) {1165+        return stream(io, machine, gpa, &.{ "nix", "store", "diff-closures", a_path, b_path });1166+    }1167+    const r = try capture(gpa, io, machine, &.{ "nix", "store", "diff-closures", a_path, b_path });1168+    defer gpa.free(r.stdout);1169+    defer gpa.free(r.stderr);1170+    const cleaned = try stripAnsi(gpa, r.stdout);1171+    defer gpa.free(cleaned);1172+    output.raw(cleaned);1173+    return r.exit_code;1174+}1175+10511176 // --- Log ---10521177 10531178 pub fn readNinaLog(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, last: u32) ![]u8 {

view file

src/main.zig+2 −1
@@ -222,6 +222,7 @@222222     if (std.mem.eql(u8, cmd, "fetch")) return commands.fetch(ctx);223223     if (std.mem.eql(u8, cmd, "build")) return commands.build(ctx);224224     if (std.mem.eql(u8, cmd, "run")) return commands.runCmd(ctx);225+    if (std.mem.eql(u8, cmd, "home")) return commands.home(ctx);225226     if (std.mem.eql(u8, cmd, "goodbye")) return commands.goodbye(ctx);226227     if (std.mem.eql(u8, cmd, "hello")) return commands.hello(ctx);227228     if (std.mem.eql(u8, cmd, "help")) { output.help(); return; }@@ -287,7 +288,7 @@287288 }288289 289290 fn isMultiWord(cmd: []const u8) bool {290-    const multi = [_][]const u8{ "service", "flake", "store", "channel", "profile", "pkg", "gen", "develop", "edit" };291+    const multi = [_][]const u8{ "service", "flake", "store", "channel", "profile", "pkg", "gen", "develop", "edit", "home" };291292     for (multi) |m| {292293         if (std.mem.eql(u8, cmd, m)) return true;293294     }

view file

src/output.zig+74 −0
@@ -964,6 +964,80 @@964964     p("   {s:<12}  {s:<24}  {s}\n", .{ kind, name, desc });965965 }966966 967+// --- Home Manager ---968+969+pub fn homeNotInstalled() void {970+    p("{s}home manager not detected  {s}nothing to do{s}\n", .{ hdr(), c(DIM), c(RESET) });971+}972+973+// HM is a NixOS/flake module — rebuilt by nixos-rebuild, not the standalone CLI.974+pub fn homeModuleManaged() void {975+    p("{s}home manager is managed by your system config\n", .{hdr()});976+    p("   {s}-> run {s}nina apply{s}{s} to rebuild both together{s}\n", .{ c(CYAN), c(BOLD), c(RESET), c(CYAN), c(RESET) });977+}978+979+pub fn homeApplyStart(machine: []const u8) void {980+    p("{s}{s}applying home config  {s}\n", .{ KAO_COZY, hdr(), machine });981+}982+983+pub fn homeApplyDone(gen: u32, elapsed_ms: u64) void {984+    const secs = @as(f64, @floatFromInt(elapsed_ms)) / 1000.0;985+    if (elapsed_ms >= 1000) {986+        p("{s}generation {s}{d}{s}  {s}[{d:.1}s]{s}\n", .{ hdr(), c(BOLD), gen, c(RESET), c(DIM), secs, c(RESET) });987+    } else {988+        p("{s}generation {s}{d}{s}\n", .{ hdr(), c(BOLD), gen, c(RESET) });989+    }990+}991+992+pub fn homeApplyDry(machine: []const u8) void {993+    p("{s}home dry run  {s}\n", .{ hdr(), machine });994+}995+996+pub fn homeApplyDryDone() void {997+    p("{s}{s}ok{s}  no changes applied\n", .{ hdr(), c(GREEN), c(RESET) });998+}999+1000+pub fn homeBackStart(machine: []const u8, from: u32, to: u32) void {1001+    p("{s}rolling back home config  {s}  gen {d} -> gen {d}\n", .{ hdr(), machine, from, to });1002+}1003+1004+pub fn homeBackDone(n: u32) void {1005+    p("{s}{s}done  gen {s}{d}{s}\n", .{ KAO_GENTLE, hdr(), c(BOLD), n, c(RESET) });1006+}1007+1008+pub fn homeHistoryHeader(machine: []const u8) void {1009+    p("{s}home generations  {s}\n\n", .{ hdr(), machine });1010+}1011+1012+pub fn homeHistoryRow(number: u32, date: []const u8, time: []const u8, current: bool) void {1013+    if (current) {1014+        p("   {s}{d}{s}   {s}   {s}   {s}current{s}\n", .{ c(BOLD), number, c(RESET), date, time, c(BOLD), c(RESET) });1015+    } else {1016+        p("   {s}{d}   {s}   {s}{s}\n", .{ c(DIM), number, date, time, c(RESET) });1017+    }1018+}1019+1020+pub fn homeEditOpen(path: []const u8, editor: []const u8) void {1021+    p("{s}opening {s}\n", .{ hdr(), path });1022+    printSubstep("{s}", .{editor});1023+}1024+1025+pub fn homeCheckStart(machine: []const u8) void {1026+    p("{s}checking home config  {s}\n", .{ hdr(), machine });1027+}1028+1029+pub fn homeCheckOk() void {1030+    p("{s}{s}ok{s}\n", .{ hdr(), c(GREEN), c(RESET) });1031+}1032+1033+pub fn homeDiffHeader(a: u32, b: u32, machine: []const u8) void {1034+    p("{s}home gen {d} -> gen {d}  {s}\n\n", .{ hdr(), a, b, machine });1035+}1036+1037+pub fn homePackagesHeader(machine: []const u8) void {1038+    p("{s}home packages  {s}\n\n", .{ hdr(), machine });1039+}1040+9671041 // --- First-run wizard ---9681042 9691043 pub fn firstRunBanner() void {

view file