Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/iloom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,79 @@ il dev-server feat/my-branch
- Use Ctrl+C to stop the server
- Respects `sourceEnvOnStart` setting for environment loading

#### Docker Dev Server Mode

By default, `il dev-server` runs your dev server as a native process. For frameworks that ignore the `PORT` environment variable (e.g., Angular CLI), you can use Docker mode to remap ports via Docker's `-p` flag.

**Requirements:**
- Docker must be installed and the Docker daemon must be running
- A `Dockerfile` in your project (or a custom path configured)

**Configuration:**

Set `capabilities.web.devServer` to `"docker"` in your `.iloom/settings.json`:

```json
{
"capabilities": {
"web": {
"devServer": "docker",
"dockerFile": "./Dockerfile",
"containerPort": 4200,
"dockerBuildArgs": {
"NODE_ENV": "development"
},
"dockerRunArgs": ["-v", "./src:/app/src"]
}
}
}
```

**Configuration Fields:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `devServer` | `"process"` \| `"docker"` | `"process"` | Dev server execution mode. `"process"` runs natively, `"docker"` runs inside a Docker container with port mapping. |
| `dockerFile` | `string` | `"./Dockerfile"` | Path to the Dockerfile relative to the worktree root. Only used when `devServer` is `"docker"`. |
| `containerPort` | `number` | Auto-detected | Port the application listens on inside the Docker container. If not set, iloom attempts to detect it from `EXPOSE` directives in the built Docker image (via `docker image inspect`), falling back to Dockerfile parsing. |
| `dockerBuildArgs` | `Record<string, string>` | - | Build arguments passed to `docker build` (e.g., `{"NODE_ENV": "development"}`). |
| `dockerRunArgs` | `string[]` | - | Additional flags passed to `docker run`. Use this for volume mounts, environment variables, user mapping, and other Docker options. |

**How Port Mapping Works:**

Each loom workspace gets a unique port calculated as `basePort + issue/PR number` (e.g., issue #25 gets port 3025). In Docker mode:

1. The Docker image is built from your Dockerfile
2. The container runs with `-p <workspace-port>:<container-port>`
3. Your app runs on `containerPort` inside the container (e.g., 4200 for Angular)
4. Docker maps that to the workspace port on the host (e.g., 3025)
5. You access the app at `http://localhost:3025` as usual

This means frameworks that hardcode their listen port work correctly -- Docker handles the port translation transparently.

**Container Naming:**

Containers are named `iloom-dev-<identifier>` where the identifier is derived from the issue/PR number or branch name. Special characters (slashes, etc.) are replaced with hyphens.

**Known Limitations:**

- **macOS volume mount performance:** Docker Desktop on macOS has known slow file-watching performance through bind mounts. This can affect hot reload when using volume mounts via `dockerRunArgs`. Consider using tools like `mutagen` or Docker's `synchronized` file sharing if performance is an issue.
- **Linux file permissions:** Docker containers typically run as root, so files created by the container via volume mounts may be owned by root on the host. Mitigate by passing `--user` via `dockerRunArgs`:
```json
{
"dockerRunArgs": ["--user", "1000:1000"]
}
```
- **Container orphaning on crash:** If the `il dev-server` process is killed ungracefully (e.g., SIGKILL), the Docker container may keep running. Use `docker stop` to clean up orphaned containers:
```bash
# List any orphaned iloom containers
docker ps --filter "name=iloom-dev-"

# Stop all orphaned iloom containers
docker stop $(docker ps -q --filter "name=iloom-dev-")
```
Normal cleanup via `il cleanup`, `il finish`, or Ctrl+C handles container shutdown automatically.

---

### il build
Expand Down
236 changes: 227 additions & 9 deletions src/commands/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DevServerCommand } from './dev-server.js'
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js'
import { DevServerManager } from '../lib/DevServerManager.js'
import { DockerManager } from '../lib/DockerManager.js'
import { SettingsManager } from '../lib/SettingsManager.js'
import { IdentifierParser } from '../utils/IdentifierParser.js'
import { loadWorkspaceEnv, isNoEnvFilesFoundError } from '../utils/env.js'
Expand All @@ -14,6 +15,7 @@ import fs from 'fs-extra'
vi.mock('../lib/GitWorktreeManager.js')
vi.mock('../lib/ProjectCapabilityDetector.js')
vi.mock('../lib/DevServerManager.js')
vi.mock('../lib/DockerManager.js')
vi.mock('../utils/IdentifierParser.js')
vi.mock('fs-extra')
vi.mock('../lib/SettingsManager.js')
Expand Down Expand Up @@ -209,7 +211,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
expect.any(Object)
expect.any(Object),
undefined
)
})

Expand Down Expand Up @@ -293,7 +296,8 @@ describe('DevServerCommand', () => {
4500,
false,
expect.any(Function),
expect.any(Object)
expect.any(Object),
undefined
)
})

Expand All @@ -315,7 +319,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
expect.any(Object)
expect.any(Object),
undefined
)
})
})
Expand Down Expand Up @@ -402,7 +407,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
expect.any(Object)
expect.any(Object),
undefined
)
})

Expand All @@ -417,7 +423,8 @@ describe('DevServerCommand', () => {
3087,
true,
expect.any(Function),
expect.any(Object)
expect.any(Object),
undefined
)
})
})
Expand Down Expand Up @@ -458,7 +465,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
{ DATABASE_URL: 'postgres://test', API_KEY: 'secret' }
{ DATABASE_URL: 'postgres://test', API_KEY: 'secret' },
undefined
)
})

