Skip to content
Merged
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ When a worktree is targeted:
| Project has devcontainer.json | `/devcontainer` |
| Different dependencies per branch | `/devcontainer` |
| Quick branch work, same deps | `/worktree` |
| No Docker available | `/worktree` |
| No Docker or Podman available | `/worktree` |
| Testing migrations/databases | `/devcontainer` |

## Configuration
Expand All @@ -89,10 +89,14 @@ When a worktree is targeted:
```json
{
"portRangeStart": 13000,
"portRangeEnd": 13099
"portRangeEnd": 13099,
"dockerPath": "podman",
"dockerComposePath": "podman-compose"
}
```

`dockerPath` and `dockerComposePath` are optional. When unset, the plugin uses Docker when available, otherwise Podman. A Podman runtime requires either `podman-compose` or `docker-compose`; the plugin detects one automatically and reports an unsupported configuration when neither is available. Set both paths explicitly to use nonstandard executable locations.

## How It Works

### Devcontainers
Expand Down
49 changes: 46 additions & 3 deletions plugin/core/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { join, basename } from 'path'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import childProcess from 'child_process'
import { PATHS, pathId } from './paths.js'

// Default configuration values
Expand Down Expand Up @@ -188,19 +189,60 @@ export async function generateOverrideConfig(workspace, port, repoName) {
return overridePath
}

export async function checkCommand(command, platform = process.platform) {
return new Promise(resolve => {
const locator = platform === 'win32' ? 'where' : 'which'
const child = childProcess.spawn(locator, [command], { stdio: 'ignore' })
child.once('close', code => resolve(code === 0))
child.once('error', () => resolve(false))
})
}

function isPodmanPath(command) {
return /(^|[\\/])podman(?:\.exe)?$/i.test(command)
}

async function detectComposePath(dockerPath) {
if (await checkCommand('podman-compose')) return 'podman-compose'
if (await checkCommand('docker-compose')) return 'docker-compose'

throw new Error(
`Unsupported container runtime configuration: no compatible Compose command is available for Podman (${dockerPath}).`
)
}


/**
* Load user configuration
*
* @returns {Promise<object>} Merged user config with defaults
*/
export async function loadUserConfig() {
let userConfig = {}
try {
const content = await readFile(PATHS.configFile, 'utf-8')
const userConfig = JSON.parse(content)
return { ...DEFAULT_CONFIG, ...userConfig }
userConfig = JSON.parse(content)
} catch {
return { ...DEFAULT_CONFIG }

}

const config = { ...DEFAULT_CONFIG, ...userConfig }

if (!config.dockerPath) {
if (await checkCommand('docker')) {
config.dockerPath = 'docker'
} else if (await checkCommand('podman')) {
config.dockerPath = 'podman'
} else {
config.dockerPath = 'docker'
}
}

if (!config.dockerComposePath && isPodmanPath(config.dockerPath)) {
config.dockerComposePath = await detectComposePath(config.dockerPath)
}

return config
}

export default {
Expand All @@ -209,4 +251,5 @@ export default {
detectInternalPort,
generateOverrideConfig,
loadUserConfig,
checkCommand,
}
50 changes: 41 additions & 9 deletions plugin/core/devcontainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { readdirSync, readFileSync, existsSync, unlinkSync } from 'fs'
import { unlink } from 'fs/promises'
import { PATHS, ensureDirs } from './paths.js'
import { allocatePort, releasePort, readPorts, getContainerPort, updatePortAllocation } from './ports.js'
import { generateOverrideConfig, getOverridePath } from './config.js'
import { generateOverrideConfig, getOverridePath, loadUserConfig } from './config.js'
import { createClone, getClonePath, removeClone } from './clones.js'
import { getCurrentBranch, getRepoRoot } from './git.js'
import { startJob, updateJob, JOB_STATUS, removeJob } from './jobs.js'
Expand Down Expand Up @@ -98,6 +98,14 @@ export function buildUpArgs(workspace, overridePath, options = {}) {
args.push('--remove-existing-container')
}

if (options.dockerPath) {
args.push('--docker-path', options.dockerPath)
}

if (options.dockerComposePath) {
args.push('--docker-compose-path', options.dockerComposePath)
}

return args
}

