import { useState, useEffect, useCallback, useRef } from "react"; import { Icon } from "../../icon"; import { extractErrorMessage } from "../../../utils/errors"; import { compileToolDefinition, type ToolDefinitionManifest, } from "../../../api/tool-definitions"; interface PackageEntry { name: string; } interface MountEntry { name: string; target: string; source_type: string; writable: boolean; owner: string; mode: string; file_mode: string; readonly: boolean; git_mount_ref: string; } interface ManifestEditorProps { manifest: Record | null; baseDefinitions: ToolDefinitionManifest[]; onChange: (manifest: Record) => void; definitionId?: string | null; } export const ManifestEditor = ({ manifest, baseDefinitions, onChange, definitionId, }: ManifestEditorProps) => { const [baseImage, setBaseImage] = useState(""); const [baseDefinitionId, setBaseDefinitionId] = useState(""); const [aptPackages, setAptPackages] = useState([]); const [npmPackages, setNpmPackages] = useState([]); const [pipPackages, setPipPackages] = useState([]); const [nodeVersion, setNodeVersion] = useState(""); const [userName, setUserName] = useState("user"); const [userUid, setUserUid] = useState("1000"); const [userGid, setUserGid] = useState("1000"); const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]); const [buildScripts, setBuildScripts] = useState([""]); const [startupScripts, setStartupScripts] = useState([""]); const [mounts, setMounts] = useState([]); const [command, setCommand] = useState([""]); const [workingDir, setWorkingDir] = useState("/workspace"); const [stdinOpen, setStdinOpen] = useState(true); const [tty, setTty] = useState(true); const [preview, setPreview] = useState<{ dockerfile: string; compose: string; entrypoint: string; } | null>(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(null); // Load manifest into form useEffect(() => { if (!manifest) return; const pkgs = (manifest.packages as Record) || {}; setBaseImage((manifest.base_image as string) || ""); setBaseDefinitionId((manifest.base_definition_id as string) || ""); setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p }))); setNpmPackages( ((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })), ); setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p }))); setNodeVersion((pkgs.node as Record)?.version || ""); const user = (manifest.user as Record) || {}; setUserName((user.name as string) || "user"); setUserUid(String(user.uid || "1000")); setUserGid(String(user.gid || "1000")); const env = (manifest.env as Record) || {}; setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value }))); const scripts = (manifest.scripts as Record) || {}; setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]); setStartupScripts( (scripts.startup || []).length > 0 ? scripts.startup : [""], ); const mts = (manifest.mounts as MountEntry[]) || []; setMounts(mts); const runtime = (manifest.runtime as Record) || {}; setCommand((runtime.command as string[]) || [""]); setWorkingDir((runtime.working_dir as string) || "/workspace"); setStdinOpen((runtime.stdin_open as boolean) ?? true); setTty((runtime.tty as boolean) ?? true); }, [manifest]); // Build manifest from form state const buildManifest = useCallback((): Record => { const packages: Record = {}; const apt = aptPackages.map((p) => p.name).filter(Boolean); if (apt.length) packages.apt = apt; const npm = npmPackages.map((p) => p.name).filter(Boolean); if (npm.length) packages.npm_global = npm; const pip = pipPackages.map((p) => p.name).filter(Boolean); if (pip.length) packages.pip = pip; if (nodeVersion) packages.node = { version: nodeVersion }; const env: Record = {}; envVars.forEach(({ key, value }) => { if (key) env[key] = value; }); const scripts: Record = {}; const build = buildScripts.filter(Boolean); if (build.length) scripts.build = build; const startup = startupScripts.filter(Boolean); if (startup.length) scripts.startup = startup; const mts = mounts.filter((m) => m.name && m.target); const result: Record = { packages, user: { name: userName, uid: parseInt(userUid) || 1000, gid: parseInt(userGid) || 1000, create_home: true, shell: "/bin/bash", }, env, scripts, mounts: mts, runtime: { command: command.filter(Boolean).length > 0 ? command.filter(Boolean) : ["/bin/bash"], stdin_open: stdinOpen, tty: tty, working_dir: workingDir, }, }; if (baseImage) result.base_image = baseImage; if (baseDefinitionId) result.base_definition_id = baseDefinitionId; return result; }, [ aptPackages, npmPackages, pipPackages, nodeVersion, userName, userUid, userGid, envVars, buildScripts, startupScripts, mounts, command, workingDir, stdinOpen, tty, baseImage, baseDefinitionId, ]); // Notify parent of changes — only when built manifest actually differs // from what we last sent, to avoid feedback loops with the manifest prop. const lastSentRef = useRef(""); const onChangeRef = useRef(onChange); onChangeRef.current = onChange; useEffect(() => { const m = buildManifest(); const serialized = JSON.stringify(m); if (serialized !== lastSentRef.current) { lastSentRef.current = serialized; onChangeRef.current(m); } }, [buildManifest]); const handlePreview = async () => { if (!definitionId) { setPreviewError("Save the tool definition first to preview"); return; } setPreviewLoading(true); setPreviewError(null); try { const result = await compileToolDefinition(definitionId); setPreview({ dockerfile: result.dockerfile, compose: result.compose, entrypoint: result.entrypoint, }); } catch (err) { setPreviewError(extractErrorMessage(err)); } finally { setPreviewLoading(false); } }; const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]); const removeAptPackage = (idx: number) => setAptPackages(aptPackages.filter((_, i) => i !== idx)); const updateAptPackage = (idx: number, name: string) => { const copy = [...aptPackages]; copy[idx] = { name }; setAptPackages(copy); }; const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]); const removeNpmPackage = (idx: number) => setNpmPackages(npmPackages.filter((_, i) => i !== idx)); const updateNpmPackage = (idx: number, name: string) => { const copy = [...npmPackages]; copy[idx] = { name }; setNpmPackages(copy); }; const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]); const removePipPackage = (idx: number) => setPipPackages(pipPackages.filter((_, i) => i !== idx)); const updatePipPackage = (idx: number, name: string) => { const copy = [...pipPackages]; copy[idx] = { name }; setPipPackages(copy); }; const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]); const removeEnvVar = (idx: number) => setEnvVars(envVars.filter((_, i) => i !== idx)); const updateEnvVar = (idx: number, field: "key" | "value", val: string) => { const copy = [...envVars]; copy[idx] = { ...copy[idx], [field]: val }; setEnvVars(copy); }; const addBuildScript = () => setBuildScripts([...buildScripts, ""]); const removeBuildScript = (idx: number) => setBuildScripts(buildScripts.filter((_, i) => i !== idx)); const updateBuildScript = (idx: number, val: string) => { const copy = [...buildScripts]; copy[idx] = val; setBuildScripts(copy); }; const addStartupScript = () => setStartupScripts([...startupScripts, ""]); const removeStartupScript = (idx: number) => setStartupScripts(startupScripts.filter((_, i) => i !== idx)); const updateStartupScript = (idx: number, val: string) => { const copy = [...startupScripts]; copy[idx] = val; setStartupScripts(copy); }; const addMount = () => setMounts([ ...mounts, { name: "", target: "", source_type: "repo", writable: true, owner: "", mode: "", file_mode: "", readonly: false, git_mount_ref: "", }, ]); const removeMount = (idx: number) => setMounts(mounts.filter((_, i) => i !== idx)); const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => { const copy = [...mounts]; copy[idx] = { ...copy[idx], [field]: val }; setMounts(copy); }; return (
{/* Base Image */}

Base Image

{ setBaseImage(e.target.value); setBaseDefinitionId(""); }} placeholder="e.g., ubuntu:24.04" className="form-input" disabled={!!baseDefinitionId} />
{/* Packages */}

Packages

setNodeVersion(e.target.value)} placeholder="e.g., 20" className="form-input" style={{ width: "120px" }} />
{aptPackages.map((pkg, idx) => (
updateAptPackage(idx, e.target.value)} placeholder="e.g., neovim" className="form-input" />
))}
{npmPackages.map((pkg, idx) => (
updateNpmPackage(idx, e.target.value)} placeholder="e.g., @scope/pkg" className="form-input" />
))}
{pipPackages.map((pkg, idx) => (
updatePipPackage(idx, e.target.value)} placeholder="e.g., requests" className="form-input" />
))}
{/* Runtime User */}

Runtime User

setUserName(e.target.value)} className="form-input" />
setUserUid(e.target.value)} className="form-input" />
setUserGid(e.target.value)} className="form-input" />
{/* Environment */}

Environment Variables

{envVars.map((ev, idx) => (
updateEnvVar(idx, "key", e.target.value)} placeholder="KEY" className="form-input" /> updateEnvVar(idx, "value", e.target.value)} placeholder="value" className="form-input" />
))}
{/* Build Scripts */}

Build Scripts (run during docker build)

{buildScripts.map((script, idx) => (