20f42f37bb78

add nina update: kepr self-update with blake3 verification and atomic install

time
2026-05-30 10:37 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
nina kepr update command
parent
4afdbd225c8f
7 files VERSION+1 −0build.zig+8 −0src/commands.zig+94 −1src/exec.zig+107 −0src/main.zig+37 −9src/output.zig+45 −0src/version.zig+17 −0
VERSION+1 −0
@@ -0,0 +1 @@1+dev

view file

build.zig+8 −0
@@ -11,6 +11,12 @@1111         b.standardTargetOptions(.{});1212     const optimize = b.standardOptimizeOption(.{});1313 14+    // VERSION file → build_options.version, accessible from version.zig.15+    const version_raw = @embedFile("VERSION");16+    const version = std.mem.trim(u8, version_raw, " \r\n\t");17+    const options = b.addOptions();18+    options.addOption([]const u8, "version", version);19+1420     const exe = b.addExecutable(.{1521         .name = "nina",1622         .root_module = b.createModule(.{@@ -19,6 +25,7 @@1925             .optimize = optimize,2026         }),2127     });28+    exe.root_module.addOptions("build_options", options);2229     b.installArtifact(exe);2330 2431     const run_cmd = b.addRunArtifact(exe);@@ -34,6 +41,7 @@3441             .optimize = optimize,3542         }),3643     });44+    tests.root_module.addOptions("build_options", options);3745     const test_step = b.step("test", "Run unit tests");3846     test_step.dependOn(&b.addRunArtifact(tests).step);3947 }

view file

src/commands.zig+94 −1
@@ -1,6 +1,7 @@11 // commands.zig — all command handlers22 // Orchestrates exec/api/output. Zero direct stdout writes.33 const std = @import("std");4+const builtin = @import("builtin");45 const types = @import("types.zig");56 const errors = @import("errors.zig");67 const config = @import("config.zig");@@ -8,7 +9,10 @@89 const exec = @import("exec.zig");910 const api = @import("api.zig");1011 const search_tui = @import("search.zig");12+const version = @import("version.zig");1113 14+const UPDATE_URL = "https://kepr.uk/api/nina/releases/latest";15+1216 pub const Ctx = struct {1317     gpa: std.mem.Allocator,1418     io: std.Io,@@ -176,7 +180,96 @@176180     output.doctorSummary(warnings, failures);177181 }178182 183+// nina update — kepr self-update for the nina binary itself.179184 pub fn update(ctx: Ctx) !void {185+    output.updateSelfStart();186+187+    if (comptime std.mem.eql(u8, version.PLATFORM, "unsupported")) {188+        output.updateSelfNoPlatform(@tagName(builtin.cpu.arch) ++ "-" ++ @tagName(builtin.os.tag));189+        return error.Internal;190+    }191+192+    const env_url = ctx.environ.get("NINA_UPDATE_SOURCE");193+    const source_url = env_url orelse UPDATE_URL;194+195+    const body = exec.fetchUrl(ctx.gpa, ctx.io, source_url) catch |e| switch (e) {196+        error.NetworkError, error.HttpError => {197+            output.updateSelfNetworkError();198+            return;199+        },200+        else => return e,201+    };202+    defer ctx.gpa.free(body);203+204+    const parsed = std.json.parseFromSlice(std.json.Value, ctx.gpa, body, .{}) catch {205+        output.printWarning("could not parse release metadata");206+        return;207+    };208+    defer parsed.deinit();209+210+    const root = parsed.value;211+    if (root != .object) { output.printWarning("malformed release metadata"); return; }212+213+    const remote_ver = blk: {214+        const v = root.object.get("version") orelse { output.printWarning("malformed release metadata"); return; };215+        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };216+    };217+218+    if (std.mem.eql(u8, remote_ver, version.VERSION)) {219+        output.updateSelfCurrent(version.VERSION);220+        return;221+    }222+223+    output.updateSelfAvailable(version.VERSION, remote_ver);224+    if (ctx.check) return;225+226+    const plat_info = blk: {227+        const pv = root.object.get("platforms") orelse break :blk null;228+        if (pv != .object) break :blk null;229+        break :blk pv.object.get(version.PLATFORM);230+    };231+    if (plat_info == null) { output.updateSelfNoPlatform(version.PLATFORM); return; }232+233+    const dl_url = blk: {234+        const v = plat_info.?.object.get("url") orelse { output.printWarning("malformed release metadata"); return; };235+        break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };236+    };237+238+    const raw_cksum = blk: {239+        if (plat_info.?.object.get("blake3")) |v|240+            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };241+        if (plat_info.?.object.get("checksum")) |v|242+            break :blk switch (v) { .string => |s| s, else => { output.printWarning("malformed release metadata"); return; } };243+        output.printWarning("missing checksum in release metadata");244+        return;245+    };246+    const expected_hex = if (std.mem.startsWith(u8, raw_cksum, "blake3:")) raw_cksum["blake3:".len..] else raw_cksum;247+248+    if (ctx.cfg.confirm and !output.confirm("")) return;249+250+    var exe_buf: [std.Io.Dir.max_path_bytes]u8 = undefined;251+    const install_path = exec.selfExePath(ctx.io, &exe_buf) catch {252+        errors.error_info.setMessage("could not determine install path", .{});253+        return error.Internal;254+    };255+256+    output.updateSelfDownloading(remote_ver, version.PLATFORM);257+    const tmp_path = exec.downloadToTemp(ctx.gpa, ctx.io, dl_url) catch {258+        output.printWarning("download failed — check your connection and try again");259+        return;260+    };261+    defer ctx.gpa.free(tmp_path);262+263+    output.updateSelfVerifying();264+    exec.verifyAndInstall(ctx.gpa, ctx.io, tmp_path, expected_hex, install_path) catch |e| switch (e) {265+        error.ChecksumMismatch => { output.updateSelfChecksumFailed(); return; },266+        else => return e,267+    };268+269+    output.updateSelfDone(version.VERSION, remote_ver);270+}271+272+fn channelUpdateCmd(ctx: Ctx) !void {180273     output.updateStart(ctx.machine.name);181274     _ = try exec.channelUpdate(ctx.io, &ctx.machine, ctx.gpa);182275     output.updateDone();@@ -556,7 +649,7 @@556649         return;557650     }558651     if (std.mem.eql(u8, ctx.sub, "update")) {559-        return update(ctx);652+        return channelUpdateCmd(ctx);560653     }561654 }562655 

