1974f3b85d9d

feat: Add lifecycle hooks for state-changing commands (v3.2.0)

- Add local ~/.config/nina/hooks runner with executable checks, exit codes, and capped tail output - Wire pre/post hooks into apply, back, home apply, and upgrade - Document hook files and bump VERSION/build.zig.zon to 3.2.0 - Verify zig build, zig build test, nina --version, and shimmed apply hook smoke

time
2026-06-12 14:13 UTC · 4 months ago
machine
koh2
parent
b06dca45c445
6 files README.md+30 −0VERSION+1 −1build.zig.zon+1 −1src/commands.zig+44 −0src/exec.zig+119 −0src/output.zig+33 −0
README.md+30 −0
@@ -99,6 +99,36 @@9999 100100 ---101101 102+## hooks103+104+Nina runs optional executable hook scripts before and after state-changing commands.105+Create them in `~/.config/nina/hooks/`:106+107+```108+pre-apply     post-apply109+pre-back      post-back110+pre-home      post-home111+pre-upgrade   post-upgrade112+```113+114+Missing hooks are ignored. Hook files must be executable (`chmod +x`) to run.115+If a pre-hook exits non-zero, Nina shows the last five output lines and asks116+whether to continue, defaulting to no. If a post-hook exits non-zero, Nina shows117+a warning after the command's success line.118+119+Example: stop `nina apply` when your config has uncommitted changes.120+121+```sh122+#!/usr/bin/env sh123+# ~/.config/nina/hooks/pre-apply124+if git -C ~/nixos-config status --porcelain | grep -q .; then125+    echo "uncommitted changes - commit before applying"126+    exit 1127+fi128+```129+130+---131+102132 ## multi-machine103133 104134 Any command runs on a remote machine with `--on <name>`. Configure machines in `~/.nina.conf`:

view file

VERSION+1 −1
@@ -1 +1 @@1-3.1.91+3.2.0

view file

build.zig.zon+1 −1
@@ -9,7 +9,7 @@99     .name = .nina,1010     // This is a [Semantic Version](https://semver.org/).1111     // In a future version of Zig it will be used for package deduplication.12-    .version = "3.1.9",12+    .version = "3.2.0",1313     // Together with name, this represents a globally unique package1414     // identifier. This field is generated by the Zig toolchain when the1515     // package is first created, and then *never changes*. This allows

view file

