commands.zig

34.2 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        const t = std.mem.trim(u8, line, " \t\r");372        if (t.len == 0) continue;373        if (t[0] == '{') {374            renderJsonLogLine(ctx.gpa, t);375        } else {376            // legacy tab-separated format377            var parts = std.mem.tokenizeScalar(u8, t, '\t');378            const date = parts.next() orelse continue;379            const time = parts.next() orelse "";380            const action = parts.next() orelse "";381            const detail = parts.next() orelse "";382            output.logRow(date, time, action, detail);383        }384    }385}386387// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.388// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.389fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {390    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {391        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop392        return;393    };394    defer parsed.deinit();395    if (parsed.value != .object) return;396    const o = parsed.value.object;397398    const ts = jsonStr(o, "ts");399    const date = if (ts.len >= 10) ts[0..10] else ts;400    const time = if (ts.len >= 16) ts[11..16] else "";401    const action = jsonStr(o, "command");402    const outcome = jsonStr(o, "outcome");403404    var buf: [160]u8 = undefined;405    var detail: []const u8 = outcome;406    if (o.get("gen_after")) |g| {407        if (g == .integer) {408            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;409        }410    }411    output.logRow(date, time, action, detail);412}413414fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {415    if (o.get(key)) |v| {416        if (v == .string) return v.string;417    }418    return "";419}420421pub fn edit(ctx: Ctx) !void {422    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");423    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";424    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });425    defer ctx.gpa.free(path);426    output.editOpen(path, ctx.cfg.editor);427    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);428}429430pub fn fmt(ctx: Ctx) !void {431    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});432    defer ctx.gpa.free(path);433    if (ctx.check) {434        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);435        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();436        return;437    }438    output.fmtStart(path);439    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);440    output.fmtDone();441}442443pub fn repl(ctx: Ctx) !void {444    output.replStart();445    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);446}447448pub fn develop(ctx: Ctx) !void {449    if (std.mem.eql(u8, ctx.sub, "show")) {450        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);451        return;452    }453    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))454        ctx.args[0]455    else456        null;457    output.developStart(&.{});458    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);459    output.developDone();460}461462pub fn info(ctx: Ctx) !void {463    output.infoHeader(ctx.machine.name);464    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");465    defer ctx.gpa.free(nixos_ver);466    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));467468    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");469    defer ctx.gpa.free(kernel);470    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));471472    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");473    defer ctx.gpa.free(uptime);474    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));475}476477pub fn boot(ctx: Ctx) !void {478    output.bootHeader(ctx.machine.name);479    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};480    defer ctx.gpa.free(entries);481    for (entries, 0..) |entry, i| output.bootEntry(entry, i);482}483484pub fn searchCmd(ctx: Ctx) !void {485    const query = if (ctx.args.len > 0) ctx.args[0] else "";486    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);487    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);488    if (result) |r| {489        installFromResult(ctx, r) catch |e| return e;490    }491}492493pub fn optionCmd(ctx: Ctx) !void {494    const query = if (ctx.args.len > 0) ctx.args[0] else "";495    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);496    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);497}498499pub fn install(ctx: Ctx) !void {500    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;501502    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);503504    // Search for the package to get the attr505    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {506        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});507        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});508        return error.PackageNotFound;509    };510    defer ctx.gpa.free(pkgs);511512    if (pkgs.len == 0) {513        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});514        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});515        return error.PackageNotFound;516    }517518    // Prefer an exact name match over the first search result — `install ripgrep`519    // must pick "ripgrep", not the alphabetically-first match like "repgrep".520    var found = pkgs[0];521    for (pkgs) |p| {522        if (std.mem.eql(u8, p.pname, pkg_name)) {523            found = p;524            break;525        }526    }527    output.installSelectPrompt(found.pname, found.version);528529    var choice_buf: [8]u8 = undefined;530    var choice_len: usize = 0;531    while (choice_len < choice_buf.len) {532        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;533        if (n == 0) break;534        if (choice_buf[choice_len] == '\n') break;535        choice_len += 1;536    }537    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");538539    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {540        try profileInstallPkg(ctx, found);541    } else if (choice[0] == 's' or choice[0] == 'S') {542        try systemInstallPkg(ctx, found);543    } else if (choice[0] == 't' or choice[0] == 'T') {544        try tryPkg(ctx, found);545    }546}547548fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {549    switch (result.action) {550        .profile => try profileInstallPkg(ctx, result.package),551        .system => try systemInstallPkg(ctx, result.package),552    }553}554555fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {556    output.installStart(npkg.pname, .profile);557    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});558    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));559    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);560    if (code != 0) return error.ProcessFailed;561    output.installDone(npkg.pname);562}563564fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {565    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});566    defer ctx.gpa.free(config_path);567    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;568    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);569    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);570    if (ctx.cfg.confirm) {571        if (output.confirm("apply now? ")) {572            try apply(ctx);573        }574    }575}576577fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {578    output.tryStart(npkg.pname);579    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});580    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);581    output.tryDone();582}583584pub fn remove(ctx: Ctx) !void {585    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;586    output.removeStart(pkg_name);587    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);588    if (code != 0) return error.ProcessFailed;589    output.done();590}591592pub fn list(ctx: Ctx) !void {593    output.listHeader();594    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;595    if (profile_out) |out| {596        defer ctx.gpa.free(out);597        output.listSection("profile");598        var lines = std.mem.splitScalar(u8, out, '\n');599        while (lines.next()) |line| {600            const t = std.mem.trim(u8, line, " \t\r");601            if (t.len == 0) continue;602            output.listPkg(t, "");603        }604    }605}606607pub fn tryCmd(ctx: Ctx) !void {608    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;609    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };610    try tryPkg(ctx, shell_pkg);611}612613// --- Service commands ---614615pub fn service(ctx: Ctx) !void {616    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {617        output.serviceListHeader(ctx.machine.name);618        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};619        defer ctx.gpa.free(svcs);620        for (svcs) |s| output.serviceRow(s);621        return;622    }623    if (std.mem.eql(u8, ctx.sub, "logs")) {624        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;625        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");626        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);627        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);628        return;629    }630    if (std.mem.eql(u8, ctx.sub, "status")) {631        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;632        output.serviceVerb("status", name, ctx.machine.name);633        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);634        return;635    }636    // start/stop/restart/enable/disable637    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;638    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {639        if (!output.confirm("")) return;640    }641    output.serviceVerb(ctx.sub, name, ctx.machine.name);642    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);643    output.serviceDone();644}645646// --- Flake commands ---647648pub fn flake(ctx: Ctx) !void {649    if (std.mem.eql(u8, ctx.sub, "update")) {650        output.flakeUpdateStart();651        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;652        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);653        output.flakeLockWritten();654        return;655    }656    if (std.mem.eql(u8, ctx.sub, "check")) {657        output.flakeCheckStart();658        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);659        if (code == 0) output.flakeValid() else output.checkFailed("");660        return;661    }662    if (std.mem.eql(u8, ctx.sub, "show")) {663        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;664        if (json_out) |j| {665            defer ctx.gpa.free(j);666            output.flakeShowHeader("flake outputs");667            // Parse and display the flake outputs668            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;669            defer parsed.deinit();670            if (parsed.value == .object) {671                var it = parsed.value.object.iterator();672                while (it.next()) |entry| {673                    if (entry.value_ptr.* == .object) {674                        var inner = entry.value_ptr.object.iterator();675                        while (inner.next()) |e2| {676                            const desc = if (e2.value_ptr.* == .object)677                                if (e2.value_ptr.object.get("description")) |d|678                                    if (d == .string) d.string else ""679                                else ""680                            else "";681                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);682                        }683                    }684                }685            }686        }687        return;688    }689    if (std.mem.eql(u8, ctx.sub, "init")) {690        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);691        return;692    }693    if (std.mem.eql(u8, ctx.sub, "lock")) {694        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);695        return;696    }697    if (std.mem.eql(u8, ctx.sub, "clone")) {698        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;699        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);700        return;701    }702}703704// --- Store commands ---705706pub fn store(ctx: Ctx) !void {707    if (std.mem.eql(u8, ctx.sub, "gc")) {708        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);709        return;710    }711    if (std.mem.eql(u8, ctx.sub, "verify")) {712        output.storeVerifyStart();713        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);714        return;715    }716    if (std.mem.eql(u8, ctx.sub, "repair")) {717        if (ctx.cfg.confirm and !output.confirm("")) return;718        output.storeRepairStart();719        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);720        return;721    }722    if (std.mem.eql(u8, ctx.sub, "info")) {723        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);724        output.storeInfo(si);725        return;726    }727    if (std.mem.eql(u8, ctx.sub, "path")) {728        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;729        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);730        defer ctx.gpa.free(path);731        output.storePath(path);732        return;733    }734    // default: info735    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);736    output.storeInfo(si);737}738739// --- Channel commands ---740741pub fn channel(ctx: Ctx) !void {742    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {743        output.channelListHeader(ctx.machine.name);744        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);745        defer ctx.gpa.free(chans);746        for (chans) |ch| output.channelRow(ch);747        return;748    }749    if (std.mem.eql(u8, ctx.sub, "add")) {750        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;751        const name = if (ctx.args.len > 1) ctx.args[1] else "";752        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);753        output.channelDone();754        return;755    }756    if (std.mem.eql(u8, ctx.sub, "remove")) {757        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;758        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);759        output.channelDone();760        return;761    }762    if (std.mem.eql(u8, ctx.sub, "update")) {763        return channelUpdateCmd(ctx);764    }765}766767// --- Profile commands ---768769pub fn profile(ctx: Ctx) !void {770    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {771        output.profileListHeader();772        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;773        if (out) |o| {774            defer ctx.gpa.free(o);775            var lines = std.mem.splitScalar(u8, o, '\n');776            while (lines.next()) |line| {777                const t = std.mem.trim(u8, line, " \t\r");778                if (t.len == 0) continue;779                output.profileRow(t, "", "");780            }781        }782        return;783    }784    if (std.mem.eql(u8, ctx.sub, "install")) {785        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;786        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);787        output.done();788        return;789    }790    if (std.mem.eql(u8, ctx.sub, "remove")) {791        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;792        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);793        output.done();794        return;795    }796    if (std.mem.eql(u8, ctx.sub, "upgrade")) {797        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);798        output.done();799        return;800    }801}802803// --- Pkg commands ---804805pub fn pkg(ctx: Ctx) !void {806    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;807    if (std.mem.eql(u8, ctx.sub, "why")) {808        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);809        return;810    }811    if (std.mem.eql(u8, ctx.sub, "deps")) {812        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);813        return;814    }815    if (std.mem.eql(u8, ctx.sub, "size")) {816        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;817        if (s) |sz| {818            defer ctx.gpa.free(sz);819            output.pkgResult("size", sz);820        }821        return;822    }823    if (std.mem.eql(u8, ctx.sub, "path")) {824        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);825        defer ctx.gpa.free(p);826        output.pkgResult("path", p);827        return;828    }829    if (std.mem.eql(u8, ctx.sub, "closure")) {830        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);831        return;832    }833}834835// --- Pin / Unpin ---836837pub fn pin(ctx: Ctx) !void {838    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;839    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;840    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;841    output.pinDone(input, commit);842}843844pub fn unpin(ctx: Ctx) !void {845    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;846    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);847    output.unpinDone(input);848}849850// --- Hash / Fetch ---851852pub fn hash(ctx: Ctx) !void {853    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;854    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);855    defer ctx.gpa.free(h);856    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));857}858859pub fn fetch(ctx: Ctx) !void {860    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;861    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);862    defer ctx.gpa.free(h);863    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);864}865866// --- Build / Run ---867868pub fn build(ctx: Ctx) !void {869    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;870    output.buildStart(attr orelse "default");871    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);872    if (code != 0) return error.BuildFailed;873    output.buildDone();874}875876pub fn runCmd(ctx: Ctx) !void {877    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;878    output.runStart(pkg_arg);879    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);880}881882// --- Hello ---883884pub fn hello(ctx: Ctx) !void {885    output.hello(ctx.cfg.machines);886}887888pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {889    output.setupCheck();890    const status = exec.checkNixFeatures(gpa, io, environ);891    output.setupFeatures(status.nix_command, status.flakes);892893    if (status.allEnabled()) {894        output.setupAllSet();895        api.ensureRegistryPinned(gpa, io, environ);896        return;897    }898899    output.setupEnabling();900    const path = try exec.enableNixFeatures(gpa, io, environ);901    defer gpa.free(path);902903    output.setupDone(path, exec.isNixOS(io));904    api.ensureRegistryPinned(gpa, io, environ);905}906907// --- Log helper ---908909fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {910    var ts_buf: [32]u8 = undefined;911    const ts = nowIso(ctx.io, &ts_buf);912    var buf: [256]u8 = undefined;913    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{914        ts, ctx.machine.name, action, gen,915    }) catch return;916    exec.appendNinaLog(ctx.io, ctx.environ, entry);917}918919// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);920// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.921fn nowIso(io: std.Io, buf: []u8) []const u8 {922    const fallback = "1970-01-01T00:00:00Z";923    const ns = std.Io.Clock.real.now(io).toNanoseconds();924    if (ns <= 0) return fallback;925    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));926    const es = std.time.epoch.EpochSeconds{ .secs = secs };927    const ed = es.getEpochDay();928    const yd = ed.calculateYearDay();929    const md = yd.calculateMonthDay();930    const ds = es.getDaySeconds();931    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", .{932        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,933        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),934    }) catch return fallback;935}