Skip to content

feat: Docker-based dev server support for framework-agnostic port mapping#549

Draft
acreeger wants to merge 1 commit intomainfrom
feat/issue-548__docker-dev-server
Draft

feat: Docker-based dev server support for framework-agnostic port mapping#549
acreeger wants to merge 1 commit intomainfrom
feat/issue-548__docker-dev-server

Conversation

@acreeger
Copy link
Collaborator

@acreeger acreeger commented Feb 5, 2026

PR for issue #548

This PR was created automatically by iloom.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Complexity Assessment

Classification: COMPLEX

Metrics:

  • Estimated files affected: 8
  • Estimated lines of code: 650
  • Breaking changes: No
  • Database migrations: No
  • Cross-cutting changes: Yes
  • File architecture quality: Good
  • Architectural signals triggered: External constraints, Integration points, "How" clarity uncertain
  • Overall risk level: High

Reasoning: Docker infrastructure integration requires architectural decisions (provider pattern, volume mount strategy), multi-step orchestration (build/run/monitor/cleanup), and OS-specific handling. The implementation involves Docker lifecycle management with container-specific error handling and platform-specific volume mount behavior, making this a multi-phase implementation task with significant coordination across configuration, execution, and testing layers.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Analysis Phase

  • Fetch issue details and understand requirements
  • Research existing dev server implementation
  • Research Docker integration patterns in codebase
  • Map configuration schema and data flow
  • Identify all affected files and interfaces
  • Document findings

Executive Summary

Issue #548 requests Docker-based dev server support as an opt-in alternative to the current process-based approach. This solves the framework port-configuration problem (where Angular and similar frameworks don't respect the PORT env var) by using Docker's -p HOST_PORT:CONTAINER_PORT mapping. This is a cross-cutting change affecting 8+ files across 4 architectural layers (settings schema, command layer, manager layer, process/cleanup layer), requiring a new Docker execution path parallel to the existing execa-based path at every integration point.

Question Answer
Should devServer: "docker" config live under capabilities.web (as the issue proposes) or as a top-level docker section? Under capabilities.web -- it follows the existing pattern where basePort already lives, and the web capability gate is already checked before dev server operations.
Should Docker container naming follow the existing port convention (iloom-dev-<issue>) or use the full branch name? Use issue/identifier-based naming (iloom-dev-<issue>) as proposed in the issue. Branch names may contain characters invalid in Docker container names (slashes, etc.).
How should the containerPort default work when no EXPOSE directive exists in the Dockerfile? It should be required if no EXPOSE directive is found -- attempting to parse EXPOSE from the Dockerfile is the pragmatic default, but the user must be able to override it. Failing with a clear error is better than guessing.
Should il open / il run commands auto-start Docker dev servers the same way they auto-start process-based servers? Yes -- DevServerManager.ensureServerRunning() is the single entry point. It should gain awareness of Docker mode and delegate accordingly.

HIGH/CRITICAL Risks

  • Container cleanup on crash/force-quit: If the il dev-server process is killed (SIGKILL, crash, force quit), the Docker container keeps running. The existing ResourceCleanup.terminateDevServer() uses ProcessManager which relies on lsof/port detection -- this will find the Docker proxy process but isDevServerProcess() will reject it since the process name won't match node/npm patterns. Containers will accumulate as orphans.
  • Volume mount performance on macOS: Docker volume mounts are notoriously slow on macOS for file watching (especially with large node_modules). This is the primary platform for this tool. The issue acknowledges this but it will affect the user experience significantly for hot reload.

Impact Summary

  • Settings schema: 2 Zod schemas must be extended (CapabilitiesSettingsSchema and CapabilitiesSettingsSchemaNoDefaults) with new Docker-related fields under capabilities.web
  • Core orchestration: DevServerManager needs parallel Docker execution paths for startDevServer(), runServerForeground(), ensureServerRunning(), and isServerRunning()
  • New module needed: A DockerManager or similar utility for build/run/stop/inspect operations
  • Process detection: ProcessManager or new Docker-aware detection for container lifecycle
  • Cleanup: ResourceCleanup.terminateDevServer() needs Docker container stop+remove path
  • Terminal launch: LoomLauncher.buildDevServerTerminalOptions() may need to run docker run instead of il dev-server (or il dev-server itself handles Docker internally)
  • Commands: DevServerCommand, OpenCommand, RunCommand all use DevServerManager and need no direct changes if Docker logic is encapsulated in DevServerManager
  • Documentation: docs/iloom-commands.md needs Docker configuration section

Complete Technical Reference (click to expand for implementation details)

Problem Space Research

Problem Understanding

Some frameworks (Angular CLI, etc.) ignore the PORT environment variable and require specific CLI flags. Issue #543 (closed) attempted portFlag injection, PATH shims, and NODE_OPTIONS injection -- none worked reliably for complex npm run chains with nested tools. Docker port mapping (-p HOST:CONTAINER) solves this at the infrastructure level without modifying project source code.

Architectural Context

The dev server subsystem has three tiers:

  1. Command layer (DevServerCommand, OpenCommand, RunCommand) -- parse input, find worktree, delegate to manager
  2. Manager layer (DevServerManager) -- orchestrate start/stop/check, calls buildDevServerCommand() and runScript()
  3. Process layer (ProcessManager) -- detect processes by port using lsof/netstat, terminate by PID