src/commands.zig+44 −0
@@ -37,6 +37,26 @@3737     return ctx.cfg.flake or exec.configHasFlake(ctx.gpa, ctx.io, &ctx.machine);3838 }3939 40+fn runPreHook(ctx: Ctx, hook_name: []const u8) !bool {41+    const hook = try exec.runHook(ctx.gpa, ctx.io, ctx.environ, hook_name);42+    defer ctx.gpa.free(hook.output);43+    switch (hook.result) {44+        .not_found, .ok => return true,45+        .failed => {46+            output.hookFailed(hook_name, hook.exit_code, hook.output);47+            return output.confirmDefaultNo("continue anyway?");48+        },49+    }50+}51+52+fn runPostHook(ctx: Ctx, hook_name: []const u8) void {53+    const hook = exec.runHook(ctx.gpa, ctx.io, ctx.environ, hook_name) catch return;54+    defer ctx.gpa.free(hook.output);55+    if (hook.result == .failed) {56+        output.hookWarning(hook_name, hook.exit_code, hook.output);57+    }58+}59+4060 pub fn apply(ctx: Ctx) !void {4161     if (ctx.dry) {4262         output.applyDry(ctx.machine.name);@@ -49,6 +69,11 @@4969         return check(ctx);5070     }5171 72+    if (!try runPreHook(ctx, "pre-apply")) {73+        output.aborted();74+        return;75+    }76+5277     output.applyStart(ctx.machine.name);5378     const start = std.Io.Clock.awake.now(ctx.io);5479 @@ -69,6 +94,7 @@6994     const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;7095     const after_failure = exec.takeApplyFailed(ctx.io, ctx.environ);7196     output.applyDone(gen, elapsed, after_failure);97+    runPostHook(ctx, "post-apply");7298     logAction(ctx, "apply", gen);7399 }74100 @@ -106,12 +132,18 @@106132         return;107133     }108134 135+    if (!try runPreHook(ctx, "pre-back")) {136+        output.aborted();137+        return;138+    }139+109140     output.backStart(ctx.machine.name, current, prev);110141     if (ctx.cfg.confirm and !output.confirm("")) return;111142 112143     const code = try exec.rollback(ctx.io, &ctx.machine, ctx.gpa);113144     if (code != 0) return error.BuildFailed;114145     output.backDone(prev);146+    runPostHook(ctx, "post-back");115147 }116148 117149 pub fn go(ctx: Ctx) !void {@@ -512,6 +544,11 @@512544 }513545 514546 pub fn upgrade(ctx: Ctx) !void {547+    if (!try runPreHook(ctx, "pre-upgrade")) {548+        output.aborted();549+        return;550+    }551+515552     output.upgradeStart(ctx.machine.name);516553     // Flake systems update inputs (flake.lock); channel systems update channels.517554     // Running nix-channel --update on a flake system is a no-op at best. The@@ -534,6 +571,7 @@534571     const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));535572     const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;536573     output.applyDone(gen, elapsed, false);574+    runPostHook(ctx, "post-upgrade");537575 }538576 539577 pub fn log(ctx: Ctx) !void {@@ -1221,6 +1259,11 @@12211259         return;12221260     }12231261 1262+    if (!try runPreHook(ctx, "pre-home")) {1263+        output.aborted();1264+        return;1265+    }1266+12241267     output.homeApplyStart(ctx.machine.name);12251268     const flake_ref = exec.homeFlakeRef(ctx.gpa, ctx.io, ctx.environ);12261269     defer if (flake_ref) |r| ctx.gpa.free(r);@@ -1240,6 +1283,7 @@12401283     const elapsed_ns = @max(0, start.untilNow(ctx.io, .awake).toNanoseconds());12411284     const elapsed: u64 = @intCast(@divFloor(elapsed_ns, 1_000_000));12421285     output.homeApplyDone(homeCurrentGen(ctx), elapsed);1286+    runPostHook(ctx, "post-home");12431287 }12441288 12451289 // --- Service commands ---

view file

src/exec.zig+119 −0
@@ -16,6 +16,18 @@1616     exit_code: u8,1717 };1818 19+pub const HookResult = enum {20+    ok,21+    failed,22+    not_found,23+};24+25+pub const HookRun = struct {26+    result: HookResult,27+    exit_code: u8 = 0,28+    output: []u8,29+};30+1931 // Run a process and capture stdout+stderr (not streaming).2032 pub fn capture(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, cmd: []const []const u8) !RunResult {2133     const argv = try buildArgv(gpa, machine, cmd);@@ -170,6 +182,113 @@170182 // matches how the rest of exec.zig provisions config locations (~/.config/nix).171183 pub fn ensureDir(gpa: std.mem.Allocator, io: std.Io, dir: []const u8) void {172184     _ = captureLocal(gpa, io, &.{ "mkdir", "-p", dir }) catch {};185+}186+187+// Optional user hooks live in ~/.config/nina/hooks and must be executable.188+// They are always local: hooks protect the user's local workflow before nina189+// starts state-changing work.190+pub fn runHook(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, hook_name: []const u8) !HookRun {191+    const home = environ.get("HOME") orelse return .{192+        .result = .not_found,193+        .output = try gpa.dupe(u8, ""),194+    };195+196+    const hook_dir = try std.fs.path.join(gpa, &.{ home, ".config", "nina", "hooks" });197+    defer gpa.free(hook_dir);198+    ensureDir(gpa, io, hook_dir);199+200+    const hook_path = try std.fs.path.join(gpa, &.{ hook_dir, hook_name });201+    defer gpa.free(hook_path);202+203+    const executable = std.process.run(gpa, io, .{ .argv = &.{ "sh", "-c", "test -x \"$1\"", "nina-hook-check", hook_path } }) catch return .{204+        .result = .not_found,205+        .output = try gpa.dupe(u8, ""),206+    };207+    defer gpa.free(executable.stdout);208+    defer gpa.free(executable.stderr);209+    const is_executable = switch (executable.term) {210+        .exited => |code| code == 0,211+        else => false,212+    };213+    if (!is_executable) return .{214+        .result = .not_found,215+        .output = try gpa.dupe(u8, ""),216+    };217+218+    const tmp_raw = try captureLocal(gpa, io, &.{ "mktemp", "/tmp/nina-hook.XXXXXX" });219+    defer gpa.free(tmp_raw);220+    const tmp_output = std.mem.trim(u8, tmp_raw, " \t\r\n");221+    defer if (std.fs.path.isAbsolute(tmp_output)) std.Io.Dir.deleteFileAbsolute(io, tmp_output) catch {};222+223+    const result = try std.process.run(gpa, io, .{224+        .argv = &.{225+            "sh",226+            "-c",227+            "$1 > \"$2\" 2>&1; code=$?; tail -c 4096 \"$2\"; exit \"$code\"",228+            "nina-hook-run",229+            hook_path,230+            tmp_output,231+        },232+        .stdout_limit = .limited(4096),233+        .stderr_limit = .limited(512),234+    });235+    defer gpa.free(result.stdout);236+    defer gpa.free(result.stderr);237+238+    const exit_code = switch (result.term) {239+        .exited => |code| code,240+        else => 1,241+    };242+    return .{243+        .result = if (exit_code == 0) .ok else .failed,244+        .exit_code = exit_code,245+        .output = try std.fmt.allocPrint(gpa, "{s}{s}", .{ result.stdout, result.stderr }),246+    };247+}248+249+test "runHook ignores non-executable hooks and captures failures" {250+    const a = std.testing.allocator;251+    const io = std.testing.io;252+253+    var tmp = std.testing.tmpDir(.{});254+    defer tmp.cleanup();255+256+    const home = try std.fmt.allocPrint(a, ".zig-cache/tmp/{s}", .{&tmp.sub_path});257+    defer a.free(home);258+259+    var env: std.process.Environ.Map = .init(a);260+    defer env.deinit();261+    try env.put("HOME", home);262+263+    var hook_dir = try tmp.dir.createDirPathOpen(io, ".config/nina/hooks", .{});264+    defer hook_dir.close(io);265+266+    const script =267+        \\#!/bin/sh268+        \\echo hook says no269+        \\echo detail >&2270+        \\exit 3271+        \\272+    ;273+    {274+        const file = try hook_dir.createFile(io, "pre-apply", .{});275+        defer file.close(io);276+        try file.writePositionalAll(io, script, 0);277+        try file.setPermissions(io, .fromMode(0o644));278+    }279+280+    const missing = try runHook(a, io, &env, "pre-apply");281+    defer a.free(missing.output);282+    try std.testing.expectEqual(HookResult.not_found, missing.result);283+284+    try hook_dir.setFilePermissions(io, "pre-apply", .fromMode(0o755), .{});285+286+    const failed = try runHook(a, io, &env, "pre-apply");287+    defer a.free(failed.output);288+    try std.testing.expectEqual(HookResult.failed, failed.result);289+    try std.testing.expectEqual(@as(u8, 3), failed.exit_code);290+    try std.testing.expect(std.mem.indexOf(u8, failed.output, "hook says no") != null);291+    try std.testing.expect(std.mem.indexOf(u8, failed.output, "detail") != null);173292 }174293 175294 fn buildArgv(gpa: std.mem.Allocator, machine: *const types.Machine, cmd: []const []const u8) ![]const []const u8 {

