1974f3b85d9d
feat: Add lifecycle hooks for state-changing commands (v3.2.0)
- time
- 2026-06-12 14:13 UTC · 4 months ago
- machine
- koh2
- parent
- b06dca45c445
↓ nina-1974f3b8.tar.gzatlas at this save
files changed6 files
README.md+30 −0VERSION+1 −1build.zig.zon+1 −1src/commands.zig+44 −0src/exec.zig+119 −0src/output.zig+33 −0README.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`: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 allowssrc/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 ---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 {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) ---