feat(FN-3109): add Docker connectivity service and target selector UI

This merge adds Docker connectivity management to Fusion (FN-3109), introducing a Docker client service in `@fusion/core` with typed interfaces for daemon connection configuration (host, socket path, TLS). The dashboard gains a `DockerTargetSelector` component for choosing between local socket and T

Fusion-Task-Id: FN-3109
This commit is contained in:
Fusion
2026-05-03 07:42:36 -07:00
committed by gsxdsm
parent cc6c9abe61
commit fca870f4f5
19 changed files with 1173 additions and 143 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add Docker target connectivity support for local daemon, Docker contexts, and direct host/TLS configuration with dashboard API and UI selectors.

View File

@@ -40,7 +40,13 @@ export default defineConfig({
// Native module: leave node-pty (aliased to @homebridge fork) out of the // Native module: leave node-pty (aliased to @homebridge fork) out of the
// bundle. esbuild can't statically resolve its conditional native require()s // bundle. esbuild can't statically resolve its conditional native require()s
// (build/Release/pty.node, build/Debug/conpty.node, ...). // (build/Release/pty.node, build/Debug/conpty.node, ...).
external: ["node-pty", "@homebridge/node-pty-prebuilt-multiarch"], external: [
"node-pty",
"@homebridge/node-pty-prebuilt-multiarch",
"dockerode",
"ssh2",
"cpu-features",
],
splitting: false, splitting: false,
clean: true, clean: true,
removeNodeProtocol: false, removeNodeProtocol: false,

View File

@@ -38,6 +38,7 @@
"test": "vitest run --silent=passed-only --reporter=dot" "test": "vitest run --silent=passed-only --reporter=dot"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "^3.3.41",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-v8": "^3.1.0", "@vitest/coverage-v8": "^3.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
@@ -51,6 +52,7 @@
"bonjour-service": "^1.3.0", "bonjour-service": "^1.3.0",
"check-disk-space": "^3.4.0", "check-disk-space": "^3.4.0",
"cron-parser": "^5.5.0", "cron-parser": "^5.5.0",
"dockerode": "^4.0.2",
"extract-zip": "^2.0.1", "extract-zip": "^2.0.1",
"tar": "^7.5.13", "tar": "^7.5.13",
"yaml": "^2.8.3" "yaml": "^2.8.3"

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor } = vi.hoisted(() => {
const execMock = vi.fn();
const readFileMock = vi.fn();
const pingMock = vi.fn();
const versionMock = vi.fn();
const inspectMock = vi.fn();
const dockerCtor = vi.fn().mockImplementation(() => ({
ping: pingMock,
version: versionMock,
getContainer: vi.fn(() => ({ inspect: inspectMock })),
}));
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor };
});
vi.mock("dockerode", () => ({ default: dockerCtor }));
vi.mock("node:child_process", () => ({ exec: execMock }));
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
import { DockerClientService } from "../docker-client";
describe("DockerClientService", () => {
beforeEach(() => {
vi.clearAllMocks();
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: "", stderr: "" }));
pingMock.mockResolvedValue(undefined);
versionMock.mockResolvedValue({ Version: "24.0.0", ApiVersion: "1.43", Os: "linux" });
readFileMock.mockResolvedValue(Buffer.from("x"));
});
it.each([
[undefined, undefined],
[{ host: "tcp://1.2.3.4:2376" }, { host: "tcp://1.2.3.4:2376" }],
])("creates docker instance for mode", async (hostConfig, expected) => {
const service = new DockerClientService();
await service.testConnection(hostConfig as never);
if (expected) expect(dockerCtor).toHaveBeenCalledWith(expected);
else expect(dockerCtor).toHaveBeenCalledWith();
});
it("returns success connection result", async () => {
const service = new DockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(true);
expect(result.dockerVersion).toBe("24.0.0");
expect(result.apiVersion).toBe("1.43");
expect(result.operatingSystem).toBe("linux");
expect(result.isLocalDaemon).toBe(true);
});
it("marks remote host as non-local daemon", async () => {
const service = new DockerClientService();
const result = await service.testConnection({ host: "tcp://1.2.3.4:2376" });
expect(result.success).toBe(true);
expect(result.isLocalDaemon).toBe(false);
});
it.each([
{ mode: "context", hostConfig: { context: "my-remote" } },
{
mode: "host+tls",
hostConfig: {
host: "tcp://1.2.3.4:2376",
tlsVerify: true,
tlsCaPath: "/ca.pem",
tlsCertPath: "/cert.pem",
tlsKeyPath: "/key.pem",
},
},
])("covers additional mode $mode", async ({ hostConfig }) => {
if ((hostConfig as any).context) {
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
else cb(null, { stdout: "", stderr: "" });
});
}
const service = new DockerClientService();
await service.testConnection(hostConfig as any);
expect(dockerCtor).toHaveBeenCalled();
});
it("uses docker context", async () => {
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => {
if (cmd.includes("context inspect")) cb(null, { stdout: '[{"Endpoints":{"docker":{"Host":"tcp://ctx:2376"}}}]', stderr: "" });
else cb(null, { stdout: "", stderr: "" });
});
const service = new DockerClientService();
await service.testConnection({ context: "my-remote" });
expect(execMock.mock.calls[0][0]).toContain("docker context inspect");
expect(dockerCtor).toHaveBeenCalledWith({ host: "tcp://ctx:2376" });
});
it("supports host with tls", async () => {
const service = new DockerClientService();
await service.testConnection({ host: "tcp://1.2.3.4:2376", tlsVerify: true, tlsCaPath: "/ca.pem", tlsCertPath: "/cert.pem", tlsKeyPath: "/key.pem" });
expect(readFileMock).toHaveBeenCalledTimes(3);
});
it("returns failure when ping fails", async () => {
pingMock.mockRejectedValue(new Error("connect ECONNREFUSED"));
const service = new DockerClientService();
const result = await service.testConnection();
expect(result.success).toBe(false);
expect(result.error).toContain("ECONNREFUSED");
});
it("lists contexts and ENOENT fallback", async () => {
execMock.mockImplementationOnce((cmd: string, _opts: unknown, cb: (err: unknown, out: { stdout: string; stderr: string }) => void) => cb(null, { stdout: '{"Name":"default","Current":true}\n{"Name":"remote","DockerHost":"tcp://1.2.3.4:2376","Current":false}\n', stderr: "" }));
const service = new DockerClientService();
const contexts = await service.listContexts();
expect(contexts).toHaveLength(2);
execMock.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (err: unknown) => void) => cb(new Error("ENOENT")));
const fallback = await service.listContexts();
expect(fallback[0].name).toBe("default");
});
it("gets container info and not found", async () => {
inspectMock.mockResolvedValue({ Id: "abc", Name: "/container", Created: "2020-01-01T00:00:00Z", Config: { Image: "img:latest" }, State: { Status: "running", Running: true, Paused: false, Restarting: false, Dead: false } });
const service = new DockerClientService();
const container = await service.getContainerInfo("abc");
expect(container?.name).toBe("container");
inspectMock.mockRejectedValue(new Error("404 no such container"));
const missing = await service.getContainerInfo("missing");
expect(missing).toBeNull();
});
it("does not use execSync", async () => {
const source = await import("node:fs/promises").then((m) => m.readFile(new URL("../docker-client.ts", import.meta.url), "utf8"));
expect(source.includes("execSync")).toBe(false);
});
});

