From 5292a86985071da078166b5e5dc26c2484fdf94f Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Wed, 11 Feb 2026 15:47:00 -0500 Subject: [PATCH 1/2] feat: add container creation with image selection, port mapping, and naming Add `runContainer` CLI method and multi-field RunContainerDialog for creating containers with image, name, port mappings, and environment variables. Press `n` on Containers or Images tab to open the dialog, with image pre-fill from the Images tab. Completed GitHub issue #12 Co-Authored-By: Claude --- README.md | 1 + progress.txt | 6 ++ src/__tests__/container-cli.test.ts | 81 ++++++++++++++++++ src/components/App.tsx | 42 ++++++++- src/components/HelpOverlay.tsx | 2 + src/components/RunContainerDialog.tsx | 118 ++++++++++++++++++++++++++ src/components/StatusBar.tsx | 4 +- src/hooks/useKeyboard.ts | 10 ++- src/services/container-cli.ts | 34 ++++++++ src/types/index.ts | 7 ++ 10 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 src/components/RunContainerDialog.tsx diff --git a/README.md b/README.md index dcaf493..f0755c1 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ contui #### Actions | Key | Action | |-----|--------| +| `n` | Run new container (Containers/Images tab) | | `s` | Start container | | `x` | Stop container | | `R` | Restart container | diff --git a/progress.txt b/progress.txt index 6ad9eab..6266dc8 100644 --- a/progress.txt +++ b/progress.txt @@ -34,3 +34,9 @@ Each entry documents: date, feature, decisions, files changed, tests, and concer - Changes: Restricted npm package contents to dist, added version display and async release check in the UI, and documented npm install/run steps with supporting tests. - Decisions: Pulled current version from env/package.json and used npm registry latest endpoint for update checks. - Issues: Release workflow may still need updates to publish; requires approval to modify .github/workflows. + +[2026-02-11] #12 feat: Add container creation with image selection, port mapping, and naming +- Files: src/types/index.ts, src/services/container-cli.ts, src/components/RunContainerDialog.tsx, src/components/App.tsx, src/hooks/useKeyboard.ts, src/components/HelpOverlay.tsx, src/__tests__/container-cli.test.ts, README.md +- Changes: Added `runContainer` CLI method with `buildRunArgs` helper, multi-field RunContainerDialog (image, name, ports, env), `n` shortcut on Containers/Images tabs with image pre-fill from Images tab +- Decisions: Exported `buildRunArgs` as standalone function for testability; used Tab key to cycle between fields in dialog; comma-separated ports/env input +- Issues: None diff --git a/src/__tests__/container-cli.test.ts b/src/__tests__/container-cli.test.ts index 5fab48f..e1f89c8 100644 --- a/src/__tests__/container-cli.test.ts +++ b/src/__tests__/container-cli.test.ts @@ -1,3 +1,84 @@ +import { buildRunArgs } from "../services/container-cli.js"; + +describe("buildRunArgs", () => { + it("should build command with image only", () => { + expect(buildRunArgs({ image: "nginx:latest" })).toBe("run --detach nginx:latest"); + }); + + it("should include --name when name is provided", () => { + expect(buildRunArgs({ image: "nginx:latest", name: "my-nginx" })).toBe( + "run --detach --name my-nginx nginx:latest" + ); + }); + + it("should include --publish for port mappings", () => { + expect(buildRunArgs({ image: "postgres:latest", ports: ["5433:5432"] })).toBe( + "run --detach --publish 5433:5432 postgres:latest" + ); + }); + + it("should include multiple --publish flags", () => { + expect(buildRunArgs({ image: "nginx:latest", ports: ["8080:80", "8443:443"] })).toBe( + "run --detach --publish 8080:80 --publish 8443:443 nginx:latest" + ); + }); + + it("should include --env for environment variables", () => { + expect(buildRunArgs({ image: "postgres:latest", env: ["POSTGRES_PASSWORD=secret"] })).toBe( + "run --detach --env POSTGRES_PASSWORD=secret postgres:latest" + ); + }); + + it("should include multiple --env flags", () => { + expect( + buildRunArgs({ + image: "postgres:latest", + env: ["POSTGRES_PASSWORD=secret", "POSTGRES_DB=mydb"], + }) + ).toBe("run --detach --env POSTGRES_PASSWORD=secret --env POSTGRES_DB=mydb postgres:latest"); + }); + + it("should combine all options", () => { + expect( + buildRunArgs({ + image: "postgres:latest", + name: "my-pg", + ports: ["5433:5432"], + env: ["POSTGRES_PASSWORD=secret"], + }) + ).toBe( + "run --detach --name my-pg --publish 5433:5432 --env POSTGRES_PASSWORD=secret postgres:latest" + ); + }); + + it("should skip empty/whitespace-only name", () => { + expect(buildRunArgs({ image: "nginx:latest", name: " " })).toBe("run --detach nginx:latest"); + }); + + it("should skip empty/whitespace-only ports", () => { + expect(buildRunArgs({ image: "nginx:latest", ports: ["", " "] })).toBe( + "run --detach nginx:latest" + ); + }); + + it("should skip empty/whitespace-only env vars", () => { + expect(buildRunArgs({ image: "nginx:latest", env: ["", " "] })).toBe( + "run --detach nginx:latest" + ); + }); + + it("should trim whitespace from all values", () => { + expect( + buildRunArgs({ + image: " nginx:latest ", + name: " my-nginx ", + ports: [" 8080:80 "], + env: [" FOO=bar "], + }) + ).toBe("run --detach --name my-nginx --publish 8080:80 --env FOO=bar nginx:latest"); + }); +}); + // Test the type parsing functions by recreating them for macOS container CLI describe("ContainerCliService parsing (macOS)", () => { const parseStatus = (state: string) => { diff --git a/src/components/App.tsx b/src/components/App.tsx index 1ed8ea7..1430d4a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -14,14 +14,15 @@ import { LogsView } from "./LogsView.js"; import { ConfirmDialog } from "./ConfirmDialog.js"; import { CreateDialog } from "./CreateDialog.js"; import { PullDialog } from "./PullDialog.js"; +import { RunContainerDialog } from "./RunContainerDialog.js"; import { useContainerData } from "../hooks/useContainerData.js"; import { useKeyboard } from "../hooks/useKeyboard.js"; import { useReleaseCheck } from "../hooks/useReleaseCheck.js"; import { containerCli } from "../services/container-cli.js"; import { getAppVersion } from "../utils/app-version.js"; -import type { Tab } from "../types/index.js"; +import type { RunContainerOptions, Tab } from "../types/index.js"; -type DialogType = "confirm" | "create" | "pull" | "logs" | "inspect" | null; +type DialogType = "confirm" | "create" | "pull" | "run" | "logs" | "inspect" | null; const APP_VERSION = getAppVersion(); @@ -60,6 +61,7 @@ interface DialogState { logs?: string; containerName?: string; createType?: "network" | "volume"; + prefilledImage?: string; } export function App(): React.ReactElement { @@ -238,6 +240,13 @@ export function App(): React.ReactElement { return; } + if (action === "run" && (activeTab === "containers" || activeTab === "images")) { + const prefilledImage = + activeTab === "images" ? images[selectedIndex]?.reference : undefined; + setDialog({ type: "run", prefilledImage }); + return; + } + const identifiers = getItemIdentifiers(); if (!identifiers) return; @@ -319,7 +328,7 @@ export function App(): React.ReactElement { setActionError(err instanceof Error ? err.message : "Action failed"); } }, - [getItemIdentifiers, activeTab, refresh, handleSelect] + [getItemIdentifiers, activeTab, refresh, handleSelect, images, selectedIndex] ); const handleConfirm = useCallback(async () => { @@ -370,6 +379,22 @@ export function App(): React.ReactElement { [dialog.createType, refresh] ); + const handleRunConfirm = useCallback( + async (options: RunContainerOptions) => { + setDialog({ type: null, data: undefined, logs: undefined }); + setActionInProgress(`Running ${options.image}...`); + try { + await containerCli.runContainer(options); + await refresh(); + setActionInProgress(null); + } catch (err) { + setActionInProgress(null); + setActionError(err instanceof Error ? err.message : "Failed to run container"); + } + }, + [refresh] + ); + const handleCancel = useCallback(() => { setDialog({ type: null, data: undefined, logs: undefined }); }, []); @@ -386,6 +411,7 @@ export function App(): React.ReactElement { onAction: handleAction, isSearchMode: searchMode, isDetailView: dialog.type === "logs" || dialog.type === "inspect", + isDialogOpen: dialog.type === "confirm" || dialog.type === "create" || dialog.type === "pull" || dialog.type === "run", activeTab, }); @@ -428,6 +454,16 @@ export function App(): React.ReactElement { ); } + if (dialog.type === "run") { + return ( + + ); + } + if (dialog.type === "logs" && dialog.logs !== undefined) { return ( void; + onCancel: () => void; +} + +const FIELDS = ["image", "name", "ports", "env"] as const; +type Field = (typeof FIELDS)[number]; + +const FIELD_LABELS: Record = { + image: "Image", + name: "Name", + ports: "Ports", + env: "Env vars", +}; + +const FIELD_HINTS: Record = { + image: "e.g. nginx:latest, postgres:16", + name: "optional container name", + ports: "HOST:CONTAINER, e.g. 8080:80, 5433:5432", + env: "KEY=VALUE, e.g. POSTGRES_PASSWORD=secret", +}; + +export function RunContainerDialog({ + initialImage = "", + onConfirm, + onCancel, +}: RunContainerDialogProps): React.ReactElement { + const [activeField, setActiveField] = useState(initialImage ? 1 : 0); + const [values, setValues] = useState>({ + image: initialImage, + name: "", + ports: "", + env: "", + }); + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + return; + } + if (key.tab) { + setActiveField((prev) => (prev + 1) % FIELDS.length); + } + }); + + const handleChange = (field: Field) => (value: string) => { + setValues((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + const image = values.image.trim(); + if (!image) return; + + const ports = values.ports + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const env = values.env + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + + onConfirm({ + image, + name: values.name.trim() || undefined, + ports: ports.length > 0 ? ports : undefined, + env: env.length > 0 ? env : undefined, + }); + }; + + return ( + + + + Run Container + + + + {FIELDS.map((field, index) => ( + + + + {activeField === index ? "▸ " : " "} + {FIELD_LABELS[field]}: + + + + {activeField === index ? ( + + ) : ( + {values[field] || FIELD_HINTS[field]} + )} + + + ))} + + + Tab: next field · Enter: run · Esc: cancel + + + ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index acf55f4..7c4d92f 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -13,8 +13,8 @@ interface StatusBarProps { } const TAB_ACTIONS: Record = { - containers: "s:start x:stop R:restart d:delete L:logs i:inspect", - images: "p:pull d:delete i:inspect", + containers: "n:run s:start x:stop R:restart d:delete L:logs i:inspect", + images: "n:run p:pull d:delete i:inspect", networks: "c:create d:delete i:inspect", volumes: "c:create d:delete i:inspect", }; diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts index d7bcc2f..4e0d5b3 100644 --- a/src/hooks/useKeyboard.ts +++ b/src/hooks/useKeyboard.ts @@ -14,6 +14,7 @@ interface UseKeyboardOptions { onAction: (action: string) => void; isSearchMode: boolean; isDetailView: boolean; + isDialogOpen: boolean; activeTab: Tab; } @@ -39,12 +40,13 @@ export function useKeyboard(options: UseKeyboardOptions): void { onAction, isSearchMode, isDetailView, + isDialogOpen, activeTab, } = options; const handleInput = useCallback( (input: string, key: { escape?: boolean; return?: boolean; upArrow?: boolean; downArrow?: boolean; tab?: boolean }) => { - if (isSearchMode) { + if (isSearchMode || isDialogOpen) { if (key.escape) { onBack(); } @@ -157,10 +159,16 @@ export function useKeyboard(options: UseKeyboardOptions): void { onAction("create"); return; } + + if (input === "n") { + onAction("run"); + return; + } }, [ isSearchMode, isDetailView, + isDialogOpen, activeTab, onQuit, onBack, diff --git a/src/services/container-cli.ts b/src/services/container-cli.ts index e73af82..93fec7a 100644 --- a/src/services/container-cli.ts +++ b/src/services/container-cli.ts @@ -7,6 +7,7 @@ import type { Image, Network, PortMapping, + RunContainerOptions, Volume, } from "../types/index.js"; @@ -74,6 +75,35 @@ interface MacOSVolumeJson { format?: string; } +export function buildRunArgs(options: RunContainerOptions): string { + const args = ["run", "--detach"]; + + if (options.name?.trim()) { + args.push("--name", options.name.trim()); + } + + if (options.ports) { + for (const port of options.ports) { + const trimmed = port.trim(); + if (trimmed) { + args.push("--publish", trimmed); + } + } + } + + if (options.env) { + for (const envVar of options.env) { + const trimmed = envVar.trim(); + if (trimmed) { + args.push("--env", trimmed); + } + } + } + + args.push(options.image.trim()); + return args.join(" "); +} + export class ContainerCliService { private async execCommand(args: string, maxBuffer = 10 * 1024 * 1024): Promise { try { @@ -146,6 +176,10 @@ export class ContainerCliService { return date.toISOString(); } + async runContainer(options: RunContainerOptions): Promise { + await this.execCommand(buildRunArgs(options)); + } + async startContainer(idOrName: string): Promise { await this.execCommand(`start ${idOrName}`); } diff --git a/src/types/index.ts b/src/types/index.ts index 4e1c26b..c060070 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -64,6 +64,13 @@ export interface ContainerDetails extends Container { }; } +export interface RunContainerOptions { + image: string; + name?: string; + ports?: string[]; // "HOST:CONTAINER" format + env?: string[]; // "KEY=VALUE" format +} + export interface HealthStatus { cliInstalled: boolean; cliVersion?: string; From 0e045861d18da29eba787117dccc82c095a43889 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Wed, 11 Feb 2026 16:12:44 -0500 Subject: [PATCH 2/2] fix: allow container creation Co-Authored-By: Claude --- README.md | 1 + src/__tests__/status-bar.test.tsx | 4 +- src/components/App.tsx | 77 +++++++++++++++++++++++---- src/components/HelpOverlay.tsx | 1 + src/components/RunContainerDialog.tsx | 20 +++---- src/components/StatusBar.tsx | 2 +- src/hooks/useKeyboard.ts | 5 ++ src/services/container-cli.ts | 13 +++++ 8 files changed, 101 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index f0755c1..b8df3fa 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ contui | Key | Action | |-----|--------| | `n` | Run new container (Containers/Images tab) | +| `e` | Edit container (recreate with new settings) | | `s` | Start container | | `x` | Stop container | | `R` | Restart container | diff --git a/src/__tests__/status-bar.test.tsx b/src/__tests__/status-bar.test.tsx index c7ebbee..77640f6 100644 --- a/src/__tests__/status-bar.test.tsx +++ b/src/__tests__/status-bar.test.tsx @@ -17,7 +17,9 @@ describe("StatusBar", () => { /> ); - expect(lastFrame()).toContain("Update available: v2.0.0"); + const frame = lastFrame()?.replace(/\n/g, " ") ?? ""; + expect(frame).toContain("Update available:"); + expect(frame).toContain("v2.0.0"); }); it("shows controls when no release message is provided", () => { diff --git a/src/components/App.tsx b/src/components/App.tsx index 1430d4a..067abbc 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -61,7 +61,8 @@ interface DialogState { logs?: string; containerName?: string; createType?: "network" | "volume"; - prefilledImage?: string; + initialValues?: { image?: string; name?: string; ports?: string; env?: string }; + editContainerId?: string; } export function App(): React.ReactElement { @@ -131,9 +132,24 @@ export function App(): React.ReactElement { case "containers": { const container = containers[selectedIndex]; if (!container) return; - inspectData = (await containerCli.inspectContainer( - container.id - )) as unknown as Record; + const details = await containerCli.inspectContainer(container.id); + const curated: Record = { + id: details.id, + image: details.image, + status: details.state, + created: details.created, + command: details.command || undefined, + entrypoint: details.config?.entrypoint || undefined, + env: details.config?.env || undefined, + ports: details.ports.length > 0 + ? details.ports.map(p => `${p.hostPort}:${p.containerPort}/${p.protocol}`) + : undefined, + network: details.networkSettings?.ipAddress + ? { ip: details.networkSettings.ipAddress, gateway: details.networkSettings.gateway } + : undefined, + mounts: details.mounts?.length ? details.mounts : undefined, + }; + inspectData = Object.fromEntries(Object.entries(curated).filter(([, v]) => v !== undefined)); break; } case "images": { @@ -160,7 +176,10 @@ export function App(): React.ReactElement { } } - setDialog({ type: "inspect", data: truncateData(inspectData) as Record }); + setDialog({ + type: "inspect", + data: activeTab === "containers" ? inspectData : truncateData(inspectData) as Record, + }); } catch (err) { setActionError(err instanceof Error ? err.message : "Failed to inspect"); } @@ -241,9 +260,37 @@ export function App(): React.ReactElement { } if (action === "run" && (activeTab === "containers" || activeTab === "images")) { - const prefilledImage = + const image = activeTab === "images" ? images[selectedIndex]?.reference : undefined; - setDialog({ type: "run", prefilledImage }); + setDialog({ type: "run", initialValues: image ? { image } : undefined }); + return; + } + + if (action === "edit" && activeTab === "containers") { + const container = containers[selectedIndex]; + if (!container) return; + try { + setActionInProgress("Loading container settings..."); + const details = await containerCli.inspectContainer(container.id); + setActionInProgress(null); + const ports = details.ports + .map((p) => `${p.hostPort}:${p.containerPort}`) + .join(", "); + const env = details.config?.env?.join(", ") ?? ""; + setDialog({ + type: "run", + initialValues: { + image: details.image, + name: details.name, + ports, + env, + }, + editContainerId: container.id, + }); + } catch (err) { + setActionInProgress(null); + setActionError(err instanceof Error ? err.message : "Failed to inspect container"); + } return; } @@ -328,7 +375,7 @@ export function App(): React.ReactElement { setActionError(err instanceof Error ? err.message : "Action failed"); } }, - [getItemIdentifiers, activeTab, refresh, handleSelect, images, selectedIndex] + [getItemIdentifiers, activeTab, refresh, handleSelect, images, selectedIndex, containers] ); const handleConfirm = useCallback(async () => { @@ -381,9 +428,16 @@ export function App(): React.ReactElement { const handleRunConfirm = useCallback( async (options: RunContainerOptions) => { + const editId = dialog.editContainerId; setDialog({ type: null, data: undefined, logs: undefined }); - setActionInProgress(`Running ${options.image}...`); try { + if (editId) { + setActionInProgress("Recreating container..."); + await containerCli.stopContainer(editId).catch(() => {}); + await containerCli.removeContainer(editId); + } else { + setActionInProgress(`Running ${options.image}...`); + } await containerCli.runContainer(options); await refresh(); setActionInProgress(null); @@ -392,7 +446,7 @@ export function App(): React.ReactElement { setActionError(err instanceof Error ? err.message : "Failed to run container"); } }, - [refresh] + [dialog.editContainerId, refresh] ); const handleCancel = useCallback(() => { @@ -457,7 +511,8 @@ export function App(): React.ReactElement { if (dialog.type === "run") { return ( diff --git a/src/components/HelpOverlay.tsx b/src/components/HelpOverlay.tsx index b3a6430..8ffe478 100644 --- a/src/components/HelpOverlay.tsx +++ b/src/components/HelpOverlay.tsx @@ -20,6 +20,7 @@ const HELP_SECTIONS = [ title: "Container Actions", items: [ { key: "n", desc: "Run new container" }, + { key: "e", desc: "Edit container" }, { key: "s", desc: "Start container" }, { key: "x", desc: "Stop container" }, { key: "R", desc: "Restart container" }, diff --git a/src/components/RunContainerDialog.tsx b/src/components/RunContainerDialog.tsx index 24e26c7..195ee59 100644 --- a/src/components/RunContainerDialog.tsx +++ b/src/components/RunContainerDialog.tsx @@ -4,7 +4,8 @@ import TextInput from "ink-text-input"; import type { RunContainerOptions } from "../types/index.js"; interface RunContainerDialogProps { - initialImage?: string; + initialValues?: { image?: string; name?: string; ports?: string; env?: string }; + isEdit?: boolean; onConfirm: (options: RunContainerOptions) => void; onCancel: () => void; } @@ -27,16 +28,17 @@ const FIELD_HINTS: Record = { }; export function RunContainerDialog({ - initialImage = "", + initialValues, + isEdit, onConfirm, onCancel, }: RunContainerDialogProps): React.ReactElement { - const [activeField, setActiveField] = useState(initialImage ? 1 : 0); + const [activeField, setActiveField] = useState(initialValues?.image ? 1 : 0); const [values, setValues] = useState>({ - image: initialImage, - name: "", - ports: "", - env: "", + image: initialValues?.image ?? "", + name: initialValues?.name ?? "", + ports: initialValues?.ports ?? "", + env: initialValues?.env ?? "", }); useInput((_input, key) => { @@ -84,7 +86,7 @@ export function RunContainerDialog({ > - Run Container + {isEdit ? "Edit Container" : "Run Container"} @@ -111,7 +113,7 @@ export function RunContainerDialog({ ))} - Tab: next field · Enter: run · Esc: cancel + Tab: next field · Enter: {isEdit ? "save" : "run"} · Esc: cancel ); diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 7c4d92f..fdaa81e 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -13,7 +13,7 @@ interface StatusBarProps { } const TAB_ACTIONS: Record = { - containers: "n:run s:start x:stop R:restart d:delete L:logs i:inspect", + containers: "n:run e:edit s:start x:stop R:restart d:delete L:logs i:inspect", images: "n:run p:pull d:delete i:inspect", networks: "c:create d:delete i:inspect", volumes: "c:create d:delete i:inspect", diff --git a/src/hooks/useKeyboard.ts b/src/hooks/useKeyboard.ts index 4e0d5b3..99c66ab 100644 --- a/src/hooks/useKeyboard.ts +++ b/src/hooks/useKeyboard.ts @@ -164,6 +164,11 @@ export function useKeyboard(options: UseKeyboardOptions): void { onAction("run"); return; } + + if (input === "e") { + onAction("edit"); + return; + } }, [ isSearchMode, diff --git a/src/services/container-cli.ts b/src/services/container-cli.ts index 93fec7a..34b7c73 100644 --- a/src/services/container-cli.ts +++ b/src/services/container-cli.ts @@ -30,7 +30,14 @@ interface MacOSContainerJson { initProcess?: { arguments?: string[]; executable?: string; + environment?: string[]; }; + env?: string[]; + mounts?: Array<{ + type?: Record; + source: string; + destination: string; + }>; }; status: string; startedDate?: number; @@ -234,7 +241,13 @@ export class ContainerCliService { entrypoint: data.configuration.initProcess?.executable ? [data.configuration.initProcess.executable] : undefined, + env: data.configuration.initProcess?.environment || data.configuration.env, }, + mounts: data.configuration.mounts?.map(m => ({ + type: m.type ? Object.keys(m.type)[0] || "unknown" : "unknown", + source: m.source, + destination: m.destination, + })), }; }