Docker mode needs a parallel path at each tier while keeping the command layer unchanged (encapsulate Docker logic in the manager/utility layer).

Edge Cases Identified

  • No Docker installed: Must detect and provide clear error with install instructions
  • Dockerfile missing: When devServer: "docker" is set but no Dockerfile found at specified path
  • EXPOSE missing from Dockerfile: When containerPort is not configured and cannot be auto-detected
  • Port already in use: Docker will fail to bind if the host port is occupied -- need clear error
  • Container name collision: If a previous container with the same name wasn't cleaned up
  • File watching through volumes: OS-specific performance (macOS is slow via Docker Desktop)
  • Host networking mode: Some projects may need --network host instead of port mapping for service discovery
  • Multi-container setups: Projects that need database containers alongside dev server (out of scope for v1)

Codebase Research Findings

Affected Area: Settings Schema (SettingsManager)

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/SettingsManager.ts:170-198 - CapabilitiesSettingsSchema

The capabilities.web object currently has only basePort. New Docker fields must be added here:

  • devServer: z.enum(['process', 'docker']).default('process') -- mode selector
  • dockerFile: z.string().default('./Dockerfile') -- path to Dockerfile
  • containerPort: z.number().optional() -- port inside container (auto-detect from EXPOSE if not set)
  • dockerBuildArgs: z.record(z.string()).optional() -- build args
  • dockerRunArgs: z.array(z.string()).optional() -- additional docker run flags

Both variants must be updated:

  • CapabilitiesSettingsSchema at line 170 (with defaults)
  • CapabilitiesSettingsSchemaNoDefaults at line 203 (without defaults)

Dependencies:

  • Uses: Zod for validation
  • Used By: SettingsManager.loadSettings(), DevServerCommand, LoomManager, LoomLauncher

Affected Area: DevServerManager (Core Orchestration)

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/DevServerManager.ts

Current flow:

  • ensureServerRunning(worktreePath, port) -- checks ProcessManager.detectDevServer(port), then startDevServer() if not running
  • startDevServer(worktreePath, port) -- calls buildDevServerCommand(), spawns via execa('sh', ['-c', devCommand]) with PORT env var, polls port for readiness
  • runServerForeground(worktreePath, port, ...) -- two paths: redirectToStderr uses direct execa, else uses runScript('dev', ...)
  • isServerRunning(port) -- delegates to ProcessManager.detectDevServer(port)

For Docker mode, these methods need to:

  • startDevServer(): Run docker build then docker run -d -p HOST:CONTAINER instead of execa
  • runServerForeground(): Run docker run (without -d) instead of execa/runScript -- attach to container stdout/stderr
  • ensureServerRunning(): Check via docker ps --filter or port check (port check still works for Docker since Docker proxy listens on host port)
  • isServerRunning(): Same -- port check works, but also docker ps for named container
  • cleanup(): Must stop and remove Docker containers

Key decision: DevServerManager currently doesn't receive settings/config. The constructor takes only ProcessManager and DevServerManagerOptions (timeout/interval). Docker mode requires settings access to know the mode and Docker-specific config. Either:

  1. Pass settings through method parameters (least invasive)
  2. Inject SettingsManager into constructor (more architectural change)

Affected Area: ProcessManager (Detection/Termination)

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/process/ProcessManager.ts

detectDevServer(port) uses lsof to find LISTEN processes and validates via isDevServerProcess() which checks for node/npm/pnpm/yarn/vite/webpack patterns. Docker containers will show the Docker proxy process (com.docker.backend or similar) which will NOT match these patterns. Result: isDevServerProcess() returns false, detectDevServer() returns { isDevServer: false }.

This means ResourceCleanup.terminateDevServer() at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:477-505 will skip Docker containers because it checks if (!processInfo.isDevServer) and returns false.

Two approaches:

  1. Make ProcessManager Docker-aware (add detectDockerContainer(port) method)
  2. Create separate DockerManager for container lifecycle and have ResourceCleanup use both

Affected Area: ResourceCleanup (Container Cleanup)

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:62-96

cleanupWorktree() Step 1 terminates dev server by port. For Docker mode, this needs to:

  1. Find the Docker container by name (e.g., iloom-dev-548)
  2. Run docker stop <container> then docker rm <container>
  3. Verify port is free

Affected Area: LoomLauncher (Terminal Launch)

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/LoomLauncher.ts:151-173

launchDevServerTerminal() and buildDevServerTerminalOptions() construct the command il dev-server <identifier>. If Docker mode is handled internally by il dev-server (via DevServerManager), then LoomLauncher may not need changes. But if Docker mode requires different terminal behavior (e.g., no env setup, different command), it would need to be aware.

Current behavior: LoomLauncher doesn't load settings for the dev server terminal -- it just runs il dev-server <identifier>. The il dev-server command loads settings itself. This means LoomLauncher should remain unchanged if Docker logic is encapsulated in DevServerCommand/DevServerManager.

Affected Area: Dev Server Utility

Entry Point: /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/utils/dev-server.ts

buildDevServerCommand() returns package-manager-specific commands (pnpm dev, npm run dev, etc.). For Docker mode, this function is not relevant -- the Docker path bypasses it entirely. But getDevServerLaunchCommand() is used in some contexts and may need a Docker-aware variant.

