74510ec9f7dd

config: use XDG_CONFIG_HOME (~/.config/nina/config) with auto-migration — configPath resolves $XDG_CONFIG_HOME/nina/config then ~/.config/nina/config; load() migrates legacy ~/.nina.conf (copy+delete, dim note) idempotently; firstRunWizard+setup write to XDG path and mkdir -p the parent; exec.ensureDir helper added — build+tests green, migration/XDG/idempotency verified on temp HOME

time
2026-06-10 01:06 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina community improvements
parent
d55887bd29e5
4 files src/commands.zig+15 −19src/config.zig+56 −2src/exec.zig+6 −0src/output.zig+5 −0
src/commands.zig+15 −19
@@ -1101,11 +1101,6 @@11011101 // Runs when ~/.nina.conf is absent on first invocation (interactive TTY only).11021102 // Prompts for machine name, config path, and local flag, then writes the file.11031103 pub fn firstRunWizard(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1104-    const home = environ.get("HOME") orelse {1105-        errors.error_info.setSuggestion("set HOME environment variable", .{});1106-        return error.ConfigNotFound;1107-    };1108-11091104     output.firstRunBanner();11101105     output.firstRunNoConfig();11111106 @@ -1117,8 +1112,9 @@11171112     const config_path = output.firstRunPrompt("config path", "/etc/nixos", &path_buf);11181113     const local_str = output.firstRunPrompt("local", "true", &local_buf);11191114 1120-    const conf_path = try std.fmt.allocPrint(gpa, "{s}/.nina.conf", .{home});1115+    const conf_path = try config.configPath(gpa, environ);11211116     defer gpa.free(conf_path);1117+    if (std.fs.path.dirname(conf_path)) |dir| exec.ensureDir(gpa, io, dir);11221118 11231119     var content_buf: [1024]u8 = undefined;11241120     const content = try std.fmt.bufPrint(&content_buf,@@ -1143,19 +1139,19 @@11431139 }11441140 11451141 pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1146-    // Create ~/.nina.conf first if it doesn't exist. This makes `nina setup`1147-    // a complete onboarding command — not just a nix-features check.1148-    if (environ.get("HOME")) |home| {1149-        var path_buf: [512]u8 = undefined;1150-        if (std.fmt.bufPrint(&path_buf, "{s}/.nina.conf", .{home})) |conf_path| {1151-            if (std.Io.Dir.openFile(.cwd(), io, conf_path, .{})) |f| {1152-                f.close(io);1153-            } else |_| {1154-                try firstRunWizard(gpa, io, environ);1155-                errors.error_info.reset();1156-            }1157-        } else |_| {}1158-    }1142+    // Create the config first if it doesn't exist. This makes `nina setup`1143+    // a complete onboarding command — not just a nix-features check. Any legacy1144+    // ~/.nina.conf is migrated to the XDG path before we decide to run the wizard.1145+    config.migrateLegacyConfig(gpa, io, environ);1146+    if (config.configPath(gpa, environ)) |conf_path| {1147+        defer gpa.free(conf_path);1148+        if (std.Io.Dir.openFile(.cwd(), io, conf_path, .{})) |f| {1149+            f.close(io);1150+        } else |_| {1151+            try firstRunWizard(gpa, io, environ);1152+            errors.error_info.reset();1153+        }1154+    } else |_| {}11591155 11601156     output.setupCheck();11611157     const status = exec.checkNixFeatures(gpa, io, environ);

view file

