commands.zig

61.0 KB · raw

1// commands.zig — all command handlers2// Orchestrates exec/api/output. Zero direct stdout writes.3const std = @import("std");4const builtin = @import("builtin");5const types = @import("types.zig");6const errors = @import("errors.zig");7const config = @import("config.zig");8const output = @import("output.zig");9const exec = @import("exec.zig");10const api = @import("api.zig");11const search_tui = @import("search.zig");12const version = @import("version.zig");1314const UPDATE_URL = "https://kepr.uk/api/nina/releases/latest";1516pub const Ctx = struct {17    gpa: std.mem.Allocator,18    io: std.Io,19    environ: *const std.process.Environ.Map,20    machine: types.Machine,21    cfg: types.NinaConfig,22    args: []const []const u8,   // positional args after subcommand23    sub: []const u8,            // subcommand (e.g. "list" in "service list")24    dry: bool,25    check: bool,26    no_apply: bool,27    all: bool,28    last: u32,29    json: bool,30    user: bool,            // --user: operate on user (vs system) services31};3233// A system is flake-managed when the config says so OR a flake.nix is present in34// the config dir. Detection means a fresh channel user who points nina at a flake35// dir gets the right behavior without editing config first.36fn flakeSystem(ctx: Ctx) bool {37    return ctx.cfg.flake or exec.configHasFlake(ctx.gpa, ctx.io, &ctx.machine);38}3940pub fn apply(ctx: Ctx) !void {41    if (ctx.dry) {42        output.applyDry(ctx.machine.name);43        const code = try exec.nixosRebuildDryActivate(ctx.io, &ctx.machine, ctx.gpa);44        if (code != 0) return error.BuildFailed;45        output.applyDryDone();46        return;47    }48    if (ctx.check) {49        return check(ctx);50    }5152    output.applyStart(ctx.machine.name);53    const start = std.Io.Clock.awake.now(ctx.io);5455    const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {56        exec.markApplyFailed(ctx.io, ctx.environ);57        output.applyFailed("");58        return e;59    };6061    if (code != 0) {62        exec.markApplyFailed(ctx.io, ctx.environ);63        output.applyFailed("");64        return error.BuildFailed;65    }6667    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());68    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));69    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;70    const after_failure = exec.takeApplyFailed(ctx.io, ctx.environ);71    output.applyDone(gen, elapsed, after_failure);72    logAction(ctx, "apply", gen);73}7475pub fn check(ctx: Ctx) !void {76    output.checkStart(ctx.machine.name);77    const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);78    if (code != 0) {79        output.checkFailed("");80        return error.BuildFailed;81    }82    output.checkOk();83}8485pub fn back(ctx: Ctx) !void {86    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);87    defer ctx.gpa.free(gens);8889    var current: u32 = 0;90    for (gens) |g| if (g.current) { current = g.number; };9192    // Newest generation older than current. If there is none, we're already at the93    // oldest — stop cleanly instead of letting rollback fail with 'build failed'.94    var prev: u32 = 0;95    var has_prev = false;96    for (gens) |g| {97        if (g.number < current and (!has_prev or g.number > prev)) {98            prev = g.number;99            has_prev = true;100        }101    }102    if (!has_prev) {103        output.alreadyOldest();104        return;105    }106107    output.backStart(ctx.machine.name, current, prev);108    if (ctx.cfg.confirm and !output.confirm("")) return;109110    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);111    if (code != 0) return error.BuildFailed;112    output.backDone(prev);113}114115pub fn go(ctx: Ctx) !void {116    if (ctx.args.len == 0) return error.Internal;117    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;118    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);119120    output.goStart(ctx.machine.name, current, target);121    if (ctx.cfg.confirm and !output.confirm("")) return;122123    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);124    if (code != 0) return error.ProcessFailed;125    output.generationDone(target);126}127128pub fn history(ctx: Ctx) !void {129    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);130    defer ctx.gpa.free(gens);131    output.historyHeader(ctx.machine.name);132    for (gens) |g| output.historyRow(g);133}134135const gen_help = [_][2][]const u8{136    .{ "list", "list generations" },137    .{ "current", "show current generation" },138    .{ "delete <n>", "delete generation n" },139    .{ "delete old", "delete old generations" },140};141142pub fn genCmd(ctx: Ctx) !void {143    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("gen", &gen_help, null);144    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);145    if (std.mem.eql(u8, ctx.sub, "current")) {146        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);147        output.genCurrent(n);148        return;149    }150    if (std.mem.eql(u8, ctx.sub, "delete")) {151        if (ctx.args.len == 0) return error.Internal;152        if (std.mem.eql(u8, ctx.args[0], "old")) {153            if (ctx.cfg.confirm and !output.confirm("")) return;154            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);155        } else {156            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;157            if (ctx.cfg.confirm and !output.confirm("")) return;158            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);159        }160        output.done();161        return;162    }163    return error.Internal;164}165166pub fn clean(ctx: Ctx) !void {167    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);168    defer ctx.gpa.free(gens);169    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");170    const total: u32 = @intCast(gens.len);171    // `clean all` keeps only the current generation; otherwise keep the configured172    // count. With nothing older than the keep limit, there's nothing to collect.173    const keep: u32 = if (all) 1 else ctx.cfg.generations;174    if (total <= keep) {175        output.cleanNothing(ctx.machine.name, total);176        return;177    }178    output.cleanStart(ctx.machine.name, keep, total);179    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;180    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all, ctx.cfg.generations);181    output.cleanDone();182}183184pub fn statusCmd(ctx: Ctx) !void {185    output.statusHeader();186    // --all renders every configured machine; remotes route through the same SSH187    // path as exec.capture. An unreachable machine is shown in red, not fatal.188    if (ctx.all and ctx.cfg.machines.len > 0) {189        for (ctx.cfg.machines) |m| statusRowFor(ctx, &m);190    } else {191        statusRowFor(ctx, &ctx.machine);192    }193    homeStatusLine(ctx);194}195196// When standalone Home Manager is present, show its current generation as a dim197// line indented under the machine rows. Gated on standalone mode (the only mode198// where we can read a generation), so a machine without HM is unaffected.199fn homeStatusLine(ctx: Ctx) void {200    if (exec.detectHomeManager(ctx.gpa, ctx.io, ctx.environ) != .standalone) return;201    const out = exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine) catch return;202    defer ctx.gpa.free(out);203    const gens = parseHomeGenerations(ctx.gpa, out) catch return;204    defer freeHomeGens(ctx.gpa, gens);205    if (gens.len == 0) return;206    output.statusHomeRow(gens[0].number, gens[0].date);207}208209fn statusRowFor(ctx: Ctx, machine: *const types.Machine) void {210    // A failure to read the current generation means we couldn't reach or query211    // the machine — mark it unreachable rather than reporting a bogus gen 0.212    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, machine) catch {213        output.statusRow(machine.name, 0, "", "", false);214        return;215    };216    const st = exec.systemStatus(ctx.gpa, ctx.io, machine);217    defer ctx.gpa.free(st.size);218    defer ctx.gpa.free(st.age);219    output.statusRow(machine.name, gen, st.age, st.size, true);220}221222pub fn diff(ctx: Ctx) !void {223    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);224    defer ctx.gpa.free(gens);225    var current: u32 = 0;226    var prev: u32 = 0;227    for (gens) |g| {228        if (g.current) current = g.number;229        if (!g.current and g.number > prev and g.number < current) prev = g.number;230    }231    if (ctx.args.len >= 2) {232        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;233        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;234        output.diffHeader(a, b, ctx.machine.name);235        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;236    } else {237        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);238        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;239    }240}241242pub fn mood(ctx: Ctx) !void {243    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;244    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);245    defer ctx.gpa.free(st.size);246    defer ctx.gpa.free(st.age);247    const state = exec.systemRunningState(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");248    defer ctx.gpa.free(state);249    output.mood(ctx.machine.name, state, gen, st.age, st.size);250}251252pub fn doctor(ctx: Ctx) !void {253    output.doctorHeader(ctx.machine.name);254    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);255    defer ctx.gpa.free(checks);256    var warnings: u32 = 0;257    var failures: u32 = 0;258    for (checks) |ch| {259        output.doctorRow(ch);260        if (ch.status == .warn) warnings += 1;261        if (ch.status == .fail) failures += 1;262    }263264    // Home Manager row only when present — absent on a machine without HM, so265    // doctor's table is unchanged there.266    const hm_mode = exec.detectHomeManager(ctx.gpa, ctx.io, ctx.environ);267    if (hm_mode != .none) {268        const note = homeDoctorNote(ctx, hm_mode);269        defer ctx.gpa.free(note);270        output.doctorRow(.{ .name = "home manager", .status = .ok, .note = note });271    }272273    output.doctorSummary(warnings, failures);274}275276// "standalone  gen 765" / "module" — the note shown in doctor's home manager row.277// Caller owns the returned slice.278fn homeDoctorNote(ctx: Ctx, mode: exec.HomeManagerMode) []const u8 {279    const label = switch (mode) {280        .standalone => "standalone",281        .module => "module",282        .none => "",283    };284    if (mode == .standalone) {285        const gen = homeCurrentGen(ctx);286        if (gen != 0)287            return std.fmt.allocPrint(ctx.gpa, "{s}  gen {d}", .{ label, gen }) catch288                (ctx.gpa.dupe(u8, label) catch "");289    }290    return ctx.gpa.dupe(u8, label) catch "";291}292293// nina update — kepr self-update for the nina binary itself.294pub fn update(ctx: Ctx) !void {295    output.updateSelfStart();296297    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {298        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));299        return error.Internal;300    }301302    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");303    const source_url = env_url orelse UPDATE_URL;304305    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {306        error.NetworkError, error.HttpError => {307            output.updateSelfNetworkError();308            return;309        },310        else => return e,311    };312    defer ctx.gpa.free(body);313314    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {315        output.printWarning("could not parse release metadata");316        return;317    };318    defer parsed.deinit();319320    const root = parsed.value;321    if (root != .object) { output.printWarning("malformed release metadata"); return; }322323    const remote_ver = blk: {324        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };325        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };326    };327328    if (std.mem.eql(u8, remote_ver, version.VERSION)) {329        output.updateSelfCurrent(version.VERSION);330        return;331    }332333    output.updateSelfAvailable(version.VERSION, remote_ver);334    if (ctx.check) return;335336    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;337    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {338        errors.error_info.setMessage("could not determine install path", .{});339        return error.Internal;340    };341342    // When running from the nix store, defer to nix profile upgrade so nix343    // manages the transition (the store is read-only; binary replacement fails).344    // A NixOS system install also resolves into the store but isn't a profile345    // element, so check the profile to avoid a no-op `upgrade` — it updates with346    // the next nixos-rebuild instead.347    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {348        const nina_element = exec.profileNinaElement(ctx.gpa, ctx.io, &ctx.machine);349        defer {350            if (nina_element) |n| ctx.gpa.free(n);351        }352        if (nina_element == null) {353            output.updateSelfNotInProfile();354            return;355        }356        const element = nina_element.?;357        output.updateSelfNixManaged(remote_ver);358        if (ctx.cfg.confirm and !output.confirm("")) return;359        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", element }) catch {360            output.updateSelfNixFailed();361            return;362        };363        if (ec != 0) output.updateSelfNixFailed();364        return;365    }366367    const plat_info = blk: {368        const pv = root.object.get("platforms") orelse break :blk null;369        if (pv != .object) break :blk null;370        break :blk pv.object.get(version.PLATFORM);371    };372    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }373374    const dl_url = blk: {375        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };376        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };377    };378379    const raw_cksum = blk: {380        if (plat_info.?.object.get("blake3")) |v|381            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };382        if (plat_info.?.object.get("checksum")) |v|383            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };384        output.printWarning("missing checksum in release metadata");385        return;386    };387    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;388389    if (ctx.cfg.confirm and !output.confirm("")) return;390391    output.updateSelfDownloading(remote_ver, version.PLATFORM);392    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {393        output.printWarning("download failed — check your connection and try again");394        return;395    };396    defer ctx.gpa.free(tmp_path);397398    output.updateSelfVerifying();399    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {400        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },401        else => return e,402    };403404    output.updateSelfDone(version.VERSION, remote_ver);405}406407// nina goodbye — clean uninstall. Three install paths:408// - nix profile (binary in nix store with a profile element) → nix profile remove409// - koh build or source build (plain binary on PATH) → delete the file410pub fn goodbye(ctx: Ctx) !void {411    output.goodbyeStart();412413    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;414    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {415        errors.error_info.setMessage("could not determine install path", .{});416        return error.Internal;417    };418419    // A binary outside the nix store is a koh build or source build — delete it.420    if (!std.mem.startsWith(u8, install_path, "/nix/store/")) {421        output.goodbyeFile(install_path);422        if (ctx.cfg.confirm and !output.confirm("")) return;423        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {424            errors.error_info.setMessage("could not remove {s}", .{install_path});425            return e;426        };427        output.goodbyeDone();428        return;429    }430431    // Binary is in the nix store — find and remove the profile element.432    const nina_element = exec.profileNinaElement(ctx.gpa, ctx.io, &ctx.machine);433    defer {434        if (nina_element) |n| ctx.gpa.free(n);435    }436437    if (nina_element == null) {438        errors.error_info.setMessage("nina not found in nix profile — was it installed via 'nix profile add'?", .{});439        return error.Internal;440    }441442    output.goodbyeNix();443    if (ctx.cfg.confirm and !output.confirm("")) return;444    const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", nina_element.? }) catch {445        output.goodbyeFailed("nix profile remove failed");446        return;447    };448    if (ec != 0) {449        output.goodbyeFailed("nix profile remove failed");450        return;451    }452    output.goodbyeDone();453}454455fn channelUpdateCmd(ctx: Ctx) !void {456    output.updateStart(ctx.machine.name);457    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);458    output.updateDone();459}460461pub fn upgrade(ctx: Ctx) !void {462    output.upgradeStart(ctx.machine.name);463    output.printSubstep("updating channels", .{});464    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};465    output.printSubstep("rebuilding", .{});466    const start = std.Io.Clock.awake.now(ctx.io);467    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);468    if (code != 0) {469        output.applyFailed("");470        return error.BuildFailed;471    }472    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());473    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));474    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;475    output.applyDone(gen, elapsed, false);476}477478pub fn log(ctx: Ctx) !void {479    output.logHeader(ctx.machine.name);480    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {481        // No log yet — that's fine482        return;483    };484    defer ctx.gpa.free(content);485    var lines = std.mem.splitScalar(u8, content, '\n');486    while (lines.next()) |line| {487        const t = std.mem.trim(u8, line, " \t\r");488        if (t.len == 0) continue;489        if (t[0] == '{') {490            renderJsonLogLine(ctx.gpa, t);491        } else {492            // legacy tab-separated format493            var parts = std.mem.tokenizeScalar(u8, t, '\t');494            const date = parts.next() orelse continue;495            const time = parts.next() orelse "";496            const action = parts.next() orelse "";497            const detail = parts.next() orelse "";498            output.logRow(date, time, action, detail);499        }500    }501}502503// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.504// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.505fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {506    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {507        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop508        return;509    };510    defer parsed.deinit();511    if (parsed.value != .object) return;512    const o = parsed.value.object;513514    const ts = jsonStr(o, "ts");515    const date = if (ts.len >= 10) ts[0..10] else ts;516    const time = if (ts.len >= 16) ts[11..16] else "";517    const action = jsonStr(o, "command");518    const outcome = jsonStr(o, "outcome");519520    var buf: [160]u8 = undefined;521    var detail: []const u8 = outcome;522    if (o.get("gen_after")) |g| {523        if (g == .integer) {524            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;525        }526    }527    output.logRow(date, time, action, detail);528}529530fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {531    if (o.get(key)) |v| {532        if (v == .string) return v.string;533    }534    return "";535}536537pub fn edit(ctx: Ctx) !void {538    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");539    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";540    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });541    defer ctx.gpa.free(path);542    output.editOpen(path, ctx.cfg.editor);543    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);544}545546pub fn fmt(ctx: Ctx) !void {547    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});548    defer ctx.gpa.free(path);549    if (ctx.check) {550        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);551        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();552        return;553    }554    output.fmtStart(path);555    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);556    output.fmtDone();557}558559pub fn repl(ctx: Ctx) !void {560    output.replStart();561    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);562}563564pub fn develop(ctx: Ctx) !void {565    if (std.mem.eql(u8, ctx.sub, "show")) {566        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);567        return;568    }569    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))570        ctx.args[0]571    else572        null;573    output.developStart(&.{});574    _ = try exec.nixDevelopPs1(ctx.io, &ctx.machine, ctx.gpa, run_cmd, ctx.environ);575    output.developDone();576}577578pub fn info(ctx: Ctx) !void {579    output.infoHeader(ctx.machine.name);580    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");581    defer ctx.gpa.free(nixos_ver);582    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));583584    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");585    defer ctx.gpa.free(kernel);586    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));587588    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");589    defer ctx.gpa.free(uptime);590    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));591}592593pub fn boot(ctx: Ctx) !void {594    output.bootHeader(ctx.machine.name);595    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};596    defer ctx.gpa.free(entries);597    for (entries, 0..) |entry, i| output.bootEntry(entry, i);598}599600pub fn searchCmd(ctx: Ctx) !void {601    const query = if (ctx.args.len > 0) ctx.args[0] else "";602    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);603    const selected = try search_tui.run(ctx.gpa, ctx.io, query, .packages);604    if (selected) |npkg| try selectAndInstall(ctx, npkg);605}606607pub fn optionCmd(ctx: Ctx) !void {608    const query = if (ctx.args.len > 0) ctx.args[0] else "";609    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);610    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);611}612613pub fn install(ctx: Ctx) !void {614    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;615616    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);617618    // Search for the package to get the attr619    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {620        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});621        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});622        return error.PackageNotFound;623    };624625    if (pkgs.len == 0) {626        // No matches — likely a typo. Suggest the closest real package and offer it.627        if (closestPackage(ctx, pkg_name)) |sugg| {628            output.didYouMean(pkg_name, sugg.pname);629            if (output.confirm("")) try selectAndInstall(ctx, sugg);630            return;631        }632        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});633        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});634        return error.PackageNotFound;635    }636637    // Prefer an exact name match over the first search result — `install ripgrep`638    // must pick "ripgrep", not the alphabetically-first match like "repgrep".639    var found = pkgs[0];640    for (pkgs) |p| {641        if (std.mem.eql(u8, p.pname, pkg_name)) {642            found = p;643            break;644        }645    }646    try selectAndInstall(ctx, found);647}648649// Find the real package whose name is closest to a not-found query (typo help).650// Typos usually keep the leading characters, so gather candidates with a prefix651// search and rank by edit distance; return the best only if it's typo-close.652fn closestPackage(ctx: Ctx, query: []const u8) ?types.NixPackage {653    if (query.len < 2) return null;654    var candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..@min(query.len, 4)]) catch &.{};655    if (candidates.len == 0 and query.len > 2) {656        candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..2]) catch &.{};657    }658    if (candidates.len == 0) return null;659660    var best: ?types.NixPackage = null;661    var best_dist: usize = std.math.maxInt(usize);662    for (candidates) |p| {663        const d = editDistance(query, p.pname);664        if (d > 0 and d < best_dist) {665            best_dist = d;666            best = p;667        }668    }669    // Only suggest a genuinely close name, scaled to query length.670    const threshold = @max(@as(usize, 2), query.len / 3);671    return if (best_dist <= threshold) best else null;672}673674// Case-insensitive Levenshtein distance. Names longer than the row buffer are675// treated as far away (never suggested).676fn editDistance(a: []const u8, b: []const u8) usize {677    const n = b.len;678    if (a.len == 0) return n;679    if (n == 0) return a.len;680    if (n + 1 > 128) return std.math.maxInt(usize);681    var prev: [128]usize = undefined;682    var curr: [128]usize = undefined;683    var j: usize = 0;684    while (j <= n) : (j += 1) prev[j] = j;685    var i: usize = 1;686    while (i <= a.len) : (i += 1) {687        curr[0] = i;688        j = 1;689        while (j <= n) : (j += 1) {690            const cost: usize = if (std.ascii.toLower(a[i - 1]) == std.ascii.toLower(b[j - 1])) 0 else 1;691            curr[j] = @min(prev[j] + 1, @min(curr[j - 1] + 1, prev[j - 1] + cost));692        }693        @memcpy(prev[0 .. n + 1], curr[0 .. n + 1]);694    }695    return prev[n];696}697698test "editDistance is case-insensitive Levenshtein" {699    try std.testing.expectEqual(@as(usize, 1), editDistance("firefux", "firefox"));700    try std.testing.expectEqual(@as(usize, 0), editDistance("git", "GIT"));701    try std.testing.expectEqual(@as(usize, 2), editDistance("fierfox", "firefox"));702    try std.testing.expectEqual(@as(usize, 7), editDistance("", "firefox"));703}704705// Prompt for the install method (profile / system / try) and dispatch. Shared by706// `nina install <pkg>` and the search widget's Enter-to-select — the letter keys707// are read here, after the search widget's raw mode is gone, so they don't708// collide with typing a query.709fn selectAndInstall(ctx: Ctx, npkg: types.NixPackage) !void {710    output.installSelectPrompt(npkg.pname, npkg.version);711712    var choice_buf: [8]u8 = undefined;713    var choice_len: usize = 0;714    while (choice_len < choice_buf.len) {715        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;716        if (n == 0) break;717        if (choice_buf[choice_len] == '\n') break;718        choice_len += 1;719    }720    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");721722    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {723        try profileInstallPkg(ctx, npkg);724    } else if (choice[0] == 's' or choice[0] == 'S') {725        try systemInstallPkg(ctx, npkg);726    } else if (choice[0] == 't' or choice[0] == 'T') {727        try tryPkg(ctx, npkg);728    }729}730731fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {732    output.installStart(npkg.pname, .profile);733    output.printSubstep("nix profile add nixpkgs#{s}", .{npkg.attr});734    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile add nixpkgs#{s}", .{npkg.attr}));735    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);736    if (code != 0) return error.ProcessFailed;737    output.installDone(npkg.pname);738}739740fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {741    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});742    defer ctx.gpa.free(config_path);743    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;744    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);745    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);746    if (ctx.cfg.confirm) {747        if (output.confirm("apply now? ")) {748            try apply(ctx);749        }750    }751}752753fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {754    output.tryStart(npkg.pname);755    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});756    output.tryExitHint();757    _ = try exec.nixShellPs1(ctx.io, &ctx.machine, ctx.gpa, npkg.attr, ctx.environ);758    output.tryDone();759}760761pub fn remove(ctx: Ctx) !void {762    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;763    output.removeStart(pkg_name);764    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);765    if (code != 0) return error.ProcessFailed;766    output.done();767}768769pub fn list(ctx: Ctx) !void {770    output.listHeader(ctx.machine.name);771772    // Profile packages: parse `nix profile list --json` into clean name+version773    // rows instead of dumping nix's verbose multi-line "Name:/Flake attribute:"774    // blocks.775    if (exec.profileListJson(ctx.gpa, ctx.io, &ctx.machine) catch null) |json| {776        defer ctx.gpa.free(json);777        output.listSection("profile");778        renderProfileJson(ctx.gpa, json);779    }780781    // System packages: the references of /run/current-system/sw, each a782    // <hash>-<name>-<version> store path.783    if (exec.systemPackagePaths(ctx.gpa, ctx.io, &ctx.machine) catch null) |paths| {784        defer ctx.gpa.free(paths);785        output.blankLine();786        output.listSection("system");787        var lines = std.mem.splitScalar(u8, paths, '\n');788        while (lines.next()) |line| {789            const t = std.mem.trim(u8, line, " \t\r");790            if (t.len == 0) continue;791            const row = pkgFromStorePath(t);792            // Skip support paths with no parseable version (man pages, wrappers).793            if (row.version.len == 0) continue;794            output.listPkg(row.name, row.version);795        }796    }797}798799const PkgRow = struct { name: []const u8, version: []const u8 };800801// Split a nix store path basename (<hash>-<name>-<version>) into name + version.802// Version begins at the first '-' followed by a digit, which reliably lands on803// the name/version boundary since the 32-char hash and package names start with804// non-digits.805fn pkgFromStorePath(path: []const u8) PkgRow {806    const base = std.fs.path.basename(path);807    const after_hash = if (base.len > 33 and base[32] == '-') base[33..] else base;808    var i: usize = 0;809    while (i + 1 < after_hash.len) : (i += 1) {810        if (after_hash[i] == '-' and std.ascii.isDigit(after_hash[i + 1])) {811            return .{ .name = after_hash[0..i], .version = stripOutputSuffix(after_hash[i + 1 ..]) };812        }813    }814    return .{ .name = after_hash, .version = "" };815}816817// Multi-output derivations append the output name to the store path (e.g.818// curl-8.20.0-bin, less-692-man). Strip that suffix so the version column reads819// as just the version.820fn stripOutputSuffix(ver: []const u8) []const u8 {821    const suffixes = [_][]const u8{ "-bin", "-man", "-dev", "-doc", "-lib", "-info", "-out", "-static", "-devdoc" };822    for (suffixes) |s| {823        if (std.mem.endsWith(u8, ver, s)) return ver[0 .. ver.len - s.len];824    }825    return ver;826}827828test "pkgFromStorePath splits name and version at first dash-digit" {829    const a = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-ripgrep-15.1.0");830    try std.testing.expectEqualStrings("ripgrep", a.name);831    try std.testing.expectEqualStrings("15.1.0", a.version);832833    const b = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-helix-25.01");834    try std.testing.expectEqualStrings("helix", b.name);835    try std.testing.expectEqualStrings("25.01", b.version);836837    // multi-output store paths drop their output-name suffix838    const c = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-curl-8.20.0-bin");839    try std.testing.expectEqualStrings("curl", c.name);840    try std.testing.expectEqualStrings("8.20.0", c.version);841}842843fn firstStorePath(el: std.json.Value) ?[]const u8 {844    if (el != .object) return null;845    const sp = el.object.get("storePaths") orelse return null;846    if (sp != .array or sp.array.items.len == 0) return null;847    const first = sp.array.items[0];848    return if (first == .string) first.string else null;849}850851// `nix profile list --json` has two shapes across nix versions: newer keys each852// element by name (object), older lists them positionally (array). Handle both.853fn renderProfileJson(gpa: std.mem.Allocator, json: []const u8) void {854    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json, .{}) catch return;855    defer parsed.deinit();856    if (parsed.value != .object) return;857    const elements = parsed.value.object.get("elements") orelse return;858    switch (elements) {859        .object => |obj| {860            var it = obj.iterator();861            while (it.next()) |entry| {862                const ver = if (firstStorePath(entry.value_ptr.*)) |p| pkgFromStorePath(p).version else "";863                output.listPkg(entry.key_ptr.*, ver);864            }865        },866        .array => |arr| {867            for (arr.items) |el| {868                const path = firstStorePath(el) orelse continue;869                const row = pkgFromStorePath(path);870                output.listPkg(row.name, row.version);871            }872        },873        else => {},874    }875}876877pub fn tryCmd(ctx: Ctx) !void {878    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;879    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };880    try tryPkg(ctx, shell_pkg);881}882883// --- Home Manager commands ---884885const home_help = [_][2][]const u8{886    .{ "apply", "apply home manager configuration" },887    .{ "apply --dry", "preview changes" },888    .{ "back", "roll back one generation" },889    .{ "history", "list all generations" },890    .{ "edit", "open home.nix" },891    .{ "check", "validate without applying" },892    .{ "diff", "changes between generations" },893    .{ "packages", "list managed packages" },894};895896const HomeGen = struct {897    number: u32,898    date: []const u8,899    time: []const u8,900    path: []const u8,901};902903// `home-manager generations` lines read:904//   2026-06-01 11:56 : id 765 -> /nix/store/<hash>-home-manager-generation905// Newest first, so index 0 is the current generation.906fn parseHomeGenerations(gpa: std.mem.Allocator, out_str: []const u8) ![]HomeGen {907    var gens: std.ArrayList(HomeGen) = .empty;908    var lines = std.mem.splitScalar(u8, out_str, '\n');909    while (lines.next()) |line| {910        const t = std.mem.trim(u8, line, " \t\r");911        if (t.len == 0) continue;912        var it = std.mem.tokenizeAny(u8, t, " \t");913        const date = it.next() orelse continue;914        const time = it.next() orelse continue;915        var number: ?u32 = null;916        var path: []const u8 = "";917        while (it.next()) |tok| {918            if (std.mem.eql(u8, tok, "id")) {919                if (it.next()) |n| number = std.fmt.parseInt(u32, n, 10) catch null;920            } else if (std.mem.startsWith(u8, tok, "/nix/store/")) {921                path = tok;922            }923        }924        const num = number orelse continue;925        try gens.append(gpa, .{926            .number = num,927            .date = try gpa.dupe(u8, date),928            .time = try gpa.dupe(u8, time),929            .path = try gpa.dupe(u8, path),930        });931    }932    return gens.toOwnedSlice(gpa);933}934935fn freeHomeGens(gpa: std.mem.Allocator, gens: []HomeGen) void {936    for (gens) |g| {937        gpa.free(g.date);938        gpa.free(g.time);939        gpa.free(g.path);940    }941    gpa.free(gens);942}943944test "parseHomeGenerations reads id, date and store path" {945    const a = std.testing.allocator;946    const sample =947        \\2026-06-01 11:56 : id 765 -> /nix/store/aaaa-home-manager-generation948        \\2026-05-31 10:29 : id 764 -> /nix/store/bbbb-home-manager-generation949    ;950    const gens = try parseHomeGenerations(a, sample);951    defer freeHomeGens(a, gens);952    try std.testing.expectEqual(@as(usize, 2), gens.len);953    try std.testing.expectEqual(@as(u32, 765), gens[0].number);954    try std.testing.expectEqualStrings("2026-06-01", gens[0].date);955    try std.testing.expectEqualStrings("11:56", gens[0].time);956    try std.testing.expectEqualStrings("/nix/store/aaaa-home-manager-generation", gens[0].path);957    try std.testing.expectEqual(@as(u32, 764), gens[1].number);958}959960// Current (newest) home generation number, or 0 if unavailable.961fn homeCurrentGen(ctx: Ctx) u32 {962    const out = exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine) catch return 0;963    defer ctx.gpa.free(out);964    const gens = parseHomeGenerations(ctx.gpa, out) catch return 0;965    defer freeHomeGens(ctx.gpa, gens);966    return if (gens.len > 0) gens[0].number else 0;967}968969pub fn home(ctx: Ctx) !void {970    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help"))971        return output.subcommandHelp("home", &home_help, null);972973    const mode = exec.detectHomeManager(ctx.gpa, ctx.io, ctx.environ);974    if (mode == .none) {975        output.homeNotInstalled();976        return;977    }978979    // apply is the only command meaningful in module mode (it redirects to980    // `nina apply`); everything else needs the standalone home-manager CLI.981    if (std.mem.eql(u8, ctx.sub, "apply")) return homeApply(ctx, mode);982    // edit just opens the local home.nix — fine in module mode too.983    if (std.mem.eql(u8, ctx.sub, "edit")) return homeEdit(ctx);984985    if (mode == .module) {986        output.homeModuleManaged();987        return;988    }989990    if (std.mem.eql(u8, ctx.sub, "back")) return homeBack(ctx);991    if (std.mem.eql(u8, ctx.sub, "history")) return homeHistory(ctx);992    if (std.mem.eql(u8, ctx.sub, "check")) return homeCheck(ctx);993    if (std.mem.eql(u8, ctx.sub, "diff")) return homeDiff(ctx);994    if (std.mem.eql(u8, ctx.sub, "packages")) return homePackages(ctx);995996    return output.subcommandHelp("home", &home_help, null);997}998999fn homePackages(ctx: Ctx) !void {1000    const out = try exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine);1001    defer ctx.gpa.free(out);1002    const gens = try parseHomeGenerations(ctx.gpa, out);1003    defer freeHomeGens(ctx.gpa, gens);10041005    output.homePackagesHeader(ctx.machine.name);1006    if (gens.len == 0 or gens[0].path.len == 0) return;10071008    // The current generation's references are its managed packages (plus a few1009    // home-manager scaffolding paths and versionless support outputs we skip).1010    const refs = exec.homeGenerationReferences(ctx.gpa, ctx.io, &ctx.machine, gens[0].path) catch return;1011    defer ctx.gpa.free(refs);1012    var lines = std.mem.splitScalar(u8, refs, '\n');1013    while (lines.next()) |line| {1014        const t = std.mem.trim(u8, line, " \t\r");1015        if (t.len == 0) continue;1016        const row = pkgFromStorePath(t);1017        if (row.version.len == 0) continue;1018        if (std.mem.indexOf(u8, row.name, "home-manager") != null) continue;1019        output.listPkg(row.name, row.version);1020    }1021}10221023fn homeDiff(ctx: Ctx) !void {1024    const out = try exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine);1025    defer ctx.gpa.free(out);1026    const gens = try parseHomeGenerations(ctx.gpa, out);1027    defer freeHomeGens(ctx.gpa, gens);10281029    // Need two generations and both their store paths to diff their closures.1030    if (gens.len < 2 or gens[0].path.len == 0 or gens[1].path.len == 0) {1031        output.homeNothingToDiff();1032        return;1033    }1034    const newer = gens[0];1035    const older = gens[1];1036    output.homeDiffHeader(older.number, newer.number, ctx.machine.name);1037    if (try exec.diffClosuresPaths(ctx.io, &ctx.machine, ctx.gpa, older.path, newer.path) != 0)1038        return error.ProcessFailed;1039}10401041fn homeCheck(ctx: Ctx) !void {1042    output.homeCheckStart(ctx.machine.name);1043    const code = try exec.homeManagerBuild(ctx.io, &ctx.machine, ctx.gpa);1044    if (code != 0) {1045        output.checkFailed("");1046        return error.BuildFailed;1047    }1048    output.homeCheckOk();1049}10501051fn homeEdit(ctx: Ctx) !void {1052    // Standalone home config lives at ~/.config/home-manager/home.nix; the same1053    // path is what detection keys on for module mode, so it resolves for both.1054    const path = exec.homeNixPath(ctx.gpa, ctx.environ) orelse {1055        errors.error_info.setMessage("could not resolve ~/.config/home-manager/home.nix", .{});1056        return error.ConfigNotFound;1057    };1058    defer ctx.gpa.free(path);1059    output.homeEditOpen(path, ctx.cfg.editor);1060    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);1061}10621063fn homeHistory(ctx: Ctx) !void {1064    const out = try exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine);1065    defer ctx.gpa.free(out);1066    const gens = try parseHomeGenerations(ctx.gpa, out);1067    defer freeHomeGens(ctx.gpa, gens);1068    output.homeHistoryHeader(ctx.machine.name);1069    // Newest-first from home-manager; the first row is the current generation.1070    for (gens, 0..) |g, i| output.homeHistoryRow(g.number, g.date, g.time, i == 0);1071}10721073fn homeBack(ctx: Ctx) !void {1074    const out = try exec.homeGenerations(ctx.gpa, ctx.io, &ctx.machine);1075    defer ctx.gpa.free(out);1076    const gens = try parseHomeGenerations(ctx.gpa, out);1077    defer freeHomeGens(ctx.gpa, gens);10781079    // Newest-first: [0] is current, [1] is the generation to roll back to.1080    if (gens.len < 2) {1081        output.alreadyOldest();1082        return;1083    }1084    const current = gens[0].number;1085    const prev = gens[1].number;10861087    output.homeBackStart(ctx.machine.name, current, prev);1088    if (ctx.cfg.confirm and !output.confirm("")) return;1089    const code = try exec.homeManagerRollback(ctx.io, &ctx.machine, ctx.gpa);1090    if (code != 0) return error.BuildFailed;1091    output.homeBackDone(prev);1092}10931094fn homeApply(ctx: Ctx, mode: exec.HomeManagerMode) !void {1095    // NixOS/flake module: home config is rebuilt by nixos-rebuild — redirect.1096    if (mode == .module) {1097        output.homeModuleManaged();1098        return;1099    }1100    if (ctx.dry) {1101        output.homeApplyDry(ctx.machine.name);1102        const code = try exec.homeManagerBuild(ctx.io, &ctx.machine, ctx.gpa);1103        if (code != 0) return error.BuildFailed;1104        output.homeApplyDryDone();1105        return;1106    }11071108    output.homeApplyStart(ctx.machine.name);1109    const flake_ref = exec.homeFlakeRef(ctx.gpa, ctx.io, ctx.environ);1110    defer if (flake_ref) |r| ctx.gpa.free(r);1111    if (flake_ref) |ref| {1112        output.printSubstep("home-manager switch --flake {s}", .{ref});1113    } else {1114        output.printSubstep("home-manager switch", .{});1115    }11161117    const start = std.Io.Clock.awake.now(ctx.io);1118    const code = if (flake_ref) |ref|1119        try exec.homeManagerSwitchFlake(ctx.io, &ctx.machine, ctx.gpa, ref)1120    else1121        try exec.homeManagerSwitch(ctx.io, &ctx.machine, ctx.gpa);1122    if (code != 0) return error.BuildFailed;11231124    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());1125    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));1126    output.homeApplyDone(homeCurrentGen(ctx), elapsed);1127}11281129// --- Service commands ---11301131const service_help = [_][2][]const u8{1132    .{ "list", "list running services" },1133    .{ "logs <svc>", "show service logs" },1134    .{ "status <svc>", "service status" },1135    .{ "start <svc>", "start a service" },1136    .{ "stop <svc>", "stop a service" },1137    .{ "restart <svc>", "restart a service" },1138    .{ "enable <svc>", "enable at boot" },1139    .{ "disable <svc>", "disable at boot" },1140};11411142pub fn service(ctx: Ctx) !void {1143    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("service", &service_help, "add --user to act on user services");1144    if (std.mem.eql(u8, ctx.sub, "list")) {1145        output.serviceListHeader(ctx.machine.name);1146        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine, ctx.user) catch &.{};1147        defer ctx.gpa.free(svcs);1148        for (svcs) |s| output.serviceRow(s);1149        return;1150    }1151    if (std.mem.eql(u8, ctx.sub, "logs")) {1152        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1153        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");1154        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);1155        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow, ctx.user);1156        return;1157    }1158    if (std.mem.eql(u8, ctx.sub, "status")) {1159        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1160        output.serviceVerb("status", name, ctx.machine.name);1161        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name, ctx.user);1162        return;1163    }1164    // start/stop/restart/enable/disable1165    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1166    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {1167        if (!output.confirm("")) return;1168    }1169    output.serviceVerb(ctx.sub, name, ctx.machine.name);1170    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name, ctx.user);1171    output.serviceDone();1172}11731174// --- Flake commands ---11751176const flake_help = [_][2][]const u8{1177    .{ "init", "create a new flake.nix" },1178    .{ "update", "update flake inputs" },1179    .{ "update <in>", "update a specific input" },1180    .{ "check", "validate the flake" },1181    .{ "show", "inspect flake outputs" },1182    .{ "lock", "regenerate flake.lock" },1183    .{ "clone <url>", "clone a flake repository" },1184};11851186pub fn flake(ctx: Ctx) !void {1187    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("flake", &flake_help, null);1188    if (std.mem.eql(u8, ctx.sub, "update")) {1189        output.flakeUpdateStart();1190        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;1191        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);1192        output.flakeLockWritten();1193        return;1194    }1195    if (std.mem.eql(u8, ctx.sub, "check")) {1196        output.flakeCheckStart();1197        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);1198        if (code == 0) output.flakeValid() else output.checkFailed("");1199        return;1200    }1201    if (std.mem.eql(u8, ctx.sub, "show")) {1202        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;1203        if (json_out) |j| {1204            defer ctx.gpa.free(j);1205            output.flakeShowHeader("flake outputs");1206            // Parse and display the flake outputs1207            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;1208            defer parsed.deinit();1209            if (parsed.value == .object) {1210                var it = parsed.value.object.iterator();1211                while (it.next()) |entry| {1212                    if (entry.value_ptr.* == .object) {1213                        var inner = entry.value_ptr.object.iterator();1214                        while (inner.next()) |e2| {1215                            const desc = if (e2.value_ptr.* == .object)1216                                if (e2.value_ptr.object.get("description")) |d|1217                                    if (d == .string) d.string else ""1218                                else ""1219                            else "";1220                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);1221                        }1222                    }1223                }1224            }1225        }1226        return;1227    }1228    if (std.mem.eql(u8, ctx.sub, "init")) {1229        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);1230        return;1231    }1232    if (std.mem.eql(u8, ctx.sub, "lock")) {1233        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);1234        return;1235    }1236    if (std.mem.eql(u8, ctx.sub, "clone")) {1237        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1238        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);1239        return;1240    }1241}12421243// --- Store commands ---12441245const store_help = [_][2][]const u8{1246    .{ "info", "store size and counts" },1247    .{ "path <attr>", "store path of a package" },1248    .{ "gc", "collect garbage" },1249    .{ "verify", "verify store integrity" },1250    .{ "repair", "repair corrupted paths" },1251};12521253pub fn store(ctx: Ctx) !void {1254    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("store", &store_help, null);1255    if (std.mem.eql(u8, ctx.sub, "gc")) {1256        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);1257        return;1258    }1259    if (std.mem.eql(u8, ctx.sub, "verify")) {1260        output.storeVerifyStart();1261        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);1262        return;1263    }1264    if (std.mem.eql(u8, ctx.sub, "repair")) {1265        if (ctx.cfg.confirm and !output.confirm("")) return;1266        output.storeRepairStart();1267        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);1268        return;1269    }1270    if (std.mem.eql(u8, ctx.sub, "info")) {1271        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);1272        output.storeInfo(si);1273        return;1274    }1275    if (std.mem.eql(u8, ctx.sub, "path")) {1276        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1277        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);1278        defer ctx.gpa.free(path);1279        output.storePath(path);1280        return;1281    }1282    // default: info1283    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);1284    output.storeInfo(si);1285}12861287// --- Channel commands ---12881289const channel_help = [_][2][]const u8{1290    .{ "list", "list channels" },1291    .{ "add <url> [name]", "add a channel" },1292    .{ "remove <name>", "remove a channel" },1293    .{ "update", "update all channels" },1294};12951296pub fn channel(ctx: Ctx) !void {1297    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("channel", &channel_help, null);1298    // On a flake-managed system, nix-channel operations generally don't apply.1299    if (flakeSystem(ctx)) output.flakeChannelNote();1300    if (std.mem.eql(u8, ctx.sub, "list")) {1301        output.channelListHeader(ctx.machine.name);1302        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);1303        defer ctx.gpa.free(chans);1304        for (chans) |ch| output.channelRow(ch);1305        return;1306    }1307    if (std.mem.eql(u8, ctx.sub, "add")) {1308        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1309        const name = if (ctx.args.len > 1) ctx.args[1] else "";1310        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);1311        output.channelDone();1312        return;1313    }1314    if (std.mem.eql(u8, ctx.sub, "remove")) {1315        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1316        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);1317        output.channelDone();1318        return;1319    }1320    if (std.mem.eql(u8, ctx.sub, "update")) {1321        return channelUpdateCmd(ctx);1322    }1323}13241325// --- Profile commands ---13261327pub fn profile(ctx: Ctx) !void {1328    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {1329        output.profileListHeader();1330        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;1331        if (out) |o| {1332            defer ctx.gpa.free(o);1333            var lines = std.mem.splitScalar(u8, o, '\n');1334            while (lines.next()) |line| {1335                const t = std.mem.trim(u8, line, " \t\r");1336                if (t.len == 0) continue;1337                output.profileRow(t, "", "");1338            }1339        }1340        return;1341    }1342    if (std.mem.eql(u8, ctx.sub, "install")) {1343        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1344        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);1345        output.done();1346        return;1347    }1348    if (std.mem.eql(u8, ctx.sub, "remove")) {1349        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1350        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);1351        output.done();1352        return;1353    }1354    if (std.mem.eql(u8, ctx.sub, "upgrade")) {1355        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);1356        output.done();1357        return;1358    }1359}13601361// --- Pkg commands ---13621363const pkg_help = [_][2][]const u8{1364    .{ "why <pkg>", "what pulled it in" },1365    .{ "deps <pkg>", "direct dependencies" },1366    .{ "size <pkg>", "closure size" },1367    .{ "path <pkg>", "store path" },1368    .{ "closure <pkg>", "full closure" },1369};13701371pub fn pkg(ctx: Ctx) !void {1372    if (ctx.sub.len == 0 or std.mem.eql(u8, ctx.sub, "help")) return output.subcommandHelp("pkg", &pkg_help, null);1373    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1374    if (std.mem.eql(u8, ctx.sub, "why")) {1375        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);1376        return;1377    }1378    if (std.mem.eql(u8, ctx.sub, "deps")) {1379        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);1380        return;1381    }1382    if (std.mem.eql(u8, ctx.sub, "size")) {1383        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;1384        if (s) |sz| {1385            defer ctx.gpa.free(sz);1386            output.pkgResult("size", sz);1387        }1388        return;1389    }1390    if (std.mem.eql(u8, ctx.sub, "path")) {1391        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);1392        defer ctx.gpa.free(p);1393        output.pkgResult("path", p);1394        return;1395    }1396    if (std.mem.eql(u8, ctx.sub, "closure")) {1397        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);1398        return;1399    }1400}14011402// Top-level alias for `nina pkg why <pkg>` — surfaces what pulled a package in1403// without the user needing to know about the `pkg` subgroup.1404pub fn why(ctx: Ctx) !void {1405    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1406    _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);1407}14081409// --- Pin / Unpin ---14101411pub fn pin(ctx: Ctx) !void {1412    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1413    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;1414    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;1415    output.pinDone(input, commit);1416}14171418pub fn unpin(ctx: Ctx) !void {1419    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1420    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);1421    output.unpinDone(input);1422}14231424// --- Hash / Fetch ---14251426pub fn hash(ctx: Ctx) !void {1427    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1428    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);1429    defer ctx.gpa.free(h);1430    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));1431}14321433pub fn fetch(ctx: Ctx) !void {1434    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1435    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);1436    defer ctx.gpa.free(h);1437    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);1438}14391440// --- Build / Run ---14411442pub fn build(ctx: Ctx) !void {1443    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;1444    output.buildStart(attr orelse "default");1445    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);1446    if (code != 0) return error.BuildFailed;1447    output.buildDone();1448}14491450pub fn runCmd(ctx: Ctx) !void {1451    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1452    output.runStart(pkg_arg);1453    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);1454}14551456// --- Hello ---14571458pub fn hello(ctx: Ctx) !void {1459    output.hello(ctx.cfg.machines);1460}14611462// Runs when ~/.nina.conf is absent on first invocation (interactive TTY only).1463// Prompts for machine name, config path, and local flag, then writes the file.1464pub fn firstRunWizard(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1465    output.firstRunBanner();1466    output.firstRunNoConfig();14671468    var name_buf: [256]u8 = undefined;1469    var path_buf: [256]u8 = undefined;1470    var local_buf: [256]u8 = undefined;14711472    const machine_name = output.firstRunPrompt("machine name", "my-machine", &name_buf);14731474    // Flake-based systems don't have a traditional /etc/nixos/configuration.nix —1475    // their config lives wherever flake.nix is. Ask, then prompt for the right1476    // path and record `flake = true` so channel commands can warn appropriately.1477    const use_flakes = output.firstRunManageType();1478    const config_path = if (use_flakes)1479        output.firstRunPrompt("flake directory", "~/nixos-config", &path_buf)1480    else1481        output.firstRunPrompt("config path", "/etc/nixos", &path_buf);1482    const local_str = output.firstRunPrompt("local", "true", &local_buf);14831484    const conf_path = try config.configPath(gpa, environ);1485    defer gpa.free(conf_path);1486    if (std.fs.path.dirname(conf_path)) |dir| exec.ensureDir(gpa, io, dir);14871488    // `flake` is a top-level config field, so it must precede the [machine] block.1489    const flake_line = if (use_flakes) "flake = true\n" else "";1490    var content_buf: [1024]u8 = undefined;1491    const content = try std.fmt.bufPrint(1492        &content_buf,1493        "{s}[machine]\nname = {s}\nconfig = {s}\nlocal = {s}\n",1494        .{ flake_line, machine_name, config_path, local_str },1495    );14961497    const file = std.Io.Dir.createFile(.cwd(), io, conf_path, .{}) catch {1498        errors.error_info.setMessage("could not write {s}", .{conf_path});1499        return error.Internal;1500    };1501    defer file.close(io);1502    file.writePositionalAll(io, content, 0) catch {1503        errors.error_info.setMessage("could not write {s}", .{conf_path});1504        return error.Internal;1505    };15061507    output.firstRunWritten(conf_path);1508}15091510pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1511    // Create the config first if it doesn't exist. This makes `nina setup`1512    // a complete onboarding command — not just a nix-features check. Any legacy1513    // ~/.nina.conf is migrated to the XDG path before we decide to run the wizard.1514    config.migrateLegacyConfig(gpa, io, environ);1515    if (config.configPath(gpa, environ)) |conf_path| {1516        defer gpa.free(conf_path);1517        if (std.Io.Dir.openFile(.cwd(), io, conf_path, .{})) |f| {1518            f.close(io);1519        } else |_| {1520            try firstRunWizard(gpa, io, environ);1521            errors.error_info.reset();1522        }1523    } else |_| {}15241525    output.setupCheck();1526    const status = exec.checkNixFeatures(gpa, io, environ);1527    output.setupFeatures(status.nix_command, status.flakes);15281529    if (status.allEnabled()) {1530        output.setupAllSet();1531        api.ensureRegistryPinned(gpa, io, environ);1532        return;1533    }15341535    // On NixOS the features belong in configuration.nix (the declarative,1536    // permanent home), not ~/.config/nix/nix.conf. Offer to add the one line;1537    // the user runs the rebuild. We don't pin the registry here because the1538    // features aren't live until that rebuild lands.1539    if (exec.isNixOS(io)) {1540        const conf_path = "/etc/nixos/configuration.nix";1541        if (exec.nixosConfigEnablesFeatures(gpa, io, conf_path)) {1542            output.setupNixosNeedsRebuild(conf_path);1543            return;1544        }1545        output.setupNixosOffer(exec.NIXOS_FEATURES_LINE, conf_path);1546        if (output.confirmDefaultNo("add it now?")) {1547            if (exec.appendFeaturesToNixosConfig(gpa, io, environ, conf_path)) |_| {1548                output.setupNixosWrote(conf_path);1549            } else |_| {1550                output.setupNixosManual(exec.NIXOS_FEATURES_LINE, conf_path);1551            }1552        } else {1553            output.setupNixosManual(exec.NIXOS_FEATURES_LINE, conf_path);1554        }1555        return;1556    }15571558    // Non-NixOS: a user-level nix.conf takes effect immediately, no sudo needed.1559    output.setupEnabling();1560    const path = try exec.enableNixFeatures(gpa, io, environ);1561    defer gpa.free(path);15621563    output.setupDone(path);1564    api.ensureRegistryPinned(gpa, io, environ);1565}15661567// --- Log helper ---15681569fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {1570    var ts_buf: [32]u8 = undefined;1571    const ts = nowIso(ctx.io, &ts_buf);1572    var buf: [256]u8 = undefined;1573    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{1574        ts, ctx.machine.name, action, gen,1575    }) catch return;1576    exec.appendNinaLog(ctx.io, ctx.environ, entry);1577}15781579// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);1580// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.1581fn nowIso(io: std.Io, buf: []u8) []const u8 {1582    const fallback = "1970-01-01T00:00:00Z";1583    const ns = std.Io.Clock.real.now(io).toNanoseconds();1584    if (ns <= 0) return fallback;1585    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));1586    const es = std.time.epoch.EpochSeconds{ .secs = secs };1587    const ed = es.getEpochDay();1588    const yd = ed.calculateYearDay();1589    const md = yd.calculateMonthDay();1590    const ds = es.getDaySeconds();1591    return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{1592        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,1593        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),1594    }) catch return fallback;1595}