Similar Patterns Found

  • Provider pattern: DatabaseProvider interface in /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/types/index.ts:111-128 shows the existing provider abstraction pattern. A DevServerProvider interface with ProcessDevServerProvider and DockerDevServerProvider implementations would follow this pattern.
  • Capability detection: ProjectCapabilityDetector at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ProjectCapabilityDetector.ts handles explicit vs. inferred capabilities. Docker config would flow through settings, not capability detection.

Architectural Flow Analysis

Data Flow: devServer mode configuration

Entry Point: .iloom/settings.json -> SettingsManager.loadSettings() -> IloomSettings.capabilities.web.devServer

Flow Path:

  1. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/SettingsManager.ts:697-748 - loadSettings() loads, merges, validates via Zod schema
  2. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/dev-server.ts:86-88 - DevServerCommand.execute() calls capabilityDetector.detectCapabilities() then checks for web capability
  3. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/dev-server.ts:109-116 - Gets port via getWorkspacePort()
  4. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/dev-server.ts:157-169 - Calls devServerManager.runServerForeground() -- this is where Docker mode diverges
  5. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/DevServerManager.ts:194-240 - runServerForeground() -- needs to branch on Docker mode

For background auto-start (open/run commands):

  1. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/open.ts:229 / /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/run.ts:271 - Calls devServerManager.ensureServerRunning()
  2. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/DevServerManager.ts:69-93 - ensureServerRunning() -> startDevServer() -- Docker divergence point

For cleanup:

  1. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:62-96 - cleanupWorktree() Step 1 calls terminateDevServer(port)
  2. /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:477-505 - terminateDevServer() uses ProcessManager.detectDevServer() -- Docker divergence point

Affected Interfaces (ALL must be updated):

  • CapabilitiesSettingsSchema at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/SettingsManager.ts:170-198 - Add devServer, dockerFile, containerPort, dockerBuildArgs, dockerRunArgs fields
  • CapabilitiesSettingsSchemaNoDefaults at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/SettingsManager.ts:203-230 - Mirror without defaults
  • DevServerManager at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/DevServerManager.ts - Add Docker execution paths or delegate to provider
  • ResourceCleanup.terminateDevServer() at /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:477-505 - Add Docker container cleanup

Critical Implementation Note: This is a cross-cutting change across 4 layers (settings -> commands -> managers -> process). The settings schema change must land first as it gates all Docker behavior. The ensureServerRunning() and terminateDevServer() methods need settings access they don't currently have -- this is the most invasive plumbing change.

Affected Files

  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/SettingsManager.ts:170-230 - Add Docker fields to both CapabilitiesSettingsSchema and CapabilitiesSettingsSchemaNoDefaults
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/DevServerManager.ts - Major changes: add Docker build/run/stop paths in startDevServer(), runServerForeground(), ensureServerRunning(), cleanup()
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/dev-server.ts:86-169 - Pass Docker config from settings to DevServerManager
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/ResourceCleanup.ts:62-96,477-505 - Add Docker container cleanup path in terminateDevServer()
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/lib/process/ProcessManager.ts - Potentially add Docker container detection, or create separate DockerManager
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/open.ts:229 - May need to pass settings to ensureServerRunning() call
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/commands/run.ts:271 - Same as open.ts
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/src/utils/dev-server.ts - Minor: add Docker-aware variant of getDevServerLaunchCommand() or gate existing function
  • /Users/adam/Documents/Projects/iloom-cli/feat-issue-548__docker-dev-server/docs/iloom-commands.md - Document Docker config options, EXPOSE detection, volume mounts
  • New file: src/lib/DockerManager.ts (or src/utils/docker.ts) - Docker CLI wrapper for build/run/stop/rm/ps/inspect operations

Integration Points

  • DevServerCommand depends on DevServerManager.runServerForeground() (line 157)
  • OpenCommand depends on DevServerManager.ensureServerRunning() (line 229)
  • RunCommand depends on DevServerManager.ensureServerRunning() (line 271)
  • ResourceCleanup.terminateDevServer() depends on ProcessManager.detectDevServer() (line 480)
  • LoomLauncher.launchDevServerTerminal() runs il dev-server <id> -- Docker handled inside that command (line 155)
  • LoomManager.createLoom() at lines 344, 1277 passes enableDevServer to LoomLauncher

Medium Severity Risks

  • Docker build time: Initial build can take 30-120 seconds depending on project size and layer caching, which is significantly slower than npm run dev startup. Progress feedback during build is important.
  • Volume mount configuration: The issue suggests -v ./src:/app/src but the correct mount paths are project-specific. Getting this wrong silently breaks hot reload. Consider requiring explicit volume mount config rather than guessing.
  • Docker Desktop requirement on macOS/Windows: Docker Desktop is a paid product for enterprise use. Some developers may have alternatives (colima, podman) that have slightly different CLI behavior.
  • Container port auto-detection from Dockerfile EXPOSE: Parsing Dockerfiles is non-trivial (multi-stage builds, ARG-based EXPOSE, etc.). A simple regex may miss edge cases.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Implementation Plan for Issue #548

Summary

Add opt-in Docker-based dev server support so frameworks that ignore the PORT environment variable (e.g., Angular CLI) can have their ports remapped via Docker's -p HOST_PORT:CONTAINER_PORT. This introduces a new DockerManager utility class, extends the settings schema with Docker-specific fields under capabilities.web, and adds Docker execution paths in DevServerManager, ResourceCleanup, and DevServerCommand.

