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
211 changes: 211 additions & 0 deletions pgpm/cli/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {
checkDocker,
checkDockerCompose,
checkNode,
checkPsql,
CommandRunner,
detectPlatform,
dockerDaemonGuidance,
dockerInstallGuidance,
ExecResult,
getDockerStatus,
parsePsqlMajor,
psqlInstallGuidance,
summarizeChecks
} from '../src/utils/doctor';

const ok = (stdout = ''): ExecResult => ({ code: 0, stdout, stderr: '' });
const notFound = (): ExecResult => ({ code: 127, stdout: '', stderr: 'command not found' });
const fail = (stderr = ''): ExecResult => ({ code: 1, stdout: '', stderr });

const makeRunner = (responses: Record<string, ExecResult>): CommandRunner => {
return async (command, args) => {
const key = `${command} ${args.join(' ')}`;
if (key in responses) {
return responses[key];
}
return notFound();
};
};

describe('detectPlatform', () => {
it('detects macos', () => {
expect(detectPlatform('darwin')).toEqual({ platform: 'macos' });
});

it('detects windows', () => {
expect(detectPlatform('win32')).toEqual({ platform: 'windows' });
});

it('detects linux distro from os-release', () => {
const readFile = (path: string): string => {
if (path === '/etc/os-release') return 'NAME="Ubuntu"\nID=ubuntu\n';
if (path === '/proc/version') return 'Linux version 6.5.0-generic';
throw new Error('not found');
};
expect(detectPlatform('linux', readFile)).toEqual({ platform: 'linux', distro: 'ubuntu' });
});

it('detects wsl via /proc/version', () => {
const readFile = (path: string): string => {
if (path === '/etc/os-release') return 'ID=ubuntu\n';
if (path === '/proc/version') return 'Linux version 5.15.90.1-microsoft-standard-WSL2';
throw new Error('not found');
};
expect(detectPlatform('linux', readFile)).toEqual({ platform: 'wsl', distro: 'ubuntu' });
});

it('handles unreadable files', () => {
const readFile = (): string => {
throw new Error('not found');
};
expect(detectPlatform('linux', readFile)).toEqual({ platform: 'linux', distro: undefined });
});
});

describe('getDockerStatus', () => {
it('reports missing binary', async () => {
const status = await getDockerStatus(makeRunner({}));
expect(status).toEqual({ binary: false, daemon: false });
});

it('reports binary present but daemon down', async () => {
const status = await getDockerStatus(makeRunner({
'docker --version': ok('Docker version 27.0.0'),
'docker info': fail('Cannot connect to the Docker daemon')
}));
expect(status).toEqual({ binary: true, daemon: false });
});

it('reports binary and daemon available', async () => {
const status = await getDockerStatus(makeRunner({
'docker --version': ok('Docker version 27.0.0'),
'docker info': ok('Server: ...')
}));
expect(status).toEqual({ binary: true, daemon: true });
});
});

describe('checkDocker', () => {
it('fails with install guidance when binary is missing', async () => {
const result = await checkDocker({ platform: 'macos' }, makeRunner({}));
expect(result.status).toBe('fail');
expect(result.remediation).toBe(dockerInstallGuidance({ platform: 'macos' }));
});

it('fails with daemon guidance when daemon is down', async () => {
const result = await checkDocker({ platform: 'linux', distro: 'ubuntu' }, makeRunner({
'docker --version': ok('Docker version 27.0.0'),
'docker info': fail('Cannot connect to the Docker daemon')
}));
expect(result.status).toBe('fail');
expect(result.remediation).toBe(dockerDaemonGuidance({ platform: 'linux', distro: 'ubuntu' }));
expect(result.remediation).toContain('systemctl start docker');
});

it('passes when docker is fully available', async () => {
const result = await checkDocker({ platform: 'linux' }, makeRunner({
'docker --version': ok('Docker version 27.0.0'),
'docker info': ok('Server: ...')
}));
expect(result.status).toBe('pass');
});
});

describe('checkDockerCompose', () => {
it('passes with the compose plugin', async () => {
const result = await checkDockerCompose({ platform: 'linux' }, makeRunner({
'docker compose version': ok('Docker Compose version v2.27.0')
}));
expect(result.status).toBe('pass');
});

it('passes with standalone docker-compose', async () => {
const result = await checkDockerCompose({ platform: 'linux' }, makeRunner({
'docker-compose --version': ok('docker-compose version 1.29.2')
}));
expect(result.status).toBe('pass');
});

it('warns when compose is missing', async () => {
const result = await checkDockerCompose({ platform: 'macos' }, makeRunner({}));
expect(result.status).toBe('warn');
expect(result.remediation).toContain('Docker Desktop');
});
});

