commands.zig

39.3 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};3132pub fn apply(ctx: Ctx) !void {33    if (ctx.dry) {34        output.applyDry(ctx.machine.name);35        const code = try exec.nixosRebuildDryActivate(ctx.io, &ctx.machine, ctx.gpa);36        if (code != 0) return error.BuildFailed;37        output.applyDryDone();38        return;39    }40    if (ctx.check) {41        return check(ctx);42    }4344    output.applyStart(ctx.machine.name);45    const start = std.Io.Clock.awake.now(ctx.io);4647    const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {48        exec.markApplyFailed(ctx.io, ctx.environ);49        output.applyFailed("");50        return e;51    };5253    if (code != 0) {54        exec.markApplyFailed(ctx.io, ctx.environ);55        output.applyFailed("");56        return error.BuildFailed;57    }5859    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());60    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));61    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;62    const after_failure = exec.takeApplyFailed(ctx.io, ctx.environ);63    output.applyDone(gen, elapsed, after_failure);64    logAction(ctx, "apply", gen);65}6667pub fn check(ctx: Ctx) !void {68    output.checkStart(ctx.machine.name);69    const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);70    if (code != 0) {71        output.checkFailed("");72        return error.BuildFailed;73    }74    output.checkOk();75}7677pub fn back(ctx: Ctx) !void {78    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);79    defer ctx.gpa.free(gens);8081    var current: u32 = 0;82    for (gens) |g| if (g.current) { current = g.number; };8384    // Newest generation older than current. If there is none, we're already at the85    // oldest — stop cleanly instead of letting rollback fail with 'build failed'.86    var prev: u32 = 0;87    var has_prev = false;88    for (gens) |g| {89        if (g.number < current and (!has_prev or g.number > prev)) {90            prev = g.number;91            has_prev = true;92        }93    }94    if (!has_prev) {95        output.alreadyOldest();96        return;97    }9899    output.backStart(ctx.machine.name, current, prev);100    if (ctx.cfg.confirm and !output.confirm("")) return;101102    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);103    if (code != 0) return error.BuildFailed;104    output.backDone(prev);105}106107pub fn go(ctx: Ctx) !void {108    if (ctx.args.len == 0) return error.Internal;109    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;110    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);111112    output.goStart(ctx.machine.name, current, target);113    if (ctx.cfg.confirm and !output.confirm("")) return;114115    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);116    if (code != 0) return error.ProcessFailed;117    output.generationDone(target);118}119120pub fn history(ctx: Ctx) !void {121    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);122    defer ctx.gpa.free(gens);123    output.historyHeader(ctx.machine.name);124    for (gens) |g| output.historyRow(g);125}126127pub fn genCmd(ctx: Ctx) !void {128    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);129    if (std.mem.eql(u8, ctx.sub, "current")) {130        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);131        output.genCurrent(n);132        return;133    }134    if (std.mem.eql(u8, ctx.sub, "delete")) {135        if (ctx.args.len == 0) return error.Internal;136        if (std.mem.eql(u8, ctx.args[0], "old")) {137            if (ctx.cfg.confirm and !output.confirm("")) return;138            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);139        } else {140            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;141            if (ctx.cfg.confirm and !output.confirm("")) return;142            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);143        }144        output.done();145        return;146    }147    return error.Internal;148}149150pub fn clean(ctx: Ctx) !void {151    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);152    defer ctx.gpa.free(gens);153    output.cleanStart(ctx.machine.name, ctx.cfg.generations, @intCast(gens.len));154    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;155    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");156    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all, ctx.cfg.generations);157    output.cleanDone("?");158}159160pub fn statusCmd(ctx: Ctx) !void {161    output.statusHeader();162    // --all renders every configured machine; remotes route through the same SSH163    // path as exec.capture. An unreachable machine is shown in red, not fatal.164    if (ctx.all and ctx.cfg.machines.len > 0) {165        for (ctx.cfg.machines) |m| statusRowFor(ctx, &m);166        return;167    }168    statusRowFor(ctx, &ctx.machine);169}170171fn statusRowFor(ctx: Ctx, machine: *const types.Machine) void {172    // A failure to read the current generation means we couldn't reach or query173    // the machine — mark it unreachable rather than reporting a bogus gen 0.174    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, machine) catch {175        output.statusRow(machine.name, 0, "", "", false);176        return;177    };178    const st = exec.systemStatus(ctx.gpa, ctx.io, machine);179    defer ctx.gpa.free(st.size);180    defer ctx.gpa.free(st.age);181    output.statusRow(machine.name, gen, st.age, st.size, true);182}183184pub fn diff(ctx: Ctx) !void {185    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);186    defer ctx.gpa.free(gens);187    var current: u32 = 0;188    var prev: u32 = 0;189    for (gens) |g| {190        if (g.current) current = g.number;191        if (!g.current and g.number > prev and g.number < current) prev = g.number;192    }193    if (ctx.args.len >= 2) {194        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;195        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;196        output.diffHeader(a, b, ctx.machine.name);197        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;198    } else {199        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);200        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;201    }202}203204pub fn mood(ctx: Ctx) !void {205    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;206    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);207    defer ctx.gpa.free(st.size);208    defer ctx.gpa.free(st.age);209    const state = exec.systemRunningState(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");210    defer ctx.gpa.free(state);211    output.mood(ctx.machine.name, state, gen, st.age, st.size);212}213214pub fn doctor(ctx: Ctx) !void {215    output.doctorHeader(ctx.machine.name);216    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);217    defer ctx.gpa.free(checks);218    var warnings: u32 = 0;219    var failures: u32 = 0;220    for (checks) |ch| {221        output.doctorRow(ch);222        if (ch.status == .warn) warnings += 1;223        if (ch.status == .fail) failures += 1;224    }225    output.doctorSummary(warnings, failures);226}227228// nina update — kepr self-update for the nina binary itself.229pub fn update(ctx: Ctx) !void {230    output.updateSelfStart();231232    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {233        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));234        return error.Internal;235    }236237    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");238    const source_url = env_url orelse UPDATE_URL;239240    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {241        error.NetworkError, error.HttpError => {242            output.updateSelfNetworkError();243            return;244        },245        else => return e,246    };247    defer ctx.gpa.free(body);248249    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {250        output.printWarning("could not parse release metadata");251        return;252    };253    defer parsed.deinit();254255    const root = parsed.value;256    if (root != .object) { output.printWarning("malformed release metadata"); return; }257258    const remote_ver = blk: {259        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };260        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };261    };262263    if (std.mem.eql(u8, remote_ver, version.VERSION)) {264        output.updateSelfCurrent(version.VERSION);265        return;266    }267268    output.updateSelfAvailable(version.VERSION, remote_ver);269    if (ctx.check) return;270271    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;272    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {273        errors.error_info.setMessage("could not determine install path", .{});274        return error.Internal;275    };276277    // When running from the nix store, defer to nix profile upgrade so nix278    // manages the transition (the store is read-only; binary replacement fails).279    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {280        output.updateSelfNixManaged(remote_ver);281        if (ctx.cfg.confirm and !output.confirm("")) return;282        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {283            output.updateSelfNixFailed();284            return;285        };286        if (ec != 0) output.updateSelfNixSystemManaged();287        return;288    }289290    const plat_info = blk: {291        const pv = root.object.get("platforms") orelse break :blk null;292        if (pv != .object) break :blk null;293        break :blk pv.object.get(version.PLATFORM);294    };295    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }296297    const dl_url = blk: {298        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };299        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };300    };301302    const raw_cksum = blk: {303        if (plat_info.?.object.get("blake3")) |v|304            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };305        if (plat_info.?.object.get("checksum")) |v|306            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };307        output.printWarning("missing checksum in release metadata");308        return;309    };310    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;311312    if (ctx.cfg.confirm and !output.confirm("")) return;313314    output.updateSelfDownloading(remote_ver, version.PLATFORM);315    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {316        output.printWarning("download failed — check your connection and try again");317        return;318    };319    defer ctx.gpa.free(tmp_path);320321    output.updateSelfVerifying();322    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {323        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },324        else => return e,325    };326327    output.updateSelfDone(version.VERSION, remote_ver);328}329330// nina goodbye — clean uninstall from nix profile or binary location.331pub fn goodbye(ctx: Ctx) !void {332    output.goodbyeStart();333334    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;335    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {336        errors.error_info.setMessage("could not determine install path", .{});337        return error.Internal;338    };339340    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {341        output.goodbyeNix();342        if (ctx.cfg.confirm and !output.confirm("")) return;343        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {344            output.goodbyeFailed("nix profile remove failed");345            return;346        };347        if (ec != 0) {348            output.goodbyeNixSystemManaged();349            return;350        }351    } else {352        output.goodbyeFile(install_path);353        if (ctx.cfg.confirm and !output.confirm("")) return;354        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {355            errors.error_info.setMessage("could not remove {s}", .{install_path});356            return e;357        };358    }359360    // Offer to remove ~/nina.nix if it exists (generated by the installer).361    if (ctx.environ.get("HOME")) |home| {362        const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;363        if (nina_nix) |path| {364            defer ctx.gpa.free(path);365            const exists = blk: {366                const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;367                ctx.gpa.free(bytes);368                break :blk true;369            };370            if (exists) {371                output.goodbyeNixFileFound(path);372                if (!ctx.cfg.confirm or output.confirm("")) {373                    std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {374                        output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");375                    };376                }377            }378        }379    }380381    output.goodbyeDone();382}383384fn channelUpdateCmd(ctx: Ctx) !void {385    output.updateStart(ctx.machine.name);386    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);387    output.updateDone();388}389390pub fn upgrade(ctx: Ctx) !void {391    output.upgradeStart(ctx.machine.name);392    output.printSubstep("updating channels", .{});393    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};394    output.printSubstep("rebuilding", .{});395    const start = std.Io.Clock.awake.now(ctx.io);396    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);397    if (code != 0) {398        output.applyFailed("");399        return error.BuildFailed;400    }401    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());402    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));403    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;404    output.applyDone(gen, elapsed, false);405}406407pub fn log(ctx: Ctx) !void {408    output.logHeader(ctx.machine.name);409    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {410        // No log yet — that's fine411        return;412    };413    defer ctx.gpa.free(content);414    var lines = std.mem.splitScalar(u8, content, '\n');415    while (lines.next()) |line| {416        const t = std.mem.trim(u8, line, " \t\r");417        if (t.len == 0) continue;418        if (t[0] == '{') {419            renderJsonLogLine(ctx.gpa, t);420        } else {421            // legacy tab-separated format422            var parts = std.mem.tokenizeScalar(u8, t, '\t');423            const date = parts.next() orelse continue;424            const time = parts.next() orelse "";425            const action = parts.next() orelse "";426            const detail = parts.next() orelse "";427            output.logRow(date, time, action, detail);428        }429    }430}431432// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.433// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.434fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {435    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {436        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop437        return;438    };439    defer parsed.deinit();440    if (parsed.value != .object) return;441    const o = parsed.value.object;442443    const ts = jsonStr(o, "ts");444    const date = if (ts.len >= 10) ts[0..10] else ts;445    const time = if (ts.len >= 16) ts[11..16] else "";446    const action = jsonStr(o, "command");447    const outcome = jsonStr(o, "outcome");448449    var buf: [160]u8 = undefined;450    var detail: []const u8 = outcome;451    if (o.get("gen_after")) |g| {452        if (g == .integer) {453            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;454        }455    }456    output.logRow(date, time, action, detail);457}458459fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {460    if (o.get(key)) |v| {461        if (v == .string) return v.string;462    }463    return "";464}465466pub fn edit(ctx: Ctx) !void {467    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");468    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";469    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });470    defer ctx.gpa.free(path);471    output.editOpen(path, ctx.cfg.editor);472    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);473}474475pub fn fmt(ctx: Ctx) !void {476    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});477    defer ctx.gpa.free(path);478    if (ctx.check) {479        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);480        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();481        return;482    }483    output.fmtStart(path);484    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);485    output.fmtDone();486}487488pub fn repl(ctx: Ctx) !void {489    output.replStart();490    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);491}492493pub fn develop(ctx: Ctx) !void {494    if (std.mem.eql(u8, ctx.sub, "show")) {495        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);496        return;497    }498    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))499        ctx.args[0]500    else501        null;502    output.developStart(&.{});503    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);504    output.developDone();505}506507pub fn info(ctx: Ctx) !void {508    output.infoHeader(ctx.machine.name);509    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");510    defer ctx.gpa.free(nixos_ver);511    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));512513    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");514    defer ctx.gpa.free(kernel);515    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));516517    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");518    defer ctx.gpa.free(uptime);519    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));520}521522pub fn boot(ctx: Ctx) !void {523    output.bootHeader(ctx.machine.name);524    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};525    defer ctx.gpa.free(entries);526    for (entries, 0..) |entry, i| output.bootEntry(entry, i);527}528529pub fn searchCmd(ctx: Ctx) !void {530    const query = if (ctx.args.len > 0) ctx.args[0] else "";531    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);532    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);533    if (result) |r| {534        installFromResult(ctx, r) catch |e| return e;535    }536}537538pub fn optionCmd(ctx: Ctx) !void {539    const query = if (ctx.args.len > 0) ctx.args[0] else "";540    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);541    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);542}543544pub fn install(ctx: Ctx) !void {545    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;546547    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);548549    // Search for the package to get the attr550    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {551        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});552        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});553        return error.PackageNotFound;554    };555    defer ctx.gpa.free(pkgs);556557    if (pkgs.len == 0) {558        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});559        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});560        return error.PackageNotFound;561    }562563    // Prefer an exact name match over the first search result — `install ripgrep`564    // must pick "ripgrep", not the alphabetically-first match like "repgrep".565    var found = pkgs[0];566    for (pkgs) |p| {567        if (std.mem.eql(u8, p.pname, pkg_name)) {568            found = p;569            break;570        }571    }572    output.installSelectPrompt(found.pname, found.version);573574    var choice_buf: [8]u8 = undefined;575    var choice_len: usize = 0;576    while (choice_len < choice_buf.len) {577        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;578        if (n == 0) break;579        if (choice_buf[choice_len] == '\n') break;580        choice_len += 1;581    }582    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");583584    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {585        try profileInstallPkg(ctx, found);586    } else if (choice[0] == 's' or choice[0] == 'S') {587        try systemInstallPkg(ctx, found);588    } else if (choice[0] == 't' or choice[0] == 'T') {589        try tryPkg(ctx, found);590    }591}592593fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {594    switch (result.action) {595        .profile => try profileInstallPkg(ctx, result.package),596        .system => try systemInstallPkg(ctx, result.package),597    }598}599600fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {601    output.installStart(npkg.pname, .profile);602    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});603    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));604    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);605    if (code != 0) return error.ProcessFailed;606    output.installDone(npkg.pname);607}608609fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {610    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});611    defer ctx.gpa.free(config_path);612    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;613    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);614    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);615    if (ctx.cfg.confirm) {616        if (output.confirm("apply now? ")) {617            try apply(ctx);618        }619    }620}621622fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {623    output.tryStart(npkg.pname);624    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});625    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);626    output.tryDone();627}628629pub fn remove(ctx: Ctx) !void {630    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;631    output.removeStart(pkg_name);632    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);633    if (code != 0) return error.ProcessFailed;634    output.done();635}636637pub fn list(ctx: Ctx) !void {638    output.listHeader(ctx.machine.name);639640    // Profile packages: parse `nix profile list --json` into clean name+version641    // rows instead of dumping nix's verbose multi-line "Name:/Flake attribute:"642    // blocks.643    if (exec.profileListJson(ctx.gpa, ctx.io, &ctx.machine) catch null) |json| {644        defer ctx.gpa.free(json);645        output.listSection("profile");646        renderProfileJson(ctx.gpa, json);647    }648649    // System packages: the references of /run/current-system/sw, each a650    // <hash>-<name>-<version> store path.651    if (exec.systemPackagePaths(ctx.gpa, ctx.io, &ctx.machine) catch null) |paths| {652        defer ctx.gpa.free(paths);653        output.blankLine();654        output.listSection("system");655        var lines = std.mem.splitScalar(u8, paths, '\n');656        while (lines.next()) |line| {657            const t = std.mem.trim(u8, line, " \t\r");658            if (t.len == 0) continue;659            const row = pkgFromStorePath(t);660            // Skip support paths with no parseable version (man pages, wrappers).661            if (row.version.len == 0) continue;662            output.listPkg(row.name, row.version);663        }664    }665}666667const PkgRow = struct { name: []const u8, version: []const u8 };668669// Split a nix store path basename (<hash>-<name>-<version>) into name + version.670// Version begins at the first '-' followed by a digit, which reliably lands on671// the name/version boundary since the 32-char hash and package names start with672// non-digits.673fn pkgFromStorePath(path: []const u8) PkgRow {674    const base = std.fs.path.basename(path);675    const after_hash = if (base.len > 33 and base[32] == '-') base[33..] else base;676    var i: usize = 0;677    while (i + 1 < after_hash.len) : (i += 1) {678        if (after_hash[i] == '-' and std.ascii.isDigit(after_hash[i + 1])) {679            return .{ .name = after_hash[0..i], .version = after_hash[i + 1 ..] };680        }681    }682    return .{ .name = after_hash, .version = "" };683}684685test "pkgFromStorePath splits name and version at first dash-digit" {686    const a = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-ripgrep-15.1.0");687    try std.testing.expectEqualStrings("ripgrep", a.name);688    try std.testing.expectEqualStrings("15.1.0", a.version);689690    const b = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-helix-25.01");691    try std.testing.expectEqualStrings("helix", b.name);692    try std.testing.expectEqualStrings("25.01", b.version);693}694695fn firstStorePath(el: std.json.Value) ?[]const u8 {696    if (el != .object) return null;697    const sp = el.object.get("storePaths") orelse return null;698    if (sp != .array or sp.array.items.len == 0) return null;699    const first = sp.array.items[0];700    return if (first == .string) first.string else null;701}702703// `nix profile list --json` has two shapes across nix versions: newer keys each704// element by name (object), older lists them positionally (array). Handle both.705fn renderProfileJson(gpa: std.mem.Allocator, json: []const u8) void {706    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json, .{}) catch return;707    defer parsed.deinit();708    if (parsed.value != .object) return;709    const elements = parsed.value.object.get("elements") orelse return;710    switch (elements) {711        .object => |obj| {712            var it = obj.iterator();713            while (it.next()) |entry| {714                const ver = if (firstStorePath(entry.value_ptr.*)) |p| pkgFromStorePath(p).version else "";715                output.listPkg(entry.key_ptr.*, ver);716            }717        },718        .array => |arr| {719            for (arr.items) |el| {720                const path = firstStorePath(el) orelse continue;721                const row = pkgFromStorePath(path);722                output.listPkg(row.name, row.version);723            }724        },725        else => {},726    }727}728729pub fn tryCmd(ctx: Ctx) !void {730    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;731    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };732    try tryPkg(ctx, shell_pkg);733}734735// --- Service commands ---736737pub fn service(ctx: Ctx) !void {738    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {739        output.serviceListHeader(ctx.machine.name);740        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};741        defer ctx.gpa.free(svcs);742        for (svcs) |s| output.serviceRow(s);743        return;744    }745    if (std.mem.eql(u8, ctx.sub, "logs")) {746        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;747        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");748        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);749        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);750        return;751    }752    if (std.mem.eql(u8, ctx.sub, "status")) {753        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;754        output.serviceVerb("status", name, ctx.machine.name);755        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);756        return;757    }758    // start/stop/restart/enable/disable759    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;760    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {761        if (!output.confirm("")) return;762    }763    output.serviceVerb(ctx.sub, name, ctx.machine.name);764    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);765    output.serviceDone();766}767768// --- Flake commands ---769770pub fn flake(ctx: Ctx) !void {771    if (std.mem.eql(u8, ctx.sub, "update")) {772        output.flakeUpdateStart();773        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;774        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);775        output.flakeLockWritten();776        return;777    }778    if (std.mem.eql(u8, ctx.sub, "check")) {779        output.flakeCheckStart();780        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);781        if (code == 0) output.flakeValid() else output.checkFailed("");782        return;783    }784    if (std.mem.eql(u8, ctx.sub, "show")) {785        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;786        if (json_out) |j| {787            defer ctx.gpa.free(j);788            output.flakeShowHeader("flake outputs");789            // Parse and display the flake outputs790            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;791            defer parsed.deinit();792            if (parsed.value == .object) {793                var it = parsed.value.object.iterator();794                while (it.next()) |entry| {795                    if (entry.value_ptr.* == .object) {796                        var inner = entry.value_ptr.object.iterator();797                        while (inner.next()) |e2| {798                            const desc = if (e2.value_ptr.* == .object)799                                if (e2.value_ptr.object.get("description")) |d|800                                    if (d == .string) d.string else ""801                                else ""802                            else "";803                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);804                        }805                    }806                }807            }808        }809        return;810    }811    if (std.mem.eql(u8, ctx.sub, "init")) {812        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);813        return;814    }815    if (std.mem.eql(u8, ctx.sub, "lock")) {816        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);817        return;818    }819    if (std.mem.eql(u8, ctx.sub, "clone")) {820        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;821        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);822        return;823    }824}825826// --- Store commands ---827828pub fn store(ctx: Ctx) !void {829    if (std.mem.eql(u8, ctx.sub, "gc")) {830        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);831        return;832    }833    if (std.mem.eql(u8, ctx.sub, "verify")) {834        output.storeVerifyStart();835        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);836        return;837    }838    if (std.mem.eql(u8, ctx.sub, "repair")) {839        if (ctx.cfg.confirm and !output.confirm("")) return;840        output.storeRepairStart();841        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);842        return;843    }844    if (std.mem.eql(u8, ctx.sub, "info")) {845        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);846        output.storeInfo(si);847        return;848    }849    if (std.mem.eql(u8, ctx.sub, "path")) {850        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;851        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);852        defer ctx.gpa.free(path);853        output.storePath(path);854        return;855    }856    // default: info857    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);858    output.storeInfo(si);859}860861// --- Channel commands ---862863pub fn channel(ctx: Ctx) !void {864    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {865        output.channelListHeader(ctx.machine.name);866        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);867        defer ctx.gpa.free(chans);868        for (chans) |ch| output.channelRow(ch);869        return;870    }871    if (std.mem.eql(u8, ctx.sub, "add")) {872        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;873        const name = if (ctx.args.len > 1) ctx.args[1] else "";874        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);875        output.channelDone();876        return;877    }878    if (std.mem.eql(u8, ctx.sub, "remove")) {879        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;880        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);881        output.channelDone();882        return;883    }884    if (std.mem.eql(u8, ctx.sub, "update")) {885        return channelUpdateCmd(ctx);886    }887}888889// --- Profile commands ---890891pub fn profile(ctx: Ctx) !void {892    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {893        output.profileListHeader();894        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;895        if (out) |o| {896            defer ctx.gpa.free(o);897            var lines = std.mem.splitScalar(u8, o, '\n');898            while (lines.next()) |line| {899                const t = std.mem.trim(u8, line, " \t\r");900                if (t.len == 0) continue;901                output.profileRow(t, "", "");902            }903        }904        return;905    }906    if (std.mem.eql(u8, ctx.sub, "install")) {907        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;908        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);909        output.done();910        return;911    }912    if (std.mem.eql(u8, ctx.sub, "remove")) {913        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;914        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);915        output.done();916        return;917    }918    if (std.mem.eql(u8, ctx.sub, "upgrade")) {919        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);920        output.done();921        return;922    }923}924925// --- Pkg commands ---926927pub fn pkg(ctx: Ctx) !void {928    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;929    if (std.mem.eql(u8, ctx.sub, "why")) {930        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);931        return;932    }933    if (std.mem.eql(u8, ctx.sub, "deps")) {934        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);935        return;936    }937    if (std.mem.eql(u8, ctx.sub, "size")) {938        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;939        if (s) |sz| {940            defer ctx.gpa.free(sz);941            output.pkgResult("size", sz);942        }943        return;944    }945    if (std.mem.eql(u8, ctx.sub, "path")) {946        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);947        defer ctx.gpa.free(p);948        output.pkgResult("path", p);949        return;950    }951    if (std.mem.eql(u8, ctx.sub, "closure")) {952        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);953        return;954    }955}956957// --- Pin / Unpin ---958959pub fn pin(ctx: Ctx) !void {960    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;961    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;962    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;963    output.pinDone(input, commit);964}965966pub fn unpin(ctx: Ctx) !void {967    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;968    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);969    output.unpinDone(input);970}971972// --- Hash / Fetch ---973974pub fn hash(ctx: Ctx) !void {975    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;976    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);977    defer ctx.gpa.free(h);978    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));979}980981pub fn fetch(ctx: Ctx) !void {982    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;983    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);984    defer ctx.gpa.free(h);985    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);986}987988// --- Build / Run ---989990pub fn build(ctx: Ctx) !void {991    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;992    output.buildStart(attr orelse "default");993    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);994    if (code != 0) return error.BuildFailed;995    output.buildDone();996}997998pub fn runCmd(ctx: Ctx) !void {999    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1000    output.runStart(pkg_arg);1001    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);1002}10031004// --- Hello ---10051006pub fn hello(ctx: Ctx) !void {1007    output.hello(ctx.cfg.machines);1008}10091010pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1011    output.setupCheck();1012    const status = exec.checkNixFeatures(gpa, io, environ);1013    output.setupFeatures(status.nix_command, status.flakes);10141015    if (status.allEnabled()) {1016        output.setupAllSet();1017        api.ensureRegistryPinned(gpa, io, environ);1018        return;1019    }10201021    output.setupEnabling();1022    const path = try exec.enableNixFeatures(gpa, io, environ);1023    defer gpa.free(path);10241025    output.setupDone(path, exec.isNixOS(io));1026    api.ensureRegistryPinned(gpa, io, environ);1027}10281029// --- Log helper ---10301031fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {1032    var ts_buf: [32]u8 = undefined;1033    const ts = nowIso(ctx.io, &ts_buf);1034    var buf: [256]u8 = undefined;1035    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{1036        ts, ctx.machine.name, action, gen,1037    }) catch return;1038    exec.appendNinaLog(ctx.io, ctx.environ, entry);1039}10401041// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);1042// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.1043fn nowIso(io: std.Io, buf: []u8) []const u8 {1044    const fallback = "1970-01-01T00:00:00Z";1045    const ns = std.Io.Clock.real.now(io).toNanoseconds();1046    if (ns <= 0) return fallback;1047    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));1048    const es = std.time.epoch.EpochSeconds{ .secs = secs };1049    const ed = es.getEpochDay();1050    const yd = ed.calculateYearDay();1051    const md = yd.calculateMonthDay();1052    const ds = es.getDaySeconds();1053    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", .{1054        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,1055        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),1056    }) catch return fallback;1057}