diff --git a/pgpm/cli/__tests__/doctor.test.ts b/pgpm/cli/__tests__/doctor.test.ts new file mode 100644 index 000000000..5323da849 --- /dev/null +++ b/pgpm/cli/__tests__/doctor.test.ts @@ -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): 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 }); + }); +}); diff --git a/pgpm/cli/src/commands.ts b/pgpm/cli/src/commands.ts index 80ba66f6c..ef6488d82 100644 --- a/pgpm/cli/src/commands.ts +++ b/pgpm/cli/src/commands.ts @@ -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'; @@ -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'; @@ -19,8 +20,6 @@ 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'; @@ -28,6 +27,8 @@ 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'; @@ -49,6 +50,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record, 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); diff --git a/pgpm/cli/src/commands/docker.ts b/pgpm/cli/src/commands/docker.ts index a9917aa60..bce0056f1 100644 --- a/pgpm/cli/src/commands/docker.ts +++ b/pgpm/cli/src/commands/docker.ts @@ -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: @@ -82,25 +84,25 @@ const ADDITIONAL_SERVICES: Record = { 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 { @@ -141,13 +143,18 @@ function run(command: string, args: string[], options: { stdio?: 'inherit' | 'pi }); } -async function checkDockerAvailable(): Promise { - try { - const result = await run('docker', ['--version']); - return result.code === 0; - } catch (error) { - return false; +async function ensureDockerReady(): Promise { + 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 { @@ -174,9 +181,7 @@ async function containerExists(name: string): Promise { async function startContainer(options: DockerRunOptions): Promise { 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; } @@ -232,9 +237,7 @@ async function startContainer(options: DockerRunOptions): Promise { } async function stopContainer(name: string): Promise { - 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; } @@ -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)) { @@ -347,7 +350,8 @@ function resolveServiceFlags(args: Partial>): ServiceDefinit } async function listServices(): Promise { - const dockerAvailable = await checkDockerAvailable(); + const dockerStatus = await getDockerStatus(); + const dockerAvailable = dockerStatus.binary && dockerStatus.daemon; console.log('\nAvailable services:\n'); console.log(' Primary:'); diff --git a/pgpm/cli/src/commands/doctor.ts b/pgpm/cli/src/commands/doctor.ts new file mode 100644 index 000000000..c2e7d800b --- /dev/null +++ b/pgpm/cli/src/commands/doctor.ts @@ -0,0 +1,127 @@ +import { spawn } from 'child_process'; +import { CLIOptions, Inquirerer } from 'inquirerer'; +import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env'; + +import { + checkDocker, + checkDockerCompose, + checkNode, + checkPsql, + CheckResult, + detectPlatform, + runCommand, + summarizeChecks +} from '../utils/doctor'; + +const doctorUsageText = ` +Doctor Command: + + pgpm doctor [OPTIONS] + + Check that your machine has the dependencies pgpm needs + (Node.js, Docker, psql) and report OS-specific installation + guidance for anything that is missing. + +Options: + --db Also check PostgreSQL connectivity using the current environment + --json Output results as JSON + --help, -h Show this help message + +Exit Codes: + 0 All checks passed (warnings allowed) + 1 One or more checks failed + +Examples: + pgpm doctor Check node, docker, docker compose, and psql + pgpm doctor --db Also verify a PostgreSQL connection + pgpm doctor --json Machine-readable output (for CI) +`; + +const STATUS_ICONS: Record = { + pass: '✅', + warn: '⚠️ ', + fail: '❌' +}; + +async function checkDatabaseConnectivity(): Promise { + const pg = getPgEnvOptions(); + const target = `${pg.host}:${pg.port}/${pg.database} (user: ${pg.user})`; + const result = await new Promise<{ code: number; stderr: string }>((resolve) => { + const child = spawn('psql', ['-X', '-A', '-t', '-c', 'SELECT 1'], { + stdio: 'pipe', + env: getSpawnEnvWithPg(pg) + }); + let stderr = ''; + child.stderr?.on('data', (d) => { stderr += d.toString(); }); + child.on('error', () => resolve({ code: 127, stderr: 'psql: command not found' })); + child.on('close', (code) => resolve({ code: code ?? 0, stderr: stderr.trim() })); + }); + + if (result.code === 0) { + return { + name: 'database', + status: 'pass', + message: `connected to PostgreSQL at ${target}` + }; + } + return { + name: 'database', + status: 'fail', + message: `could not connect to PostgreSQL at ${target}: ${result.stderr}`, + remediation: 'Start a local PostgreSQL with `pgpm docker start`, or point PGHOST/PGPORT/PGUSER/PGPASSWORD at a running server (see `pgpm env`).' + }; +} + +function printResults(results: CheckResult[]): void { + console.log(''); + for (const result of results) { + console.log(` ${STATUS_ICONS[result.status]} ${result.name.padEnd(16)} ${result.message}`); + if (result.remediation && result.status !== 'pass') { + console.log(` ↳ ${result.remediation}`); + } + } + const { failed, warned, passed } = summarizeChecks(results); + console.log(''); + console.log(` ${passed} passed, ${warned} warning(s), ${failed} failed`); + console.log(''); +} + +export default async ( + argv: Partial>, + _prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(doctorUsageText); + process.exit(0); + } + + const platformInfo = detectPlatform(); + + const results: CheckResult[] = [ + checkNode(), + await checkDocker(platformInfo, runCommand), + await checkDockerCompose(platformInfo, runCommand), + await checkPsql(platformInfo, runCommand) + ]; + + if (argv.db === true) { + results.push(await checkDatabaseConnectivity()); + } + + if (argv.json === true) { + const { failed, warned, passed } = summarizeChecks(results); + console.log(JSON.stringify({ + platform: platformInfo, + checks: results, + summary: { passed, warned, failed } + }, null, 2)); + } else { + printResults(results); + } + + const { failed } = summarizeChecks(results); + if (failed > 0) { + process.exit(1); + } +}; diff --git a/pgpm/cli/src/utils/display.ts b/pgpm/cli/src/utils/display.ts index c8fe5d408..125b387cd 100644 --- a/pgpm/cli/src/utils/display.ts +++ b/pgpm/cli/src/utils/display.ts @@ -41,6 +41,7 @@ export const usageText = ` Development Tools: docker Manage Docker containers (start/stop/ls, --minio) + doctor Check local dependencies (node, docker, psql) with install guidance env Manage environment variables (--supabase, --minio) test-packages Run integration tests on workspace packages diff --git a/pgpm/cli/src/utils/doctor.ts b/pgpm/cli/src/utils/doctor.ts new file mode 100644 index 000000000..fe72a833b --- /dev/null +++ b/pgpm/cli/src/utils/doctor.ts @@ -0,0 +1,310 @@ +import { spawn } from 'child_process'; +import { readFileSync } from 'fs'; + +export type Platform = 'macos' | 'linux' | 'wsl' | 'windows' | 'unknown'; + +export interface PlatformInfo { + platform: Platform; + distro?: string; +} + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +export type CommandRunner = (command: string, args: string[]) => Promise; + +export type CheckStatus = 'pass' | 'warn' | 'fail'; + +export interface CheckResult { + name: string; + status: CheckStatus; + message: string; + remediation?: string; +} + +export const runCommand: CommandRunner = (command, args) => { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: 'pipe' }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', () => { + resolve({ code: 127, stdout: '', stderr: `${command}: command not found` }); + }); + + child.on('close', (code) => { + resolve({ code: code ?? 0, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + }); +}; + +export function detectPlatform( + platform: NodeJS.Platform = process.platform, + readFile: (path: string) => string = (p) => readFileSync(p, 'utf-8') +): PlatformInfo { + if (platform === 'darwin') { + return { platform: 'macos' }; + } + if (platform === 'win32') { + return { platform: 'windows' }; + } + if (platform === 'linux') { + let distro: string | undefined; + try { + const release = readFile('/etc/os-release'); + const match = release.match(/^ID=("?)([^"\n]*)\1$/m); + if (match) { + distro = match[2]; + } + } catch { + // ignore + } + try { + const version = readFile('/proc/version'); + if (/microsoft/i.test(version)) { + return { platform: 'wsl', distro }; + } + } catch { + // ignore + } + return { platform: 'linux', distro }; + } + return { platform: 'unknown' }; +} + +const DEBIAN_LIKE = ['ubuntu', 'debian', 'pop', 'linuxmint', 'elementary']; +const RHEL_LIKE = ['fedora', 'rhel', 'centos', 'rocky', 'almalinux', 'amzn']; + +function isDebianLike(distro?: string): boolean { + return !!distro && DEBIAN_LIKE.includes(distro); +} + +function isRhelLike(distro?: string): boolean { + return !!distro && RHEL_LIKE.includes(distro); +} + +export function dockerInstallGuidance(info: PlatformInfo): string { + switch (info.platform) { + case 'macos': + return 'Install Docker Desktop for Mac: https://www.docker.com/products/docker-desktop/ (or `brew install --cask docker`)'; + case 'windows': + return 'Install Docker Desktop for Windows: https://www.docker.com/products/docker-desktop/'; + case 'wsl': + return 'Install Docker Desktop for Windows with the WSL 2 backend enabled: https://docs.docker.com/desktop/wsl/'; + case 'linux': + if (isDebianLike(info.distro)) { + return 'Install Docker Engine: https://docs.docker.com/engine/install/ubuntu/ (e.g. `sudo apt-get install docker.io` or the official docker-ce packages)'; + } + if (isRhelLike(info.distro)) { + return 'Install Docker Engine: https://docs.docker.com/engine/install/fedora/ (e.g. `sudo dnf install docker-ce`)'; + } + return 'Install Docker Engine for your distribution: https://docs.docker.com/engine/install/'; + default: + return 'Install Docker: https://docs.docker.com/get-docker/'; + } +} + +export function dockerDaemonGuidance(info: PlatformInfo): string { + switch (info.platform) { + case 'macos': + case 'windows': + return 'Docker is installed but the daemon is not running. Open the Docker Desktop application and wait for it to finish starting.'; + case 'wsl': + return 'Docker is installed but the daemon is not reachable. Start Docker Desktop on Windows and make sure WSL integration is enabled for this distro.'; + case 'linux': + return 'Docker is installed but the daemon is not running. Start it with `sudo systemctl start docker` (and `sudo systemctl enable docker` to start on boot). If you get a permission error, add your user to the docker group: `sudo usermod -aG docker $USER` and re-login.'; + default: + return 'Docker is installed but the daemon is not reachable. Start the Docker daemon and try again.'; + } +} + +export function psqlInstallGuidance(info: PlatformInfo): string { + switch (info.platform) { + case 'macos': + return 'Install the PostgreSQL client via Homebrew: `brew install libpq && brew link --force libpq`'; + case 'windows': + return 'Install the PostgreSQL client tools: `winget install PostgreSQL.PostgreSQL` or download from https://www.postgresql.org/download/windows/'; + case 'wsl': + case 'linux': + if (isRhelLike(info.distro)) { + return 'Install the PostgreSQL client: `sudo dnf install postgresql`'; + } + return 'Install the PostgreSQL client: `sudo apt-get install postgresql-client`'; + default: + return 'Install the PostgreSQL client tools (psql): https://www.postgresql.org/download/'; + } +} + +export async function checkDockerBinary(exec: CommandRunner = runCommand): Promise { + const result = await exec('docker', ['--version']); + return result.code === 0; +} + +export async function checkDockerDaemon(exec: CommandRunner = runCommand): Promise { + const result = await exec('docker', ['info']); + return result.code === 0; +} + +export interface DockerStatus { + binary: boolean; + daemon: boolean; +} + +export async function getDockerStatus(exec: CommandRunner = runCommand): Promise { + const binary = await checkDockerBinary(exec); + if (!binary) { + return { binary: false, daemon: false }; + } + const daemon = await checkDockerDaemon(exec); + return { binary, daemon }; +} + +export async function checkDocker( + info: PlatformInfo, + exec: CommandRunner = runCommand +): Promise { + const status = await getDockerStatus(exec); + if (!status.binary) { + return { + name: 'docker', + status: 'fail', + message: 'docker is not installed or not on PATH', + remediation: dockerInstallGuidance(info) + }; + } + if (!status.daemon) { + return { + name: 'docker', + status: 'fail', + message: 'docker is installed but the daemon is not reachable', + remediation: dockerDaemonGuidance(info) + }; + } + return { + name: 'docker', + status: 'pass', + message: 'docker is installed and the daemon is running' + }; +} + +export async function checkDockerCompose( + info: PlatformInfo, + exec: CommandRunner = runCommand +): Promise { + const plugin = await exec('docker', ['compose', 'version']); + if (plugin.code === 0) { + return { + name: 'docker-compose', + status: 'pass', + message: 'docker compose plugin is available' + }; + } + const standalone = await exec('docker-compose', ['--version']); + if (standalone.code === 0) { + return { + name: 'docker-compose', + status: 'pass', + message: 'docker-compose (standalone) is available' + }; + } + return { + name: 'docker-compose', + status: 'warn', + message: 'docker compose is not available (only needed for docker-compose based workflows)', + remediation: info.platform === 'linux' || info.platform === 'wsl' + ? 'Install the compose plugin: https://docs.docker.com/compose/install/linux/' + : 'Docker Desktop ships with compose; install or update Docker Desktop: https://www.docker.com/products/docker-desktop/' + }; +} + +export const MIN_PSQL_MAJOR = 17; + +export function parsePsqlMajor(versionOutput: string): number | null { + const match = versionOutput.match(/\(PostgreSQL\)\s+(\d+)/) || versionOutput.match(/psql[^\d]*(\d+)/); + if (!match) return null; + const major = parseInt(match[1], 10); + return Number.isNaN(major) ? null : major; +} + +export async function checkPsql( + info: PlatformInfo, + exec: CommandRunner = runCommand +): Promise { + const result = await exec('psql', ['--version']); + if (result.code !== 0) { + return { + name: 'psql', + status: 'fail', + message: 'psql (PostgreSQL client) is not installed or not on PATH', + remediation: psqlInstallGuidance(info) + }; + } + const major = parsePsqlMajor(result.stdout); + if (major === null) { + return { + name: 'psql', + status: 'warn', + message: `could not determine psql version from: ${result.stdout}` + }; + } + if (major < MIN_PSQL_MAJOR) { + return { + name: 'psql', + status: 'warn', + message: `psql ${major} found; PostgreSQL ${MIN_PSQL_MAJOR}+ client is recommended`, + remediation: psqlInstallGuidance(info) + }; + } + return { + name: 'psql', + status: 'pass', + message: `psql ${major} is installed` + }; +} + +export const MIN_NODE_MAJOR = 18; +export const RECOMMENDED_NODE_MAJOR = 20; + +export function checkNode(version: string = process.version): CheckResult { + const major = parseInt(version.replace(/^v/, '').split('.')[0], 10); + if (Number.isNaN(major)) { + return { name: 'node', status: 'warn', message: `could not parse Node.js version: ${version}` }; + } + if (major < MIN_NODE_MAJOR) { + return { + name: 'node', + status: 'fail', + message: `Node.js ${version} is not supported; Node.js ${MIN_NODE_MAJOR}+ is required`, + remediation: 'Upgrade Node.js (e.g. via nvm: `nvm install --lts`): https://nodejs.org/' + }; + } + if (major < RECOMMENDED_NODE_MAJOR) { + return { + name: 'node', + status: 'warn', + message: `Node.js ${version} works but ${RECOMMENDED_NODE_MAJOR}+ is recommended`, + remediation: 'Upgrade Node.js (e.g. via nvm: `nvm install --lts`): https://nodejs.org/' + }; + } + return { name: 'node', status: 'pass', message: `Node.js ${version} is installed` }; +} + +export function summarizeChecks(results: CheckResult[]): { failed: number; warned: number; passed: number } { + return { + failed: results.filter((r) => r.status === 'fail').length, + warned: results.filter((r) => r.status === 'warn').length, + passed: results.filter((r) => r.status === 'pass').length + }; +} diff --git a/pgpm/cli/src/utils/index.ts b/pgpm/cli/src/utils/index.ts index ef0a3e92f..22f7d42c5 100644 --- a/pgpm/cli/src/utils/index.ts +++ b/pgpm/cli/src/utils/index.ts @@ -1,7 +1,8 @@ export * from './database'; -export * from './driver'; -export * from './display'; export * from './deployed-changes'; +export * from './display'; +export * from './doctor'; +export * from './driver'; export * from './module-utils'; export * from './npm-version'; export * from './package-alias';