View File

@@ -0,0 +1,192 @@
import Docker from "dockerode";
import { exec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import type {
DockerConnectivityResult,
DockerContainerInspectResult,
DockerContextInfo,
DockerHostConfig,
} from "./types.js";
const EXEC_OPTIONS = {
timeout: 15_000,
maxBuffer: 5 * 1024 * 1024,
} as const;
function isLocalDaemonHost(host?: string): boolean {
return !host || host.trim() === "" || host === "unix:///var/run/docker.sock";
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}
interface DockerContextCliEntry {
Name?: string;
Description?: string;
DockerEndpoint?: string;
DockerHost?: string;
Current?: boolean;
Error?: string;
}
export class DockerClientService {
private dockerInstance: Docker | null = null;
constructor(private readonly defaultHostConfig?: DockerHostConfig) {}
private async createDockerInstance(hostConfig?: DockerHostConfig): Promise<Docker> {
if (hostConfig?.context) {
const contextName = hostConfig.context.trim();
if (!contextName) throw new Error("Docker context name cannot be empty");
let stdout: string;
try {
({ stdout } = await promisify(exec)(`docker context inspect ${JSON.stringify(contextName)}`, EXEC_OPTIONS));
} catch (error) {
throw new Error(`Failed to inspect Docker context "${contextName}": ${toErrorMessage(error)}`);
}
const parsed = JSON.parse(stdout) as Array<{ Endpoints?: { docker?: { Host?: string } } }>;
const dockerHost = parsed[0]?.Endpoints?.docker?.Host;
if (!dockerHost) throw new Error(`Docker context "${contextName}" does not define a Docker endpoint host`);
return new Docker({ host: dockerHost });
}
if (hostConfig?.host) {
const options: {
host: string;
ca?: Buffer;
cert?: Buffer;
key?: Buffer;
rejectUnauthorized?: boolean;
} = {
host: hostConfig.host,
};
if (hostConfig.tlsCaPath) options.ca = await readFile(hostConfig.tlsCaPath);
if (hostConfig.tlsCertPath) options.cert = await readFile(hostConfig.tlsCertPath);
if (hostConfig.tlsKeyPath) options.key = await readFile(hostConfig.tlsKeyPath);
if (hostConfig.tlsVerify === false) options.rejectUnauthorized = false;
return new Docker(options);
}
return new Docker();
}
async testConnection(hostConfig?: DockerHostConfig): Promise<DockerConnectivityResult> {
const isLocalDaemon = isLocalDaemonHost(hostConfig?.host) && !hostConfig?.context;
try {
const docker = await this.createDockerInstance(hostConfig);
await docker.ping();
const version = await docker.version();
return {
success: true,
dockerVersion: version.Version,
apiVersion: version.ApiVersion,
operatingSystem: version.Os,
isLocalDaemon,
};
} catch (error) {
return {
success: false,
error: toErrorMessage(error),
isLocalDaemon,
};
}
}
async listContexts(): Promise<DockerContextInfo[]> {
try {
const { stdout } = await promisify(exec)("docker context ls --format json", EXEC_OPTIONS);
const lines = stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
}
try {
return lines.map((line) => {
const entry = JSON.parse(line) as DockerContextCliEntry;
return {
name: entry.Name ?? "default",
description: entry.Description,
dockerHost: entry.DockerEndpoint ?? entry.DockerHost,
isCurrentContext: Boolean(entry.Current),
isError: Boolean(entry.Error),
errorMessage: entry.Error,
} satisfies DockerContextInfo;
});
} catch {
const tableLines = lines.slice(1);
const contexts: DockerContextInfo[] = [];
for (const line of tableLines) {
const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) continue;
const rawName = parts[0] ?? "";
const isCurrentContext = rawName.startsWith("*");
const name = rawName.replace(/^\*\s*/, "") || "default";
contexts.push({
name,
description: parts[2] || undefined,
dockerHost: parts[1] || undefined,
isCurrentContext,
});
}
return contexts;
}
} catch (error) {
const message = toErrorMessage(error);
if (message.includes("ENOENT")) {
return [{ name: "default", isCurrentContext: true, description: "Current Docker context" }];
}
throw error;
}
}
async getContainerInfo(containerId: string): Promise<DockerContainerInspectResult | null> {
try {
const docker = await this.getInstance();
const inspect = await docker.getContainer(containerId).inspect();
return {
id: inspect.Id,
name: (inspect.Name ?? "").replace(/^\//, ""),
status: inspect.State?.Status ?? "unknown",
image: inspect.Config?.Image ?? "",
created: inspect.Created ? Date.parse(inspect.Created) : 0,
state: {
running: Boolean(inspect.State?.Running),
paused: Boolean(inspect.State?.Paused),
restarting: Boolean(inspect.State?.Restarting),
dead: Boolean(inspect.State?.Dead),
error: inspect.State?.Error || undefined,
},
};
} catch (error) {
const message = toErrorMessage(error);
if (message.includes("404") || message.toLowerCase().includes("no such container")) {
return null;
}
throw error;
}
}
private async getInstance(): Promise<Docker> {
if (!this.dockerInstance) {
this.dockerInstance = await this.createDockerInstance(this.defaultHostConfig);
}
return this.dockerInstance;
}
dispose(): void {
this.dockerInstance = null;
}
}

View File

