commands.zig

28.9 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    const plat_info = blk: {227        const pv = root.object.get("platforms") orelse break :blk null;228        if (pv != .object) break :blk null;229        break :blk pv.object.get(version.PLATFORM);230    };231    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }232233    const dl_url = blk: {234        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };235        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };236    };237238    const raw_cksum = blk: {239        if (plat_info.?.object.get("blake3")) |v|240            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };241        if (plat_info.?.object.get("checksum")) |v|242            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };243        output.printWarning("missing checksum in release metadata");244        return;245    };246    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;247248    if (ctx.cfg.confirm and !output.confirm("")) return;249250    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;251    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {252        errors.error_info.setMessage("could not determine install path", .{});253        return error.Internal;254    };255256    output.updateSelfDownloading(remote_ver, version.PLATFORM);257    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {258        output.printWarning("download failed — check your connection and try again");259        return;260    };261    defer ctx.gpa.free(tmp_path);262263    output.updateSelfVerifying();264    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {265        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },266        else => return e,267    };268269    output.updateSelfDone(version.VERSION, remote_ver);270}271272fn channelUpdateCmd(ctx: Ctx) !void {273    output.updateStart(ctx.machine.name);274    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);275    output.updateDone();276}277278pub fn upgrade(ctx: Ctx) !void {279    output.upgradeStart(ctx.machine.name);280    output.printSubstep("updating channels", .{});281    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};282    output.printSubstep("rebuilding", .{});283    const start = std.Io.Clock.awake.now(ctx.io);284    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);285    if (code != 0) {286        output.applyFailed("");287        return error.BuildFailed;288    }289    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());290    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));291    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;292    output.applyDone(gen, elapsed);293}294295pub fn log(ctx: Ctx) !void {296    output.logHeader(ctx.machine.name);297    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {298        // No log yet — that's fine299        return;300    };301    defer ctx.gpa.free(content);302    var lines = std.mem.splitScalar(u8, content, '\n');303    while (lines.next()) |line| {304        if (line.len == 0) continue;305        var parts = std.mem.tokenizeScalar(u8, line, '\t');306        const date = parts.next() orelse continue;307        const time = parts.next() orelse "";308        const action = parts.next() orelse "";309        const detail = parts.next() orelse "";310        output.logRow(date, time, action, detail);311    }312}313314pub fn edit(ctx: Ctx) !void {315    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");316    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";317    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });318    defer ctx.gpa.free(path);319    output.editOpen(path, ctx.cfg.editor);320    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);321}322323pub fn fmt(ctx: Ctx) !void {324    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});325    defer ctx.gpa.free(path);326    if (ctx.check) {327        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);328        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();329        return;330    }331    output.fmtStart(path);332    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);333    output.fmtDone();334}335336pub fn repl(ctx: Ctx) !void {337    output.replStart();338    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);339}340341pub fn develop(ctx: Ctx) !void {342    if (std.mem.eql(u8, ctx.sub, "show")) {343        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);344        return;345    }346    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))347        ctx.args[0]348    else349        null;350    output.developStart(&.{});351    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);352    output.developDone();353}354355pub fn info(ctx: Ctx) !void {356    output.infoHeader(ctx.machine.name);357    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");358    defer ctx.gpa.free(nixos_ver);359    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));360361    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");362    defer ctx.gpa.free(kernel);363    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));364365    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");366    defer ctx.gpa.free(uptime);367    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));368}369370pub fn boot(ctx: Ctx) !void {371    output.bootHeader(ctx.machine.name);372    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};373    defer ctx.gpa.free(entries);374    for (entries, 0..) |entry, i| output.bootEntry(entry, i);375}376377pub fn searchCmd(ctx: Ctx) !void {378    const query = if (ctx.args.len > 0) ctx.args[0] else "";379    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);380    if (result) |r| {381        installFromResult(ctx, r) catch |e| return e;382    }383}384385pub fn optionCmd(ctx: Ctx) !void {386    const query = if (ctx.args.len > 0) ctx.args[0] else "";387    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);388}389390pub fn install(ctx: Ctx) !void {391    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;392393    // Search for the package to get the attr394    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {395        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});396        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});397        return error.PackageNotFound;398    };399    defer ctx.gpa.free(pkgs);400401    if (pkgs.len == 0) {402        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});403        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});404        return error.PackageNotFound;405    }406407    // Prefer an exact name match over the first search result — `install ripgrep`408    // must pick "ripgrep", not the alphabetically-first match like "repgrep".409    var found = pkgs[0];410    for (pkgs) |p| {411        if (std.mem.eql(u8, p.pname, pkg_name)) {412            found = p;413            break;414        }415    }416    output.installSelectPrompt(found.pname, found.version);417418    var choice_buf: [8]u8 = undefined;419    var choice_len: usize = 0;420    while (choice_len < choice_buf.len) {421        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;422        if (n == 0) break;423        if (choice_buf[choice_len] == '\n') break;424        choice_len += 1;425    }426    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");427428    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {429        try profileInstallPkg(ctx, found);430    } else if (choice[0] == 's' or choice[0] == 'S') {431        try systemInstallPkg(ctx, found);432    } else if (choice[0] == 't' or choice[0] == 'T') {433        try tryPkg(ctx, found);434    }435}436437fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {438    switch (result.action) {439        .profile => try profileInstallPkg(ctx, result.package),440        .system => try systemInstallPkg(ctx, result.package),441    }442}443444fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {445    output.installStart(npkg.pname, .profile);446    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});447    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));448    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);449    if (code != 0) return error.ProcessFailed;450    output.installDone(npkg.pname);451}452453fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {454    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});455    defer ctx.gpa.free(config_path);456    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;457    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);458    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);459    if (ctx.cfg.confirm) {460        if (output.confirm("apply now? ")) {461            try apply(ctx);462        }463    }464}465466fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {467    output.tryStart(npkg.pname);468    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});469    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);470    output.tryDone();471}472473pub fn remove(ctx: Ctx) !void {474    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;475    output.installStart(pkg_name, .profile); // reuse installStart for remove UI476    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);477    if (code != 0) return error.ProcessFailed;478    output.done();479}480481pub fn list(ctx: Ctx) !void {482    output.listHeader();483    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;484    if (profile_out) |out| {485        defer ctx.gpa.free(out);486        output.listSection("profile");487        var lines = std.mem.splitScalar(u8, out, '\n');488        while (lines.next()) |line| {489            const t = std.mem.trim(u8, line, " \t\r");490            if (t.len == 0) continue;491            output.listPkg(t, "");492        }493    }494}495496pub fn tryCmd(ctx: Ctx) !void {497    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;498    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };499    try tryPkg(ctx, shell_pkg);500}501502// --- Service commands ---503504pub fn service(ctx: Ctx) !void {505    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {506        output.serviceListHeader(ctx.machine.name);507        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};508        defer ctx.gpa.free(svcs);509        for (svcs) |s| output.serviceRow(s);510        return;511    }512    if (std.mem.eql(u8, ctx.sub, "logs")) {513        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;514        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");515        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);516        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);517        return;518    }519    if (std.mem.eql(u8, ctx.sub, "status")) {520        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;521        output.serviceVerb("status", name, ctx.machine.name);522        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);523        return;524    }525    // start/stop/restart/enable/disable526    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;527    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {528        if (!output.confirm("")) return;529    }530    output.serviceVerb(ctx.sub, name, ctx.machine.name);531    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);532    output.serviceDone();533}534535// --- Flake commands ---536537pub fn flake(ctx: Ctx) !void {538    if (std.mem.eql(u8, ctx.sub, "update")) {539        output.flakeUpdateStart();540        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;541        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);542        output.flakeLockWritten();543        return;544    }545    if (std.mem.eql(u8, ctx.sub, "check")) {546        output.flakeCheckStart();547        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);548        if (code == 0) output.flakeValid() else output.checkFailed("");549        return;550    }551    if (std.mem.eql(u8, ctx.sub, "show")) {552        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;553        if (json_out) |j| {554            defer ctx.gpa.free(j);555            output.flakeShowHeader(".");556            // Parse and display the flake outputs557            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;558            defer parsed.deinit();559            if (parsed.value == .object) {560                var it = parsed.value.object.iterator();561                while (it.next()) |entry| {562                    if (entry.value_ptr.* == .object) {563                        var inner = entry.value_ptr.object.iterator();564                        while (inner.next()) |e2| {565                            const desc = if (e2.value_ptr.* == .object)566                                if (e2.value_ptr.object.get("description")) |d|567                                    if (d == .string) d.string else ""568                                else ""569                            else "";570                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);571                        }572                    }573                }574            }575        }576        return;577    }578    if (std.mem.eql(u8, ctx.sub, "init")) {579        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);580        return;581    }582    if (std.mem.eql(u8, ctx.sub, "lock")) {583        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);584        return;585    }586    if (std.mem.eql(u8, ctx.sub, "clone")) {587        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;588        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);589        return;590    }591}592593// --- Store commands ---594595pub fn store(ctx: Ctx) !void {596    if (std.mem.eql(u8, ctx.sub, "gc")) {597        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);598        return;599    }600    if (std.mem.eql(u8, ctx.sub, "verify")) {601        output.storeVerifyStart();602        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);603        return;604    }605    if (std.mem.eql(u8, ctx.sub, "repair")) {606        if (ctx.cfg.confirm and !output.confirm("")) return;607        output.storeRepairStart();608        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);609        return;610    }611    if (std.mem.eql(u8, ctx.sub, "info")) {612        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);613        output.storeInfo(si);614        return;615    }616    if (std.mem.eql(u8, ctx.sub, "path")) {617        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;618        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);619        defer ctx.gpa.free(path);620        output.storePath(path);621        return;622    }623    // default: info624    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);625    output.storeInfo(si);626}627628// --- Channel commands ---629630pub fn channel(ctx: Ctx) !void {631    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {632        output.channelListHeader(ctx.machine.name);633        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);634        defer ctx.gpa.free(chans);635        for (chans) |ch| output.channelRow(ch);636        return;637    }638    if (std.mem.eql(u8, ctx.sub, "add")) {639        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;640        const name = if (ctx.args.len > 1) ctx.args[1] else "";641        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);642        output.channelDone();643        return;644    }645    if (std.mem.eql(u8, ctx.sub, "remove")) {646        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;647        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);648        output.channelDone();649        return;650    }651    if (std.mem.eql(u8, ctx.sub, "update")) {652        return channelUpdateCmd(ctx);653    }654}655656// --- Profile commands ---657658pub fn profile(ctx: Ctx) !void {659    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {660        output.profileListHeader();661        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;662        if (out) |o| {663            defer ctx.gpa.free(o);664            var lines = std.mem.splitScalar(u8, o, '\n');665            while (lines.next()) |line| {666                const t = std.mem.trim(u8, line, " \t\r");667                if (t.len == 0) continue;668                output.profileRow(t, "", "");669            }670        }671        return;672    }673    if (std.mem.eql(u8, ctx.sub, "install")) {674        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;675        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);676        output.done();677        return;678    }679    if (std.mem.eql(u8, ctx.sub, "remove")) {680        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;681        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);682        output.done();683        return;684    }685    if (std.mem.eql(u8, ctx.sub, "upgrade")) {686        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);687        output.done();688        return;689    }690}691692// --- Pkg commands ---693694pub fn pkg(ctx: Ctx) !void {695    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;696    if (std.mem.eql(u8, ctx.sub, "why")) {697        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);698        return;699    }700    if (std.mem.eql(u8, ctx.sub, "deps")) {701        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);702        return;703    }704    if (std.mem.eql(u8, ctx.sub, "size")) {705        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;706        if (s) |sz| {707            defer ctx.gpa.free(sz);708            output.pkgResult("size", sz);709        }710        return;711    }712    if (std.mem.eql(u8, ctx.sub, "path")) {713        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);714        defer ctx.gpa.free(p);715        output.pkgResult("path", p);716        return;717    }718    if (std.mem.eql(u8, ctx.sub, "closure")) {719        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);720        return;721    }722}723724// --- Pin / Unpin ---725726pub fn pin(ctx: Ctx) !void {727    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;728    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;729    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;730    output.pinDone(input, commit);731}732733pub fn unpin(ctx: Ctx) !void {734    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;735    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);736    output.unpinDone(input);737}738739// --- Hash / Fetch ---740741pub fn hash(ctx: Ctx) !void {742    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;743    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);744    defer ctx.gpa.free(h);745    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));746}747748pub fn fetch(ctx: Ctx) !void {749    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;750    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);751    defer ctx.gpa.free(h);752    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);753}754755// --- Build / Run ---756757pub fn build(ctx: Ctx) !void {758    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;759    output.buildStart(attr orelse "default");760    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);761    if (code != 0) return error.BuildFailed;762    output.buildDone();763}764765pub fn runCmd(ctx: Ctx) !void {766    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;767    output.runStart(pkg_arg);768    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);769}770771// --- Hello ---772773pub fn hello(ctx: Ctx) !void {774    output.hello(ctx.cfg.machines);775}776777pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {778    output.setupCheck();779    const status = exec.checkNixFeatures(gpa, io, environ);780    output.setupFeatures(status.nix_command, status.flakes);781782    if (status.allEnabled()) {783        output.setupAllSet();784        return;785    }786787    output.setupEnabling();788    const path = try exec.enableNixFeatures(gpa, io, environ);789    defer gpa.free(path);790791    output.setupDone(path, exec.isNixOS(io));792}793794// --- Log helper ---795796fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {797    var buf: [128]u8 = undefined;798    const entry = std.fmt.bufPrint(&buf, "{s}\t{s}\t{s}\tgen {d}\n", .{799        "2026", "00:00", action, gen,800    }) catch return;801    exec.appendNinaLog(ctx.io, ctx.environ, entry);802}