From d10eda8d4beeb5ae0e18508718d6af849c5826c8 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Tue, 19 May 2026 15:35:59 +0800 Subject: [PATCH 1/9] feat(instances): import local standalone nullclaw homes --- src/api/components.zig | 58 +- src/api/instance_runtime.zig | 85 ++- src/api/instances.zig | 690 +++++++++++++++++- src/integration_tests.zig | 71 ++ ui/src/lib/api/client.ts | 18 +- .../lib/components/AddExistingDialog.svelte | 279 +++++++ ui/src/lib/components/ComponentCard.svelte | 29 +- ui/src/routes/install/+page.svelte | 108 ++- 8 files changed, 1278 insertions(+), 60 deletions(-) create mode 100644 ui/src/lib/components/AddExistingDialog.svelte diff --git a/src/api/components.zig b/src/api/components.zig index 7998c86..ff66805 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -62,9 +62,9 @@ fn buildListJson(allocator: std.mem.Allocator, s: *state_mod.State) ![]const u8 // Count managed instances from state const instance_count = countInstancesFromState(s, comp.name); - // standalone = has dot-dir config but not yet imported into nullhub + // standalone is a lightweight hint that the default dot-dir install exists. const has_dot_dir = hasStandaloneInstall(allocator, comp.name); - const standalone = has_dot_dir and instance_count == 0; + const standalone = has_dot_dir; const installed = has_dot_dir or instance_count > 0; try buf.print( @@ -233,6 +233,60 @@ test "handleList returns valid JSON with all known components" { try std.testing.expect(std.mem.indexOf(u8, json, "\"instance_count\"") != null); } +test "handleList keeps standalone hint when default install is already managed" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + try s.addInstance("nullclaw", "default", .{ .version = "1.0.0" }); + + const home_dir = try fixture.path(allocator, "home"); + defer allocator.free(home_dir); + try std_compat.fs.makeDirAbsolute(home_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const dot_dir = try std.fs.path.join(allocator, &.{ home_dir, ".nullclaw" }); + defer allocator.free(dot_dir); + try std_compat.fs.makeDirAbsolute(dot_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const config_path = try std.fs.path.join(allocator, &.{ dot_dir, "config.json" }); + defer allocator.free(config_path); + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{}\n"); + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer if (builtin.os.tag != .windows) { + if (previous_home) |value| { + _ = std.c.setenv("HOME", value.ptr, 1); + } else { + _ = std.c.unsetenv("HOME"); + } + }; + if (std.c.setenv("HOME", home_dir.ptr, 1) != 0) return error.Unexpected; + + const json = try handleList(allocator, &s); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\"",) == null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\",\"description\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\",\"description\":\"Autonomous AI agent runtime\",\"repo\":\"nullclaw/nullclaw\",\"alpha\":false,\"installable\":true,\"installed\":true,\"standalone\":true,\"instance_count\":1") != null); +} + test "handleManifest returns null for non-cached manifest" { const allocator = std.testing.allocator; diff --git a/src/api/instance_runtime.zig b/src/api/instance_runtime.zig index d33d033..3d456c9 100644 --- a/src/api/instance_runtime.zig +++ b/src/api/instance_runtime.zig @@ -1,10 +1,12 @@ const std = @import("std"); +const builtin = @import("builtin"); const std_compat = @import("compat"); const state_mod = @import("../core/state.zig"); const manager_mod = @import("../supervisor/manager.zig"); const paths_mod = @import("../core/paths.zig"); const health_mod = @import("../supervisor/health.zig"); const registry = @import("../installer/registry.zig"); +const test_helpers = @import("../test_helpers.zig"); pub const Snapshot = struct { status: manager_mod.Status, @@ -114,18 +116,12 @@ fn isImportedStandalone( const inst_dir = paths.instanceDir(allocator, component, name) catch return false; defer allocator.free(inst_dir); - const real_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch return false; - defer allocator.free(real_dir); - - const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch - std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return false; - defer allocator.free(home); - const standalone_root = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return false; - defer allocator.free(standalone_root); - const real_standalone_root = std_compat.fs.realpathAlloc(allocator, standalone_root) catch return false; - defer allocator.free(real_standalone_root); - - return std.mem.eql(u8, real_dir, real_standalone_root); + if (std_compat.fs.realpathAlloc(allocator, inst_dir)) |real_dir| { + defer allocator.free(real_dir); + return !std.mem.eql(u8, real_dir, inst_dir); + } else |_| { + return false; + } } fn standalonePortConfigKey(component: []const u8) ?[]const u8 { @@ -257,3 +253,68 @@ test "readPortFromConfig accepts string ports" { defer allocator.free(normalized); try std.testing.expectEqualStrings("127.0.0.1", normalized); } + +test "resolve treats custom-path imported standalone as running when health passes" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + + const HealthServerCtx = struct { + server: *std_compat.net.Server, + + fn run(ctx: @This()) void { + var conn = ctx.server.accept() catch return; + defer conn.stream.close(); + + var buf: [1024]u8 = undefined; + _ = conn.stream.read(&buf) catch return; + conn.stream.writeAll( + "HTTP/1.1 200 OK\r\n" ++ + "Content-Type: application/json\r\n" ++ + "Content-Length: 15\r\n" ++ + "Connection: close\r\n\r\n" ++ + "{\"status\":\"ok\"}", + ) catch return; + } + }; + + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const source_dir = try fixture.path(allocator, "custom-nullclaw-home"); + defer allocator.free(source_dir); + try std_compat.fs.makeDirAbsolute(source_dir); + + const source_config_path = try std.fs.path.join(allocator, &.{ source_dir, "config.json" }); + defer allocator.free(source_config_path); + const source_config = try std_compat.fs.createFileAbsolute(source_config_path, .{ .truncate = true }); + defer source_config.close(); + try source_config.writeAll("{\"gateway\":{\"port\":43129},\"host\":\"127.0.0.1\"}"); + + const inst_parent = try std.fs.path.join(allocator, &.{ fixture.paths.root, "instances", "nullclaw" }); + defer allocator.free(inst_parent); + try std_compat.fs.makeDirAbsolute(inst_parent); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullclaw", "imported"); + defer allocator.free(inst_dir); + try std_compat.fs.symLinkAbsolute(source_dir, inst_dir, .{ .is_directory = true }); + + const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 43129); + var server = try addr.listen(.{}); + defer server.deinit(); + const thread = try std.Thread.spawn(.{}, HealthServerCtx.run, .{.{ .server = &server }}); + defer thread.join(); + + var manager = manager_mod.Manager.init(allocator, fixture.paths); + defer manager.deinit(); + + const snapshot = resolve(allocator, fixture.paths, &manager, "nullclaw", "imported", .{ + .version = "dev-local", + .auto_start = false, + .launch_mode = "gateway", + .verbose = false, + }); + + try std.testing.expectEqual(manager_mod.Status.running, snapshot.status); + try std.testing.expectEqual(@as(u16, 43129), snapshot.port); +} diff --git a/src/api/instances.zig b/src/api/instances.zig index 1b61e59..709f44c 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -3912,13 +3912,219 @@ fn resolveImportBinaryVersion(allocator: std.mem.Allocator, paths: paths_mod.Pat return downloadLatestBinaryVersion(allocator, paths, component); } +const ImportRequest = struct { + path: []const u8 = "", + name: []const u8 = "", +}; + +const ParsedImportConfig = struct { + instance_name: ?[]const u8 = null, +}; + +fn duplicateImportRequest(parsed: ImportRequest, allocator: std.mem.Allocator) !ImportRequest { + return .{ + .path = try allocator.dupe(u8, parsed.path), + .name = try allocator.dupe(u8, parsed.name), + }; +} + +fn deinitImportRequest(allocator: std.mem.Allocator, req: ImportRequest) void { + allocator.free(req.path); + allocator.free(req.name); +} + +fn loadImportRequest(allocator: std.mem.Allocator, body: []const u8) !ImportRequest { + if (std.mem.trim(u8, body, &std.ascii.whitespace).len == 0) { + return .{ + .path = try allocator.dupe(u8, ""), + .name = try allocator.dupe(u8, ""), + }; + } + + const parsed = try std.json.parseFromSlice(ImportRequest, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + return duplicateImportRequest(parsed.value, allocator); +} + +fn resolveDefaultImportSourceDir(allocator: std.mem.Allocator, home: []const u8, component: []const u8) ![]u8 { + return std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }); +} + +fn resolveImportSourceDir(allocator: std.mem.Allocator, home: []const u8, component: []const u8, req: ImportRequest) ![]u8 { + if (req.path.len > 0) return allocator.dupe(u8, req.path); + return resolveDefaultImportSourceDir(allocator, home, component); +} + +fn validateImportSourceDir(allocator: std.mem.Allocator, source_dir: []const u8) ?[]const u8 { + std_compat.fs.accessAbsolute(source_dir, .{}) catch return "{\"error\":\"path does not exist\"}"; + + const config_path = std.fs.path.join(allocator, &.{ source_dir, "config.json" }) catch return "{\"error\":\"config.json not found at path\"}"; + defer allocator.free(config_path); + + std_compat.fs.accessAbsolute(config_path, .{}) catch return "{\"error\":\"config.json not found at path\"}"; + return null; +} + +fn readImportConfig(allocator: std.mem.Allocator, source_dir: []const u8) !ParsedImportConfig { + const config_path = try std.fs.path.join(allocator, &.{ source_dir, "config.json" }); + defer allocator.free(config_path); + + const bytes = try std_compat.fs.cwd().readFileAlloc(allocator, config_path, 1024 * 1024); + defer allocator.free(bytes); + + const parsed = try std.json.parseFromSlice(ParsedImportConfig, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + return .{ + .instance_name = if (parsed.value.instance_name) |name| + try allocator.dupe(u8, name) + else + null, + }; +} + +fn deinitParsedImportConfig(allocator: std.mem.Allocator, cfg: ParsedImportConfig) void { + if (cfg.instance_name) |name| allocator.free(name); +} + +fn isFilesystemSafeImportName(name: []const u8) bool { + if (name.len == 0) return false; + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + if (std.mem.indexOfScalar(u8, name, 0) != null) return false; + if (std.mem.indexOfScalar(u8, name, '/') != null) return false; + if (std.mem.indexOfScalar(u8, name, '\\') != null) return false; + if (std.mem.indexOf(u8, name, "..") != null) return false; + return true; +} + +fn nextLocalImportName(allocator: std.mem.Allocator, s: *state_mod.State, component: []const u8) ![]u8 { + const names = try s.instanceNames(component); + defer if (names) |owned_names| s.allocator.free(owned_names); + + var next_number: usize = 1; + if (names) |owned_names| { + for (owned_names) |existing_name| { + if (!std.mem.startsWith(u8, existing_name, "Local Import #")) continue; + const suffix = existing_name["Local Import #".len..]; + const n = std.fmt.parseUnsigned(usize, suffix, 10) catch continue; + if (n >= next_number) next_number = n + 1; + } + } + + return std.fmt.allocPrint(allocator, "Local Import #{d}", .{next_number}); +} + +fn resolveImportInstanceName( + allocator: std.mem.Allocator, + s: *state_mod.State, + component: []const u8, + req: ImportRequest, + cfg: ParsedImportConfig, +) ![]u8 { + if (req.name.len > 0) return allocator.dupe(u8, req.name); + if (cfg.instance_name) |name| return allocator.dupe(u8, name); + return nextLocalImportName(allocator, s, component); +} + +fn invalidImportNameResponse(allocator: std.mem.Allocator, name: []const u8) ApiResponse { + const body = std.fmt.allocPrint(allocator, "{{\"error\":\"invalid or duplicate instance name: {s}\"}}", .{name}) catch return helpers.serverError(); + return badRequest(body); +} + +fn buildImportResponse(allocator: std.mem.Allocator, instance_name: []const u8, source_dir: []const u8) ![]u8 { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + + try buf.appendSlice("{\"status\":\"imported\",\"instance\":\""); + try appendEscaped(&buf, instance_name); + try buf.appendSlice("\",\"path\":\""); + try appendEscaped(&buf, source_dir); + try buf.appendSlice("\"}"); + return buf.toOwnedSlice(); +} + +fn hasStandaloneInstallAtPath(allocator: std.mem.Allocator, source_dir: []const u8) bool { + return validateImportSourceDir(allocator, source_dir) == null; +} + +fn isStandaloneImported( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + standalone_dir: []const u8, +) bool { + const real_standalone_dir = std_compat.fs.realpathAlloc(allocator, standalone_dir) catch return false; + defer allocator.free(real_standalone_dir); + + const names = s.instanceNames(component) catch return false; + defer if (names) |owned_names| s.allocator.free(owned_names); + + if (names) |owned_names| { + for (owned_names) |name| { + const inst_dir = paths.instanceDir(allocator, component, name) catch continue; + defer allocator.free(inst_dir); + + const real_inst_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch continue; + defer allocator.free(real_inst_dir); + if (std.mem.eql(u8, real_inst_dir, real_standalone_dir)) return true; + } + } + + return false; +} + +fn buildStandaloneResponse( + allocator: std.mem.Allocator, + standalone_dir: ?[]const u8, + already_imported: bool, +) ![]u8 { + if (standalone_dir == null) return allocator.dupe(u8, "{\"standalone\":false}"); + + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("{\"standalone\":true,\"standalone_path\":\""); + try appendEscaped(&buf, standalone_dir.?); + try buf.appendSlice("\",\"already_imported\":"); + try buf.appendSlice(if (already_imported) "true" else "false"); + try buf.appendSlice("}"); + return buf.toOwnedSlice(); +} + +pub fn handleStandalone(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8) ApiResponse { + const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { + if (builtin.os.tag == .windows) { + break :blk std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return helpers.serverError(); + } + return helpers.serverError(); + }; + defer allocator.free(home); + + const standalone_dir = resolveDefaultImportSourceDir(allocator, home, component) catch return helpers.serverError(); + defer allocator.free(standalone_dir); + + if (!hasStandaloneInstallAtPath(allocator, standalone_dir)) { + const body = buildStandaloneResponse(allocator, null, false) catch return helpers.serverError(); + return jsonOk(body); + } + + const already_imported = isStandaloneImported(allocator, s, paths, component, standalone_dir); + const body = buildStandaloneResponse(allocator, standalone_dir, already_imported) catch return helpers.serverError(); + return jsonOk(body); +} + /// POST /api/instances/{component}/import — import a standalone installation. /// Copies config and data from ~/.{component}/ into the nullhub instance directory. /// A runnable binary is staged during import so the managed instance can start. -pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8) ApiResponse { - if (s.getInstance(component, "default") != null) { - return conflict("{\"error\":\"default instance already exists\"}"); - } +pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8, body: []const u8) ApiResponse { + const req = loadImportRequest(allocator, body) catch return badRequest("{\"error\":\"invalid JSON body\"}"); + defer deinitImportRequest(allocator, req); const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { if (builtin.os.tag == .windows) { @@ -3928,16 +4134,32 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa }; defer allocator.free(home); - // 1. Verify standalone dir exists - const dot_dir = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return helpers.serverError(); - defer allocator.free(dot_dir); - std_compat.fs.accessAbsolute(dot_dir, .{}) catch return notFound(); + const source_dir = resolveImportSourceDir(allocator, home, component, req) catch return helpers.serverError(); + defer allocator.free(source_dir); + + if (validateImportSourceDir(allocator, source_dir)) |error_body| { + const is_default_path = req.path.len == 0; + if (is_default_path and std.mem.eql(u8, error_body, "{\"error\":\"path does not exist\"}")) { + return notFound(); + } + return badRequest(error_body); + } + + const parsed_config = readImportConfig(allocator, source_dir) catch return badRequest("{\"error\":\"config.json is not valid JSON\"}"); + defer deinitParsedImportConfig(allocator, parsed_config); + + const instance_name = resolveImportInstanceName(allocator, s, component, req, parsed_config) catch return helpers.serverError(); + defer allocator.free(instance_name); + + if (!isFilesystemSafeImportName(instance_name) or s.getInstance(component, instance_name) != null) { + return invalidImportNameResponse(allocator, instance_name); + } // 2. Create instance directory structure - const inst_dir = paths.instanceDir(allocator, component, "default") catch return helpers.serverError(); + const inst_dir = paths.instanceDir(allocator, component, instance_name) catch return helpers.serverError(); defer allocator.free(inst_dir); if (std_compat.fs.accessAbsolute(inst_dir, .{})) |_| { - return conflict("{\"error\":\"default instance directory already exists\"}"); + return invalidImportNameResponse(allocator, instance_name); } else |err| switch (err) { error.FileNotFound => {}, else => return helpers.serverError(), @@ -3958,10 +4180,10 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa // 4. Symlink the entire standalone dir as the instance dir // ~/.nullclaw → ~/.nullhub/instances/nullclaw/default // This preserves all data in place (config, auth, workspace, state, logs) - std_compat.fs.symLinkAbsolute(dot_dir, inst_dir, .{ .is_directory = true }) catch return helpers.serverError(); + std_compat.fs.symLinkAbsolute(source_dir, inst_dir, .{ .is_directory = true }) catch return helpers.serverError(); // 5. Register in state - s.addInstance(component, "default", .{ + s.addInstance(component, instance_name, .{ .version = version, .auto_start = false, .launch_mode = defaultLaunchModeForComponent(component), @@ -3971,12 +4193,13 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa return helpers.serverError(); }; s.save() catch { - _ = s.removeInstance(component, "default"); + _ = s.removeInstance(component, instance_name); std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; return helpers.serverError(); }; - return jsonOk("{\"status\":\"imported\",\"instance\":\"default\"}"); + const response_body = buildImportResponse(allocator, instance_name, source_dir) catch return helpers.serverError(); + return jsonOk(response_body); } /// PATCH /api/instances/{component}/{name} — update settings (auto_start). @@ -4720,7 +4943,11 @@ pub fn dispatch( // POST /api/instances/{component}/import — import standalone installation if (std.mem.eql(u8, method, "POST") and std.mem.eql(u8, parsed.name, "import")) { - return handleImport(allocator, s, paths, parsed.component); + return handleImport(allocator, s, paths, parsed.component, body); + } + + if (std.mem.eql(u8, method, "GET") and std.mem.eql(u8, parsed.name, "standalone")) { + return handleStandalone(allocator, s, paths, parsed.component); } // No action — CRUD on the instance itself. @@ -4823,6 +5050,439 @@ fn writeTestInstanceConfig( try file.writeAll("\n"); } +fn writeAbsoluteFile(path: []const u8, contents: []const u8) !void { + const file = try std_compat.fs.createFileAbsolute(path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(contents); +} + +fn setTestHomeEnv(home: []const u8) !void { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + if (std.c.setenv("HOME", home.ptr, 1) != 0) return error.Unexpected; +} + +fn restoreTestHomeEnv(previous_home: ?[]const u8) void { + if (comptime builtin.os.tag == .windows) return; + if (previous_home) |home| { + _ = std.c.setenv("HOME", home.ptr, 1); + } else { + _ = std.c.unsetenv("HOME"); + } +} + +fn withTestHome( + allocator: std.mem.Allocator, + home: []const u8, + comptime callback: *const fn (std.mem.Allocator) anyerror!void, +) !void { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer restoreTestHomeEnv(previous_home); + + try setTestHomeEnv(home); + try callback(allocator); +} + +fn readAbsoluteSymlinkTarget(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var buf: [std_compat.fs.max_path_bytes]u8 = undefined; + const len = try std.Io.Dir.readLinkAbsolute(std_compat.io(), path, &buf); + return allocator.dupe(u8, buf[0..len]); +} + +fn parseImportResponse(allocator: std.mem.Allocator, body: []const u8) !struct { + status: []const u8, + instance: []const u8, + path: []const u8, +} { + const parsed = try std.json.parseFromSlice(struct { + status: []const u8, + instance: []const u8, + path: []const u8, + }, allocator, body, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + return parsed.value; +} + +fn parseStandaloneResponse(allocator: std.mem.Allocator, body: []const u8) !struct { + standalone: bool, + standalone_path: ?[]const u8 = null, + already_imported: ?bool = null, +} { + const parsed = try std.json.parseFromSlice(struct { + standalone: bool, + standalone_path: ?[]const u8 = null, + already_imported: ?bool = null, + }, allocator, body, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + return parsed.value; +} + +fn createStandaloneImportSource( + allocator: std.mem.Allocator, + fixture: test_helpers.TempPaths, + relative_dir: []const u8, + config_json: []const u8, +) ![]const u8 { + const source_dir = try fixture.path(allocator, relative_dir); + errdefer allocator.free(source_dir); + try ensurePath(source_dir); + + const config_path = try std.fs.path.join(allocator, &.{ source_dir, "config.json" }); + defer allocator.free(config_path); + try writeAbsoluteFile(config_path, config_json); + return source_dir; +} + +test "handleImport with custom path imports and registers instance" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "custom-nullclaw", "{\"instance_name\":\"config-name\",\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\",\"name\":\"review-bot\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + const parsed = try parseImportResponse(allocator, resp.body); + try std.testing.expectEqualStrings("imported", parsed.status); + try std.testing.expectEqualStrings("review-bot", parsed.instance); + try std.testing.expectEqualStrings(source_dir, parsed.path); + + const entry = s.getInstance("nullclaw", "review-bot").?; + try std.testing.expectEqualStrings(local_binary.dev_local_version, entry.version); + try std.testing.expect(!entry.auto_start); + + const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "review-bot"); + defer allocator.free(inst_dir); + const link_target = try readAbsoluteSymlinkTarget(allocator, inst_dir); + defer allocator.free(link_target); + try std.testing.expectEqualStrings(source_dir, link_target); +} + +test "handleImport without body imports default path as default" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const home_dir = try state_fixture.path(allocator, "home"); + defer allocator.free(home_dir); + try ensurePath(home_dir); + const dot_dir = try std.fs.path.join(allocator, &.{ home_dir, ".nullclaw" }); + defer allocator.free(dot_dir); + try ensurePath(dot_dir); + const config_path = try std.fs.path.join(allocator, &.{ dot_dir, "config.json" }); + defer allocator.free(config_path); + try writeAbsoluteFile(config_path, "{\"gateway\":{\"port\":3000}}\n"); + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer restoreTestHomeEnv(previous_home); + try setTestHomeEnv(home_dir); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", ""); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + const parsed = try parseImportResponse(allocator, resp.body); + try std.testing.expectEqualStrings("default", parsed.instance); + try std.testing.expectEqualStrings(dot_dir, parsed.path); + try std.testing.expect(s.getInstance("nullclaw", "default") != null); +} + +test "handleImport reads instance_name from config when name omitted" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "config-named", "{\"instance_name\":\"from-config\",\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + const parsed = try parseImportResponse(allocator, resp.body); + try std.testing.expectEqualStrings("from-config", parsed.instance); + try std.testing.expect(s.getInstance("nullclaw", "from-config") != null); +} + +test "handleImport auto generates local import name when config lacks one" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "Local Import #1", .{ .version = "1.0.0" }); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "generated-name", "{\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + const parsed = try parseImportResponse(allocator, resp.body); + try std.testing.expectEqualStrings("Local Import #2", parsed.instance); + try std.testing.expect(s.getInstance("nullclaw", "Local Import #2") != null); +} + +test "handleImport returns error for missing path" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const missing_dir = try state_fixture.path(allocator, "missing-dir"); + defer allocator.free(missing_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\"}}", .{missing_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"path does not exist\"}", resp.body); +} + +test "handleImport returns error for missing config json at path" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try state_fixture.path(allocator, "no-config"); + defer allocator.free(source_dir); + try ensurePath(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"config.json not found at path\"}", resp.body); +} + +test "handleImport returns error for invalid config json" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "invalid-config", "{not-json}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"config.json is not valid JSON\"}", resp.body); +} + +test "handleImport returns error for duplicate instance name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullclaw", "review-bot", .{ .version = "1.0.0" }); + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "duplicate-name", "{\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\",\"name\":\"review-bot\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"invalid or duplicate instance name: review-bot\"}", resp.body); +} + +test "handleImport returns error for invalid instance name" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "invalid-name", "{\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\",\"name\":\"../bad\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"invalid or duplicate instance name: ../bad\"}", resp.body); +} + +test "handleStandalone returns standalone false when default install is missing" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const home_dir = try state_fixture.path(allocator, "home-missing"); + defer allocator.free(home_dir); + try ensurePath(home_dir); + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer restoreTestHomeEnv(previous_home); + try setTestHomeEnv(home_dir); + + const resp = handleStandalone(allocator, &s, mctx.paths, "nullclaw"); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + + const parsed = try parseStandaloneResponse(allocator, resp.body); + try std.testing.expect(!parsed.standalone); + try std.testing.expect(parsed.standalone_path == null); + try std.testing.expect(parsed.already_imported == null); +} + +test "handleStandalone returns default path when install exists and is not imported" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const home_dir = try state_fixture.path(allocator, "home-standalone"); + defer allocator.free(home_dir); + try ensurePath(home_dir); + const dot_dir = try std.fs.path.join(allocator, &.{ home_dir, ".nullclaw" }); + defer allocator.free(dot_dir); + try ensurePath(dot_dir); + const config_path = try std.fs.path.join(allocator, &.{ dot_dir, "config.json" }); + defer allocator.free(config_path); + try writeAbsoluteFile(config_path, "{\"gateway\":{\"port\":3000}}\n"); + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer restoreTestHomeEnv(previous_home); + try setTestHomeEnv(home_dir); + + const resp = handleStandalone(allocator, &s, mctx.paths, "nullclaw"); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("200 OK", resp.status); + + const parsed = try parseStandaloneResponse(allocator, resp.body); + try std.testing.expect(parsed.standalone); + try std.testing.expectEqualStrings(dot_dir, parsed.standalone_path.?); + try std.testing.expectEqual(@as(?bool, false), parsed.already_imported); +} + +test "handleStandalone returns already imported after default import" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const home_dir = try state_fixture.path(allocator, "home-imported"); + defer allocator.free(home_dir); + try ensurePath(home_dir); + const dot_dir = try std.fs.path.join(allocator, &.{ home_dir, ".nullclaw" }); + defer allocator.free(dot_dir); + try ensurePath(dot_dir); + const config_path = try std.fs.path.join(allocator, &.{ dot_dir, "config.json" }); + defer allocator.free(config_path); + try writeAbsoluteFile(config_path, "{\"gateway\":{\"port\":3000}}\n"); + + const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null; + defer if (previous_home) |value| allocator.free(value); + defer restoreTestHomeEnv(previous_home); + try setTestHomeEnv(home_dir); + + const import_resp = handleImport(allocator, &s, mctx.paths, "nullclaw", ""); + defer allocator.free(import_resp.body); + try std.testing.expectEqualStrings("200 OK", import_resp.status); + + const standalone_resp = handleStandalone(allocator, &s, mctx.paths, "nullclaw"); + defer allocator.free(standalone_resp.body); + try std.testing.expectEqualStrings("200 OK", standalone_resp.status); + + const parsed = try parseStandaloneResponse(allocator, standalone_resp.body); + try std.testing.expect(parsed.standalone); + try std.testing.expectEqualStrings(dot_dir, parsed.standalone_path.?); + try std.testing.expectEqual(@as(?bool, true), parsed.already_imported); +} + fn writeTestTrackerWorkflow( allocator: std.mem.Allocator, paths: paths_mod.Paths, diff --git a/src/integration_tests.zig b/src/integration_tests.zig index 66a7e31..ac5bf15 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -327,6 +327,22 @@ fn seedLaunchableGatewayInstance(server: *IntegrationServer, component: []const } } +fn seedStandaloneInstall(server: *IntegrationServer, component: []const u8, config_json: []const u8) ![]const u8 { + const dir_name = try std.fmt.allocPrint(server.allocator, ".{s}", .{component}); + defer server.allocator.free(dir_name); + const standalone_dir = try std.fs.path.join(server.allocator, &.{ server.home_dir, dir_name }); + errdefer server.allocator.free(standalone_dir); + try std_compat.fs.makeDirAbsolute(standalone_dir); + + const config_path = try std.fs.path.join(server.allocator, &.{ standalone_dir, "config.json" }); + defer server.allocator.free(config_path); + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(config_json); + + return standalone_dir; +} + test "integration harness serves health and core api routes" { var server = try IntegrationServer.start(std.testing.allocator); defer server.deinit(); @@ -619,6 +635,61 @@ test "integration harness covers lifecycle error paths" { } } +test "integration harness covers standalone detection and import flow" { + var server = try IntegrationServer.startWithSeed(std.testing.allocator, struct { + fn call(srv: *IntegrationServer) !void { + const standalone_dir = try seedStandaloneInstall(srv, "nullclaw", "{\"instance_name\":\"existing-bot\",\"gateway\":{\"port\":3000}}\n"); + srv.allocator.free(standalone_dir); + } + }.call); + defer server.deinit(); + + { + const resp = try server.fetch(.{ .path = "/api/instances/nullclaw/standalone" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"standalone\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"already_imported\":false") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "/.nullclaw") != null); + } + + { + const resp = try server.fetch(.{ + .path = "/api/instances/nullclaw/import", + .method = .POST, + .body = "{\"path\":\"", + }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.bad_request, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "invalid JSON body") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances/nullclaw/import", .method = .POST }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"imported\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"instance\":\"existing-bot\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "/.nullclaw") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances/nullclaw/standalone" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"standalone\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"already_imported\":true") != null); + } + + { + const resp = try server.fetch(.{ .path = "/api/instances" }); + defer resp.deinit(std.testing.allocator); + try std.testing.expectEqual(std.http.Status.ok, resp.status); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"nullclaw\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"existing-bot\"") != null); + } +} + test "integration harness covers orchestration proxy not configured" { var server = try IntegrationServer.start(std.testing.allocator); defer server.deinit(); diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 6ed8d3d..e8190cf 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -30,6 +30,15 @@ type InstanceDeleteOptions = { type ObservabilityTarget = { watch?: string; }; +export type ImportInstanceRequest = { + path?: string; + name?: string; +}; +export type StandaloneInfo = { + standalone: boolean; + standalone_path?: string; + already_imported?: boolean; +}; export type ApiRequestError = Error & { status?: number; body?: any; @@ -247,8 +256,13 @@ export const api = { serviceStatus: () => request('/service/status'), - importInstance: (component: string) => - request(`/instances/${component}/import`, { method: 'POST' }), + importInstance: (component: string, data?: ImportInstanceRequest) => + request(`/instances/${component}/import`, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }), + getStandalone: (component: string) => + request(`/instances/${component}/standalone`), getUiModules: () => request<{ modules: Record }>('/ui-modules'), getAvailableUiModules: () => request<{ name: string; repo: string; component: string }[]>('/ui-modules/available'), diff --git a/ui/src/lib/components/AddExistingDialog.svelte b/ui/src/lib/components/AddExistingDialog.svelte new file mode 100644 index 0000000..a9e0e6e --- /dev/null +++ b/ui/src/lib/components/AddExistingDialog.svelte @@ -0,0 +1,279 @@ + + +{#if open} + + +{/if} + + diff --git a/ui/src/lib/components/ComponentCard.svelte b/ui/src/lib/components/ComponentCard.svelte index 34a5d83..00b3fff 100644 --- a/ui/src/lib/components/ComponentCard.svelte +++ b/ui/src/lib/components/ComponentCard.svelte @@ -1,6 +1,4 @@ @@ -52,11 +39,9 @@ {#if alpha} <Alpha> {/if} - {#if imported} - Imported - {:else if standalone} - {:else if installed} import { afterNavigate } from "$app/navigation"; + import AddExistingDialog from "$lib/components/AddExistingDialog.svelte"; import ComponentCard from "$lib/components/ComponentCard.svelte"; - import { api } from "$lib/api/client"; + import { api, type StandaloneInfo } from "$lib/api/client"; + import { onMount } from "svelte"; let components = $state([]); + let standalone = $state(null); + let dialogOpen = $state(false); + let dialogError = $state(""); + let dialogImporting = $state(false); - async function loadComponents() { + async function loadPageData() { try { - const data = await api.getComponents(); + const [data, standaloneInfo] = await Promise.all([ + api.getComponents(), + api.getStandalone("nullclaw").catch(() => null), + ]); components = data.components || []; + standalone = standaloneInfo; } catch (e) { console.error(e); } } - afterNavigate(loadComponents); + async function openExistingDialog(component: string) { + if (component !== "nullclaw") return; + dialogError = ""; + dialogOpen = true; + try { + standalone = await api.getStandalone("nullclaw"); + } catch (e) { + console.error(e); + } + } + + function closeDialog() { + if (dialogImporting) return; + dialogOpen = false; + dialogError = ""; + } + + async function handleExistingSubmit(payload: { path?: string; name?: string }) { + dialogImporting = true; + dialogError = ""; + try { + await api.importInstance("nullclaw", payload); + dialogOpen = false; + await loadPageData(); + } catch (e) { + dialogError = (e as Error).message; + } finally { + dialogImporting = false; + } + } + + onMount(() => { + void loadPageData(); + }); + afterNavigate(loadPageData);
-

