commands.zig

45.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; };8384    // Newest generation older than current. If there is none, we're already at the85    // oldest — stop cleanly instead of letting rollback fail with 'build failed'.86    var prev: u32 = 0;87    var has_prev = false;88    for (gens) |g| {89        if (g.number < current and (!has_prev or g.number > prev)) {90            prev = g.number;91            has_prev = true;92        }93    }94    if (!has_prev) {95        output.alreadyOldest();96        return;97    }9899    output.backStart(ctx.machine.name, current, prev);100    if (ctx.cfg.confirm and !output.confirm("")) return;101102    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);103    if (code != 0) return error.BuildFailed;104    output.backDone(prev);105}106107pub fn go(ctx: Ctx) !void {108    if (ctx.args.len == 0) return error.Internal;109    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;110    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);111112    output.goStart(ctx.machine.name, current, target);113    if (ctx.cfg.confirm and !output.confirm("")) return;114115    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);116    if (code != 0) return error.ProcessFailed;117    output.generationDone(target);118}119120pub fn history(ctx: Ctx) !void {121    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);122    defer ctx.gpa.free(gens);123    output.historyHeader(ctx.machine.name);124    for (gens) |g| output.historyRow(g);125}126127pub fn genCmd(ctx: Ctx) !void {128    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);129    if (std.mem.eql(u8, ctx.sub, "current")) {130        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);131        output.genCurrent(n);132        return;133    }134    if (std.mem.eql(u8, ctx.sub, "delete")) {135        if (ctx.args.len == 0) return error.Internal;136        if (std.mem.eql(u8, ctx.args[0], "old")) {137            if (ctx.cfg.confirm and !output.confirm("")) return;138            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);139        } else {140            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;141            if (ctx.cfg.confirm and !output.confirm("")) return;142            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);143        }144        output.done();145        return;146    }147    return error.Internal;148}149150pub fn clean(ctx: Ctx) !void {151    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);152    defer ctx.gpa.free(gens);153    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");154    const total: u32 = @intCast(gens.len);155    // `clean all` keeps only the current generation; otherwise keep the configured156    // count. With nothing older than the keep limit, there's nothing to collect.157    const keep: u32 = if (all) 1 else ctx.cfg.generations;158    if (total <= keep) {159        output.cleanNothing(ctx.machine.name, total);160        return;161    }162    output.cleanStart(ctx.machine.name, keep, total);163    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;164    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all, ctx.cfg.generations);165    output.cleanDone("?");166}167168pub fn statusCmd(ctx: Ctx) !void {169    output.statusHeader();170    // --all renders every configured machine; remotes route through the same SSH171    // path as exec.capture. An unreachable machine is shown in red, not fatal.172    if (ctx.all and ctx.cfg.machines.len > 0) {173        for (ctx.cfg.machines) |m| statusRowFor(ctx, &m);174        return;175    }176    statusRowFor(ctx, &ctx.machine);177}178179fn statusRowFor(ctx: Ctx, machine: *const types.Machine) void {180    // A failure to read the current generation means we couldn't reach or query181    // the machine — mark it unreachable rather than reporting a bogus gen 0.182    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, machine) catch {183        output.statusRow(machine.name, 0, "", "", false);184        return;185    };186    const st = exec.systemStatus(ctx.gpa, ctx.io, machine);187    defer ctx.gpa.free(st.size);188    defer ctx.gpa.free(st.age);189    output.statusRow(machine.name, gen, st.age, st.size, true);190}191192pub fn diff(ctx: Ctx) !void {193    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);194    defer ctx.gpa.free(gens);195    var current: u32 = 0;196    var prev: u32 = 0;197    for (gens) |g| {198        if (g.current) current = g.number;199        if (!g.current and g.number > prev and g.number < current) prev = g.number;200    }201    if (ctx.args.len >= 2) {202        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;203        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;204        output.diffHeader(a, b, ctx.machine.name);205        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b) != 0) return error.ProcessFailed;206    } else {207        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);208        if (try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current) != 0) return error.ProcessFailed;209    }210}211212pub fn mood(ctx: Ctx) !void {213    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;214    const st = exec.systemStatus(ctx.gpa, ctx.io, &ctx.machine);215    defer ctx.gpa.free(st.size);216    defer ctx.gpa.free(st.age);217    const state = exec.systemRunningState(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");218    defer ctx.gpa.free(state);219    output.mood(ctx.machine.name, state, gen, st.age, st.size);220}221222pub fn doctor(ctx: Ctx) !void {223    output.doctorHeader(ctx.machine.name);224    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);225    defer ctx.gpa.free(checks);226    var warnings: u32 = 0;227    var failures: u32 = 0;228    for (checks) |ch| {229        output.doctorRow(ch);230        if (ch.status == .warn) warnings += 1;231        if (ch.status == .fail) failures += 1;232    }233    output.doctorSummary(warnings, failures);234}235236// nina update — kepr self-update for the nina binary itself.237pub fn update(ctx: Ctx) !void {238    output.updateSelfStart();239240    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {241        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));242        return error.Internal;243    }244245    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");246    const source_url = env_url orelse UPDATE_URL;247248    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {249        error.NetworkError, error.HttpError => {250            output.updateSelfNetworkError();251            return;252        },253        else => return e,254    };255    defer ctx.gpa.free(body);256257    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {258        output.printWarning("could not parse release metadata");259        return;260    };261    defer parsed.deinit();262263    const root = parsed.value;264    if (root != .object) { output.printWarning("malformed release metadata"); return; }265266    const remote_ver = blk: {267        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };268        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };269    };270271    if (std.mem.eql(u8, remote_ver, version.VERSION)) {272        output.updateSelfCurrent(version.VERSION);273        return;274    }275276    output.updateSelfAvailable(version.VERSION, remote_ver);277    if (ctx.check) return;278279    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;280    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {281        errors.error_info.setMessage("could not determine install path", .{});282        return error.Internal;283    };284285    // When running from the nix store, defer to nix profile upgrade so nix286    // manages the transition (the store is read-only; binary replacement fails).287    // A NixOS system install also resolves into the store but isn't a profile288    // element, so check the profile to avoid a no-op `upgrade` — it updates with289    // the next nixos-rebuild instead.290    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {291        if (!exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine)) {292            output.updateSelfNixSystemManaged();293            return;294        }295        output.updateSelfNixManaged(remote_ver);296        if (ctx.cfg.confirm and !output.confirm("")) return;297        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", "nina" }) catch {298            output.updateSelfNixFailed();299            return;300        };301        if (ec != 0) output.updateSelfNixFailed();302        return;303    }304305    const plat_info = blk: {306        const pv = root.object.get("platforms") orelse break :blk null;307        if (pv != .object) break :blk null;308        break :blk pv.object.get(version.PLATFORM);309    };310    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }311312    const dl_url = blk: {313        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };314        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };315    };316317    const raw_cksum = blk: {318        if (plat_info.?.object.get("blake3")) |v|319            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };320        if (plat_info.?.object.get("checksum")) |v|321            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };322        output.printWarning("missing checksum in release metadata");323        return;324    };325    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;326327    if (ctx.cfg.confirm and !output.confirm("")) return;328329    output.updateSelfDownloading(remote_ver, version.PLATFORM);330    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {331        output.printWarning("download failed — check your connection and try again");332        return;333    };334    defer ctx.gpa.free(tmp_path);335336    output.updateSelfVerifying();337    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {338        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },339        else => return e,340    };341342    output.updateSelfDone(version.VERSION, remote_ver);343}344345// nina goodbye — clean uninstall. Handles the three install methods, and the346// case where more than one is present at once (the installer can add nina to347// the nix profile AND wire ~/nina.nix into the system config).348pub fn goodbye(ctx: Ctx) !void {349    output.goodbyeStart();350351    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;352    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {353        errors.error_info.setMessage("could not determine install path", .{});354        return error.Internal;355    };356357    // A binary outside the nix store is a plain install — just delete it.358    if (!std.mem.startsWith(u8, install_path, "/nix/store/")) {359        output.goodbyeFile(install_path);360        if (ctx.cfg.confirm and !output.confirm("")) return;361        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {362            errors.error_info.setMessage("could not remove {s}", .{install_path});363            return e;364        };365        output.goodbyeDone();366        return;367    }368369    // In the nix store: a `nix profile` install, a NixOS system package370    // (programs.nina.enable via ~/nina.nix), or both — they're independent and371    // both resolve into the store. Remove the profile element if present, and372    // separately flag the system module. We never auto-remove the system one:373    // editing /etc/nixos needs sudo, and deleting ~/nina.nix while it's still374    // imported breaks the next rebuild.375    const in_profile = exec.profileHasNina(ctx.gpa, ctx.io, &ctx.machine);376    const system_managed = ninaNixExists(ctx);377378    if (in_profile) {379        output.goodbyeNix();380        if (ctx.cfg.confirm and !output.confirm("")) return;381        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", "nina" }) catch {382            output.goodbyeFailed("nix profile remove failed");383            return;384        };385        if (ec != 0) {386            output.goodbyeFailed("nix profile remove failed");387            return;388        }389    }390391    // System module present, or nothing in the profile and not a plain binary392    // (so it must be system-managed) — instruct rather than touch /etc/nixos.393    if (system_managed or !in_profile) {394        output.goodbyeNixSystemManaged();395    }396397    output.goodbyeDone();398}399400// Read-only check for the installer's ~/nina.nix system-config snippet.401fn ninaNixExists(ctx: Ctx) bool {402    const home = ctx.environ.get("HOME") orelse return false;403    const path = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch return false;404    defer ctx.gpa.free(path);405    const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch return false;406    ctx.gpa.free(bytes);407    return true;408}409410fn channelUpdateCmd(ctx: Ctx) !void {411    output.updateStart(ctx.machine.name);412    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);413    output.updateDone();414}415416pub fn upgrade(ctx: Ctx) !void {417    output.upgradeStart(ctx.machine.name);418    output.printSubstep("updating channels", .{});419    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};420    output.printSubstep("rebuilding", .{});421    const start = std.Io.Clock.awake.now(ctx.io);422    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);423    if (code != 0) {424        output.applyFailed("");425        return error.BuildFailed;426    }427    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());428    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));429    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;430    output.applyDone(gen, elapsed, false);431}432433pub fn log(ctx: Ctx) !void {434    output.logHeader(ctx.machine.name);435    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {436        // No log yet — that's fine437        return;438    };439    defer ctx.gpa.free(content);440    var lines = std.mem.splitScalar(u8, content, '\n');441    while (lines.next()) |line| {442        const t = std.mem.trim(u8, line, " \t\r");443        if (t.len == 0) continue;444        if (t[0] == '{') {445            renderJsonLogLine(ctx.gpa, t);446        } else {447            // legacy tab-separated format448            var parts = std.mem.tokenizeScalar(u8, t, '\t');449            const date = parts.next() orelse continue;450            const time = parts.next() orelse "";451            const action = parts.next() orelse "";452            const detail = parts.next() orelse "";453            output.logRow(date, time, action, detail);454        }455    }456}457458// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.459// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.460fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {461    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {462        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop463        return;464    };465    defer parsed.deinit();466    if (parsed.value != .object) return;467    const o = parsed.value.object;468469    const ts = jsonStr(o, "ts");470    const date = if (ts.len >= 10) ts[0..10] else ts;471    const time = if (ts.len >= 16) ts[11..16] else "";472    const action = jsonStr(o, "command");473    const outcome = jsonStr(o, "outcome");474475    var buf: [160]u8 = undefined;476    var detail: []const u8 = outcome;477    if (o.get("gen_after")) |g| {478        if (g == .integer) {479            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;480        }481    }482    output.logRow(date, time, action, detail);483}484485fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {486    if (o.get(key)) |v| {487        if (v == .string) return v.string;488    }489    return "";490}491492pub fn edit(ctx: Ctx) !void {493    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");494    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";495    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });496    defer ctx.gpa.free(path);497    output.editOpen(path, ctx.cfg.editor);498    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);499}500501pub fn fmt(ctx: Ctx) !void {502    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});503    defer ctx.gpa.free(path);504    if (ctx.check) {505        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);506        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();507        return;508    }509    output.fmtStart(path);510    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);511    output.fmtDone();512}513514pub fn repl(ctx: Ctx) !void {515    output.replStart();516    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);517}518519pub fn develop(ctx: Ctx) !void {520    if (std.mem.eql(u8, ctx.sub, "show")) {521        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);522        return;523    }524    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))525        ctx.args[0]526    else527        null;528    output.developStart(&.{});529    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);530    output.developDone();531}532533pub fn info(ctx: Ctx) !void {534    output.infoHeader(ctx.machine.name);535    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");536    defer ctx.gpa.free(nixos_ver);537    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));538539    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");540    defer ctx.gpa.free(kernel);541    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));542543    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");544    defer ctx.gpa.free(uptime);545    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));546}547548pub fn boot(ctx: Ctx) !void {549    output.bootHeader(ctx.machine.name);550    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};551    defer ctx.gpa.free(entries);552    for (entries, 0..) |entry, i| output.bootEntry(entry, i);553}554555pub fn searchCmd(ctx: Ctx) !void {556    const query = if (ctx.args.len > 0) ctx.args[0] else "";557    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);558    const selected = try search_tui.run(ctx.gpa, ctx.io, query, .packages);559    if (selected) |npkg| try selectAndInstall(ctx, npkg);560}561562pub fn optionCmd(ctx: Ctx) !void {563    const query = if (ctx.args.len > 0) ctx.args[0] else "";564    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);565    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);566}567568pub fn install(ctx: Ctx) !void {569    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;570571    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);572573    // Search for the package to get the attr574    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {575        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});576        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});577        return error.PackageNotFound;578    };579580    if (pkgs.len == 0) {581        // No matches — likely a typo. Suggest the closest real package and offer it.582        if (closestPackage(ctx, pkg_name)) |sugg| {583            output.didYouMean(pkg_name, sugg.pname);584            if (output.confirm("")) try selectAndInstall(ctx, sugg);585            return;586        }587        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});588        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});589        return error.PackageNotFound;590    }591592    // Prefer an exact name match over the first search result — `install ripgrep`593    // must pick "ripgrep", not the alphabetically-first match like "repgrep".594    var found = pkgs[0];595    for (pkgs) |p| {596        if (std.mem.eql(u8, p.pname, pkg_name)) {597            found = p;598            break;599        }600    }601    try selectAndInstall(ctx, found);602}603604// Find the real package whose name is closest to a not-found query (typo help).605// Typos usually keep the leading characters, so gather candidates with a prefix606// search and rank by edit distance; return the best only if it's typo-close.607fn closestPackage(ctx: Ctx, query: []const u8) ?types.NixPackage {608    if (query.len < 2) return null;609    var candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..@min(query.len, 4)]) catch &.{};610    if (candidates.len == 0 and query.len > 2) {611        candidates = api.searchPackages(ctx.gpa, ctx.io, query[0..2]) catch &.{};612    }613    if (candidates.len == 0) return null;614615    var best: ?types.NixPackage = null;616    var best_dist: usize = std.math.maxInt(usize);617    for (candidates) |p| {618        const d = editDistance(query, p.pname);619        if (d > 0 and d < best_dist) {620            best_dist = d;621            best = p;622        }623    }624    // Only suggest a genuinely close name, scaled to query length.625    const threshold = @max(@as(usize, 2), query.len / 3);626    return if (best_dist <= threshold) best else null;627}628629// Case-insensitive Levenshtein distance. Names longer than the row buffer are630// treated as far away (never suggested).631fn editDistance(a: []const u8, b: []const u8) usize {632    const n = b.len;633    if (a.len == 0) return n;634    if (n == 0) return a.len;635    if (n + 1 > 128) return std.math.maxInt(usize);636    var prev: [128]usize = undefined;637    var curr: [128]usize = undefined;638    var j: usize = 0;639    while (j <= n) : (j += 1) prev[j] = j;640    var i: usize = 1;641    while (i <= a.len) : (i += 1) {642        curr[0] = i;643        j = 1;644        while (j <= n) : (j += 1) {645            const cost: usize = if (std.ascii.toLower(a[i - 1]) == std.ascii.toLower(b[j - 1])) 0 else 1;646            curr[j] = @min(prev[j] + 1, @min(curr[j - 1] + 1, prev[j - 1] + cost));647        }648        @memcpy(prev[0 .. n + 1], curr[0 .. n + 1]);649    }650    return prev[n];651}652653test "editDistance is case-insensitive Levenshtein" {654    try std.testing.expectEqual(@as(usize, 1), editDistance("firefux", "firefox"));655    try std.testing.expectEqual(@as(usize, 0), editDistance("git", "GIT"));656    try std.testing.expectEqual(@as(usize, 2), editDistance("fierfox", "firefox"));657    try std.testing.expectEqual(@as(usize, 7), editDistance("", "firefox"));658}659660// Prompt for the install method (profile / system / try) and dispatch. Shared by661// `nina install <pkg>` and the search widget's Enter-to-select — the letter keys662// are read here, after the search widget's raw mode is gone, so they don't663// collide with typing a query.664fn selectAndInstall(ctx: Ctx, npkg: types.NixPackage) !void {665    output.installSelectPrompt(npkg.pname, npkg.version);666667    var choice_buf: [8]u8 = undefined;668    var choice_len: usize = 0;669    while (choice_len < choice_buf.len) {670        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;671        if (n == 0) break;672        if (choice_buf[choice_len] == '\n') break;673        choice_len += 1;674    }675    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");676677    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {678        try profileInstallPkg(ctx, npkg);679    } else if (choice[0] == 's' or choice[0] == 'S') {680        try systemInstallPkg(ctx, npkg);681    } else if (choice[0] == 't' or choice[0] == 'T') {682        try tryPkg(ctx, npkg);683    }684}685686fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {687    output.installStart(npkg.pname, .profile);688    output.printSubstep("nix profile add nixpkgs#{s}", .{npkg.attr});689    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile add nixpkgs#{s}", .{npkg.attr}));690    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);691    if (code != 0) return error.ProcessFailed;692    output.installDone(npkg.pname);693}694695fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {696    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});697    defer ctx.gpa.free(config_path);698    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;699    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);700    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);701    if (ctx.cfg.confirm) {702        if (output.confirm("apply now? ")) {703            try apply(ctx);704        }705    }706}707708fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {709    output.tryStart(npkg.pname);710    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});711    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);712    output.tryDone();713}714715pub fn remove(ctx: Ctx) !void {716    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;717    output.removeStart(pkg_name);718    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);719    if (code != 0) return error.ProcessFailed;720    output.done();721}722723pub fn list(ctx: Ctx) !void {724    output.listHeader(ctx.machine.name);725726    // Profile packages: parse `nix profile list --json` into clean name+version727    // rows instead of dumping nix's verbose multi-line "Name:/Flake attribute:"728    // blocks.729    if (exec.profileListJson(ctx.gpa, ctx.io, &ctx.machine) catch null) |json| {730        defer ctx.gpa.free(json);731        output.listSection("profile");732        renderProfileJson(ctx.gpa, json);733    }734735    // System packages: the references of /run/current-system/sw, each a736    // <hash>-<name>-<version> store path.737    if (exec.systemPackagePaths(ctx.gpa, ctx.io, &ctx.machine) catch null) |paths| {738        defer ctx.gpa.free(paths);739        output.blankLine();740        output.listSection("system");741        var lines = std.mem.splitScalar(u8, paths, '\n');742        while (lines.next()) |line| {743            const t = std.mem.trim(u8, line, " \t\r");744            if (t.len == 0) continue;745            const row = pkgFromStorePath(t);746            // Skip support paths with no parseable version (man pages, wrappers).747            if (row.version.len == 0) continue;748            output.listPkg(row.name, row.version);749        }750    }751}752753const PkgRow = struct { name: []const u8, version: []const u8 };754755// Split a nix store path basename (<hash>-<name>-<version>) into name + version.756// Version begins at the first '-' followed by a digit, which reliably lands on757// the name/version boundary since the 32-char hash and package names start with758// non-digits.759fn pkgFromStorePath(path: []const u8) PkgRow {760    const base = std.fs.path.basename(path);761    const after_hash = if (base.len > 33 and base[32] == '-') base[33..] else base;762    var i: usize = 0;763    while (i + 1 < after_hash.len) : (i += 1) {764        if (after_hash[i] == '-' and std.ascii.isDigit(after_hash[i + 1])) {765            return .{ .name = after_hash[0..i], .version = stripOutputSuffix(after_hash[i + 1 ..]) };766        }767    }768    return .{ .name = after_hash, .version = "" };769}770771// Multi-output derivations append the output name to the store path (e.g.772// curl-8.20.0-bin, less-692-man). Strip that suffix so the version column reads773// as just the version.774fn stripOutputSuffix(ver: []const u8) []const u8 {775    const suffixes = [_][]const u8{ "-bin", "-man", "-dev", "-doc", "-lib", "-info", "-out", "-static", "-devdoc" };776    for (suffixes) |s| {777        if (std.mem.endsWith(u8, ver, s)) return ver[0 .. ver.len - s.len];778    }779    return ver;780}781782test "pkgFromStorePath splits name and version at first dash-digit" {783    const a = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-ripgrep-15.1.0");784    try std.testing.expectEqualStrings("ripgrep", a.name);785    try std.testing.expectEqualStrings("15.1.0", a.version);786787    const b = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-helix-25.01");788    try std.testing.expectEqualStrings("helix", b.name);789    try std.testing.expectEqualStrings("25.01", b.version);790791    // multi-output store paths drop their output-name suffix792    const c = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-curl-8.20.0-bin");793    try std.testing.expectEqualStrings("curl", c.name);794    try std.testing.expectEqualStrings("8.20.0", c.version);795}796797fn firstStorePath(el: std.json.Value) ?[]const u8 {798    if (el != .object) return null;799    const sp = el.object.get("storePaths") orelse return null;800    if (sp != .array or sp.array.items.len == 0) return null;801    const first = sp.array.items[0];802    return if (first == .string) first.string else null;803}804805// `nix profile list --json` has two shapes across nix versions: newer keys each806// element by name (object), older lists them positionally (array). Handle both.807fn renderProfileJson(gpa: std.mem.Allocator, json: []const u8) void {808    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json, .{}) catch return;809    defer parsed.deinit();810    if (parsed.value != .object) return;811    const elements = parsed.value.object.get("elements") orelse return;812    switch (elements) {813        .object => |obj| {814            var it = obj.iterator();815            while (it.next()) |entry| {816                const ver = if (firstStorePath(entry.value_ptr.*)) |p| pkgFromStorePath(p).version else "";817                output.listPkg(entry.key_ptr.*, ver);818            }819        },820        .array => |arr| {821            for (arr.items) |el| {822                const path = firstStorePath(el) orelse continue;823                const row = pkgFromStorePath(path);824                output.listPkg(row.name, row.version);825            }826        },827        else => {},828    }829}830831pub fn tryCmd(ctx: Ctx) !void {832    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;833    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };834    try tryPkg(ctx, shell_pkg);835}836837// --- Service commands ---838839pub fn service(ctx: Ctx) !void {840    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {841        output.serviceListHeader(ctx.machine.name);842        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};843        defer ctx.gpa.free(svcs);844        for (svcs) |s| output.serviceRow(s);845        return;846    }847    if (std.mem.eql(u8, ctx.sub, "logs")) {848        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;849        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");850        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);851        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);852        return;853    }854    if (std.mem.eql(u8, ctx.sub, "status")) {855        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;856        output.serviceVerb("status", name, ctx.machine.name);857        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);858        return;859    }860    // start/stop/restart/enable/disable861    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;862    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {863        if (!output.confirm("")) return;864    }865    output.serviceVerb(ctx.sub, name, ctx.machine.name);866    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);867    output.serviceDone();868}869870// --- Flake commands ---871872pub fn flake(ctx: Ctx) !void {873    if (std.mem.eql(u8, ctx.sub, "update")) {874        output.flakeUpdateStart();875        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;876        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);877        output.flakeLockWritten();878        return;879    }880    if (std.mem.eql(u8, ctx.sub, "check")) {881        output.flakeCheckStart();882        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);883        if (code == 0) output.flakeValid() else output.checkFailed("");884        return;885    }886    if (std.mem.eql(u8, ctx.sub, "show")) {887        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;888        if (json_out) |j| {889            defer ctx.gpa.free(j);890            output.flakeShowHeader("flake outputs");891            // Parse and display the flake outputs892            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;893            defer parsed.deinit();894            if (parsed.value == .object) {895                var it = parsed.value.object.iterator();896                while (it.next()) |entry| {897                    if (entry.value_ptr.* == .object) {898                        var inner = entry.value_ptr.object.iterator();899                        while (inner.next()) |e2| {900                            const desc = if (e2.value_ptr.* == .object)901                                if (e2.value_ptr.object.get("description")) |d|902                                    if (d == .string) d.string else ""903                                else ""904                            else "";905                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);906                        }907                    }908                }909            }910        }911        return;912    }913    if (std.mem.eql(u8, ctx.sub, "init")) {914        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);915        return;916    }917    if (std.mem.eql(u8, ctx.sub, "lock")) {918        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);919        return;920    }921    if (std.mem.eql(u8, ctx.sub, "clone")) {922        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;923        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);924        return;925    }926}927928// --- Store commands ---929930pub fn store(ctx: Ctx) !void {931    if (std.mem.eql(u8, ctx.sub, "gc")) {932        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);933        return;934    }935    if (std.mem.eql(u8, ctx.sub, "verify")) {936        output.storeVerifyStart();937        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);938        return;939    }940    if (std.mem.eql(u8, ctx.sub, "repair")) {941        if (ctx.cfg.confirm and !output.confirm("")) return;942        output.storeRepairStart();943        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);944        return;945    }946    if (std.mem.eql(u8, ctx.sub, "info")) {947        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);948        output.storeInfo(si);949        return;950    }951    if (std.mem.eql(u8, ctx.sub, "path")) {952        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;953        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);954        defer ctx.gpa.free(path);955        output.storePath(path);956        return;957    }958    // default: info959    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);960    output.storeInfo(si);961}962963// --- Channel commands ---964965pub fn channel(ctx: Ctx) !void {966    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {967        output.channelListHeader(ctx.machine.name);968        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);969        defer ctx.gpa.free(chans);970        for (chans) |ch| output.channelRow(ch);971        return;972    }973    if (std.mem.eql(u8, ctx.sub, "add")) {974        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;975        const name = if (ctx.args.len > 1) ctx.args[1] else "";976        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);977        output.channelDone();978        return;979    }980    if (std.mem.eql(u8, ctx.sub, "remove")) {981        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;982        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);983        output.channelDone();984        return;985    }986    if (std.mem.eql(u8, ctx.sub, "update")) {987        return channelUpdateCmd(ctx);988    }989}990991// --- Profile commands ---992993pub fn profile(ctx: Ctx) !void {994    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {995        output.profileListHeader();996        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;997        if (out) |o| {998            defer ctx.gpa.free(o);999            var lines = std.mem.splitScalar(u8, o, '\n');1000            while (lines.next()) |line| {1001                const t = std.mem.trim(u8, line, " \t\r");1002                if (t.len == 0) continue;1003                output.profileRow(t, "", "");1004            }1005        }1006        return;1007    }1008    if (std.mem.eql(u8, ctx.sub, "install")) {1009        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1010        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);1011        output.done();1012        return;1013    }1014    if (std.mem.eql(u8, ctx.sub, "remove")) {1015        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1016        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);1017        output.done();1018        return;1019    }1020    if (std.mem.eql(u8, ctx.sub, "upgrade")) {1021        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);1022        output.done();1023        return;1024    }1025}10261027// --- Pkg commands ---10281029pub fn pkg(ctx: Ctx) !void {1030    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1031    if (std.mem.eql(u8, ctx.sub, "why")) {1032        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);1033        return;1034    }1035    if (std.mem.eql(u8, ctx.sub, "deps")) {1036        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);1037        return;1038    }1039    if (std.mem.eql(u8, ctx.sub, "size")) {1040        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;1041        if (s) |sz| {1042            defer ctx.gpa.free(sz);1043            output.pkgResult("size", sz);1044        }1045        return;1046    }1047    if (std.mem.eql(u8, ctx.sub, "path")) {1048        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);1049        defer ctx.gpa.free(p);1050        output.pkgResult("path", p);1051        return;1052    }1053    if (std.mem.eql(u8, ctx.sub, "closure")) {1054        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);1055        return;1056    }1057}10581059// --- Pin / Unpin ---10601061pub fn pin(ctx: Ctx) !void {1062    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1063    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;1064    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;1065    output.pinDone(input, commit);1066}10671068pub fn unpin(ctx: Ctx) !void {1069    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1070    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);1071    output.unpinDone(input);1072}10731074// --- Hash / Fetch ---10751076pub fn hash(ctx: Ctx) !void {1077    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1078    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);1079    defer ctx.gpa.free(h);1080    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));1081}10821083pub fn fetch(ctx: Ctx) !void {1084    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;1085    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);1086    defer ctx.gpa.free(h);1087    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);1088}10891090// --- Build / Run ---10911092pub fn build(ctx: Ctx) !void {1093    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;1094    output.buildStart(attr orelse "default");1095    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);1096    if (code != 0) return error.BuildFailed;1097    output.buildDone();1098}10991100pub fn runCmd(ctx: Ctx) !void {1101    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1102    output.runStart(pkg_arg);1103    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);1104}11051106// --- Hello ---11071108pub fn hello(ctx: Ctx) !void {1109    output.hello(ctx.cfg.machines);1110}11111112pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1113    output.setupCheck();1114    const status = exec.checkNixFeatures(gpa, io, environ);1115    output.setupFeatures(status.nix_command, status.flakes);11161117    if (status.allEnabled()) {1118        output.setupAllSet();1119        api.ensureRegistryPinned(gpa, io, environ);1120        return;1121    }11221123    // On NixOS the features belong in configuration.nix (the declarative,1124    // permanent home), not ~/.config/nix/nix.conf. Offer to add the one line;1125    // the user runs the rebuild. We don't pin the registry here because the1126    // features aren't live until that rebuild lands.1127    if (exec.isNixOS(io)) {1128        const conf_path = "/etc/nixos/configuration.nix";1129        if (exec.nixosConfigEnablesFeatures(gpa, io, conf_path)) {1130            output.setupNixosNeedsRebuild(conf_path);1131            return;1132        }1133        output.setupNixosOffer(exec.NIXOS_FEATURES_LINE, conf_path);1134        if (output.confirmDefaultNo("add it now?")) {1135            if (exec.appendFeaturesToNixosConfig(gpa, io, environ, conf_path)) |_| {1136                output.setupNixosWrote(conf_path);1137            } else |_| {1138                output.setupNixosManual(exec.NIXOS_FEATURES_LINE, conf_path);1139            }1140        } else {1141            output.setupNixosManual(exec.NIXOS_FEATURES_LINE, conf_path);1142        }1143        return;1144    }11451146    // Non-NixOS: a user-level nix.conf takes effect immediately, no sudo needed.1147    output.setupEnabling();1148    const path = try exec.enableNixFeatures(gpa, io, environ);1149    defer gpa.free(path);11501151    output.setupDone(path);1152    api.ensureRegistryPinned(gpa, io, environ);1153}11541155// --- Log helper ---11561157fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {1158    var ts_buf: [32]u8 = undefined;1159    const ts = nowIso(ctx.io, &ts_buf);1160    var buf: [256]u8 = undefined;1161    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{1162        ts, ctx.machine.name, action, gen,1163    }) catch return;1164    exec.appendNinaLog(ctx.io, ctx.environ, entry);1165}11661167// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);1168// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.1169fn nowIso(io: std.Io, buf: []u8) []const u8 {1170    const fallback = "1970-01-01T00:00:00Z";1171    const ns = std.Io.Clock.real.now(io).toNanoseconds();1172    if (ns <= 0) return fallback;1173    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));1174    const es = std.time.epoch.EpochSeconds{ .secs = secs };1175    const ed = es.getEpochDay();1176    const yd = ed.calculateYearDay();1177    const md = yd.calculateMonthDay();1178    const ds = es.getDaySeconds();1179    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", .{1180        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,1181        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),1182    }) catch return fallback;1183}