src/config.zig+56 −2
@@ -1,14 +1,68 @@11 const std = @import("std");22 const types = @import("types.zig");33 const errors = @import("errors.zig");4+const exec = @import("exec.zig");5+const output = @import("output.zig");46 5-pub fn load(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !types.NinaConfig {7+// Resolve the config file path: $XDG_CONFIG_HOME/nina/config, falling back to8+// ~/.config/nina/config. nina historically stored ~/.nina.conf; that legacy9+// location is auto-migrated to this path on load (see migrateLegacyConfig).10+pub fn configPath(arena: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 {11+    if (environ.get("XDG_CONFIG_HOME")) |xdg| {12+        if (xdg.len > 0) return std.fs.path.join(arena, &.{ xdg, "nina", "config" });13+    }614     const home = environ.get("HOME") orelse {715         errors.error_info.setSuggestion("set HOME environment variable", .{});816         return error.ConfigNotFound;917     };18+    return std.fs.path.join(arena, &.{ home, ".config", "nina", "config" });19+}1020 11-    const path = try std.fmt.allocPrint(arena, "{s}/.nina.conf", .{home});21+fn legacyPath(arena: std.mem.Allocator, environ: *const std.process.Environ.Map) ?[]const u8 {22+    const home = environ.get("HOME") orelse return null;23+    return std.fmt.allocPrint(arena, "{s}/.nina.conf", .{home}) catch null;24+}25+26+fn fileExists(io: std.Io, path: []const u8) bool {27+    const f = std.Io.Dir.openFile(.cwd(), io, path, .{}) catch return false;28+    f.close(io);29+    return true;30+}31+32+// One-time migration of the legacy ~/.nina.conf to the XDG path. Runs only when33+// the old file exists and the new one does not, so it's idempotent and a no-op34+// for fresh installs. Best-effort: any failure leaves the legacy file in place35+// so the next load still finds it.36+pub fn migrateLegacyConfig(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) void {37+    const new_path = configPath(arena, environ) catch return;38+    if (fileExists(io, new_path)) return;39+    const legacy = legacyPath(arena, environ) orelse return;40+    const content = std.Io.Dir.readFileAlloc(.cwd(), io, legacy, arena, .unlimited) catch return;41+42+    if (std.fs.path.dirname(new_path)) |dir| exec.ensureDir(arena, io, dir);43+    const f = std.Io.Dir.createFile(.cwd(), io, new_path, .{}) catch return;44+    f.writePositionalAll(io, content, 0) catch {45+        f.close(io);46+        return;47+    };48+    f.close(io);49+    std.Io.Dir.deleteFile(.cwd(), io, legacy) catch {};50+    output.configMigrated(homeShort(arena, new_path, environ));51+}52+53+// Collapse a leading $HOME to ~ for display only. Returns the input unchanged on54+// any mismatch or allocation failure.55+fn homeShort(arena: std.mem.Allocator, path: []const u8, environ: *const std.process.Environ.Map) []const u8 {56+    const home = environ.get("HOME") orelse return path;57+    if (home.len == 0 or !std.mem.startsWith(u8, path, home)) return path;58+    return std.fmt.allocPrint(arena, "~{s}", .{path[home.len..]}) catch path;59+}60+61+pub fn load(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !types.NinaConfig {62+    migrateLegacyConfig(arena, io, environ);63+64+    const path = try configPath(arena, environ);65+    const home = environ.get("HOME") orelse "";1266 1367     const content = std.Io.Dir.readFileAlloc(.cwd(), io, path, arena, .unlimited) catch {1468         errors.error_info.setSuggestion("run nina to set up", .{});

view file

src/exec.zig+6 −0
@@ -136,6 +136,12 @@136136     return result.stdout;137137 }138138 139+// Create a directory and its parents, best-effort. Shelling out to `mkdir -p`140+// matches how the rest of exec.zig provisions config locations (~/.config/nix).141+pub fn ensureDir(gpa: std.mem.Allocator, io: std.Io, dir: []const u8) void {142+    _ = captureLocal(gpa, io, &.{ "mkdir", "-p", dir }) catch {};143+}144+139145 fn buildArgv(gpa: std.mem.Allocator, machine: *const types.Machine, cmd: []const []const u8) ![]const []const u8 {140146     if (machine.local) return cmd;141147 

view file

src/output.zig+5 −0
@@ -133,6 +133,11 @@133133     p("{s}{s}\n", .{ if (color_enabled) WARN_C else WARN_P, message });134134 }135135 136+// One-time dim note when ~/.nina.conf is migrated to the XDG config path.137+pub fn configMigrated(path: []const u8) void {138+    p("{s}{s}config migrated to {s}{s}\n", .{ step(), c(DIM), path, c(RESET) });139+}140+136141 pub fn printTeach(cmd: []const u8) void {137142     if (teach_enabled) p("{s}{s}{s}\n", .{ teach_pfx(), cmd, c(RESET) });138143 }

view file