view file

src/output.zig+33 −0
@@ -134,6 +134,10 @@134134     p("{s}{s}\n", .{ if (color_enabled) WARN_C else WARN_P, message });135135 }136136 137+pub fn aborted() void {138+    p("{s}aborted\n", .{hdr()});139+}140+137141 // One-time dim note when ~/.nina.conf is migrated to the XDG config path.138142 pub fn configMigrated(path: []const u8) void {139143     p("{s}{s}config migrated to {s}{s}\n", .{ step(), c(DIM), path, c(RESET) });@@ -147,6 +151,35 @@147151     p("{s}", .{step()});148152     p(fmt, args);149153     p("\n", .{});154+}155+156+pub fn hookFailed(name: []const u8, code: u8, hook_output: []const u8) void {157+    p("{s}{s} hook exited with code {d}{s}\n\n", .{ hdr(), name, code, c(RESET) });158+159+    if (hook_output.len > 0) {160+        var lines = std.mem.splitScalar(u8, hook_output, '\n');161+        var shown: [5][]const u8 = undefined;162+        var count: usize = 0;163+        while (lines.next()) |line| {164+            const trimmed = std.mem.trim(u8, line, " \t\r");165+            if (trimmed.len == 0) continue;166+            shown[count % shown.len] = trimmed;167+            count += 1;168+        }169+170+        const display_count = @min(count, shown.len);171+        const start = count - display_count;172+        for (0..display_count) |i| {173+            const line = shown[(start + i) % shown.len];174+            p("   {s}{s}{s}\n", .{ c(DIM), line, c(RESET) });175+        }176+        p("\n", .{});177+    }178+}179+180+pub fn hookWarning(name: []const u8, code: u8, hook_output: []const u8) void {181+    _ = hook_output;182+    p("{s}   warning: {s} hook exited with code {d}{s}\n", .{ c(YELLOW), name, code, c(RESET) });150183 }151184 152185 // --- Confirmation prompt (returns true = yes) ---

view file