Install Component

-

Choose a component to install

+
{#each components as comp} @@ -30,17 +81,35 @@ alpha={Boolean(comp.alpha)} installable={comp.installable !== false} installed={comp.installed} - standalone={comp.standalone} + standalone={comp.name === "nullclaw" ? comp.standalone : false} instanceCount={comp.instance_count} + importLabel={comp.name === "nullclaw" ? "Add Existing" : "Import"} + onImportExisting={openExistingDialog} /> {/each}
+ + From 26f4f9f9e296002d0479751824f79228722afb17 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Tue, 19 May 2026 16:23:52 +0800 Subject: [PATCH 2/9] test(integration): seed import binary for standalone flow --- src/integration_tests.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/integration_tests.zig b/src/integration_tests.zig index ac5bf15..e2310f9 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -327,6 +327,28 @@ fn seedLaunchableGatewayInstance(server: *IntegrationServer, component: []const } } +fn seedInstalledBinaryVersion(server: *IntegrationServer, component: []const u8, version: []const u8) !void { + const root = try server.nullhubRoot(); + defer server.allocator.free(root); + try std_compat.fs.cwd().makePath(root); + + const binary_name = try std.fmt.allocPrint(server.allocator, "{s}-{s}", .{ component, version }); + defer server.allocator.free(binary_name); + const script = + \\#!/bin/sh + \\exit 0 + ; + try writeSeedFile(server, &.{ root, "bin", binary_name }, script); + + const binary_path = try std.fs.path.join(server.allocator, &.{ root, "bin", binary_name }); + defer server.allocator.free(binary_path); + if (comptime std_compat.fs.has_executable_bit) { + const file = try std_compat.fs.openFileAbsolute(binary_path, .{}); + defer file.close(); + try file.chmod(0o755); + } +} + fn seedStandaloneInstall(server: *IntegrationServer, component: []const u8, config_json: []const u8) ![]const u8 { const dir_name = try std.fmt.allocPrint(server.allocator, ".{s}", .{component}); defer server.allocator.free(dir_name); @@ -640,6 +662,7 @@ test "integration harness covers standalone detection and import flow" { fn call(srv: *IntegrationServer) !void { const standalone_dir = try seedStandaloneInstall(srv, "nullclaw", "{\"instance_name\":\"existing-bot\",\"gateway\":{\"port\":3000}}\n"); srv.allocator.free(standalone_dir); + try seedInstalledBinaryVersion(srv, "nullclaw", "1.0.0"); } }.call); defer server.deinit(); From d1c4c67eda0b563c8cadb6bb338c66f497979ebc Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Thu, 28 May 2026 22:44:48 -0300 Subject: [PATCH 3/9] Fix standalone import architecture --- src/api/components.zig | 2 +- src/api/instance_runtime.zig | 15 +-- src/api/instances.zig | 109 +++++++++++++--- src/core/state.zig | 121 +++++++++++++----- ui/src/lib/api/client.ts | 58 ++++----- .../lib/components/AddExistingDialog.svelte | 115 ++++++++++++++--- ui/src/lib/components/ComponentCard.svelte | 33 ++++- ui/src/lib/components/InstanceCard.svelte | 48 ++++--- ui/src/lib/components/Sidebar.svelte | 8 +- ui/src/lib/nullstack/path.ts | 12 ++ ui/src/routes/install/+page.svelte | 28 ++-- .../instances/[component]/[name]/+page.svelte | 3 +- 12 files changed, 405 insertions(+), 147 deletions(-) diff --git a/src/api/components.zig b/src/api/components.zig index 70e6806..9339b9e 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -62,7 +62,7 @@ fn buildListJson(allocator: std.mem.Allocator, s: *state_mod.State) ![]const u8 // Count managed instances from state const instance_count = countInstancesFromState(s, comp.name); - // standalone = dot-dir config exists without a managed NullHub instance + // standalone = dot-dir config exists and can be offered as an import source. const has_dot_dir = hasStandaloneInstall(allocator, comp.name); const standalone = has_dot_dir; const installed = has_dot_dir or instance_count > 0; diff --git a/src/api/instance_runtime.zig b/src/api/instance_runtime.zig index 5958ca9..473d1fe 100644 --- a/src/api/instance_runtime.zig +++ b/src/api/instance_runtime.zig @@ -7,6 +7,7 @@ const paths_mod = @import("../core/paths.zig"); const health_mod = @import("../supervisor/health.zig"); const registry = @import("../installer/registry.zig"); const test_helpers = @import("../test_helpers.zig"); +const imported_standalone_storage_mode = "imported-standalone"; pub const Snapshot = struct { status: manager_mod.Status, @@ -104,21 +105,13 @@ fn normalizeHealthHost(allocator: std.mem.Allocator, host: []const u8) ![]u8 { } fn isImportedStandalone( - allocator: std.mem.Allocator, - paths: paths_mod.Paths, component: []const u8, - name: []const u8, entry: state_mod.InstanceEntry, ) bool { const known = registry.findKnownComponent(component) orelse return false; if (standalonePortConfigKey(component) == null) return false; if (!isStandaloneLaunchMode(component, entry.launch_mode, known.default_launch_command)) return false; - - const inst_dir = paths.instanceDir(allocator, component, name) catch return false; - defer allocator.free(inst_dir); - var link_buf: [std_compat.fs.max_path_bytes]u8 = undefined; - _ = std.Io.Dir.readLinkAbsolute(std_compat.io(), inst_dir, &link_buf) catch return false; - return true; + return std.mem.eql(u8, entry.storage_mode, imported_standalone_storage_mode) and entry.source_path.len > 0; } fn standalonePortConfigKey(component: []const u8) ?[]const u8 { @@ -166,7 +159,7 @@ fn deriveImportedStandaloneSnapshot( entry: state_mod.InstanceEntry, manager_snapshot: ?Snapshot, ) ?Snapshot { - if (!isImportedStandalone(allocator, paths, component, name, entry)) return null; + if (!isImportedStandalone(component, entry)) return null; const known = registry.findKnownComponent(component) orelse return null; const port_key = standalonePortConfigKey(component) orelse return null; @@ -314,6 +307,8 @@ test "resolve treats custom-path imported standalone as running when health pass .auto_start = false, .launch_mode = "gateway", .verbose = false, + .storage_mode = imported_standalone_storage_mode, + .source_path = source_dir, }); try std.testing.expectEqual(manager_mod.Status.running, snapshot.status); diff --git a/src/api/instances.zig b/src/api/instances.zig index 95df63d..86df295 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -27,6 +27,7 @@ const jsonOk = helpers.jsonOk; const notFound = helpers.notFound; const badRequest = helpers.badRequest; const methodNotAllowed = helpers.methodNotAllowed; +const imported_standalone_storage_mode = "imported-standalone"; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -58,6 +59,8 @@ fn persistStartVersion( .auto_start = entry.auto_start, .launch_mode = entry.launch_mode, .verbose = entry.verbose, + .storage_mode = entry.storage_mode, + .source_path = entry.source_path, }); if (!updated) return error.StateError; s.save() catch return error.StateError; @@ -2294,6 +2297,16 @@ fn appendInstanceJson(buf: *std.array_list.Managed(u8), entry: state_mod.Instanc try buf.appendSlice(",\"status\":\""); try buf.appendSlice(status_str); try buf.append('"'); + if (entry.storage_mode.len > 0) { + try buf.appendSlice(",\"storage_mode\":\""); + try appendEscaped(buf, entry.storage_mode); + try buf.append('"'); + } + if (entry.source_path.len > 0) { + try buf.appendSlice(",\"source_path\":\""); + try appendEscaped(buf, entry.source_path); + try buf.append('"'); + } if (snapshot.pid) |pid| { try buf.appendSlice(",\"pid\":"); @@ -2480,6 +2493,8 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * .auto_start = entry.auto_start, .launch_mode = launch_cmd, .verbose = entry.verbose, + .storage_mode = entry.storage_mode, + .source_path = entry.source_path, }) catch {}; s.save() catch {}; } @@ -3848,6 +3863,8 @@ fn restoreDeletedInstance( rollback_auto_start: bool, rollback_launch_mode: []const u8, rollback_verbose: bool, + rollback_storage_mode: []const u8, + rollback_source_path: []const u8, inst_dir: []const u8, hidden_inst_dir: ?[]const u8, ) void { @@ -3856,6 +3873,8 @@ fn restoreDeletedInstance( .auto_start = rollback_auto_start, .launch_mode = rollback_launch_mode, .verbose = rollback_verbose, + .storage_mode = rollback_storage_mode, + .source_path = rollback_source_path, }) catch {}; _ = s.save() catch {}; if (hidden_inst_dir) |path| { @@ -3880,6 +3899,16 @@ pub fn handleDelete( const rollback_launch_mode = allocator.dupe(u8, existing.launch_mode) catch return helpers.serverError(); defer allocator.free(rollback_launch_mode); const rollback_verbose = existing.verbose; + const rollback_storage_mode = if (existing.storage_mode.len > 0) + allocator.dupe(u8, existing.storage_mode) catch return helpers.serverError() + else + ""; + defer if (rollback_storage_mode.len > 0) allocator.free(rollback_storage_mode); + const rollback_source_path = if (existing.source_path.len > 0) + allocator.dupe(u8, existing.source_path) catch return helpers.serverError() + else + ""; + defer if (rollback_source_path.len > 0) allocator.free(rollback_source_path); var delete_impact = collectDeleteImpact(allocator, s, paths, component, name) catch return helpers.serverError(); defer delete_impact.deinit(allocator); @@ -3902,13 +3931,13 @@ pub fn handleDelete( return notFound(); } s.save() catch { - restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, inst_dir, hidden_inst_dir); + restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, rollback_storage_mode, rollback_source_path, inst_dir, hidden_inst_dir); return helpers.serverError(); }; if (delete_impact.dependents.items.items.len > 0) { unlinkDeleteImpact(allocator, paths, component, delete_impact) catch { - restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, inst_dir, hidden_inst_dir); + restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, rollback_storage_mode, rollback_source_path, inst_dir, hidden_inst_dir); _ = s.save() catch {}; return helpers.serverError(); }; @@ -4108,13 +4137,26 @@ fn deinitParsedImportConfig(allocator: std.mem.Allocator, cfg: ParsedImportConfi fn isFilesystemSafeImportName(name: []const u8) bool { if (name.len == 0) return false; if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + if (std.mem.eql(u8, name, "import") or std.mem.eql(u8, name, "standalone")) return false; if (std.mem.indexOfScalar(u8, name, 0) != null) return false; - if (std.mem.indexOfScalar(u8, name, '/') != null) return false; - if (std.mem.indexOfScalar(u8, name, '\\') != null) return false; + for (name) |ch| { + const safe = std.ascii.isAlphanumeric(ch) or ch == '-' or ch == '_' or ch == '.'; + if (!safe) return false; + } if (std.mem.indexOf(u8, name, "..") != null) return false; return true; } +fn parseLocalImportOrdinal(name: []const u8) ?usize { + const prefixes = [_][]const u8{ "local-import-", "Local Import #" }; + inline for (prefixes) |prefix| { + if (std.mem.startsWith(u8, name, prefix)) { + return std.fmt.parseUnsigned(usize, name[prefix.len..], 10) catch null; + } + } + return null; +} + fn nextLocalImportName(allocator: std.mem.Allocator, s: *state_mod.State, component: []const u8) ![]u8 { const names = try s.instanceNames(component); defer if (names) |owned_names| s.allocator.free(owned_names); @@ -4122,14 +4164,12 @@ fn nextLocalImportName(allocator: std.mem.Allocator, s: *state_mod.State, compon var next_number: usize = 1; if (names) |owned_names| { for (owned_names) |existing_name| { - if (!std.mem.startsWith(u8, existing_name, "Local Import #")) continue; - const suffix = existing_name["Local Import #".len..]; - const n = std.fmt.parseUnsigned(usize, suffix, 10) catch continue; + const n = parseLocalImportOrdinal(existing_name) orelse continue; if (n >= next_number) next_number = n + 1; } } - return std.fmt.allocPrint(allocator, "Local Import #{d}", .{next_number}); + return std.fmt.allocPrint(allocator, "local-import-{d}", .{next_number}); } fn resolveImportInstanceName( @@ -4141,6 +4181,7 @@ fn resolveImportInstanceName( ) ![]u8 { if (req.name.len > 0) return allocator.dupe(u8, req.name); if (cfg.instance_name) |name| return allocator.dupe(u8, name); + if (req.path.len == 0) return allocator.dupe(u8, "default"); return nextLocalImportName(allocator, s, component); } @@ -4173,7 +4214,6 @@ fn hasStandaloneInstallAtPath(allocator: std.mem.Allocator, source_dir: []const fn isStandaloneImported( allocator: std.mem.Allocator, s: *state_mod.State, - paths: paths_mod.Paths, component: []const u8, standalone_dir: []const u8, ) bool { @@ -4185,12 +4225,13 @@ fn isStandaloneImported( if (names) |owned_names| { for (owned_names) |name| { - const inst_dir = paths.instanceDir(allocator, component, name) catch continue; - defer allocator.free(inst_dir); + const entry = s.getInstance(component, name) orelse continue; + if (!std.mem.eql(u8, entry.storage_mode, imported_standalone_storage_mode)) continue; + if (entry.source_path.len == 0) continue; - const real_inst_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch continue; - defer allocator.free(real_inst_dir); - if (std.mem.eql(u8, real_inst_dir, real_standalone_dir)) return true; + const real_source_dir = std_compat.fs.realpathAlloc(allocator, entry.source_path) catch continue; + defer allocator.free(real_source_dir); + if (std.mem.eql(u8, real_source_dir, real_standalone_dir)) return true; } } @@ -4215,6 +4256,7 @@ fn buildStandaloneResponse( } pub fn handleStandalone(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8) ApiResponse { + _ = paths; const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { if (builtin.os.tag == .windows) { break :blk std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return helpers.serverError(); @@ -4231,14 +4273,14 @@ pub fn handleStandalone(allocator: std.mem.Allocator, s: *state_mod.State, paths return jsonOk(body); } - const already_imported = isStandaloneImported(allocator, s, paths, component, standalone_dir); + const already_imported = isStandaloneImported(allocator, s, component, standalone_dir); const body = buildStandaloneResponse(allocator, standalone_dir, already_imported) catch return helpers.serverError(); return jsonOk(body); } /// POST /api/instances/{component}/import — import a standalone installation. -/// Copies config and data from ~/.{component}/ into the nullhub instance directory. -/// A runnable binary is staged during import so the managed instance can start. +/// Links the external standalone home into NullHub state without taking ownership +/// of its files. A runnable binary is staged so the managed instance can start. pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8, body: []const u8) ApiResponse { const req = loadImportRequest(allocator, body) catch return badRequest("{\"error\":\"invalid JSON body\"}"); defer deinitImportRequest(allocator, req); @@ -4305,6 +4347,8 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa .auto_start = false, .launch_mode = defaultLaunchModeForComponent(component), .verbose = false, + .storage_mode = imported_standalone_storage_mode, + .source_path = source_dir, }) catch { std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; return helpers.serverError(); @@ -4349,6 +4393,8 @@ pub fn handlePatch(s: *state_mod.State, component: []const u8, name: []const u8, .auto_start = new_auto_start, .launch_mode = new_launch_mode, .verbose = new_verbose, + .storage_mode = entry.storage_mode, + .source_path = entry.source_path, }) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", @@ -5270,6 +5316,8 @@ test "handleImport with custom path imports and registers instance" { const entry = s.getInstance("nullclaw", "review-bot").?; try std.testing.expectEqualStrings(local_binary.dev_local_version, entry.version); try std.testing.expect(!entry.auto_start); + try std.testing.expectEqualStrings(imported_standalone_storage_mode, entry.storage_mode); + try std.testing.expectEqualStrings(source_dir, entry.source_path); const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "review-bot"); defer allocator.free(inst_dir); @@ -5353,6 +5401,7 @@ test "handleImport auto generates local import name when config lacks one" { defer mctx.deinit(allocator); try s.addInstance("nullclaw", "Local Import #1", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "local-import-2", .{ .version = "1.0.0" }); const source_dir = try createStandaloneImportSource(allocator, state_fixture, "generated-name", "{\"gateway\":{\"port\":3000}}\n"); defer allocator.free(source_dir); @@ -5364,8 +5413,8 @@ test "handleImport auto generates local import name when config lacks one" { try std.testing.expectEqualStrings("200 OK", resp.status); const parsed = try parseImportResponse(allocator, resp.body); - try std.testing.expectEqualStrings("Local Import #2", parsed.instance); - try std.testing.expect(s.getInstance("nullclaw", "Local Import #2") != null); + try std.testing.expectEqualStrings("local-import-3", parsed.instance); + try std.testing.expect(s.getInstance("nullclaw", "local-import-3") != null); } test "handleImport returns error for missing path" { @@ -5477,6 +5526,28 @@ test "handleImport returns error for invalid instance name" { try std.testing.expectEqualStrings("{\"error\":\"invalid or duplicate instance name: ../bad\"}", resp.body); } +test "handleImport rejects route-reserved instance names" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + const source_dir = try createStandaloneImportSource(allocator, state_fixture, "reserved-name", "{\"gateway\":{\"port\":3000}}\n"); + defer allocator.free(source_dir); + const body = try std.fmt.allocPrint(allocator, "{{\"path\":\"{s}\",\"name\":\"standalone\"}}", .{source_dir}); + defer allocator.free(body); + + const resp = handleImport(allocator, &s, mctx.paths, "nullclaw", body); + defer allocator.free(resp.body); + try std.testing.expectEqualStrings("400 Bad Request", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"invalid or duplicate instance name: standalone\"}", resp.body); +} + test "handleStandalone returns standalone false when default install is missing" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; diff --git a/src/core/state.zig b/src/core/state.zig index 9ffce8f..e89937c 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -8,6 +8,8 @@ pub const InstanceEntry = struct { auto_start: bool = false, launch_mode: []const u8 = "gateway", verbose: bool = false, + storage_mode: []const u8 = "", + source_path: []const u8 = "", }; pub const SavedProvider = struct { @@ -182,6 +184,42 @@ const InstanceMap = ManagedStringArrayHashMap(InstanceEntry); /// Outer map type: component-name → InstanceMap. const ComponentMap = ManagedStringArrayHashMap(InstanceMap); +fn duplicateOptionalString(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { + if (value.len == 0) return ""; + return allocator.dupe(u8, value); +} + +fn freeOptionalString(allocator: std.mem.Allocator, value: []const u8) void { + if (value.len > 0) allocator.free(value); +} + +fn duplicateInstanceEntry(allocator: std.mem.Allocator, entry: InstanceEntry) !InstanceEntry { + const owned_version = try allocator.dupe(u8, entry.version); + errdefer allocator.free(owned_version); + const owned_launch_mode = try allocator.dupe(u8, entry.launch_mode); + errdefer allocator.free(owned_launch_mode); + const owned_storage_mode = try duplicateOptionalString(allocator, entry.storage_mode); + errdefer freeOptionalString(allocator, owned_storage_mode); + const owned_source_path = try duplicateOptionalString(allocator, entry.source_path); + errdefer freeOptionalString(allocator, owned_source_path); + + return .{ + .version = owned_version, + .auto_start = entry.auto_start, + .launch_mode = owned_launch_mode, + .verbose = entry.verbose, + .storage_mode = owned_storage_mode, + .source_path = owned_source_path, + }; +} + +fn freeInstanceEntry(allocator: std.mem.Allocator, entry: InstanceEntry) void { + allocator.free(entry.version); + allocator.free(entry.launch_mode); + freeOptionalString(allocator, entry.storage_mode); + freeOptionalString(allocator, entry.source_path); +} + // ─── State ─────────────────────────────────────────────────────────────────── pub const State = struct { @@ -239,8 +277,7 @@ pub const State = struct { while (comp_it.next()) |comp_entry| { var inst_it = comp_entry.value_ptr.iterator(); while (inst_it.next()) |inst_entry| { - self.allocator.free(inst_entry.value_ptr.version); - self.allocator.free(inst_entry.value_ptr.launch_mode); + freeInstanceEntry(self.allocator, inst_entry.value_ptr.*); self.allocator.free(inst_entry.key_ptr.*); } comp_entry.value_ptr.deinit(); @@ -283,8 +320,7 @@ pub const State = struct { errdefer { var it = inner.iterator(); while (it.next()) |e| { - allocator.free(e.value_ptr.version); - allocator.free(e.value_ptr.launch_mode); + freeInstanceEntry(allocator, e.value_ptr.*); allocator.free(e.key_ptr.*); } inner.deinit(); @@ -294,14 +330,7 @@ pub const State = struct { while (inst_it.next()) |inst_kv| { const inst_name = try allocator.dupe(u8, inst_kv.key_ptr.*); errdefer allocator.free(inst_name); - const duped_launch_mode = try allocator.dupe(u8, inst_kv.value_ptr.launch_mode); - errdefer allocator.free(duped_launch_mode); - const entry = InstanceEntry{ - .version = try allocator.dupe(u8, inst_kv.value_ptr.version), - .auto_start = inst_kv.value_ptr.auto_start, - .launch_mode = duped_launch_mode, - .verbose = inst_kv.value_ptr.verbose, - }; + const entry = try duplicateInstanceEntry(allocator, inst_kv.value_ptr.*); try inner.put(inst_name, entry); } @@ -438,14 +467,8 @@ pub const State = struct { const owned_name = try self.allocator.dupe(u8, name); errdefer self.allocator.free(owned_name); - const owned_launch_mode = try self.allocator.dupe(u8, entry.launch_mode); - errdefer self.allocator.free(owned_launch_mode); - const owned_entry = InstanceEntry{ - .version = try self.allocator.dupe(u8, entry.version), - .auto_start = entry.auto_start, - .launch_mode = owned_launch_mode, - .verbose = entry.verbose, - }; + const owned_entry = try duplicateInstanceEntry(self.allocator, entry); + errdefer freeInstanceEntry(self.allocator, owned_entry); try inner_ptr.put(owned_name, owned_entry); } @@ -454,8 +477,7 @@ pub const State = struct { const inner = self.instances.getPtr(component) orelse return false; const entry = inner.fetchSwapRemove(name) orelse return false; - self.allocator.free(entry.value.version); - self.allocator.free(entry.value.launch_mode); + freeInstanceEntry(self.allocator, entry.value); self.allocator.free(entry.key); // If this was the last instance, remove the component key too. @@ -485,19 +507,24 @@ pub const State = struct { const inner = self.instances.getPtr(component) orelse return false; const ptr = inner.getPtr(name) orelse return false; + const effective_storage_mode = if (entry.storage_mode.len > 0) entry.storage_mode else ptr.storage_mode; + const effective_source_path = if (entry.source_path.len > 0) entry.source_path else ptr.source_path; + const effective_entry = InstanceEntry{ + .version = entry.version, + .auto_start = entry.auto_start, + .launch_mode = entry.launch_mode, + .verbose = entry.verbose, + .storage_mode = effective_storage_mode, + .source_path = effective_source_path, + }; + // Dupe new values before freeing old ones to avoid use-after-free // when the caller passes slices pointing to the old entry's memory. - const new_version = try self.allocator.dupe(u8, entry.version); - errdefer self.allocator.free(new_version); - const new_launch_mode = try self.allocator.dupe(u8, entry.launch_mode); - errdefer self.allocator.free(new_launch_mode); - - self.allocator.free(ptr.version); - self.allocator.free(ptr.launch_mode); - ptr.version = new_version; - ptr.launch_mode = new_launch_mode; - ptr.auto_start = entry.auto_start; - ptr.verbose = entry.verbose; + const new_entry = try duplicateInstanceEntry(self.allocator, effective_entry); + errdefer freeInstanceEntry(self.allocator, new_entry); + + freeInstanceEntry(self.allocator, ptr.*); + ptr.* = new_entry; return true; } @@ -945,6 +972,34 @@ test "update instance version, save, load, verify" { } } +test "update preserves instance storage metadata when omitted" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addInstance("nullclaw", "imported", .{ + .version = "1.0.0", + .launch_mode = "gateway", + .storage_mode = "imported-standalone", + .source_path = "/tmp/external-nullclaw", + }); + + const updated = try s.updateInstance("nullclaw", "imported", .{ + .version = "2.0.0", + .launch_mode = "gateway", + }); + try std.testing.expect(updated); + + const entry = s.getInstance("nullclaw", "imported").?; + try std.testing.expectEqualStrings("2.0.0", entry.version); + try std.testing.expectEqualStrings("imported-standalone", entry.storage_mode); + try std.testing.expectEqualStrings("/tmp/external-nullclaw", entry.source_path); +} + test "load non-existent file returns empty state" { const allocator = std.testing.allocator; const path = "/tmp/nullhub-state-test-nonexistent/state.json"; diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 94d2a97..3e8bdbb 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -1,7 +1,7 @@ import { createNullBoilerApi } from '$lib/api/nullboiler'; import { createMissionControlApi } from '$lib/api/missionControl'; import { createNullTicketsApi, createNullTicketsStoreApi } from '$lib/api/nulltickets'; -import { encodePathSegment } from '$lib/nullstack/path'; +import { componentApiPath, encodePathSegment, instanceApiPath } from '$lib/nullstack/path'; const BASE = '/api'; @@ -95,7 +95,7 @@ async function request(path: string, options?: RequestInit): Promise { } export const nullTicketsApi = createNullTicketsApi((c, n, payload) => - request(`/instances/${c}/${n}/tickets`, { + request(instanceApiPath(c, n, '/tickets'), { method: 'POST', body: JSON.stringify(payload), }), @@ -181,7 +181,7 @@ export const api = { postWizard: (component: string, data: any) => request(`/wizard/${component}`, { method: 'POST', body: JSON.stringify(data) }), startInstance: (c: string, n: string, modeOrOptions?: string | InstanceStartOptions) => - request(`/instances/${c}/${n}/start`, { + request(instanceApiPath(c, n, '/start'), { method: 'POST', body: typeof modeOrOptions === 'string' @@ -191,38 +191,38 @@ export const api = { : undefined }), stopInstance: (c: string, n: string) => - request(`/instances/${c}/${n}/stop`, { method: 'POST' }), + request(instanceApiPath(c, n, '/stop'), { method: 'POST' }), restartInstance: (c: string, n: string, options?: InstanceStartOptions) => - request(`/instances/${c}/${n}/restart`, { + request(instanceApiPath(c, n, '/restart'), { method: 'POST', body: options ? JSON.stringify(options) : undefined }), deleteInstance: (c: string, n: string, options?: InstanceDeleteOptions) => - request(withQuery(`/instances/${c}/${n}`, { force: options?.force ? 1 : undefined }), { + request(withQuery(instanceApiPath(c, n), { force: options?.force ? 1 : undefined }), { method: 'DELETE' }), - getConfig: (c: string, n: string) => request(`/instances/${c}/${n}/config`), + getConfig: (c: string, n: string) => request(instanceApiPath(c, n, '/config')), getProviderHealth: (c: string, n: string) => - request(`/instances/${c}/${n}/provider-health`), + request(instanceApiPath(c, n, '/provider-health')), getUsage: (c: string, n: string, window: '24h' | '7d' | '30d' | 'all' = '24h') => - request(`/instances/${c}/${n}/usage?window=${window}`), + request(withQuery(instanceApiPath(c, n, '/usage'), { window })), getHistory: (c: string, n: string, params?: { sessionId?: string; limit?: number; offset?: number }) => request( - withQuery(`/instances/${c}/${n}/history`, { + withQuery(instanceApiPath(c, n, '/history'), { session_id: params?.sessionId, limit: params?.limit, offset: params?.offset, }), ), getOnboarding: (c: string, n: string) => - request(`/instances/${c}/${n}/onboarding`), + request(instanceApiPath(c, n, '/onboarding')), getMemory: ( c: string, n: string, params?: { stats?: boolean; key?: string; query?: string; category?: string; limit?: number }, ) => request( - withQuery(`/instances/${c}/${n}/memory`, { + withQuery(instanceApiPath(c, n, '/memory'), { stats: params?.stats ? 1 : undefined, key: params?.key, query: params?.query, @@ -231,55 +231,55 @@ export const api = { }), ), getSkills: (c: string, n: string, name?: string) => - request(withQuery(`/instances/${c}/${n}/skills`, { name })), + request(withQuery(instanceApiPath(c, n, '/skills'), { name })), getSkillCatalog: (c: string, n: string) => - request(withQuery(`/instances/${c}/${n}/skills`, { catalog: 1 })), + request(withQuery(instanceApiPath(c, n, '/skills'), { catalog: 1 })), installBundledSkill: (c: string, n: string, bundled: string) => - request(`/instances/${c}/${n}/skills`, { + request(instanceApiPath(c, n, '/skills'), { method: 'POST', body: JSON.stringify({ bundled }), }), installSkillFromClawhub: (c: string, n: string, clawhub_slug: string) => - request(`/instances/${c}/${n}/skills`, { + request(instanceApiPath(c, n, '/skills'), { method: 'POST', body: JSON.stringify({ clawhub_slug }), }), installSkillFromSource: (c: string, n: string, source: string) => - request(`/instances/${c}/${n}/skills`, { + request(instanceApiPath(c, n, '/skills'), { method: 'POST', body: JSON.stringify({ source }), }), removeSkill: (c: string, n: string, skillName: string) => - request(withQuery(`/instances/${c}/${n}/skills`, { name: skillName }), { + request(withQuery(instanceApiPath(c, n, '/skills'), { name: skillName }), { method: 'DELETE', }), getIntegration: (c: string, n: string) => - request(`/instances/${c}/${n}/integration`), + request(instanceApiPath(c, n, '/integration')), linkIntegration: (c: string, n: string, payload: any) => - request(`/instances/${c}/${n}/integration`, { + request(instanceApiPath(c, n, '/integration'), { method: 'POST', body: JSON.stringify(payload), }), ...nullTicketsApi, ...nullTicketsStoreApi, putConfig: (c: string, n: string, config: any) => - request(`/instances/${c}/${n}/config`, { method: 'PUT', body: JSON.stringify(config) }), + request(instanceApiPath(c, n, '/config'), { method: 'PUT', body: JSON.stringify(config) }), getLogs: (c: string, n: string, lines = 100, source: LogSource = 'instance') => - request(withQuery(`/instances/${c}/${n}/logs`, { lines, source })), + request(withQuery(instanceApiPath(c, n, '/logs'), { lines, source })), clearLogs: (c: string, n: string, source: LogSource = 'instance') => - request(withQuery(`/instances/${c}/${n}/logs`, { source }), { method: 'DELETE' }), + request(withQuery(instanceApiPath(c, n, '/logs'), { source }), { method: 'DELETE' }), getUpdates: () => request('/updates'), getSettings: () => request('/settings'), putSettings: (settings: any) => request('/settings', { method: 'PUT', body: JSON.stringify(settings) }), patchConfig: (c: string, n: string, config: any) => - request(`/instances/${c}/${n}/config`, { method: 'PATCH', body: JSON.stringify(config) }), + request(instanceApiPath(c, n, '/config'), { method: 'PATCH', body: JSON.stringify(config) }), patchInstance: (c: string, n: string, settings: any) => - request(`/instances/${c}/${n}`, { method: 'PATCH', body: JSON.stringify(settings) }), + request(instanceApiPath(c, n), { method: 'PATCH', body: JSON.stringify(settings) }), - getComponentManifest: (name: string) => request(`/components/${name}/manifest`), + getComponentManifest: (name: string) => request(`/components/${encodePathSegment(name)}/manifest`), refreshComponents: () => request('/components/refresh', { method: 'POST' }), @@ -288,7 +288,7 @@ export const api = { ...missionControlApi, applyUpdate: (c: string, n: string) => - request(`/instances/${c}/${n}/update`, { method: 'POST' }), + request(instanceApiPath(c, n, '/update'), { method: 'POST' }), serviceInstall: () => request('/service/install', { method: 'POST' }), @@ -297,12 +297,12 @@ export const api = { serviceStatus: () => request('/service/status'), importInstance: (component: string, data?: ImportInstanceRequest) => - request(`/instances/${component}/import`, { + request(componentApiPath(component, '/import'), { method: 'POST', body: data ? JSON.stringify(data) : undefined, }), getStandalone: (component: string) => - request(`/instances/${component}/standalone`), + request(componentApiPath(component, '/standalone')), getUiModules: () => request<{ modules: Record }>('/ui-modules'), getAvailableUiModules: () => request<{ name: string; repo: string; component: string }[]>('/ui-modules/available'), diff --git a/ui/src/lib/components/AddExistingDialog.svelte b/ui/src/lib/components/AddExistingDialog.svelte index a9e0e6e..af6b898 100644 --- a/ui/src/lib/components/AddExistingDialog.svelte +++ b/ui/src/lib/components/AddExistingDialog.svelte @@ -1,8 +1,11 @@ {#if open} - -