describe('parsePsqlMajor', () => {
it('parses standard version output', () => {
expect(parsePsqlMajor('psql (PostgreSQL) 18.1')).toBe(18);
expect(parsePsqlMajor('psql (PostgreSQL) 15.4 (Ubuntu 15.4-1)')).toBe(15);
});

it('returns null for unparseable output', () => {
expect(parsePsqlMajor('something unexpected')).toBeNull();
});
});

describe('checkPsql', () => {
it('fails with OS guidance when psql is missing', async () => {
const result = await checkPsql({ platform: 'macos' }, makeRunner({}));
expect(result.status).toBe('fail');
expect(result.remediation).toBe(psqlInstallGuidance({ platform: 'macos' }));
expect(result.remediation).toContain('brew install libpq');
});

it('warns for old psql versions', async () => {
const result = await checkPsql({ platform: 'linux', distro: 'ubuntu' }, makeRunner({
'psql --version': ok('psql (PostgreSQL) 14.9')
}));
expect(result.status).toBe('warn');
expect(result.message).toContain('14');
});

it('passes for supported psql versions', async () => {
const result = await checkPsql({ platform: 'linux' }, makeRunner({
'psql --version': ok('psql (PostgreSQL) 18.1')
}));
expect(result.status).toBe('pass');
});
});

describe('checkNode', () => {
it('fails below the minimum version', () => {
expect(checkNode('v16.20.0').status).toBe('fail');
});

it('warns below the recommended version', () => {
expect(checkNode('v18.19.0').status).toBe('warn');
});

it('passes on recommended versions', () => {
expect(checkNode('v22.14.0').status).toBe('pass');
});
});

describe('guidance per platform', () => {
it('docker install guidance differs by platform', () => {
expect(dockerInstallGuidance({ platform: 'macos' })).toContain('Docker Desktop');
expect(dockerInstallGuidance({ platform: 'linux', distro: 'ubuntu' })).toContain('apt-get');
expect(dockerInstallGuidance({ platform: 'linux', distro: 'fedora' })).toContain('dnf');
expect(dockerInstallGuidance({ platform: 'wsl' })).toContain('WSL');
});

it('psql install guidance differs by platform', () => {
expect(psqlInstallGuidance({ platform: 'macos' })).toContain('brew');
expect(psqlInstallGuidance({ platform: 'linux', distro: 'ubuntu' })).toContain('apt-get');
expect(psqlInstallGuidance({ platform: 'linux', distro: 'fedora' })).toContain('dnf');
expect(psqlInstallGuidance({ platform: 'windows' })).toContain('winget');
});
});

describe('summarizeChecks', () => {
it('counts statuses', () => {
expect(summarizeChecks([
{ name: 'a', status: 'pass', message: '' },
{ name: 'b', status: 'warn', message: '' },
{ name: 'c', status: 'fail', message: '' },
{ name: 'd', status: 'pass', message: '' }
])).toEqual({ failed: 1, warned: 1, passed: 2 });
});
});
26 changes: 14 additions & 12 deletions pgpm/cli/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { checkForUpdates } from '@inquirerer/utils';
import { CLIOptions, Inquirerer, ParsedArgs, cliExitWithError, extractFirst, getPackageJson } from 'inquirerer';
import { cliExitWithError, CLIOptions, extractFirst, getPackageJson,Inquirerer, ParsedArgs } from 'inquirerer';
import { teardownPgPools } from 'pg-cache';

import add from './commands/add';
Expand All @@ -9,6 +9,7 @@ import cache from './commands/cache';
import clear from './commands/clear';
import deploy from './commands/deploy';
import docker from './commands/docker';
import doctor from './commands/doctor';
import dump from './commands/dump';
import env from './commands/env';
import _export from './commands/export';
Expand All @@ -19,15 +20,15 @@ import kill from './commands/kill';
import migrate from './commands/migrate';
import _package from './commands/package';
import plan from './commands/plan';
import updateCmd from './commands/update';
import upgrade from './commands/upgrade';
import remove from './commands/remove';
import renameCmd from './commands/rename';
import revert from './commands/revert';
import slice from './commands/slice';
import tag from './commands/tag';
import testPackages from './commands/test-packages';
import tune from './commands/tune';
import updateCmd from './commands/update';
import upgrade from './commands/upgrade';
import verify from './commands/verify';
import { usageText } from './utils';

Expand All @@ -49,6 +50,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
clear: pgt(clear),
deploy: pgt(deploy),
docker,
doctor,
dump: pgt(dump),
env,
verify: pgt(verify),
Expand All @@ -64,14 +66,14 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
install: pgt(install),
migrate: pgt(migrate),
analyze: pgt(analyze),
rename: pgt(renameCmd),
slice,
'test-packages': pgt(testPackages),
tune: pgt(tune),
upgrade: pgt(upgrade),
up: pgt(upgrade),
cache,
update: updateCmd
rename: pgt(renameCmd),
slice,
'test-packages': pgt(testPackages),
tune: pgt(tune),
upgrade: pgt(upgrade),
up: pgt(upgrade),
cache,
update: updateCmd
};
};

