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
2 changes: 2 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -32,6 +33,7 @@ function createDefaultDiagnostics(): readonly DiagnosticCheck[] {
createRustDiagnostic(),
createCargoDiagnostic(),
createStellarDiagnostic(),
createDockerDiagnostic(),
];
}

Expand Down
87 changes: 87 additions & 0 deletions src/diagnostics/docker.ts
Original file line number Diff line number Diff line change
@@ -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.`,
};
},
};
}
110 changes: 110 additions & 0 deletions tests/docker-diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 2 additions & 1 deletion tests/executable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\./,
);
});

Expand Down