diff --git a/README.md b/README.md index 48299c8..1972680 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/plugin/core/config.js b/plugin/core/config.js index c2f9bf3..f75e124 100644 --- a/plugin/core/config.js +++ b/plugin/core/config.js @@ -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 @@ -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} 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 { @@ -209,4 +251,5 @@ export default { detectInternalPort, generateOverrideConfig, loadUserConfig, + checkCommand, } diff --git a/plugin/core/devcontainer.js b/plugin/core/devcontainer.js index 3353776..e089bb5 100644 --- a/plugin/core/devcontainer.js +++ b/plugin/core/devcontainer.js @@ -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' @@ -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 } @@ -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 = {}) { @@ -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) @@ -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 @@ -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) { @@ -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, { @@ -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} 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}}', @@ -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 @@ -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}}', ]) @@ -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}`) @@ -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 @@ -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}}', diff --git a/plugin/core/ports.js b/plugin/core/ports.js index 8bdf281..c48bcaa 100644 --- a/plugin/core/ports.js +++ b/plugin/core/ports.js @@ -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) @@ -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) @@ -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]) { @@ -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'], }) @@ -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}}', @@ -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, diff --git a/test/unit/config.test.js b/test/unit/config.test.js index 682c819..14ef0e2 100644 --- a/test/unit/config.test.js +++ b/test/unit/config.test.js @@ -9,6 +9,8 @@ import assert from 'node:assert' import { join, basename } from 'path' import { homedir } from 'os' import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs' +import childProcess from 'child_process' +import { EventEmitter } from 'events' // Module under test import { @@ -270,4 +272,73 @@ describe('loadUserConfig', () => { assert.strictEqual(config.portRangeStart, 15000) assert.strictEqual(config.portRangeEnd, 13099) // Default }) + + test('selects podman-compose when Podman is auto-detected', async (t) => { + t.mock.method(childProcess, 'spawn', (command, [target]) => { + const child = new EventEmitter() + process.nextTick(() => child.emit('close', target === 'podman' || target === 'podman-compose' ? 0 : 1)) + return child + }) + + const config = await loadUserConfig() + assert.strictEqual(config.dockerPath, 'podman') + assert.strictEqual(config.dockerComposePath, 'podman-compose') + }) + + test('uses docker-compose when configured Podman lacks podman-compose', async (t) => { + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ dockerPath: '/usr/bin/podman' }) + ) + t.mock.method(childProcess, 'spawn', (command, [target]) => { + const child = new EventEmitter() + process.nextTick(() => child.emit('close', target === 'docker-compose' ? 0 : 1)) + return child + }) + + const config = await loadUserConfig() + + assert.strictEqual(config.dockerComposePath, 'docker-compose') + }) + + test('preserves explicit runtime executable paths', async () => { + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ dockerPath: '/opt/bin/podman', dockerComposePath: '/opt/bin/podman-compose' }) + ) + + const config = await loadUserConfig() + + assert.strictEqual(config.dockerPath, '/opt/bin/podman') + assert.strictEqual(config.dockerComposePath, '/opt/bin/podman-compose') + }) + + test('preserves explicitly configured Docker executables', async () => { + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ dockerPath: 'custom-docker', dockerComposePath: 'custom-compose' }) + ) + + const config = await loadUserConfig() + + assert.strictEqual(config.dockerPath, 'custom-docker') + assert.strictEqual(config.dockerComposePath, 'custom-compose') + }) + + test('rejects Podman without an available Compose command', async (t) => { + writeFileSync( + join(testDir, 'config.json'), + JSON.stringify({ dockerPath: '/usr/bin/podman' }) + ) + t.mock.method(childProcess, 'spawn', () => { + const child = new EventEmitter() + process.nextTick(() => child.emit('close', 1)) + return child + }) + + await assert.rejects( + loadUserConfig(), + /no compatible Compose command is available for Podman/ + ) + }) }) diff --git a/test/unit/devcontainer.test.js b/test/unit/devcontainer.test.js index 509500c..9b1927f 100644 --- a/test/unit/devcontainer.test.js +++ b/test/unit/devcontainer.test.js @@ -54,6 +54,18 @@ describe('buildUpArgs', () => { assert.ok(!args.includes('--remove-existing-container')) }) + + test('includes configured container runtime paths', () => { + const args = buildUpArgs('/workspace', '/override.json', { + dockerPath: '/usr/bin/podman', + dockerComposePath: '/usr/bin/podman-compose', + }) + + assert.deepStrictEqual( + args.slice(-4), + ['--docker-path', '/usr/bin/podman', '--docker-compose-path', '/usr/bin/podman-compose'] + ) + }) }) describe('buildExecArgs', () => { @@ -93,6 +105,18 @@ describe('buildExecArgs', () => { assert.strictEqual(args[dashIndex + 2], '-c') assert.strictEqual(args[dashIndex + 3], 'npm test') }) + + test('includes configured container runtime paths', () => { + const args = buildExecArgs('/workspace', 'npm test', { + dockerPath: '/usr/bin/podman', + dockerComposePath: '/usr/bin/podman-compose', + }) + + assert.deepStrictEqual( + args.slice(3, 7), + ['--docker-path', '/usr/bin/podman', '--docker-compose-path', '/usr/bin/podman-compose'] + ) + }) }) describe('checkDevcontainerCli', () => { diff --git a/test/unit/ports.test.js b/test/unit/ports.test.js index b8890ad..48cb8e6 100644 --- a/test/unit/ports.test.js +++ b/test/unit/ports.test.js @@ -9,6 +9,8 @@ import assert from 'node:assert' import { join } from 'path' import { homedir } from 'os' import { mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs' +import childProcess from 'child_process' +import { EventEmitter } from 'events' // Module under test import { @@ -311,6 +313,56 @@ describe('getContainerPort', () => { }) }) +describe('getContainerPort with a configured runtime', () => { + const testDir = join(homedir(), '.cache/ocdc-test-runtime-port-' + Date.now()) + + beforeEach(() => { + process.env.OCDC_CACHE_DIR = testDir + process.env.OCDC_CONFIG_DIR = join(testDir, 'config') + mkdirSync(join(testDir, 'config'), { recursive: true }) + writeFileSync( + join(testDir, 'config', 'config.json'), + JSON.stringify({ + dockerPath: '/usr/bin/podman', + dockerComposePath: '/usr/bin/podman-compose', + }) + ) + }) + + afterEach(() => { + delete process.env.OCDC_CACHE_DIR + delete process.env.OCDC_CONFIG_DIR + rmSync(testDir, { recursive: true, force: true }) + }) + + test('inspects containers through the configured runtime', async (t) => { + const commands = [] + t.mock.method(childProcess, 'spawn', (command, args) => { + commands.push({ command, args }) + const child = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + process.nextTick(() => { + if (args[0] === 'ps') { + child.stdout.emit('data', 'container-id\n') + } else { + child.stdout.emit('data', '{"3000/tcp":[{"HostPort":"13042"}]}') + } + child.emit('close', 0) + }) + return child + }) + + const port = await getContainerPort('/workspace/test') + + assert.strictEqual(port, 13042) + assert.deepStrictEqual( + commands.map(({ command }) => command), + ['/usr/bin/podman', '/usr/bin/podman'] + ) + }) +}) + describe('updatePortAllocation', () => { const testDir = join(homedir(), '.cache/ocdc-test-update-' + Date.now())