Expand Down Expand Up @@ -116,7 +118,7 @@ export const commands = async (argv: Partial<ParsedArgs>, prompter: Inquirerer,
const updateResult = await checkForUpdates({
pkgName: pkg.name,
pkgVersion: pkg.version,
toolName: 'pgpm',
toolName: 'pgpm'
});
if (updateResult.hasUpdate && updateResult.message) {
console.warn(updateResult.message);
Expand Down
46 changes: 25 additions & 21 deletions pgpm/cli/src/commands/docker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { spawn } from 'child_process';
import { CLIOptions, Inquirerer, cliExitWithError, extractFirst } from 'inquirerer';
import { cliExitWithError, CLIOptions, extractFirst,Inquirerer } from 'inquirerer';

import { detectPlatform, dockerDaemonGuidance, dockerInstallGuidance, getDockerStatus } from '../utils/doctor';

const dockerUsageText = `
Docker Command:
Expand Down Expand Up @@ -82,25 +84,25 @@ const ADDITIONAL_SERVICES: Record<string, ServiceDefinition> = {
image: 'minio/minio',
ports: [
{ host: 9000, container: 9000 },
{ host: 9001, container: 9001 },
{ host: 9001, container: 9001 }
],
env: {
MINIO_ROOT_USER: 'minioadmin',
MINIO_ROOT_PASSWORD: 'minioadmin',
MINIO_ROOT_PASSWORD: 'minioadmin'
},
command: ['server', '/data', '--console-address', ':9001'],
volumes: [{ name: 'minio-data', containerPath: '/data' }],
volumes: [{ name: 'minio-data', containerPath: '/data' }]
},
ollama: {
name: 'ollama',
image: 'ollama/ollama',
ports: [
{ host: 11434, container: 11434 },
{ host: 11434, container: 11434 }
],
env: {},
volumes: [{ name: 'ollama-data', containerPath: '/root/.ollama' }],
gpuCapable: true,
},
gpuCapable: true
}
};

interface SpawnResult {
Expand Down Expand Up @@ -141,13 +143,18 @@ function run(command: string, args: string[], options: { stdio?: 'inherit' | 'pi
});
}

async function checkDockerAvailable(): Promise<boolean> {
try {
const result = await run('docker', ['--version']);
return result.code === 0;
} catch (error) {
return false;
async function ensureDockerReady(): Promise<boolean> {
const status = await getDockerStatus();
if (status.binary && status.daemon) {
return true;
}
const platformInfo = detectPlatform();
if (!status.binary) {
await cliExitWithError(`Docker is not installed or not available in PATH.\n${dockerInstallGuidance(platformInfo)}`);
} else {
await cliExitWithError(dockerDaemonGuidance(platformInfo));
}
return false;
}

async function isContainerRunning(name: string): Promise<boolean | null> {
Expand All @@ -174,9 +181,7 @@ async function containerExists(name: string): Promise<boolean> {
async function startContainer(options: DockerRunOptions): Promise<void> {
const { name, image, port, user, password, shmSize, recreate } = options;

const dockerAvailable = await checkDockerAvailable();
if (!dockerAvailable) {
await cliExitWithError('Docker is not installed or not available in PATH. Please install Docker first.');
if (!(await ensureDockerReady())) {
return;
}

Expand Down Expand Up @@ -232,9 +237,7 @@ async function startContainer(options: DockerRunOptions): Promise<void> {
}

async function stopContainer(name: string): Promise<void> {
const dockerAvailable = await checkDockerAvailable();
if (!dockerAvailable) {
await cliExitWithError('Docker is not installed or not available in PATH. Please install Docker first.');
if (!(await ensureDockerReady())) {
return;
}

Expand Down Expand Up @@ -294,7 +297,7 @@ async function startService(service: ServiceDefinition, recreate: boolean, gpu:
const runArgs = [
'run',
'-d',
'--name', name,
'--name', name
];

for (const [key, value] of Object.entries(serviceEnv)) {
Expand Down Expand Up @@ -347,7 +350,8 @@ function resolveServiceFlags(args: Partial<Record<string, any>>): ServiceDefinit
}

async function listServices(): Promise<void> {
const dockerAvailable = await checkDockerAvailable();
const dockerStatus = await getDockerStatus();
const dockerAvailable = dockerStatus.binary && dockerStatus.daemon;

console.log('\nAvailable services:\n');
console.log(' Primary:');
Expand Down
Loading
Loading