exec.zig

23.7 KB · raw

1// exec.zig — all process execution and SSH routing2// Commands call these functions; output is streamed or captured here.3const std = @import("std");4const builtin = @import("builtin");5const types = @import("types.zig");6const errors = @import("errors.zig");7const output = @import("output.zig");89pub const RunResult = struct {10    stdout: []u8,11    stderr: []u8,12    exit_code: u8,13};1415// Run a process and capture stdout+stderr (not streaming).16pub fn capture(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, cmd: []const []const u8) !RunResult {17    const argv = try buildArgv(gpa, machine, cmd);18    defer if (!machine.local) gpa.free(argv);1920    const result = std.process.run(gpa, io, .{ .argv = argv }) catch |e| {21        errors.error_info.setMessage("process failed: {s}", .{cmd[0]});22        return e;23    };2425    return .{26        .stdout = result.stdout,27        .stderr = result.stderr,28        .exit_code = switch (result.term) {29            .exited => |c| c,30            else => 1,31        },32    };33}3435// Run a process and stream its output to the terminal (inherit stdio).36pub fn stream(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, cmd: []const []const u8) !u8 {37    const argv = try buildArgv(gpa, machine, cmd);38    defer if (!machine.local) gpa.free(argv);3940    var child = std.process.spawn(io, .{41        .argv = argv,42        .stdin = .inherit,43        .stdout = .inherit,44        .stderr = .inherit,45    }) catch |e| {46        errors.error_info.setMessage("failed to spawn: {s}", .{cmd[0]});47        return e;48    };4950    const term = child.wait(io) catch |e| {51        errors.error_info.setMessage("process wait failed", .{});52        return e;53    };5455    return switch (term) {56        .exited => |c| c,57        else => 1,58    };59}6061// Run a process and capture output as a single string (no SSH routing — for local nix commands).62pub fn captureLocal(gpa: std.mem.Allocator, io: std.Io, cmd: []const []const u8) ![]u8 {63    const result = std.process.run(gpa, io, .{ .argv = cmd }) catch return error.ProcessFailed;64    defer gpa.free(result.stderr);65    switch (result.term) {66        .exited => |c| if (c != 0) {67            gpa.free(result.stdout);68            return error.ProcessFailed;69        },70        else => {71            gpa.free(result.stdout);72            return error.ProcessFailed;73        },74    }75    return result.stdout;76}7778fn buildArgv(gpa: std.mem.Allocator, machine: *const types.Machine, cmd: []const []const u8) ![]const []const u8 {79    if (machine.local) return cmd;8081    const host = machine.host orelse {82        errors.error_info.setMessage("machine '{s}' has no host configured", .{machine.name});83        return error.SshFailed;84    };8586    var ssh: std.ArrayList([]const u8) = .empty;87    try ssh.append(gpa, "ssh");88    try ssh.append(gpa, "-o");89    try ssh.append(gpa, "BatchMode=yes");90    try ssh.append(gpa, "-o");91    try ssh.append(gpa, "StrictHostKeyChecking=accept-new");92    if (machine.ssh_key) |key| {93        try ssh.append(gpa, "-i");94        try ssh.append(gpa, key);95    }96    const addr = if (machine.user) |u|97        try std.fmt.allocPrint(gpa, "{s}@{s}", .{ u, host })98    else99        try gpa.dupe(u8, host);100    try ssh.append(gpa, addr);101    for (cmd) |arg| try ssh.append(gpa, arg);102    return ssh.toOwnedSlice(gpa);103}104105// --- NixOS rebuild ---106107pub fn nixosRebuildSwitch(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {108    const cmd = &.{ "sudo", "nixos-rebuild", "switch" };109    output.printSubstep("nixos-rebuild switch", .{});110    return stream(io, machine, gpa, cmd);111}112113pub fn nixosRebuildDryActivate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {114    return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "dry-activate" });115}116117pub fn nixosRebuildBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {118    return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "build" });119}120121// --- Generations ---122123pub fn getGenerations(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.GenerationInfo {124    const r = try capture(gpa, io, machine, &.{ "nix-env", "--list-generations" });125    defer gpa.free(r.stderr);126    defer gpa.free(r.stdout);127    if (r.exit_code != 0) return error.ProcessFailed;128    return parseGenerations(gpa, r.stdout);129}130131fn parseGenerations(gpa: std.mem.Allocator, output_str: []const u8) ![]types.GenerationInfo {132    var gens: std.ArrayList(types.GenerationInfo) = .empty;133    var lines = std.mem.splitScalar(u8, output_str, '\n');134    while (lines.next()) |line| {135        const trimmed = std.mem.trim(u8, line, " \t");136        if (trimmed.len == 0) continue;137        var parts = std.mem.tokenizeAny(u8, trimmed, " \t");138        const num_str = parts.next() orelse continue;139        const date_str = parts.next() orelse continue;140        const time_str = parts.next() orelse continue;141        const rest = parts.rest();142        const current = std.mem.indexOf(u8, rest, "current") != null;143        const num = std.fmt.parseInt(u32, num_str, 10) catch continue;144        try gens.append(gpa, .{145            .number = num,146            .date = try gpa.dupe(u8, date_str),147            .time = try gpa.dupe(u8, time_str),148            .current = current,149        });150    }151    return gens.toOwnedSlice(gpa);152}153154pub fn getCurrentGeneration(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) !u32 {155    const gens = try getGenerations(gpa, io, machine);156    defer gpa.free(gens);157    for (gens) |g| {158        if (g.current) return g.number;159    }160    return 0;161}162163pub fn switchGeneration(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, n: u32) !u8 {164    const n_str = try std.fmt.allocPrint(gpa, "{d}", .{n});165    defer gpa.free(n_str);166    return stream(io, machine, gpa, &.{ "nix-env", "--switch-generation", n_str });167}168169pub fn rollback(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {170    return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "switch", "--rollback" });171}172173pub fn deleteGeneration(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, n: u32) !u8 {174    const n_str = try std.fmt.allocPrint(gpa, "{d}", .{n});175    defer gpa.free(n_str);176    return stream(io, machine, gpa, &.{ "nix-env", "--delete-generations", n_str });177}178179pub fn deleteOldGenerations(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {180    return stream(io, machine, gpa, &.{ "nix-env", "--delete-generations", "old" });181}182183// --- Garbage collection ---184185pub fn collectGarbage(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, delete_old: bool) !u8 {186    if (delete_old) {187        return stream(io, machine, gpa, &.{ "sudo", "nix-collect-garbage", "-d" });188    }189    const keep_str = try std.fmt.allocPrint(gpa, "{d}", .{5});190    defer gpa.free(keep_str);191    return stream(io, machine, gpa, &.{ "sudo", "nix-collect-garbage", "--delete-older-than", keep_str });192}193194// --- Channels ---195196pub fn channelList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.ChannelInfo {197    const r = try capture(gpa, io, machine, &.{ "nix-channel", "--list" });198    defer gpa.free(r.stderr);199    defer gpa.free(r.stdout);200    if (r.exit_code != 0) return error.ProcessFailed;201    return parseChannels(gpa, r.stdout);202}203204fn parseChannels(gpa: std.mem.Allocator, output_str: []const u8) ![]types.ChannelInfo {205    var channels: std.ArrayList(types.ChannelInfo) = .empty;206    var lines = std.mem.splitScalar(u8, output_str, '\n');207    while (lines.next()) |line| {208        const trimmed = std.mem.trim(u8, line, " \t\r");209        if (trimmed.len == 0) continue;210        const space = std.mem.indexOfScalar(u8, trimmed, ' ') orelse continue;211        try channels.append(gpa, .{212            .name = try gpa.dupe(u8, trimmed[0..space]),213            .url = try gpa.dupe(u8, std.mem.trim(u8, trimmed[space + 1 ..], " \t")),214        });215    }216    return channels.toOwnedSlice(gpa);217}218219pub fn channelUpdate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {220    return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--update" });221}222223pub fn channelAdd(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, url: []const u8, name: []const u8) !u8 {224    return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--add", url, name });225}226227pub fn channelRemove(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, name: []const u8) !u8 {228    return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--remove", name });229}230231// --- Services ---232233pub fn serviceList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.ServiceInfo {234    const r = try capture(gpa, io, machine, &.{ "systemctl", "list-units", "--type=service", "--no-legend", "--no-pager" });235    defer gpa.free(r.stderr);236    defer gpa.free(r.stdout);237    if (r.exit_code != 0) return error.ProcessFailed;238    return parseServices(gpa, r.stdout);239}240241fn parseServices(gpa: std.mem.Allocator, output_str: []const u8) ![]types.ServiceInfo {242    var svcs: std.ArrayList(types.ServiceInfo) = .empty;243    var lines = std.mem.splitScalar(u8, output_str, '\n');244    while (lines.next()) |line| {245        const trimmed = std.mem.trim(u8, line, " \t\r");246        if (trimmed.len == 0 or trimmed[0] == 0xE2) continue; // skip UTF-8 bullet lines247        var parts = std.mem.tokenizeAny(u8, trimmed, " \t");248        const name = parts.next() orelse continue;249        _ = parts.next(); // load250        const active_str = parts.next() orelse continue;251        const sub_str = parts.next() orelse "unknown";252253        const state: types.ServiceState = if (std.mem.eql(u8, active_str, "active"))254            (if (std.mem.eql(u8, sub_str, "running") or std.mem.eql(u8, sub_str, "exited")) .active else .active)255        else if (std.mem.eql(u8, active_str, "failed"))256            .failed257        else258            .inactive;259260        try svcs.append(gpa, .{261            .name = try gpa.dupe(u8, name),262            .state = state,263            .uptime = null,264        });265    }266    return svcs.toOwnedSlice(gpa);267}268269pub fn serviceCtl(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, verb: []const u8, name: []const u8) !u8 {270    return stream(io, machine, gpa, &.{ "sudo", "systemctl", verb, name });271}272273pub fn serviceLogs(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, name: []const u8, last: u32, follow: bool) !u8 {274    const n_str = try std.fmt.allocPrint(gpa, "{d}", .{last});275    defer gpa.free(n_str);276    if (follow) {277        return stream(io, machine, gpa, &.{ "journalctl", "-u", name, "-f" });278    }279    return stream(io, machine, gpa, &.{ "journalctl", "-u", name, "-n", n_str, "--no-pager" });280}281282// --- Profile ---283284pub fn profileInstall(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: []const u8) !u8 {285    const pkg_ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{attr});286    defer gpa.free(pkg_ref);287    return stream(io, machine, gpa, &.{ "nix", "profile", "install", pkg_ref });288}289290pub fn profileRemove(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: []const u8) !u8 {291    const pkg_ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{attr});292    defer gpa.free(pkg_ref);293    return stream(io, machine, gpa, &.{ "nix", "profile", "remove", pkg_ref });294}295296pub fn profileList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {297    const r = try capture(gpa, io, machine, &.{ "nix", "profile", "list" });298    defer gpa.free(r.stderr);299    if (r.exit_code != 0) return error.ProcessFailed;300    return r.stdout;301}302303pub fn profileUpgrade(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {304    return stream(io, machine, gpa, &.{ "nix", "profile", "upgrade", ".*" });305}306307// --- Store ---308309pub fn storeGc(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {310    return stream(io, machine, gpa, &.{ "sudo", "nix-collect-garbage" });311}312313pub fn storeVerify(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {314    return stream(io, machine, gpa, &.{ "nix", "store", "verify", "--all" });315}316317pub fn storeRepair(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {318    return stream(io, machine, gpa, &.{ "sudo", "nix", "store", "repair", "--all" });319}320321pub fn storeInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) !types.StoreInfo {322    const pi = try capture(gpa, io, machine, &.{ "nix", "path-info", "-S", "--all", "--json" });323    defer gpa.free(pi.stdout);324    defer gpa.free(pi.stderr);325326    var live: u64 = 0;327    if (pi.exit_code == 0) {328        live = countJsonPaths(pi.stdout);329    }330331    const du = try capture(gpa, io, machine, &.{ "du", "-sh", "/nix/store" });332    defer gpa.free(du.stderr);333    const total = blk: {334        const line = std.mem.trim(u8, du.stdout, " \t\n\r");335        const tab = std.mem.indexOfScalar(u8, line, '\t') orelse break :blk "?";336        break :blk line[0..tab];337    };338    const total_owned = try gpa.dupe(u8, total);339    gpa.free(du.stdout);340341    return .{342        .total_size = total_owned,343        .live_paths = live,344        .reclaimable_paths = 0,345    };346}347348fn countJsonPaths(json_str: []const u8) u64 {349    // quick count: number of top-level keys350    var count: u64 = 0;351    var i: usize = 0;352    while (i < json_str.len) : (i += 1) {353        if (json_str[i] == '"') count += 1;354    }355    return count / 2; // each path has 2 quotes356}357358pub fn storePath(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, attr: []const u8) ![]u8 {359    const expr = try std.fmt.allocPrint(gpa, ".#{s}.outPath", .{attr});360    defer gpa.free(expr);361    const r = try capture(gpa, io, machine, &.{ "nix", "eval", "--raw", expr });362    defer gpa.free(r.stderr);363    if (r.exit_code != 0) {364        gpa.free(r.stdout);365        return error.ProcessFailed;366    }367    return r.stdout;368}369370// --- Flake ---371372pub fn flakeUpdate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, input: ?[]const u8) !u8 {373    if (input) |inp| {374        return stream(io, machine, gpa, &.{ "nix", "flake", "update", inp });375    }376    return stream(io, machine, gpa, &.{ "nix", "flake", "update" });377}378379pub fn flakeCheck(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {380    return stream(io, machine, gpa, &.{ "nix", "flake", "check" });381}382383pub fn flakeInit(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {384    return stream(io, machine, gpa, &.{ "nix", "flake", "init" });385}386387pub fn flakeLock(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {388    return stream(io, machine, gpa, &.{ "nix", "flake", "lock" });389}390391pub fn flakeClone(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, url: []const u8) !u8 {392    return stream(io, machine, gpa, &.{ "nix", "flake", "clone", url });393}394395pub fn flakeShow(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {396    const r = try capture(gpa, io, machine, &.{ "nix", "flake", "show", "--json" });397    defer gpa.free(r.stderr);398    if (r.exit_code != 0) {399        gpa.free(r.stdout);400        return error.ProcessFailed;401    }402    return r.stdout;403}404405// --- Build / Run / Develop ---406407pub fn nixBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: ?[]const u8) !u8 {408    if (attr) |a| {409        const ref = try std.fmt.allocPrint(gpa, ".#{s}", .{a});410        defer gpa.free(ref);411        return stream(io, machine, gpa, &.{ "nix", "build", ref });412    }413    return stream(io, machine, gpa, &.{ "nix", "build" });414}415416pub fn nixRun(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {417    // nix run always gets --no-write-lock-file for remote URLs418    const is_url = std.mem.startsWith(u8, pkg, "http://") or std.mem.startsWith(u8, pkg, "https://") or std.mem.startsWith(u8, pkg, "github:");419    if (is_url) {420        return stream(io, machine, gpa, &.{ "nix", "run", pkg, "--no-write-lock-file" });421    }422    const ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{pkg});423    defer gpa.free(ref);424    return stream(io, machine, gpa, &.{ "nix", "run", ref });425}426427pub fn nixShell(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {428    const ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{pkg});429    defer gpa.free(ref);430    return stream(io, machine, gpa, &.{ "nix", "shell", ref });431}432433pub fn nixDevelop(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, run_cmd: ?[]const u8) !u8 {434    if (run_cmd) |cmd| {435        return stream(io, machine, gpa, &.{ "nix", "develop", "--command", cmd });436    }437    return stream(io, machine, gpa, &.{ "nix", "develop" });438}439440pub fn nixRepl(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {441    return stream(io, machine, gpa, &.{ "nix", "repl", "--expr", "import <nixpkgs> {}" });442}443444// --- Format ---445446pub fn nixpkgsFmt(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, path: []const u8, check: bool) !u8 {447    if (check) {448        return stream(io, machine, gpa, &.{ "nixpkgs-fmt", "--check", path });449    }450    return stream(io, machine, gpa, &.{ "nixpkgs-fmt", path });451}452453// --- Info ---454455pub fn systemInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {456    const r = try capture(gpa, io, machine, &.{"nixos-version"});457    defer gpa.free(r.stderr);458    if (r.exit_code != 0) {459        gpa.free(r.stdout);460        return error.ProcessFailed;461    }462    return r.stdout;463}464465pub fn kernelInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {466    const r = try capture(gpa, io, machine, &.{ "uname", "-r" });467    defer gpa.free(r.stderr);468    if (r.exit_code != 0) {469        gpa.free(r.stdout);470        return error.ProcessFailed;471    }472    return r.stdout;473}474475pub fn uptimeInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {476    const r = try capture(gpa, io, machine, &.{"uptime"});477    defer gpa.free(r.stderr);478    if (r.exit_code != 0) {479        gpa.free(r.stdout);480        return error.ProcessFailed;481    }482    return r.stdout;483}484485// --- Boot ---486487pub fn bootEntries(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.BootEntry {488    const r = try capture(gpa, io, machine, &.{ "bootctl", "list" });489    defer gpa.free(r.stderr);490    defer gpa.free(r.stdout);491    if (r.exit_code != 0) return error.ProcessFailed;492    return parseBootEntries(gpa, r.stdout);493}494495fn parseBootEntries(gpa: std.mem.Allocator, output_str: []const u8) ![]types.BootEntry {496    var entries: std.ArrayList(types.BootEntry) = .empty;497    var lines = std.mem.splitScalar(u8, output_str, '\n');498    while (lines.next()) |line| {499        const trimmed = std.mem.trim(u8, line, " \t\r");500        if (std.mem.startsWith(u8, trimmed, "title:")) {501            const title = std.mem.trim(u8, trimmed[6..], " \t");502            const current = std.mem.indexOf(u8, title, "(current)") != null;503            try entries.append(gpa, .{504                .title = try gpa.dupe(u8, title),505                .current = current,506            });507        }508    }509    return entries.toOwnedSlice(gpa);510}511512// --- Pkg info ---513514pub fn pkgWhy(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {515    return stream(io, machine, gpa, &.{ "nix", "why-depends", ".#", pkg });516}517518pub fn pkgDeps(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {519    return stream(io, machine, gpa, &.{ "nix-store", "--query", "--requisites", pkg });520}521522pub fn pkgSize(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, pkg: []const u8) ![]u8 {523    const r = try capture(gpa, io, machine, &.{ "nix", "path-info", "-s", pkg });524    defer gpa.free(r.stderr);525    if (r.exit_code != 0) {526        gpa.free(r.stdout);527        return error.ProcessFailed;528    }529    return r.stdout;530}531532pub fn pkgClosure(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {533    return stream(io, machine, gpa, &.{ "nix", "path-info", "-rS", pkg });534}535536// --- Hash / Fetch ---537538pub fn nixHash(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, path: []const u8) ![]u8 {539    const r = try capture(gpa, io, machine, &.{ "nix", "hash", "path", path });540    defer gpa.free(r.stderr);541    if (r.exit_code != 0) {542        gpa.free(r.stdout);543        return error.ProcessFailed;544    }545    return r.stdout;546}547548pub fn nixFetch(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, url: []const u8) ![]u8 {549    const r = try capture(gpa, io, machine, &.{ "nix-prefetch-url", url });550    defer gpa.free(r.stderr);551    if (r.exit_code != 0) {552        gpa.free(r.stdout);553        return error.ProcessFailed;554    }555    return r.stdout;556}557558// --- Pin ---559560pub fn editFlakeLock(_: std.mem.Allocator, _: []const u8, _: []const u8) !void {561    // Stub: full implementation would parse and edit flake.lock JSON562    // The pin/unpin flow works via nix flake update which regenerates flake.lock563}564565pub fn openEditor(io: std.Io, gpa: std.mem.Allocator, editor: []const u8, path: []const u8, line: ?u32) !u8 {566    if (line) |l| {567        const line_arg = try std.fmt.allocPrint(gpa, "+{d}", .{l});568        defer gpa.free(line_arg);569        return stream(io, &types.Machine{}, gpa, &.{ editor, path, line_arg });570    }571    return stream(io, &types.Machine{}, gpa, &.{ editor, path });572}573574pub fn diffClosures(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, a: u32, b: u32) !u8 {575    const a_str = try std.fmt.allocPrint(gpa, "/nix/var/nix/profiles/system-{d}-link", .{a});576    defer gpa.free(a_str);577    const b_str = try std.fmt.allocPrint(gpa, "/nix/var/nix/profiles/system-{d}-link", .{b});578    defer gpa.free(b_str);579    return stream(io, machine, gpa, &.{ "nix", "store", "diff-closures", a_str, b_str });580}581582// --- Doctor checks ---583584pub fn runDoctorChecks(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.DoctorCheck {585    var checks: std.ArrayList(types.DoctorCheck) = .empty;586587    // Check nix daemon588    {589        const r = try capture(gpa, io, machine, &.{ "systemctl", "is-active", "nix-daemon" });590        defer gpa.free(r.stdout);591        defer gpa.free(r.stderr);592        const active = std.mem.indexOf(u8, r.stdout, "active") != null;593        try checks.append(gpa, .{ .name = "nix daemon", .status = if (active) .ok else .fail });594    }595596    // Check config syntax597    {598        const r = try capture(gpa, io, machine, &.{ "sudo", "nixos-rebuild", "build", "--dry-run" });599        defer gpa.free(r.stdout);600        defer gpa.free(r.stderr);601        try checks.append(gpa, .{ .name = "config syntax", .status = if (r.exit_code == 0) .ok else .fail });602    }603604    // Check channel605    {606        const r = try capture(gpa, io, machine, &.{ "nix-channel", "--list" });607        defer gpa.free(r.stdout);608        defer gpa.free(r.stderr);609        const has_channel = r.exit_code == 0 and r.stdout.len > 0;610        try checks.append(gpa, .{ .name = "channel", .status = if (has_channel) .ok else .warn });611    }612613    return checks.toOwnedSlice(gpa);614}615616// --- Log ---617618pub fn readNinaLog(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, last: u32) ![]u8 {619    const home = environ.get("HOME") orelse return error.FileNotFound;620    const path = try std.fmt.allocPrint(gpa, "{s}/.nina.log", .{home});621    defer gpa.free(path);622    _ = last;623    return std.Io.Dir.readFileAlloc(.cwd(), io, path, gpa, .unlimited) catch return error.FileNotFound;624}625626pub fn appendNinaLog(io: std.Io, environ: *const std.process.Environ.Map, entry: []const u8) void {627    const home = environ.get("HOME") orelse return;628    var path_buf: [512]u8 = undefined;629    const path = std.fmt.bufPrint(&path_buf, "{s}/.nina.log", .{home}) catch return;630    const file = std.Io.Dir.openFile(.cwd(), io, path, .{ .mode = .read_write }) catch631        (std.Io.Dir.createFile(.cwd(), io, path, .{}) catch return);632    defer file.close(io);633    const size = file.length(io) catch 0;634    file.writePositionalAll(io, entry, size) catch {};635}