view file

src/exec.zig+107 −0
@@ -651,6 +651,113 @@651651     file.writePositionalAll(io, entry, size) catch {};652652 }653653 654+// --- Update helpers ---655+656+// Fetch a URL with curl and return the response body. Caller must free.657+// error.NetworkError = DNS/connect failure; error.HttpError = non-2xx response.658+pub fn fetchUrl(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 {659+    const argv = [_][]const u8{660+        "curl", "-s", "-L", "-w", "\nHTTP_STATUS:%{http_code}", "-m", "30", "--", url,661+    };662+    const result = std.process.run(gpa, io, .{ .argv = &argv }) catch return error.NetworkError;663+    defer gpa.free(result.stderr);664+665+    const marker = "\nHTTP_STATUS:";666+    const mark_idx = std.mem.lastIndexOf(u8, result.stdout, marker) orelse {667+        gpa.free(result.stdout);668+        return error.NetworkError;669+    };670+    const status_str = std.mem.trim(u8, result.stdout[mark_idx + marker.len ..], " \r\n");671+    const status = std.fmt.parseInt(u16, status_str, 10) catch {672+        gpa.free(result.stdout);673+        return error.NetworkError;674+    };675+    if (status == 0) { gpa.free(result.stdout); return error.NetworkError; }676+    if (status < 200 or status >= 300) { gpa.free(result.stdout); return error.HttpError; }677+    const body = gpa.dupe(u8, result.stdout[0..mark_idx]) catch |e| {678+        gpa.free(result.stdout);679+        return e;680+    };681+    gpa.free(result.stdout);682+    return body;683+}684+685+// Download a URL to a temp file. Returns the temp path (caller must gpa.free).686+// The file is written to /tmp/nina-update-<pid> and left for the caller to use.687+pub fn downloadToTemp(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 {688+    const tmp_path = try gpa.dupe(u8, "/tmp/nina-update.bin");689+    errdefer gpa.free(tmp_path);690+691+    var child = std.process.spawn(io, .{692+        .argv = &.{ "curl", "-fsSL", "-m", "300", "-o", tmp_path, "--", url },693+        .stdin = .close,694+        .stdout = .close,695+        .stderr = .inherit,696+    }) catch return error.NetworkError;697+    const term = child.wait(io) catch return error.NetworkError;698+    switch (term) {699+        .exited => |c| if (c != 0) return error.NetworkError,700+        else => return error.NetworkError,701+    }702+    return tmp_path;703+}704+705+// Get the current executable path into buf. Returns slice of buf.706+pub fn selfExePath(io: std.Io, buf: *[std.Io.Dir.max_path_bytes]u8) ![]const u8 {707+    const len = try std.process.executablePath(io, buf);708+    return buf[0..len];709+}710+711+// Verify blake3 of tmp_path against expected_hex (raw hex, no prefix), then712+// atomically install the binary to install_path. Errors: ChecksumMismatch,713+// AccessDenied (with error_info set), or underlying I/O errors.714+pub fn verifyAndInstall(gpa: std.mem.Allocator, io: std.Io, tmp_path: []const u8, expected_hex: []const u8, install_path: []const u8) !void {715+    const bytes = std.Io.Dir.readFileAlloc(.cwd(), io, tmp_path, gpa, .unlimited) catch {716+        errors.error_info.setMessage("downloaded file not found: {s}", .{tmp_path});717+        return error.FileNotFound;718+    };719+    defer gpa.free(bytes);720+721+    var hasher = std.crypto.hash.Blake3.init(.{});722+    hasher.update(bytes);723+    var raw: [32]u8 = undefined;724+    hasher.final(&raw);725+    const hex = std.fmt.bytesToHex(raw, .lower);726+    if (!std.mem.eql(u8, &hex, expected_hex)) return error.ChecksumMismatch;727+728+    var inst_tmp_buf: [std.Io.Dir.max_path_bytes + 8]u8 = undefined;729+    const inst_tmp = std.fmt.bufPrint(&inst_tmp_buf, "{s}.new", .{install_path}) catch730+        return error.NameTooLong;731+732+    {733+        const f = std.Io.Dir.createFileAbsolute(io, inst_tmp, .{}) catch {734+            errors.error_info.setMessage("cannot write to {s}", .{install_path});735+            errors.error_info.setSuggestion("try: sudo nina update", .{});736+            return error.AccessDenied;737+        };738+        defer f.close(io);739+        f.writePositionalAll(io, bytes, 0) catch {740+            errors.error_info.setMessage("cannot write to {s}", .{install_path});741+            return error.AccessDenied;742+        };743+        try f.setPermissions(io, .fromMode(0o755));744+    }745+    errdefer std.Io.Dir.deleteFileAbsolute(io, inst_tmp) catch {};746+747+    if (comptime builtin.os.tag == .macos) {748+        _ = captureLocal(gpa, io, &.{ "codesign", "-f", "-s", "-", inst_tmp }) catch {};749+    }750+751+    std.Io.Dir.renameAbsolute(inst_tmp, install_path, io) catch |e| switch (e) {752+        error.AccessDenied => {753+            errors.error_info.setMessage("cannot replace {s}", .{install_path});754+            errors.error_info.setSuggestion("try: sudo nina update", .{});755+            return error.AccessDenied;756+        },757+        else => return e,758+    };759+}760+654761 // --- Nix feature setup ---655762 656763 pub const NixFeatureStatus = struct {

