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
100 changes: 19 additions & 81 deletions src/cli/external-requirements/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { detectContainerRuntime, getStartHint, requireContainerRuntime } from '../detect.js';
import { detectContainerRuntime, requireContainerRuntime } from '../detect.js';
import { afterEach, describe, expect, it, vi } from 'vitest';

const { mockCheckSubprocess, mockRunSubprocessCapture } = vi.hoisted(() => ({
Expand All @@ -8,50 +8,23 @@ const { mockCheckSubprocess, mockRunSubprocessCapture } = vi.hoisted(() => ({

vi.mock('../../../lib', () => ({
CONTAINER_RUNTIMES: ['docker', 'podman', 'finch'],
START_HINTS: {
docker: 'Start Docker Desktop or run: sudo systemctl start docker',
podman: 'Run: podman machine start',
finch: 'Run: finch vm init && finch vm start',
},
checkSubprocess: mockCheckSubprocess,
runSubprocessCapture: mockRunSubprocessCapture,
isWindows: false,
}));

afterEach(() => vi.clearAllMocks());

describe('getStartHint', () => {
it('formats a single runtime hint', () => {
const result = getStartHint(['docker']);
expect(result).toBe(' docker: Start Docker Desktop or run: sudo systemctl start docker');
});

it('joins multiple runtime hints with newlines', () => {
const result = getStartHint(['docker', 'finch']);
expect(result).toBe(
' docker: Start Docker Desktop or run: sudo systemctl start docker\n' +
' finch: Run: finch vm init && finch vm start'
);
});

it('returns empty string for empty array', () => {
const result = getStartHint([]);
expect(result).toBe('');
});
});

describe('detectContainerRuntime', () => {
it('returns docker when docker is installed and ready', async () => {
it('returns docker when docker is installed', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
expect(result.runtime).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' });
expect(result.notReadyRuntimes).toEqual([]);
});

it('falls back to podman when docker not installed', async () => {
Expand All @@ -63,52 +36,18 @@ describe('detectContainerRuntime', () => {
mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => {
if (bin === 'podman' && args[0] === '--version')
return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' });
if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' });
});

it('reports docker as notReady when installed but daemon not running', async () => {
// docker exists and --version works, but info fails
mockCheckSubprocess.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'docker') return Promise.resolve(true);
return Promise.resolve(false);
});
mockRunSubprocessCapture.mockImplementation((bin: string, args: string[]) => {
if (bin === 'docker' && args[0] === '--version')
return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
if (bin === 'docker' && args[0] === 'info')
return Promise.resolve({ code: 1, stdout: '', stderr: 'Cannot connect to the Docker daemon' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
expect(result.runtime).toBeNull();
expect(result.notReadyRuntimes).toContain('docker');
});

it('returns null runtime when nothing is installed', async () => {
mockCheckSubprocess.mockResolvedValue(false);

const result = await detectContainerRuntime();
expect(result.runtime).toBeNull();
expect(result.notReadyRuntimes).toEqual([]);
});

it('returns null with notReadyRuntimes when installed but not ready', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
expect(result.runtime).toBeNull();
expect(result.notReadyRuntimes).toEqual(['docker', 'podman', 'finch']);
});

it('skips runtime when --version check fails', async () => {
Expand All @@ -118,23 +57,20 @@ describe('detectContainerRuntime', () => {
if (bin === 'docker' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' });
if (bin === 'podman' && args[0] === '--version')
return Promise.resolve({ code: 0, stdout: 'podman version 4.5.0\n', stderr: '' });
if (bin === 'podman' && args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
// finch --version also fails
if (bin === 'finch' && args[0] === '--version') return Promise.resolve({ code: 1, stdout: '', stderr: 'error' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
expect(result.runtime).toEqual({ runtime: 'podman', binary: 'podman', version: 'podman version 4.5.0' });
expect(result.notReadyRuntimes).toEqual([]);
});

it('extracts first line of --version output as version string', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version')
return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\nExtra info line\n', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

Expand All @@ -146,45 +82,47 @@ describe('detectContainerRuntime', () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await detectContainerRuntime();
// ''.trim().split('\n')[0] returns '' (not undefined), so ?? 'unknown' doesn't trigger
expect(result.runtime?.version).toBe('');
});

it('does not call docker info to check daemon status', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

await detectContainerRuntime();

// Verify 'info' was never called — this is the key behavioral change
const infoCalls = mockRunSubprocessCapture.mock.calls.filter(
(call: unknown[]) => (call[1] as string[])[0] === 'info'
);
expect(infoCalls).toHaveLength(0);
});
});

describe('requireContainerRuntime', () => {
it('returns runtime info when available', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'Docker version 24.0.0\n', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 0, stdout: '', stderr: '' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

const result = await requireContainerRuntime();
expect(result).toEqual({ runtime: 'docker', binary: 'docker', version: 'Docker version 24.0.0' });
});

it('throws with install links when no runtime found and none notReady', async () => {
it('throws with install links when no runtime found', async () => {
mockCheckSubprocess.mockResolvedValue(false);

await expect(requireContainerRuntime()).rejects.toThrow('No container runtime found');
await expect(requireContainerRuntime()).rejects.toThrow('https://docker.com');
});

it('throws with start hints when runtimes installed but not ready', async () => {
mockCheckSubprocess.mockResolvedValue(true);
mockRunSubprocessCapture.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === '--version') return Promise.resolve({ code: 0, stdout: 'v1.0.0\n', stderr: '' });
if (args[0] === 'info') return Promise.resolve({ code: 1, stdout: '', stderr: 'not running' });
return Promise.resolve({ code: 1, stdout: '', stderr: '' });
});

await expect(requireContainerRuntime()).rejects.toThrow('not ready');
await expect(requireContainerRuntime()).rejects.toThrow('Start a runtime');
});
});
39 changes: 10 additions & 29 deletions src/cli/external-requirements/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Container runtime detection.
* Detects Docker, Podman, or Finch for container operations.
*/
import { CONTAINER_RUNTIMES, type ContainerRuntime, START_HINTS } from '../../lib';
import { CONTAINER_RUNTIMES, type ContainerRuntime } from '../../lib';
import { checkSubprocess, isWindows, runSubprocessCapture } from '../../lib';

export type { ContainerRuntime } from '../../lib';
Expand All @@ -14,26 +14,19 @@ export interface ContainerRuntimeInfo {
}

export interface DetectionResult {
/** The first ready runtime, or null if none are ready. */
/** The first available runtime, or null if none are installed. */
runtime: ContainerRuntimeInfo | null;
/** Runtimes that are installed but not ready (e.g., VM not started). */
notReadyRuntimes: ContainerRuntime[];
}

/**
* Build a user-friendly hint for runtimes that are installed but not ready.
*/
export function getStartHint(runtimes: ContainerRuntime[]): string {
return runtimes.map(r => ` ${r}: ${START_HINTS[r]}`).join('\n');
}

/**
* Detect available container runtime.
* Checks docker, podman, finch in order; returns the first that is installed and usable,
* plus a list of runtimes that are installed but not ready.
* Checks docker, podman, finch in order; returns the first that is installed.
* Does not probe the daemon (e.g., `docker info`) — that would require socket
* access and can trigger OS password prompts on systems where the user is not
* in the docker group. Actual daemon availability is validated when the runtime
* is used (build, run, etc.).
*/
export async function detectContainerRuntime(): Promise<DetectionResult> {
const notReadyRuntimes: ContainerRuntime[] = [];
for (const runtime of CONTAINER_RUNTIMES) {
// Check if binary exists
const exists = isWindows ? await checkSubprocess('where', [runtime]) : await checkSubprocess('which', [runtime]);
Expand All @@ -43,31 +36,19 @@ export async function detectContainerRuntime(): Promise<DetectionResult> {
const result = await runSubprocessCapture(runtime, ['--version']);
if (result.code !== 0) continue;

// Verify the runtime is actually usable (e.g., finch VM initialized, docker daemon running)
const infoResult = await runSubprocessCapture(runtime, ['info']);
if (infoResult.code !== 0) {
notReadyRuntimes.push(runtime);
continue;
}

const version = result.stdout.trim().split('\n')[0] ?? 'unknown';
return { runtime: { runtime, binary: runtime, version }, notReadyRuntimes };
return { runtime: { runtime, binary: runtime, version } };
}
return { runtime: null, notReadyRuntimes };
return { runtime: null };
}

/**
* Get the container runtime binary path, or throw with install guidance.
* Used by commands that require a container runtime (e.g., dev).
*/
export async function requireContainerRuntime(): Promise<ContainerRuntimeInfo> {
const { runtime, notReadyRuntimes } = await detectContainerRuntime();
const { runtime } = await detectContainerRuntime();
if (!runtime) {
if (notReadyRuntimes.length > 0) {
throw new Error(
`Found ${notReadyRuntimes.join(', ')} but not ready. Start a runtime:\n${getStartHint(notReadyRuntimes)}`
);
}
throw new Error(
'No container runtime found. Install Docker (https://docker.com), ' +
'Podman (https://podman.io), or Finch (https://runfinch.com).'
Expand Down
1 change: 0 additions & 1 deletion src/cli/external-requirements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export {
export {
detectContainerRuntime,
requireContainerRuntime,
getStartHint,
type ContainerRuntime,
type ContainerRuntimeInfo,
type DetectionResult,
Expand Down
23 changes: 0 additions & 23 deletions src/cli/operations/dev/__tests__/container-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const mockSpawnSync = vi.fn();
const mockSpawn = vi.fn();
const mockExistsSync = vi.fn();
const mockDetectContainerRuntime = vi.fn();
const mockGetStartHint = vi.fn();
const mockWaitForServerReady = vi.fn();

vi.mock('child_process', () => ({
Expand All @@ -29,7 +28,6 @@ vi.mock('os', () => ({
// Path is relative to this test file in __tests__/, so 3 levels up to reach cli/
vi.mock('../../../external-requirements/detect', () => ({
detectContainerRuntime: (...args: unknown[]) => mockDetectContainerRuntime(...args),
getStartHint: (...args: unknown[]) => mockGetStartHint(...args),
}));

vi.mock('../utils', async importOriginal => {
Expand Down Expand Up @@ -71,7 +69,6 @@ function mockSuccessfulPrepare() {
// Runtime detected
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
notReadyRuntimes: [],
});
// Dockerfile exists (first call), ~/.aws exists (second call in getSpawnConfig)
mockExistsSync.mockReturnValue(true);
Expand Down Expand Up @@ -115,7 +112,6 @@ describe('ContainerDevServer', () => {
it('returns null when no container runtime detected', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: null,
notReadyRuntimes: [],
});

const server = new ContainerDevServer(defaultConfig, defaultOptions);
Expand All @@ -128,25 +124,9 @@ describe('ContainerDevServer', () => {
);
});

it('logs start hints when runtimes installed but not ready', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: null,
notReadyRuntimes: ['docker', 'podman'],
});
mockGetStartHint.mockReturnValue('Start Docker Desktop');

const server = new ContainerDevServer(defaultConfig, defaultOptions);
const result = await server.start();

expect(result).toBeNull();
expect(mockCallbacks.onLog).toHaveBeenCalledWith('error', expect.stringContaining('docker, podman'));
expect(mockGetStartHint).toHaveBeenCalledWith(['docker', 'podman']);
});

it('returns null when Dockerfile is missing', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
notReadyRuntimes: [],
});
mockExistsSync.mockReturnValue(false);

Expand Down Expand Up @@ -175,7 +155,6 @@ describe('ContainerDevServer', () => {
it('returns null when image build fails', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
notReadyRuntimes: [],
});
mockExistsSync.mockReturnValue(true);
// rm succeeds (spawnSync)
Expand Down Expand Up @@ -250,7 +229,6 @@ describe('ContainerDevServer', () => {
it('streams build output lines at system level in real-time', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
notReadyRuntimes: [],
});
mockExistsSync.mockReturnValue(true);
mockSpawnSync.mockReturnValue({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') }); // rm
Expand Down Expand Up @@ -437,7 +415,6 @@ describe('ContainerDevServer', () => {
it('skips ~/.aws mount when directory does not exist', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
notReadyRuntimes: [],
});
// existsSync is called for: (1) Dockerfile in prepare(), (2) ~/.aws in getSpawnConfig()
mockExistsSync.mockImplementation((path: string) => {
Expand Down
13 changes: 3 additions & 10 deletions src/cli/operations/dev/container-dev-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib';
import { getUvBuildArgs } from '../../../lib/packaging/build-args';
import { detectContainerRuntime, getStartHint } from '../../external-requirements/detect';
import { detectContainerRuntime } from '../../external-requirements/detect';
import { DevServer, type LogLevel, type SpawnConfig } from './dev-server';
import { waitForServerReady } from './utils';
import { type ChildProcess, spawn, spawnSync } from 'child_process';
Expand Down Expand Up @@ -58,16 +58,9 @@ export class ContainerDevServer extends DevServer {
const { onLog } = this.options.callbacks;

// 1. Detect container runtime
const { runtime, notReadyRuntimes } = await detectContainerRuntime();
const { runtime } = await detectContainerRuntime();
if (!runtime) {
if (notReadyRuntimes.length > 0) {
onLog(
'error',
`Found ${notReadyRuntimes.join(', ')} but not ready. Start a runtime:\n${getStartHint(notReadyRuntimes)}`
);
} else {
onLog('error', 'No container runtime found. Install Docker, Podman, or Finch.');
}
onLog('error', 'No container runtime found. Install Docker, Podman, or Finch.');
return false;
}
this.runtimeBinary = runtime.binary;
Expand Down
Loading
Loading