diff --git a/src/api/components.zig b/src/api/components.zig index 64580dc..21b618d 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -62,18 +62,19 @@ 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 and instance_count == 0; + const standalone = has_dot_dir; const installed = has_dot_dir or instance_count > 0; try buf.print( - "{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"alpha\":{s},\"installable\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}", + "{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"stage\":\"{s}\",\"alpha\":{s},\"installable\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}", .{ comp.name, comp.display_name, comp.description, comp.repo, + comp.stage, if (comp.is_alpha) "true" else "false", if (comp.installable) "true" else "false", if (installed) "true" else "false", @@ -234,15 +235,68 @@ test "handleList returns valid JSON with all known components" { try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullwatch\"") != null); // Verify structural fields + try std.testing.expect(std.mem.indexOf(u8, json, "\"stage\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"alpha\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"installable\"") != null); - try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, json, "\"installable\":true")); - try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"alpha\":true")); - try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"alpha\":false")); + try std.testing.expectEqual(@as(usize, 4), std.mem.count(u8, json, "\"installable\":true")); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "\"stage\":\"alpha\"")); + try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"stage\":\"beta\"")); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "\"alpha\":true")); + try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, json, "\"alpha\":false")); try std.testing.expect(std.mem.indexOf(u8, json, "\"installed\"") != null); 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\",\"display_name\":\"NullClaw\",\"description\":\"Autonomous AI agent runtime\",\"repo\":\"nullclaw/nullclaw\",\"stage\":\"\",\"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..473d1fe 100644 --- a/src/api/instance_runtime.zig +++ b/src/api/instance_runtime.zig @@ -1,10 +1,13 @@ 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"); +const imported_standalone_storage_mode = "imported-standalone"; pub const Snapshot = struct { status: manager_mod.Status, @@ -102,30 +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); - 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); + 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 { @@ -173,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; @@ -257,3 +243,74 @@ 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 addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0); + var server = try addr.listen(.{}); + defer server.deinit(); + const port = server.listen_address.in.getPort(); + + 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(); + const source_config_json = try std.fmt.allocPrint(allocator, "{{\"gateway\":{{\"port\":{d}}},\"host\":\"127.0.0.1\"}}", .{port}); + defer allocator.free(source_config_json); + try source_config.writeAll(source_config_json); + + 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 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, + .storage_mode = imported_standalone_storage_mode, + .source_path = source_dir, + }); + + try std.testing.expectEqual(manager_mod.Status.running, snapshot.status); + try std.testing.expectEqual(port, snapshot.port); +} diff --git a/src/api/instances.zig b/src/api/instances.zig index 7c2547a..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(); }; @@ -4020,14 +4049,214 @@ fn resolveImportBinaryVersion(allocator: std.mem.Allocator, paths: paths_mod.Pat return downloadLatestBinaryVersion(allocator, paths, component); } -/// 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\"}"); +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 { + const dot_name = try std.fmt.allocPrint(allocator, ".{s}", .{component}); + defer allocator.free(dot_name); + return std.fs.path.join(allocator, &.{ home, dot_name }); +} + +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 file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const bytes = try file.readToEndAlloc(allocator, 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.eql(u8, name, "import") or std.mem.eql(u8, name, "standalone")) return false; + if (std.mem.indexOfScalar(u8, name, 0) != 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); + + var next_number: usize = 1; + if (names) |owned_names| { + for (owned_names) |existing_name| { + 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}); +} + +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); + if (req.path.len == 0) return allocator.dupe(u8, "default"); + return nextLocalImportName(allocator, s, component); +} + +fn invalidImportNameResponse(allocator: std.mem.Allocator, name: []const u8) ApiResponse { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + buf.appendSlice("{\"error\":\"invalid or duplicate instance name: ") catch return helpers.serverError(); + appendEscaped(&buf, name) catch return helpers.serverError(); + buf.appendSlice("\"}") catch return helpers.serverError(); + const body = buf.toOwnedSlice() 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, + 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 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_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; + } + } + + 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 { + _ = 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(); @@ -4036,16 +4265,60 @@ 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 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, 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. +/// 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); + + 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 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(), @@ -4064,27 +4337,30 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa defer allocator.free(version); // 4. Symlink the entire standalone dir as the instance dir - // ~/.nullclaw → ~/.nullhub/instances/nullclaw/default + // ~/.nullclaw -> ~/.nullhub/instances/nullclaw/{name} // 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), .verbose = false, + .storage_mode = imported_standalone_storage_mode, + .source_path = source_dir, }) catch { std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; 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). @@ -4117,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", @@ -4820,7 +5098,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. @@ -4920,6 +5202,464 @@ 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); + 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); + 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" }); + 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); + 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-3", parsed.instance); + try std.testing.expect(s.getInstance("nullclaw", "local-import-3") != 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 "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; + + 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); +} + test "nullclaw gateway config patches generic gateway capabilities" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); diff --git a/src/api/meta.zig b/src/api/meta.zig index ddda851..4bf4375 100644 --- a/src/api/meta.zig +++ b/src/api/meta.zig @@ -1127,6 +1127,16 @@ const routes = [_]RouteSpec{ .body = "Integration update payload.", .response = "Integration update result.", }, + .{ + .id = "instances.standalone", + .method = "GET", + .path_template = "/api/instances/{component}/standalone", + .category = "instances", + .summary = "Detect a local default standalone installation for a component.", + .auth_mode = "optional_bearer", + .path_params = component_only_params[0..], + .response = "Standalone detection payload with optional path and import status.", + }, .{ .id = "instances.import", .method = "POST", @@ -1135,6 +1145,7 @@ const routes = [_]RouteSpec{ .summary = "Import a standalone installation into nullhub management.", .auth_mode = "optional_bearer", .path_params = component_only_params[0..], + .body = "Optional JSON body with path and name. Empty body imports the default ~/.{component} path.", .response = "Imported instance payload.", }, .{ 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/src/installer/registry.zig b/src/installer/registry.zig index 192a080..9a074dd 100644 --- a/src/installer/registry.zig +++ b/src/installer/registry.zig @@ -15,6 +15,7 @@ pub const KnownComponent = struct { display_name: []const u8, description: []const u8, repo: []const u8, + stage: []const u8 = "", is_alpha: bool = false, installable: bool = true, default_launch_command: []const u8 = "gateway", @@ -40,7 +41,7 @@ pub const known_components = [_]KnownComponent{ .display_name = "NullBoiler", .description = "DAG-based workflow orchestrator. Chains agents into multi-step pipelines with branching, loops, and parallel execution. Turns NullClaw agents into teams.", .repo = "nullclaw/nullboiler", - .is_alpha = true, + .stage = "beta", .default_launch_command = "server", .default_port = 8080, }, @@ -49,7 +50,7 @@ pub const known_components = [_]KnownComponent{ .display_name = "NullTickets", .description = "Task and issue tracker for AI agents. Project management that agents can read, create, and update autonomously via API.", .repo = "nullclaw/nulltickets", - .is_alpha = true, + .stage = "beta", .default_launch_command = "server", .default_port = 7700, }, @@ -58,6 +59,8 @@ pub const known_components = [_]KnownComponent{ .display_name = "NullWatch", .description = "Headless tracing, evals, and run intelligence for lightweight agent infrastructure.", .repo = "nullclaw/nullwatch", + .stage = "alpha", + .is_alpha = true, .default_launch_command = "serve", .default_port = 7710, }, diff --git a/src/integration_tests.zig b/src/integration_tests.zig index adcfbec..b4b6bf0 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -327,6 +327,54 @@ 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); + 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; +} + +fn expectJsonPathBasename(allocator: std.mem.Allocator, body: []const u8, field: []const u8, expected: []const u8) !void { + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + + try std.testing.expect(parsed.value == .object); + const value = parsed.value.object.get(field) orelse return error.TestUnexpectedResult; + try std.testing.expect(value == .string); + try std.testing.expectEqualStrings(expected, std.fs.path.basename(value.string)); +} + test "integration harness serves health and core api routes" { var server = try IntegrationServer.start(std.testing.allocator); defer server.deinit(); @@ -619,6 +667,62 @@ 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); + try seedInstalledBinaryVersion(srv, "nullclaw", "1.0.0"); + } + }.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 expectJsonPathBasename(std.testing.allocator, resp.body, "standalone_path", ".nullclaw"); + } + + { + 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 expectJsonPathBasename(std.testing.allocator, resp.body, "path", ".nullclaw"); + } + + { + 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 NullBoiler 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 5c0686a..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'; @@ -56,6 +56,15 @@ type InstanceDeleteOptions = { type NullWatchTarget = { 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; @@ -86,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), }), @@ -172,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' @@ -182,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, @@ -222,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' }), @@ -279,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' }), @@ -287,8 +296,13 @@ export const api = { serviceStatus: () => request('/service/status'), - importInstance: (component: string) => - request(`/instances/${component}/import`, { method: 'POST' }), + importInstance: (component: string, data?: ImportInstanceRequest) => + request(componentApiPath(component, '/import'), { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }), + getStandalone: (component: string) => + 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 new file mode 100644 index 0000000..af6b898 --- /dev/null +++ b/ui/src/lib/components/AddExistingDialog.svelte @@ -0,0 +1,364 @@ + + +{#if open} + +{/if} + + diff --git a/ui/src/lib/components/ComponentCard.svelte b/ui/src/lib/components/ComponentCard.svelte index d8f54f1..94cd898 100644 --- a/ui/src/lib/components/ComponentCard.svelte +++ b/ui/src/lib/components/ComponentCard.svelte @@ -1,36 +1,20 @@ {#if comingSoon} @@ -38,27 +22,23 @@

{displayName}

- <Alpha> + {#if badgeLabel} + <{badgeText}> + {/if} Coming Soon

{description}

{:else} - +

{displayName}

- {#if alpha} - <Alpha> + {#if badgeLabel} + <{badgeText}> {/if} - {#if imported} - Imported - {:else if standalone} - - {:else if installed} + {#if installed} {instanceCount} {instanceCount === 1 ? "instance" : "instances"} @@ -89,6 +69,11 @@ transform: translateY(-2px); } + .component-card:focus-within:not(.disabled) { + border-color: var(--accent); + box-shadow: 0 0 15px var(--border-glow); + } + .component-card.disabled { opacity: 0.45; cursor: not-allowed; @@ -132,19 +117,29 @@ box-shadow: inset 0 0 5px color-mix(in srgb, var(--accent) 30%, transparent); } - .alpha-badge { + .maturity-badge { font-size: 0.7rem; - background: color-mix(in srgb, #ffb84d 18%, transparent); - color: #ffb84d; - border: 1px solid color-mix(in srgb, #ffb84d 65%, #000 35%); padding: 0.25rem 0.45rem; border-radius: 2px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 700; + } + + .maturity-badge.alpha { + background: color-mix(in srgb, #ffb84d 18%, transparent); + color: #ffb84d; + border: 1px solid color-mix(in srgb, #ffb84d 65%, #000 35%); box-shadow: inset 0 0 4px color-mix(in srgb, #ffb84d 35%, transparent); } + .maturity-badge.beta { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + border: 1px solid var(--accent-dim); + box-shadow: inset 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent); + } + .coming-soon-badge { font-size: 0.7rem; background: color-mix(in srgb, var(--fg-dim) 12%, transparent); @@ -157,34 +152,6 @@ font-weight: 700; } - .import-btn { - font-size: 0.75rem; - background: var(--bg-surface); - color: var(--accent); - border: 1px solid var(--accent-dim); - padding: 0.375rem 0.75rem; - border-radius: 2px; - cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease, text-shadow 0.2s ease; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: bold; - } - - .import-btn:hover { - background: var(--bg-hover); - border-color: var(--accent); - box-shadow: 0 0 10px var(--border-glow); - text-shadow: 0 0 8px var(--accent); - } - - .import-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - box-shadow: none; - text-shadow: none; - } - p { font-size: 0.875rem; color: var(--fg-dim); diff --git a/ui/src/lib/components/InstanceCard.svelte b/ui/src/lib/components/InstanceCard.svelte index 7947915..4d7ad22 100644 --- a/ui/src/lib/components/InstanceCard.svelte +++ b/ui/src/lib/components/InstanceCard.svelte @@ -1,6 +1,7 @@ - -
- {name} - -
-
- {component} - {displayVersion} -
- {#if localStatus === "running" && port > 0} -
- {portLabel}: - 127.0.0.1:{port} +
+ +
+ {name} + +
+
+ {component} + {displayVersion}
- {/if} + {#if localStatus === "running" && port > 0} +
+ {portLabel}: + 127.0.0.1:{port} +
+ {/if} +
{#if localStatus === "running" || localStatus === "stopping"} - {:else} - {/if}
- +
diff --git a/ui/src/routes/install/+page.svelte b/ui/src/routes/install/+page.svelte index 62e29dd..0c37b98 100644 --- a/ui/src/routes/install/+page.svelte +++ b/ui/src/routes/install/+page.svelte @@ -5,7 +5,7 @@ let components = $state([]); - async function loadComponents() { + async function loadPageData() { try { const data = await api.getComponents(); components = data.components || []; @@ -14,12 +14,16 @@ } } - afterNavigate(loadComponents); + afterNavigate(loadPageData);
-

Install Component

-

Choose a component to install

+
{#each components as comp} @@ -28,9 +32,9 @@ displayName={comp.display_name} description={comp.description} alpha={Boolean(comp.alpha)} + stage={comp.stage || ""} installable={comp.installable !== false} - installed={comp.installed} - standalone={comp.standalone} + installed={comp.instance_count > 0} instanceCount={comp.instance_count} /> {/each} @@ -41,6 +45,13 @@ .install-page { max-width: 900px; } + .page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 2rem; + } h1 { font-size: 1.75rem; font-weight: 700; @@ -61,4 +72,9 @@ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; } + @media (max-width: 640px) { + .page-header { + flex-direction: column; + } + } diff --git a/ui/src/routes/install/[component]/+page.svelte b/ui/src/routes/install/[component]/+page.svelte index a2bd88d..e000661 100644 --- a/ui/src/routes/install/[component]/+page.svelte +++ b/ui/src/routes/install/[component]/+page.svelte @@ -1,14 +1,21 @@
+
+
+
Already Have {displayName}
+
+ {#if standalone?.standalone && standalone.standalone_path} + {#if standalone.already_imported} + Default install is already added. + {:else} + Default install detected at {standalone.standalone_path} + {/if} + {:else} + Add a local {displayName} home. + {/if} +
+
+ +
+ {#if wizardError}

{wizardError}

@@ -60,8 +165,68 @@ {/if}
+ + diff --git a/ui/src/routes/instances/[component]/+page.svelte b/ui/src/routes/instances/[component]/+page.svelte new file mode 100644 index 0000000..d235ab7 --- /dev/null +++ b/ui/src/routes/instances/[component]/+page.svelte @@ -0,0 +1,306 @@ + + +
+
+
+

{displayName}

+

{instanceEntries.length} installed instances

+
+
+ {#each actions as action} + {action.label} + {/each} + Install Instance +
+
+ + {#if error} +
ERR: {error}
+ {/if} + +
+
+ Running + {runningCount} +
+
+ Stopped + {stoppedCount} +
+
+ Total + {instanceEntries.length} +
+
+ + {#if status} + {#if instanceEntries.length > 0} +
+ {#each instanceEntries as [name, info]} + + {/each} +
+ {:else} +
+

> No {displayName} instances installed.

+ Install {displayName} +
+ {/if} + {/if} +
+ + diff --git a/ui/src/routes/instances/[component]/[name]/+page.svelte b/ui/src/routes/instances/[component]/[name]/+page.svelte index 7f9b2f3..c8b0117 100644 --- a/ui/src/routes/instances/[component]/[name]/+page.svelte +++ b/ui/src/routes/instances/[component]/[name]/+page.svelte @@ -20,6 +20,7 @@ setSelectedBoilerInstance, setSelectedTicketsInstance, } from "$lib/nullstack/backendSelection"; + import { instanceRoute } from "$lib/nullstack/path"; let component = $derived($page.params.component); let name = $derived($page.params.name); @@ -1303,7 +1304,7 @@ >{claw.running ? "running" : "stopped"}
- Open
diff --git a/ui/src/routes/nullboiler/+page.svelte b/ui/src/routes/nullboiler/+page.svelte index 3cd338a..6a2e0ce 100644 --- a/ui/src/routes/nullboiler/+page.svelte +++ b/ui/src/routes/nullboiler/+page.svelte @@ -79,7 +79,8 @@

NullBoiler

{ loading = true; error = null; void loadRuns(); }} /> - New Run + Workflows + Runs