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
↓ nina-2c3d3893.tar.gzatlas at this save
files changed11 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 −0build.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+}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+}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+}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+}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+}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+}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+}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+}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+}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+}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+}