2c3d3893f7f1

Phase 1-5: foundation, output, api, exec, search TUI, commands — compiles and tests pass on Zig 0.16

time
2026-05-29 02:33 UTC · 4 months ago
machine
koh2
parent
edb704e69eb3
11 files build.zig+32 −0build.zig.zon+81 −0src/api.zig+160 −0src/commands.zig+684 −0src/config.zig+147 −0src/errors.zig+117 −0src/exec.zig+635 −0src/main.zig+222 −0src/output.zig+651 −0src/search.zig+272 −0src/types.zig+100 −0
build.zig+32 −0
@@ -0,0 +1,32 @@1+const std = @import("std");2+3+pub fn build(b: *std.Build) void {4+    const target = b.standardTargetOptions(.{});5+    const optimize = b.standardOptimizeOption(.{});6+7+    const exe = b.addExecutable(.{8+        .name = "nina",9+        .root_module = b.createModule(.{10+            .root_source_file = b.path("src/main.zig"),11+            .target = target,12+            .optimize = optimize,13+        }),14+    });15+    b.installArtifact(exe);16+17+    const run_cmd = b.addRunArtifact(exe);18+    run_cmd.step.dependOn(b.getInstallStep());19+    if (b.args) |args| run_cmd.addArgs(args);20+    const run_step = b.step("run", "Run nina");21+    run_step.dependOn(&run_cmd.step);22+23+    const tests = b.addTest(.{24+        .root_module = b.createModule(.{25+            .root_source_file = b.path("src/main.zig"),26+            .target = target,27+            .optimize = optimize,28+        }),29+    });30+    const test_step = b.step("test", "Run unit tests");31+    test_step.dependOn(&b.addRunArtifact(tests).step);32+}

view file