Questions and Key Decisions

Question Answer Rationale
How should Docker config be plumbed to DevServerManager methods? Via an optional dockerConfig parameter on key methods (ensureServerRunning, runServerForeground, startDevServer) Avoids changing the constructor signature which would break all existing call sites and tests. Commands that already load settings pass the Docker portion through.
Should volume mounts be auto-detected? No -- users specify via dockerRunArgs Volume mount paths are project-specific (./src:/app/src vs ./app:/app/app). Guessing wrong silently breaks hot reload. Explicit config is safer.
How should containerPort default when no EXPOSE exists? Require it -- error with clear message if neither containerPort config nor EXPOSE directive is found Parsing multi-stage Dockerfiles for EXPOSE is fragile. A simple regex check on the Dockerfile is the pragmatic first pass, but must fail clearly if nothing found.
Container naming convention? iloom-dev-{sanitized-identifier} where slashes/special chars are replaced with hyphens Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]. Branch names often contain slashes.
How does ResourceCleanup find Docker containers? By container name via docker ps --filter name=iloom-dev-{id} then docker stop + docker rm Port-based detection via lsof won't identify Docker proxy processes correctly. Name-based lookup is reliable.
Where does Docker availability get checked? In DockerManager.isAvailable(), called early in DevServerCommand.execute() when Docker mode is configured Fail fast with a clear "Docker not installed" error before attempting build.

High-Level Execution Phases

  1. Schema Extension: Add Docker-related fields to CapabilitiesSettingsSchema and CapabilitiesSettingsSchemaNoDefaults
  2. DockerManager Module: Create new DockerManager class with build/run/stop/rm/inspect/isAvailable methods
  3. DevServerManager Docker Paths: Add Docker execution paths in startDevServer(), runServerForeground(), ensureServerRunning(), and isServerRunning()
  4. DevServerCommand Integration: Thread Docker config from settings into DevServerManager calls
  5. ResourceCleanup Docker Cleanup: Add Docker container stop+remove path in terminateDevServer()
  6. Open/Run Command Plumbing: Pass Docker config through ensureServerRunning() calls
  7. Tests: Unit tests for DockerManager, updated tests for DevServerManager, DevServerCommand, and ResourceCleanup
  8. Documentation: Update docs/iloom-commands.md with Docker configuration section

Quick Stats

  • 0 files for deletion
  • 7 files to modify
  • 2 new files to create (DockerManager + tests)
  • Dependencies: None (uses Docker CLI via execa, already a dependency)
  • Estimated complexity: Complex

Potential Risks (HIGH/CRITICAL only)

  • Container orphaning on crash: If the il dev-server process is killed (SIGKILL), the Docker container keeps running. The terminateDevServer() cleanup path handles graceful cleanup, but ungraceful exits leave orphans. Mitigated by: named containers allow manual docker stop iloom-dev-* and future il cleanup can scan for orphaned containers.
  • macOS volume mount performance: Docker Desktop on macOS has known slow file-watching performance through bind mounts. This affects hot reload for all projects using Docker mode. Documented as a known limitation.

Complete Implementation Guide (click to expand for step-by-step details)

Automated Test Cases to Create

Test File: src/lib/DockerManager.test.ts (NEW)

Purpose: Unit tests for Docker CLI wrapper operations

Click to expand complete test structure (45 lines)
describe('DockerManager', () => {
  describe('isAvailable', () => {
    it('should return true when docker CLI is accessible')
    it('should return false when docker CLI is not found')
  })
  
  describe('buildImage', () => {
    it('should build with default Dockerfile path')
    it('should build with custom Dockerfile path')
    it('should pass build args when configured')
    it('should throw on build failure with clear error message')
  })
  
  describe('runContainer', () => {
    it('should run with port mapping -p hostPort:containerPort')
    it('should pass additional dockerRunArgs')
    it('should use named container iloom-dev-{identifier}')
    it('should run detached (-d) for background mode')
    it('should run attached for foreground mode')
  })
  
  describe('stopAndRemoveContainer', () => {
    it('should stop then remove container by name')
    it('should not throw if container does not exist')
  })
  
  describe('isContainerRunning', () => {
    it('should return true for running named container')
    it('should return false for stopped/missing container')
  })
  
  describe('parseExposeFromDockerfile', () => {
    it('should extract port from EXPOSE directive')
    it('should return first EXPOSE if multiple exist')
    it('should return null if no EXPOSE directive')
    it('should handle EXPOSE with protocol (e.g., 4200/tcp)')
  })
  
  describe('sanitizeContainerName', () => {
    it('should replace slashes with hyphens')
    it('should remove invalid characters')
    it('should handle branch names like feat/issue-548__docker')
  })
})

Test File: src/lib/DevServerManager.test.ts (MODIFY)

Purpose: Add Docker-mode test cases alongside existing process-mode tests

Click to expand test additions (30 lines)
describe('Docker mode', () => {
  describe('ensureServerRunning', () => {
    it('should use DockerManager when dockerConfig is provided')
    it('should build image then run container in background')
    it('should detect running container and skip start')
    it('should throw if Docker is not available')
  })

  describe('runServerForeground', () => {
    it('should build image then run container in foreground (attached)')
    it('should pass port mapping as -p hostPort:containerPort')
    it('should pass dockerRunArgs to container')
    it('should use containerPort from config or Dockerfile EXPOSE')
  })

  describe('startDevServer (Docker)', () => {
    it('should build image before running container')
    it('should use configured dockerFile path')
    it('should error when containerPort unknown and no EXPOSE found')
  })
})

