123const std = @import("std");4const builtin = @import("builtin");5const types = @import("types.zig");6const errors = @import("errors.zig");7const output = @import("output.zig");891011const SYSTEM_PROFILE = "/nix/var/nix/profiles/system";1213pub const RunResult = struct {14 stdout: []u8,15 stderr: []u8,16 exit_code: u8,17};181920pub fn capture(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, cmd: []const []const u8) !RunResult {21 const argv = try buildArgv(gpa, machine, cmd);22 defer if (!machine.local) gpa.free(argv);2324 const result = std.process.run(gpa, io, .{ .argv = argv }) catch |e| {25 errors.error_info.setMessage("process failed: {s}", .{cmd[0]});26 return e;27 };2829 return .{30 .stdout = result.stdout,31 .stderr = result.stderr,32 .exit_code = switch (result.term) {33 .exited => |c| c,34 else => 1,35 },36 };37}383940pub fn stream(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, cmd: []const []const u8) !u8 {41 42 43 output.flush();44 const argv = try buildArgv(gpa, machine, cmd);45 defer if (!machine.local) gpa.free(argv);4647 var child = std.process.spawn(io, .{48 .argv = argv,49 .stdin = .inherit,50 .stdout = .inherit,51 .stderr = .inherit,52 }) catch |e| {53 errors.error_info.setMessage("failed to spawn: {s}", .{cmd[0]});54 return e;55 };5657 const term = child.wait(io) catch |e| {58 errors.error_info.setMessage("process wait failed", .{});59 return e;60 };6162 return switch (term) {63 .exited => |c| c,64 else => 1,65 };66}676869pub fn streamLocal(io: std.Io, cmd: []const []const u8) !u8 {70 output.flush();71 var child = std.process.spawn(io, .{72 .argv = cmd,73 .stdin = .inherit,74 .stdout = .inherit,75 .stderr = .inherit,76 }) catch |e| {77 errors.error_info.setMessage("failed to spawn: {s}", .{cmd[0]});78 return e;79 };80 const term = child.wait(io) catch {81 errors.error_info.setMessage("process wait failed", .{});82 return error.ProcessFailed;83 };84 return switch (term) {85 .exited => |c| c,86 else => 1,87 };88}899091pub fn captureLocal(gpa: std.mem.Allocator, io: std.Io, cmd: []const []const u8) ![]u8 {92 const result = std.process.run(gpa, io, .{ .argv = cmd }) catch return error.ProcessFailed;93 defer gpa.free(result.stderr);94 switch (result.term) {95 .exited => |c| if (c != 0) {96 gpa.free(result.stdout);97 return error.ProcessFailed;98 },99 else => {100 gpa.free(result.stdout);101 return error.ProcessFailed;102 },103 }104 return result.stdout;105}106107fn buildArgv(gpa: std.mem.Allocator, machine: *const types.Machine, cmd: []const []const u8) ![]const []const u8 {108 if (machine.local) return cmd;109110 const host = machine.host orelse {111 errors.error_info.setMessage("machine '{s}' has no host configured", .{machine.name});112 return error.SshFailed;113 };114115 var ssh: std.ArrayList([]const u8) = .empty;116 try ssh.append(gpa, "ssh");117 try ssh.append(gpa, "-o");118 try ssh.append(gpa, "BatchMode=yes");119 try ssh.append(gpa, "-o");120 try ssh.append(gpa, "StrictHostKeyChecking=accept-new");121 if (machine.ssh_key) |key| {122 try ssh.append(gpa, "-i");123 try ssh.append(gpa, key);124 }125 const addr = if (machine.user) |u|126 try std.fmt.allocPrint(gpa, "{s}@{s}", .{ u, host })127 else128 try gpa.dupe(u8, host);129 try ssh.append(gpa, addr);130 for (cmd) |arg| try ssh.append(gpa, arg);131 return ssh.toOwnedSlice(gpa);132}133134135136pub fn nixosRebuildSwitch(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {137 const cmd = &.{ "sudo", "nixos-rebuild", "switch" };138 output.printSubstep("nixos-rebuild switch", .{});139 return stream(io, machine, gpa, cmd);140}141142pub fn nixosRebuildDryActivate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {143 return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "dry-activate" });144}145146pub fn nixosRebuildBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {147 return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "build" });148}149150151152153154155156157158159const GEN_LIST_SCRIPT =160 \\cur=$(readlink /nix/var/nix/profiles/system 2>/dev/null | sed 's/.*system-\([0-9]*\)-link/\1/'); find /nix/var/nix/profiles -maxdepth 1 -name 'system-*-link' -printf '%f %TY-%Tm-%Td %TH:%TM\n' 2>/dev/null | sed 's/system-\([0-9]*\)-link/\1/' | sort -n | awk -v c="$cur" '{ if ($1==c) print $0" current"; else print $0 }'161;162163pub fn getGenerations(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.GenerationInfo {164 const r = try capture(gpa, io, machine, &.{ "sh", "-c", GEN_LIST_SCRIPT });165 defer gpa.free(r.stderr);166 defer gpa.free(r.stdout);167 if (r.exit_code != 0) return error.ProcessFailed;168 return parseGenerations(gpa, r.stdout);169}170171fn parseGenerations(gpa: std.mem.Allocator, output_str: []const u8) ![]types.GenerationInfo {172 var gens: std.ArrayList(types.GenerationInfo) = .empty;173 var lines = std.mem.splitScalar(u8, output_str, '\n');174 while (lines.next()) |line| {175 const trimmed = std.mem.trim(u8, line, " \t");176 if (trimmed.len == 0) continue;177 var parts = std.mem.tokenizeAny(u8, trimmed, " \t");178 const num_str = parts.next() orelse continue;179 const date_str = parts.next() orelse continue;180 const time_str = parts.next() orelse continue;181 const rest = parts.rest();182 const current = std.mem.indexOf(u8, rest, "current") != null;183 const num = std.fmt.parseInt(u32, num_str, 10) catch continue;184 try gens.append(gpa, .{185 .number = num,186 .date = try gpa.dupe(u8, date_str),187 .time = try gpa.dupe(u8, time_str),188 .current = current,189 });190 }191 return gens.toOwnedSlice(gpa);192}193194pub fn getCurrentGeneration(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) !u32 {195 const gens = try getGenerations(gpa, io, machine);196 defer gpa.free(gens);197 for (gens) |g| {198 if (g.current) return g.number;199 }200 return 0;201}202203pub fn switchGeneration(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, n: u32) !u8 {204 205 206 const cmd = try std.fmt.allocPrint(gpa, "nix-env -p {s} --switch-generation {d} && {s}/bin/switch-to-configuration switch", .{ SYSTEM_PROFILE, n, SYSTEM_PROFILE });207 defer gpa.free(cmd);208 return stream(io, machine, gpa, &.{ "sudo", "sh", "-c", cmd });209}210211pub fn rollback(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {212 return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", "switch", "--rollback" });213}214215pub fn deleteGeneration(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, n: u32) !u8 {216 const n_str = try std.fmt.allocPrint(gpa, "{d}", .{n});217 defer gpa.free(n_str);218 return stream(io, machine, gpa, &.{ "sudo", "nix-env", "-p", SYSTEM_PROFILE, "--delete-generations", n_str });219}220221pub fn deleteOldGenerations(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {222 return stream(io, machine, gpa, &.{ "sudo", "nix-env", "-p", SYSTEM_PROFILE, "--delete-generations", "old" });223}224225226227pub fn collectGarbage(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, delete_old: bool, keep: u32) !u8 {228 if (delete_old) {229 return stream(io, machine, gpa, &.{ "sudo", "nix-collect-garbage", "-d" });230 }231 232 233 234 const cmd = try std.fmt.allocPrint(gpa, "nix-env -p {s} --delete-generations +{d} && nix-collect-garbage", .{ SYSTEM_PROFILE, keep });235 defer gpa.free(cmd);236 return stream(io, machine, gpa, &.{ "sudo", "sh", "-c", cmd });237}238239240241pub fn channelList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.ChannelInfo {242 const r = try capture(gpa, io, machine, &.{ "nix-channel", "--list" });243 defer gpa.free(r.stderr);244 defer gpa.free(r.stdout);245 if (r.exit_code != 0) return error.ProcessFailed;246 return parseChannels(gpa, r.stdout);247}248249fn parseChannels(gpa: std.mem.Allocator, output_str: []const u8) ![]types.ChannelInfo {250 var channels: std.ArrayList(types.ChannelInfo) = .empty;251 var lines = std.mem.splitScalar(u8, output_str, '\n');252 while (lines.next()) |line| {253 const trimmed = std.mem.trim(u8, line, " \t\r");254 if (trimmed.len == 0) continue;255 const space = std.mem.indexOfScalar(u8, trimmed, ' ') orelse continue;256 try channels.append(gpa, .{257 .name = try gpa.dupe(u8, trimmed[0..space]),258 .url = try gpa.dupe(u8, std.mem.trim(u8, trimmed[space + 1 ..], " \t")),259 });260 }261 return channels.toOwnedSlice(gpa);262}263264pub fn channelUpdate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {265 return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--update" });266}267268pub fn channelAdd(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, url: []const u8, name: []const u8) !u8 {269 return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--add", url, name });270}271272pub fn channelRemove(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, name: []const u8) !u8 {273 return stream(io, machine, gpa, &.{ "sudo", "nix-channel", "--remove", name });274}275276277278pub fn serviceList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.ServiceInfo {279 const r = try capture(gpa, io, machine, &.{ "systemctl", "list-units", "--type=service", "--no-legend", "--no-pager" });280 defer gpa.free(r.stderr);281 defer gpa.free(r.stdout);282 if (r.exit_code != 0) return error.ProcessFailed;283 return parseServices(gpa, r.stdout);284}285286fn parseServices(gpa: std.mem.Allocator, output_str: []const u8) ![]types.ServiceInfo {287 var svcs: std.ArrayList(types.ServiceInfo) = .empty;288 var lines = std.mem.splitScalar(u8, output_str, '\n');289 while (lines.next()) |line| {290 const trimmed = std.mem.trim(u8, line, " \t\r");291 if (trimmed.len == 0 or trimmed[0] == 0xE2) continue; 292 var parts = std.mem.tokenizeAny(u8, trimmed, " \t");293 const name = parts.next() orelse continue;294 _ = parts.next(); 295 const active_str = parts.next() orelse continue;296 const sub_str = parts.next() orelse "unknown";297298 const state: types.ServiceState = if (std.mem.eql(u8, active_str, "active"))299 (if (std.mem.eql(u8, sub_str, "running") or std.mem.eql(u8, sub_str, "exited")) .active else .active)300 else if (std.mem.eql(u8, active_str, "failed"))301 .failed302 else303 .inactive;304305 try svcs.append(gpa, .{306 .name = try gpa.dupe(u8, name),307 .state = state,308 .uptime = null,309 });310 }311 return svcs.toOwnedSlice(gpa);312}313314pub fn serviceCtl(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, verb: []const u8, name: []const u8) !u8 {315 316 return stream(io, machine, gpa, &.{ "sudo", "systemctl", "--no-pager", verb, name });317}318319pub fn serviceLogs(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, name: []const u8, last: u32, follow: bool) !u8 {320 const n_str = try std.fmt.allocPrint(gpa, "{d}", .{last});321 defer gpa.free(n_str);322 if (follow) {323 return stream(io, machine, gpa, &.{ "journalctl", "-u", name, "-f" });324 }325 return stream(io, machine, gpa, &.{ "journalctl", "-u", name, "-n", n_str, "--no-pager" });326}327328329330pub fn profileInstall(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: []const u8) !u8 {331 const pkg_ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{attr});332 defer gpa.free(pkg_ref);333 334 335 return stream(io, machine, gpa, &.{ "nix", "profile", "add", pkg_ref });336}337338pub fn profileRemove(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: []const u8) !u8 {339 340 341 return stream(io, machine, gpa, &.{ "nix", "profile", "remove", attr });342}343344pub fn profileList(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {345 const r = try capture(gpa, io, machine, &.{ "nix", "profile", "list" });346 defer gpa.free(r.stderr);347 if (r.exit_code != 0) return error.ProcessFailed;348 return r.stdout;349}350351352pub fn profileListJson(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {353 const r = try capture(gpa, io, machine, &.{ "nix", "profile", "list", "--json" });354 defer gpa.free(r.stderr);355 if (r.exit_code != 0) {356 gpa.free(r.stdout);357 return error.ProcessFailed;358 }359 return r.stdout;360}361362363364365pub fn systemPackagePaths(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {366 const r = try capture(gpa, io, machine, &.{ "nix-store", "-q", "--references", "/run/current-system/sw" });367 defer gpa.free(r.stderr);368 if (r.exit_code != 0) {369 gpa.free(r.stdout);370 return error.ProcessFailed;371 }372 return r.stdout;373}374375pub fn profileUpgrade(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {376 return stream(io, machine, gpa, &.{ "nix", "profile", "upgrade", ".*" });377}378379380381pub fn storeGc(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {382 return stream(io, machine, gpa, &.{ "sudo", "nix-collect-garbage" });383}384385pub fn storeVerify(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {386 return stream(io, machine, gpa, &.{ "nix", "store", "verify", "--all" });387}388389pub const SystemStatus = struct { size: []u8, age: []u8 };390391392393394395const STATUS_SCRIPT =396 \\sz=$(nix path-info -S /run/current-system 2>/dev/null | awk '{print $NF}'); mt=$(stat -c %Y /nix/var/nix/profiles/system 2>/dev/null); now=$(date +%s); echo "${sz:-0} $(( ${now:-0} - ${mt:-0} ))"397;398399pub fn systemStatus(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) SystemStatus {400 const unknown = SystemStatus{401 .size = gpa.dupe(u8, "") catch return .{ .size = "", .age = "" },402 .age = gpa.dupe(u8, "") catch return .{ .size = "", .age = "" },403 };404 const r = capture(gpa, io, machine, &.{ "sh", "-c", STATUS_SCRIPT }) catch return unknown;405 defer gpa.free(r.stdout);406 defer gpa.free(r.stderr);407 if (r.exit_code != 0) return unknown;408409 var it = std.mem.tokenizeAny(u8, r.stdout, " \t\n\r");410 const sz_str = it.next() orelse return unknown;411 const age_str = it.next() orelse return unknown;412 const bytes = std.fmt.parseInt(u64, sz_str, 10) catch 0;413 const age_secs = std.fmt.parseInt(u64, age_str, 10) catch 0;414415 gpa.free(unknown.size);416 gpa.free(unknown.age);417 return .{418 .size = humanSize(gpa, bytes) catch (gpa.dupe(u8, "") catch ""),419 .age = humanAgo(gpa, age_secs) catch (gpa.dupe(u8, "") catch ""),420 };421}422423424425426pub fn systemRunningState(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {427 const r = capture(gpa, io, machine, &.{ "systemctl", "is-system-running" }) catch428 return gpa.dupe(u8, "unknown");429 defer gpa.free(r.stderr);430 const state = std.mem.trim(u8, r.stdout, " \t\n\r");431 const owned = try gpa.dupe(u8, if (state.len > 0) state else "unknown");432 gpa.free(r.stdout);433 return owned;434}435436fn humanAgo(gpa: std.mem.Allocator, secs: u64) ![]u8 {437 if (secs < 60) return std.fmt.allocPrint(gpa, "{d}s ago", .{secs});438 if (secs < 3600) return std.fmt.allocPrint(gpa, "{d}m ago", .{secs / 60});439 if (secs < 86400) return std.fmt.allocPrint(gpa, "{d}h ago", .{secs / 3600});440 return std.fmt.allocPrint(gpa, "{d}d ago", .{secs / 86400});441}442443pub fn storeRepair(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {444 return stream(io, machine, gpa, &.{ "sudo", "nix", "store", "repair", "--all" });445}446447pub fn storeInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) !types.StoreInfo {448 449 450 451 452 453 454 const pi = try capture(gpa, io, machine, &.{ "sh", "-c", "timeout 90 nix path-info --all --json 2>/dev/null" });455 defer gpa.free(pi.stdout);456 defer gpa.free(pi.stderr);457458 const scan = scanStoreJson(pi.stdout);459 const total_owned = try humanSize(gpa, scan.total_bytes);460461 return .{462 .total_size = total_owned,463 .live_paths = scan.count,464 .reclaimable_paths = 0,465 };466}467468const StoreScan = struct { count: u64, total_bytes: u64 };469470471472473fn scanStoreJson(json_str: []const u8) StoreScan {474 const needle = "\"narSize\":";475 var count: u64 = 0;476 var total: u64 = 0;477 var i: usize = 0;478 while (std.mem.indexOfPos(u8, json_str, i, needle)) |pos| {479 var j = pos + needle.len;480 while (j < json_str.len and json_str[j] == ' ') : (j += 1) {}481 var val: u64 = 0;482 var any = false;483 while (j < json_str.len and json_str[j] >= '0' and json_str[j] <= '9') : (j += 1) {484 val = val * 10 + (json_str[j] - '0');485 any = true;486 }487 if (any) {488 count += 1;489 total += val;490 }491 i = j;492 }493 return .{ .count = count, .total_bytes = total };494}495496fn humanSize(gpa: std.mem.Allocator, bytes: u64) ![]u8 {497 const units = [_][]const u8{ "B", "KB", "MB", "GB", "TB" };498 var val: f64 = @floatFromInt(bytes);499 var unit: usize = 0;500 while (val >= 1024.0 and unit + 1 < units.len) : (unit += 1) val /= 1024.0;501 if (unit == 0) return std.fmt.allocPrint(gpa, "{d} {s}", .{ bytes, units[0] });502 return std.fmt.allocPrint(gpa, "{d:.1} {s}", .{ val, units[unit] });503}504505pub fn storePath(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, attr: []const u8) ![]u8 {506 507 508 const expr = try std.fmt.allocPrint(gpa, "nixpkgs#{s}.outPath", .{attr});509 defer gpa.free(expr);510 const r = try capture(gpa, io, machine, &.{ "nix", "eval", "--raw", expr });511 defer gpa.free(r.stderr);512 if (r.exit_code != 0) {513 gpa.free(r.stdout);514 return error.ProcessFailed;515 }516 return r.stdout;517}518519520521pub fn flakeUpdate(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, input: ?[]const u8) !u8 {522 if (input) |inp| {523 return stream(io, machine, gpa, &.{ "nix", "flake", "update", inp });524 }525 return stream(io, machine, gpa, &.{ "nix", "flake", "update" });526}527528pub fn flakeCheck(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {529 return stream(io, machine, gpa, &.{ "nix", "flake", "check" });530}531532pub fn flakeInit(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {533 return stream(io, machine, gpa, &.{ "nix", "flake", "init" });534}535536pub fn flakeLock(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {537 return stream(io, machine, gpa, &.{ "nix", "flake", "lock" });538}539540pub fn flakeClone(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, url: []const u8) !u8 {541 return stream(io, machine, gpa, &.{ "nix", "flake", "clone", url });542}543544pub fn flakeShow(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {545 const r = try capture(gpa, io, machine, &.{ "nix", "flake", "show", "--json" });546 defer gpa.free(r.stderr);547 if (r.exit_code != 0) {548 gpa.free(r.stdout);549 return error.ProcessFailed;550 }551 return r.stdout;552}553554555556pub fn nixBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, attr: ?[]const u8) !u8 {557 if (attr) |a| {558 const ref = try std.fmt.allocPrint(gpa, ".#{s}", .{a});559 defer gpa.free(ref);560 return stream(io, machine, gpa, &.{ "nix", "build", ref });561 }562 return stream(io, machine, gpa, &.{ "nix", "build" });563}564565pub fn nixRun(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {566 567 const is_url = std.mem.startsWith(u8, pkg, "http://") or std.mem.startsWith(u8, pkg, "https://") or std.mem.startsWith(u8, pkg, "github:");568 if (is_url) {569 return stream(io, machine, gpa, &.{ "nix", "run", pkg, "--no-write-lock-file" });570 }571 572 if (std.mem.indexOfScalar(u8, pkg, '#') != null) {573 return stream(io, machine, gpa, &.{ "nix", "run", pkg });574 }575 const ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{pkg});576 defer gpa.free(ref);577 return stream(io, machine, gpa, &.{ "nix", "run", ref });578}579580pub fn nixShell(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {581 const ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{pkg});582 defer gpa.free(ref);583 return stream(io, machine, gpa, &.{ "nix", "shell", ref });584}585586pub fn nixDevelop(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, run_cmd: ?[]const u8) !u8 {587 if (run_cmd) |cmd| {588 589 return stream(io, machine, gpa, &.{ "nix", "develop", "--command", "sh", "-c", cmd });590 }591 return stream(io, machine, gpa, &.{ "nix", "develop" });592}593594pub fn nixRepl(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {595 return stream(io, machine, gpa, &.{ "nix", "repl", "--expr", "import <nixpkgs> {}" });596}597598599600pub fn nixpkgsFmt(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, path: []const u8, check: bool) !u8 {601 if (check) {602 return stream(io, machine, gpa, &.{ "nixpkgs-fmt", "--check", path });603 }604 return stream(io, machine, gpa, &.{ "nixpkgs-fmt", path });605}606607608609pub fn systemInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {610 const r = try capture(gpa, io, machine, &.{"nixos-version"});611 defer gpa.free(r.stderr);612 if (r.exit_code != 0) {613 gpa.free(r.stdout);614 return error.ProcessFailed;615 }616 return r.stdout;617}618619pub fn kernelInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {620 const r = try capture(gpa, io, machine, &.{ "uname", "-r" });621 defer gpa.free(r.stderr);622 if (r.exit_code != 0) {623 gpa.free(r.stdout);624 return error.ProcessFailed;625 }626 return r.stdout;627}628629pub fn uptimeInfo(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {630 const r = try capture(gpa, io, machine, &.{"uptime"});631 defer gpa.free(r.stderr);632 if (r.exit_code != 0) {633 gpa.free(r.stdout);634 return error.ProcessFailed;635 }636 return r.stdout;637}638639640641pub fn bootEntries(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.BootEntry {642 const r = try capture(gpa, io, machine, &.{ "bootctl", "list" });643 defer gpa.free(r.stderr);644 defer gpa.free(r.stdout);645 if (r.exit_code != 0) return error.ProcessFailed;646 return parseBootEntries(gpa, r.stdout);647}648649fn parseBootEntries(gpa: std.mem.Allocator, output_str: []const u8) ![]types.BootEntry {650 var entries: std.ArrayList(types.BootEntry) = .empty;651 var lines = std.mem.splitScalar(u8, output_str, '\n');652 while (lines.next()) |line| {653 const trimmed = std.mem.trim(u8, line, " \t\r");654 if (std.mem.startsWith(u8, trimmed, "title:")) {655 const title = std.mem.trim(u8, trimmed[6..], " \t");656 const current = std.mem.indexOf(u8, title, "(current)") != null;657 try entries.append(gpa, .{658 .title = try gpa.dupe(u8, title),659 .current = current,660 });661 }662 }663 return entries.toOwnedSlice(gpa);664}665666667668pub fn pkgWhy(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {669 return stream(io, machine, gpa, &.{ "nix", "why-depends", ".#", pkg });670}671672pub fn pkgDeps(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {673 return stream(io, machine, gpa, &.{ "nix-store", "--query", "--requisites", pkg });674}675676pub fn pkgSize(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, pkg: []const u8) ![]u8 {677 678 679 680 const ref = try std.fmt.allocPrint(gpa, "nixpkgs#{s}", .{pkg});681 defer gpa.free(ref);682 const r = try capture(gpa, io, machine, &.{ "nix", "path-info", "-S", "-h", ref });683 defer gpa.free(r.stderr);684 defer gpa.free(r.stdout);685 if (r.exit_code != 0) return error.ProcessFailed;686687 const line = std.mem.trim(u8, r.stdout, " \t\n\r");688 var it = std.mem.tokenizeAny(u8, line, " \t");689 _ = it.next(); 690 const size = std.mem.trim(u8, it.rest(), " \t");691 if (size.len == 0) return error.ProcessFailed;692 return gpa.dupe(u8, size);693}694695pub fn pkgClosure(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, pkg: []const u8) !u8 {696 return stream(io, machine, gpa, &.{ "nix", "path-info", "-rS", pkg });697}698699700701pub fn nixHash(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, path: []const u8) ![]u8 {702 const r = try capture(gpa, io, machine, &.{ "nix", "hash", "path", path });703 defer gpa.free(r.stderr);704 if (r.exit_code != 0) {705 gpa.free(r.stdout);706 return error.ProcessFailed;707 }708 return r.stdout;709}710711pub fn nixFetch(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine, url: []const u8) ![]u8 {712 const r = try capture(gpa, io, machine, &.{ "nix-prefetch-url", url });713 defer gpa.free(r.stderr);714 if (r.exit_code != 0) {715 gpa.free(r.stdout);716 return error.ProcessFailed;717 }718 return r.stdout;719}720721722723pub fn editFlakeLock(_: std.mem.Allocator, _: []const u8, _: []const u8) !void {724 725 726}727728pub fn openEditor(io: std.Io, gpa: std.mem.Allocator, editor: []const u8, path: []const u8, line: ?u32) !u8 {729 if (line) |l| {730 const line_arg = try std.fmt.allocPrint(gpa, "+{d}", .{l});731 defer gpa.free(line_arg);732 return stream(io, &types.Machine{}, gpa, &.{ editor, path, line_arg });733 }734 return stream(io, &types.Machine{}, gpa, &.{ editor, path });735}736737pub fn diffClosures(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, a: u32, b: u32) !u8 {738 const a_str = try std.fmt.allocPrint(gpa, "/nix/var/nix/profiles/system-{d}-link", .{a});739 defer gpa.free(a_str);740 const b_str = try std.fmt.allocPrint(gpa, "/nix/var/nix/profiles/system-{d}-link", .{b});741 defer gpa.free(b_str);742 743 if (output.colorEnabled()) {744 return stream(io, machine, gpa, &.{ "nix", "store", "diff-closures", a_str, b_str });745 }746 747 748 const r = try capture(gpa, io, machine, &.{ "nix", "store", "diff-closures", a_str, b_str });749 defer gpa.free(r.stdout);750 defer gpa.free(r.stderr);751 const cleaned = try stripAnsi(gpa, r.stdout);752 defer gpa.free(cleaned);753 output.raw(cleaned);754 return r.exit_code;755}756757758759fn stripAnsi(gpa: std.mem.Allocator, input: []const u8) ![]u8 {760 var out: std.ArrayList(u8) = .empty;761 errdefer out.deinit(gpa);762 var i: usize = 0;763 while (i < input.len) {764 if (input[i] == 0x1b and i + 1 < input.len and input[i + 1] == '[') {765 i += 2;766 while (i < input.len and !(input[i] >= 0x40 and input[i] <= 0x7e)) : (i += 1) {}767 if (i < input.len) i += 1; 768 continue;769 }770 try out.append(gpa, input[i]);771 i += 1;772 }773 return out.toOwnedSlice(gpa);774}775776test "stripAnsi removes CSI color sequences" {777 const cleaned = try stripAnsi(std.testing.allocator, "kepr: \x1b[31;1m13.1 KiB\x1b[0m");778 defer std.testing.allocator.free(cleaned);779 try std.testing.expectEqualStrings("kepr: 13.1 KiB", cleaned);780}781782783784pub fn runDoctorChecks(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]types.DoctorCheck {785 var checks: std.ArrayList(types.DoctorCheck) = .empty;786787 788 {789 const r = try capture(gpa, io, machine, &.{ "systemctl", "is-active", "nix-daemon" });790 defer gpa.free(r.stdout);791 defer gpa.free(r.stderr);792 const active = std.mem.indexOf(u8, r.stdout, "active") != null;793 try checks.append(gpa, .{ .name = "nix daemon", .status = if (active) .ok else .fail });794 }795796 797 {798 const r = try capture(gpa, io, machine, &.{ "sudo", "nixos-rebuild", "dry-build" });799 defer gpa.free(r.stdout);800 defer gpa.free(r.stderr);801 try checks.append(gpa, .{ .name = "config syntax", .status = if (r.exit_code == 0) .ok else .fail });802 }803804 805 {806 const r = try capture(gpa, io, machine, &.{ "nix-channel", "--list" });807 defer gpa.free(r.stdout);808 defer gpa.free(r.stderr);809 const has_channel = r.exit_code == 0 and r.stdout.len > 0;810 try checks.append(gpa, .{ .name = "channel", .status = if (has_channel) .ok else .warn });811 }812813 return checks.toOwnedSlice(gpa);814}815816817818pub fn readNinaLog(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, last: u32) ![]u8 {819 const home = environ.get("HOME") orelse return error.FileNotFound;820 const path = try std.fmt.allocPrint(gpa, "{s}/.nina.log", .{home});821 defer gpa.free(path);822 const content = std.Io.Dir.readFileAlloc(.cwd(), io, path, gpa, .unlimited) catch return error.FileNotFound;823 if (last == 0) return content;824825 826 827 var lines: std.ArrayList([]const u8) = .empty;828 defer lines.deinit(gpa);829 var it = std.mem.splitScalar(u8, content, '\n');830 while (it.next()) |line| {831 if (std.mem.trim(u8, line, " \t\r").len == 0) continue;832 try lines.append(gpa, line);833 }834 if (lines.items.len <= last) return content;835836 var out: std.ArrayList(u8) = .empty;837 errdefer out.deinit(gpa);838 for (lines.items[lines.items.len - last ..]) |line| {839 try out.appendSlice(gpa, line);840 try out.append(gpa, '\n');841 }842 gpa.free(content);843 return out.toOwnedSlice(gpa);844}845846847848849850pub fn markApplyFailed(io: std.Io, environ: *const std.process.Environ.Map) void {851 const home = environ.get("HOME") orelse return;852 var buf: [512]u8 = undefined;853 const path = std.fmt.bufPrint(&buf, "{s}/.nina-apply-failed", .{home}) catch return;854 const file = std.Io.Dir.createFile(.cwd(), io, path, .{}) catch return;855 file.close(io);856}857858pub fn takeApplyFailed(io: std.Io, environ: *const std.process.Environ.Map) bool {859 const home = environ.get("HOME") orelse return false;860 var buf: [512]u8 = undefined;861 const path = std.fmt.bufPrint(&buf, "{s}/.nina-apply-failed", .{home}) catch return false;862 if (std.Io.Dir.openFile(.cwd(), io, path, .{})) |f| {863 f.close(io);864 std.Io.Dir.deleteFile(.cwd(), io, path) catch {};865 return true;866 } else |_| {867 return false;868 }869}870871pub fn appendNinaLog(io: std.Io, environ: *const std.process.Environ.Map, entry: []const u8) void {872 const home = environ.get("HOME") orelse return;873 var path_buf: [512]u8 = undefined;874 const path = std.fmt.bufPrint(&path_buf, "{s}/.nina.log", .{home}) catch return;875 const file = std.Io.Dir.openFile(.cwd(), io, path, .{ .mode = .read_write }) catch876 (std.Io.Dir.createFile(.cwd(), io, path, .{}) catch return);877 defer file.close(io);878 const size = file.length(io) catch 0;879 file.writePositionalAll(io, entry, size) catch {};880}881882883884885886pub fn fetchUrl(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 {887 const argv = [_][]const u8{888 "curl", "-s", "-L", "-w", "\nHTTP_STATUS:%{http_code}", "-m", "30", "--", url,889 };890 const result = std.process.run(gpa, io, .{ .argv = &argv }) catch return error.NetworkError;891 defer gpa.free(result.stderr);892893 const marker = "\nHTTP_STATUS:";894 const mark_idx = std.mem.lastIndexOf(u8, result.stdout, marker) orelse {895 gpa.free(result.stdout);896 return error.NetworkError;897 };898 const status_str = std.mem.trim(u8, result.stdout[mark_idx + marker.len ..], " \r\n");899 const status = std.fmt.parseInt(u16, status_str, 10) catch {900 gpa.free(result.stdout);901 return error.NetworkError;902 };903 if (status == 0) { gpa.free(result.stdout); return error.NetworkError; }904 if (status < 200 or status >= 300) { gpa.free(result.stdout); return error.HttpError; }905 const body = gpa.dupe(u8, result.stdout[0..mark_idx]) catch |e| {906 gpa.free(result.stdout);907 return e;908 };909 gpa.free(result.stdout);910 return body;911}912913914915pub fn downloadToTemp(gpa: std.mem.Allocator, io: std.Io, url: []const u8) ![]u8 {916 const tmp_path = try gpa.dupe(u8, "/tmp/nina-update.bin");917 errdefer gpa.free(tmp_path);918919 var child = std.process.spawn(io, .{920 .argv = &.{ "curl", "-fsSL", "-m", "300", "-o", tmp_path, "--", url },921 .stdin = .close,922 .stdout = .close,923 .stderr = .inherit,924 }) catch return error.NetworkError;925 const term = child.wait(io) catch return error.NetworkError;926 switch (term) {927 .exited => |c| if (c != 0) return error.NetworkError,928 else => return error.NetworkError,929 }930 return tmp_path;931}932933934pub fn selfExePath(io: std.Io, buf: *[std.Io.Dir.max_path_bytes]u8) ![]const u8 {935 const len = try std.process.executablePath(io, buf);936 return buf[0..len];937}938939940941942pub fn verifyAndInstall(gpa: std.mem.Allocator, io: std.Io, tmp_path: []const u8, expected_hex: []const u8, install_path: []const u8) !void {943 const bytes = std.Io.Dir.readFileAlloc(.cwd(), io, tmp_path, gpa, .unlimited) catch {944 errors.error_info.setMessage("downloaded file not found: {s}", .{tmp_path});945 return error.FileNotFound;946 };947 defer gpa.free(bytes);948949 var hasher = std.crypto.hash.Blake3.init(.{});950 hasher.update(bytes);951 var raw: [32]u8 = undefined;952 hasher.final(&raw);953 const hex = std.fmt.bytesToHex(raw, .lower);954 if (!std.mem.eql(u8, &hex, expected_hex)) return error.ChecksumMismatch;955956 var inst_tmp_buf: [std.Io.Dir.max_path_bytes + 8]u8 = undefined;957 const inst_tmp = std.fmt.bufPrint(&inst_tmp_buf, "{s}.new", .{install_path}) catch958 return error.NameTooLong;959960 {961 const f = std.Io.Dir.createFileAbsolute(io, inst_tmp, .{}) catch {962 errors.error_info.setMessage("cannot write to {s}", .{install_path});963 errors.error_info.setSuggestion("try: sudo nina update", .{});964 return error.AccessDenied;965 };966 defer f.close(io);967 f.writePositionalAll(io, bytes, 0) catch {968 errors.error_info.setMessage("cannot write to {s}", .{install_path});969 return error.AccessDenied;970 };971 try f.setPermissions(io, .fromMode(0o755));972 }973 errdefer std.Io.Dir.deleteFileAbsolute(io, inst_tmp) catch {};974975 if (comptime builtin.os.tag == .macos) {976 _ = captureLocal(gpa, io, &.{ "codesign", "-f", "-s", "-", inst_tmp }) catch {};977 }978979 std.Io.Dir.renameAbsolute(inst_tmp, install_path, io) catch |e| switch (e) {980 error.AccessDenied => {981 errors.error_info.setMessage("cannot replace {s}", .{install_path});982 errors.error_info.setSuggestion("try: sudo nina update", .{});983 return error.AccessDenied;984 },985 else => return e,986 };987}988989990991pub const NixFeatureStatus = struct {992 nix_command: bool = false,993 flakes: bool = false,994995 pub fn allEnabled(self: NixFeatureStatus) bool {996 return self.nix_command and self.flakes;997 }998};9991000fn scanConfFeatures(conf: []const u8, status: *NixFeatureStatus) void {1001 var lines = std.mem.splitScalar(u8, conf, '\n');1002 while (lines.next()) |line| {1003 const trimmed = std.mem.trim(u8, line, " \t");1004 if (!std.mem.startsWith(u8, trimmed, "experimental-features")) continue;1005 if (std.mem.indexOf(u8, trimmed, "nix-command") != null) status.nix_command = true;1006 if (std.mem.indexOf(u8, trimmed, "flakes") != null) status.flakes = true;1007 }1008}10091010pub fn checkNixFeatures(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) NixFeatureStatus {1011 var status = NixFeatureStatus{};10121013 if (std.Io.Dir.readFileAlloc(.cwd(), io, "/etc/nix/nix.conf", gpa, .unlimited) catch null) |conf| {1014 defer gpa.free(conf);1015 scanConfFeatures(conf, &status);1016 }10171018 if (environ.get("HOME")) |home| {1019 var buf: [512]u8 = undefined;1020 const path = std.fmt.bufPrint(&buf, "{s}/.config/nix/nix.conf", .{home}) catch return status;1021 if (std.Io.Dir.readFileAlloc(.cwd(), io, path, gpa, .unlimited) catch null) |conf| {1022 defer gpa.free(conf);1023 scanConfFeatures(conf, &status);1024 }1025 }10261027 return status;1028}10291030pub fn isNixOS(io: std.Io) bool {1031 const file = std.Io.Dir.openFile(.cwd(), io, "/etc/NIXOS", .{}) catch return false;1032 file.close(io);1033 return true;1034}1035103610371038pub fn enableNixFeatures(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) ![]const u8 {1039 const home = environ.get("HOME") orelse {1040 errors.error_info.setMessage("HOME not set", .{});1041 return error.ConfigNotFound;1042 };10431044 const dir_path = try std.fmt.allocPrint(gpa, "{s}/.config/nix", .{home});1045 defer gpa.free(dir_path);1046 const file_path = try std.fmt.allocPrint(gpa, "{s}/.config/nix/nix.conf", .{home});1047 errdefer gpa.free(file_path);10481049 _ = captureLocal(gpa, io, &.{ "mkdir", "-p", dir_path }) catch {};10501051 const line = "experimental-features = nix-command flakes\n";1052 const file = std.Io.Dir.openFile(.cwd(), io, file_path, .{ .mode = .read_write }) catch1053 (std.Io.Dir.createFile(.cwd(), io, file_path, .{}) catch {1054 errors.error_info.setMessage("could not write {s}", .{file_path});1055 return error.FileNotFound;1056 });1057 defer file.close(io);10581059 const size = file.length(io) catch 0;1060 file.writePositionalAll(io, line, size) catch {1061 errors.error_info.setMessage("could not write {s}", .{file_path});1062 return error.FileNotFound;1063 };10641065 return file_path;1066}10671068106910701071107210731074pub const NIXOS_FEATURES_LINE =1075 "nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];";1076107710781079pub fn nixosConfigEnablesFeatures(gpa: std.mem.Allocator, io: std.Io, path: []const u8) bool {1080 const conf = std.Io.Dir.readFileAlloc(.cwd(), io, path, gpa, .unlimited) catch return false;1081 defer gpa.free(conf);1082 return std.mem.indexOf(u8, conf, "experimental-features") != null and1083 std.mem.indexOf(u8, conf, "nix-command") != null and1084 std.mem.indexOf(u8, conf, "flakes") != null;1085}10861087fn readMaybeSudo(gpa: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 {1088 if (std.Io.Dir.readFileAlloc(.cwd(), io, path, gpa, .unlimited) catch null) |data| return data;1089 return captureLocal(gpa, io, &.{ "sudo", "cat", path });1090}109110921093109410951096pub fn appendFeaturesToNixosConfig(1097 gpa: std.mem.Allocator,1098 io: std.Io,1099 environ: *const std.process.Environ.Map,1100 path: []const u8,1101) !void {1102 const conf = readMaybeSudo(gpa, io, path) catch {1103 errors.error_info.setMessage("could not read {s}", .{path});1104 return error.ConfigNotFound;1105 };1106 defer gpa.free(conf);11071108 if (std.mem.indexOf(u8, conf, "experimental-features") != null) return; 11091110 const brace = std.mem.lastIndexOfScalar(u8, conf, '}') orelse {1111 errors.error_info.setMessage("no closing brace in {s} — add the line by hand", .{path});1112 return error.MalformedConfig;1113 };11141115 const home = environ.get("HOME") orelse {1116 errors.error_info.setMessage("HOME not set", .{});1117 return error.ConfigNotFound;1118 };1119 const tmp_path = try std.fmt.allocPrint(gpa, "{s}/.nina-configuration.nix", .{home});1120 defer gpa.free(tmp_path);11211122 var buf: std.ArrayList(u8) = .empty;1123 defer buf.deinit(gpa);1124 try buf.appendSlice(gpa, conf[0..brace]);1125 if (brace == 0 or conf[brace - 1] != '\n') try buf.append(gpa, '\n');1126 try buf.appendSlice(gpa, " # added by nina setup — flakes + the new nix CLI\n");1127 try buf.appendSlice(gpa, " ");1128 try buf.appendSlice(gpa, NIXOS_FEATURES_LINE);1129 try buf.append(gpa, '\n');1130 try buf.appendSlice(gpa, conf[brace..]);11311132 const tmp = std.Io.Dir.createFile(.cwd(), io, tmp_path, .{}) catch {1133 errors.error_info.setMessage("could not write {s}", .{tmp_path});1134 return error.FileNotFound;1135 };1136 tmp.writePositionalAll(io, buf.items, 0) catch {1137 tmp.close(io);1138 std.Io.Dir.deleteFile(.cwd(), io, tmp_path) catch {};1139 errors.error_info.setMessage("could not write {s}", .{tmp_path});1140 return error.FileNotFound;1141 };1142 tmp.close(io);11431144 1145 1146 1147 const code = streamLocal(io, &.{1148 "sudo", "sh", "-c", "cp -- \"$0\" \"$0.nina-bak\" && cp -- \"$1\" \"$0\"", path, tmp_path,1149 }) catch {1150 std.Io.Dir.deleteFile(.cwd(), io, tmp_path) catch {};1151 errors.error_info.setMessage("could not write {s}", .{path});1152 return error.ProcessFailed;1153 };1154 std.Io.Dir.deleteFile(.cwd(), io, tmp_path) catch {};1155 if (code != 0) {1156 errors.error_info.setMessage("sudo cp into {s} failed", .{path});1157 return error.ProcessFailed;1158 }1159}