Expand All @@ -475,7 +483,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
{}
{},
undefined
)
})

Expand All @@ -490,7 +499,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
{}
{},
undefined
)
})

Expand All @@ -513,7 +523,8 @@ describe('DevServerCommand', () => {
3087,
false,
expect.any(Function),
{}
{},
undefined
)
})

Expand All @@ -533,4 +544,211 @@ describe('DevServerCommand', () => {
expect(mockDevServerManager.runServerForeground).toHaveBeenCalled()
})
})

describe('Docker mode', () => {
beforeEach(() => {
vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
type: 'issue',
number: 87,
originalInput: '87',
})

vi.mocked(mockGitWorktreeManager.findWorktreeForIssue).mockResolvedValue(mockWorktree)

const mockCapabilities: ProjectCapabilities = {
capabilities: ['web'],
binEntries: {},
}
vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue(mockCapabilities)

vi.mocked(fs.pathExists).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue('PORT=3087\n')

// Docker is available by default in Docker mode tests
vi.mocked(DockerManager.assertAvailable).mockResolvedValue(undefined)
})

it('should construct DockerConfig and pass to isServerRunning and runServerForeground when devServer is "docker"', async () => {
const expectedDockerConfig = {
dockerFile: './Dockerfile.dev',
containerPort: 4200,
dockerBuildArgs: { NODE_ENV: 'development' },
dockerRunArgs: ['-v', './src:/app/src'],
identifier: '87',
}

vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
capabilities: {
web: {
devServer: 'docker',
dockerFile: './Dockerfile.dev',
containerPort: 4200,
dockerBuildArgs: { NODE_ENV: 'development' },
dockerRunArgs: ['-v', './src:/app/src'],
},
},
})

vi.mocked(DockerManager.buildDockerConfigFromSettings).mockReturnValue(expectedDockerConfig)

await command.execute({ identifier: '87' })

expect(DockerManager.assertAvailable).toHaveBeenCalled()
expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith(
3087,
expectedDockerConfig
)
expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith(
mockWorktree.path,
3087,
false,
expect.any(Function),
{},
expectedDockerConfig
)
})

it('should use default dockerFile when not specified in settings', async () => {
const expectedDockerConfig = {
dockerFile: './Dockerfile',
containerPort: undefined,
dockerBuildArgs: undefined,
dockerRunArgs: undefined,
identifier: '87',
}

vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
capabilities: {
web: {
devServer: 'docker',
},
},
})

vi.mocked(DockerManager.buildDockerConfigFromSettings).mockReturnValue(expectedDockerConfig)

await command.execute({ identifier: '87' })

expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith(
mockWorktree.path,
3087,
false,
expect.any(Function),
{},
expect.objectContaining({
dockerFile: './Dockerfile',
identifier: '87',
})
)
})

it('should use branchName as identifier when number is not available', async () => {
vi.mocked(mockIdentifierParser.parseForPatternDetection).mockResolvedValue({
type: 'branch',
branchName: 'feat/docker-support',
originalInput: 'feat/docker-support',
})

vi.mocked(mockGitWorktreeManager.findWorktreeForBranch).mockResolvedValue(mockWorktree)

vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
capabilities: {
web: {
devServer: 'docker',
},
},
})

vi.mocked(DockerManager.buildDockerConfigFromSettings).mockReturnValue({
dockerFile: './Dockerfile',
containerPort: undefined,
dockerBuildArgs: undefined,
dockerRunArgs: undefined,
identifier: 'feat/docker-support',
})

await command.execute({ identifier: 'feat/docker-support' })

expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith(
mockWorktree.path,
3087,
false,
expect.any(Function),
{},
expect.objectContaining({
identifier: 'feat/docker-support',
})
)
})

it('should throw when Docker is not available in Docker mode', async () => {
vi.mocked(DockerManager.assertAvailable).mockRejectedValue(
new Error(
'Docker is not available. Please ensure Docker is installed and the Docker daemon is running.'
)
)

vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
capabilities: {
web: {
devServer: 'docker',
},
},
})

// Return a config so that the code path reaches assertAvailable
vi.mocked(DockerManager.buildDockerConfigFromSettings).mockReturnValue({
dockerFile: './Dockerfile',
containerPort: undefined,
dockerBuildArgs: undefined,
dockerRunArgs: undefined,
identifier: '87',
})

await expect(command.execute({ identifier: '87' })).rejects.toThrow(
'Docker is not available'
)

// Should not proceed to run server
expect(mockDevServerManager.runServerForeground).not.toHaveBeenCalled()
})

it('should not call DockerManager.assertAvailable when devServer is "process"', async () => {
vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({
capabilities: {
web: {
devServer: 'process',
},
},
})

await command.execute({ identifier: '87' })

expect(DockerManager.assertAvailable).not.toHaveBeenCalled()
expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith(
3087,
undefined
)
expect(mockDevServerManager.runServerForeground).toHaveBeenCalledWith(
mockWorktree.path,
3087,
false,
expect.any(Function),
{},
undefined
)
})

it('should not call DockerManager.assertAvailable when devServer is not configured', async () => {
vi.mocked(mockSettingsManager.loadSettings).mockResolvedValue({})

await command.execute({ identifier: '87' })

expect(DockerManager.assertAvailable).not.toHaveBeenCalled()
expect(mockDevServerManager.isServerRunning).toHaveBeenCalledWith(
3087,
undefined
)
})
})
})
Loading