Existing Test Files to Update

  • src/commands/dev-server.test.ts: Add test for Docker mode detection and config passing
  • src/lib/ResourceCleanup.test.ts: Add test for Docker container cleanup path

Files to Modify

1. src/lib/SettingsManager.ts:170-230

Change: Add Docker-specific fields to both CapabilitiesSettingsSchema (lines 176-185) and CapabilitiesSettingsSchemaNoDefaults (lines 209-218) under the web object.

New fields in the web object:

  • devServer: z.enum(['process', 'docker']).default('process') -- mode selector
  • dockerFile: z.string().default('./Dockerfile') -- path to Dockerfile
  • containerPort: z.number().min(1).max(65535).optional() -- port inside container
  • dockerBuildArgs: z.record(z.string()).optional() -- build arguments
  • dockerRunArgs: z.array(z.string()).optional() -- additional docker run flags

For CapabilitiesSettingsSchemaNoDefaults, mirror the same fields but without .default() calls (use .optional() instead of .default()).

2. src/lib/DevServerManager.ts (full file)

Change: Add Docker execution paths alongside existing process-based paths.

Key modifications:

Lines 1-8 (imports): Add import for new DockerManager class.

Lines 26-39 (DevServerManagerOptions): Add optional DockerConfig interface:

// New interface for Docker configuration passed to methods
export interface DockerConfig {
  dockerFile: string      // path to Dockerfile
  containerPort?: number  // port inside container (auto-detect from EXPOSE if not set)
  dockerBuildArgs?: Record<string, string>
  dockerRunArgs?: string[]
  identifier: string      // for container naming (issue number/branch)
}

Lines 69-93 (ensureServerRunning): Add optional dockerConfig param. When provided:

  • Check if Docker container is already running via DockerManager.isContainerRunning(name)
  • If not, call new startDockerServer() method instead of startDevServer()
  • If docker running, return true

Lines 98-140 (startDevServer): After this method, add new private startDockerServer() method:

  • Call DockerManager.buildImage(worktreePath, config)
  • Resolve container port: config.containerPort ?? DockerManager.parseExposeFromDockerfile()
  • Call DockerManager.runContainer() in detached mode with port mapping
  • Poll for readiness using existing waitForServerReady() (port check still works because Docker proxy listens on host port)

Lines 179-182 (isServerRunning): Add optional dockerConfig param. When provided, check via DockerManager.isContainerRunning() in addition to port check.

Lines 194-240 (runServerForeground): Add optional dockerConfig param. When provided:

  • Build image
  • Resolve container port
  • Run container in attached mode (no -d flag) via DockerManager.runContainerForeground()
  • Container stdout/stderr streams to terminal

Lines 246-258 (cleanup): Add Docker container cleanup -- iterate runningDockerContainers map and call DockerManager.stopAndRemoveContainer().

3. src/commands/dev-server.ts:55-176 (execute method)

Change: After loading settings (line 69), extract Docker config. Before calling runServerForeground (line 157), check if Docker mode is configured and construct DockerConfig object.

Lines 69-70: After const settings = await this.settingsManager.loadSettings(), add:

// pseudocode: Extract Docker config from settings
const webSettings = settings.capabilities?.web
const isDockerMode = webSettings?.devServer === 'docker'
let dockerConfig: DockerConfig | undefined

if (isDockerMode) {
  // Validate Docker is available
  await DockerManager.assertAvailable()
  dockerConfig = {
    dockerFile: webSettings.dockerFile ?? './Dockerfile',
    containerPort: webSettings.containerPort,
    dockerBuildArgs: webSettings.dockerBuildArgs,
    dockerRunArgs: webSettings.dockerRunArgs,
    identifier: parsed.number?.toString() ?? parsed.branchName ?? parsed.originalInput,
  }
}

Line 120: Pass dockerConfig to isServerRunning():

const isRunning = await this.devServerManager.isServerRunning(port, dockerConfig)

Line 157: Pass dockerConfig to runServerForeground():

const processInfo = await this.devServerManager.runServerForeground(
  worktree.path, port, !!input.json, (pid) => { ... }, envOverrides, dockerConfig
)

4. src/lib/ResourceCleanup.ts:62-96,477-505

Change: Add Docker container cleanup path in terminateDevServer() and in the main cleanupWorktree() method.

Lines 62-96 (cleanupWorktree Step 1): After calculating port (line 67), also load Docker settings:

// pseudocode: Check if this workspace uses Docker mode
const devServerMode = settings?.capabilities?.web?.devServer ?? 'process'
const isDockerMode = devServerMode === 'docker'

Then pass isDockerMode and identifier to terminateDevServer().

Lines 477-505 (terminateDevServer): Add Docker-aware branch:

