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
↓ nina-ee5a921d.tar.gzatlas at this save
files changed4 files
src/commands.zig+143 −0src/exec.zig+125 −0src/main.zig+2 −1src/output.zig+74 −0src/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{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 {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 }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 {