commands.zig

35.0 KB · raw

1// commands.zig — all command handlers2// Orchestrates exec/api/output. Zero direct stdout writes.3const std = @import("std");4const builtin = @import("builtin");5const types = @import("types.zig");6const errors = @import("errors.zig");7const config = @import("config.zig");8const output = @import("output.zig");9const exec = @import("exec.zig");10const api = @import("api.zig");11const search_tui = @import("search.zig");12const version = @import("version.zig");1314const UPDATE_URL = "https://kepr.uk/api/nina/releases/latest";1516pub const Ctx = struct {17    gpa: std.mem.Allocator,18    io: std.Io,19    environ: *const std.process.Environ.Map,20    machine: types.Machine,21    cfg: types.NinaConfig,22    args: []const []const u8,   // positional args after subcommand23    sub: []const u8,            // subcommand (e.g. "list" in "service list")24    dry: bool,25    check: bool,26    no_apply: bool,27    all: bool,28    last: u32,29    json: bool,30};3132pub fn apply(ctx: Ctx) !void {33    if (ctx.dry) {34        output.applyDry(ctx.machine.name);35        const code = try exec.nixosRebuildDryActivate(ctx.io, &ctx.machine, ctx.gpa);36        if (code != 0) return error.BuildFailed;37        output.applyDryDone();38        return;39    }40    if (ctx.check) {41        return check(ctx);42    }4344    output.applyStart(ctx.machine.name);45    const start = std.Io.Clock.awake.now(ctx.io);4647    const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {48        exec.markApplyFailed(ctx.io, ctx.environ);49        output.applyFailed("");50        return e;51    };5253    if (code != 0) {54        exec.markApplyFailed(ctx.io, ctx.environ);55        output.applyFailed("");56        return error.BuildFailed;57    }5859    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());60    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));61    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;62    const after_failure = exec.takeApplyFailed(ctx.io, ctx.environ);63    output.applyDone(gen, elapsed, after_failure);64    logAction(ctx, "apply", gen);65}6667pub fn check(ctx: Ctx) !void {68    output.checkStart(ctx.machine.name);69    const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);70    if (code != 0) {71        output.checkFailed("");72        return error.BuildFailed;73    }74    output.checkOk();75}7677pub fn back(ctx: Ctx) !void {78    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);79    defer ctx.gpa.free(gens);8081    var current: u32 = 0;82    for (gens) |g| if (g.current) { current = g.number; };83    const prev = if (current > 1) current - 1 else current;8485    output.backStart(ctx.machine.name, current, prev);86    if (ctx.cfg.confirm and !output.confirm("")) return;8788    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);89    if (code != 0) return error.BuildFailed;90    output.backDone(prev);91}9293pub fn go(ctx: Ctx) !void {94    if (ctx.args.len == 0) return error.Internal;95    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;96    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);9798    output.goStart(ctx.machine.name, current, target);99    if (ctx.cfg.confirm and !output.confirm("")) return;100101    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);102    if (code != 0) return error.ProcessFailed;103    output.generationDone(target);104}105106pub fn history(ctx: Ctx) !void {107    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);108    defer ctx.gpa.free(gens);109    output.historyHeader(ctx.machine.name);110    for (gens) |g| output.historyRow(g);111}112113pub fn genCmd(ctx: Ctx) !void {114    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);115    if (std.mem.eql(u8, ctx.sub, "current")) {116        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);117        output.genCurrent(n);118        return;119    }120    if (std.mem.eql(u8, ctx.sub, "delete")) {121        if (ctx.args.len == 0) return error.Internal;122        if (std.mem.eql(u8, ctx.args[0], "old")) {123            if (ctx.cfg.confirm and !output.confirm("")) return;124            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);125        } else {126            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;127            if (ctx.cfg.confirm and !output.confirm("")) return;128            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);129        }130        output.done();131        return;132    }133    return error.Internal;134}135136pub fn clean(ctx: Ctx) !void {137    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);138    defer ctx.gpa.free(gens);139    output.cleanStart(ctx.machine.name, ctx.cfg.generations, @intCast(gens.len));140    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;141    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");142    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all, ctx.cfg.generations);143    output.cleanDone("?");144}145146pub fn statusCmd(ctx: Ctx) !void {147    output.statusHeader();148    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;149    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);150    defer ctx.gpa.free(st.size);151    defer ctx.gpa.free(st.age);152    output.statusRow(ctx.machine.name, gen, st.age, st.size, true);153}154155pub fn diff(ctx: Ctx) !void {156    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);157    defer ctx.gpa.free(gens);158    var current: u32 = 0;159    var prev: u32 = 0;160    for (gens) |g| {161        if (g.current) current = g.number;162        if (!g.current and g.number > prev and g.number < current) prev = g.number;163    }164    if (ctx.args.len >= 2) {165        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;166        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;167        output.diffHeader(a, b, ctx.machine.name);168        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;169    } else {170        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);171        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;172    }173}174175pub fn mood(ctx: Ctx) !void {176    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;177    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);178    defer ctx.gpa.free(st.size);179    defer ctx.gpa.free(st.age);180    const state = exec.systemRunningState(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");181    defer ctx.gpa.free(state);182    output.mood(ctx.machine.name, state, gen, st.age, st.size);183}184185pub fn doctor(ctx: Ctx) !void {186    output.doctorHeader(ctx.machine.name);187    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);188    defer ctx.gpa.free(checks);189    var warnings: u32 = 0;190    var failures: u32 = 0;191    for (checks) |ch| {192        output.doctorRow(ch);193        if (ch.status == .warn) warnings += 1;194        if (ch.status == .fail) failures += 1;195    }196    output.doctorSummary(warnings, failures);197}198199// nina update — kepr self-update for the nina binary itself.200pub fn update(ctx: Ctx) !void {201    output.updateSelfStart();202203    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {204        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));205        return error.Internal;206    }207208    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");209    const source_url = env_url orelse UPDATE_URL;210211    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {212        error.NetworkError, error.HttpError => {213            output.updateSelfNetworkError();214            return;215        },216        else => return e,217    };218    defer ctx.gpa.free(body);219220    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {221        output.printWarning("could not parse release metadata");222        return;223    };224    defer parsed.deinit();225226    const root = parsed.value;227    if (root != .object) { output.printWarning("malformed release metadata"); return; }228229    const remote_ver = blk: {230        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };231        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };232    };233234    if (std.mem.eql(u8, remote_ver, version.VERSION)) {235        output.updateSelfCurrent(version.VERSION);236        return;237    }238239    output.updateSelfAvailable(version.VERSION, remote_ver);240    if (ctx.check) return;241242    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;243    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {244        errors.error_info.setMessage("could not determine install path", .{});245        return error.Internal;246    };247248    // When running from the nix store, defer to nix profile upgrade so nix249    // manages the transition (the store is read-only; binary replacement fails).250    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {251        output.updateSelfNixManaged(remote_ver);252        if (ctx.cfg.confirm and !output.confirm("")) return;253        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {254            output.updateSelfNixFailed();255            return;256        };257        if (ec != 0) output.updateSelfNixSystemManaged();258        return;259    }260261    const plat_info = blk: {262        const pv = root.object.get("platforms") orelse break :blk null;263        if (pv != .object) break :blk null;264        break :blk pv.object.get(version.PLATFORM);265    };266    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }267268    const dl_url = blk: {269        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };270        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };271    };272273    const raw_cksum = blk: {274        if (plat_info.?.object.get("blake3")) |v|275            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };276        if (plat_info.?.object.get("checksum")) |v|277            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };278        output.printWarning("missing checksum in release metadata");279        return;280    };281    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;282283    if (ctx.cfg.confirm and !output.confirm("")) return;284285    output.updateSelfDownloading(remote_ver, version.PLATFORM);286    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {287        output.printWarning("download failed — check your connection and try again");288        return;289    };290    defer ctx.gpa.free(tmp_path);291292    output.updateSelfVerifying();293    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {294        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },295        else => return e,296    };297298    output.updateSelfDone(version.VERSION, remote_ver);299}300301// nina goodbye — clean uninstall from nix profile or binary location.302pub fn goodbye(ctx: Ctx) !void {303    output.goodbyeStart();304305    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;306    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {307        errors.error_info.setMessage("could not determine install path", .{});308        return error.Internal;309    };310311    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {312        output.goodbyeNix();313        if (ctx.cfg.confirm and !output.confirm("")) return;314        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {315            output.goodbyeFailed("nix profile remove failed");316            return;317        };318        if (ec != 0) {319            output.goodbyeNixSystemManaged();320            return;321        }322    } else {323        output.goodbyeFile(install_path);324        if (ctx.cfg.confirm and !output.confirm("")) return;325        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {326            errors.error_info.setMessage("could not remove {s}", .{install_path});327            return e;328        };329    }330331    // Offer to remove ~/nina.nix if it exists (generated by the installer).332    if (ctx.environ.get("HOME")) |home| {333        const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;334        if (nina_nix) |path| {335            defer ctx.gpa.free(path);336            const exists = blk: {337                const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;338                ctx.gpa.free(bytes);339                break :blk true;340            };341            if (exists) {342                output.goodbyeNixFileFound(path);343                if (!ctx.cfg.confirm or output.confirm("")) {344                    std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {345                        output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");346                    };347                }348            }349        }350    }351352    output.goodbyeDone();353}354355fn channelUpdateCmd(ctx: Ctx) !void {356    output.updateStart(ctx.machine.name);357    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);358    output.updateDone();359}360361pub fn upgrade(ctx: Ctx) !void {362    output.upgradeStart(ctx.machine.name);363    output.printSubstep("updating channels", .{});364    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};365    output.printSubstep("rebuilding", .{});366    const start = std.Io.Clock.awake.now(ctx.io);367    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);368    if (code != 0) {369        output.applyFailed("");370        return error.BuildFailed;371    }372    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());373    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));374    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;375    output.applyDone(gen, elapsed, false);376}377378pub fn log(ctx: Ctx) !void {379    output.logHeader(ctx.machine.name);380    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {381        // No log yet — that's fine382        return;383    };384    defer ctx.gpa.free(content);385    var lines = std.mem.splitScalar(u8, content, '\n');386    while (lines.next()) |line| {387        const t = std.mem.trim(u8, line, " \t\r");388        if (t.len == 0) continue;389        if (t[0] == '{') {390            renderJsonLogLine(ctx.gpa, t);391        } else {392            // legacy tab-separated format393            var parts = std.mem.tokenizeScalar(u8, t, '\t');394            const date = parts.next() orelse continue;395            const time = parts.next() orelse "";396            const action = parts.next() orelse "";397            const detail = parts.next() orelse "";398            output.logRow(date, time, action, detail);399        }400    }401}402403// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.404// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.405fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {406    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {407        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop408        return;409    };410    defer parsed.deinit();411    if (parsed.value != .object) return;412    const o = parsed.value.object;413414    const ts = jsonStr(o, "ts");415    const date = if (ts.len >= 10) ts[0..10] else ts;416    const time = if (ts.len >= 16) ts[11..16] else "";417    const action = jsonStr(o, "command");418    const outcome = jsonStr(o, "outcome");419420    var buf: [160]u8 = undefined;421    var detail: []const u8 = outcome;422    if (o.get("gen_after")) |g| {423        if (g == .integer) {424            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;425        }426    }427    output.logRow(date, time, action, detail);428}429430fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {431    if (o.get(key)) |v| {432        if (v == .string) return v.string;433    }434    return "";435}436437pub fn edit(ctx: Ctx) !void {438    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");439    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";440    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });441    defer ctx.gpa.free(path);442    output.editOpen(path, ctx.cfg.editor);443    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);444}445446pub fn fmt(ctx: Ctx) !void {447    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});448    defer ctx.gpa.free(path);449    if (ctx.check) {450        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);451        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();452        return;453    }454    output.fmtStart(path);455    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);456    output.fmtDone();457}458459pub fn repl(ctx: Ctx) !void {460    output.replStart();461    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);462}463464pub fn develop(ctx: Ctx) !void {465    if (std.mem.eql(u8, ctx.sub, "show")) {466        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);467        return;468    }469    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))470        ctx.args[0]471    else472        null;473    output.developStart(&.{});474    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);475    output.developDone();476}477478pub fn info(ctx: Ctx) !void {479    output.infoHeader(ctx.machine.name);480    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");481    defer ctx.gpa.free(nixos_ver);482    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));483484    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");485    defer ctx.gpa.free(kernel);486    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));487488    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");489    defer ctx.gpa.free(uptime);490    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));491}492493pub fn boot(ctx: Ctx) !void {494    output.bootHeader(ctx.machine.name);495    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};496    defer ctx.gpa.free(entries);497    for (entries, 0..) |entry, i| output.bootEntry(entry, i);498}499500pub fn searchCmd(ctx: Ctx) !void {501    const query = if (ctx.args.len > 0) ctx.args[0] else "";502    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);503    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);504    if (result) |r| {505        installFromResult(ctx, r) catch |e| return e;506    }507}508509pub fn optionCmd(ctx: Ctx) !void {510    const query = if (ctx.args.len > 0) ctx.args[0] else "";511    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);512    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);513}514515pub fn install(ctx: Ctx) !void {516    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;517518    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);519520    // Search for the package to get the attr521    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {522        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});523        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});524        return error.PackageNotFound;525    };526    defer ctx.gpa.free(pkgs);527528    if (pkgs.len == 0) {529        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});530        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});531        return error.PackageNotFound;532    }533534    // Prefer an exact name match over the first search result — `install ripgrep`535    // must pick "ripgrep", not the alphabetically-first match like "repgrep".536    var found = pkgs[0];537    for (pkgs) |p| {538        if (std.mem.eql(u8, p.pname, pkg_name)) {539            found = p;540            break;541        }542    }543    output.installSelectPrompt(found.pname, found.version);544545    var choice_buf: [8]u8 = undefined;546    var choice_len: usize = 0;547    while (choice_len < choice_buf.len) {548        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;549        if (n == 0) break;550        if (choice_buf[choice_len] == '\n') break;551        choice_len += 1;552    }553    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");554555    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {556        try profileInstallPkg(ctx, found);557    } else if (choice[0] == 's' or choice[0] == 'S') {558        try systemInstallPkg(ctx, found);559    } else if (choice[0] == 't' or choice[0] == 'T') {560        try tryPkg(ctx, found);561    }562}563564fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {565    switch (result.action) {566        .profile => try profileInstallPkg(ctx, result.package),567        .system => try systemInstallPkg(ctx, result.package),568    }569}570571fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {572    output.installStart(npkg.pname, .profile);573    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});574    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));575    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);576    if (code != 0) return error.ProcessFailed;577    output.installDone(npkg.pname);578}579580fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {581    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});582    defer ctx.gpa.free(config_path);583    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;584    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);585    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);586    if (ctx.cfg.confirm) {587        if (output.confirm("apply now? ")) {588            try apply(ctx);589        }590    }591}592593fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {594    output.tryStart(npkg.pname);595    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});596    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);597    output.tryDone();598}599600pub fn remove(ctx: Ctx) !void {601    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;602    output.removeStart(pkg_name);603    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);604    if (code != 0) return error.ProcessFailed;605    output.done();606}607608pub fn list(ctx: Ctx) !void {609    output.listHeader();610    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;611    if (profile_out) |out| {612        defer ctx.gpa.free(out);613        output.listSection("profile");614        var lines = std.mem.splitScalar(u8, out, '\n');615        while (lines.next()) |line| {616            const t = std.mem.trim(u8, line, " \t\r");617            if (t.len == 0) continue;618            output.listPkg(t, "");619        }620    }621}622623pub fn tryCmd(ctx: Ctx) !void {624    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;625    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };626    try tryPkg(ctx, shell_pkg);627}628629// --- Service commands ---630631pub fn service(ctx: Ctx) !void {632    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {633        output.serviceListHeader(ctx.machine.name);634        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};635        defer ctx.gpa.free(svcs);636        for (svcs) |s| output.serviceRow(s);637        return;638    }639    if (std.mem.eql(u8, ctx.sub, "logs")) {640        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;641        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");642        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);643        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);644        return;645    }646    if (std.mem.eql(u8, ctx.sub, "status")) {647        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;648        output.serviceVerb("status", name, ctx.machine.name);649        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);650        return;651    }652    // start/stop/restart/enable/disable653    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;654    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {655        if (!output.confirm("")) return;656    }657    output.serviceVerb(ctx.sub, name, ctx.machine.name);658    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);659    output.serviceDone();660}661662// --- Flake commands ---663664pub fn flake(ctx: Ctx) !void {665    if (std.mem.eql(u8, ctx.sub, "update")) {666        output.flakeUpdateStart();667        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;668        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);669        output.flakeLockWritten();670        return;671    }672    if (std.mem.eql(u8, ctx.sub, "check")) {673        output.flakeCheckStart();674        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);675        if (code == 0) output.flakeValid() else output.checkFailed("");676        return;677    }678    if (std.mem.eql(u8, ctx.sub, "show")) {679        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;680        if (json_out) |j| {681            defer ctx.gpa.free(j);682            output.flakeShowHeader("flake outputs");683            // Parse and display the flake outputs684            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;685            defer parsed.deinit();686            if (parsed.value == .object) {687                var it = parsed.value.object.iterator();688                while (it.next()) |entry| {689                    if (entry.value_ptr.* == .object) {690                        var inner = entry.value_ptr.object.iterator();691                        while (inner.next()) |e2| {692                            const desc = if (e2.value_ptr.* == .object)693                                if (e2.value_ptr.object.get("description")) |d|694                                    if (d == .string) d.string else ""695                                else ""696                            else "";697                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);698                        }699                    }700                }701            }702        }703        return;704    }705    if (std.mem.eql(u8, ctx.sub, "init")) {706        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);707        return;708    }709    if (std.mem.eql(u8, ctx.sub, "lock")) {710        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);711        return;712    }713    if (std.mem.eql(u8, ctx.sub, "clone")) {714        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;715        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);716        return;717    }718}719720// --- Store commands ---721722pub fn store(ctx: Ctx) !void {723    if (std.mem.eql(u8, ctx.sub, "gc")) {724        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);725        return;726    }727    if (std.mem.eql(u8, ctx.sub, "verify")) {728        output.storeVerifyStart();729        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);730        return;731    }732    if (std.mem.eql(u8, ctx.sub, "repair")) {733        if (ctx.cfg.confirm and !output.confirm("")) return;734        output.storeRepairStart();735        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);736        return;737    }738    if (std.mem.eql(u8, ctx.sub, "info")) {739        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);740        output.storeInfo(si);741        return;742    }743    if (std.mem.eql(u8, ctx.sub, "path")) {744        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;745        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);746        defer ctx.gpa.free(path);747        output.storePath(path);748        return;749    }750    // default: info751    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);752    output.storeInfo(si);753}754755// --- Channel commands ---756757pub fn channel(ctx: Ctx) !void {758    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {759        output.channelListHeader(ctx.machine.name);760        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);761        defer ctx.gpa.free(chans);762        for (chans) |ch| output.channelRow(ch);763        return;764    }765    if (std.mem.eql(u8, ctx.sub, "add")) {766        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;767        const name = if (ctx.args.len > 1) ctx.args[1] else "";768        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);769        output.channelDone();770        return;771    }772    if (std.mem.eql(u8, ctx.sub, "remove")) {773        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;774        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);775        output.channelDone();776        return;777    }778    if (std.mem.eql(u8, ctx.sub, "update")) {779        return channelUpdateCmd(ctx);780    }781}782783// --- Profile commands ---784785pub fn profile(ctx: Ctx) !void {786    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {787        output.profileListHeader();788        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;789        if (out) |o| {790            defer ctx.gpa.free(o);791            var lines = std.mem.splitScalar(u8, o, '\n');792            while (lines.next()) |line| {793                const t = std.mem.trim(u8, line, " \t\r");794                if (t.len == 0) continue;795                output.profileRow(t, "", "");796            }797        }798        return;799    }800    if (std.mem.eql(u8, ctx.sub, "install")) {801        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;802        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);803        output.done();804        return;805    }806    if (std.mem.eql(u8, ctx.sub, "remove")) {807        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;808        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);809        output.done();810        return;811    }812    if (std.mem.eql(u8, ctx.sub, "upgrade")) {813        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);814        output.done();815        return;816    }817}818819// --- Pkg commands ---820821pub fn pkg(ctx: Ctx) !void {822    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;823    if (std.mem.eql(u8, ctx.sub, "why")) {824        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);825        return;826    }827    if (std.mem.eql(u8, ctx.sub, "deps")) {828        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);829        return;830    }831    if (std.mem.eql(u8, ctx.sub, "size")) {832        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;833        if (s) |sz| {834            defer ctx.gpa.free(sz);835            output.pkgResult("size", sz);836        }837        return;838    }839    if (std.mem.eql(u8, ctx.sub, "path")) {840        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);841        defer ctx.gpa.free(p);842        output.pkgResult("path", p);843        return;844    }845    if (std.mem.eql(u8, ctx.sub, "closure")) {846        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);847        return;848    }849}850851// --- Pin / Unpin ---852853pub fn pin(ctx: Ctx) !void {854    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;855    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;856    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;857    output.pinDone(input, commit);858}859860pub fn unpin(ctx: Ctx) !void {861    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;862    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);863    output.unpinDone(input);864}865866// --- Hash / Fetch ---867868pub fn hash(ctx: Ctx) !void {869    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;870    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);871    defer ctx.gpa.free(h);872    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));873}874875pub fn fetch(ctx: Ctx) !void {876    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;877    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);878    defer ctx.gpa.free(h);879    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);880}881882// --- Build / Run ---883884pub fn build(ctx: Ctx) !void {885    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;886    output.buildStart(attr orelse "default");887    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);888    if (code != 0) return error.BuildFailed;889    output.buildDone();890}891892pub fn runCmd(ctx: Ctx) !void {893    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;894    output.runStart(pkg_arg);895    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);896}897898// --- Hello ---899900pub fn hello(ctx: Ctx) !void {901    output.hello(ctx.cfg.machines);902}903904pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {905    output.setupCheck();906    const status = exec.checkNixFeatures(gpa, io, environ);907    output.setupFeatures(status.nix_command, status.flakes);908909    if (status.allEnabled()) {910        output.setupAllSet();911        api.ensureRegistryPinned(gpa, io, environ);912        return;913    }914915    output.setupEnabling();916    const path = try exec.enableNixFeatures(gpa, io, environ);917    defer gpa.free(path);918919    output.setupDone(path, exec.isNixOS(io));920    api.ensureRegistryPinned(gpa, io, environ);921}922923// --- Log helper ---924925fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {926    var ts_buf: [32]u8 = undefined;927    const ts = nowIso(ctx.io, &ts_buf);928    var buf: [256]u8 = undefined;929    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{930        ts, ctx.machine.name, action, gen,931    }) catch return;932    exec.appendNinaLog(ctx.io, ctx.environ, entry);933}934935// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);936// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.937fn nowIso(io: std.Io, buf: []u8) []const u8 {938    const fallback = "1970-01-01T00:00:00Z";939    const ns = std.Io.Clock.real.now(io).toNanoseconds();940    if (ns <= 0) return fallback;941    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));942    const es = std.time.epoch.EpochSeconds{ .secs = secs };943    const ed = es.getEpochDay();944    const yd = ed.calculateYearDay();945    const md = yd.calculateMonthDay();946    const ds = es.getDaySeconds();947    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", .{948        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,949        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),950    }) catch return fallback;951}