// pseudocode: Before existing process-based detection
// If Docker mode or if process detection fails, try Docker container cleanup
async terminateDevServer(port: number, dockerIdentifier?: string | number): Promise<boolean> {
  // Try Docker container first if identifier provided
  if (dockerIdentifier !== undefined) {
    const containerName = DockerManager.sanitizeContainerName(`iloom-dev-${dockerIdentifier}`)
    const isRunning = await DockerManager.isContainerRunning(containerName)
    if (isRunning) {
      await DockerManager.stopAndRemoveContainer(containerName)
      // Verify port is free after stopping
      const isFree = await this.processManager.verifyPortFree(port)
      return isFree || true // Container stopped even if port check is ambiguous
    }
  }
  
  // Existing process-based detection (unchanged)
  const processInfo = await this.processManager.detectDevServer(port)
  // ... rest of existing logic
}

5. src/commands/open.ts:218-245 (openWebBrowser method)

Change: Load Docker config from settings and pass to ensureServerRunning().

Lines 229-232: Settings are already loaded on line 220. Extract Docker config and pass:

// pseudocode: After getting settings on line 220
const webSettings = settings.capabilities?.web
const isDockerMode = webSettings?.devServer === 'docker'
const dockerConfig = isDockerMode ? buildDockerConfig(webSettings, worktree) : undefined

const serverReady = await this.devServerManager.ensureServerRunning(
  worktree.path, port, dockerConfig
)

6. src/commands/run.ts:260-287 (openWebBrowser method)

Change: Same as open.ts -- load Docker config from settings and pass to ensureServerRunning().

Lines 271-274: Settings are already loaded on line 262. Extract Docker config and pass:

// Same pattern as open.ts changes
const dockerConfig = isDockerMode ? buildDockerConfig(webSettings, worktree) : undefined
const serverReady = await this.devServerManager.ensureServerRunning(
  worktree.path, port, dockerConfig
)

7. docs/iloom-commands.md:616-656

Change: Add Docker configuration section after the existing il dev-server documentation.

Add new section documenting:

  • The capabilities.web.devServer setting ("process" or "docker")
  • All Docker-specific config fields with descriptions and defaults
  • Example .iloom/settings.json configuration
  • Requirements (Docker must be installed)
  • How port mapping works
  • Known limitations (macOS volume mount performance)

New Files to Create

src/lib/DockerManager.ts (NEW)

Purpose: Encapsulates all Docker CLI interactions. Used by DevServerManager and ResourceCleanup.

Click to expand complete structure (80 lines)
// Static utility class - all methods are static since no instance state is needed
// Uses execa for Docker CLI commands (already a project dependency)

import { execa } from 'execa'
import fs from 'fs-extra'
import path from 'path'
import { logger } from '../utils/logger.js'

export class DockerManager {
  /**
   * Check if Docker CLI is available and daemon is running
   * Throws with clear install message if not available
   */
  static async isAvailable(): Promise<boolean>
  // Run `docker info` with reject: false, return exitCode === 0

  static async assertAvailable(): Promise<void>
  // Call isAvailable(), throw if false with install instructions

  /**
   * Build Docker image
   * @param cwd - working directory (worktree path)
   * @param imageName - image tag name
   * @param dockerFile - path to Dockerfile (relative to cwd)
   * @param buildArgs - optional build arguments
   */
  static async buildImage(
    cwd: string, imageName: string, dockerFile: string,
    buildArgs?: Record<string, string>
  ): Promise<void>
  // Construct: docker build -t {imageName} -f {dockerFile} {buildArgFlags} .
  // Stream stdout/stderr for build progress feedback
  // Throw on non-zero exit with build error details

  /**
   * Run container in detached mode (background)
   * Returns container ID
   */
  static async runDetached(
    imageName: string, containerName: string,
    hostPort: number, containerPort: number,
    additionalArgs?: string[]
  ): Promise<string>
  // docker run -d --name {containerName} -p {hostPort}:{containerPort} {additionalArgs} {imageName}
  // Force-remove any existing container with same name first (handle name collision)

  /**
   * Run container in foreground (attached, blocking)
   * Streams stdout/stderr to terminal
   */
  static async runForeground(
    imageName: string, containerName: string,
    hostPort: number, containerPort: number,
    additionalArgs?: string[],
    redirectToStderr?: boolean
  ): Promise<void>
  // docker run --name {containerName} --rm -p {hostPort}:{containerPort} {additionalArgs} {imageName}
  // Use stdio: 'inherit' or redirect to stderr based on flag

  /**
   * Stop and remove container by name. No-op if container doesn't exist.
   */
  static async stopAndRemoveContainer(containerName: string): Promise<boolean>
  // docker stop {containerName} (with timeout), then docker rm {containerName}
  // Both with reject: false to handle already-stopped/removed

  /**
   * Check if a named container is currently running
   */
  static async isContainerRunning(containerName: string): Promise<boolean>
  // docker ps --filter name=^{containerName}$ --format '{{.Names}}'
  // Return stdout.trim() === containerName

  /**
   * Parse EXPOSE directive from Dockerfile
   * Returns first exposed port number, or null if none found
   */
  static async parseExposeFromDockerfile(
    dockerfilePath: string
  ): Promise<number | null>
  // Read file, regex for /^EXPOSE\s+(\d+)/m, return parsed number
  // Handle EXPOSE 4200/tcp format (strip protocol)

  /**
   * Sanitize string for use as Docker container name
   * Docker names: [a-zA-Z0-9][a-zA-Z0-9_.-]
   */
  static sanitizeContainerName(name: string): string
  // Replace slashes, spaces, special chars with hyphens
  // Ensure starts with alphanumeric
  // Truncate to reasonable length (63 chars)
}

