commands.zig

39.6 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    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {288        output.updateSelfNixManaged(remote_ver);289        if (ctx.cfg.confirm and !output.confirm("")) return;290        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "upgrade", ".*nina.*" }) catch {291            output.updateSelfNixFailed();292            return;293        };294        if (ec != 0) output.updateSelfNixSystemManaged();295        return;296    }297298    const plat_info = blk: {299        const pv = root.object.get("platforms") orelse break :blk null;300        if (pv != .object) break :blk null;301        break :blk pv.object.get(version.PLATFORM);302    };303    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }304305    const dl_url = blk: {306        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };307        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };308    };309310    const raw_cksum = blk: {311        if (plat_info.?.object.get("blake3")) |v|312            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };313        if (plat_info.?.object.get("checksum")) |v|314            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };315        output.printWarning("missing checksum in release metadata");316        return;317    };318    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;319320    if (ctx.cfg.confirm and !output.confirm("")) return;321322    output.updateSelfDownloading(remote_ver, version.PLATFORM);323    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {324        output.printWarning("download failed — check your connection and try again");325        return;326    };327    defer ctx.gpa.free(tmp_path);328329    output.updateSelfVerifying();330    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {331        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },332        else => return e,333    };334335    output.updateSelfDone(version.VERSION, remote_ver);336}337338// nina goodbye — clean uninstall from nix profile or binary location.339pub fn goodbye(ctx: Ctx) !void {340    output.goodbyeStart();341342    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;343    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {344        errors.error_info.setMessage("could not determine install path", .{});345        return error.Internal;346    };347348    if (std.mem.startsWith(u8, install_path, "/nix/store/")) {349        output.goodbyeNix();350        if (ctx.cfg.confirm and !output.confirm("")) return;351        const ec = exec.streamLocal(ctx.io, &.{ "nix", "profile", "remove", ".*nina.*" }) catch {352            output.goodbyeFailed("nix profile remove failed");353            return;354        };355        if (ec != 0) {356            output.goodbyeNixSystemManaged();357            return;358        }359    } else {360        output.goodbyeFile(install_path);361        if (ctx.cfg.confirm and !output.confirm("")) return;362        std.Io.Dir.deleteFileAbsolute(ctx.io, install_path) catch |e| {363            errors.error_info.setMessage("could not remove {s}", .{install_path});364            return e;365        };366    }367368    // Offer to remove ~/nina.nix if it exists (generated by the installer).369    if (ctx.environ.get("HOME")) |home| {370        const nina_nix: ?[]u8 = std.fmt.allocPrint(ctx.gpa, "{s}/nina.nix", .{home}) catch null;371        if (nina_nix) |path| {372            defer ctx.gpa.free(path);373            const exists = blk: {374                const bytes = std.Io.Dir.readFileAlloc(.cwd(), ctx.io, path, ctx.gpa, .unlimited) catch break :blk false;375                ctx.gpa.free(bytes);376                break :blk true;377            };378            if (exists) {379                output.goodbyeNixFileFound(path);380                if (!ctx.cfg.confirm or output.confirm("")) {381                    std.Io.Dir.deleteFileAbsolute(ctx.io, path) catch {382                        output.printWarning("could not remove ~/nina.nix — remove it manually to clean up");383                    };384                }385            }386        }387    }388389    output.goodbyeDone();390}391392fn channelUpdateCmd(ctx: Ctx) !void {393    output.updateStart(ctx.machine.name);394    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);395    output.updateDone();396}397398pub fn upgrade(ctx: Ctx) !void {399    output.upgradeStart(ctx.machine.name);400    output.printSubstep("updating channels", .{});401    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};402    output.printSubstep("rebuilding", .{});403    const start = std.Io.Clock.awake.now(ctx.io);404    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);405    if (code != 0) {406        output.applyFailed("");407        return error.BuildFailed;408    }409    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());410    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));411    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;412    output.applyDone(gen, elapsed, false);413}414415pub fn log(ctx: Ctx) !void {416    output.logHeader(ctx.machine.name);417    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {418        // No log yet — that's fine419        return;420    };421    defer ctx.gpa.free(content);422    var lines = std.mem.splitScalar(u8, content, '\n');423    while (lines.next()) |line| {424        const t = std.mem.trim(u8, line, " \t\r");425        if (t.len == 0) continue;426        if (t[0] == '{') {427            renderJsonLogLine(ctx.gpa, t);428        } else {429            // legacy tab-separated format430            var parts = std.mem.tokenizeScalar(u8, t, '\t');431            const date = parts.next() orelse continue;432            const time = parts.next() orelse "";433            const action = parts.next() orelse "";434            const detail = parts.next() orelse "";435            output.logRow(date, time, action, detail);436        }437    }438}439440// The on-disk log is JSON Lines: {"ts","machine","command","outcome","gen_after",...}.441// Render ts -> date/time columns, command -> action, outcome(+gen) -> detail.442fn renderJsonLogLine(gpa: std.mem.Allocator, line: []const u8) void {443    const parsed = std.json.parseFromSlice(std.json.Value, gpa, line, .{}) catch {444        output.logRow(line, "", "", ""); // unparseable — show raw rather than drop445        return;446    };447    defer parsed.deinit();448    if (parsed.value != .object) return;449    const o = parsed.value.object;450451    const ts = jsonStr(o, "ts");452    const date = if (ts.len >= 10) ts[0..10] else ts;453    const time = if (ts.len >= 16) ts[11..16] else "";454    const action = jsonStr(o, "command");455    const outcome = jsonStr(o, "outcome");456457    var buf: [160]u8 = undefined;458    var detail: []const u8 = outcome;459    if (o.get("gen_after")) |g| {460        if (g == .integer) {461            detail = std.fmt.bufPrint(&buf, "{s}   -> gen {d}", .{ outcome, g.integer }) catch outcome;462        }463    }464    output.logRow(date, time, action, detail);465}466467fn jsonStr(o: std.json.ObjectMap, key: []const u8) []const u8 {468    if (o.get(key)) |v| {469        if (v == .string) return v.string;470    }471    return "";472}473474pub fn edit(ctx: Ctx) !void {475    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");476    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";477    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });478    defer ctx.gpa.free(path);479    output.editOpen(path, ctx.cfg.editor);480    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);481}482483pub fn fmt(ctx: Ctx) !void {484    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});485    defer ctx.gpa.free(path);486    if (ctx.check) {487        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);488        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();489        return;490    }491    output.fmtStart(path);492    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);493    output.fmtDone();494}495496pub fn repl(ctx: Ctx) !void {497    output.replStart();498    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);499}500501pub fn develop(ctx: Ctx) !void {502    if (std.mem.eql(u8, ctx.sub, "show")) {503        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);504        return;505    }506    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))507        ctx.args[0]508    else509        null;510    output.developStart(&.{});511    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);512    output.developDone();513}514515pub fn info(ctx: Ctx) !void {516    output.infoHeader(ctx.machine.name);517    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");518    defer ctx.gpa.free(nixos_ver);519    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));520521    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");522    defer ctx.gpa.free(kernel);523    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));524525    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");526    defer ctx.gpa.free(uptime);527    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));528}529530pub fn boot(ctx: Ctx) !void {531    output.bootHeader(ctx.machine.name);532    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};533    defer ctx.gpa.free(entries);534    for (entries, 0..) |entry, i| output.bootEntry(entry, i);535}536537pub fn searchCmd(ctx: Ctx) !void {538    const query = if (ctx.args.len > 0) ctx.args[0] else "";539    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);540    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);541    if (result) |r| {542        installFromResult(ctx, r) catch |e| return e;543    }544}545546pub fn optionCmd(ctx: Ctx) !void {547    const query = if (ctx.args.len > 0) ctx.args[0] else "";548    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);549    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);550}551552pub fn install(ctx: Ctx) !void {553    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;554555    api.ensureRegistryPinned(ctx.gpa, ctx.io, ctx.environ);556557    // Search for the package to get the attr558    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {559        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});560        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});561        return error.PackageNotFound;562    };563    defer ctx.gpa.free(pkgs);564565    if (pkgs.len == 0) {566        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});567        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});568        return error.PackageNotFound;569    }570571    // Prefer an exact name match over the first search result — `install ripgrep`572    // must pick "ripgrep", not the alphabetically-first match like "repgrep".573    var found = pkgs[0];574    for (pkgs) |p| {575        if (std.mem.eql(u8, p.pname, pkg_name)) {576            found = p;577            break;578        }579    }580    output.installSelectPrompt(found.pname, found.version);581582    var choice_buf: [8]u8 = undefined;583    var choice_len: usize = 0;584    while (choice_len < choice_buf.len) {585        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;586        if (n == 0) break;587        if (choice_buf[choice_len] == '\n') break;588        choice_len += 1;589    }590    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");591592    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {593        try profileInstallPkg(ctx, found);594    } else if (choice[0] == 's' or choice[0] == 'S') {595        try systemInstallPkg(ctx, found);596    } else if (choice[0] == 't' or choice[0] == 'T') {597        try tryPkg(ctx, found);598    }599}600601fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {602    switch (result.action) {603        .profile => try profileInstallPkg(ctx, result.package),604        .system => try systemInstallPkg(ctx, result.package),605    }606}607608fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {609    output.installStart(npkg.pname, .profile);610    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});611    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));612    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);613    if (code != 0) return error.ProcessFailed;614    output.installDone(npkg.pname);615}616617fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {618    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});619    defer ctx.gpa.free(config_path);620    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;621    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);622    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);623    if (ctx.cfg.confirm) {624        if (output.confirm("apply now? ")) {625            try apply(ctx);626        }627    }628}629630fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {631    output.tryStart(npkg.pname);632    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});633    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);634    output.tryDone();635}636637pub fn remove(ctx: Ctx) !void {638    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;639    output.removeStart(pkg_name);640    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);641    if (code != 0) return error.ProcessFailed;642    output.done();643}644645pub fn list(ctx: Ctx) !void {646    output.listHeader(ctx.machine.name);647648    // Profile packages: parse `nix profile list --json` into clean name+version649    // rows instead of dumping nix's verbose multi-line "Name:/Flake attribute:"650    // blocks.651    if (exec.profileListJson(ctx.gpa, ctx.io, &ctx.machine) catch null) |json| {652        defer ctx.gpa.free(json);653        output.listSection("profile");654        renderProfileJson(ctx.gpa, json);655    }656657    // System packages: the references of /run/current-system/sw, each a658    // <hash>-<name>-<version> store path.659    if (exec.systemPackagePaths(ctx.gpa, ctx.io, &ctx.machine) catch null) |paths| {660        defer ctx.gpa.free(paths);661        output.blankLine();662        output.listSection("system");663        var lines = std.mem.splitScalar(u8, paths, '\n');664        while (lines.next()) |line| {665            const t = std.mem.trim(u8, line, " \t\r");666            if (t.len == 0) continue;667            const row = pkgFromStorePath(t);668            // Skip support paths with no parseable version (man pages, wrappers).669            if (row.version.len == 0) continue;670            output.listPkg(row.name, row.version);671        }672    }673}674675const PkgRow = struct { name: []const u8, version: []const u8 };676677// Split a nix store path basename (<hash>-<name>-<version>) into name + version.678// Version begins at the first '-' followed by a digit, which reliably lands on679// the name/version boundary since the 32-char hash and package names start with680// non-digits.681fn pkgFromStorePath(path: []const u8) PkgRow {682    const base = std.fs.path.basename(path);683    const after_hash = if (base.len > 33 and base[32] == '-') base[33..] else base;684    var i: usize = 0;685    while (i + 1 < after_hash.len) : (i += 1) {686        if (after_hash[i] == '-' and std.ascii.isDigit(after_hash[i + 1])) {687            return .{ .name = after_hash[0..i], .version = after_hash[i + 1 ..] };688        }689    }690    return .{ .name = after_hash, .version = "" };691}692693test "pkgFromStorePath splits name and version at first dash-digit" {694    const a = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-ripgrep-15.1.0");695    try std.testing.expectEqualStrings("ripgrep", a.name);696    try std.testing.expectEqualStrings("15.1.0", a.version);697698    const b = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-helix-25.01");699    try std.testing.expectEqualStrings("helix", b.name);700    try std.testing.expectEqualStrings("25.01", b.version);701}702703fn firstStorePath(el: std.json.Value) ?[]const u8 {704    if (el != .object) return null;705    const sp = el.object.get("storePaths") orelse return null;706    if (sp != .array or sp.array.items.len == 0) return null;707    const first = sp.array.items[0];708    return if (first == .string) first.string else null;709}710711// `nix profile list --json` has two shapes across nix versions: newer keys each712// element by name (object), older lists them positionally (array). Handle both.713fn renderProfileJson(gpa: std.mem.Allocator, json: []const u8) void {714    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json, .{}) catch return;715    defer parsed.deinit();716    if (parsed.value != .object) return;717    const elements = parsed.value.object.get("elements") orelse return;718    switch (elements) {719        .object => |obj| {720            var it = obj.iterator();721            while (it.next()) |entry| {722                const ver = if (firstStorePath(entry.value_ptr.*)) |p| pkgFromStorePath(p).version else "";723                output.listPkg(entry.key_ptr.*, ver);724            }725        },726        .array => |arr| {727            for (arr.items) |el| {728                const path = firstStorePath(el) orelse continue;729                const row = pkgFromStorePath(path);730                output.listPkg(row.name, row.version);731            }732        },733        else => {},734    }735}736737pub fn tryCmd(ctx: Ctx) !void {738    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;739    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };740    try tryPkg(ctx, shell_pkg);741}742743// --- Service commands ---744745pub fn service(ctx: Ctx) !void {746    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {747        output.serviceListHeader(ctx.machine.name);748        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};749        defer ctx.gpa.free(svcs);750        for (svcs) |s| output.serviceRow(s);751        return;752    }753    if (std.mem.eql(u8, ctx.sub, "logs")) {754        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;755        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");756        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);757        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);758        return;759    }760    if (std.mem.eql(u8, ctx.sub, "status")) {761        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;762        output.serviceVerb("status", name, ctx.machine.name);763        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);764        return;765    }766    // start/stop/restart/enable/disable767    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;768    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {769        if (!output.confirm("")) return;770    }771    output.serviceVerb(ctx.sub, name, ctx.machine.name);772    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);773    output.serviceDone();774}775776// --- Flake commands ---777778pub fn flake(ctx: Ctx) !void {779    if (std.mem.eql(u8, ctx.sub, "update")) {780        output.flakeUpdateStart();781        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;782        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);783        output.flakeLockWritten();784        return;785    }786    if (std.mem.eql(u8, ctx.sub, "check")) {787        output.flakeCheckStart();788        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);789        if (code == 0) output.flakeValid() else output.checkFailed("");790        return;791    }792    if (std.mem.eql(u8, ctx.sub, "show")) {793        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;794        if (json_out) |j| {795            defer ctx.gpa.free(j);796            output.flakeShowHeader("flake outputs");797            // Parse and display the flake outputs798            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;799            defer parsed.deinit();800            if (parsed.value == .object) {801                var it = parsed.value.object.iterator();802                while (it.next()) |entry| {803                    if (entry.value_ptr.* == .object) {804                        var inner = entry.value_ptr.object.iterator();805                        while (inner.next()) |e2| {806                            const desc = if (e2.value_ptr.* == .object)807                                if (e2.value_ptr.object.get("description")) |d|808                                    if (d == .string) d.string else ""809                                else ""810                            else "";811                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);812                        }813                    }814                }815            }816        }817        return;818    }819    if (std.mem.eql(u8, ctx.sub, "init")) {820        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);821        return;822    }823    if (std.mem.eql(u8, ctx.sub, "lock")) {824        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);825        return;826    }827    if (std.mem.eql(u8, ctx.sub, "clone")) {828        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;829        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);830        return;831    }832}833834// --- Store commands ---835836pub fn store(ctx: Ctx) !void {837    if (std.mem.eql(u8, ctx.sub, "gc")) {838        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);839        return;840    }841    if (std.mem.eql(u8, ctx.sub, "verify")) {842        output.storeVerifyStart();843        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);844        return;845    }846    if (std.mem.eql(u8, ctx.sub, "repair")) {847        if (ctx.cfg.confirm and !output.confirm("")) return;848        output.storeRepairStart();849        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);850        return;851    }852    if (std.mem.eql(u8, ctx.sub, "info")) {853        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);854        output.storeInfo(si);855        return;856    }857    if (std.mem.eql(u8, ctx.sub, "path")) {858        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;859        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);860        defer ctx.gpa.free(path);861        output.storePath(path);862        return;863    }864    // default: info865    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);866    output.storeInfo(si);867}868869// --- Channel commands ---870871pub fn channel(ctx: Ctx) !void {872    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {873        output.channelListHeader(ctx.machine.name);874        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);875        defer ctx.gpa.free(chans);876        for (chans) |ch| output.channelRow(ch);877        return;878    }879    if (std.mem.eql(u8, ctx.sub, "add")) {880        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;881        const name = if (ctx.args.len > 1) ctx.args[1] else "";882        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);883        output.channelDone();884        return;885    }886    if (std.mem.eql(u8, ctx.sub, "remove")) {887        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;888        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);889        output.channelDone();890        return;891    }892    if (std.mem.eql(u8, ctx.sub, "update")) {893        return channelUpdateCmd(ctx);894    }895}896897// --- Profile commands ---898899pub fn profile(ctx: Ctx) !void {900    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {901        output.profileListHeader();902        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;903        if (out) |o| {904            defer ctx.gpa.free(o);905            var lines = std.mem.splitScalar(u8, o, '\n');906            while (lines.next()) |line| {907                const t = std.mem.trim(u8, line, " \t\r");908                if (t.len == 0) continue;909                output.profileRow(t, "", "");910            }911        }912        return;913    }914    if (std.mem.eql(u8, ctx.sub, "install")) {915        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;916        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);917        output.done();918        return;919    }920    if (std.mem.eql(u8, ctx.sub, "remove")) {921        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;922        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);923        output.done();924        return;925    }926    if (std.mem.eql(u8, ctx.sub, "upgrade")) {927        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);928        output.done();929        return;930    }931}932933// --- Pkg commands ---934935pub fn pkg(ctx: Ctx) !void {936    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;937    if (std.mem.eql(u8, ctx.sub, "why")) {938        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);939        return;940    }941    if (std.mem.eql(u8, ctx.sub, "deps")) {942        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);943        return;944    }945    if (std.mem.eql(u8, ctx.sub, "size")) {946        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;947        if (s) |sz| {948            defer ctx.gpa.free(sz);949            output.pkgResult("size", sz);950        }951        return;952    }953    if (std.mem.eql(u8, ctx.sub, "path")) {954        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);955        defer ctx.gpa.free(p);956        output.pkgResult("path", p);957        return;958    }959    if (std.mem.eql(u8, ctx.sub, "closure")) {960        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);961        return;962    }963}964965// --- Pin / Unpin ---966967pub fn pin(ctx: Ctx) !void {968    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;969    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;970    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;971    output.pinDone(input, commit);972}973974pub fn unpin(ctx: Ctx) !void {975    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;976    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);977    output.unpinDone(input);978}979980// --- Hash / Fetch ---981982pub fn hash(ctx: Ctx) !void {983    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;984    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);985    defer ctx.gpa.free(h);986    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));987}988989pub fn fetch(ctx: Ctx) !void {990    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;991    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);992    defer ctx.gpa.free(h);993    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);994}995996// --- Build / Run ---997998pub fn build(ctx: Ctx) !void {999    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;1000    output.buildStart(attr orelse "default");1001    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);1002    if (code != 0) return error.BuildFailed;1003    output.buildDone();1004}10051006pub fn runCmd(ctx: Ctx) !void {1007    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;1008    output.runStart(pkg_arg);1009    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);1010}10111012// --- Hello ---10131014pub fn hello(ctx: Ctx) !void {1015    output.hello(ctx.cfg.machines);1016}10171018pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1019    output.setupCheck();1020    const status = exec.checkNixFeatures(gpa, io, environ);1021    output.setupFeatures(status.nix_command, status.flakes);10221023    if (status.allEnabled()) {1024        output.setupAllSet();1025        api.ensureRegistryPinned(gpa, io, environ);1026        return;1027    }10281029    output.setupEnabling();1030    const path = try exec.enableNixFeatures(gpa, io, environ);1031    defer gpa.free(path);10321033    output.setupDone(path, exec.isNixOS(io));1034    api.ensureRegistryPinned(gpa, io, environ);1035}10361037// --- Log helper ---10381039fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {1040    var ts_buf: [32]u8 = undefined;1041    const ts = nowIso(ctx.io, &ts_buf);1042    var buf: [256]u8 = undefined;1043    const entry = std.fmt.bufPrint(&buf, "{{\"ts\":\"{s}\",\"machine\":\"{s}\",\"command\":\"{s}\",\"outcome\":\"success\",\"gen_before\":null,\"gen_after\":{d},\"duration_ms\":0}}\n", .{1044        ts, ctx.machine.name, action, gen,1045    }) catch return;1046    exec.appendNinaLog(ctx.io, ctx.environ, entry);1047}10481049// Wall-clock UTC as ISO-8601. Uses Io.Clock.real (the settable Unix-epoch clock);1050// Io.Clock.awake is monotonic and unsuitable for timestamps. Falls back to epoch 0.1051fn nowIso(io: std.Io, buf: []u8) []const u8 {1052    const fallback = "1970-01-01T00:00:00Z";1053    const ns = std.Io.Clock.real.now(io).toNanoseconds();1054    if (ns <= 0) return fallback;1055    const secs: u64 = @intCast(@divFloor(ns, 1_000_000_000));1056    const es = std.time.epoch.EpochSeconds{ .secs = secs };1057    const ed = es.getEpochDay();1058    const yd = ed.calculateYearDay();1059    const md = yd.calculateMonthDay();1060    const ds = es.getDaySeconds();1061    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", .{1062        yd.year, md.month.numeric(), @as(u32, md.day_index) + 1,1063        ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(),1064    }) catch return fallback;1065}