e66b7c22892f

auto-run setup on first run when ~/.nina.conf not found

time
2026-06-08 17:37 UTC · 4 months ago
machine
koh2
agent
claude-sonnet-4-6
session
auto-run setup on first run
parent
b7b8e9e220c2
5 files VERSION+1 −1build.zig.zon+1 −1src/commands.zig+44 −0src/main.zig+32 −8src/output.zig+41 −0
VERSION+1 −1
@@ -1 +1 @@1-3.0.201+3.0.21

view file

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.0.19",12+    .version = "3.0.21",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 allows

view file

src/commands.zig+44 −0
@@ -1097,6 +1097,50 @@10971097     output.hello(ctx.cfg.machines);10981098 }10991099 1100+// Runs when ~/.nina.conf is absent on first invocation (interactive TTY only).1101+// Prompts for machine name, config path, and local flag, then writes the file.1102+pub fn firstRunWizard(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {1103+    const home = environ.get("HOME") orelse {1104+        errors.error_info.setSuggestion("set HOME environment variable", .{});1105+        return error.ConfigNotFound;1106+    };1107+1108+    output.firstRunBanner();1109+    output.firstRunNoConfig();1110+1111+    var name_buf: [256]u8 = undefined;1112+    var path_buf: [256]u8 = undefined;1113+    var local_buf: [256]u8 = undefined;1114+1115+    const machine_name = output.firstRunPrompt("machine name", "my-machine", &name_buf);1116+    const config_path = output.firstRunPrompt("config path", "/etc/nixos", &path_buf);1117+    const local_str = output.firstRunPrompt("local", "true", &local_buf);1118+1119+    const conf_path = try std.fmt.allocPrint(gpa, "{s}/.nina.conf", .{home});1120+    defer gpa.free(conf_path);1121+1122+    var content_buf: [1024]u8 = undefined;1123+    const content = try std.fmt.bufPrint(&content_buf,1124+        \\[machine]1125+        \\name = {s}1126+        \\config = {s}1127+        \\local = {s}1128+        \\1129+    , .{ machine_name, config_path, local_str });1130+1131+    const file = std.Io.Dir.createFile(.cwd(), io, conf_path, .{}) catch {1132+        errors.error_info.setMessage("could not write {s}", .{conf_path});1133+        return error.Internal;1134+    };1135+    defer file.close(io);1136+    file.writePositionalAll(io, content, 0) catch {1137+        errors.error_info.setMessage("could not write {s}", .{conf_path});1138+        return error.Internal;1139+    };1140+1141+    output.firstRunWritten(conf_path);1142+}1143+11001144 pub fn setup(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !void {11011145     output.setupCheck();11021146     const status = exec.checkNixFeatures(gpa, io, environ);

view file

src/main.zig+32 −8
@@ -98,14 +98,38 @@9898         return;9999     }100100 101-    // Load config and resolve machine102-    const cfg = config.load(arena, io, startup.environ_map) catch |err| {103-        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);104-        const detail = errors.error_info.getDetail();105-        const suggestion = errors.error_info.getSuggestion();106-        output.printError(msg, detail, suggestion);107-        output.flush();108-        std.process.exit(1);101+    // Load config and resolve machine. On first run with no config file, offer102+    // the setup wizard when stdout is a TTY; skip silently in piped/scripted mode.103+    const cfg = cfg_blk: {104+        if (config.load(arena, io, startup.environ_map)) |c| {105+            break :cfg_blk c;106+        } else |err| {107+            if (err == error.ConfigNotFound and (std.Io.File.stdout().isTty(io) catch false)) {108+                commands.firstRunWizard(ca, io, startup.environ_map) catch |wizard_err| {109+                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(wizard_err);110+                    const detail = errors.error_info.getDetail();111+                    const suggestion = errors.error_info.getSuggestion();112+                    output.printError(msg, detail, suggestion);113+                    output.flush();114+                    std.process.exit(1);115+                };116+                errors.error_info.reset();117+                break :cfg_blk config.load(arena, io, startup.environ_map) catch |err2| {118+                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(err2);119+                    const detail = errors.error_info.getDetail();120+                    const suggestion = errors.error_info.getSuggestion();121+                    output.printError(msg, detail, suggestion);122+                    output.flush();123+                    std.process.exit(1);124+                };125+            }126+            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);127+            const detail = errors.error_info.getDetail();128+            const suggestion = errors.error_info.getSuggestion();129+            output.printError(msg, detail, suggestion);130+            output.flush();131+            std.process.exit(1);132+        }109133     };110134 111135     output.setTeach(cfg.teach);

view file

src/output.zig+41 −0
@@ -942,3 +942,44 @@942942 pub fn flakeShowEntry(kind: []const u8, name: []const u8, desc: []const u8) void {943943     p("   {s:<12}  {s:<24}  {s}\n", .{ kind, name, desc });944944 }945+946+// --- First-run wizard ---947+948+pub fn firstRunBanner() void {949+    p("{s}{s}nina \u{2014} first run\n\n", .{ KAO_FACE, hdr() });950+}951+952+pub fn firstRunNoConfig() void {953+    p("   no config found. let me get set up.\n\n", .{});954+}955+956+// Print a prompt line and read the user's answer. Returns the default if the957+// user presses Enter without typing anything. buf must be at least 256 bytes.958+// Returns a slice into buf (or default if empty input).959+pub fn firstRunPrompt(label: []const u8, default: []const u8, buf: []u8) []const u8 {960+    p("   {s:<14} {s}> {s}", .{ label, c(DIM), c(RESET) });961+    flush();962+    var i: usize = 0;963+    while (i < buf.len - 1) {964+        const n = std.posix.read(std.posix.STDIN_FILENO, buf[i .. i + 1]) catch break;965+        if (n == 0) break;966+        if (buf[i] == '\n') break;967+        i += 1;968+    }969+    const input = std.mem.trim(u8, buf[0..i], " \t\r");970+    if (input.len == 0) {971+        // Echo the default so the output shows what was chosen972+        p("{s}{s}{s}\n", .{ c(DIM), default, c(RESET) });973+        flush();974+        return default;975+    }976+    return input;977+}978+979+pub fn firstRunWritten(path: []const u8) void {980+    p("\n   {s}written to {s}{s}\n\n", .{ c(DIM), path, c(RESET) });981+}982+983+pub fn firstRunSkipped() void {984+    p("\n   {s}skipping setup in non-interactive mode{s}\n", .{ c(DIM), c(RESET) });985+}

view file