src/lib/DockerManager.test.ts (NEW)

Purpose: Unit tests for DockerManager (see test structure above)

Detailed Execution Order

Step 1: Schema Extension + DockerManager Module

Files: src/lib/SettingsManager.ts, src/lib/DockerManager.ts (NEW), src/lib/DockerManager.test.ts (NEW)

  1. Extend CapabilitiesSettingsSchema web object at line 177-185 with Docker fields -> Verify: pnpm build succeeds, types flow correctly
  2. Extend CapabilitiesSettingsSchemaNoDefaults web object at line 210-218 with matching fields -> Verify: Both schemas accept Docker config
  3. Create src/lib/DockerManager.ts with all static methods -> Verify: pnpm build succeeds
  4. Create src/lib/DockerManager.test.ts with tests -> Verify: pnpm test:single src/lib/DockerManager.test.ts passes

Step 2: DevServerManager Docker Paths

Files: src/lib/DevServerManager.ts, src/lib/DevServerManager.test.ts

  1. Add DockerConfig interface and import DockerManager at top of file -> Verify: types compile
  2. Add dockerConfig optional parameter to ensureServerRunning(), isServerRunning(), runServerForeground() -> Verify: existing callers still work (param is optional)
  3. Add startDockerServer() private method (parallel to startDevServer()) -> Verify: method builds and runs container
  4. Add Docker branch in runServerForeground() -> Verify: foreground Docker run works
  5. Add Docker container tracking and cleanup in cleanup() -> Verify: containers cleaned up
  6. Update src/lib/DevServerManager.test.ts with Docker mode tests -> Verify: tests pass

Step 3: DevServerCommand Integration

Files: src/commands/dev-server.ts, src/commands/dev-server.test.ts

  1. Import DockerManager and DockerConfig at top of file
  2. After settings load (line 69), extract Docker config and validate Docker availability
  3. Pass dockerConfig to isServerRunning() (line 120) and runServerForeground() (line 157)
  4. Update src/commands/dev-server.test.ts with Docker mode test cases -> Verify: tests pass

Step 4: ResourceCleanup Docker Cleanup

Files: src/lib/ResourceCleanup.ts, src/lib/ResourceCleanup.test.ts

  1. Import DockerManager at top of file
  2. In cleanupWorktree() (lines 62-96), load Docker mode from settings and pass identifier to terminateDevServer()
  3. In terminateDevServer() (lines 477-505), add Docker container cleanup path before existing process-based path
  4. Update src/lib/ResourceCleanup.test.ts with Docker cleanup test cases -> Verify: tests pass

Step 5: Open/Run Command Plumbing

Files: src/commands/open.ts, src/commands/run.ts

  1. In open.ts openWebBrowser() (line 218-245), extract Docker config from already-loaded settings and pass to ensureServerRunning()
  2. In run.ts openWebBrowser() (line 260-287), same pattern -> Verify: both commands pass Docker config through

Step 6: Documentation + Build Verification

Files: docs/iloom-commands.md

  1. Add Docker configuration section after line 656 in the il dev-server documentation
  2. Document all config fields, example config, requirements, and known limitations
  3. Run pnpm build to verify complete compilation -> Verify: build succeeds
  4. Run pnpm test to verify all tests pass -> Verify: full test suite green

