feat: enforce single tool type with port config

- Replace interfaces array with interface_type string and requires_port boolean
- Add database migration for schema change
- Update backend model, API schemas, and validation
- Update frontend types and tool workshop UI
- Add dropdown for interface type selection
- Conditionally show/hide port fields based on requires_port
- Update tests and mock data
- All frontend tests pass (37/37)
- Frontend typecheck and lint pass
This commit is contained in:
2026-05-22 20:32:10 +00:00
parent 5c17de0c3c
commit 0fa926284c
18 changed files with 558 additions and 232 deletions
+40 -34
View File
@@ -48,7 +48,8 @@ export const ToolWorkshopPage = () => {
display_name: "",
description: "",
category: "",
interfaces: [] as string[],
interface_type: "web" as "web" | "terminal",
requires_port: true,
default_port: "",
definition_type: "compose" as "compose" | "dockerfile",
compose_template: "",
@@ -120,7 +121,8 @@ export const ToolWorkshopPage = () => {
display_name: "",
description: "",
category: "",
interfaces: [],
interface_type: "web",
requires_port: true,
default_port: "",
definition_type: "compose",
compose_template: "",
@@ -140,7 +142,8 @@ export const ToolWorkshopPage = () => {
display_name: toolType.display_name,
description: toolType.description || "",
category: toolType.category || "",
interfaces: toolType.interfaces || [],
interface_type: (toolType.interface_type as "web" | "terminal") || "web",
requires_port: toolType.requires_port ?? true,
default_port: toolType.default_port?.toString() || "",
definition_type: toolType.definition_type || "compose",
compose_template: toolType.compose_template || "",
@@ -205,7 +208,7 @@ export const ToolWorkshopPage = () => {
return;
}
if (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port))) {
if (toolTypeForm.requires_port && (!toolTypeForm.default_port.trim() || isNaN(Number(toolTypeForm.default_port)))) {
setToolTypeError("Default port is required and must be a number");
return;
}
@@ -239,8 +242,9 @@ export const ToolWorkshopPage = () => {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
default_port: Number(toolTypeForm.default_port),
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0,
definition_type: toolTypeForm.definition_type,
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
@@ -256,8 +260,9 @@ export const ToolWorkshopPage = () => {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interfaces: toolTypeForm.interfaces.length > 0 ? toolTypeForm.interfaces : undefined,
default_port: Number(toolTypeForm.default_port),
interface_type: toolTypeForm.interface_type,
requires_port: toolTypeForm.requires_port,
default_port: toolTypeForm.requires_port ? Number(toolTypeForm.default_port) : 0,
definition_type: toolTypeForm.definition_type,
compose_template: toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template: toolTypeForm.definition_type === "dockerfile" ? template : undefined,
@@ -553,7 +558,7 @@ export const ToolWorkshopPage = () => {
{toolType.display_name}
</div>
<div style={{ fontSize: "0.8125rem", opacity: 0.8, marginTop: "0.125rem" }}>
{toolType.category || "Uncategorized"} · Port {toolType.default_port || "N/A"}
{toolType.category || "Uncategorized"} · {toolType.interface_type === "web" ? `Port ${toolType.default_port}` : "Terminal"}
</div>
</div>
{toolType.is_builtin && (
@@ -649,7 +654,7 @@ export const ToolWorkshopPage = () => {
</h1>
{!isCreating && (
<p className="muted" style={{ margin: 0 }}>
{selectedToolType?.name} · {selectedToolType?.definition_type} · Port {selectedToolType?.default_port}
{selectedToolType?.name} · {selectedToolType?.definition_type} · {selectedToolType?.interface_type === "web" ? `Port ${selectedToolType?.default_port}` : "Terminal"}
</p>
)}
</div>
@@ -763,6 +768,30 @@ export const ToolWorkshopPage = () => {
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="tool-type-interface">Interface Type</label>
<select
id="tool-type-interface"
value={toolTypeForm.interface_type}
onChange={(e) => {
const value = e.target.value as "web" | "terminal";
setToolTypeForm({
...toolTypeForm,
interface_type: value,
requires_port: value === "web",
default_port: value === "web" ? toolTypeForm.default_port : "",
});
setToolTypeDirty(true);
}}
className="form-input"
>
<option value="web">Web</option>
<option value="terminal">Terminal</option>
</select>
</div>
</div>
{toolTypeForm.requires_port && (
<div className="form-group">
<label htmlFor="tool-type-default-port">Default Port *</label>
<input
id="tool-type-default-port"
@@ -777,30 +806,7 @@ export const ToolWorkshopPage = () => {
required
/>
</div>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
{["web", "terminal"].map((iface) => (
<label key={iface} className="checkbox-label">
<input
type="checkbox"
checked={toolTypeForm.interfaces.includes(iface)}
onChange={(e) => {
if (e.target.checked) {
setToolTypeForm({ ...toolTypeForm, interfaces: [...toolTypeForm.interfaces, iface] });
} else {
setToolTypeForm({ ...toolTypeForm, interfaces: toolTypeForm.interfaces.filter((i) => i !== iface) });
}
setToolTypeDirty(true);
}}
/>
{iface}
</label>
))}
</div>
</div>
)}
<div className="form-group">
<label htmlFor="tool-type-template">{toolTypeForm.definition_type === "compose" ? "Compose Template" : "Dockerfile"} *</label>