Expand All @@ -108,6 +116,8 @@ export function buildUpArgs(workspace, overridePath, options = {}) {
* @param {string} command - Command to execute
* @param {object} [options]
* @param {string} [options.overridePath] - Override config path
* @param {string} [options.dockerPath] - Docker CLI path
* @param {string} [options.dockerComposePath] - Docker Compose CLI path
* @returns {string[]}
*/
export function buildExecArgs(workspace, command, options = {}) {
Expand All @@ -120,6 +130,14 @@ export function buildExecArgs(workspace, command, options = {}) {
args.push('--override-config', options.overridePath)
}

if (options.dockerPath) {
args.push('--docker-path', options.dockerPath)
}

if (options.dockerComposePath) {
args.push('--docker-compose-path', options.dockerComposePath)
}

// Use sh -c to properly handle commands with arguments, pipes, and redirects
args.push('--', 'sh', '-c', command)

Expand Down Expand Up @@ -186,6 +204,9 @@ export async function up(workspaceOrBranch, options = {}) {
throw new Error(`No devcontainer.json found in ${workspace}`)
}


const config = await loadUserConfig()

// Allocate port
const portAllocation = await allocatePort(workspace, repoName, branch)
const port = portAllocation.port
Expand All @@ -196,6 +217,8 @@ export async function up(workspaceOrBranch, options = {}) {
// Build command args
const args = buildUpArgs(workspace, overridePath, {
removeExisting: options.removeExisting,
dockerPath: config.dockerPath,
dockerComposePath: config.dockerComposePath,
})

if (options.dryRun) {
Expand Down Expand Up @@ -377,11 +400,14 @@ function runUpInBackground(workspaceOrBranch, workspace, options) {
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
*/
export async function exec(workspace, command, options = {}) {
const config = await loadUserConfig()
const overridePath = getOverridePath(workspace)
const hasOverride = existsSync(overridePath)

const args = buildExecArgs(workspace, command, {
overridePath: hasOverride ? overridePath : undefined,
dockerPath: config.dockerPath,
dockerComposePath: config.dockerComposePath,
})

const result = await runCommand('devcontainer', args, {
Expand All @@ -400,11 +426,12 @@ export async function exec(workspace, command, options = {}) {
* Find Docker container ID for a workspace (running or stopped)
*
* @param {string} workspace - Workspace path
* @param {string} [dockerPath] - Path/command for docker CLI (defaults to 'docker')
* @returns {Promise<string|null>} Container ID or null if not found
*/
async function findContainerId(workspace) {
async function findContainerId(workspace, dockerPath = 'docker') {
try {
const result = await runCommand('docker', [
const result = await runCommand(dockerPath, [
'ps', '-a',
'--filter', `label=devcontainer.local_folder=${workspace}`,
'--format', '{{.ID}}',
Expand Down Expand Up @@ -490,14 +517,17 @@ export async function remove(workspace, repo, branch) {
errors: [],
}

const config = await loadUserConfig()
const dockerPath = config.dockerPath || 'docker'

// 1. Find Docker container
const containerId = await findContainerId(workspace)
const containerId = await findContainerId(workspace, dockerPath)
if (containerId) {
summary.containerFound = true

// 2. Stop container (ignore error if already stopped)
try {
await runCommand('docker', ['stop', containerId])
await runCommand(dockerPath, ['stop', containerId])
summary.containerStopped = true
} catch {
// Container might not be running
Expand All @@ -506,7 +536,7 @@ export async function remove(workspace, repo, branch) {
// 3. Get image ref before removing container
let imageRef = null
try {
const inspectResult = await runCommand('docker', [
const inspectResult = await runCommand(dockerPath, [
'inspect', containerId,
'--format', '{{.Image}}',
])
Expand All @@ -519,7 +549,7 @@ export async function remove(workspace, repo, branch) {

// 4. Remove container
try {
await runCommand('docker', ['rm', containerId])
await runCommand(dockerPath, ['rm', containerId])
summary.containerRemoved = true
} catch (err) {
summary.errors.push(`Failed to remove container: ${err.message}`)
Expand All @@ -528,7 +558,7 @@ export async function remove(workspace, repo, branch) {
// 5. Remove image (after container is removed)
if (imageRef) {
try {
await runCommand('docker', ['rmi', imageRef])
await runCommand(dockerPath, ['rmi', imageRef])
summary.imageRemoved = true
} catch {
// Image may be in use by other containers
Expand Down Expand Up @@ -654,8 +684,10 @@ export async function list(options = {}) {
*/
export async function isContainerRunning(workspace) {
try {
const config = await loadUserConfig()
const dockerPath = config.dockerPath || 'docker'
// Look for container with devcontainer.local_folder label
const result = await runCommand('docker', [
const result = await runCommand(dockerPath, [
'ps',
'--filter', `label=devcontainer.local_folder=${workspace}`,
'--format', '{{.ID}}',
Expand Down
34 changes: 9 additions & 25 deletions plugin/core/ports.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { join } from 'path'
import { mkdir, rmdir, readFile, writeFile, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { createServer } from 'net'
import { spawn } from 'child_process'
import childProcess from 'child_process'
import { PATHS } from './paths.js'
import { loadUserConfig } from './config.js'

/**
* File-based locking using mkdir (atomic on all platforms)
Expand Down Expand Up @@ -90,26 +91,6 @@ export async function writePorts(ports) {
await writeFile(PATHS.ports, content) // rename not available, just write
}

/**
* Read config.json to get port range
*
* @returns {Promise<{portRangeStart: number, portRangeEnd: number}>}
*/
async function readConfig() {
try {
const content = await readFile(PATHS.configFile, 'utf-8')
const config = JSON.parse(content)
return {
portRangeStart: config.portRangeStart || 13000,
portRangeEnd: config.portRangeEnd || 13099,
}
} catch {
return {
portRangeStart: 13000,
portRangeEnd: 13099,
}
}
}

/**
* Check if a port is free (not in use by any process)
Expand Down Expand Up @@ -156,7 +137,7 @@ export async function allocatePort(workspace, repo, branch) {

return withLock(lockPath, async () => {
const ports = await readPorts()
const config = await readConfig()
const config = await loadUserConfig()

// Check existing assignment
if (ports[workspace]) {
Expand Down Expand Up @@ -224,7 +205,7 @@ export async function listPorts() {
*/
async function runCommand(cmd, args) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
const child = childProcess.spawn(cmd, args, {
stdio: ['ignore', 'pipe', 'pipe'],
})

Expand Down Expand Up @@ -263,8 +244,11 @@ async function runCommand(cmd, args) {
*/
export async function getContainerPort(workspace) {
try {
const config = await loadUserConfig()
const dockerPath = config.dockerPath || 'docker'

// Find container with matching workspace label
const result = await runCommand('docker', [
const result = await runCommand(dockerPath, [
'ps',
'--filter', `label=devcontainer.local_folder=${workspace}`,
'--format', '{{.ID}}',
Expand All @@ -280,7 +264,7 @@ export async function getContainerPort(workspace) {
}

// Get port mappings from container
const inspectResult = await runCommand('docker', [
const inspectResult = await runCommand(dockerPath, [
'inspect',
'--format', '{{json .NetworkSettings.Ports}}',
containerId,
Expand Down
Loading