Execution Plan

  1. Run Step 1 (sequential - schema + DockerManager are foundations that everything else imports)
  2. Run Steps 2, 3, 4 in parallel (independent: DevServerManager, DevServerCommand, and ResourceCleanup touch different files)
  3. Run Step 5 (sequential - open/run depend on DevServerManager's new method signatures from Step 2)
  4. Run Step 6 (sequential - documentation + full build/test verification must run last)

Dependencies and Configuration

None -- Docker CLI is an external tool invoked via execa which is already a project dependency. No new npm packages needed.

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Implementation Complete

Summary

Added opt-in Docker-based dev server support for framework-agnostic port mapping. Frameworks that don't respect the PORT environment variable (e.g., Angular CLI) can now have their ports remapped via Docker's -p HOST_PORT:CONTAINER_PORT. The app runs on its default port inside the container while Docker maps it to the workspace-specific port on the host.

Changes Made

  • src/lib/SettingsManager.ts: Extended schema with Docker config fields under capabilities.web
  • src/lib/DockerManager.ts (NEW): Static utility class for Docker CLI operations (build, run, stop, inspect, port detection)
  • src/lib/DockerManager.test.ts (NEW): 64 unit tests for Docker operations
  • src/lib/DevServerManager.ts: Added Docker execution paths alongside process-based paths
  • src/lib/DevServerManager.test.ts: Added Docker mode test cases
  • src/commands/dev-server.ts: Docker config extraction and threading
  • src/commands/dev-server.test.ts: Docker mode integration tests
  • src/lib/ResourceCleanup.ts: Docker container cleanup in terminateDevServer()
  • src/lib/ResourceCleanup.test.ts: Docker cleanup tests
  • src/commands/open.ts: Docker config plumbing for ensureServerRunning()
  • src/commands/run.ts: Docker config plumbing for ensureServerRunning()
  • docs/iloom-commands.md: Docker dev server documentation

Validation Results

  • ✅ Tests: 3863 passed / 23 skipped (115 test files)
  • ✅ Typecheck: Passed
  • ✅ Lint: Passed (0 warnings)
  • ✅ Build: Passed

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Plan Amendments (Gemini Review)

The following improvements were incorporated after external AI review:

Changes to Implementation

  1. Container cleanup: Use docker rm -f (atomic force-remove) instead of docker stop + docker rm (eliminates race conditions between stop and remove)

  2. Port detection: After building the image, use docker image inspect <image> --format='{{json .Config.ExposedPorts}}' to detect exposed ports instead of regex-parsing the Dockerfile. This handles multi-stage builds, inherited images, and is the source of truth. Fall back to Dockerfile regex only if inspect fails.

  3. Signal forwarding in foreground mode: In DockerManager.runForeground(), capture SIGINT/SIGTERM and forward to the container via docker kill --signal=SIGINT <container> to allow graceful shutdown of the framework inside the container before force-removing.

  4. Documentation: Add known limitation about Linux file permissions - Docker containers typically run as root, so volume-mounted files may be owned by root on the host. Users can pass --user $(id -u):$(id -g) via dockerRunArgs to mitigate.

Decisions NOT to change (pragmatic for v1)

  • Keep static DockerManager class (vitest mocking handles statics fine)
  • Keep optional parameter threading (Strategy pattern is over-engineering for v1)
  • Keep explicit volume mount config (auto-mounting cwd is too risky/project-specific)
  • No custom build cache hashing (Docker layer caching is sufficient)
  • No auto host.docker.internal injection (users can add via dockerRunArgs)

@acreeger
Copy link
Collaborator Author

acreeger commented Feb 5, 2026

Code Review Fixes - Issue #548

Summary

Fixed three code review issues: aligned Docker container identifier derivation between startup and cleanup paths, added foreground container tracking, and eliminated Docker config extraction duplication across three command files.

Changes Made

  • src/lib/ResourceCleanup.ts: Fixed identifier mismatch -- now derives Docker identifier using same fallback logic as dev-server.ts (number > branchName > originalInput), and runs dev server cleanup for all identifier types (not just when number is defined)
  • src/lib/DevServerManager.ts: Added try/finally tracking of foreground Docker containers in runningDockerContainers map; moved DockerConfig interface to DockerManager and re-exports for backward compatibility
  • src/lib/DockerManager.ts: Added DockerConfig interface, WebSettings interface, and buildDockerConfigFromSettings() static method to centralize Docker config extraction
  • src/commands/dev-server.ts, open.ts, run.ts: Replaced duplicated Docker config extraction with calls to DockerManager.buildDockerConfigFromSettings()
  • Tests updated in DockerManager.test.ts, dev-server.test.ts, ResourceCleanup.test.ts

Validation Results

  • ✅ Tests: 3869 passed / 3892 total (23 skipped)
  • ✅ Typecheck: Passed
  • ✅ Lint: Passed

📋 Detailed Changes by File (click to expand)

Files Modified

src/lib/DockerManager.ts

Changes: Added DockerConfig interface (moved from DevServerManager), WebSettings interface, and buildDockerConfigFromSettings() static method

  • buildDockerConfigFromSettings(webSettings, identifier) returns DockerConfig | undefined based on whether Docker mode is enabled

src/lib/DevServerManager.ts

Changes: Removed local DockerConfig interface, re-exports from DockerManager; added try/finally tracking for foreground containers

  • Foreground Docker path now wraps DockerManager.runForeground() with this.runningDockerContainers.set/delete

src/lib/ResourceCleanup.ts

Changes: Fixed identifier derivation for Docker cleanup to match startup path

  • Removed guard if (number !== undefined) that skipped dev server cleanup for branch-name identifiers
  • Docker identifier now uses parsed.number?.toString() ?? parsed.branchName ?? parsed.originalInput

src/commands/dev-server.ts

Changes: Replaced inline Docker config extraction with DockerManager.buildDockerConfigFromSettings()

src/commands/open.ts

Changes: Replaced inline Docker config extraction with DockerManager.buildDockerConfigFromSettings(); removed unused DockerConfig type import

src/commands/run.ts

Changes: Replaced inline Docker config extraction with DockerManager.buildDockerConfigFromSettings(); removed unused DockerConfig type import

Test Coverage

  • src/lib/DockerManager.test.ts: Added 7 tests for buildDockerConfigFromSettings (undefined webSettings, process mode, docker mode, custom dockerFile, passthrough of all config options)
  • src/commands/dev-server.test.ts: Updated Docker mode tests to mock buildDockerConfigFromSettings
  • src/lib/ResourceCleanup.test.ts: Updated assertion from number to string for Docker identifier

…gnostic port mapping

Add opt-in Docker mode for dev servers where the app runs on its default
port inside a container and Docker maps it to the workspace-specific port
on the host. This solves the problem of frameworks like Angular CLI that
don't respect the PORT environment variable.

New DockerManager utility class handles Docker CLI operations (build, run,
stop, inspect, port detection). DevServerManager gains Docker execution
paths alongside existing process-based paths. ResourceCleanup handles
Docker container cleanup. Configuration via capabilities.web.devServer
setting in .iloom/settings.json.

Closes #548
@acreeger acreeger force-pushed the feat/issue-548__docker-dev-server branch from 3229ccb to 366492a Compare February 5, 2026 05:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant