Skip to content

Commit e4fcc05

Browse files
committed
feat: add home-scoped config loading
1 parent 850d2f2 commit e4fcc05

4 files changed

Lines changed: 90 additions & 4 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,13 @@ This keeps the architecture modular, simpler to reason about, and easier to evol
6666
- `nulltickets + nullboiler + nullclaw`: full multi-agent orchestration with durable task source.
6767

6868
See additional integration docs in [`docs/`](./docs).
69+
70+
## Config Location
71+
72+
- Default config path: `~/.nullboiler/config.json`
73+
- Override instance home with `NULLBOILER_HOME=/path/to/dir`
74+
- Override config file directly with `--config /path/to/config.json`
75+
76+
When `NULLBOILER_HOME` is set, `nullboiler` reads `config.json` from that directory and
77+
resolves relative paths like `db`, `strategies_dir`, `tracker.workflows_dir`, and
78+
`tracker.workspace.root` relative to that config file.

src/config.zig

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
const std = @import("std");
2+
const builtin = @import("builtin");
3+
4+
pub const home_env_var = "NULLBOILER_HOME";
5+
pub const home_dir_name = ".nullboiler";
26

37
pub const WorkerConfig = struct {
48
id: []const u8,
@@ -78,6 +82,27 @@ pub const Config = struct {
7882
tracker: ?TrackerConfig = null,
7983
};
8084

85+
pub fn resolveConfigPath(allocator: std.mem.Allocator, override_path: ?[]const u8) ![]const u8 {
86+
if (override_path) |path| return allocator.dupe(u8, path);
87+
88+
const home_dir = try resolveHomeDir(allocator);
89+
defer allocator.free(home_dir);
90+
return std.fs.path.join(allocator, &.{ home_dir, "config.json" });
91+
}
92+
93+
pub fn resolveHomeDir(allocator: std.mem.Allocator) ![]const u8 {
94+
if (std.process.getEnvVarOwned(allocator, home_env_var)) |env_home| {
95+
return env_home;
96+
} else |err| switch (err) {
97+
error.EnvironmentVariableNotFound => {},
98+
else => return err,
99+
}
100+
101+
const home = try getHomeDirOwned(allocator);
102+
defer allocator.free(home);
103+
return std.fs.path.join(allocator, &.{ home, home_dir_name });
104+
}
105+
81106
/// Load configuration from a JSON file. If the file does not exist,
82107
/// return a default Config.
83108
pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) !Config {
@@ -116,6 +141,18 @@ fn resolveRelativePath(allocator: std.mem.Allocator, config_path: []const u8, va
116141
return std.fs.path.resolve(allocator, &.{ base_dir, value });
117142
}
118143

144+
fn getHomeDirOwned(allocator: std.mem.Allocator) ![]u8 {
145+
return std.process.getEnvVarOwned(allocator, "HOME") catch |err| switch (err) {
146+
error.EnvironmentVariableNotFound => {
147+
if (builtin.os.tag == .windows) {
148+
return std.process.getEnvVarOwned(allocator, "USERPROFILE") catch error.HomeNotSet;
149+
}
150+
return error.HomeNotSet;
151+
},
152+
else => return err,
153+
};
154+
}
155+
119156
// ── Tests ──────────────────────────────────────────────────────────────
120157

121158
test "default config" {
@@ -215,7 +252,7 @@ test "SubprocessDefaults has base_port and health_check_retries" {
215252
try std.testing.expectEqualStrings("Continue working on this task. Your previous context is preserved.", sd.continuation_prompt);
216253
}
217254

218-
test "resolveRelativePaths anchors tracker compat and advanced paths to config directory" {
255+
test "resolveRelativePaths anchors tracker paths to config directory" {
219256
var tmp = std.testing.tmpDir(.{});
220257
defer tmp.cleanup();
221258

src/from_json.zig

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const std = @import("std");
22
const builtin = @import("builtin");
3+
const config_mod = @import("config.zig");
34

45
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
56
if (args.len == 0) {
@@ -25,7 +26,11 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
2526
const port = getU16(obj, "port") orelse 8080;
2627
const api_token = getString(obj, "api_token");
2728
const db_path = getString(obj, "db_path") orelse "nullboiler.db";
28-
const home = getString(obj, "home") orelse ".";
29+
const home = resolveHome(allocator, getString(obj, "home")) catch |err| {
30+
std.debug.print("error: failed to resolve home: {s}\n", .{@errorName(err)});
31+
std.process.exit(1);
32+
};
33+
defer allocator.free(home);
2934
const tracker_enabled = getBoolish(obj, "tracker_enabled");
3035
const tracker_url = getString(obj, "tracker_url");
3136
const tracker_api_token = getString(obj, "tracker_api_token");
@@ -230,6 +235,11 @@ fn sanitizeFileComponent(allocator: std.mem.Allocator, value: []const u8) ![]con
230235
return out;
231236
}
232237

238+
fn resolveHome(allocator: std.mem.Allocator, json_home: ?[]const u8) ![]const u8 {
239+
if (json_home) |home| return allocator.dupe(u8, home);
240+
return config_mod.resolveHomeDir(allocator);
241+
}
242+
233243
test "run writes tracker config and workflow with advanced settings" {
234244
var tmp = std.testing.tmpDir(.{});
235245
defer tmp.cleanup();

src/main.zig

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pub fn main() !void {
5959
var port_override: ?u16 = null;
6060
var db_override: ?[:0]const u8 = null;
6161
var token_override: ?[]const u8 = null;
62-
var config_path: []const u8 = "config.json";
62+
var config_path_override: ?[]const u8 = null;
6363

6464
var i: usize = 0;
6565
while (i < all_args.len) : (i += 1) {
@@ -90,7 +90,7 @@ pub fn main() !void {
9090
} else if (std.mem.eql(u8, arg, "--config")) {
9191
i += 1;
9292
if (i < all_args.len) {
93-
config_path = all_args[i];
93+
config_path_override = all_args[i];
9494
}
9595
} else if (std.mem.eql(u8, arg, "--version")) {
9696
std.debug.print("nullboiler v{s}\n", .{version});
@@ -101,6 +101,10 @@ pub fn main() !void {
101101
// Load configuration
102102
var cfg_arena = std.heap.ArenaAllocator.init(allocator);
103103
defer cfg_arena.deinit();
104+
const config_path = config.resolveConfigPath(cfg_arena.allocator(), config_path_override) catch |err| {
105+
std.debug.print("failed to resolve config path: {}\n", .{err});
106+
return;
107+
};
104108
var cfg = config.loadFromFile(cfg_arena.allocator(), config_path) catch |err| {
105109
std.debug.print("failed to load config from {s}: {}\n", .{ config_path, err });
106110
return;
@@ -134,6 +138,11 @@ pub fn main() !void {
134138
std.debug.print("API auth: disabled\n", .{});
135139
}
136140

141+
ensureParentDirForFile(db_path) catch |err| {
142+
std.debug.print("failed to create database directory for {s}: {}\n", .{ db_path, err });
143+
return;
144+
};
145+
137146
var store = try Store.init(allocator, db_path);
138147
defer store.deinit();
139148
var metrics = metrics_mod.Metrics{};
@@ -396,6 +405,26 @@ pub fn main() !void {
396405
}
397406
}
398407

408+
fn ensureParentDirForFile(path: []const u8) !void {
409+
if (path.len == 0 or std.mem.eql(u8, path, ":memory:") or std.mem.startsWith(u8, path, "file:")) return;
410+
411+
const parent = std.fs.path.dirname(path) orelse return;
412+
if (parent.len == 0) return;
413+
414+
if (std.fs.path.isAbsolute(parent)) {
415+
std.fs.makeDirAbsolute(parent) catch |err| switch (err) {
416+
error.PathAlreadyExists => {},
417+
else => return err,
418+
};
419+
return;
420+
}
421+
422+
std.fs.cwd().makePath(parent) catch |err| switch (err) {
423+
error.PathAlreadyExists => {},
424+
else => return err,
425+
};
426+
}
427+
399428
fn serializeTagsJson(allocator: std.mem.Allocator, tags: []const []const u8) ![]const u8 {
400429
return std.json.Stringify.valueAlloc(allocator, tags, .{});
401430
}

0 commit comments

Comments
 (0)