@@ -391,6 +391,7 @@ export { NodeConnection } from "./node-connection.js";
export { NodeDiscovery } from "./node-discovery.js"; export { NodeDiscovery } from "./node-discovery.js";
export { collectSystemMetrics } from "./system-metrics.js"; export { collectSystemMetrics } from "./system-metrics.js";
export { getAppVersion, parseSemver } from "./app-version.js"; export { getAppVersion, parseSemver } from "./app-version.js";
export { DockerClientService } from "./docker-client.js";
export type { export type {
ConnectionErrorType, ConnectionErrorType,
ConnectionOptions, ConnectionOptions,
@@ -414,6 +415,9 @@ export type {
DockerResourceSizing, DockerResourceSizing,
DockerVolumeMount, DockerVolumeMount,
DockerExtraCli, DockerExtraCli,
DockerContextInfo,
DockerConnectivityResult,
DockerContainerInspectResult,
ManagedDockerNode, ManagedDockerNode,
ManagedDockerNodeInput, ManagedDockerNodeInput,
ManagedDockerNodeUpdate, ManagedDockerNodeUpdate,

View File

@@ -2581,6 +2581,60 @@ export type ManagedDockerNodeUpdate = Partial<
Omit<ManagedDockerNode, "id" | "createdAt"> Omit<ManagedDockerNode, "id" | "createdAt">
>; >;
/** Information about a discovered Docker context */
export interface DockerContextInfo {
/** Context name (e.g., "default", "my-remote") */
name: string;
/** Human-readable description */
description?: string;
/** Docker host URI for this context (e.g., "tcp://192.168.1.50:2376") */
dockerHost?: string;
/** Whether this is the currently active context */
isCurrentContext: boolean;
/** Whether this context has a connection error */
isError?: boolean;
/** Error message if the context is unreachable */
errorMessage?: string;
}
/** Result of testing Docker daemon connectivity */
export interface DockerConnectivityResult {
/** Whether the connection succeeded */
success: boolean;
/** Docker Engine version string */
dockerVersion?: string;
/** Docker API version string */
apiVersion?: string;
/** Docker Engine OS/arch info */
operatingSystem?: string;
/** Error message if connection failed */
error?: string;
/** Whether the target is the local Docker daemon */
isLocalDaemon: boolean;
}
/** Minimal container inspection result from Docker */
export interface DockerContainerInspectResult {
/** Container ID */
id: string;
/** Container name (with leading / stripped) */
name: string;
/** Container status string (e.g., "running", "exited") */
status: string;
/** Image name/tag */
image: string;
/** Creation timestamp (Unix epoch) */
created: number;
/** Detailed container state */
state: {
running: boolean;
paused: boolean;
restarting: boolean;
dead: boolean;
error?: string;
};
}
/** A single plugin's version information for sync comparison */ /** A single plugin's version information for sync comparison */
export interface PluginVersionEntry { export interface PluginVersionEntry {
/** Plugin ID (matches PluginManifest.id) */ /** Plugin ID (matches PluginManifest.id) */

View File

@@ -1,7 +1,8 @@
import { ChevronDown, Plus, Trash2 } from "lucide-react"; import { ChevronDown, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ManagedDockerNodeInput } from "@fusion/core"; import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { DockerTargetSelector } from "./DockerTargetSelector";
import "./DockerNodeOnboardingModal.css"; import "./DockerNodeOnboardingModal.css";
interface DockerNodeOnboardingModalProps { interface DockerNodeOnboardingModalProps {
@@ -33,7 +34,7 @@ const DEFAULT_URL = "http://localhost:4040";
export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) { export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [location, setLocation] = useState<"local" | "remote">("local"); const [hostConfig, setHostConfig] = useState<DockerHostConfig>({});
const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL); const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL);
const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto"); const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto");
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
@@ -45,12 +46,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [imageName, setImageName] = useState("runfusion/fusion"); const [imageName, setImageName] = useState("runfusion/fusion");
const [imageTag, setImageTag] = useState("latest"); const [imageTag, setImageTag] = useState("latest");
const [dockerContext, setDockerContext] = useState("");
const [dockerHost, setDockerHost] = useState("");
const [tlsVerify, setTlsVerify] = useState(true);
const [tlsCaPath, setTlsCaPath] = useState("");
const [tlsCertPath, setTlsCertPath] = useState("");
const [tlsKeyPath, setTlsKeyPath] = useState("");
const [envRows, setEnvRows] = useState<KeyValueRow[]>([]); const [envRows, setEnvRows] = useState<KeyValueRow[]>([]);
const [mountRows, setMountRows] = useState<MountRow[]>([]); const [mountRows, setMountRows] = useState<MountRow[]>([]);
const [errors, setErrors] = useState<FormErrors>({}); const [errors, setErrors] = useState<FormErrors>({});
@@ -58,7 +54,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
const resetForm = useCallback(() => { const resetForm = useCallback(() => {
setName(""); setName("");
setLocation("local"); setHostConfig({});
setReachableUrl(DEFAULT_URL); setReachableUrl(DEFAULT_URL);
setApiKeyMode("auto"); setApiKeyMode("auto");
setApiKey(""); setApiKey("");
@@ -70,12 +66,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
setShowAdvanced(false); setShowAdvanced(false);
setImageName("runfusion/fusion"); setImageName("runfusion/fusion");
setImageTag("latest"); setImageTag("latest");
setDockerContext("");
setDockerHost("");
setTlsVerify(true);
setTlsCaPath("");
setTlsCertPath("");
setTlsKeyPath("");
setEnvRows([]); setEnvRows([]);
setMountRows([]); setMountRows([]);
setErrors({}); setErrors({});
@@ -111,12 +102,12 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
imageName: imageName.trim() || "runfusion/fusion", imageName: imageName.trim() || "runfusion/fusion",
imageTag: imageTag.trim() || "latest", imageTag: imageTag.trim() || "latest",
hostConfig: { hostConfig: {
context: dockerContext.trim() || undefined, context: hostConfig.context?.trim() || undefined,
host: location === "remote" ? dockerHost.trim() || undefined : undefined, host: hostConfig.host?.trim() || undefined,
tlsVerify: location === "remote" ? tlsVerify : undefined, tlsVerify: hostConfig.tlsVerify,
tlsCaPath: location === "remote" ? tlsCaPath.trim() || undefined : undefined, tlsCaPath: hostConfig.tlsCaPath?.trim() || undefined,
tlsCertPath: location === "remote" ? tlsCertPath.trim() || undefined : undefined, tlsCertPath: hostConfig.tlsCertPath?.trim() || undefined,
tlsKeyPath: location === "remote" ? tlsKeyPath.trim() || undefined : undefined, tlsKeyPath: hostConfig.tlsKeyPath?.trim() || undefined,
}, },
envVars: Object.fromEntries( envVars: Object.fromEntries(
envRows envRows
@@ -141,23 +132,17 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
apiKey, apiKey,
apiKeyMode, apiKeyMode,
cpus, cpus,
dockerContext, hostConfig,
dockerHost,
envRows, envRows,
imageName, imageName,
imageTag, imageTag,
includeClaudeCli, includeClaudeCli,
includeDroidCli, includeDroidCli,
location,
memoryMB, memoryMB,
mountRows, mountRows,
name, name,
persistentStorage, persistentStorage,
reachableUrl, reachableUrl,
tlsCaPath,
tlsCertPath,
tlsKeyPath,
tlsVerify,
]); ]);
const addEnvRow = useCallback(() => { const addEnvRow = useCallback(() => {
@@ -191,8 +176,8 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
if (!input.name || input.name.length > 64) { if (!input.name || input.name.length > 64) {
nextErrors.name = "Name is required and must be 64 characters or fewer"; nextErrors.name = "Name is required and must be 64 characters or fewer";
} }
if (location === "remote" && !input.reachableUrl) { if (!input.reachableUrl) {
nextErrors.reachableUrl = "URL is required for remote Docker"; nextErrors.reachableUrl = "URL is required";
} }
if (memoryMB < 512) { if (memoryMB < 512) {
nextErrors.memoryMB = "Memory must be at least 512 MB"; nextErrors.memoryMB = "Memory must be at least 512 MB";
@@ -215,7 +200,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [closeModal, cpus, input, location, memoryMB, onSubmit, submitting]); }, [closeModal, cpus, input, memoryMB, onSubmit, submitting]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -252,26 +237,7 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
</label> </label>
{errors.name && <div className="form-error">{errors.name}</div>} {errors.name && <div className="form-error">{errors.name}</div>}
<div className="docker-onboarding__type-toggle" role="tablist" aria-label="Target location"> <DockerTargetSelector value={hostConfig} onChange={setHostConfig} />
<button
type="button"
className={`docker-onboarding__type-btn ${location === "local" ? "is-active" : ""}`}
onClick={() => setLocation("local")}
disabled={submitting}
aria-pressed={location === "local"}
>
Local Docker
</button>
<button
type="button"
className={`docker-onboarding__type-btn ${location === "remote" ? "is-active" : ""}`}
onClick={() => setLocation("remote")}
disabled={submitting}
aria-pressed={location === "remote"}
>
Remote Docker
</button>
</div>
<label className="docker-onboarding__field"> <label className="docker-onboarding__field">
<span>Reachable URL</span> <span>Reachable URL</span>
@@ -280,23 +246,11 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
value={reachableUrl} value={reachableUrl}
onChange={(event) => setReachableUrl(event.target.value)} onChange={(event) => setReachableUrl(event.target.value)}
disabled={submitting} disabled={submitting}
placeholder={location === "local" ? DEFAULT_URL : "http://192.168.1.50:4040"} placeholder={DEFAULT_URL}
/> />
</label> </label>
{errors.reachableUrl && <div className="form-error">{errors.reachableUrl}</div>} {errors.reachableUrl && <div className="form-error">{errors.reachableUrl}</div>}
{location === "remote" && (
<label className="docker-onboarding__field">
<span>Docker Host</span>
<input
className="input"
value={dockerHost}
onChange={(event) => setDockerHost(event.target.value)}
disabled={submitting}
placeholder="tcp://host:2376"
/>
</label>
)}
<div className="docker-onboarding__radio-group"> <div className="docker-onboarding__radio-group">
<label className="checkbox-label"> <label className="checkbox-label">
@@ -428,73 +382,6 @@ export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast:
</label> </label>
</div> </div>
<label className="docker-onboarding__field">
<span>Docker Context</span>
<input
className="input"
value={dockerContext}
onChange={(event) => setDockerContext(event.target.value)}
disabled={submitting}
placeholder="default"
/>
</label>
{location === "local" && (
<label className="docker-onboarding__field">
<span>Docker Host</span>
<input
className="input"
value={dockerHost}
onChange={(event) => setDockerHost(event.target.value)}
disabled={submitting || Boolean(dockerContext.trim())}
placeholder="tcp://host:2376"
/>
</label>
)}
{location === "remote" && (
<div className="docker-onboarding__tls-fields">
<label className="docker-onboarding__field">
<span>CA Cert Path</span>
<input
className="input"
value={tlsCaPath}
onChange={(event) => setTlsCaPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/ca.pem"
/>
</label>
<label className="docker-onboarding__field">
<span>Client Cert Path</span>
<input
className="input"
value={tlsCertPath}
onChange={(event) => setTlsCertPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/cert.pem"
/>
</label>
<label className="docker-onboarding__field">
<span>Client Key Path</span>
<input
className="input"
value={tlsKeyPath}
onChange={(event) => setTlsKeyPath(event.target.value)}
disabled={submitting}
placeholder="/etc/docker/key.pem"
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={tlsVerify}
onChange={(event) => setTlsVerify(event.target.checked)}
disabled={submitting}
/>
TLS verify
</label>
</div>
)}
<div className="docker-onboarding__kv-list"> <div className="docker-onboarding__kv-list">
<h5>Environment Variables</h5> <h5>Environment Variables</h5>

View File

@@ -0,0 +1,56 @@
.docker-target-selector {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.docker-target-selector__modes {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.docker-target-selector__mode-active {
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
}
.docker-target-selector__panel {
display: grid;
gap: var(--space-md);
}
.docker-target-selector__field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.docker-target-selector__field label {
color: var(--text-muted);
}
.docker-target-selector__context-row {
display: grid;
gap: var(--space-sm);
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.docker-target-selector__status {
color: var(--text-muted);
}
.docker-target-selector__success {
color: var(--color-success);
}
.docker-target-selector__error {
color: var(--color-error);
}
@media (max-width: 768px) {
.docker-target-selector__modes .btn {
min-height: calc(var(--mobile-nav-height) - var(--space-sm));
}
}

View File

@@ -0,0 +1,128 @@
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { DockerHostConfig } from "@fusion/core";
import { useDockerTargets } from "../hooks/useDockerTargets";
import { DockerTlsConfig } from "./DockerTlsConfig";
import "./DockerTargetSelector.css";
type TargetMode = "local" | "context" | "host";
interface DockerTargetSelectorProps {
value?: DockerHostConfig;
onChange: (config: DockerHostConfig) => void;
onError?: (error: string) => void;
}
export function DockerTargetSelector({ value, onChange, onError }: DockerTargetSelectorProps) {
const initialMode: TargetMode = value?.context ? "context" : value?.host ? "host" : "local";
const [mode, setMode] = useState<TargetMode>(initialMode);
const [selectedContext, setSelectedContext] = useState(value?.context ?? "");
const [host, setHost] = useState(value?.host ?? "");
const [localStatus, setLocalStatus] = useState<string | null>(null);
const { contexts, isLoadingContexts, contextsError, loadContexts, testConnection, isTestingConnection, lastTestResult, checkLocalDocker, isCheckingLocal } = useDockerTargets();
const tlsValue = useMemo(
() => ({
tlsVerify: value?.tlsVerify,
tlsCaPath: value?.tlsCaPath,
tlsCertPath: value?.tlsCertPath,
tlsKeyPath: value?.tlsKeyPath,
}),
[value],
);
useEffect(() => {
if (mode === "local") {
onChange({});
return;
}
if (mode === "context") {
void loadContexts().catch((error) => onError?.(error instanceof Error ? error.message : String(error)));
onChange(selectedContext ? { context: selectedContext } : {});
return;
}
onChange({ host, ...tlsValue });
}, [mode]);
const updateTls = useCallback(
(tls: Pick<DockerHostConfig, "tlsVerify" | "tlsCaPath" | "tlsCertPath" | "tlsKeyPath">) => {
if (mode !== "host") return;
onChange({ host, ...tls });
},
[host, mode, onChange],
);
return (
<div className="docker-target-selector">
<div className="docker-target-selector__modes" role="group" aria-label="Docker target mode">
<button type="button" className={`btn btn-sm ${mode === "local" ? "docker-target-selector__mode-active" : ""}`} onClick={() => {
setMode("local");
void checkLocalDocker()
.then((result) => setLocalStatus(result.available ? `Docker is available${result.version ? ` (${result.version})` : ""}` : `Docker not found${result.error ? `: ${result.error}` : ""}`))
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setLocalStatus(`Docker not found: ${message}`);
onError?.(message);
});
}}>Local Docker</button>
<button type="button" className={`btn btn-sm ${mode === "context" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("context")}>Docker Context</button>
<button type="button" className={`btn btn-sm ${mode === "host" ? "docker-target-selector__mode-active" : ""}`} onClick={() => setMode("host")}>Remote Host</button>
</div>
{mode === "local" && localStatus && <div className="docker-target-selector__status">{localStatus}</div>}
{mode === "context" && (
<div className="docker-target-selector__panel">
<div className="docker-target-selector__context-row">
<select className="select" value={selectedContext} onChange={(event) => {
const next = event.target.value;
setSelectedContext(next);
onChange(next ? { context: next } : {});
}}>
<option value="">Select context</option>
{contexts.map((context) => (
<option key={context.name} value={context.name}>{context.name}{context.isCurrentContext ? " (current)" : ""}{context.dockerHost ? `${context.dockerHost}` : ""}</option>
))}
</select>
<button type="button" className="btn btn-sm btn-icon" onClick={() => void loadContexts()} disabled={isLoadingContexts} aria-label="Refresh contexts"><RefreshCw size={14} /></button>
</div>
{contextsError && <div className="docker-target-selector__error">{contextsError}</div>}
</div>
)}
{mode === "host" && (
<div className="docker-target-selector__panel">
<div className="docker-target-selector__field">
<label htmlFor="docker-target-selector-host">Docker Host</label>
<input
id="docker-target-selector-host"
className="input"
placeholder="tcp://host:2376"
value={host}
onChange={(event) => {
const next = event.target.value;
setHost(next);
onChange({ host: next, ...tlsValue });
}}
/>
</div>
<DockerTlsConfig value={tlsValue} onChange={updateTls} />
</div>
)}
<button type="button" className="btn btn-sm" onClick={() => void testConnection(mode === "local" ? undefined : mode === "context" ? { context: selectedContext } : { host, ...tlsValue })} disabled={isTestingConnection || isCheckingLocal}>
{isTestingConnection ? "Testing..." : "Test Connection"}
</button>
{lastTestResult && (
<div className={lastTestResult.success ? "docker-target-selector__success" : "docker-target-selector__error"}>
{lastTestResult.success
? `Connected${lastTestResult.dockerVersion ? ` (Docker ${lastTestResult.dockerVersion})` : ""}`
: lastTestResult.error ?? "Connection failed"}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
.docker-tls-config {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.docker-tls-config__fields {
display: grid;
gap: var(--space-md);
}
.docker-tls-config__field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.docker-tls-config__field label {
color: var(--text-muted);
}
@media (max-width: 768px) {
.docker-tls-config .input {
min-height: calc(var(--mobile-nav-height) - var(--space-sm));
}
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from "react";
import type { DockerHostConfig } from "@fusion/core";
import "./DockerTlsConfig.css";
type DockerTlsValue = Pick<DockerHostConfig, "tlsVerify" | "tlsCaPath" | "tlsCertPath" | "tlsKeyPath">;
interface DockerTlsConfigProps {
value?: DockerTlsValue;
onChange: (tls: DockerTlsValue) => void;
}
export function DockerTlsConfig({ value, onChange }: DockerTlsConfigProps) {
const [enabled, setEnabled] = useState(Boolean(value?.tlsCaPath || value?.tlsCertPath || value?.tlsKeyPath || value?.tlsVerify));
useEffect(() => {
if (!enabled) {
onChange({ tlsVerify: undefined, tlsCaPath: undefined, tlsCertPath: undefined, tlsKeyPath: undefined });
}
}, [enabled, onChange]);
const tls = useMemo(() => ({ tlsVerify: value?.tlsVerify ?? true, tlsCaPath: value?.tlsCaPath ?? "", tlsCertPath: value?.tlsCertPath ?? "", tlsKeyPath: value?.tlsKeyPath ?? "" }), [value]);
return (
<div className="docker-tls-config">
<label className="checkbox-label">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
Use TLS
</label>
{enabled && (
<div className="docker-tls-config__fields">
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-ca-path">CA Certificate Path</label>
<input
id="docker-tls-ca-path"
className="input"
value={tls.tlsCaPath}
onChange={(event) => onChange({ ...tls, tlsCaPath: event.target.value })}
placeholder="/etc/docker/ca.pem"
/>
</div>
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-cert-path">Client Certificate Path</label>
<input
id="docker-tls-cert-path"
className="input"
value={tls.tlsCertPath}
onChange={(event) => onChange({ ...tls, tlsCertPath: event.target.value })}
placeholder="/etc/docker/cert.pem"
/>
</div>
<div className="docker-tls-config__field">
<label htmlFor="docker-tls-key-path">Client Key Path</label>
<input
id="docker-tls-key-path"
className="input"
value={tls.tlsKeyPath}
onChange={(event) => onChange({ ...tls, tlsKeyPath: event.target.value })}
placeholder="/etc/docker/key.pem"
/>
</div>
<label className="checkbox-label">
<input type="checkbox" checked={tls.tlsVerify} onChange={(event) => onChange({ ...tls, tlsVerify: event.target.checked })} />
Verify TLS Certificate
</label>
</div>
)}
</div>
);
}

View File

@@ -26,7 +26,7 @@ describe("DockerNodeOnboardingModal", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />); render(<DockerNodeOnboardingModal {...defaultProps} />);
expect(screen.getByPlaceholderText("my-docker-node")).toBeInTheDocument(); expect(screen.getByPlaceholderText("my-docker-node")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Local Docker" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Local Docker" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remote Docker" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Remote Host" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Auto-generate" })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Auto-generate" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Provide manually" })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Provide manually" })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Claude CLI" })).toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Claude CLI" })).toBeInTheDocument();
@@ -46,9 +46,8 @@ describe("DockerNodeOnboardingModal", () => {
expect(advancedToggle).toHaveClass("is-expanded"); expect(advancedToggle).toHaveClass("is-expanded");
expect(screen.getByPlaceholderText("runfusion/fusion")).toBeInTheDocument(); expect(screen.getByPlaceholderText("runfusion/fusion")).toBeInTheDocument();
expect(screen.getByPlaceholderText("latest")).toBeInTheDocument(); expect(screen.getByPlaceholderText("latest")).toBeInTheDocument();
expect(screen.getByPlaceholderText("default")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Use TLS" }));
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" }));
expect(screen.getByPlaceholderText("/etc/docker/ca.pem")).toBeInTheDocument(); expect(screen.getByPlaceholderText("/etc/docker/ca.pem")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add variable" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add variable" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add mount" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add mount" })).toBeInTheDocument();
@@ -60,10 +59,9 @@ describe("DockerNodeOnboardingModal", () => {
expect(await screen.findByText("Name is required and must be 64 characters or fewer")).toBeInTheDocument(); expect(await screen.findByText("Name is required and must be 64 characters or fewer")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "n1" } }); fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "n1" } });
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" })); fireEvent.change(screen.getByPlaceholderText("http://localhost:4040"), { target: { value: "" } });
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" })); fireEvent.click(screen.getByRole("button", { name: "Create Docker Node" }));
expect(await screen.findByText("URL is required for remote Docker")).toBeInTheDocument(); expect(await screen.findByText("URL is required")).toBeInTheDocument();
fireEvent.change(screen.getByRole("spinbutton", { name: "Memory (MB)" }), { target: { value: "256" } }); fireEvent.change(screen.getByRole("spinbutton", { name: "Memory (MB)" }), { target: { value: "256" } });
fireEvent.change(screen.getByRole("spinbutton", { name: "CPUs" }), { target: { value: "0.25" } }); fireEvent.change(screen.getByRole("spinbutton", { name: "CPUs" }), { target: { value: "0.25" } });
@@ -75,8 +73,8 @@ describe("DockerNodeOnboardingModal", () => {
it("shows remote host and manual api key controls", () => { it("shows remote host and manual api key controls", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />); render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" })); fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
expect(screen.getAllByPlaceholderText("tcp://host:2376").length).toBeGreaterThan(0); expect(screen.getByPlaceholderText("tcp://host:2376")).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Provide manually" })); fireEvent.click(screen.getByRole("radio", { name: "Provide manually" }));
expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument();
@@ -86,8 +84,8 @@ describe("DockerNodeOnboardingModal", () => {
render(<DockerNodeOnboardingModal {...defaultProps} />); render(<DockerNodeOnboardingModal {...defaultProps} />);
fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "docker-a" } }); fireEvent.change(screen.getByPlaceholderText("my-docker-node"), { target: { value: "docker-a" } });
fireEvent.click(screen.getByRole("button", { name: "Remote Docker" })); fireEvent.click(screen.getByRole("button", { name: "Remote Host" }));
fireEvent.change(screen.getByPlaceholderText("http://192.168.1.50:4040"), { target: { value: "http://10.0.0.2:4040" } }); fireEvent.change(screen.getByPlaceholderText("http://localhost:4040"), { target: { value: "http://10.0.0.2:4040" } });
fireEvent.change(screen.getByPlaceholderText("tcp://host:2376"), { target: { value: "tcp://10.0.0.2:2376" } }); fireEvent.change(screen.getByPlaceholderText("tcp://host:2376"), { target: { value: "tcp://10.0.0.2:2376" } });
fireEvent.click(screen.getByRole("checkbox", { name: "Claude CLI" })); fireEvent.click(screen.getByRole("checkbox", { name: "Claude CLI" }));
fireEvent.click(screen.getByRole("button", { name: "Advanced" })); fireEvent.click(screen.getByRole("button", { name: "Advanced" }));

View File

@@ -0,0 +1,40 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DockerTargetSelector } from "../DockerTargetSelector";
vi.mock("lucide-react", () => ({ RefreshCw: () => null }));
const hookState = {
contexts: [{ name: "default", isCurrentContext: true }],
isLoadingContexts: false,
contextsError: null,
loadContexts: vi.fn().mockResolvedValue(undefined),
testConnection: vi.fn().mockResolvedValue({ success: true, dockerVersion: "24.0" }),
isTestingConnection: false,
lastTestResult: null,
checkLocalDocker: vi.fn().mockResolvedValue({ available: true, version: "24.0" }),
isCheckingLocal: false,
};
vi.mock("../../hooks/useDockerTargets", () => ({ useDockerTargets: () => hookState }));
describe("DockerTargetSelector", () => {
it("renders modes and emits config", async () => {
const onChange = vi.fn();
render(<DockerTargetSelector onChange={onChange} />);
expect(screen.getByText("Local Docker")).toBeInTheDocument();
expect(screen.getByText("Docker Context")).toBeInTheDocument();
expect(screen.getByText("Remote Host")).toBeInTheDocument();
fireEvent.click(screen.getByText("Docker Context"));
await waitFor(() => expect(hookState.loadContexts).toHaveBeenCalled());
fireEvent.click(screen.getByText("Remote Host"));
expect(screen.getByPlaceholderText("tcp://host:2376")).toBeInTheDocument();
fireEvent.click(screen.getByText("Test Connection"));
await waitFor(() => expect(hookState.testConnection).toHaveBeenCalled());
expect(onChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDockerTargets } from "../useDockerTargets";
describe("useDockerTargets", () => {
beforeEach(() => {
vi.restoreAllMocks();
global.fetch = vi.fn() as unknown as typeof fetch;
});
it("loads contexts", async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => [{ name: "default", isCurrentContext: true }] } as Response);
const { result } = renderHook(() => useDockerTargets());
await act(async () => {
await result.current.loadContexts();
});
expect(result.current.contexts).toHaveLength(1);
});
it("tests connection", async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ success: true, isLocalDaemon: true }) } as Response);
const { result } = renderHook(() => useDockerTargets());
await act(async () => {
await result.current.testConnection({ host: "tcp://1.2.3.4:2376" });
});
expect(fetch).toHaveBeenCalledWith(
"/api/docker/test-connection",
expect.objectContaining({ method: "POST" }),
);
});
it("checks local docker", async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true, json: async () => ({ available: true, version: "24.0" }) } as Response);
const { result } = renderHook(() => useDockerTargets());
await act(async () => {
const response = await result.current.checkLocalDocker();
expect(response.available).toBe(true);
});
});
});

View File

@@ -0,0 +1,81 @@
import { useCallback, useState } from "react";
import type { DockerConnectivityResult, DockerContextInfo, DockerHostConfig } from "@fusion/core";
interface LocalDockerAvailability {
available: boolean;
version?: string;
error?: string;
}
export function useDockerTargets() {
const [contexts, setContexts] = useState<DockerContextInfo[]>([]);
const [isLoadingContexts, setIsLoadingContexts] = useState(false);
const [contextsError, setContextsError] = useState<string | null>(null);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [lastTestResult, setLastTestResult] = useState<DockerConnectivityResult | null>(null);
const [isCheckingLocal, setIsCheckingLocal] = useState(false);
const loadContexts = useCallback(async () => {
setIsLoadingContexts(true);
setContextsError(null);
try {
const response = await fetch("/api/docker/contexts");
if (!response.ok) {
throw new Error(`Failed to load Docker contexts (${response.status})`);
}
const payload = (await response.json()) as DockerContextInfo[];
setContexts(payload);
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setContextsError(message);
throw error;
} finally {
setIsLoadingContexts(false);
}
}, []);
const testConnection = useCallback(async (hostConfig?: DockerHostConfig) => {
setIsTestingConnection(true);
try {
const response = await fetch("/api/docker/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hostConfig }),
});
if (!response.ok) {
throw new Error(`Failed to test Docker connection (${response.status})`);
}
const payload = (await response.json()) as DockerConnectivityResult;
setLastTestResult(payload);
return payload;
} finally {
setIsTestingConnection(false);
}
}, []);
const checkLocalDocker = useCallback(async () => {
setIsCheckingLocal(true);
try {
const response = await fetch("/api/docker/local-available");
if (!response.ok) {
throw new Error(`Failed to check local Docker availability (${response.status})`);
}
return (await response.json()) as LocalDockerAvailability;
} finally {
setIsCheckingLocal(false);
}
}, []);
return {
contexts,
isLoadingContexts,
contextsError,
loadContexts,
isTestingConnection,
lastTestResult,
testConnection,
isCheckingLocal,
checkLocalDocker,
};
}

View File

@@ -0,0 +1,79 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const service = {
listContexts: vi.fn(),
testConnection: vi.fn(),
};
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, DockerClientService: vi.fn().mockImplementation(() => service) };
});
function createStore() {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })),
getRootDir: vi.fn().mockReturnValue("/tmp"),
getFusionDir: vi.fn().mockReturnValue("/tmp/.fusion"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
getMissionStore: vi.fn(),
on: vi.fn(),
off: vi.fn(),
} as any;
}
function app() {
const server = express();
server.use(express.json());
server.use("/api", createApiRoutes(createStore()));
return server;
}
describe("registerDockerNodeRoutes", () => {
beforeEach(() => vi.clearAllMocks());
it("GET /api/docker/contexts returns contexts", async () => {
service.listContexts.mockResolvedValue([{ name: "default", isCurrentContext: true }]);
const res = await request(app(), "GET", "/api/docker/contexts");
expect(res.status).toBe(200);
expect(res.body).toEqual([{ name: "default", isCurrentContext: true }]);
});
it("POST /api/docker/test-connection validates host protocol", async () => {
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig: { host: "http://bad" } }), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
});
it("POST /api/docker/test-connection passes hostConfig", async () => {
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: false });
const hostConfig = { host: "tcp://1.2.3.4:2376", tlsVerify: true };
const res = await request(app(), "POST", "/api/docker/test-connection", JSON.stringify({ hostConfig }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(service.testConnection).toHaveBeenCalledWith(hostConfig);
});
it("GET /api/docker/local-available maps connection", async () => {
service.testConnection.mockResolvedValue({ success: true, isLocalDaemon: true, dockerVersion: "24.0" });
const res = await request(app(), "GET", "/api/docker/local-available");
expect(res.status).toBe(200);
expect(res.body).toEqual({ available: true, version: "24.0" });
});
it("GET /api/docker/local-available handles errors", async () => {
service.testConnection.mockRejectedValue(new Error("boom"));
const res = await request(app(), "GET", "/api/docker/local-available");
expect(res.status).toBe(200);
expect((res.body as any).available).toBe(false);
});
});

View File

@@ -57,6 +57,50 @@ function sanitizeExtraClis(input: unknown): DockerExtraCli[] {
export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => { export const registerDockerNodeRoutes: ApiRouteRegistrar = (ctx) => {
const { router, rethrowAsApiError } = ctx; const { router, rethrowAsApiError } = ctx;
router.get("/docker/contexts", async (_req, res) => {
try {
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const contexts = await service.listContexts();
res.json(contexts);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
router.post("/docker/test-connection", async (req, res) => {
try {
const hostConfig = ((req.body ?? {}) as { hostConfig?: DockerHostConfig }).hostConfig;
if (hostConfig?.host && !/^(tcp|unix|npipe):\/\//.test(hostConfig.host)) {
throw badRequest("hostConfig.host must start with tcp://, unix://, or npipe://");
}
if (hostConfig?.context !== undefined && typeof hostConfig.context === "string" && hostConfig.context.trim() === "") {
throw badRequest("hostConfig.context must be a non-empty string");
}
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const result = await service.testConnection(hostConfig);
res.json(result);
} catch (error: unknown) {
if (error instanceof ApiError) throw error;
rethrowAsApiError(error);
}
});
router.get("/docker/local-available", async (_req, res) => {
try {
const { DockerClientService } = await import("@fusion/core");
const service = new DockerClientService();
const result = await service.testConnection();
res.json({ available: result.success, version: result.dockerVersion, error: result.error });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
res.json({ available: false, error: message });
}
});
router.get("/docker-nodes", async (_req, res) => { router.get("/docker-nodes", async (_req, res) => {
try { try {
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");

185
pnpm-lock.yaml generated
View File

@@ -123,6 +123,9 @@ importers:
cron-parser: cron-parser:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0 version: 5.5.0
dockerode:
specifier: ^4.0.2
version: 4.0.12
extract-zip: extract-zip:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
@@ -133,6 +136,9 @@ importers:
specifier: ^2.8.3 specifier: ^2.8.3
version: 2.8.3 version: 2.8.3
devDependencies: devDependencies:
'@types/dockerode':
specifier: ^3.3.41
version: 3.3.47
'@types/node': '@types/node':
specifier: ^25.5.0 specifier: ^25.5.0
version: 25.5.0 version: 25.5.0
@@ -1021,6 +1027,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@bcoe/v8-coverage@1.0.2': '@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1629,6 +1638,20 @@ packages:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
optional: true optional: true
'@grpc/grpc-js@1.14.3':
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.7.15':
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
engines: {node: '>=6'}
hasBin: true
'@grpc/proto-loader@0.8.0':
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
engines: {node: '>=6'}
hasBin: true
'@homebridge/node-pty-prebuilt-multiarch@0.13.1': '@homebridge/node-pty-prebuilt-multiarch@0.13.1':
resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==} resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==}
engines: {node: '>=18.0.0 <25.0.0'} engines: {node: '>=18.0.0 <25.0.0'}
@@ -1844,6 +1867,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@leichtgewicht/ip-codec@2.0.5': '@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
@@ -2658,6 +2684,12 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/docker-modem@3.0.6':
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
'@types/dockerode@3.3.47':
resolution: {integrity: sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==}
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -2706,6 +2738,9 @@ packages:
'@types/node@12.20.55': '@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@22.19.15': '@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -2753,6 +2788,9 @@ packages:
'@types/slice-ansi@4.0.0': '@types/slice-ansi@4.0.0':
resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/unist@2.0.11': '@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -3015,6 +3053,9 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'} engines: {node: '>=8'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assert-plus@1.0.0: assert-plus@1.0.0:
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -3123,6 +3164,9 @@ packages:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-path-resolve@1.0.0: better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3196,6 +3240,10 @@ packages:
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
builder-util-runtime@9.5.1: builder-util-runtime@9.5.1:
resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -3458,6 +3506,10 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
crc-32@1.2.2: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -3625,6 +3677,14 @@ packages:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'} engines: {node: '>=6'}
docker-modem@5.0.7:
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
engines: {node: '>= 8.0'}
dockerode@4.0.12:
resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==}
engines: {node: '>= 8.0'}
dom-accessibility-api@0.5.16: dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -4572,6 +4632,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.defaults@4.2.0: lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -4939,6 +5002,9 @@ packages:
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nan@2.26.2:
resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -5681,6 +5747,9 @@ packages:
spawndamnit@3.0.1: spawndamnit@3.0.1:
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
split-ca@1.0.1:
resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==}
split2@4.2.0: split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
@@ -5691,6 +5760,10 @@ packages:
sprintf-js@1.1.3: sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
ssri@12.0.0: ssri@12.0.0:
resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
@@ -5983,6 +6056,9 @@ packages:
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6032,6 +6108,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -6099,6 +6178,11 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@14.0.0: uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true hasBin: true
@@ -7312,6 +7396,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@balena/dockerignore@1.0.2': {}
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}
'@borewit/text-codec@0.2.2': {} '@borewit/text-codec@0.2.2': {}
@@ -7998,6 +8084,25 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@grpc/grpc-js@1.14.3':
dependencies:
'@grpc/proto-loader': 0.8.0
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.7.15':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.4
yargs: 17.7.2
'@grpc/proto-loader@0.8.0':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.4
yargs: 17.7.2
'@homebridge/node-pty-prebuilt-multiarch@0.13.1': '@homebridge/node-pty-prebuilt-multiarch@0.13.1':
dependencies: dependencies:
node-addon-api: 7.1.1 node-addon-api: 7.1.1
@@ -8212,6 +8317,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
'@lezer/common@0.16.1': {} '@lezer/common@0.16.1': {}
@@ -9489,6 +9596,17 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 25.5.2
'@types/ssh2': 1.15.5
'@types/dockerode@3.3.47':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 25.5.2
'@types/ssh2': 1.15.5
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -9544,6 +9662,10 @@ snapshots:
'@types/node@12.20.55': {} '@types/node@12.20.55': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@22.19.15': '@types/node@22.19.15':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -9599,6 +9721,10 @@ snapshots:
'@types/slice-ansi@4.0.0': {} '@types/slice-ansi@4.0.0': {}
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/unist@2.0.11': {} '@types/unist@2.0.11': {}
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
@@ -9982,6 +10108,10 @@ snapshots:
array-union@2.1.0: {} array-union@2.1.0: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assert-plus@1.0.0: assert-plus@1.0.0:
optional: true optional: true
@@ -10055,6 +10185,10 @@ snapshots:
basic-ftp@5.2.0: {} basic-ftp@5.2.0: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
better-path-resolve@1.0.0: better-path-resolve@1.0.0:
dependencies: dependencies:
is-windows: 1.0.2 is-windows: 1.0.2
@@ -10144,6 +10278,9 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
buildcheck@0.0.7:
optional: true
builder-util-runtime@9.5.1: builder-util-runtime@9.5.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -10411,6 +10548,12 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 vary: 1.1.2
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.26.2
optional: true
crc-32@1.2.2: {} crc-32@1.2.2: {}
crc32-stream@6.0.0: crc32-stream@6.0.0:
@@ -10573,6 +10716,27 @@ snapshots:
dependencies: dependencies:
'@leichtgewicht/ip-codec': 2.0.5 '@leichtgewicht/ip-codec': 2.0.5
docker-modem@5.0.7:
dependencies:
debug: 4.4.3
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.17.0
transitivePeerDependencies:
- supports-color
dockerode@4.0.12:
dependencies:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.14.3
'@grpc/proto-loader': 0.7.15
docker-modem: 5.0.7
protobufjs: 7.5.4
tar-fs: 2.1.4
uuid: 10.0.0
transitivePeerDependencies:
- supports-color
dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
@@ -11721,6 +11885,8 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
lodash.escaperegexp@4.1.2: {} lodash.escaperegexp@4.1.2: {}
@@ -12279,6 +12445,9 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
thenify-all: 1.6.0 thenify-all: 1.6.0
nan@2.26.2:
optional: true
nanoid@3.3.11: {} nanoid@3.3.11: {}
napi-build-utils@2.0.0: {} napi-build-utils@2.0.0: {}
@@ -13139,6 +13308,8 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
split-ca@1.0.1: {}
split2@4.2.0: {} split2@4.2.0: {}
sprintf-js@1.0.3: {} sprintf-js@1.0.3: {}
@@ -13146,6 +13317,14 @@ snapshots:
sprintf-js@1.1.3: sprintf-js@1.1.3:
optional: true optional: true
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.26.2
ssri@12.0.0: ssri@12.0.0:
dependencies: dependencies:
minipass: 7.1.3 minipass: 7.1.3
@@ -13468,6 +13647,8 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
tweetnacl@0.14.5: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -13513,6 +13694,8 @@ snapshots:
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}
@@ -13582,6 +13765,8 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@10.0.0: {}
uuid@14.0.0: {} uuid@14.0.0: {}
vary@1.1.2: {} vary@1.1.2: {}