diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a0956b8..09a9773 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { createDockerDiagnostic } from '../diagnostics/docker.js'; import { createGitDiagnostic } from '../diagnostics/git.js'; import { runDiagnostics } from '../diagnostics/run.js'; import { @@ -32,6 +33,7 @@ function createDefaultDiagnostics(): readonly DiagnosticCheck[] { createRustDiagnostic(), createCargoDiagnostic(), createStellarDiagnostic(), + createDockerDiagnostic(), ]; } diff --git a/src/diagnostics/docker.ts b/src/diagnostics/docker.ts new file mode 100644 index 0000000..6fb5d2b --- /dev/null +++ b/src/diagnostics/docker.ts @@ -0,0 +1,87 @@ +import { runCommand, type RunCommand } from './process.js'; +import type { + DiagnosticCheck, + DiagnosticResult, + DiagnosticStatus, +} from './types.js'; + +export interface DockerDiagnosticOptions { + readonly execute?: RunCommand; + readonly required?: boolean; +} + +function unavailableStatus(required: boolean): DiagnosticStatus { + return required ? 'fail' : 'warn'; +} + +function parseDockerVersion(output: string): string | null { + const match = /^Docker version (\d+\.\d+\.\d+)/i.exec(output.trim()); + return match?.[1] ?? null; +} + +export function createDockerDiagnostic( + options: DockerDiagnosticOptions = {}, +): DiagnosticCheck { + const execute = options.execute ?? runCommand; + const required = options.required ?? false; + + return { + id: 'docker', + label: 'Docker', + run(): DiagnosticResult { + const client = execute('docker', ['--version']); + + if (client.error || client.status !== 0) { + return { + id: 'docker', + label: 'Docker', + status: unavailableStatus(required), + message: required + ? 'Docker is required but the Docker CLI is unavailable.' + : 'Docker CLI is not available; container-based workflows may be unavailable.', + remediation: + 'Install Docker and ensure `docker` is available on PATH if your selected workflow requires containers.', + }; + } + + const version = parseDockerVersion(client.stdout); + + if (!version) { + return { + id: 'docker', + label: 'Docker', + status: unavailableStatus(required), + message: 'Docker returned an unrecognized client version.', + remediation: + 'Verify `docker --version` works before using container-based workflows.', + }; + } + + const daemon = execute('docker', [ + 'info', + '--format', + '{{.ServerVersion}}', + ]); + + if (daemon.error || daemon.status !== 0 || daemon.stdout.trim() === '') { + return { + id: 'docker', + label: 'Docker', + status: unavailableStatus(required), + message: required + ? `Docker ${version} is installed, but the Docker daemon is unavailable.` + : `Docker ${version} is installed, but the daemon is unavailable; container-based workflows may not run.`, + remediation: + 'Start the Docker daemon before running workflows that require containers.', + }; + } + + return { + id: 'docker', + label: 'Docker', + status: 'pass', + message: `Client ${version} detected and Docker daemon is available.`, + }; + }, + }; +} diff --git a/tests/docker-diagnostic.test.ts b/tests/docker-diagnostic.test.ts new file mode 100644 index 0000000..a542d13 --- /dev/null +++ b/tests/docker-diagnostic.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { createDockerDiagnostic } from '../src/diagnostics/docker.js'; +import type { RunCommand } from '../src/diagnostics/process.js'; + +function result( + status: number | null, + stdout = '', + stderr = '', + error?: Error, +) { + return { + status, + stdout, + stderr, + ...(error === undefined ? {} : { error }), + }; +} + +describe('Docker diagnostic', () => { + it('passes when the Docker CLI and daemon are available', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + + if (args[0] === '--version') { + return result(0, 'Docker version 28.4.0, build abcdef\n'); + } + + return result(0, '28.4.0\n'); + }; + + const diagnostic = createDockerDiagnostic({ execute }).run(); + + expect(calls).toEqual([ + { executable: 'docker', args: ['--version'] }, + { + executable: 'docker', + args: ['info', '--format', '{{.ServerVersion}}'], + }, + ]); + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('28.4.0'); + }); + + it('warns when Docker is missing for a generic doctor run', () => { + const execute: RunCommand = () => + result(null, '', '', new Error('spawn docker ENOENT')); + + const diagnostic = createDockerDiagnostic({ execute }).run(); + + expect(diagnostic.status).toBe('warn'); + expect(diagnostic.remediation).toContain('if your selected workflow'); + }); + + it('fails when Docker is required but the CLI is missing', () => { + const execute: RunCommand = () => + result(null, '', '', new Error('spawn docker ENOENT')); + + const diagnostic = createDockerDiagnostic({ + execute, + required: true, + }).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toContain('required'); + }); + + it('distinguishes an unavailable daemon from a missing CLI', () => { + const execute: RunCommand = (_executable, args) => { + if (args[0] === '--version') { + return result(0, 'Docker version 28.4.0, build abcdef\n'); + } + + return result(1, '', 'SECRET_TOKEN=do-not-render'); + }; + + const diagnostic = createDockerDiagnostic({ execute }).run(); + + expect(diagnostic.status).toBe('warn'); + expect(diagnostic.message).toContain('daemon is unavailable'); + expect(JSON.stringify(diagnostic)).not.toContain('SECRET_TOKEN'); + }); + + it('fails an unavailable daemon when Docker is required', () => { + const execute: RunCommand = (_executable, args) => { + if (args[0] === '--version') { + return result(0, 'Docker version 28.4.0, build abcdef\n'); + } + + return result(1); + }; + + const diagnostic = createDockerDiagnostic({ + execute, + required: true, + }).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toContain('daemon is unavailable'); + }); + + it('reports malformed Docker client output without crashing', () => { + const execute: RunCommand = () => result(0, 'Docker unknown\n'); + + const diagnostic = createDockerDiagnostic({ execute }).run(); + + expect(diagnostic.status).toBe('warn'); + expect(diagnostic.message).toContain('unrecognized client version'); + }); +}); diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 3c8f496..73e1609 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -59,8 +59,9 @@ describe('StellarForge CLI executable', () => { expect(result.stdout).toContain('[PASS] Rust:'); expect(result.stdout).toContain('[PASS] Cargo:'); expect(result.stdout).toContain('Stellar CLI:'); + expect(result.stdout).toContain('Docker:'); expect(result.stdout).toMatch( - /Summary: 5 passed, 0 warnings, [01] failed\./, + /Summary: \d+ passed, \d+ warnings, \d+ failed\./, ); });