commands.zig

31.8 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        output.applyFailed("");49        return e;50    };5152    if (code != 0) {53        output.applyFailed("");54        return error.BuildFailed;55    }5657    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());58    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));59    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;60    output.applyDone(gen, elapsed);61    logAction(ctx, "apply", gen);62}6364pub fn check(ctx: Ctx) !void {65    output.checkStart(ctx.machine.name);66    const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);67    if (code != 0) {68        output.checkFailed("");69        return error.BuildFailed;70    }71    output.checkOk();72}7374pub fn back(ctx: Ctx) !void {75    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);76    defer ctx.gpa.free(gens);7778    var current: u32 = 0;79    for (gens) |g| if (g.current) { current = g.number; };80    const prev = if (current > 1) current - 1 else current;8182    output.backStart(ctx.machine.name, current, prev);83    if (ctx.cfg.confirm and !output.confirm("")) return;8485    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);86    if (code != 0) return error.BuildFailed;87    output.generationDone(prev);88}8990pub fn go(ctx: Ctx) !void {91    if (ctx.args.len == 0) return error.Internal;92    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;93    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);9495    output.goStart(ctx.machine.name, current, target);96    if (ctx.cfg.confirm and !output.confirm("")) return;9798    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);99    if (code != 0) return error.ProcessFailed;100    output.generationDone(target);101}102103pub fn history(ctx: Ctx) !void {104    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);105    defer ctx.gpa.free(gens);106    output.historyHeader(ctx.machine.name);107    for (gens) |g| output.historyRow(g);108}109110pub fn genCmd(ctx: Ctx) !void {111    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);112    if (std.mem.eql(u8, ctx.sub, "current")) {113        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);114        output.genCurrent(n);115        return;116    }117    if (std.mem.eql(u8, ctx.sub, "delete")) {118        if (ctx.args.len == 0) return error.Internal;119        if (std.mem.eql(u8, ctx.args[0], "old")) {120            if (ctx.cfg.confirm and !output.confirm("")) return;121            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);122        } else {123            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;124            if (ctx.cfg.confirm and !output.confirm("")) return;125            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);126        }127        output.done();128        return;129    }130    return error.Internal;131}132133pub fn clean(ctx: Ctx) !void {134    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);135    defer ctx.gpa.free(gens);136    output.cleanStart(ctx.machine.name, ctx.cfg.generations, @intCast(gens.len));137    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;138    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");139    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all, ctx.cfg.generations);140    output.cleanDone("?");141}142143pub fn statusCmd(ctx: Ctx) !void {144    output.statusHeader();145    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;146    output.statusRow(ctx.machine.name, gen, "", "", true);147}148149pub fn diff(ctx: Ctx) !void {150    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);151    defer ctx.gpa.free(gens);152    var current: u32 = 0;153    var prev: u32 = 0;154    for (gens) |g| {155        if (g.current) current = g.number;156        if (!g.current and g.number > prev and g.number < current) prev = g.number;157    }158    if (ctx.args.len >= 2) {159        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;160        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;161        output.diffHeader(a, b, ctx.machine.name);162        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;163    } else {164        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);165        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;166    }167}168169pub fn doctor(ctx: Ctx) !void {170    output.doctorHeader(ctx.machine.name);171    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);172    defer ctx.gpa.free(checks);173    var warnings: u32 = 0;174    var failures: u32 = 0;175    for (checks) |ch| {176        output.doctorRow(ch);177        if (ch.status == .warn) warnings += 1;178        if (ch.status == .fail) failures += 1;179    }180    output.doctorSummary(warnings, failures);181}182183// nina update — kepr self-update for the nina binary itself.184pub fn update(ctx: Ctx) !void {185    output.updateSelfStart();186187    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {188        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));189        return error.Internal;190    }191192    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");193    const source_url = env_url orelse UPDATE_URL;194195    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {196        error.NetworkError, error.HttpError => {197            output.updateSelfNetworkError();198            return;199        },200        else => return e,201    };202    defer ctx.gpa.free(body);203204    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {205        output.printWarning("could not parse release metadata");206        return;207    };208    defer parsed.deinit();209210    const root = parsed.value;211    if (root != .object) { output.printWarning("malformed release metadata"); return; }212213    const remote_ver = blk: {214        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };215        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };216    };217218    if (std.mem.eql(u8, remote_ver, version.VERSION)) {219        output.updateSelfCurrent(version.VERSION);220        return;221    }222223    output.updateSelfAvailable(version.VERSION, remote_ver);224    if (ctx.check) return;225226    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;227    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {228        errors.error_info.setMessage("could not determine install path", .{});229        return error.Internal;230    };231232    // When running from the nix store, defer to nix profile upgrade so nix233    // manages the transition (the store is read-only; binary replacement fails).234    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {235        output.updateSelfNixManaged(remote_ver);236        if (ctx.cfg.confirm and !output.confirm("")) return;237        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {238            output.updateSelfNixFailed();239            return;240        };241        if (ec != 0) output.updateSelfNixSystemManaged();242        return;243    }244245    const plat_info = blk: {246        const pv = root.object.get("platforms") orelse break :blk null;247        if (pv != .object) break :blk null;248        break :blk pv.object.get(version.PLATFORM);249    };250    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }251252    const dl_url = blk: {253        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };254        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };255    };256257    const raw_cksum = blk: {258        if (plat_info.?.object.get("blake3")) |v|259            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };260        if (plat_info.?.object.get("checksum")) |v|261            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };262        output.printWarning("missing checksum in release metadata");263        return;264    };265    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;266267    if (ctx.cfg.confirm and !output.confirm("")) return;268269    output.updateSelfDownloading(remote_ver, version.PLATFORM);270    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {271        output.printWarning("download failed — check your connection and try again");272        return;273    };274    defer ctx.gpa.free(tmp_path);275276    output.updateSelfVerifying();277    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {278        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },279        else => return e,280    };281282    output.updateSelfDone(version.VERSION, remote_ver);283}284285// nina goodbye — clean uninstall from nix profile or binary location.286pub fn goodbye(ctx: Ctx) !void {287    output.goodbyeStart();288289    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;290    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {291        errors.error_info.setMessage("could not determine install path", .{});292        return error.Internal;293    };294295    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {296        output.goodbyeNix();297        if (ctx.cfg.confirm and !output.confirm("")) return;298        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {299            output.goodbyeFailed("nix profile remove failed");300            return;301        };302        if (ec != 0) {303            output.goodbyeNixSystemManaged();304            return;305        }306    } else {307        output.goodbyeFile(install_path);308        if (ctx.cfg.confirm and !output.confirm("")) return;309        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {310            errors.error_info.setMessage("could not remove {s}", .{install_path});311            return e;312        };313    }314315    // Offer to remove ~/nina.nix if it exists (generated by the installer).316    if (ctx.environ.get("HOME")) |home| {317        const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;318        if (nina_nix) |path| {319            defer ctx.gpa.free(path);320            const exists = blk: {321                const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;322                ctx.gpa.free(bytes);323                break :blk true;324            };325            if (exists) {326                output.goodbyeNixFileFound(path);327                if (!ctx.cfg.confirm or output.confirm("")) {328                    std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {329                        output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");330                    };331                }332            }333        }334    }335336    output.goodbyeDone();337}338339fn channelUpdateCmd(ctx: Ctx) !void {340    output.updateStart(ctx.machine.name);341    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);342    output.updateDone();343}344345pub fn upgrade(ctx: Ctx) !void {346    output.upgradeStart(ctx.machine.name);347    output.printSubstep("updating channels", .{});348    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};349    output.printSubstep("rebuilding", .{});350    const start = std.Io.Clock.awake.now(ctx.io);351    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);352    if (code != 0) {353        output.applyFailed("");354        return error.BuildFailed;355    }356    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());357    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));358    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;359    output.applyDone(gen, elapsed);360}361362pub fn log(ctx: Ctx) !void {363    output.logHeader(ctx.machine.name);364    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {365        // No log yet — that's fine366        return;367    };368    defer ctx.gpa.free(content);369    var lines = std.mem.splitScalar(u8, content, '\n');370    while (lines.next()) |line| {371        if (line.len == 0) continue;372        var parts = std.mem.tokenizeScalar(u8, line, '\t');373        const date = parts.next() orelse continue;374        const time = parts.next() orelse "";375        const action = parts.next() orelse "";376        const detail = parts.next() orelse "";377        output.logRow(date, time, action, detail);378    }379}380381pub fn edit(ctx: Ctx) !void {382    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");383    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";384    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });385    defer ctx.gpa.free(path);386    output.editOpen(path, ctx.cfg.editor);387    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);388}389390pub fn fmt(ctx: Ctx) !void {391    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});392    defer ctx.gpa.free(path);393    if (ctx.check) {394        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);395        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();396        return;397    }398    output.fmtStart(path);399    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);400    output.fmtDone();401}402403pub fn repl(ctx: Ctx) !void {404    output.replStart();405    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);406}407408pub fn develop(ctx: Ctx) !void {409    if (std.mem.eql(u8, ctx.sub, "show")) {410        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);411        return;412    }413    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))414        ctx.args[0]415    else416        null;417    output.developStart(&.{});418    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);419    output.developDone();420}421422pub fn info(ctx: Ctx) !void {423    output.infoHeader(ctx.machine.name);424    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");425    defer ctx.gpa.free(nixos_ver);426    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));427428    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");429    defer ctx.gpa.free(kernel);430    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));431432    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");433    defer ctx.gpa.free(uptime);434    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));435}436437pub fn boot(ctx: Ctx) !void {438    output.bootHeader(ctx.machine.name);439    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};440    defer ctx.gpa.free(entries);441    for (entries, 0..) |entry, i| output.bootEntry(entry, i);442}443444pub fn searchCmd(ctx: Ctx) !void {445    const query = if (ctx.args.len > 0) ctx.args[0] else "";446    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);447    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);448    if (result) |r| {449        installFromResult(ctx, r) catch |e| return e;450    }451}452453pub fn optionCmd(ctx: Ctx) !void {454    const query = if (ctx.args.len > 0) ctx.args[0] else "";455    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);456    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);457}458459pub fn install(ctx: Ctx) !void {460    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;461462    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);463464    // Search for the package to get the attr465    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {466        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});467        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});468        return error.PackageNotFound;469    };470    defer ctx.gpa.free(pkgs);471472    if (pkgs.len == 0) {473        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});474        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});475        return error.PackageNotFound;476    }477478    // Prefer an exact name match over the first search result — `install ripgrep`479    // must pick "ripgrep", not the alphabetically-first match like "repgrep".480    var found = pkgs[0];481    for (pkgs) |p| {482        if (std.mem.eql(u8, p.pname, pkg_name)) {483            found = p;484            break;485        }486    }487    output.installSelectPrompt(found.pname, found.version);488489    var choice_buf: [8]u8 = undefined;490    var choice_len: usize = 0;491    while (choice_len < choice_buf.len) {492        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;493        if (n == 0) break;494        if (choice_buf[choice_len] == '\n') break;495        choice_len += 1;496    }497    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");498499    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {500        try profileInstallPkg(ctx, found);501    } else if (choice[0] == 's' or choice[0] == 'S') {502        try systemInstallPkg(ctx, found);503    } else if (choice[0] == 't' or choice[0] == 'T') {504        try tryPkg(ctx, found);505    }506}507508fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {509    switch (result.action) {510        .profile => try profileInstallPkg(ctx, result.package),511        .system => try systemInstallPkg(ctx, result.package),512    }513}514515fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {516    output.installStart(npkg.pname, .profile);517    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});518    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));519    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);520    if (code != 0) return error.ProcessFailed;521    output.installDone(npkg.pname);522}523524fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {525    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});526    defer ctx.gpa.free(config_path);527    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;528    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);529    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);530    if (ctx.cfg.confirm) {531        if (output.confirm("apply now? ")) {532            try apply(ctx);533        }534    }535}536537fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {538    output.tryStart(npkg.pname);539    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});540    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);541    output.tryDone();542}543544pub fn remove(ctx: Ctx) !void {545    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;546    output.installStart(pkg_name, .profile); // reuse installStart for remove UI547    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);548    if (code != 0) return error.ProcessFailed;549    output.done();550}551552pub fn list(ctx: Ctx) !void {553    output.listHeader();554    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;555    if (profile_out) |out| {556        defer ctx.gpa.free(out);557        output.listSection("profile");558        var lines = std.mem.splitScalar(u8, out, '\n');559        while (lines.next()) |line| {560            const t = std.mem.trim(u8, line, " \t\r");561            if (t.len == 0) continue;562            output.listPkg(t, "");563        }564    }565}566567pub fn tryCmd(ctx: Ctx) !void {568    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;569    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };570    try tryPkg(ctx, shell_pkg);571}572573// --- Service commands ---574575pub fn service(ctx: Ctx) !void {576    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {577        output.serviceListHeader(ctx.machine.name);578        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};579        defer ctx.gpa.free(svcs);580        for (svcs) |s| output.serviceRow(s);581        return;582    }583    if (std.mem.eql(u8, ctx.sub, "logs")) {584        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;585        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");586        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);587        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);588        return;589    }590    if (std.mem.eql(u8, ctx.sub, "status")) {591        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;592        output.serviceVerb("status", name, ctx.machine.name);593        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);594        return;595    }596    // start/stop/restart/enable/disable597    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;598    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {599        if (!output.confirm("")) return;600    }601    output.serviceVerb(ctx.sub, name, ctx.machine.name);602    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);603    output.serviceDone();604}605606// --- Flake commands ---607608pub fn flake(ctx: Ctx) !void {609    if (std.mem.eql(u8, ctx.sub, "update")) {610        output.flakeUpdateStart();611        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;612        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);613        output.flakeLockWritten();614        return;615    }616    if (std.mem.eql(u8, ctx.sub, "check")) {617        output.flakeCheckStart();618        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);619        if (code == 0) output.flakeValid() else output.checkFailed("");620        return;621    }622    if (std.mem.eql(u8, ctx.sub, "show")) {623        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;624        if (json_out) |j| {625            defer ctx.gpa.free(j);626            output.flakeShowHeader(".");627            // Parse and display the flake outputs628            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;629            defer parsed.deinit();630            if (parsed.value == .object) {631                var it = parsed.value.object.iterator();632                while (it.next()) |entry| {633                    if (entry.value_ptr.* == .object) {634                        var inner = entry.value_ptr.object.iterator();635                        while (inner.next()) |e2| {636                            const desc = if (e2.value_ptr.* == .object)637                                if (e2.value_ptr.object.get("description")) |d|638                                    if (d == .string) d.string else ""639                                else ""640                            else "";641                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);642                        }643                    }644                }645            }646        }647        return;648    }649    if (std.mem.eql(u8, ctx.sub, "init")) {650        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);651        return;652    }653    if (std.mem.eql(u8, ctx.sub, "lock")) {654        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);655        return;656    }657    if (std.mem.eql(u8, ctx.sub, "clone")) {658        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;659        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);660        return;661    }662}663664// --- Store commands ---665666pub fn store(ctx: Ctx) !void {667    if (std.mem.eql(u8, ctx.sub, "gc")) {668        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);669        return;670    }671    if (std.mem.eql(u8, ctx.sub, "verify")) {672        output.storeVerifyStart();673        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);674        return;675    }676    if (std.mem.eql(u8, ctx.sub, "repair")) {677        if (ctx.cfg.confirm and !output.confirm("")) return;678        output.storeRepairStart();679        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);680        return;681    }682    if (std.mem.eql(u8, ctx.sub, "info")) {683        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);684        output.storeInfo(si);685        return;686    }687    if (std.mem.eql(u8, ctx.sub, "path")) {688        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;689        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);690        defer ctx.gpa.free(path);691        output.storePath(path);692        return;693    }694    // default: info695    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);696    output.storeInfo(si);697}698699// --- Channel commands ---700701pub fn channel(ctx: Ctx) !void {702    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {703        output.channelListHeader(ctx.machine.name);704        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);705        defer ctx.gpa.free(chans);706        for (chans) |ch| output.channelRow(ch);707        return;708    }709    if (std.mem.eql(u8, ctx.sub, "add")) {710        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;711        const name = if (ctx.args.len > 1) ctx.args[1] else "";712        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);713        output.channelDone();714        return;715    }716    if (std.mem.eql(u8, ctx.sub, "remove")) {717        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;718        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);719        output.channelDone();720        return;721    }722    if (std.mem.eql(u8, ctx.sub, "update")) {723        return channelUpdateCmd(ctx);724    }725}726727// --- Profile commands ---728729pub fn profile(ctx: Ctx) !void {730    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {731        output.profileListHeader();732        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;733        if (out) |o| {734            defer ctx.gpa.free(o);735            var lines = std.mem.splitScalar(u8, o, '\n');736            while (lines.next()) |line| {737                const t = std.mem.trim(u8, line, " \t\r");738                if (t.len == 0) continue;739                output.profileRow(t, "", "");740            }741        }742        return;743    }744    if (std.mem.eql(u8, ctx.sub, "install")) {745        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;746        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);747        output.done();748        return;749    }750    if (std.mem.eql(u8, ctx.sub, "remove")) {751        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;752        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);753        output.done();754        return;755    }756    if (std.mem.eql(u8, ctx.sub, "upgrade")) {757        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);758        output.done();759        return;760    }761}762763// --- Pkg commands ---764765pub fn pkg(ctx: Ctx) !void {766    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;767    if (std.mem.eql(u8, ctx.sub, "why")) {768        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);769        return;770    }771    if (std.mem.eql(u8, ctx.sub, "deps")) {772        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);773        return;774    }775    if (std.mem.eql(u8, ctx.sub, "size")) {776        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;777        if (s) |sz| {778            defer ctx.gpa.free(sz);779            output.pkgResult("size", sz);780        }781        return;782    }783    if (std.mem.eql(u8, ctx.sub, "path")) {784        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);785        defer ctx.gpa.free(p);786        output.pkgResult("path", p);787        return;788    }789    if (std.mem.eql(u8, ctx.sub, "closure")) {790        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);791        return;792    }793}794795// --- Pin / Unpin ---796797pub fn pin(ctx: Ctx) !void {798    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;799    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;800    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;801    output.pinDone(input, commit);802}803804pub fn unpin(ctx: Ctx) !void {805    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;806    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);807    output.unpinDone(input);808}809810// --- Hash / Fetch ---811812pub fn hash(ctx: Ctx) !void {813    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;814    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);815    defer ctx.gpa.free(h);816    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));817}818819pub fn fetch(ctx: Ctx) !void {820    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;821    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);822    defer ctx.gpa.free(h);823    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);824}825826// --- Build / Run ---827828pub fn build(ctx: Ctx) !void {829    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;830    output.buildStart(attr orelse "default");831    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);832    if (code != 0) return error.BuildFailed;833    output.buildDone();834}835836pub fn runCmd(ctx: Ctx) !void {837    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;838    output.runStart(pkg_arg);839    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);840}841842// --- Hello ---843844pub fn hello(ctx: Ctx) !void {845    output.hello(ctx.cfg.machines);846}847848pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {849    output.setupCheck();850    const status = exec.checkNixFeatures(gpa, io, environ);851    output.setupFeatures(status.nix_command, status.flakes);852853    if (status.allEnabled()) {854        output.setupAllSet();855        api.ensureRegistryPinned(gpa, io, environ);856        return;857    }858859    output.setupEnabling();860    const path = try exec.enableNixFeatures(gpa, io, environ);861    defer gpa.free(path);862863    output.setupDone(path, exec.isNixOS(io));864    api.ensureRegistryPinned(gpa, io, environ);865}866867// --- Log helper ---868869fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {870    var buf: [128]u8 = undefined;871    const entry = std.fmt.bufPrint(&buf, "{s}\t{s}\t{s}\tgen {d}\n", .{872        "2026", "00:00", action, gen,873    }) catch return;874    exec.appendNinaLog(ctx.io, ctx.environ, entry);875}