view file

src/main.zig+37 −9
@@ -42,16 +42,44 @@4242         return;4343     }4444 45-    // setup runs before config so it works as an onboarding step46-    if (std.mem.eql(u8, args.command, "setup")) {47-        commands.setup(gpa, io, startup.environ_map) catch |err| {48-            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);49-            const detail = errors.error_info.getDetail();50-            const suggestion = errors.error_info.getSuggestion();51-            output.printError(msg, detail, suggestion);52-            output.flush();53-            std.process.exit(1);45+    // setup and update run before config so they work without ~/.nina.conf46+    if (std.mem.eql(u8, args.command, "setup") or std.mem.eql(u8, args.command, "update")) {47+        const dummy_machine: types.Machine = .{ .name = "local", .local = true };48+        const dummy_cfg: types.NinaConfig = .{};49+        const pre_ctx = commands.Ctx{50+            .gpa = gpa,51+            .io = io,52+            .environ = startup.environ_map,53+            .machine = dummy_machine,54+            .cfg = dummy_cfg,55+            .args = args.rest,56+            .sub = args.sub,57+            .dry = args.dry,58+            .check = args.check,59+            .no_apply = args.no_apply,60+            .all = args.all,61+            .last = args.last,62+            .json = args.json,5463         };64+        if (std.mem.eql(u8, args.command, "setup")) {65+            commands.setup(gpa, io, startup.environ_map) catch |err| {66+                const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);67+                const detail = errors.error_info.getDetail();68+                const suggestion = errors.error_info.getSuggestion();69+                output.printError(msg, detail, suggestion);70+                output.flush();71+                std.process.exit(1);72+            };73+        } else {74+            dispatch(pre_ctx, args.command) catch |err| {75+                const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);76+                const detail = errors.error_info.getDetail();77+                const suggestion = errors.error_info.getSuggestion();78+                output.printError(msg, detail, suggestion);79+                output.flush();80+                std.process.exit(1);81+            };82+        }5583         return;5684     }5785 

