commands.zig

34.4 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    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);147    defer ctx.gpa.free(st.size);148    defer ctx.gpa.free(st.age);149    output.statusRow(ctx.machine.name, gen, st.age, st.size, true);150}151152pub fn diff(ctx: Ctx) !void {153    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);154    defer ctx.gpa.free(gens);155    var current: u32 = 0;156    var prev: u32 = 0;157    for (gens) |g| {158        if (g.current) current = g.number;159        if (!g.current and g.number > prev and g.number < current) prev = g.number;160    }161    if (ctx.args.len >= 2) {162        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;163        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;164        output.diffHeader(a, b, ctx.machine.name);165        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;166    } else {167        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);168        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;169    }170}171172pub fn doctor(ctx: Ctx) !void {173    output.doctorHeader(ctx.machine.name);174    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);175    defer ctx.gpa.free(checks);176    var warnings: u32 = 0;177    var failures: u32 = 0;178    for (checks) |ch| {179        output.doctorRow(ch);180        if (ch.status == .warn) warnings += 1;181        if (ch.status == .fail) failures += 1;182    }183    output.doctorSummary(warnings, failures);184}185186// nina update — kepr self-update for the nina binary itself.187pub fn update(ctx: Ctx) !void {188    output.updateSelfStart();189190    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {191        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));192        return error.Internal;193    }194195    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");196    const source_url = env_url orelse UPDATE_URL;197198    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {199        error.NetworkError, error.HttpError => {200            output.updateSelfNetworkError();201            return;202        },203        else => return e,204    };205    defer ctx.gpa.free(body);206207    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {208        output.printWarning("could not parse release metadata");209        return;210    };211    defer parsed.deinit();212213    const root = parsed.value;214    if (root != .object) { output.printWarning("malformed release metadata"); return; }215216    const remote_ver = blk: {217        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };218        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };219    };220221    if (std.mem.eql(u8, remote_ver, version.VERSION)) {222        output.updateSelfCurrent(version.VERSION);223        return;224    }225226    output.updateSelfAvailable(version.VERSION, remote_ver);227    if (ctx.check) return;228229    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;230    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {231        errors.error_info.setMessage("could not determine install path", .{});232        return error.Internal;233    };234235    // When running from the nix store, defer to nix profile upgrade so nix236    // manages the transition (the store is read-only; binary replacement fails).237    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {238        output.updateSelfNixManaged(remote_ver);239        if (ctx.cfg.confirm and !output.confirm("")) return;240        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {241            output.updateSelfNixFailed();242            return;243        };244        if (ec != 0) output.updateSelfNixSystemManaged();245        return;246    }247248    const plat_info = blk: {249        const pv = root.object.get("platforms") orelse break :blk null;250        if (pv != .object) break :blk null;251        break :blk pv.object.get(version.PLATFORM);252    };253    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }254255    const dl_url = blk: {256        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };257        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };258    };259260    const raw_cksum = blk: {261        if (plat_info.?.object.get("blake3")) |v|262            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };263        if (plat_info.?.object.get("checksum")) |v|264            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };265        output.printWarning("missing checksum in release metadata");266        return;267    };268    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;269270    if (ctx.cfg.confirm and !output.confirm("")) return;271272    output.updateSelfDownloading(remote_ver, version.PLATFORM);273    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {274        output.printWarning("download failed — check your connection and try again");275        return;276    };277    defer ctx.gpa.free(tmp_path);278279    output.updateSelfVerifying();280    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {281        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },282        else => return e,283    };284285    output.updateSelfDone(version.VERSION, remote_ver);286}287288// nina goodbye — clean uninstall from nix profile or binary location.289pub fn goodbye(ctx: Ctx) !void {290    output.goodbyeStart();291292    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;293    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {294        errors.error_info.setMessage("could not determine install path", .{});295        return error.Internal;296    };297298    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {299        output.goodbyeNix();300        if (ctx.cfg.confirm and !output.confirm("")) return;301        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {302            output.goodbyeFailed("nix profile remove failed");303            return;304        };305        if (ec != 0) {306            output.goodbyeNixSystemManaged();307            return;308        }309    } else {310        output.goodbyeFile(install_path);311        if (ctx.cfg.confirm and !output.confirm("")) return;312        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {313            errors.error_info.setMessage("could not remove {s}", .{install_path});314            return e;315        };316    }317318    // Offer to remove ~/nina.nix if it exists (generated by the installer).319    if (ctx.environ.get("HOME")) |home| {320        const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;321        if (nina_nix) |path| {322            defer ctx.gpa.free(path);323            const exists = blk: {324                const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;325                ctx.gpa.free(bytes);326                break :blk true;327            };328            if (exists) {329                output.goodbyeNixFileFound(path);330                if (!ctx.cfg.confirm or output.confirm("")) {331                    std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {332                        output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");333                    };334                }335            }336        }337    }338339    output.goodbyeDone();340}341342fn channelUpdateCmd(ctx: Ctx) !void {343    output.updateStart(ctx.machine.name);344    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);345    output.updateDone();346}347348pub fn upgrade(ctx: Ctx) !void {349    output.upgradeStart(ctx.machine.name);350    output.printSubstep("updating channels", .{});351    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};352    output.printSubstep("rebuilding", .{});353    const start = std.Io.Clock.awake.now(ctx.io);354    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);355    if (code != 0) {356        output.applyFailed("");357        return error.BuildFailed;358    }359    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());360    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));361    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;362    output.applyDone(gen, elapsed);363}364365pub fn log(ctx: Ctx) !void {366    output.logHeader(ctx.machine.name);367    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {368        // No log yet — that's fine369        return;370    };371    defer ctx.gpa.free(content);372    var lines = std.mem.splitScalar(u8, content, '\n');373    while (lines.next()) |line| {374        const t = std.mem.trim(u8, line, " \t\r");375        if (t.len == 0) continue;376        if (t[0] == '{') {377            renderJsonLogLine(ctx.gpa, t);378        } else {379            // legacy tab-separated format380            var parts = std.mem.tokenizeScalar(u8, t, '\t');381            const date = parts.next() orelse continue;382            const time = parts.next() orelse "";383            const action = parts.next() orelse "";384            const detail = parts.next() orelse "";385            output.logRow(date, time, action, detail);386        }387    }388}389390// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.391// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.392fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {393    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {394        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop395        return;396    };397    defer parsed.deinit();398    if (parsed.value != .object) return;399    const o = parsed.value.object;400401    const ts = jsonStr(o, "ts");402    const date = if (ts.len >= 10) ts[0..10] else ts;403    const time = if (ts.len >= 16) ts[11..16] else "";404    const action = jsonStr(o, "command");405    const outcome = jsonStr(o, "outcome");406407    var buf: [160]u8 = undefined;408    var detail: []const u8 = outcome;409    if (o.get("gen_after")) |g| {410        if (g == .integer) {411            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;412        }413    }414    output.logRow(date, time, action, detail);415}416417fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {418    if (o.get(key)) |v| {419        if (v == .string) return v.string;420    }421    return "";422}423424pub fn edit(ctx: Ctx) !void {425    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");426    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";427    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });428    defer ctx.gpa.free(path);429    output.editOpen(path, ctx.cfg.editor);430    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);431}432433pub fn fmt(ctx: Ctx) !void {434    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});435    defer ctx.gpa.free(path);436    if (ctx.check) {437        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);438        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();439        return;440    }441    output.fmtStart(path);442    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);443    output.fmtDone();444}445446pub fn repl(ctx: Ctx) !void {447    output.replStart();448    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);449}450451pub fn develop(ctx: Ctx) !void {452    if (std.mem.eql(u8, ctx.sub, "show")) {453        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);454        return;455    }456    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))457        ctx.args[0]458    else459        null;460    output.developStart(&.{});461    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);462    output.developDone();463}464465pub fn info(ctx: Ctx) !void {466    output.infoHeader(ctx.machine.name);467    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");468    defer ctx.gpa.free(nixos_ver);469    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));470471    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");472    defer ctx.gpa.free(kernel);473    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));474475    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");476    defer ctx.gpa.free(uptime);477    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));478}479480pub fn boot(ctx: Ctx) !void {481    output.bootHeader(ctx.machine.name);482    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};483    defer ctx.gpa.free(entries);484    for (entries, 0..) |entry, i| output.bootEntry(entry, i);485}486487pub fn searchCmd(ctx: Ctx) !void {488    const query = if (ctx.args.len > 0) ctx.args[0] else "";489    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);490    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);491    if (result) |r| {492        installFromResult(ctx, r) catch |e| return e;493    }494}495496pub fn optionCmd(ctx: Ctx) !void {497    const query = if (ctx.args.len > 0) ctx.args[0] else "";498    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);499    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);500}501502pub fn install(ctx: Ctx) !void {503    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;504505    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);506507    // Search for the package to get the attr508    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {509        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});510        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});511        return error.PackageNotFound;512    };513    defer ctx.gpa.free(pkgs);514515    if (pkgs.len == 0) {516        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});517        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});518        return error.PackageNotFound;519    }520521    // Prefer an exact name match over the first search result — `install ripgrep`522    // must pick "ripgrep", not the alphabetically-first match like "repgrep".523    var found = pkgs[0];524    for (pkgs) |p| {525        if (std.mem.eql(u8, p.pname, pkg_name)) {526            found = p;527            break;528        }529    }530    output.installSelectPrompt(found.pname, found.version);531532    var choice_buf: [8]u8 = undefined;533    var choice_len: usize = 0;534    while (choice_len < choice_buf.len) {535        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;536        if (n == 0) break;537        if (choice_buf[choice_len] == '\n') break;538        choice_len += 1;539    }540    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");541542    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {543        try profileInstallPkg(ctx, found);544    } else if (choice[0] == 's' or choice[0] == 'S') {545        try systemInstallPkg(ctx, found);546    } else if (choice[0] == 't' or choice[0] == 'T') {547        try tryPkg(ctx, found);548    }549}550551fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {552    switch (result.action) {553        .profile => try profileInstallPkg(ctx, result.package),554        .system => try systemInstallPkg(ctx, result.package),555    }556}557558fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {559    output.installStart(npkg.pname, .profile);560    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});561    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));562    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);563    if (code != 0) return error.ProcessFailed;564    output.installDone(npkg.pname);565}566567fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {568    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});569    defer ctx.gpa.free(config_path);570    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;571    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);572    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);573    if (ctx.cfg.confirm) {574        if (output.confirm("apply now? ")) {575            try apply(ctx);576        }577    }578}579580fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {581    output.tryStart(npkg.pname);582    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});583    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);584    output.tryDone();585}586587pub fn remove(ctx: Ctx) !void {588    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;589    output.removeStart(pkg_name);590    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);591    if (code != 0) return error.ProcessFailed;592    output.done();593}594595pub fn list(ctx: Ctx) !void {596    output.listHeader();597    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;598    if (profile_out) |out| {599        defer ctx.gpa.free(out);600        output.listSection("profile");601        var lines = std.mem.splitScalar(u8, out, '\n');602        while (lines.next()) |line| {603            const t = std.mem.trim(u8, line, " \t\r");604            if (t.len == 0) continue;605            output.listPkg(t, "");606        }607    }608}609610pub fn tryCmd(ctx: Ctx) !void {611    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;612    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };613    try tryPkg(ctx, shell_pkg);614}615616// --- Service commands ---617618pub fn service(ctx: Ctx) !void {619    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {620        output.serviceListHeader(ctx.machine.name);621        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};622        defer ctx.gpa.free(svcs);623        for (svcs) |s| output.serviceRow(s);624        return;625    }626    if (std.mem.eql(u8, ctx.sub, "logs")) {627        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;628        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");629        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);630        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);631        return;632    }633    if (std.mem.eql(u8, ctx.sub, "status")) {634        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;635        output.serviceVerb("status", name, ctx.machine.name);636        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);637        return;638    }639    // start/stop/restart/enable/disable640    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;641    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {642        if (!output.confirm("")) return;643    }644    output.serviceVerb(ctx.sub, name, ctx.machine.name);645    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);646    output.serviceDone();647}648649// --- Flake commands ---650651pub fn flake(ctx: Ctx) !void {652    if (std.mem.eql(u8, ctx.sub, "update")) {653        output.flakeUpdateStart();654        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;655        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);656        output.flakeLockWritten();657        return;658    }659    if (std.mem.eql(u8, ctx.sub, "check")) {660        output.flakeCheckStart();661        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);662        if (code == 0) output.flakeValid() else output.checkFailed("");663        return;664    }665    if (std.mem.eql(u8, ctx.sub, "show")) {666        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;667        if (json_out) |j| {668            defer ctx.gpa.free(j);669            output.flakeShowHeader("flake outputs");670            // Parse and display the flake outputs671            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;672            defer parsed.deinit();673            if (parsed.value == .object) {674                var it = parsed.value.object.iterator();675                while (it.next()) |entry| {676                    if (entry.value_ptr.* == .object) {677                        var inner = entry.value_ptr.object.iterator();678                        while (inner.next()) |e2| {679                            const desc = if (e2.value_ptr.* == .object)680                                if (e2.value_ptr.object.get("description")) |d|681                                    if (d == .string) d.string else ""682                                else ""683                            else "";684                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);685                        }686                    }687                }688            }689        }690        return;691    }692    if (std.mem.eql(u8, ctx.sub, "init")) {693        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);694        return;695    }696    if (std.mem.eql(u8, ctx.sub, "lock")) {697        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);698        return;699    }700    if (std.mem.eql(u8, ctx.sub, "clone")) {701        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;702        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);703        return;704    }705}706707// --- Store commands ---708709pub fn store(ctx: Ctx) !void {710    if (std.mem.eql(u8, ctx.sub, "gc")) {711        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);712        return;713    }714    if (std.mem.eql(u8, ctx.sub, "verify")) {715        output.storeVerifyStart();716        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);717        return;718    }719    if (std.mem.eql(u8, ctx.sub, "repair")) {720        if (ctx.cfg.confirm and !output.confirm("")) return;721        output.storeRepairStart();722        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);723        return;724    }725    if (std.mem.eql(u8, ctx.sub, "info")) {726        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);727        output.storeInfo(si);728        return;729    }730    if (std.mem.eql(u8, ctx.sub, "path")) {731        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;732        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);733        defer ctx.gpa.free(path);734        output.storePath(path);735        return;736    }737    // default: info738    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);739    output.storeInfo(si);740}741742// --- Channel commands ---743744pub fn channel(ctx: Ctx) !void {745    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {746        output.channelListHeader(ctx.machine.name);747        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);748        defer ctx.gpa.free(chans);749        for (chans) |ch| output.channelRow(ch);750        return;751    }752    if (std.mem.eql(u8, ctx.sub, "add")) {753        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;754        const name = if (ctx.args.len > 1) ctx.args[1] else "";755        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);756        output.channelDone();757        return;758    }759    if (std.mem.eql(u8, ctx.sub, "remove")) {760        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;761        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);762        output.channelDone();763        return;764    }765    if (std.mem.eql(u8, ctx.sub, "update")) {766        return channelUpdateCmd(ctx);767    }768}769770// --- Profile commands ---771772pub fn profile(ctx: Ctx) !void {773    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {774        output.profileListHeader();775        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;776        if (out) |o| {777            defer ctx.gpa.free(o);778            var lines = std.mem.splitScalar(u8, o, '\n');779            while (lines.next()) |line| {780                const t = std.mem.trim(u8, line, " \t\r");781                if (t.len == 0) continue;782                output.profileRow(t, "", "");783            }784        }785        return;786    }787    if (std.mem.eql(u8, ctx.sub, "install")) {788        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;789        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);790        output.done();791        return;792    }793    if (std.mem.eql(u8, ctx.sub, "remove")) {794        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;795        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);796        output.done();797        return;798    }799    if (std.mem.eql(u8, ctx.sub, "upgrade")) {800        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);801        output.done();802        return;803    }804}805806// --- Pkg commands ---807808pub fn pkg(ctx: Ctx) !void {809    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;810    if (std.mem.eql(u8, ctx.sub, "why")) {811        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);812        return;813    }814    if (std.mem.eql(u8, ctx.sub, "deps")) {815        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);816        return;817    }818    if (std.mem.eql(u8, ctx.sub, "size")) {819        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;820        if (s) |sz| {821            defer ctx.gpa.free(sz);822            output.pkgResult("size", sz);823        }824        return;825    }826    if (std.mem.eql(u8, ctx.sub, "path")) {827        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);828        defer ctx.gpa.free(p);829        output.pkgResult("path", p);830        return;831    }832    if (std.mem.eql(u8, ctx.sub, "closure")) {833        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);834        return;835    }836}837838// --- Pin / Unpin ---839840pub fn pin(ctx: Ctx) !void {841    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;842    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;843    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;844    output.pinDone(input, commit);845}846847pub fn unpin(ctx: Ctx) !void {848    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;849    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);850    output.unpinDone(input);851}852853// --- Hash / Fetch ---854855pub fn hash(ctx: Ctx) !void {856    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;857    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);858    defer ctx.gpa.free(h);859    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));860}861862pub fn fetch(ctx: Ctx) !void {863    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;864    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);865    defer ctx.gpa.free(h);866    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);867}868869// --- Build / Run ---870871pub fn build(ctx: Ctx) !void {872    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;873    output.buildStart(attr orelse "default");874    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);875    if (code != 0) return error.BuildFailed;876    output.buildDone();877}878879pub fn runCmd(ctx: Ctx) !void {880    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;881    output.runStart(pkg_arg);882    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);883}884885// --- Hello ---886887pub fn hello(ctx: Ctx) !void {888    output.hello(ctx.cfg.machines);889}890891pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {892    output.setupCheck();893    const status = exec.checkNixFeatures(gpa, io, environ);894    output.setupFeatures(status.nix_command, status.flakes);895896    if (status.allEnabled()) {897        output.setupAllSet();898        api.ensureRegistryPinned(gpa, io, environ);899        return;900    }901902    output.setupEnabling();903    const path = try exec.enableNixFeatures(gpa, io, environ);904    defer gpa.free(path);905906    output.setupDone(path, exec.isNixOS(io));907    api.ensureRegistryPinned(gpa, io, environ);908}909910// --- Log helper ---911912fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {913    var ts_buf: [32]u8 = undefined;914    const ts = nowIso(ctx.io, &ts_buf);915    var buf: [256]u8 = undefined;916    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{917        ts, ctx.machine.name, action, gen,918    }) catch return;919    exec.appendNinaLog(ctx.io, ctx.environ, entry);920}921922// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);923// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.924fn nowIso(io: std.Io, buf: []u8) []const u8 {925    const fallback = "1970-01-01T00:00:00Z";926    const ns = std.Io.Clock.real.now(io).toNanoseconds();927    if (ns <= 0) return fallback;928    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));929    const es = std.time.epoch.EpochSeconds{ .secs = secs };930    const ed = es.getEpochDay();931    const yd = ed.calculateYearDay();932    const md = yd.calculateMonthDay();933    const ds = es.getDaySeconds();934    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", .{935        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,936        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),937    }) catch return fallback;938}