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
↓ nina-20f42f37.tar.gzatlas at this save
files changed7 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 −0build.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 }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 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 {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 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 {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+};