view file

src/output.zig+45 −0
@@ -608,6 +608,51 @@608608     p("{s}done\n", .{hdr()});609609 }610610 611+// --- Self-update (nina update) ---612+613+pub fn updateSelfStart() void {614+    p("{s}checking for updates\n", .{hdr()});615+}616+617+pub fn updateSelfCurrent(ver: []const u8) void {618+    p("{s}{s}nina {s} is up to date{s}\n", .{ hdr(), c(GREEN), ver, c(RESET) });619+}620+621+pub fn updateSelfAvailable(current: []const u8, latest: []const u8) void {622+    p("{s}current  {s}\n", .{ step(), current });623+    p("{s}latest   {s}{s}{s}\n", .{ step(), c(BOLD), latest, c(RESET) });624+}625+626+pub fn updateSelfDownloading(ver: []const u8, platform: []const u8) void {627+    p("{s}downloading nina {s} for {s}\n", .{ step(), ver, platform });628+}629+630+pub fn updateSelfVerifying() void {631+    p("{s}verifying checksum\n", .{step()});632+}633+634+pub fn updateSelfInstalling(install_path: []const u8) void {635+    p("{s}installing to {s}\n", .{ step(), install_path });636+}637+638+pub fn updateSelfDone(old_ver: []const u8, new_ver: []const u8) void {639+    p("{s}updated  {s} {s}→{s} {s}\n", .{ hdr(), old_ver, c(DIM), c(RESET), new_ver });640+}641+642+pub fn updateSelfNoPlatform(platform: []const u8) void {643+    p("{s}no pre-built binary for {s}\n", .{ hdr(), platform });644+    p("{s}check kepr.uk/nina/releases for available platforms\n", .{step()});645+}646+647+pub fn updateSelfNetworkError() void {648+    p("{s}{s}\n", .{ if (color_enabled) WARN_C else WARN_P, "could not reach kepr.uk — check your connection" });649+}650+651+pub fn updateSelfChecksumFailed() void {652+    p("{s}checksum mismatch — download may be corrupt\n", .{if (color_enabled) ERR_C else ERR_P});653+    p("{s}try again or download manually from kepr.uk/nina/releases\n", .{step()});654+}655+611656 // --- Setup ---612657 613658 pub fn setupCheck() void {

view file

src/version.zig+17 −0
@@ -0,0 +1,17 @@1+const builtin = @import("builtin");2+3+pub const VERSION = @import("build_options").version;4+5+pub const PLATFORM: []const u8 = switch (builtin.os.tag) {6+    .macos => switch (builtin.cpu.arch) {7+        .aarch64 => "macos-aarch64",8+        .x86_64 => "macos-x86_64",9+        else => "unsupported",10+    },11+    .linux => switch (builtin.cpu.arch) {12+        .aarch64 => "linux-aarch64",13+        .x86_64 => "linux-x86_64",14+        else => "unsupported",15+    },16+    else => "unsupported",17+};

view file