build.zig.zon+81 −0
@@ -0,0 +1,81 @@1+.{2+    // This is the default name used by packages depending on this one. For3+    // example, when a user runs `zig fetch --save <url>`, this field is used4+    // as the key in the `dependencies` table. Although the user can choose a5+    // different name, most users will stick with this provided value.6+    //7+    // It is redundant to include "zig" in this name because it is already8+    // within the Zig package namespace.9+    .name = .nina,10+    // This is a [Semantic Version](https://semver.org/).11+    // In a future version of Zig it will be used for package deduplication.12+    .version = "3.0.0",13+    // Together with name, this represents a globally unique package14+    // identifier. This field is generated by the Zig toolchain when the15+    // package is first created, and then *never changes*. This allows16+    // unambiguous detection of one package being an updated version of17+    // another.18+    //19+    // When forking a Zig project, this id should be regenerated (delete the20+    // field and run `zig build`) if the upstream project is still maintained.21+    // Otherwise, the fork is *hostile*, attempting to take control over the22+    // original project's identity. Thus it is recommended to leave the comment23+    // on the following line intact, so that it shows up in code reviews that24+    // modify the field.25+    .fingerprint = 0x7c70b8644751c4b1, // Changing this has security and trust implications.26+    // Tracks the earliest Zig version that the package considers to be a27+    // supported use case.28+    .minimum_zig_version = "0.16.0",29+    // This field is optional.30+    // Each dependency must either provide a `url` and `hash`, or a `path`.31+    // `zig build --fetch` can be used to fetch all dependencies of a package, recursively.32+    // Once all dependencies are fetched, `zig build` no longer requires33+    // internet connectivity.34+    .dependencies = .{35+        // See `zig fetch --save <url>` for a command-line interface for adding dependencies.36+        //.example = .{37+        //    // When updating this field to a new URL, be sure to delete the corresponding38+        //    // `hash`, otherwise you are communicating that you expect to find the old hash at39+        //    // the new URL. If the contents of a URL change this will result in a hash mismatch40+        //    // which will prevent zig from using it.41+        //    .url = "https://example.com/foo.tar.gz",42+        //43+        //    // This is computed from the file contents of the directory of files that is44+        //    // obtained after fetching `url` and applying the inclusion rules given by45+        //    // `paths`.46+        //    //47+        //    // This field is the source of truth; packages do not come from a `url`; they48+        //    // come from a `hash`. `url` is just one of many possible mirrors for how to49+        //    // obtain a package matching this `hash`.50+        //    //51+        //    // Uses the [multihash](https://multiformats.io/multihash/) format.52+        //    .hash = "...",53+        //54+        //    // When this is provided, the package is found in a directory relative to the55+        //    // build root. In this case the package's hash is irrelevant and therefore not56+        //    // computed. This field and `url` are mutually exclusive.57+        //    .path = "foo",58+        //59+        //    // When this is set to `true`, a package is declared to be lazily60+        //    // fetched. This makes the dependency only get fetched if it is61+        //    // actually used.62+        //    .lazy = false,63+        //},64+    },65+    // Specifies the set of files and directories that are included in this package.66+    // Only files and directories listed here are included in the `hash` that67+    // is computed for this package. Only files listed here will remain on disk68+    // when using the zig package manager. As a rule of thumb, one should list69+    // files required for compilation plus any license(s).70+    // Paths are relative to the build root. Use the empty string (`""`) to refer to71+    // the build root itself.72+    // A directory listed here means that all files within, recursively, are included.73+    .paths = .{74+        "build.zig",75+        "build.zig.zon",76+        "src",77+        // For example...78+        //"LICENSE",79+        //"README.md",80+    },81+}

view file

src/api.zig+160 −0
@@ -0,0 +1,160 @@1+// api.zig — nix data access: search, eval, options2+// No HTTP. No curl. nix is on PATH by definition on NixOS.3+const std = @import("std");4+const types = @import("types.zig");5+const errors = @import("errors.zig");6+7+pub fn searchPackages(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]types.NixPackage {8+    const result = std.process.run(gpa, io, .{9+        .argv = &.{ "nix", "search", "nixpkgs", query, "--json", "--no-update-lock-file" },10+    }) catch {11+        errors.error_info.setSuggestion("run nix-channel --update and try again", .{});12+        return error.SearchFailed;13+    };14+    defer gpa.free(result.stderr);15+    defer gpa.free(result.stdout);16+17+    switch (result.term) {18+        .exited => |code| if (code != 0) {19+            errors.error_info.setSuggestion("run nix-channel --update and try again", .{});20+            return error.SearchFailed;21+        },22+        else => return error.SearchFailed,23+    }24+25+    return parseSearchJson(gpa, result.stdout);26+}27+28+fn parseSearchJson(gpa: std.mem.Allocator, json_str: []const u8) ![]types.NixPackage {29+    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;30+    defer parsed.deinit();31+32+    if (parsed.value != .object) return error.SearchFailed;33+34+    var packages: std.ArrayList(types.NixPackage) = .empty;35+    var it = parsed.value.object.iterator();36+    while (it.next()) |entry| {37+        const key = entry.key_ptr.*;38+        const val = entry.value_ptr.*;39+        if (val != .object) continue;40+41+        // Key format: "legacyPackages.x86_64-linux.firefox"42+        const last_dot = std.mem.lastIndexOfScalar(u8, key, '.') orelse continue;43+        const attr = key[last_dot + 1 ..];44+45+        const pname = if (val.object.get("pname")) |v|46+            if (v == .string) v.string else attr47+        else48+            attr;49+50+        const version = if (val.object.get("version")) |v|51+            if (v == .string) v.string else ""52+        else53+            "";54+55+        const description = if (val.object.get("description")) |v|56+            if (v == .string) v.string else ""57+        else58+            "";59+60+        try packages.append(gpa, .{61+            .attr = try gpa.dupe(u8, attr),62+            .pname = try gpa.dupe(u8, pname),63+            .version = try gpa.dupe(u8, version),64+            .description = try gpa.dupe(u8, description),65+        });66+    }67+68+    return packages.toOwnedSlice(gpa);69+}70+71+pub fn getLicense(gpa: std.mem.Allocator, io: std.Io, attr: []const u8) ?[]const u8 {72+    const expr = std.fmt.allocPrint(gpa, "nixpkgs#{s}.meta.license.shortName", .{attr}) catch return null;73+    defer gpa.free(expr);74+75+    const result = std.process.run(gpa, io, .{76+        .argv = &.{ "nix", "eval", "--raw", expr },77+    }) catch return null;78+    defer gpa.free(result.stderr);79+80+    switch (result.term) {81+        .exited => |code| if (code != 0) {82+            gpa.free(result.stdout);83+            return null;84+        },85+        else => {86+            gpa.free(result.stdout);87+            return null;88+        },89+    }90+91+    const license = std.mem.trim(u8, result.stdout, " \t\n\r");92+    if (license.len == 0) {93+        gpa.free(result.stdout);94+        return null;95+    }96+    return license;97+}98+99+pub fn searchOptions(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]NixOption {100+    const result = std.process.run(gpa, io, .{101+        .argv = &.{ "nix", "eval", "--json", "nixpkgs#lib.optionAttrSetToDocList", "nixpkgs#nixosModules" },102+    }) catch return error.SearchFailed;103+    defer gpa.free(result.stderr);104+    defer gpa.free(result.stdout);105+106+    switch (result.term) {107+        .exited => |code| if (code != 0) return error.SearchFailed,108+        else => return error.SearchFailed,109+    }110+111+    return filterOptions(gpa, result.stdout, query);112+}113+114+pub const NixOption = struct {115+    name: []const u8,116+    type_str: []const u8,117+    default: ?[]const u8,118+    description: []const u8,119+};120+121+fn filterOptions(gpa: std.mem.Allocator, json_str: []const u8, query: []const u8) ![]NixOption {122+    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;123+    defer parsed.deinit();124+125+    if (parsed.value != .array) return error.SearchFailed;126+127+    var opts: std.ArrayList(NixOption) = .empty;128+    for (parsed.value.array.items) |item| {129+        if (item != .object) continue;130+        const name = if (item.object.get("name")) |v| if (v == .string) v.string else continue else continue;131+        if (query.len > 0 and std.mem.indexOf(u8, name, query) == null) continue;132+133+        const type_str = if (item.object.get("type")) |v| if (v == .string) v.string else "any" else "any";134+        const description = if (item.object.get("description")) |v| if (v == .string) v.string else "" else "";135+        const default_val: ?[]const u8 = if (item.object.get("default")) |v| switch (v) {136+            .string => |s| s,137+            else => null,138+        } else null;139+140+        try opts.append(gpa, .{141+            .name = try gpa.dupe(u8, name),142+            .type_str = try gpa.dupe(u8, type_str),143+            .default = if (default_val) |d| try gpa.dupe(u8, d) else null,144+            .description = try gpa.dupe(u8, description),145+        });146+    }147+148+    return opts.toOwnedSlice(gpa);149+}150+151+pub fn findSystemPackagesLine(io: std.Io, config_path: []const u8) ?u32 {152+    var buf: [65536]u8 = undefined;153+    const content = std.Io.Dir.readFile(.cwd(), io, config_path, &buf) catch return null;154+    var lines = std.mem.splitScalar(u8, content, '\n');155+    var line_num: u32 = 1;156+    while (lines.next()) |line| : (line_num += 1) {157+        if (std.mem.indexOf(u8, line, "systemPackages") != null) return line_num;158+    }159+    return null;160+}

view file

src/commands.zig+684 −0
@@ -0,0 +1,684 @@1+// commands.zig — all command handlers2+// Orchestrates exec/api/output. Zero direct stdout writes.3+const std = @import("std");4+const types = @import("types.zig");5+const errors = @import("errors.zig");6+const config = @import("config.zig");7+const output = @import("output.zig");8+const exec = @import("exec.zig");9+const api = @import("api.zig");10+const search_tui = @import("search.zig");11+12+pub const Ctx = struct {13+    gpa: std.mem.Allocator,14+    io: std.Io,15+    environ: *const std.process.Environ.Map,16+    machine: types.Machine,17+    cfg: types.NinaConfig,18+    args: []const []const u8,   // positional args after subcommand19+    sub: []const u8,            // subcommand (e.g. "list" in "service list")20+    dry: bool,21+    check: bool,22+    no_apply: bool,23+    all: bool,24+    last: u32,25+    json: bool,26+};27+28+pub fn apply(ctx: Ctx) !void {29+    if (ctx.dry) {30+        output.applyDry(ctx.machine.name);31+        const code = try exec.nixosRebuildDryActivate(ctx.io, &ctx.machine, ctx.gpa);32+        if (code != 0) return error.BuildFailed;33+        output.applyDryDone();34+        return;35+    }36+    if (ctx.check) {37+        return check(ctx);38+    }39+40+    output.applyStart(ctx.machine.name);41+    const start = std.Io.Clock.awake.now(ctx.io);42+43+    const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {44+        output.applyFailed("");45+        return e;46+    };47+48+    if (code != 0) {49+        output.applyFailed("");50+        return error.BuildFailed;51+    }52+53+    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());54+    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));55+    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;56+    output.applyDone(gen, elapsed);57+    logAction(ctx, "apply", gen);58+}59+60+pub fn check(ctx: Ctx) !void {61+    output.checkStart(ctx.machine.name);62+    const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);63+    if (code != 0) {64+        output.checkFailed("");65+        return error.BuildFailed;66+    }67+    output.checkOk();68+}69+70+pub fn back(ctx: Ctx) !void {71+    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);72+    defer ctx.gpa.free(gens);73+74+    var current: u32 = 0;75+    for (gens) |g| if (g.current) { current = g.number; };76+    const prev = if (current > 1) current - 1 else current;77+78+    output.backStart(ctx.machine.name, current, prev);79+    if (ctx.cfg.confirm and !output.confirm("")) return;80+81+    const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);82+    if (code != 0) return error.BuildFailed;83+    output.generationDone(prev);84+}85+86+pub fn go(ctx: Ctx) !void {87+    if (ctx.args.len == 0) return error.Internal;88+    const target = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;89+    const current = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);90+91+    output.goStart(ctx.machine.name, current, target);92+    if (ctx.cfg.confirm and !output.confirm("")) return;93+94+    const code = try exec.switchGeneration(ctx.io, &ctx.machine, ctx.gpa, target);95+    if (code != 0) return error.ProcessFailed;96+    output.generationDone(target);97+}98+99+pub fn history(ctx: Ctx) !void {100+    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);101+    defer ctx.gpa.free(gens);102+    output.historyHeader(ctx.machine.name);103+    for (gens) |g| output.historyRow(g);104+}105+106+pub fn genCmd(ctx: Ctx) !void {107+    if (std.mem.eql(u8, ctx.sub, "list")) return history(ctx);108+    if (std.mem.eql(u8, ctx.sub, "current")) {109+        const n = try exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine);110+        output.genCurrent(n);111+        return;112+    }113+    if (std.mem.eql(u8, ctx.sub, "delete")) {114+        if (ctx.args.len == 0) return error.Internal;115+        if (std.mem.eql(u8, ctx.args[0], "old")) {116+            if (ctx.cfg.confirm and !output.confirm("")) return;117+            _ = try exec.deleteOldGenerations(ctx.io, &ctx.machine, ctx.gpa);118+        } else {119+            const n = std.fmt.parseInt(u32, ctx.args[0], 10) catch return error.Internal;120+            if (ctx.cfg.confirm and !output.confirm("")) return;121+            _ = try exec.deleteGeneration(ctx.io, &ctx.machine, ctx.gpa, n);122+        }123+        output.done();124+        return;125+    }126+    return error.Internal;127+}128+129+pub fn clean(ctx: Ctx) !void {130+    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);131+    defer ctx.gpa.free(gens);132+    output.cleanStart(ctx.machine.name, ctx.cfg.generations, @intCast(gens.len));133+    if (ctx.cfg.confirm and !output.confirm("removing — cannot be undone")) return;134+    const all = ctx.all or std.mem.eql(u8, ctx.sub, "all");135+    _ = try exec.collectGarbage(ctx.io, &ctx.machine, ctx.gpa, all);136+    output.cleanDone("?");137+}138+139+pub fn statusCmd(ctx: Ctx) !void {140+    output.statusHeader();141+    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;142+    output.statusRow(ctx.machine.name, gen, "", "", true);143+}144+145+pub fn diff(ctx: Ctx) !void {146+    const gens = try exec.getGenerations(ctx.gpa, ctx.io, &ctx.machine);147+    defer ctx.gpa.free(gens);148+    var current: u32 = 0;149+    var prev: u32 = 0;150+    for (gens) |g| {151+        if (g.current) current = g.number;152+        if (!g.current and g.number > prev and g.number < current) prev = g.number;153+    }154+    if (ctx.args.len >= 2) {155+        const a = std.fmt.parseInt(u32, ctx.args[0], 10) catch current - 1;156+        const b = std.fmt.parseInt(u32, ctx.args[1], 10) catch current;157+        output.diffHeader(a, b, ctx.machine.name);158+        _ = try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, a, b);159+    } else {160+        output.diffHeader(if (prev > 0) prev else current - 1, current, ctx.machine.name);161+        _ = try exec.diffClosures(ctx.io, &ctx.machine, ctx.gpa, if (prev > 0) prev else current - 1, current);162+    }163+}164+165+pub fn doctor(ctx: Ctx) !void {166+    output.doctorHeader(ctx.machine.name);167+    const checks = try exec.runDoctorChecks(ctx.gpa, ctx.io, &ctx.machine);168+    defer ctx.gpa.free(checks);169+    var warnings: u32 = 0;170+    var failures: u32 = 0;171+    for (checks) |ch| {172+        output.doctorRow(ch);173+        if (ch.status == .warn) warnings += 1;174+        if (ch.status == .fail) failures += 1;175+    }176+    output.doctorSummary(warnings, failures);177+}178+179+pub fn update(ctx: Ctx) !void {180+    output.updateStart(ctx.machine.name);181+    _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);182+    output.updateDone();183+}184+185+pub fn upgrade(ctx: Ctx) !void {186+    output.upgradeStart(ctx.machine.name);187+    output.printSubstep("updating channels", .{});188+    _ = exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa) catch {};189+    output.printSubstep("rebuilding", .{});190+    const start = std.Io.Clock.awake.now(ctx.io);191+    const code = try exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa);192+    if (code != 0) {193+        output.applyFailed("");194+        return error.BuildFailed;195+    }196+    const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());197+    const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));198+    const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;199+    output.applyDone(gen, elapsed);200+}201+202+pub fn log(ctx: Ctx) !void {203+    output.logHeader(ctx.machine.name);204+    const content = exec.readNinaLog(ctx.gpa, ctx.io, ctx.environ, ctx.last) catch {205+        // No log yet — that's fine206+        return;207+    };208+    defer ctx.gpa.free(content);209+    var lines = std.mem.splitScalar(u8, content, '\n');210+    while (lines.next()) |line| {211+        if (line.len == 0) continue;212+        var parts = std.mem.tokenizeScalar(u8, line, '\t');213+        const date = parts.next() orelse continue;214+        const time = parts.next() orelse "";215+        const action = parts.next() orelse "";216+        const detail = parts.next() orelse "";217+        output.logRow(date, time, action, detail);218+    }219+}220+221+pub fn edit(ctx: Ctx) !void {222+    const hardware = ctx.args.len > 0 and std.mem.eql(u8, ctx.args[0], "hardware");223+    const filename = if (hardware) "hardware-configuration.nix" else "configuration.nix";224+    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/{s}", .{ ctx.machine.config_path, filename });225+    defer ctx.gpa.free(path);226+    output.editOpen(path, ctx.cfg.editor);227+    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, path, null);228+}229+230+pub fn fmt(ctx: Ctx) !void {231+    const path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});232+    defer ctx.gpa.free(path);233+    if (ctx.check) {234+        const code = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, true);235+        if (code != 0) output.fmtNeedsFormat() else output.fmtDone();236+        return;237+    }238+    output.fmtStart(path);239+    _ = try exec.nixpkgsFmt(ctx.io, &ctx.machine, ctx.gpa, path, false);240+    output.fmtDone();241+}242+243+pub fn repl(ctx: Ctx) !void {244+    output.replStart();245+    _ = try exec.nixRepl(ctx.io, &ctx.machine, ctx.gpa);246+}247+248+pub fn develop(ctx: Ctx) !void {249+    if (std.mem.eql(u8, ctx.sub, "show")) {250+        _ = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, null);251+        return;252+    }253+    const run_cmd: ?[]const u8 = if (ctx.args.len > 0 and std.mem.eql(u8, ctx.sub, "run"))254+        ctx.args[0]255+    else256+        null;257+    output.developStart(&.{});258+    _ = try exec.nixDevelop(ctx.io, &ctx.machine, ctx.gpa, run_cmd);259+    output.developDone();260+}261+262+pub fn info(ctx: Ctx) !void {263+    output.infoHeader(ctx.machine.name);264+    const nixos_ver = exec.systemInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");265+    defer ctx.gpa.free(nixos_ver);266+    output.infoRow("nixos", std.mem.trim(u8, nixos_ver, " \t\n\r"));267+268+    const kernel = exec.kernelInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");269+    defer ctx.gpa.free(kernel);270+    output.infoRow("kernel", std.mem.trim(u8, kernel, " \t\n\r"));271+272+    const uptime = exec.uptimeInfo(ctx.gpa, ctx.io, &ctx.machine) catch try ctx.gpa.dupe(u8, "unknown");273+    defer ctx.gpa.free(uptime);274+    output.infoRow("uptime", std.mem.trim(u8, uptime, " \t\n\r"));275+}276+277+pub fn boot(ctx: Ctx) !void {278+    output.bootHeader(ctx.machine.name);279+    const entries = exec.bootEntries(ctx.gpa, ctx.io, &ctx.machine) catch &.{};280+    defer ctx.gpa.free(entries);281+    for (entries, 0..) |entry, i| output.bootEntry(entry, i);282+}283+284+pub fn searchCmd(ctx: Ctx) !void {285+    const query = if (ctx.args.len > 0) ctx.args[0] else "";286+    const result = try search_tui.run(ctx.gpa, ctx.io, query, .packages);287+    if (result) |r| {288+        installFromResult(ctx, r) catch |e| return e;289+    }290+}291+292+pub fn optionCmd(ctx: Ctx) !void {293+    const query = if (ctx.args.len > 0) ctx.args[0] else "";294+    _ = try search_tui.run(ctx.gpa, ctx.io, query, .options);295+}296+297+pub fn install(ctx: Ctx) !void {298+    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;299+300+    // Search for the package to get the attr301+    const pkgs = api.searchPackages(ctx.gpa, ctx.io, pkg_name) catch {302+        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});303+        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});304+        return error.PackageNotFound;305+    };306+    defer ctx.gpa.free(pkgs);307+308+    if (pkgs.len == 0) {309+        errors.error_info.setMessage("'{s}' not found in nixpkgs", .{pkg_name});310+        errors.error_info.setSuggestion("nina search {s}", .{pkg_name});311+        return error.PackageNotFound;312+    }313+314+    const found = pkgs[0];315+    output.installSelectPrompt(found.pname, found.version);316+317+    var choice_buf: [8]u8 = undefined;318+    var choice_len: usize = 0;319+    while (choice_len < choice_buf.len) {320+        const n = std.posix.read(std.posix.STDIN_FILENO, choice_buf[choice_len .. choice_len + 1]) catch break;321+        if (n == 0) break;322+        if (choice_buf[choice_len] == '\n') break;323+        choice_len += 1;324+    }325+    const choice = std.mem.trim(u8, choice_buf[0..choice_len], " \t");326+327+    if (choice.len == 0 or choice[0] == 'i' or choice[0] == 'I') {328+        try profileInstallPkg(ctx, found);329+    } else if (choice[0] == 's' or choice[0] == 'S') {330+        try systemInstallPkg(ctx, found);331+    } else if (choice[0] == 't' or choice[0] == 'T') {332+        try tryPkg(ctx, found);333+    }334+}335+336+fn installFromResult(ctx: Ctx, result: types.SearchResult) !void {337+    switch (result.action) {338+        .profile => try profileInstallPkg(ctx, result.package),339+        .system => try systemInstallPkg(ctx, result.package),340+    }341+}342+343+fn profileInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {344+    output.installStart(npkg.pname, .profile);345+    output.printSubstep("nix profile install nixpkgs#{s}", .{npkg.attr});346+    output.printTeach(try std.fmt.allocPrint(ctx.gpa, "nix profile install nixpkgs#{s}", .{npkg.attr}));347+    const code = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);348+    if (code != 0) return error.ProcessFailed;349+    output.installDone(npkg.pname);350+}351+352+fn systemInstallPkg(ctx: Ctx, npkg: types.NixPackage) !void {353+    const config_path = try std.fmt.allocPrint(ctx.gpa, "{s}/configuration.nix", .{ctx.machine.config_path});354+    defer ctx.gpa.free(config_path);355+    const line = api.findSystemPackagesLine(ctx.io, config_path) orelse 1;356+    output.installEditorPrompt(npkg.pname, config_path, line, ctx.cfg.editor);357+    _ = try exec.openEditor(ctx.io, ctx.gpa, ctx.cfg.editor, config_path, line);358+    if (ctx.cfg.confirm) {359+        if (output.confirm("apply now? ")) {360+            try apply(ctx);361+        }362+    }363+}364+365+fn tryPkg(ctx: Ctx, npkg: types.NixPackage) !void {366+    output.tryStart(npkg.pname);367+    output.printSubstep("nix shell nixpkgs#{s}", .{npkg.attr});368+    _ = try exec.nixShell(ctx.io, &ctx.machine, ctx.gpa, npkg.attr);369+    output.tryDone();370+}371+372+pub fn remove(ctx: Ctx) !void {373+    const pkg_name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;374+    output.installStart(pkg_name, .profile); // reuse installStart for remove UI375+    const code = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, pkg_name);376+    if (code != 0) return error.ProcessFailed;377+    output.done();378+}379+380+pub fn list(ctx: Ctx) !void {381+    output.listHeader();382+    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;383+    if (profile_out) |out| {384+        defer ctx.gpa.free(out);385+        output.listSection("profile");386+        var lines = std.mem.splitScalar(u8, out, '\n');387+        while (lines.next()) |line| {388+            const t = std.mem.trim(u8, line, " \t\r");389+            if (t.len == 0) continue;390+            output.listPkg(t, "");391+        }392+    }393+}394+395+pub fn tryCmd(ctx: Ctx) !void {396+    const pkg_name_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;397+    const shell_pkg = types.NixPackage{ .attr = pkg_name_arg, .pname = pkg_name_arg, .version = "", .description = "" };398+    try tryPkg(ctx, shell_pkg);399+}400+401+// --- Service commands ---402+403+pub fn service(ctx: Ctx) !void {404+    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {405+        output.serviceListHeader(ctx.machine.name);406+        const svcs = exec.serviceList(ctx.gpa, ctx.io, &ctx.machine) catch &.{};407+        defer ctx.gpa.free(svcs);408+        for (svcs) |s| output.serviceRow(s);409+        return;410+    }411+    if (std.mem.eql(u8, ctx.sub, "logs")) {412+        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;413+        const follow = ctx.args.len > 1 and std.mem.eql(u8, ctx.args[1], "-f");414+        output.serviceLogsHeader(name, ctx.last, ctx.machine.name);415+        _ = try exec.serviceLogs(ctx.io, &ctx.machine, ctx.gpa, name, ctx.last, follow);416+        return;417+    }418+    if (std.mem.eql(u8, ctx.sub, "status")) {419+        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;420+        output.serviceVerb("status", name, ctx.machine.name);421+        _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, "status", name);422+        return;423+    }424+    // start/stop/restart/enable/disable425+    const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;426+    if (ctx.cfg.confirm and (std.mem.eql(u8, ctx.sub, "stop") or std.mem.eql(u8, ctx.sub, "disable"))) {427+        if (!output.confirm("")) return;428+    }429+    output.serviceVerb(ctx.sub, name, ctx.machine.name);430+    _ = try exec.serviceCtl(ctx.io, &ctx.machine, ctx.gpa, ctx.sub, name);431+    output.serviceDone();432+}433+434+// --- Flake commands ---435+436+pub fn flake(ctx: Ctx) !void {437+    if (std.mem.eql(u8, ctx.sub, "update")) {438+        output.flakeUpdateStart();439+        const input: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;440+        _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);441+        output.flakeLockWritten();442+        return;443+    }444+    if (std.mem.eql(u8, ctx.sub, "check")) {445+        output.flakeCheckStart();446+        const code = try exec.flakeCheck(ctx.io, &ctx.machine, ctx.gpa);447+        if (code == 0) output.flakeValid() else output.checkFailed("");448+        return;449+    }450+    if (std.mem.eql(u8, ctx.sub, "show")) {451+        const json_out = exec.flakeShow(ctx.gpa, ctx.io, &ctx.machine) catch null;452+        if (json_out) |j| {453+            defer ctx.gpa.free(j);454+            output.flakeShowHeader(".");455+            // Parse and display the flake outputs456+            const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, j, .{}) catch return;457+            defer parsed.deinit();458+            if (parsed.value == .object) {459+                var it = parsed.value.object.iterator();460+                while (it.next()) |entry| {461+                    if (entry.value_ptr.* == .object) {462+                        var inner = entry.value_ptr.object.iterator();463+                        while (inner.next()) |e2| {464+                            const desc = if (e2.value_ptr.* == .object)465+                                if (e2.value_ptr.object.get("description")) |d|466+                                    if (d == .string) d.string else ""467+                                else ""468+                            else "";469+                            output.flakeShowEntry(entry.key_ptr.*, e2.key_ptr.*, desc);470+                        }471+                    }472+                }473+            }474+        }475+        return;476+    }477+    if (std.mem.eql(u8, ctx.sub, "init")) {478+        _ = try exec.flakeInit(ctx.io, &ctx.machine, ctx.gpa);479+        return;480+    }481+    if (std.mem.eql(u8, ctx.sub, "lock")) {482+        _ = try exec.flakeLock(ctx.io, &ctx.machine, ctx.gpa);483+        return;484+    }485+    if (std.mem.eql(u8, ctx.sub, "clone")) {486+        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;487+        _ = try exec.flakeClone(ctx.io, &ctx.machine, ctx.gpa, url);488+        return;489+    }490+}491+492+// --- Store commands ---493+494+pub fn store(ctx: Ctx) !void {495+    if (std.mem.eql(u8, ctx.sub, "gc")) {496+        _ = try exec.storeGc(ctx.io, &ctx.machine, ctx.gpa);497+        return;498+    }499+    if (std.mem.eql(u8, ctx.sub, "verify")) {500+        output.storeVerifyStart();501+        _ = try exec.storeVerify(ctx.io, &ctx.machine, ctx.gpa);502+        return;503+    }504+    if (std.mem.eql(u8, ctx.sub, "repair")) {505+        if (ctx.cfg.confirm and !output.confirm("")) return;506+        output.storeRepairStart();507+        _ = try exec.storeRepair(ctx.io, &ctx.machine, ctx.gpa);508+        return;509+    }510+    if (std.mem.eql(u8, ctx.sub, "info")) {511+        const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);512+        output.storeInfo(si);513+        return;514+    }515+    if (std.mem.eql(u8, ctx.sub, "path")) {516+        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;517+        const path = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, attr);518+        defer ctx.gpa.free(path);519+        output.storePath(path);520+        return;521+    }522+    // default: info523+    const si = try exec.storeInfo(ctx.gpa, ctx.io, &ctx.machine);524+    output.storeInfo(si);525+}526+527+// --- Channel commands ---528+529+pub fn channel(ctx: Ctx) !void {530+    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {531+        output.channelListHeader(ctx.machine.name);532+        const chans = try exec.channelList(ctx.gpa, ctx.io, &ctx.machine);533+        defer ctx.gpa.free(chans);534+        for (chans) |ch| output.channelRow(ch);535+        return;536+    }537+    if (std.mem.eql(u8, ctx.sub, "add")) {538+        const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;539+        const name = if (ctx.args.len > 1) ctx.args[1] else "";540+        _ = try exec.channelAdd(ctx.io, &ctx.machine, ctx.gpa, url, name);541+        output.channelDone();542+        return;543+    }544+    if (std.mem.eql(u8, ctx.sub, "remove")) {545+        const name = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;546+        _ = try exec.channelRemove(ctx.io, &ctx.machine, ctx.gpa, name);547+        output.channelDone();548+        return;549+    }550+    if (std.mem.eql(u8, ctx.sub, "update")) {551+        return update(ctx);552+    }553+}554+555+// --- Profile commands ---556+557+pub fn profile(ctx: Ctx) !void {558+    if (std.mem.eql(u8, ctx.sub, "list") or ctx.sub.len == 0) {559+        output.profileListHeader();560+        const out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;561+        if (out) |o| {562+            defer ctx.gpa.free(o);563+            var lines = std.mem.splitScalar(u8, o, '\n');564+            while (lines.next()) |line| {565+                const t = std.mem.trim(u8, line, " \t\r");566+                if (t.len == 0) continue;567+                output.profileRow(t, "", "");568+            }569+        }570+        return;571+    }572+    if (std.mem.eql(u8, ctx.sub, "install")) {573+        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;574+        _ = try exec.profileInstall(ctx.io, &ctx.machine, ctx.gpa, attr);575+        output.done();576+        return;577+    }578+    if (std.mem.eql(u8, ctx.sub, "remove")) {579+        const attr = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;580+        _ = try exec.profileRemove(ctx.io, &ctx.machine, ctx.gpa, attr);581+        output.done();582+        return;583+    }584+    if (std.mem.eql(u8, ctx.sub, "upgrade")) {585+        _ = try exec.profileUpgrade(ctx.io, &ctx.machine, ctx.gpa);586+        output.done();587+        return;588+    }589+}590+591+// --- Pkg commands ---592+593+pub fn pkg(ctx: Ctx) !void {594+    const name = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;595+    if (std.mem.eql(u8, ctx.sub, "why")) {596+        _ = try exec.pkgWhy(ctx.io, &ctx.machine, ctx.gpa, name);597+        return;598+    }599+    if (std.mem.eql(u8, ctx.sub, "deps")) {600+        _ = try exec.pkgDeps(ctx.io, &ctx.machine, ctx.gpa, name);601+        return;602+    }603+    if (std.mem.eql(u8, ctx.sub, "size")) {604+        const s = exec.pkgSize(ctx.gpa, ctx.io, &ctx.machine, name) catch null;605+        if (s) |sz| {606+            defer ctx.gpa.free(sz);607+            output.pkgResult("size", sz);608+        }609+        return;610+    }611+    if (std.mem.eql(u8, ctx.sub, "path")) {612+        const p = try exec.storePath(ctx.gpa, ctx.io, &ctx.machine, name);613+        defer ctx.gpa.free(p);614+        output.pkgResult("path", p);615+        return;616+    }617+    if (std.mem.eql(u8, ctx.sub, "closure")) {618+        _ = try exec.pkgClosure(ctx.io, &ctx.machine, ctx.gpa, name);619+        return;620+    }621+}622+623+// --- Pin / Unpin ---624+625+pub fn pin(ctx: Ctx) !void {626+    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;627+    const commit = if (ctx.args.len > 1) ctx.args[1] else return error.Internal;628+    exec.editFlakeLock(ctx.gpa, input, commit) catch return error.ProcessFailed;629+    output.pinDone(input, commit);630+}631+632+pub fn unpin(ctx: Ctx) !void {633+    const input = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;634+    _ = try exec.flakeUpdate(ctx.io, &ctx.machine, ctx.gpa, input);635+    output.unpinDone(input);636+}637+638+// --- Hash / Fetch ---639+640+pub fn hash(ctx: Ctx) !void {641+    const path = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;642+    const h = try exec.nixHash(ctx.gpa, ctx.io, &ctx.machine, path);643+    defer ctx.gpa.free(h);644+    output.hashResult(std.mem.trim(u8, h, " \t\n\r"));645+}646+647+pub fn fetch(ctx: Ctx) !void {648+    const url = if (ctx.args.len > 0) ctx.args[0] else return error.Internal;649+    const h = try exec.nixFetch(ctx.gpa, ctx.io, &ctx.machine, url);650+    defer ctx.gpa.free(h);651+    output.fetchResult(std.mem.trim(u8, h, " \t\n\r"), url);652+}653+654+// --- Build / Run ---655+656+pub fn build(ctx: Ctx) !void {657+    const attr: ?[]const u8 = if (ctx.args.len > 0) ctx.args[0] else null;658+    output.buildStart(attr orelse "default");659+    const code = try exec.nixBuild(ctx.io, &ctx.machine, ctx.gpa, attr);660+    if (code != 0) return error.BuildFailed;661+    output.buildDone();662+}663+664+pub fn runCmd(ctx: Ctx) !void {665+    const pkg_arg = if (ctx.args.len > 0) ctx.args[0] else return error.PackageNotFound;666+    output.runStart(pkg_arg);667+    _ = try exec.nixRun(ctx.io, &ctx.machine, ctx.gpa, pkg_arg);668+}669+670+// --- Hello ---671+672+pub fn hello(ctx: Ctx) !void {673+    output.hello(ctx.cfg.machines);674+}675+676+// --- Log helper ---677+678+fn logAction(ctx: Ctx, action: []const u8, gen: u32) void {679+    var buf: [128]u8 = undefined;680+    const entry = std.fmt.bufPrint(&buf, "{s}\t{s}\t{s}\tgen {d}\n", .{681+        "2026", "00:00", action, gen,682+    }) catch return;683+    exec.appendNinaLog(ctx.io, ctx.environ, entry);684+}

view file

src/config.zig+147 −0
@@ -0,0 +1,147 @@1+const std = @import("std");2+const types = @import("types.zig");3+const errors = @import("errors.zig");4+5+pub fn load(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !types.NinaConfig {6+    const home = environ.get("HOME") orelse {7+        errors.error_info.setSuggestion("set HOME environment variable", .{});8+        return error.ConfigNotFound;9+    };10+11+    const path = try std.fmt.allocPrint(arena, "{s}/.nina.conf", .{home});12+13+    const content = std.Io.Dir.readFileAlloc(.cwd(), io, path, arena, .unlimited) catch {14+        errors.error_info.setSuggestion("run nina to set up", .{});15+        return error.ConfigNotFound;16+    };17+    return parse(arena, content, home);18+}19+20+fn parse(arena: std.mem.Allocator, input: []const u8, home: []const u8) !types.NinaConfig {21+    var config = types.NinaConfig{};22+    var machines: std.ArrayList(types.Machine) = .empty;23+    var current: ?types.Machine = null;24+25+    var lines = std.mem.splitScalar(u8, input, '\n');26+    while (lines.next()) |line| {27+        const trimmed = std.mem.trim(u8, line, " \t\r");28+        if (trimmed.len == 0 or trimmed[0] == '#') continue;29+30+        if (std.mem.eql(u8, trimmed, "[machine]")) {31+            if (current) |m| try machines.append(arena, m);32+            current = .{};33+            continue;34+        }35+36+        const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue;37+        const key = std.mem.trim(u8, trimmed[0..eq], " \t");38+        const val = std.mem.trim(u8, trimmed[eq + 1 ..], " \t");39+40+        if (current) |*m| {41+            setMachineField(arena, m, key, val, home) catch {};42+        } else {43+            setConfigField(arena, &config, key, val) catch {};44+        }45+    }46+47+    if (current) |m| try machines.append(arena, m);48+    config.machines = try machines.toOwnedSlice(arena);49+    return config;50+}51+52+fn setConfigField(arena: std.mem.Allocator, config: *types.NinaConfig, key: []const u8, val: []const u8) !void {53+    if (std.mem.eql(u8, key, "editor")) {54+        config.editor = try arena.dupe(u8, val);55+    } else if (std.mem.eql(u8, key, "generations")) {56+        config.generations = std.fmt.parseInt(u32, val, 10) catch 5;57+    } else if (std.mem.eql(u8, key, "confirm")) {58+        config.confirm = std.mem.eql(u8, val, "true");59+    } else if (std.mem.eql(u8, key, "teach")) {60+        config.teach = std.mem.eql(u8, val, "true");61+    } else if (std.mem.eql(u8, key, "color")) {62+        config.color = std.mem.eql(u8, val, "true");63+    }64+}65+66+fn setMachineField(arena: std.mem.Allocator, m: *types.Machine, key: []const u8, val: []const u8, home: []const u8) !void {67+    if (std.mem.eql(u8, key, "name")) {68+        m.name = try arena.dupe(u8, val);69+    } else if (std.mem.eql(u8, key, "config")) {70+        m.config_path = try arena.dupe(u8, val);71+    } else if (std.mem.eql(u8, key, "local")) {72+        m.local = std.mem.eql(u8, val, "true");73+    } else if (std.mem.eql(u8, key, "default")) {74+        m.default = std.mem.eql(u8, val, "true");75+    } else if (std.mem.eql(u8, key, "host")) {76+        m.host = try arena.dupe(u8, val);77+    } else if (std.mem.eql(u8, key, "user")) {78+        m.user = try arena.dupe(u8, val);79+    } else if (std.mem.eql(u8, key, "ssh_key")) {80+        m.ssh_key = try expandHome(arena, val, home);81+    }82+}83+84+fn expandHome(arena: std.mem.Allocator, path: []const u8, home: []const u8) ![]const u8 {85+    if (!std.mem.startsWith(u8, path, "~")) return arena.dupe(u8, path);86+    return std.fmt.allocPrint(arena, "{s}{s}", .{ home, path[1..] });87+}88+89+pub fn resolveMachine(config: *const types.NinaConfig, name: ?[]const u8) !types.Machine {90+    if (name) |n| {91+        for (config.machines) |m| {92+            if (std.mem.eql(u8, m.name, n)) return m;93+        }94+        errors.error_info.setMessage("'{s}' is not in your config", .{n});95+        errors.error_info.setSuggestion("check ~/.nina.conf", .{});96+        return error.MachineNotFound;97+    }98+    for (config.machines) |m| {99+        if (m.default) return m;100+    }101+    if (config.machines.len > 0) return config.machines[0];102+    // No machines defined — return a default local machine103+    return .{};104+}105+106+test "parse basic config" {107+    const input =108+        \\editor = hx109+        \\generations = 3110+        \\confirm = false111+        \\teach = true112+        \\113+        \\[machine]114+        \\name = test-box115+        \\local = true116+        \\default = true117+    ;118+    const config = try parse(std.testing.allocator, input, "/home/test");119+    defer std.testing.allocator.free(config.machines);120+121+    try std.testing.expectEqualStrings("hx", config.editor);122+    try std.testing.expectEqual(@as(u32, 3), config.generations);123+    try std.testing.expect(!config.confirm);124+    try std.testing.expect(config.teach);125+    try std.testing.expectEqual(@as(usize, 1), config.machines.len);126+    try std.testing.expectEqualStrings("test-box", config.machines[0].name);127+}128+129+test "resolve machine by name" {130+    var machines = [_]types.Machine{131+        .{ .name = "alpha", .default = true },132+        .{ .name = "beta" },133+    };134+    const config = types.NinaConfig{ .machines = &machines };135+    const m = try resolveMachine(&config, "beta");136+    try std.testing.expectEqualStrings("beta", m.name);137+}138+139+test "resolve default machine" {140+    var machines = [_]types.Machine{141+        .{ .name = "alpha", .default = true },142+        .{ .name = "beta" },143+    };144+    const config = types.NinaConfig{ .machines = &machines };145+    const m = try resolveMachine(&config, null);146+    try std.testing.expectEqualStrings("alpha", m.name);147+}

view file

src/errors.zig+117 −0
@@ -0,0 +1,117 @@1+const std = @import("std");2+3+pub const NinaError = error{4+    PackageNotFound,5+    MachineNotFound,6+    GenerationNotFound,7+    ConfigNotFound,8+    ConfigParseError,9+    BuildFailed,10+    SshFailed,11+    SearchFailed,12+    NetworkError,13+    OutOfMemory,14+    ProcessFailed,15+    Internal,16+    // common IO errors that bubble up17+    FileNotFound,18+    AccessDenied,19+    SystemResources,20+    Unexpected,21+};22+23+pub var error_info: ErrorInfo = .{};24+25+pub const ErrorInfo = struct {26+    message: [512]u8 = undefined,27+    message_len: usize = 0,28+    detail: [512]u8 = undefined,29+    detail_len: usize = 0,30+    suggestion: [256]u8 = undefined,31+    suggestion_len: usize = 0,32+33+    pub fn setMessage(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {34+        const r = std.fmt.bufPrint(&self.message, fmt, args) catch return;35+        self.message_len = r.len;36+    }37+38+    pub fn setDetail(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {39+        const r = std.fmt.bufPrint(&self.detail, fmt, args) catch return;40+        self.detail_len = r.len;41+    }42+43+    pub fn setSuggestion(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {44+        const r = std.fmt.bufPrint(&self.suggestion, fmt, args) catch return;45+        self.suggestion_len = r.len;46+    }47+48+    pub fn getMessage(self: *const ErrorInfo) ?[]const u8 {49+        if (self.message_len == 0) return null;50+        return self.message[0..self.message_len];51+    }52+53+    pub fn getDetail(self: *const ErrorInfo) ?[]const u8 {54+        if (self.detail_len == 0) return null;55+        return self.detail[0..self.detail_len];56+    }57+58+    pub fn getSuggestion(self: *const ErrorInfo) ?[]const u8 {59+        if (self.suggestion_len == 0) return null;60+        return self.suggestion[0..self.suggestion_len];61+    }62+63+    pub fn reset(self: *ErrorInfo) void {64+        self.message_len = 0;65+        self.detail_len = 0;66+        self.suggestion_len = 0;67+    }68+};69+70+const BuildPattern = struct { pattern: []const u8, message: []const u8, suggestion: ?[]const u8 = null };71+const build_patterns = [_]BuildPattern{72+    .{ .pattern = "undefined variable", .message = "package not found in nixpkgs", .suggestion = "nina search <name>" },73+    .{ .pattern = "has been renamed to", .message = "package was renamed — update your config" },74+    .{ .pattern = "attribute '", .message = "package doesn't exist in this nixpkgs version" },75+    .{ .pattern = "collision between", .message = "two packages are claiming the same file — remove one" },76+    .{ .pattern = "infinite recursion", .message = "circular reference in configuration.nix — check recent edits" },77+    .{ .pattern = "No space left on device", .message = "disk is full", .suggestion = "nina clean" },78+    .{ .pattern = "hash mismatch", .message = "download checksum mismatch — try again" },79+    .{ .pattern = "cannot write lock file", .message = "can't write flake.lock", .suggestion = "use --no-write-lock-file" },80+    .{ .pattern = "SSL certificate", .message = "ssl error — check your network" },81+    .{ .pattern = "permission denied", .message = "permission denied — try with sudo" },82+};83+84+pub fn translateBuildError(stderr: []const u8) struct { message: []const u8, suggestion: ?[]const u8 } {85+    for (build_patterns) |p| {86+        if (std.mem.indexOf(u8, stderr, p.pattern) != null) {87+            return .{ .message = p.message, .suggestion = p.suggestion };88+        }89+    }90+    return .{ .message = stderr, .suggestion = null };91+}92+93+pub fn staticMessage(err: anyerror) []const u8 {94+    return switch (err) {95+        error.ConfigNotFound => "~/.nina.conf not found",96+        error.ConfigParseError => "could not parse ~/.nina.conf",97+        error.SearchFailed => "nix search failed",98+        error.NetworkError => "network unavailable",99+        error.OutOfMemory => "out of memory",100+        error.ProcessFailed => "process failed",101+        error.SshFailed => "ssh connection failed",102+        error.MachineNotFound => "machine not found in config",103+        error.PackageNotFound => "package not found",104+        error.GenerationNotFound => "generation not found",105+        error.BuildFailed => "build failed",106+        else => "internal error",107+    };108+}109+110+test "build error translation" {111+    const r = translateBuildError("error: undefined variable 'pkgs.notexist'");112+    try std.testing.expectEqualStrings("package not found in nixpkgs", r.message);113+}114+115+test "static message" {116+    try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));117+}

view file

src/exec.zig+635 −0
@@ -0,0 +1,635 @@1+// exec.zig — all process execution and SSH routing2+// Commands call these functions; output is streamed or captured here.3+const std = @import("std");4+const builtin = @import("builtin");5+const types = @import("types.zig");6+const errors = @import("errors.zig");7+const output = @import("output.zig");8+9+pub const RunResult = struct {10+    stdout: []u8,11+    stderr: []u8,12+    exit_code: u8,13+};14+15+// Run a process and capture stdout+stderr (not streaming).16+pub 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);19+20+    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+    };24+25+    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+}34+35+// Run a process and stream its output to the terminal (inherit stdio).36+pub 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);39+40+    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+    };49+50+    const term = child.wait(io) catch |e| {51+        errors.error_info.setMessage("process wait failed", .{});52+        return e;53+    };54+55+    return switch (term) {56+        .exited => |c| c,57+        else => 1,58+    };59+}60+61+// Run a process and capture output as a single string (no SSH routing — for local nix commands).62+pub 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+}77+78+fn buildArgv(gpa: std.mem.Allocator, machine: *const types.Machine, cmd: []const []const u8) ![]const []const u8 {79+    if (machine.local) return cmd;80+81+    const host = machine.host orelse {82+        errors.error_info.setMessage("machine '{s}' has no host configured", .{machine.name});83+        return error.SshFailed;84+    };85+86+    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+}104+105+// --- NixOS rebuild ---106+107+pub 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+}112+113+pub 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+}116+117+pub 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+}120+121+// --- Generations ---122+123+pub 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+}130+131+fn 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+}153+154+pub 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+}162+163+pub 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+}168+169+pub 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+}172+173+pub 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+}178+179+pub 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+}182+183+// --- Garbage collection ---184+185+pub 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+}193+194+// --- Channels ---195+196+pub 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+}203+204+fn 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+}218+219+pub 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+}222+223+pub 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+}226+227+pub 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+}230+231+// --- Services ---232+233+pub 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+}240+241+fn 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";252+253+        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;259+260+        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+}268+269+pub 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+}272+273+pub 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+}281+282+// --- Profile ---283+284+pub 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+}289+290+pub 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+}295+296+pub 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+}302+303+pub fn profileUpgrade(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {304+    return stream(io, machine, gpa, &.{ "nix", "profile", "upgrade", ".*" });305+}306+307+// --- Store ---308+309+pub 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+}312+313+pub 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+}316+317+pub 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+}320+321+pub 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);325+326+    var live: u64 = 0;327+    if (pi.exit_code == 0) {328+        live = countJsonPaths(pi.stdout);329+    }330+331+    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);340+341+    return .{342+        .total_size = total_owned,343+        .live_paths = live,344+        .reclaimable_paths = 0,345+    };346+}347+348+fn 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+}357+358+pub 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+}369+370+// --- Flake ---371+372+pub 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+}378+379+pub fn flakeCheck(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {380+    return stream(io, machine, gpa, &.{ "nix", "flake", "check" });381+}382+383+pub fn flakeInit(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {384+    return stream(io, machine, gpa, &.{ "nix", "flake", "init" });385+}386+387+pub fn flakeLock(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {388+    return stream(io, machine, gpa, &.{ "nix", "flake", "lock" });389+}390+391+pub 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+}394+395+pub 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+}404+405+// --- Build / Run / Develop ---406+407+pub 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+}415+416+pub 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+}426+427+pub 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+}432+433+pub 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+}439+440+pub 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+}443+444+// --- Format ---445+446+pub 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+}452+453+// --- Info ---454+455+pub 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+}464+465+pub 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+}474+475+pub 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+}484+485+// --- Boot ---486+487+pub 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+}494+495+fn 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+}511+512+// --- Pkg info ---513+514+pub 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+}517+518+pub 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+}521+522+pub 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+}531+532+pub 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+}535+536+// --- Hash / Fetch ---537+538+pub 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+}547+548+pub 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+}557+558+// --- Pin ---559+560+pub 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+}564+565+pub 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+}573+574+pub 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+}581+582+// --- Doctor checks ---583+584+pub fn runDoctorChecks(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.DoctorCheck {585+    var checks: std.ArrayList(types.DoctorCheck) = .empty;586+587+    // 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+    }595+596+    // 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+    }603+604+    // 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+    }612+613+    return checks.toOwnedSlice(gpa);614+}615+616+// --- Log ---617+618+pub 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+}625+626+pub 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+}

view file

src/main.zig+222 −0
@@ -0,0 +1,222 @@1+// main.zig — entry point, arg parsing, dispatch, error handler2+// THE ONLY FILE that creates the Io.Writer and passes it to output.init().3+const std = @import("std");4+const Io = std.Io;5+6+const types = @import("types.zig");7+const errors = @import("errors.zig");8+const config = @import("config.zig");9+const commands = @import("commands.zig");10+const output = @import("output.zig");11+12+const Args = struct {13+    command: []const u8 = "",14+    sub: []const u8 = "",15+    rest: []const []const u8 = &.{},16+    machine: ?[]const u8 = null,17+    dry: bool = false,18+    check: bool = false,19+    no_apply: bool = false,20+    all: bool = false,21+    json: bool = false,22+    last: u32 = 50,23+};24+25+pub fn main(startup: std.process.Init) !void {26+    const arena = startup.arena.allocator();27+    const gpa = startup.gpa;28+    const io = startup.io;29+30+    // Initialize output module with buffered stdout writer31+    var stdout_buf: [16384]u8 = undefined;32+    var stdout_fw: Io.File.Writer = .init(.stdout(), io, &stdout_buf);33+    output.init(&stdout_fw.interface, startup.environ_map, true);34+    defer output.flush();35+36+    const argv = try startup.minimal.args.toSlice(arena);37+    const args = try parseArgs(arena, argv);38+39+    // Help and hello work without config40+    if (args.command.len == 0 or std.mem.eql(u8, args.command, "help")) {41+        output.help();42+        return;43+    }44+45+    // Load config and resolve machine46+    const cfg = config.load(arena, io, startup.environ_map) catch |err| {47+        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);48+        const detail = errors.error_info.getDetail();49+        const suggestion = errors.error_info.getSuggestion();50+        output.printError(msg, detail, suggestion);51+        output.flush();52+        std.process.exit(1);53+    };54+55+    output.setTeach(cfg.teach);56+57+    const machine = config.resolveMachine(&cfg, args.machine) catch |err| {58+        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);59+        const detail = errors.error_info.getDetail();60+        const suggestion = errors.error_info.getSuggestion();61+        output.printError(msg, detail, suggestion);62+        output.flush();63+        std.process.exit(1);64+    };65+66+    const ctx = commands.Ctx{67+        .gpa = gpa,68+        .io = io,69+        .environ = startup.environ_map,70+        .machine = machine,71+        .cfg = cfg,72+        .args = args.rest,73+        .sub = args.sub,74+        .dry = args.dry,75+        .check = args.check,76+        .no_apply = args.no_apply,77+        .all = args.all,78+        .last = args.last,79+        .json = args.json,80+    };81+82+    dispatch(ctx, args.command) catch |err| {83+        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);84+        const detail = errors.error_info.getDetail();85+        const suggestion = errors.error_info.getSuggestion();86+        output.printError(msg, detail, suggestion);87+        output.flush();88+        std.process.exit(1);89+    };90+}91+92+fn dispatch(ctx: commands.Ctx, cmd: []const u8) !void {93+    if (std.mem.eql(u8, cmd, "apply")) return commands.apply(ctx);94+    if (std.mem.eql(u8, cmd, "check")) return commands.check(ctx);95+    if (std.mem.eql(u8, cmd, "back")) return commands.back(ctx);96+    if (std.mem.eql(u8, cmd, "go")) return commands.go(ctx);97+    if (std.mem.eql(u8, cmd, "history")) return commands.history(ctx);98+    if (std.mem.eql(u8, cmd, "gen")) return commands.genCmd(ctx);99+    if (std.mem.eql(u8, cmd, "clean")) return commands.clean(ctx);100+    if (std.mem.eql(u8, cmd, "status")) return commands.statusCmd(ctx);101+    if (std.mem.eql(u8, cmd, "diff")) return commands.diff(ctx);102+    if (std.mem.eql(u8, cmd, "doctor")) return commands.doctor(ctx);103+    if (std.mem.eql(u8, cmd, "update")) return commands.update(ctx);104+    if (std.mem.eql(u8, cmd, "upgrade")) return commands.upgrade(ctx);105+    if (std.mem.eql(u8, cmd, "log")) return commands.log(ctx);106+    if (std.mem.eql(u8, cmd, "edit")) return commands.edit(ctx);107+    if (std.mem.eql(u8, cmd, "fmt")) return commands.fmt(ctx);108+    if (std.mem.eql(u8, cmd, "repl")) return commands.repl(ctx);109+    if (std.mem.eql(u8, cmd, "develop")) return commands.develop(ctx);110+    if (std.mem.eql(u8, cmd, "info")) return commands.info(ctx);111+    if (std.mem.eql(u8, cmd, "boot")) return commands.boot(ctx);112+    if (std.mem.eql(u8, cmd, "search")) return commands.searchCmd(ctx);113+    if (std.mem.eql(u8, cmd, "option")) return commands.optionCmd(ctx);114+    if (std.mem.eql(u8, cmd, "install")) return commands.install(ctx);115+    if (std.mem.eql(u8, cmd, "remove")) return commands.remove(ctx);116+    if (std.mem.eql(u8, cmd, "list")) return commands.list(ctx);117+    if (std.mem.eql(u8, cmd, "try")) return commands.tryCmd(ctx);118+    if (std.mem.eql(u8, cmd, "service")) return commands.service(ctx);119+    if (std.mem.eql(u8, cmd, "flake")) return commands.flake(ctx);120+    if (std.mem.eql(u8, cmd, "store")) return commands.store(ctx);121+    if (std.mem.eql(u8, cmd, "channel")) return commands.channel(ctx);122+    if (std.mem.eql(u8, cmd, "profile")) return commands.profile(ctx);123+    if (std.mem.eql(u8, cmd, "pkg")) return commands.pkg(ctx);124+    if (std.mem.eql(u8, cmd, "pin")) return commands.pin(ctx);125+    if (std.mem.eql(u8, cmd, "unpin")) return commands.unpin(ctx);126+    if (std.mem.eql(u8, cmd, "hash")) return commands.hash(ctx);127+    if (std.mem.eql(u8, cmd, "fetch")) return commands.fetch(ctx);128+    if (std.mem.eql(u8, cmd, "build")) return commands.build(ctx);129+    if (std.mem.eql(u8, cmd, "run")) return commands.runCmd(ctx);130+    if (std.mem.eql(u8, cmd, "hello")) return commands.hello(ctx);131+    if (std.mem.eql(u8, cmd, "help")) { output.help(); return; }132+133+    errors.error_info.setMessage("unknown command: {s}", .{cmd});134+    errors.error_info.setSuggestion("nina help", .{});135+    return error.Internal;136+}137+138+fn parseArgs(arena: std.mem.Allocator, argv: []const [:0]const u8) !Args {139+    var result = Args{};140+    var rest: std.ArrayList([]const u8) = .empty;141+    var i: usize = 1; // skip program name142+143+    // First positional = command144+    if (i < argv.len and argv[i][0] != '-') {145+        result.command = argv[i];146+        i += 1;147+    }148+149+    // Second positional (if not a flag) = subcommand for multi-word commands150+    if (i < argv.len and argv[i][0] != '-') {151+        const multi_word = isMultiWord(result.command);152+        if (multi_word) {153+            result.sub = argv[i];154+            i += 1;155+        }156+    }157+158+    while (i < argv.len) : (i += 1) {159+        const a = argv[i];160+        if (std.mem.eql(u8, a, "--on") or std.mem.eql(u8, a, "-on")) {161+            i += 1;162+            if (i < argv.len) result.machine = argv[i];163+        } else if (std.mem.eql(u8, a, "--dry")) {164+            result.dry = true;165+        } else if (std.mem.eql(u8, a, "--check")) {166+            result.check = true;167+        } else if (std.mem.eql(u8, a, "--no-apply")) {168+            result.no_apply = true;169+        } else if (std.mem.eql(u8, a, "--all")) {170+            result.all = true;171+        } else if (std.mem.eql(u8, a, "--json")) {172+            result.json = true;173+        } else if (std.mem.eql(u8, a, "--last")) {174+            i += 1;175+            if (i < argv.len) result.last = std.fmt.parseInt(u32, argv[i], 10) catch 50;176+        } else if (std.mem.eql(u8, a, "-f")) {177+            // -f flag for service logs follow178+            try rest.append(arena, "-f");179+        } else if (a.len > 0 and a[0] != '-') {180+            try rest.append(arena, a);181+        }182+        // Ignore unknown flags silently183+    }184+185+    result.rest = try rest.toOwnedSlice(arena);186+    return result;187+}188+189+fn isMultiWord(cmd: []const u8) bool {190+    const multi = [_][]const u8{ "service", "flake", "store", "channel", "profile", "pkg", "gen", "develop", "edit" };191+    for (multi) |m| {192+        if (std.mem.eql(u8, cmd, m)) return true;193+    }194+    return false;195+}196+197+test "parse args basic" {198+    const gpa = std.testing.allocator;199+    const argv = [_][:0]const u8{ "nina", "apply", "--dry" };200+    const args = try parseArgs(gpa, &argv);201+    defer gpa.free(args.rest);202+    try std.testing.expectEqualStrings("apply", args.command);203+    try std.testing.expect(args.dry);204+}205+206+test "parse args with machine" {207+    const gpa = std.testing.allocator;208+    const argv = [_][:0]const u8{ "nina", "status", "--on", "build-server" };209+    const args = try parseArgs(gpa, &argv);210+    defer gpa.free(args.rest);211+    try std.testing.expectEqualStrings("status", args.command);212+    try std.testing.expectEqualStrings("build-server", args.machine.?);213+}214+215+test "parse subcommand" {216+    const gpa = std.testing.allocator;217+    const argv = [_][:0]const u8{ "nina", "service", "list" };218+    const args = try parseArgs(gpa, &argv);219+    defer gpa.free(args.rest);220+    try std.testing.expectEqualStrings("service", args.command);221+    try std.testing.expectEqualStrings("list", args.sub);222+}

view file

src/output.zig+651 −0
@@ -0,0 +1,651 @@1+// output.zig — the only file that writes to stdout2+// All ANSI codes live here. No raw escape sequences elsewhere.3+const std = @import("std");4+const types = @import("types.zig");5+const Io = std.Io;6+7+// Color constants — all ANSI codes live here only8+const PINK = "\x1b[38;2;255;183;197m";9+const BOLD = "\x1b[1m";10+const GREEN = "\x1b[32m";11+const YELLOW = "\x1b[33m";12+const RED = "\x1b[31m";13+const CYAN = "\x1b[36m";14+const DIM = "\x1b[2m";15+const RESET = "\x1b[0m";16+17+// Exported for TUI use (search.zig references these to avoid \x1b in that file)18+pub const C_PINK = PINK;19+pub const C_BOLD = BOLD;20+pub const C_GREEN = GREEN;21+pub const C_YELLOW = YELLOW;22+pub const C_RED = RED;23+pub const C_CYAN = CYAN;24+pub const C_DIM = DIM;25+pub const C_RESET = RESET;26+pub const C_CLEAR_EOL = "\x1b[K";27+pub const C_CURSOR_UP_FMT = "\x1b[{d}A";28+29+// Precomputed prefix strings30+const HDR_C = PINK ++ ":: " ++ RESET;31+const HDR_P = ":: ";32+const STEP_C = "   " ++ DIM ++ "-> " ++ RESET;33+const STEP_P = "   -> ";34+const TEACH_C = "   " ++ DIM ++ "= " ++ RESET;35+const TEACH_P = "   = ";36+const ERR_C = PINK ++ ":: " ++ RED ++ "error:" ++ RESET ++ " ";37+const ERR_P = ":: error: ";38+const WARN_C = PINK ++ ":: " ++ YELLOW ++ "warning:" ++ RESET ++ " ";39+const WARN_P = ":: warning: ";40+41+var _fw: Io.File.Writer = undefined;42+var _w: *Io.Writer = undefined;43+var color_enabled: bool = true;44+var teach_enabled: bool = false;45+46+pub fn init(writer: *Io.Writer, environ: *const std.process.Environ.Map, color_config: bool) void {47+    _w = writer;48+    color_enabled = color_config and49+        (std.c.isatty(std.posix.STDOUT_FILENO) != 0) and50+        !environ.contains("NO_COLOR");51+}52+53+pub fn setTeach(enabled: bool) void {54+    teach_enabled = enabled;55+}56+57+pub fn flush() void {58+    _w.flush() catch {};59+}60+61+fn c(code: []const u8) []const u8 {62+    return if (color_enabled) code else "";63+}64+65+fn hdr() []const u8 {66+    return if (color_enabled) HDR_C else HDR_P;67+}68+69+fn step() []const u8 {70+    return if (color_enabled) STEP_C else STEP_P;71+}72+73+fn teach_pfx() []const u8 {74+    return if (color_enabled) TEACH_C else TEACH_P;75+}76+77+// Raw write — used by TUI78+pub fn raw(bytes: []const u8) void {79+    _w.writeAll(bytes) catch {};80+}81+82+// Formatted print — used by TUI for in-place rendering83+pub fn p(comptime fmt: []const u8, args: anytype) void {84+    _w.print(fmt, args) catch {};85+}86+87+pub fn cursorUp(n: usize) void {88+    if (n > 0) p("\x1b[{d}A", .{n});89+}90+91+pub fn clearLine() void {92+    p("\r\x1b[K", .{});93+}94+95+// --- Error and warning output ---96+97+pub fn printError(message: []const u8, detail: ?[]const u8, suggestion: ?[]const u8) void {98+    p("{s}{s}\n", .{ if (color_enabled) ERR_C else ERR_P, message });99+    if (detail) |d| p("   {s}{s}{s}\n", .{ c(DIM), d, c(RESET) });100+    if (suggestion) |s| p("   {s}-> {s}{s}\n", .{ c(DIM), s, c(RESET) });101+}102+103+pub fn printWarning(message: []const u8) void {104+    p("{s}{s}\n", .{ if (color_enabled) WARN_C else WARN_P, message });105+}106+107+pub fn printTeach(cmd: []const u8) void {108+    if (teach_enabled) p("{s}{s}{s}\n", .{ teach_pfx(), cmd, c(RESET) });109+}110+111+pub fn printSubstep(comptime fmt: []const u8, args: anytype) void {112+    p("{s}", .{step()});113+    p(fmt, args);114+    p("\n", .{});115+}116+117+// --- Confirmation prompt (returns true = yes) ---118+119+pub fn confirm(prompt: []const u8) bool {120+    p("   {s}continue? [Y/n]  ", .{prompt});121+    flush();122+    var buf: [64]u8 = undefined;123+    var i: usize = 0;124+    while (i < buf.len) {125+        const n = std.posix.read(std.posix.STDIN_FILENO, buf[i .. i + 1]) catch break;126+        if (n == 0) break;127+        if (buf[i] == '\n') break;128+        i += 1;129+    }130+    const input = std.mem.trim(u8, buf[0..i], " \t");131+    if (input.len == 0) return true;132+    return input[0] != 'n' and input[0] != 'N';133+}134+135+// --- Apply / rebuild ---136+137+pub fn applyStart(machine: []const u8) void {138+    p("{s}rebuilding {s}...\n", .{ hdr(), machine });139+}140+141+pub fn applyDone(gen: u32, elapsed_ms: u64) void {142+    const secs = @as(f64, @floatFromInt(elapsed_ms)) / 1000.0;143+    if (elapsed_ms >= 1000) {144+        p("{s}generation {s}{d}{s}  {s}[{d:.1}s]{s}\n", .{ hdr(), c(BOLD), gen, c(RESET), c(DIM), secs, c(RESET) });145+    } else {146+        p("{s}generation {s}{d}{s}\n", .{ hdr(), c(BOLD), gen, c(RESET) });147+    }148+}149+150+pub fn applyFailed(translated: []const u8) void {151+    p("{s}build failed\n", .{if (color_enabled) ERR_C else ERR_P});152+    if (translated.len > 0) p("   {s}{s}{s}\n", .{ c(DIM), translated, c(RESET) });153+    p("   {s}-> nina doctor{s}\n", .{ c(DIM), c(RESET) });154+}155+156+pub fn applyDry(machine: []const u8) void {157+    p("{s}dry run  {s}\n", .{ hdr(), machine });158+}159+160+pub fn applyDryDone() void {161+    p("{s}no changes applied\n", .{hdr()});162+}163+164+// --- Check ---165+166+pub fn checkStart(machine: []const u8) void {167+    p("{s}checking {s}...\n", .{ hdr(), machine });168+}169+170+pub fn checkOk() void {171+    p("{s}{s}ok{s}\n", .{ hdr(), c(GREEN), c(RESET) });172+}173+174+pub fn checkFailed(msg: []const u8) void {175+    p("{s}config invalid\n", .{if (color_enabled) ERR_C else ERR_P});176+    if (msg.len > 0) p("   {s}{s}{s}\n", .{ c(DIM), msg, c(RESET) });177+}178+179+// --- Back / go ---180+181+pub fn backStart(machine: []const u8, from: u32, to: u32) void {182+    p("{s}rolling back  {s}  gen {d} -> gen {d}\n", .{ hdr(), machine, from, to });183+}184+185+pub fn goStart(machine: []const u8, current: u32, target: u32) void {186+    p("{s}switching  {s}  gen {d} -> gen {d}\n", .{ hdr(), machine, current, target });187+}188+189+pub fn generationDone(n: u32) void {190+    p("{s}done  gen {s}{d}{s}\n", .{ hdr(), c(BOLD), n, c(RESET) });191+}192+193+// --- History ---194+195+pub fn historyHeader(machine: []const u8) void {196+    p("{s}generations  {s}\n\n", .{ hdr(), machine });197+}198+199+pub fn historyRow(gen: types.GenerationInfo) void {200+    if (gen.current) {201+        p("   {s}{d}{s}   {s}   {s}   {s}current{s}\n", .{ c(BOLD), gen.number, c(RESET), gen.date, gen.time, c(BOLD), c(RESET) });202+    } else {203+        p("   {s}{d}   {s}   {s}{s}\n", .{ c(DIM), gen.number, gen.date, gen.time, c(RESET) });204+    }205+}206+207+// --- Clean ---208+209+pub fn cleanStart(machine: []const u8, keep: u32, total: u32) void {210+    p("{s}cleaning  {s}  keeping {d} of {d}\n", .{ hdr(), machine, keep, total });211+}212+213+pub fn cleanDone(freed: []const u8) void {214+    p("{s}freed {s}\n", .{ hdr(), freed });215+}216+217+// --- Status ---218+219+pub fn statusHeader() void {220+    p("{s}status\n\n", .{hdr()});221+}222+223+pub fn statusRow(machine: []const u8, gen: u32, time: []const u8, size: []const u8, ok: bool) void {224+    if (ok) {225+        p("   {s}{s}   gen {d}   {s}   {s}{s}\n", .{ c(DIM), machine, gen, time, size, c(RESET) });226+    } else {227+        p("   {s}{s}   {s}unreachable{s}\n", .{ c(RED), machine, "", c(RESET) });228+    }229+}230+231+// --- Diff ---232+233+pub fn diffHeader(a: u32, b: u32, machine: []const u8) void {234+    p("{s}gen {d} -> gen {d}  {s}\n\n", .{ hdr(), a, b, machine });235+}236+237+pub fn diffRow(entry: types.DiffEntry) void {238+    switch (entry.op) {239+        .add => p("   {s}+{s} {s}   {s}\n", .{ c(GREEN), c(RESET), entry.package, entry.new_version orelse "" }),240+        .remove => p("   {s}-{s} {s}   {s}\n", .{ c(RED), c(RESET), entry.package, entry.old_version orelse "" }),241+        .change => p("   {s}^{s} {s}   {s} -> {s}\n", .{ c(CYAN), c(RESET), entry.package, entry.old_version orelse "", entry.new_version orelse "" }),242+    }243+}244+245+// --- Doctor ---246+247+pub fn doctorHeader(machine: []const u8) void {248+    p("{s}diagnosing  {s}\n\n", .{ hdr(), machine });249+}250+251+pub fn doctorRow(check: types.DoctorCheck) void {252+    const status_str = switch (check.status) {253+        .ok => if (color_enabled) GREEN ++ "ok" ++ RESET else "ok",254+        .warn => if (color_enabled) YELLOW ++ "warn" ++ RESET else "warn",255+        .fail => if (color_enabled) RED ++ "fail" ++ RESET else "fail",256+    };257+    const note_str = check.note orelse "";258+    p("   {s:<16}  {s}   {s}\n", .{ check.name, status_str, note_str });259+}260+261+pub fn doctorSummary(warnings: u32, failures: u32) void {262+    if (failures > 0) {263+        p("{s}{d} problems\n", .{ hdr(), failures });264+    } else if (warnings > 0) {265+        p("{s}{d} warnings\n", .{ hdr(), warnings });266+    } else {267+        p("{s}{s}all good{s}\n", .{ hdr(), c(GREEN), c(RESET) });268+    }269+}270+271+// --- Update ---272+273+pub fn updateStart(machine: []const u8) void {274+    p("{s}updating channels  {s}\n\n", .{ hdr(), machine });275+}276+277+pub fn updateChannel(name: []const u8, old: ?[]const u8, new: ?[]const u8) void {278+    if (old != null and new != null and !std.mem.eql(u8, old.?, new.?)) {279+        p("   {s}   {s} -> {s}\n", .{ name, old.?, new.? });280+    } else {281+        p("   {s}   {s}no change{s}\n", .{ name, c(DIM), c(RESET) });282+    }283+}284+285+pub fn updateDone() void {286+    p("\n{s}done\n", .{hdr()});287+}288+289+// --- Upgrade ---290+291+pub fn upgradeStart(machine: []const u8) void {292+    p("{s}upgrading  {s}\n", .{ hdr(), machine });293+}294+295+// --- Log ---296+297+pub fn logHeader(machine: []const u8) void {298+    p("{s}history  {s}\n\n", .{ hdr(), machine });299+}300+301+pub fn logRow(date: []const u8, time: []const u8, action: []const u8, detail: []const u8) void {302+    p("   {s}  {s}   {s:<12}  {s}\n", .{ date, time, action, detail });303+}304+305+// --- Edit ---306+307+pub fn editOpen(path: []const u8, editor: []const u8) void {308+    p("{s}opening {s}\n", .{ hdr(), path });309+    printSubstep("{s}", .{editor});310+    p("\n   {s}run nina check to validate before applying{s}\n", .{ c(DIM), c(RESET) });311+}312+313+// --- Fmt ---314+315+pub fn fmtStart(path: []const u8) void {316+    p("{s}formatting  {s}\n", .{ hdr(), path });317+}318+319+pub fn fmtDone() void {320+    p("{s}done\n", .{hdr()});321+}322+323+pub fn fmtNeedsFormat() void {324+    p("{s}needs formatting  {s}run nina fmt to fix{s}\n", .{ hdr(), c(DIM), c(RESET) });325+}326+327+// --- Repl ---328+329+pub fn replStart() void {330+    p("{s}nix repl  {s}nixpkgs loaded{s}  :q to exit\n", .{ hdr(), c(DIM), c(RESET) });331+}332+333+// --- Develop ---334+335+pub fn developStart(pkgs: []const []const u8) void {336+    p("{s}entering dev shell\n\n   ", .{hdr()});337+    for (pkgs, 0..) |pkg, i| {338+        if (i > 0) p("  ", .{});339+        p("{s}", .{pkg});340+    }341+    p("\n\n   {s}exit to return{s}\n\n", .{ c(DIM), c(RESET) });342+}343+344+pub fn developDone() void {345+    p("{s}back\n", .{hdr()});346+}347+348+// --- Info ---349+350+pub fn infoHeader(machine: []const u8) void {351+    p("{s}{s}\n\n", .{ hdr(), machine });352+}353+354+pub fn infoRow(key: []const u8, val: []const u8) void {355+    p("   {s:<12}  {s}\n", .{ key, val });356+}357+358+// --- Boot ---359+360+pub fn bootHeader(machine: []const u8) void {361+    p("{s}boot entries  {s}\n\n", .{ hdr(), machine });362+}363+364+pub fn bootEntry(entry: types.BootEntry, idx: usize) void {365+    if (entry.current) {366+        p("   {s}> {s}{s}{s}\n", .{ c(BOLD), entry.title, if (entry.date) |d| std.fmt.allocPrint(std.heap.page_allocator, "   {s}", .{d}) catch "   " else "", c(RESET) });367+    } else {368+        p("   {s}  {s}{s}{s}\n", .{ c(DIM), entry.title, if (entry.date) |d| std.fmt.allocPrint(std.heap.page_allocator, "   {s}", .{d}) catch "   " else "", c(RESET) });369+    }370+    _ = idx;371+}372+373+// --- Service ---374+375+pub fn serviceListHeader(machine: []const u8) void {376+    p("{s}services  {s}\n\n", .{ hdr(), machine });377+}378+379+pub fn serviceRow(svc: types.ServiceInfo) void {380+    const state_str = switch (svc.state) {381+        .active => if (color_enabled) GREEN ++ "active" ++ RESET else "active",382+        .failed => if (color_enabled) RED ++ "failed" ++ RESET else "failed",383+        .inactive => "inactive",384+        .unknown => "unknown",385+    };386+    const uptime = svc.uptime orelse "";387+    p("   {s:<24}  {s:<14}  {s}\n", .{ svc.name, state_str, uptime });388+}389+390+pub fn serviceVerb(verb: []const u8, name: []const u8, machine: []const u8) void {391+    p("{s}{s} {s}  {s}\n", .{ hdr(), verb, name, machine });392+}393+394+pub fn serviceDone() void {395+    p("{s}done\n", .{hdr()});396+}397+398+pub fn serviceLogsHeader(name: []const u8, n: u32, machine: []const u8) void {399+    p("{s}{s}  last {d} lines  {s}\n\n", .{ hdr(), name, n, machine });400+}401+402+// --- Store ---403+404+pub fn storeInfo(info: types.StoreInfo) void {405+    p("{s}store\n\n", .{hdr()});406+    p("   {s:<16}  {s}\n", .{ "total", info.total_size });407+    p("   {s:<16}  {d} paths\n", .{ "live", info.live_paths });408+    p("   {s:<16}  {s}{d} paths{s}   run nina clean\n", .{ "reclaimable", c(DIM), info.reclaimable_paths, c(RESET) });409+}410+411+// --- Channel ---412+413+pub fn channelListHeader(machine: []const u8) void {414+    p("{s}channels  {s}\n\n", .{ hdr(), machine });415+}416+417+pub fn channelRow(ch: types.ChannelInfo) void {418+    p("   {s:<16}  {s}\n", .{ ch.name, ch.url });419+}420+421+// --- Flake ---422+423+pub fn flakeUpdateStart() void {424+    p("{s}updating inputs\n\n", .{hdr()});425+}426+427+pub fn flakeInput(input: types.FlakeInput) void {428+    if (input.changed) {429+        const old = if (input.old_rev) |r| r[0..@min(7, r.len)] else "?";430+        const new = if (input.new_rev) |r| r[0..@min(7, r.len)] else "?";431+        p("   {s:<20}  {s} -> {s}\n", .{ input.name, old, new });432+    } else {433+        p("   {s:<20}  {s}no change{s}\n", .{ input.name, c(DIM), c(RESET) });434+    }435+}436+437+pub fn flakeLockWritten() void {438+    p("\n{s}flake.lock written\n", .{hdr()});439+}440+441+pub fn flakeValid() void {442+    p("{s}{s}valid{s}\n", .{ hdr(), c(GREEN), c(RESET) });443+}444+445+pub fn flakeCheckStart() void {446+    p("{s}checking flake\n", .{hdr()});447+}448+449+// --- Profile ---450+451+pub fn profileListHeader() void {452+    p("{s}profile packages\n\n", .{hdr()});453+}454+455+pub fn profileRow(attr: []const u8, version: []const u8, description: []const u8) void {456+    p("   {s:<24}  {s:<12}  {s}\n", .{ attr, version, description });457+}458+459+// --- Install / Remove / Try ---460+461+pub fn installStart(pkg: []const u8, path: types.InstallPath) void {462+    const path_str = switch (path) {463+        .profile => "profile",464+        .system => "system",465+    };466+    p("{s}installing {s}{s}{s}  {s}\n", .{ hdr(), c(PINK), pkg, c(RESET), path_str });467+}468+469+pub fn installDone(pkg: []const u8) void {470+    p("{s}{s}{s}{s} installed  {s}available now{s}\n", .{ hdr(), c(PINK), pkg, c(RESET), c(DIM), c(RESET) });471+}472+473+pub fn installEditorPrompt(pkg: []const u8, config_path: []const u8, line: u32, editor: []const u8) void {474+    p("{s}opening {s}  add {s}{s}{s} to systemPackages\n", .{ hdr(), config_path, c(PINK), pkg, c(RESET) });475+    const cmd = std.fmt.allocPrint(std.heap.page_allocator, "{s} {s} +{d}", .{ editor, config_path, line }) catch editor;476+    printSubstep("{s}", .{cmd});477+    p("\n   {s}save and close when done{s}\n\n", .{ c(DIM), c(RESET) });478+}479+480+pub fn installSelectPrompt(pkg: []const u8, version: []const u8) void {481+    p("{s}{s}{s}{s}  {s}\n\n", .{ hdr(), c(PINK), pkg, c(RESET), version });482+    p("   [i] profile install    {s}instant  no rebuild{s}\n", .{ c(DIM), c(RESET) });483+    p("   [s] system install     {s}opens editor  requires apply{s}\n", .{ c(DIM), c(RESET) });484+    p("   [t] try now            {s}exits when done{s}\n\n", .{ c(DIM), c(RESET) });485+    p("   > ", .{});486+    flush();487+}488+489+pub fn tryStart(pkg: []const u8) void {490+    p("{s}trying {s}{s}{s}  exit when done\n", .{ hdr(), c(PINK), pkg, c(RESET) });491+}492+493+pub fn tryDone() void {494+    p("{s}back\n", .{hdr()});495+}496+497+// --- List ---498+499+pub fn listHeader() void {500+    p("{s}installed packages\n\n", .{hdr()});501+}502+503+pub fn listSection(title: []const u8) void {504+    p("   {s}{s}{s}\n", .{ c(DIM), title, c(RESET) });505+}506+507+pub fn listPkg(name: []const u8, version: []const u8) void {508+    p("   {s}{s}{s}   {s}{s}{s}\n", .{ c(PINK), name, c(RESET), c(DIM), version, c(RESET) });509+}510+511+// --- Hello ---512+513+pub fn hello(machines: []const types.Machine) void {514+    p("{s}nina\n\n", .{hdr()});515+    for (machines) |m| {516+        const kind = if (m.local) "local" else "ssh";517+        const detail = if (m.local) "default" else m.host orelse "";518+        p("   {s}{s}{s}   {s}   {s}\n", .{ c(DIM), m.name, c(RESET), kind, detail });519+    }520+    p("\n", .{});521+}522+523+// --- Help ---524+525+pub fn help() void {526+    p("{s}nina  {s}nixos helper{s}\n\n", .{ hdr(), c(DIM), c(RESET) });527+    const cmds = [_][2][]const u8{528+        .{ "apply", "rebuild and switch" },529+        .{ "back", "roll back one generation" },530+        .{ "go <n>", "switch to generation n" },531+        .{ "history", "list all generations" },532+        .{ "clean", "remove old generations" },533+        .{ "search <q>", "find packages" },534+        .{ "install <pkg>", "add to profile or system" },535+        .{ "remove <pkg>", "remove from profile or system" },536+        .{ "try <pkg>", "test without installing" },537+        .{ "list", "show installed packages" },538+        .{ "edit", "open configuration.nix" },539+        .{ "check", "validate config" },540+        .{ "diff", "changes between generations" },541+        .{ "status", "machine health" },542+        .{ "update", "refresh channels" },543+        .{ "upgrade", "update and rebuild" },544+        .{ "log", "operation history" },545+        .{ "doctor", "diagnose issues" },546+        .{ "develop", "enter dev shell" },547+        .{ "flake", "flake management" },548+        .{ "build", "build a package" },549+        .{ "run", "run without installing" },550+        .{ "service", "manage services" },551+        .{ "store", "nix store tools" },552+        .{ "channel", "manage channels" },553+        .{ "profile", "user profile packages" },554+        .{ "info", "system information" },555+        .{ "boot", "boot entries" },556+        .{ "fmt", "format configuration.nix" },557+        .{ "repl", "nix repl with nixpkgs" },558+        .{ "option <q>", "search nixos options" },559+        .{ "pkg", "inspect packages" },560+        .{ "pin", "pin flake inputs" },561+        .{ "hash", "compute nix hash" },562+        .{ "fetch", "fetch and hash a url" },563+        .{ "hello", "list machines" },564+        .{ "help", "this message" },565+    };566+    for (cmds) |cmd| {567+        p("   {s:<18}  {s}{s}{s}\n", .{ cmd[0], c(DIM), cmd[1], c(RESET) });568+    }569+    p("\n   {s}flags: --on <machine>  --dry  --check  --no-apply  --last <n>  --all  --json{s}\n", .{ c(DIM), c(RESET) });570+}571+572+// --- Search (text output before TUI) ---573+574+pub fn searchResult(pkg: types.NixPackage) void {575+    p("   {s}{s}{s}   {s}{s}{s}   {s}\n", .{ c(PINK), pkg.pname, c(RESET), c(DIM), pkg.version, c(RESET), pkg.description });576+}577+578+pub fn searchHeader(query: []const u8, count: usize) void {579+    p("{s}search nixpkgs {s}  {s}{d} results{s}\n\n", .{ hdr(), query, c(DIM), count, c(RESET) });580+}581+582+// --- Gen commands ---583+584+pub fn genCurrent(n: u32) void {585+    p("{d}\n", .{n});586+}587+588+pub fn done() void {589+    p("{s}done\n", .{hdr()});590+}591+592+pub fn runStart(pkg: []const u8) void {593+    p("{s}running {s}{s}{s}\n", .{ hdr(), c(PINK), pkg, c(RESET) });594+}595+596+pub fn buildStart(attr: []const u8) void {597+    p("{s}building {s}\n", .{ hdr(), attr });598+}599+600+pub fn buildDone() void {601+    p("{s}done\n", .{hdr()});602+}603+604+pub fn pinDone(input: []const u8, commit: []const u8) void {605+    p("{s}pinned {s} -> {s}\n", .{ hdr(), input, commit });606+}607+608+pub fn unpinDone(input: []const u8) void {609+    p("{s}unpinned {s}\n", .{ hdr(), input });610+}611+612+pub fn hashResult(hash: []const u8) void {613+    p("{s}\n", .{hash});614+}615+616+pub fn fetchResult(hash: []const u8, url: []const u8) void {617+    _ = url;618+    p("{s}\n", .{hash});619+}620+621+pub fn storeRepairStart() void {622+    p("{s}repairing store\n", .{hdr()});623+}624+625+pub fn storeVerifyStart() void {626+    p("{s}verifying store\n", .{hdr()});627+}628+629+pub fn storeGcDone(freed: []const u8) void {630+    p("{s}freed {s}\n", .{ hdr(), freed });631+}632+633+pub fn storePath(path: []const u8) void {634+    p("{s}\n", .{path});635+}636+637+pub fn pkgResult(key: []const u8, val: []const u8) void {638+    p("   {s:<16}  {s}\n", .{ key, val });639+}640+641+pub fn channelDone() void {642+    p("{s}done\n", .{hdr()});643+}644+645+pub fn flakeShowHeader(name: []const u8) void {646+    p("{s}{s}\n\n", .{ hdr(), name });647+}648+649+pub fn flakeShowEntry(kind: []const u8, name: []const u8, desc: []const u8) void {650+    p("   {s:<12}  {s:<24}  {s}\n", .{ kind, name, desc });651+}

view file

src/search.zig+272 −0
@@ -0,0 +1,272 @@1+// search.zig — inline 18-line TUI widget, no alternate screen2+// Raw terminal mode for interactive key handling. Linux-only (NixOS target).3+const std = @import("std");4+const builtin = @import("builtin");5+const types = @import("types.zig");6+const output = @import("output.zig");7+const api = @import("api.zig");8+9+const WIDGET_LINES: usize = 18;10+const RESULT_ROWS: usize = 5;11+const STDIN_FD = std.posix.STDIN_FILENO;12+13+pub fn run(14+    gpa: std.mem.Allocator,15+    io: std.Io,16+    initial_query: []const u8,17+    mode: Mode,18+) !?types.SearchResult {19+    var saved: std.posix.termios = undefined;20+    try rawModeEnable(STDIN_FD, &saved);21+    defer rawModeDisable(STDIN_FD, saved);22+23+    var arena = std.heap.ArenaAllocator.init(gpa);24+    defer arena.deinit();25+26+    var query_buf: [256]u8 = undefined;27+    var query_len: usize = @min(initial_query.len, query_buf.len - 1);28+    @memcpy(query_buf[0..query_len], initial_query[0..query_len]);29+30+    var packages: []types.NixPackage = &.{};31+    var opts: []api.NixOption = &.{};32+    var selection: usize = 0;33+34+    // Initial search35+    _ = arena.reset(.free_all);36+    packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);37+38+    // Initial render (prints WIDGET_LINES lines)39+    renderWidget(query_buf[0..query_len], packages, opts, selection, mode);40+    output.flush();41+42+    // Key loop43+    var chosen: ?types.SearchResult = null;44+    loop: while (true) {45+        var buf: [4]u8 = undefined;46+        const n = std.posix.read(STDIN_FD, &buf) catch break;47+        if (n == 0) break;48+49+        var needs_search = false;50+51+        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {52+            switch (buf[2]) {53+                'A' => if (selection > 0) { selection -= 1; }, // up54+                'B' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; }, // down55+                else => {},56+            }57+        } else switch (buf[0]) {58+            'k' => if (selection > 0) { selection -= 1; },59+            'j' => if (selection + 1 < resultCount(packages, opts, mode)) { selection += 1; },60+            0x1b, 'q' => break :loop,61+            'i', '\r' => {62+                if (mode == .packages and packages.len > selection) {63+                    chosen = .{ .package = packages[selection], .action = .profile };64+                }65+                break :loop;66+            },67+            's' => {68+                if (mode == .packages and packages.len > selection) {69+                    chosen = .{ .package = packages[selection], .action = .system };70+                }71+                break :loop;72+            },73+            't' => {74+                if (mode == .packages and packages.len > selection) {75+                    chosen = .{ .package = packages[selection], .action = .profile };76+                    // flag: try mode — caller handles77+                }78+                break :loop;79+            },80+            'c' => {81+                // Copy attr to clipboard (best-effort via pbcopy/xclip/xsel)82+                if (mode == .packages and packages.len > selection) {83+                    copyToClipboard(gpa, io, packages[selection].attr);84+                }85+            },86+            127 => { // backspace87+                if (query_len > 0) {88+                    query_len -= 1;89+                    needs_search = true;90+                    selection = 0;91+                }92+            },93+            else => |ch| {94+                if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {95+                    query_buf[query_len] = ch;96+                    query_len += 1;97+                    needs_search = true;98+                    selection = 0;99+                }100+            },101+        }102+103+        if (needs_search) {104+            _ = arena.reset(.free_all);105+            packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);106+        }107+108+        // Move cursor up and redraw109+        output.cursorUp(WIDGET_LINES);110+        renderWidget(query_buf[0..query_len], packages, opts, selection, mode);111+        output.flush();112+    }113+114+    // Clear the widget area115+    output.cursorUp(WIDGET_LINES);116+    var i: usize = 0;117+    while (i < WIDGET_LINES) : (i += 1) {118+        output.raw(output.C_CLEAR_EOL ++ "\n");119+    }120+    output.cursorUp(WIDGET_LINES);121+    output.flush();122+123+    return chosen;124+}125+126+pub const Mode = enum { packages, options };127+128+fn doSearch(arena: std.mem.Allocator, io: std.Io, query: []const u8, mode: Mode, opts: *[]api.NixOption) []types.NixPackage {129+    switch (mode) {130+        .packages => return api.searchPackages(arena, io, query) catch &.{},131+        .options => {132+            opts.* = api.searchOptions(arena, io, query) catch &.{};133+            return &.{};134+        },135+    }136+}137+138+fn resultCount(packages: []types.NixPackage, opts: []api.NixOption, mode: Mode) usize {139+    return switch (mode) {140+        .packages => packages.len,141+        .options => opts.len,142+    };143+}144+145+fn renderWidget(query: []const u8, packages: []types.NixPackage, opts: []api.NixOption, selection: usize, mode: Mode) void {146+    const count = resultCount(packages, opts, mode);147+    const CLR = output.C_CLEAR_EOL;148+    const RST = output.C_RESET;149+    const PKN = output.C_PINK;150+    const BLD = output.C_BOLD;151+    const DIM = output.C_DIM;152+153+    // Line 1: header154+    output.raw(CLR);155+    const mode_str = if (mode == .packages) "nixpkgs" else "options";156+    output.p("{s}:: {s}search {s} {s}  {s}{d} results{s}\n", .{157+        PKN, RST, mode_str, query, DIM, count, RST,158+    });159+160+    // Line 2: blank161+    output.raw(CLR ++ "\n");162+163+    // Lines 3-7: results (up to RESULT_ROWS)164+    var i: usize = 0;165+    while (i < RESULT_ROWS) : (i += 1) {166+        output.raw(CLR);167+        if (i < count) {168+            const selected = (i == selection);169+            switch (mode) {170+                .packages => {171+                    const pkg = packages[i];172+                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";173+                    const name_pre = if (selected) PKN ++ BLD else "";174+                    const name_suf = if (selected) RST else "";175+                    output.p("   {s}{s}{s}{s}   {s}{s}{s}   {s}\n", .{ marker, name_pre, pkg.pname, name_suf, DIM, pkg.version, RST, pkg.description });176+                },177+                .options => {178+                    const opt = opts[i];179+                    const marker = if (selected) PKN ++ "> " ++ RST else "  ";180+                    output.p("   {s}{s}   {s}{s}{s}\n", .{ marker, opt.name, DIM, opt.type_str, RST });181+                },182+            }183+        } else {184+            output.raw("\n");185+        }186+    }187+188+    // Line 8: blank189+    output.raw(CLR ++ "\n");190+191+    // Lines 9-12: selected package detail192+    if (count > 0 and mode == .packages) {193+        const pkg = packages[selection];194+        output.raw(CLR);195+        output.p("   {s}{s}{s}{s}  {s}{s}{s}\n", .{ PKN, BLD, pkg.pname, RST, DIM, pkg.version, RST });196+        output.raw(CLR);197+        output.p("   {s}\n", .{pkg.description});198+        output.raw(CLR ++ "\n");199+        output.raw(CLR);200+        output.p("   {s}attr  {s}{s}\n", .{ DIM, pkg.attr, RST });201+    } else if (count > 0 and mode == .options) {202+        const opt = opts[selection];203+        output.raw(CLR);204+        output.p("   {s}  {s}{s}{s}\n", .{ opt.name, DIM, opt.type_str, RST });205+        output.raw(CLR);206+        output.p("   {s}\n", .{opt.description});207+        output.raw(CLR ++ "\n");208+        output.raw(CLR ++ "\n");209+    } else {210+        output.raw(CLR ++ "\n");211+        output.raw(CLR ++ "\n");212+        output.raw(CLR ++ "\n");213+        output.raw(CLR ++ "\n");214+    }215+216+    // Line 13: blank217+    output.raw(CLR ++ "\n");218+219+    // Lines 14-17: controls + padding to reach WIDGET_LINES220+    output.raw(CLR);221+    if (mode == .packages) {222+        output.p("   {s}[i] profile  [s] system  [t] try  [c] copy  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });223+    } else {224+        output.p("   {s}[i] insert  [up/down] nav  [esc] exit{s}\n", .{ DIM, RST });225+    }226+    // Padding to reach WIDGET_LINES (18)227+    var pad: usize = 14;228+    while (pad < WIDGET_LINES) : (pad += 1) {229+        output.raw(CLR ++ "\n");230+    }231+}232+233+fn copyToClipboard(gpa: std.mem.Allocator, io: std.Io, text: []const u8) void {234+    _ = gpa;235+    // Try common clipboard tools236+    const tools = [_][]const u8{ "pbcopy", "xclip", "xsel" };237+    for (tools) |tool| {238+        var child = std.process.spawn(io, .{239+            .argv = &.{tool},240+            .stdin = .pipe,241+            .stdout = .ignore,242+            .stderr = .ignore,243+        }) catch continue;244+        if (child.stdin) |stdin| {245+            stdin.writeStreamingAll(io, text) catch {};246+            stdin.close(io);247+        }248+        _ = child.wait(io) catch {};249+        return;250+    }251+}252+253+fn rawModeEnable(fd: std.posix.fd_t, saved: *std.posix.termios) !void {254+    var tio = try std.posix.tcgetattr(fd);255+    saved.* = tio;256+    if (comptime builtin.os.tag == .linux) {257+        tio.lflag.ECHO = false;258+        tio.lflag.ICANON = false;259+        tio.lflag.ISIG = false;260+        tio.lflag.IEXTEN = false;261+        tio.iflag.ICRNL = false;262+        tio.iflag.IXON = false;263+        tio.cc[@intFromEnum(std.posix.V.MIN)] = 1;264+        tio.cc[@intFromEnum(std.posix.V.TIME)] = 0;265+    }266+    // On non-Linux, terminal will not be in raw mode (TUI requires Linux)267+    try std.posix.tcsetattr(fd, .FLUSH, tio);268+}269+270+fn rawModeDisable(fd: std.posix.fd_t, saved: std.posix.termios) void {271+    std.posix.tcsetattr(fd, .FLUSH, saved) catch {};272+}

view file

src/types.zig+100 −0
@@ -0,0 +1,100 @@1+const std = @import("std");2+3+pub const NixPackage = struct {4+    attr: []const u8,5+    pname: []const u8,6+    version: []const u8,7+    description: []const u8,8+};9+10+pub const Machine = struct {11+    name: []const u8 = "local",12+    config_path: []const u8 = "/etc/nixos",13+    local: bool = true,14+    default: bool = false,15+    host: ?[]const u8 = null,16+    user: ?[]const u8 = null,17+    ssh_key: ?[]const u8 = null,18+};19+20+pub const NinaConfig = struct {21+    editor: []const u8 = "vim",22+    generations: u32 = 5,23+    confirm: bool = true,24+    teach: bool = false,25+    color: bool = true,26+    machines: []Machine = &.{},27+};28+29+pub const GenerationInfo = struct {30+    number: u32,31+    date: []const u8,32+    time: []const u8,33+    current: bool,34+};35+36+pub const ServiceInfo = struct {37+    name: []const u8,38+    state: ServiceState,39+    uptime: ?[]const u8 = null,40+};41+42+pub const ServiceState = enum { active, failed, inactive, unknown };43+44+pub const ChannelInfo = struct {45+    name: []const u8,46+    url: []const u8,47+};48+49+pub const DiffEntry = struct {50+    op: DiffOp,51+    package: []const u8,52+    old_version: ?[]const u8 = null,53+    new_version: ?[]const u8 = null,54+};55+56+pub const DiffOp = enum { add, remove, change };57+58+pub const FlakeInput = struct {59+    name: []const u8,60+    old_rev: ?[]const u8 = null,61+    new_rev: ?[]const u8 = null,62+    changed: bool = false,63+};64+65+pub const StoreInfo = struct {66+    total_size: []const u8,67+    live_paths: u64,68+    reclaimable_paths: u64,69+};70+71+pub const BootEntry = struct {72+    title: []const u8,73+    date: ?[]const u8 = null,74+    current: bool = false,75+};76+77+pub const DoctorCheck = struct {78+    name: []const u8,79+    status: DoctorStatus,80+    note: ?[]const u8 = null,81+};82+83+pub const DoctorStatus = enum { ok, warn, fail };84+85+pub const InstallPath = enum { profile, system };86+87+pub const SearchResult = struct {88+    package: NixPackage,89+    action: InstallPath,90+};91+92+test "types are defined" {93+    const p = NixPackage{94+        .attr = "firefox",95+        .pname = "firefox",96+        .version = "124.0",97+        .description = "A browser",98+    };99+    try std.testing.expectEqualStrings("firefox", p.attr);100+}

view file