From 4f5a4fd398d88a3c66da6229e68bf568cccf74ac Mon Sep 17 00:00:00 2001 From: ryrahul Date: Fri, 22 May 2026 23:38:06 +0545 Subject: [PATCH 01/12] feat(cli): add vendure doctor command with project check Fixes #4776 --- .../cli/src/commands/command-declarations.ts | 37 ++++ .../commands/doctor/checks/project-check.ts | 181 ++++++++++++++++++ packages/cli/src/commands/doctor/doctor.ts | 85 ++++++++ .../doctor/formatters/console-formatter.ts | 48 +++++ .../doctor/formatters/json-formatter.ts | 10 + packages/cli/src/commands/doctor/types.ts | 24 +++ 6 files changed, 385 insertions(+) create mode 100644 packages/cli/src/commands/doctor/checks/project-check.ts create mode 100644 packages/cli/src/commands/doctor/doctor.ts create mode 100644 packages/cli/src/commands/doctor/formatters/console-formatter.ts create mode 100644 packages/cli/src/commands/doctor/formatters/json-formatter.ts create mode 100644 packages/cli/src/commands/doctor/types.ts diff --git a/packages/cli/src/commands/command-declarations.ts b/packages/cli/src/commands/command-declarations.ts index aac20cd6a7..53086ae364 100644 --- a/packages/cli/src/commands/command-declarations.ts +++ b/packages/cli/src/commands/command-declarations.ts @@ -262,4 +262,41 @@ export const cliCommands: CliCommandDefinition[] = [ process.exit(0); }, }, + { + name: 'doctor', + description: 'Run diagnostic checks on your Vendure project', + options: [ + { + long: '--config ', + description: 'Specify the path to a custom Vendure config file', + required: false, + }, + { + long: '--check ', + description: + 'Run specific checks only (project, dependencies, config, schema, database)', + required: false, + }, + { + long: '--profile ', + description: 'Run profile-specific checks (production)', + required: false, + }, + { + long: '--format ', + description: 'Output format: text (default) or json', + required: false, + }, + { + long: '--strict', + description: 'Treat warnings as failures (useful for CI)', + required: false, + }, + ], + action: async options => { + const { doctorCommand } = await import('./doctor/doctor'); + await doctorCommand(options); + process.exit(0); + }, + }, ]; diff --git a/packages/cli/src/commands/doctor/checks/project-check.ts b/packages/cli/src/commands/doctor/checks/project-check.ts new file mode 100644 index 0000000000..07b294044c --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/project-check.ts @@ -0,0 +1,181 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import { detectMonorepoStructure, findPackageJsonWithDependency } from '../../../utilities/monorepo-utils'; +import { CheckResult } from '../types'; + +/** + * Checks whether the current working directory is a valid Vendure project, + * discovers the Vendure config file, and reports package manager and monorepo info. + */ +export async function runProjectCheck(configFlag?: string): Promise { + const cwd = process.cwd(); + const details: string[] = []; + + // 1. Check package.json exists + const packageJsonPath = path.join(cwd, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return { + name: 'Project', + status: 'fail', + message: 'No package.json found in the current directory', + details: ['Run this command from the root of your Vendure project.'], + }; + } + + // 2. Check for @vendure/* dependencies + let packageJson: Record; + try { + packageJson = fs.readJsonSync(packageJsonPath); + } catch { + return { + name: 'Project', + status: 'fail', + message: 'Failed to parse package.json', + }; + } + + const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies }; + const vendureDeps = Object.keys(allDeps).filter( + dep => dep.startsWith('@vendure/') || dep === 'vendure', + ); + + // If no vendure deps at root, check monorepo subdirectories + let vendurePackageJsonPath: string | null = null; + if (vendureDeps.length === 0) { + vendurePackageJsonPath = findPackageJsonWithDependency(cwd, '@vendure/core'); + if (!vendurePackageJsonPath) { + return { + name: 'Project', + status: 'fail', + message: 'No @vendure/* dependencies found', + details: [ + 'This does not appear to be a Vendure project.', + 'Ensure @vendure/core is listed in your package.json dependencies.', + ], + }; + } + details.push(`Vendure dependencies found at ${path.relative(cwd, vendurePackageJsonPath)}`); + } + + // 3. Detect monorepo + const monorepoInfo = detectMonorepoStructure(cwd); + if (monorepoInfo.isMonorepo) { + details.push(`Monorepo detected (${monorepoInfo.packageDir})`); + } + + // 4. Detect package manager + // In a monorepo, lockfiles live at the root, not in the subpackage + const lockfileSearchDir = monorepoInfo.isMonorepo && monorepoInfo.root ? monorepoInfo.root : cwd; + const packageManager = detectPackageManager(lockfileSearchDir); + details.push(`Package manager: ${packageManager}`); + + // Check for lockfile consistency + const lockfiles = detectLockfiles(lockfileSearchDir); + if (lockfiles.length > 1) { + details.push(`Warning: multiple lockfiles found (${lockfiles.join(', ')})`); + } else if (lockfiles.length === 0) { + details.push('No lockfile found'); + } + + // 5. Discover Vendure config file + const configResult = discoverVendureConfig(cwd, configFlag); + if (configResult.error) { + return { + name: 'Project', + status: 'fail', + message: configResult.error, + details, + }; + } + + // 6. Report Node.js version + details.push(`Node.js ${process.version}`); + + const message = configResult.path + ? `Vendure config found at ${path.relative(cwd, configResult.path)}` + : 'Vendure project detected'; + + return { + name: 'Project', + status: 'pass', + message, + details, + }; +} + +function detectPackageManager(cwd: string): string { + if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock'))) { + return 'bun'; + } + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) { + return 'yarn'; + } + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) { + return 'pnpm'; + } + if (fs.existsSync(path.join(cwd, 'package-lock.json'))) { + return 'npm'; + } + return 'unknown'; +} + +function detectLockfiles(cwd: string): string[] { + const lockfiles: string[] = []; + if (fs.existsSync(path.join(cwd, 'package-lock.json'))) lockfiles.push('package-lock.json'); + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) lockfiles.push('yarn.lock'); + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) lockfiles.push('pnpm-lock.yaml'); + if (fs.existsSync(path.join(cwd, 'bun.lockb'))) lockfiles.push('bun.lockb'); + if (fs.existsSync(path.join(cwd, 'bun.lock'))) lockfiles.push('bun.lock'); + return lockfiles; +} + +interface ConfigDiscoveryResult { + path?: string; + error?: string; +} + +/** + * Discovers the Vendure config file. If `--config` is specified, uses that path. + * Otherwise, checks common locations. + */ +function discoverVendureConfig(cwd: string, configFlag?: string): ConfigDiscoveryResult { + if (configFlag) { + const resolved = path.resolve(cwd, configFlag); + if (fs.existsSync(resolved)) { + return { path: resolved }; + } + return { error: `Specified config file not found: ${configFlag}` }; + } + + const candidates = [ + 'vendure-config.ts', + 'src/vendure-config.ts', + 'vendure-config.js', + 'src/vendure-config.js', + ]; + + const found: string[] = []; + for (const candidate of candidates) { + const fullPath = path.join(cwd, candidate); + if (fs.existsSync(fullPath)) { + found.push(fullPath); + } + } + + if (found.length === 1) { + return { path: found[0] }; + } + + if (found.length > 1) { + return { + error: + 'Multiple Vendure config files found. Use --config to specify which one:\n' + + found.map(f => ` - ${path.relative(cwd, f)}`).join('\n'), + }; + } + + // No config at standard locations -- not necessarily an error for the project check, + // but later checks that need the config will report this. + return {}; +} diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts new file mode 100644 index 0000000000..0a5494b21c --- /dev/null +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -0,0 +1,85 @@ +import { log } from '@clack/prompts'; + +import { runProjectCheck } from './checks/project-check'; +import { formatConsoleReport } from './formatters/console-formatter'; +import { formatJsonReport } from './formatters/json-formatter'; +import { CheckResult, DoctorOptions, DoctorReport } from './types'; + +const ALL_CHECKS = ['project', 'dependencies', 'config', 'schema', 'database'] as const; + +/** + * Entry point for the `vendure doctor` command. + * Runs diagnostic checks on a Vendure project and reports results. + */ +export async function doctorCommand(options?: DoctorOptions) { + const checksToRun = resolveChecks(options?.check); + + const results: CheckResult[] = []; + + // Check 1: Project detection & config discovery + if (checksToRun.includes('project')) { + const projectResult = await runProjectCheck(options?.config); + results.push(projectResult); + + // If project check fails, skip remaining checks that depend on it + if (projectResult.status === 'fail' && checksToRun.length > 1) { + for (const check of checksToRun.filter(c => c !== 'project')) { + results.push({ + name: capitalize(check), + status: 'skip', + message: 'Skipped due to project check failure', + }); + } + outputReport(buildReport(results, options), options); + return; + } + } + + // Future checks (2-5) will be added here as they are implemented. + // Each check will follow the same pattern: + // if (checksToRun.includes('checkName')) { + // results.push(await runCheckName(...)); + // } + + outputReport(buildReport(results, options), options); +} + +function resolveChecks(checkFlags?: string[]): string[] { + if (!checkFlags || checkFlags.length === 0) { + return [...ALL_CHECKS]; + } + const valid = checkFlags.filter(c => (ALL_CHECKS as readonly string[]).includes(c)); + const invalid = checkFlags.filter(c => !(ALL_CHECKS as readonly string[]).includes(c)); + if (invalid.length > 0) { + log.warn(`Unknown check(s): ${invalid.join(', ')}. Valid checks: ${ALL_CHECKS.join(', ')}`); + } + return valid.length > 0 ? valid : [...ALL_CHECKS]; +} + +function buildReport(checks: CheckResult[], options?: DoctorOptions): DoctorReport { + const hasFail = checks.some(c => c.status === 'fail'); + const hasWarn = checks.some(c => c.status === 'warn'); + const overallStatus = hasFail || (options?.strict && hasWarn) ? 'failed' : 'passed'; + + return { + nodeVersion: process.version, + checks, + overallStatus, + }; +} + +function outputReport(report: DoctorReport, options?: DoctorOptions): void { + if (options?.format === 'json') { + formatJsonReport(report); + } else { + formatConsoleReport(report); + } + + if (report.overallStatus === 'failed') { + process.exit(1); + } +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/packages/cli/src/commands/doctor/formatters/console-formatter.ts b/packages/cli/src/commands/doctor/formatters/console-formatter.ts new file mode 100644 index 0000000000..d7966da971 --- /dev/null +++ b/packages/cli/src/commands/doctor/formatters/console-formatter.ts @@ -0,0 +1,48 @@ +import { log } from '@clack/prompts'; +import pc from 'picocolors'; + +import { CheckResult, CheckStatus, DoctorReport } from '../types'; + +const STATUS_LABELS: Record = { + pass: pc.green('pass'), + warn: pc.yellow('warn'), + fail: pc.red('fail'), + skip: pc.dim('skip'), +}; + +/** + * Formats the doctor report for terminal output using @clack/prompts and picocolors. + */ +export function formatConsoleReport(report: DoctorReport): void { + console.log(''); + log.info(pc.bold('Vendure Doctor')); + console.log(''); + + for (const check of report.checks) { + const status = STATUS_LABELS[check.status]; + const name = check.name.padEnd(16); + log.message(`${name}${status} ${check.message}`); + + if (check.details?.length) { + for (const detail of check.details) { + log.message(pc.dim(` ${detail}`)); + } + } + } + + console.log(''); + const failCount = report.checks.filter(c => c.status === 'fail').length; + const warnCount = report.checks.filter(c => c.status === 'warn').length; + + if (report.overallStatus === 'failed') { + const parts: string[] = []; + if (failCount > 0) parts.push(`${failCount} failure${failCount > 1 ? 's' : ''}`); + if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? 's' : ''}`); + log.error(`Result: ${pc.red('failed')} (${parts.join(', ')})`); + } else { + const msg = warnCount > 0 + ? `Result: ${pc.green('passed')} (${warnCount} warning${warnCount > 1 ? 's' : ''})` + : `Result: ${pc.green('passed')}`; + log.success(msg); + } +} diff --git a/packages/cli/src/commands/doctor/formatters/json-formatter.ts b/packages/cli/src/commands/doctor/formatters/json-formatter.ts new file mode 100644 index 0000000000..893fc8fb7c --- /dev/null +++ b/packages/cli/src/commands/doctor/formatters/json-formatter.ts @@ -0,0 +1,10 @@ +import { DoctorReport } from '../types'; + +/** + * Outputs the doctor report as structured JSON to stdout. + * Suitable for CI pipelines, agent workflows, and machine consumption. + */ +export function formatJsonReport(report: DoctorReport): void { + // eslint-disable-next-line no-console + console.log(JSON.stringify(report, null, 2)); +} diff --git a/packages/cli/src/commands/doctor/types.ts b/packages/cli/src/commands/doctor/types.ts new file mode 100644 index 0000000000..0ac348c4c7 --- /dev/null +++ b/packages/cli/src/commands/doctor/types.ts @@ -0,0 +1,24 @@ +export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip'; + +export interface CheckResult { + name: string; + status: CheckStatus; + message: string; + details?: string[]; +} + +export interface DoctorOptions { + config?: string; + check?: string[]; + profile?: string; + format?: 'text' | 'json'; + strict?: boolean; +} + +export interface DoctorReport { + vendureVersion?: string; + nodeVersion: string; + packageManager?: string; + checks: CheckResult[]; + overallStatus: 'passed' | 'failed'; +} From e7b53689cfa69b69c96b418a0bc909bfb4313979 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Sat, 23 May 2026 00:20:40 +0545 Subject: [PATCH 02/12] feat(cli): add dependency and config checks to vendure doctor --- .../commands/doctor/checks/config-check.ts | 130 +++++++ .../doctor/checks/dependency-check.ts | 332 ++++++++++++++++++ packages/cli/src/commands/doctor/doctor.ts | 36 +- 3 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/doctor/checks/config-check.ts create mode 100644 packages/cli/src/commands/doctor/checks/dependency-check.ts diff --git a/packages/cli/src/commands/doctor/checks/config-check.ts b/packages/cli/src/commands/doctor/checks/config-check.ts new file mode 100644 index 0000000000..96f33ac049 --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/config-check.ts @@ -0,0 +1,130 @@ +import { log } from '@clack/prompts'; +import { + getCompatibility, + getConfig, + preBootstrapConfig, + resetConfig, + RuntimeVendureConfig, + setConfig, + VENDURE_VERSION, +} from '@vendure/core'; +import { satisfies } from 'semver'; + +import { loadVendureConfigFile } from '../../../shared/load-vendure-config-file'; +import { analyzeProject } from '../../../shared/shared-prompts'; +import { VendureConfigRef } from '../../../shared/vendure-config-ref'; +import { CheckResult } from '../types'; + +export interface ConfigCheckResult { + check: CheckResult; + /** The loaded runtime config, available for subsequent checks (schema, db, production). */ + config?: RuntimeVendureConfig; +} + +/** + * Loads the Vendure config, runs preBootstrapConfig() to validate custom fields, + * register entities, and run plugin configuration hooks. Then checks plugin + * compatibility with the current Vendure version. + */ +export async function runConfigCheck(configFlag?: string): Promise { + const details: string[] = []; + let runtimeConfig: RuntimeVendureConfig | undefined; + + try { + resetConfig(); + process.env.VENDURE_RUNNING_IN_CLI = 'true'; + + // 1. Analyze the project (finds tsconfig, creates ts-morph Project) + const { project, vendureTsConfig } = await analyzeProject({ + cancelledMessage: '', + config: configFlag, + }); + + // 2. Find the VendureConfig source file + const vendureConfigRef = new VendureConfigRef(project, configFlag); + const configPath = vendureConfigRef.getPathRelativeToProjectRoot(); + details.push(`Config loaded from ${configPath}`); + + // 3. Load the config at runtime (ts-node, path mappings, dotenv) + const config = await loadVendureConfigFile(vendureConfigRef, vendureTsConfig); + + // 4. Run preBootstrapConfig() -- validates custom fields, registers entities, + // runs plugin configuration() hooks, sets strategies + runtimeConfig = await preBootstrapConfig(config); + details.push('Custom fields validated'); + details.push('Plugin configuration completed'); + + // 5. Check plugin compatibility + const pluginResults = checkPlugins(runtimeConfig); + details.push(...pluginResults.details); + + const status = pluginResults.hasIncompatible ? 'fail' : pluginResults.hasNoCompat ? 'warn' : 'pass'; + const message = + status === 'pass' + ? 'Vendure config loaded and validated successfully' + : status === 'warn' + ? 'Config loaded with warnings' + : 'Plugin compatibility issues detected'; + + return { + check: { name: 'Config', status, message, details }, + config: runtimeConfig, + }; + } catch (e: any) { + const errorMessage = e instanceof Error ? e.message : String(e); + details.push(`Error: ${errorMessage}`); + return { + check: { + name: 'Config', + status: 'fail', + message: 'Failed to load Vendure config', + details, + }, + config: runtimeConfig, + }; + } finally { + process.env.VENDURE_RUNNING_IN_CLI = undefined; + } +} + +interface PluginCheckResult { + details: string[]; + hasIncompatible: boolean; + hasNoCompat: boolean; +} + +/** + * Checks each plugin's compatibility range against the current Vendure version. + * Reports results per-plugin instead of throwing on the first incompatible one. + */ +function checkPlugins(config: RuntimeVendureConfig): PluginCheckResult { + const details: string[] = []; + let hasIncompatible = false; + let hasNoCompat = false; + + if (!config.plugins || config.plugins.length === 0) { + details.push('No plugins configured'); + return { details, hasIncompatible, hasNoCompat }; + } + + details.push(`${config.plugins.length} plugin(s) loaded`); + + for (const plugin of config.plugins) { + const pluginName = (plugin as any).name as string; + const compatibility = getCompatibility(plugin); + + if (!compatibility) { + hasNoCompat = true; + details.push(`Plugin "${pluginName}": no compatibility range specified`); + } else if ( + !satisfies(VENDURE_VERSION, compatibility, { loose: true, includePrerelease: true }) + ) { + hasIncompatible = true; + details.push( + `Plugin "${pluginName}": incompatible (requires ${compatibility}, running ${VENDURE_VERSION})`, + ); + } + } + + return { details, hasIncompatible, hasNoCompat }; +} diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts new file mode 100644 index 0000000000..58450c9d32 --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -0,0 +1,332 @@ +import fs from 'fs-extra'; +import path from 'node:path'; + +import { CheckResult } from '../types'; + +/** + * Known @vendure/* packages that use fixed versioning. + * These should all be at the same version when installed. + */ +const VENDURE_PACKAGES = [ + '@vendure/admin-ui', + '@vendure/admin-ui-plugin', + '@vendure/asset-server-plugin', + '@vendure/cli', + '@vendure/common', + '@vendure/core', + '@vendure/create', + '@vendure/dashboard', + '@vendure/email-plugin', + '@vendure/graphiql-plugin', + '@vendure/harden-plugin', + '@vendure/job-queue-plugin', + '@vendure/telemetry-plugin', + '@vendure/testing', + '@vendure/ui-devkit', +]; + +/** + * Dependencies that must not be duplicated in node_modules. + * Multiple installed versions of these cause runtime identity bugs. + */ +const SINGLETON_PACKAGES = [ + 'graphql', + 'typeorm', + '@nestjs/core', + '@nestjs/common', + '@apollo/server', +]; + +/** + * Maps dbConnectionOptions.type to the required npm package. + */ +const DB_DRIVER_MAP: Record = { + mysql: 'mysql2', + mariadb: 'mysql2', + postgres: 'pg', + 'better-sqlite3': 'better-sqlite3', + sqlite: 'better-sqlite3', + sqljs: 'sql.js', +}; + +/** + * Runs dependency checks: + * 1. Detects mismatched @vendure/* package versions + * 2. Detects duplicate singleton dependencies + * 3. Verifies the configured DB driver is installed + */ +export async function runDependencyCheck(nodeModulesPath?: string): Promise { + const cwd = process.cwd(); + const modulesDir = nodeModulesPath ?? path.join(cwd, 'node_modules'); + const details: string[] = []; + let worstStatus: 'pass' | 'warn' | 'fail' = 'pass'; + + if (!fs.existsSync(modulesDir)) { + return { + name: 'Dependencies', + status: 'fail', + message: 'node_modules not found. Run your package manager install first.', + }; + } + + // 1. Check @vendure/* version alignment + const vendureVersions = getInstalledVendureVersions(modulesDir); + if (vendureVersions.size > 0) { + const versions = new Set(vendureVersions.values()); + if (versions.size > 1) { + worstStatus = 'fail'; + const grouped = groupByVersion(vendureVersions); + details.push('Mismatched @vendure/* package versions:'); + for (const [version, pkgs] of grouped) { + details.push(` ${version}: ${pkgs.join(', ')}`); + } + } else { + const version = [...versions][0]; + details.push(`All @vendure/* packages at ${version}`); + } + } + + // 2. Check for duplicate singleton dependencies + const duplicates = findDuplicatePackages(modulesDir, SINGLETON_PACKAGES); + for (const [pkg, versions] of duplicates) { + if (versions.length > 1) { + worstStatus = 'fail'; + details.push(`Multiple ${pkg} versions: ${versions.join(', ')}`); + } + } + if (duplicates.size === 0 || [...duplicates.values()].every(v => v.length <= 1)) { + details.push('No duplicate singleton dependencies'); + } + + // 3. Check DB driver + const dbDriverResult = checkDbDriver(cwd, modulesDir); + if (dbDriverResult) { + if (dbDriverResult.status === 'fail') { + worstStatus = 'fail'; + } else if (dbDriverResult.status === 'warn' && worstStatus === 'pass') { + worstStatus = 'warn'; + } + details.push(dbDriverResult.message); + } + + const message = + worstStatus === 'pass' + ? 'All dependency checks passed' + : worstStatus === 'warn' + ? 'Dependency warnings detected' + : 'Dependency issues detected'; + + return { + name: 'Dependencies', + status: worstStatus, + message, + details, + }; +} + +/** + * Reads installed @vendure/* package versions from node_modules. + */ +function getInstalledVendureVersions(modulesDir: string): Map { + const versions = new Map(); + const vendureDir = path.join(modulesDir, '@vendure'); + if (!fs.existsSync(vendureDir)) { + return versions; + } + + for (const pkg of VENDURE_PACKAGES) { + const pkgJsonPath = path.join(modulesDir, pkg, 'package.json'); + const version = readPackageVersion(pkgJsonPath); + if (version) { + versions.set(pkg, version); + } + } + return versions; +} + +/** + * Finds duplicate installations of packages by scanning nested node_modules. + * Returns a map of package name -> list of installed versions. + */ +function findDuplicatePackages( + modulesDir: string, + packages: string[], +): Map { + const result = new Map(); + + for (const pkg of packages) { + const versions = new Set(); + + // Check root node_modules + const rootVersion = readPackageVersion(path.join(modulesDir, pkg, 'package.json')); + if (rootVersion) { + versions.add(rootVersion); + } + + // Scan for nested copies in node_modules/*/node_modules/ + // and node_modules/@*/*/node_modules/ + const nestedVersions = findNestedPackageVersions(modulesDir, pkg); + for (const v of nestedVersions) { + versions.add(v); + } + + if (versions.size > 0) { + result.set(pkg, [...versions].sort()); + } + } + + return result; +} + +/** + * Scans nested node_modules directories for additional installations of a package. + * Checks up to 2 levels deep to catch common duplication patterns. + */ +function findNestedPackageVersions(modulesDir: string, targetPkg: string): string[] { + const versions: string[] = []; + + let entries: string[]; + try { + entries = fs.readdirSync(modulesDir); + } catch { + return versions; + } + + for (const entry of entries) { + // Skip the target package itself and hidden directories + if (entry === targetPkg || entry.startsWith('.')) continue; + + const entryPath = path.join(modulesDir, entry); + + if (entry.startsWith('@')) { + // Scoped packages: check @scope/pkg/node_modules/ + let scopedEntries: string[]; + try { + scopedEntries = fs.readdirSync(entryPath); + } catch { + continue; + } + for (const scopedEntry of scopedEntries) { + const nestedModules = path.join(entryPath, scopedEntry, 'node_modules'); + const nestedPkgJson = path.join(nestedModules, targetPkg, 'package.json'); + const version = readPackageVersion(nestedPkgJson); + if (version) { + versions.push(version); + } + } + } else { + // Regular packages: check pkg/node_modules/ + const nestedModules = path.join(entryPath, 'node_modules'); + const nestedPkgJson = path.join(nestedModules, targetPkg, 'package.json'); + const version = readPackageVersion(nestedPkgJson); + if (version) { + versions.push(version); + } + } + } + + return versions; +} + +/** + * Reads the version field from a package.json file. + * Returns undefined if the file doesn't exist or can't be parsed. + */ +function readPackageVersion(pkgJsonPath: string): string | undefined { + try { + if (!fs.existsSync(pkgJsonPath)) return undefined; + const pkg = fs.readJsonSync(pkgJsonPath); + return pkg.version; + } catch { + return undefined; + } +} + +/** + * Groups packages by their version for readable output. + */ +function groupByVersion(packages: Map): Map { + const grouped = new Map(); + for (const [pkg, version] of packages) { + const existing = grouped.get(version) ?? []; + existing.push(pkg); + grouped.set(version, existing); + } + return grouped; +} + +/** + * Checks if the correct database driver package is installed for the configured DB type. + * Reads dbConnectionOptions.type from the project's vendure config or package.json. + */ +function checkDbDriver( + cwd: string, + modulesDir: string, +): { status: 'pass' | 'warn' | 'fail'; message: string } | null { + // Try to detect the DB type from the project's vendure config + // We can't import the config here (that's Check 3's job), so we do a simple + // text-based scan of common config files for the dbConnectionOptions.type value. + const dbType = detectDbTypeFromSource(cwd); + if (!dbType) { + return null; // Can't determine DB type -- skip this sub-check + } + + const driverPkg = DB_DRIVER_MAP[dbType]; + if (!driverPkg) { + return { + status: 'warn', + message: `Unknown database type: ${dbType}`, + }; + } + + const driverPath = path.join(modulesDir, driverPkg, 'package.json'); + if (fs.existsSync(driverPath)) { + const version = readPackageVersion(driverPath); + return { + status: 'pass', + message: `DB driver ${driverPkg}${version ? ` (${version})` : ''} installed for type "${dbType}"`, + }; + } + + return { + status: 'fail', + message: `DB driver "${driverPkg}" not installed (required for dbConnectionOptions.type: "${dbType}")`, + }; +} + +/** + * Attempts to detect the database type by scanning source files for + * dbConnectionOptions configuration. This is a lightweight text-based scan + * that avoids importing the config (which requires ts-node setup). + */ +function detectDbTypeFromSource(cwd: string): string | undefined { + const candidates = [ + 'vendure-config.ts', + 'src/vendure-config.ts', + 'vendure-config.js', + 'src/vendure-config.js', + 'dev-config.ts', + 'src/dev-config.ts', + ]; + + for (const candidate of candidates) { + const filePath = path.join(cwd, candidate); + if (!fs.existsSync(filePath)) continue; + + try { + const content = fs.readFileSync(filePath, 'utf-8'); + // Match patterns like: type: 'postgres' or type: "mysql" + const match = content.match(/type\s*:\s*['"]([^'"]+)['"]/); + if (match) { + const type = match[1]; + if (type in DB_DRIVER_MAP) { + return type; + } + } + } catch { + continue; + } + } + + return undefined; +} diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts index 0a5494b21c..4b3d021deb 100644 --- a/packages/cli/src/commands/doctor/doctor.ts +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -1,5 +1,8 @@ import { log } from '@clack/prompts'; +import { RuntimeVendureConfig } from '@vendure/core'; +import { runConfigCheck } from './checks/config-check'; +import { runDependencyCheck } from './checks/dependency-check'; import { runProjectCheck } from './checks/project-check'; import { formatConsoleReport } from './formatters/console-formatter'; import { formatJsonReport } from './formatters/json-formatter'; @@ -15,6 +18,7 @@ export async function doctorCommand(options?: DoctorOptions) { const checksToRun = resolveChecks(options?.check); const results: CheckResult[] = []; + let loadedConfig: RuntimeVendureConfig | undefined; // Check 1: Project detection & config discovery if (checksToRun.includes('project')) { @@ -35,11 +39,33 @@ export async function doctorCommand(options?: DoctorOptions) { } } - // Future checks (2-5) will be added here as they are implemented. - // Each check will follow the same pattern: - // if (checksToRun.includes('checkName')) { - // results.push(await runCheckName(...)); - // } + // Check 2: Dependency version alignment, singleton duplication, DB driver + if (checksToRun.includes('dependencies')) { + results.push(await runDependencyCheck()); + } + + // Check 3: Config loading, validation, plugin compatibility + if (checksToRun.includes('config')) { + const configResult = await runConfigCheck(options?.config); + results.push(configResult.check); + loadedConfig = configResult.config; + + // If config check fails, skip checks that depend on a loaded config + if (configResult.check.status === 'fail') { + const configDependentChecks = ['schema', 'database']; + for (const check of configDependentChecks.filter(c => checksToRun.includes(c))) { + results.push({ + name: capitalize(check), + status: 'skip', + message: 'Skipped due to config check failure', + }); + } + outputReport(buildReport(results, options), options); + return; + } + } + + // Checks 4-5 (schema, database) will use loadedConfig when implemented. outputReport(buildReport(results, options), options); } From fa2016575a8803f0e0e51abbe948b2b750548b12 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Sun, 24 May 2026 02:36:52 +0545 Subject: [PATCH 03/12] feat(cli): add schema check to vendure doctor --- .../commands/doctor/checks/schema-check.ts | 68 +++++++++++++++++++ packages/cli/src/commands/doctor/doctor.ts | 16 ++++- 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/doctor/checks/schema-check.ts diff --git a/packages/cli/src/commands/doctor/checks/schema-check.ts b/packages/cli/src/commands/doctor/checks/schema-check.ts new file mode 100644 index 0000000000..c8d68ba79b --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/schema-check.ts @@ -0,0 +1,68 @@ +import { GraphQLTypesLoader } from '@nestjs/graphql'; +import { + getFinalVendureSchema, + RuntimeVendureConfig, + VENDURE_ADMIN_API_TYPE_PATHS, + VENDURE_SHOP_API_TYPE_PATHS, +} from '@vendure/core'; + +import { CheckResult } from '../types'; + +/** + * Attempts to build the Admin API and Shop API GraphQL schemas. + * This validates that all plugin schema extensions, custom field types, + * and SDL definitions compile correctly. + * + * Requires a loaded RuntimeVendureConfig from the config check (Check 3). + */ +export async function runSchemaCheck(config: RuntimeVendureConfig): Promise { + const details: string[] = []; + let worstStatus: 'pass' | 'warn' | 'fail' = 'pass'; + + // Note: GraphQLTypesLoader is cast to `any` to avoid type mismatch errors + // when multiple @nestjs/graphql copies exist in the monorepo. This matches + // the pattern used in the existing schema command. + const typesLoader = new GraphQLTypesLoader() as any; + + // 1. Build Admin API schema + try { + await getFinalVendureSchema({ + config, + typePaths: VENDURE_ADMIN_API_TYPE_PATHS, + typesLoader, + apiType: 'admin', + } as any); + details.push('Admin API schema generated successfully'); + } catch (e: any) { + worstStatus = 'fail'; + const errorMessage = e instanceof Error ? e.message : String(e); + details.push(`Admin API schema failed: ${errorMessage}`); + } + + // 2. Build Shop API schema + try { + await getFinalVendureSchema({ + config, + typePaths: VENDURE_SHOP_API_TYPE_PATHS, + typesLoader, + apiType: 'shop', + } as any); + details.push('Shop API schema generated successfully'); + } catch (e: any) { + worstStatus = 'fail'; + const errorMessage = e instanceof Error ? e.message : String(e); + details.push(`Shop API schema failed: ${errorMessage}`); + } + + const message = + worstStatus === 'pass' + ? 'Admin and Shop schemas generated successfully' + : 'Schema generation failed'; + + return { + name: 'Schema', + status: worstStatus, + message, + details, + }; +} diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts index 4b3d021deb..2bd1e431f6 100644 --- a/packages/cli/src/commands/doctor/doctor.ts +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -4,6 +4,7 @@ import { RuntimeVendureConfig } from '@vendure/core'; import { runConfigCheck } from './checks/config-check'; import { runDependencyCheck } from './checks/dependency-check'; import { runProjectCheck } from './checks/project-check'; +import { runSchemaCheck } from './checks/schema-check'; import { formatConsoleReport } from './formatters/console-formatter'; import { formatJsonReport } from './formatters/json-formatter'; import { CheckResult, DoctorOptions, DoctorReport } from './types'; @@ -65,7 +66,20 @@ export async function doctorCommand(options?: DoctorOptions) { } } - // Checks 4-5 (schema, database) will use loadedConfig when implemented. + // Check 4: GraphQL schema generation + if (checksToRun.includes('schema')) { + if (loadedConfig) { + results.push(await runSchemaCheck(loadedConfig)); + } else { + results.push({ + name: 'Schema', + status: 'skip', + message: 'Skipped (config check must run first)', + }); + } + } + + // Check 5 (database) will use loadedConfig when implemented. outputReport(buildReport(results, options), options); } From e773ffbc00187b0e3f34b148ea8b684249c5fb14 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Sun, 24 May 2026 02:48:52 +0545 Subject: [PATCH 04/12] feat(cli): add production profile check to vendure doctor --- .../doctor/checks/production-check.ts | 130 ++++++++++++++++++ packages/cli/src/commands/doctor/doctor.ts | 16 ++- 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/doctor/checks/production-check.ts diff --git a/packages/cli/src/commands/doctor/checks/production-check.ts b/packages/cli/src/commands/doctor/checks/production-check.ts new file mode 100644 index 0000000000..981e7a281c --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/production-check.ts @@ -0,0 +1,130 @@ +import { + SUPER_ADMIN_USER_IDENTIFIER, + SUPER_ADMIN_USER_PASSWORD, +} from '@vendure/common/lib/shared-constants'; +import { RuntimeVendureConfig } from '@vendure/core'; + +import { CheckResult } from '../types'; + +/** + * Production profile checks. Inspects the loaded RuntimeVendureConfig for settings + * that are commonly unsafe in production environments. + * + * Only runs when `--profile production` is passed. + */ +export async function runProductionCheck(config: RuntimeVendureConfig): Promise { + const details: string[] = []; + let worstStatus: 'pass' | 'warn' | 'fail' = 'pass'; + + function fail(message: string) { + worstStatus = 'fail'; + details.push(`FAIL: ${message}`); + } + + function warn(message: string) { + if (worstStatus === 'pass') worstStatus = 'warn'; + details.push(`WARN: ${message}`); + } + + // 1. Auth disabled + if (config.authOptions.disableAuth) { + fail('authOptions.disableAuth is enabled'); + } + + // 2. Default superadmin credentials + const { identifier, password } = config.authOptions.superadminCredentials; + if (identifier === SUPER_ADMIN_USER_IDENTIFIER || identifier === 'superadmin') { + warn('Default superadmin identifier "superadmin" is in use'); + } + if (password === SUPER_ADMIN_USER_PASSWORD || password === 'superadmin') { + warn('Default superadmin password "superadmin" is in use'); + } + + // 3. Cookie secret -- if using cookie auth, the secret should be explicitly set. + // The default is randomBytes(16) which changes on every restart, breaking sessions + // across multiple instances or restarts. + const tokenMethod = config.authOptions.tokenMethod; + const usesCookies = tokenMethod === 'cookie' || (Array.isArray(tokenMethod) && tokenMethod.includes('cookie')); + if (usesCookies) { + const secret = config.authOptions.cookieOptions?.secret; + if (!secret) { + warn('Cookie auth enabled but no cookie secret configured'); + } + } + + // 4. Introspection enabled + if (config.apiOptions.introspection) { + warn('GraphQL introspection is enabled'); + } + + // 5. Playground enabled + if (config.apiOptions.adminApiPlayground) { + warn('Admin API playground is enabled'); + } + if (config.apiOptions.shopApiPlayground) { + warn('Shop API playground is enabled'); + } + + // 6. Debug mode enabled + if (config.apiOptions.adminApiDebug) { + warn('Admin API debug mode is enabled'); + } + if (config.apiOptions.shopApiDebug) { + warn('Shop API debug mode is enabled'); + } + + // 7. Broad CORS with credentials + const cors = config.apiOptions.cors; + if (cors && typeof cors === 'object' && 'origin' in cors) { + if (cors.origin === true && cors.credentials === true) { + warn('CORS allows all origins with credentials enabled'); + } + } + + // 8. In-memory job queue strategy + const jobQueueStrategy = config.jobQueueOptions?.jobQueueStrategy; + if (jobQueueStrategy?.constructor?.name === 'InMemoryJobQueueStrategy') { + warn('Using InMemoryJobQueueStrategy (not persistent across restarts)'); + } + + // 9. In-memory cache strategy + const cacheStrategy = config.systemOptions?.cacheStrategy; + if (cacheStrategy?.constructor?.name === 'InMemoryCacheStrategy') { + warn('Using InMemoryCacheStrategy (not shared across instances)'); + } + + // 10. No asset storage configured + const assetStorage = config.assetOptions?.assetStorageStrategy; + if (assetStorage?.constructor?.name === 'NoAssetStorageStrategy') { + warn('No asset storage strategy configured'); + } + + // 11. No asset preview configured + const assetPreview = config.assetOptions?.assetPreviewStrategy; + if (assetPreview?.constructor?.name === 'NoAssetPreviewStrategy') { + warn('No asset preview strategy configured'); + } + + // 12. synchronize: true + if (config.dbConnectionOptions?.synchronize) { + fail('dbConnectionOptions.synchronize is enabled (use migrations instead)'); + } + + if (details.length === 0) { + details.push('All production checks passed'); + } + + const message = + worstStatus === 'pass' + ? 'No production issues detected' + : worstStatus === 'warn' + ? 'Production warnings detected' + : 'Production safety issues detected'; + + return { + name: 'Production', + status: worstStatus, + message, + details, + }; +} diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts index 2bd1e431f6..8b4fa6c839 100644 --- a/packages/cli/src/commands/doctor/doctor.ts +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -3,6 +3,7 @@ import { RuntimeVendureConfig } from '@vendure/core'; import { runConfigCheck } from './checks/config-check'; import { runDependencyCheck } from './checks/dependency-check'; +import { runProductionCheck } from './checks/production-check'; import { runProjectCheck } from './checks/project-check'; import { runSchemaCheck } from './checks/schema-check'; import { formatConsoleReport } from './formatters/console-formatter'; @@ -53,7 +54,7 @@ export async function doctorCommand(options?: DoctorOptions) { // If config check fails, skip checks that depend on a loaded config if (configResult.check.status === 'fail') { - const configDependentChecks = ['schema', 'database']; + const configDependentChecks = ['schema', 'database', 'production']; for (const check of configDependentChecks.filter(c => checksToRun.includes(c))) { results.push({ name: capitalize(check), @@ -81,6 +82,19 @@ export async function doctorCommand(options?: DoctorOptions) { // Check 5 (database) will use loadedConfig when implemented. + // Check 6: Production profile checks (only with --profile production) + if (options?.profile === 'production') { + if (loadedConfig) { + results.push(await runProductionCheck(loadedConfig)); + } else { + results.push({ + name: 'Production', + status: 'skip', + message: 'Skipped (config check must run first)', + }); + } + } + outputReport(buildReport(results, options), options); } From ac5c70b11edbe5c275a07b3640ddcc9c852c24e9 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Mon, 25 May 2026 01:15:18 +0545 Subject: [PATCH 05/12] feat(cli): add database check, unit tests, and code quality fixes --- .../commands/doctor/checks/config-check.ts | 7 +- .../commands/doctor/checks/database-check.ts | 81 ++++++ .../doctor/checks/dependency-check.spec.ts | 114 ++++++++ .../doctor/checks/dependency-check.ts | 8 +- .../doctor/checks/production-check.spec.ts | 243 ++++++++++++++++++ .../doctor/checks/project-check.spec.ts | 175 +++++++++++++ .../commands/doctor/checks/project-check.ts | 7 +- .../cli/src/commands/doctor/doctor.spec.ts | 119 +++++++++ packages/cli/src/commands/doctor/doctor.ts | 40 ++- .../doctor/formatters/console-formatter.ts | 6 +- packages/cli/src/commands/doctor/types.ts | 1 + 11 files changed, 784 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/doctor/checks/database-check.ts create mode 100644 packages/cli/src/commands/doctor/checks/dependency-check.spec.ts create mode 100644 packages/cli/src/commands/doctor/checks/production-check.spec.ts create mode 100644 packages/cli/src/commands/doctor/checks/project-check.spec.ts create mode 100644 packages/cli/src/commands/doctor/doctor.spec.ts diff --git a/packages/cli/src/commands/doctor/checks/config-check.ts b/packages/cli/src/commands/doctor/checks/config-check.ts index 96f33ac049..e8688c3d8d 100644 --- a/packages/cli/src/commands/doctor/checks/config-check.ts +++ b/packages/cli/src/commands/doctor/checks/config-check.ts @@ -1,11 +1,8 @@ -import { log } from '@clack/prompts'; import { getCompatibility, - getConfig, preBootstrapConfig, resetConfig, RuntimeVendureConfig, - setConfig, VENDURE_VERSION, } from '@vendure/core'; import { satisfies } from 'semver'; @@ -19,6 +16,8 @@ export interface ConfigCheckResult { check: CheckResult; /** The loaded runtime config, available for subsequent checks (schema, db, production). */ config?: RuntimeVendureConfig; + /** The Vendure version detected from @vendure/core. */ + vendureVersion?: string; } /** @@ -69,6 +68,7 @@ export async function runConfigCheck(configFlag?: string): Promise { + const details: string[] = []; + const dbOptions = config.dbConnectionOptions; + const dbType = (dbOptions as any).type || 'unknown'; + let worstStatus: 'pass' | 'warn' | 'fail' = 'pass'; + + // Check for synchronize: true (risky regardless of environment) + if ((dbOptions as any).synchronize) { + worstStatus = 'warn'; + details.push('Warning: synchronize is enabled (use migrations instead)'); + } + + let dataSource: DataSource | undefined; + try { + // Connectivity check only -- entities are emptied to avoid TypeORM + // metadata validation errors (e.g. plugin entities that require + // NestJS module initialization to register their primary columns). + dataSource = new DataSource( + Object.assign({}, dbOptions, { + entities: [], + subscribers: [], + synchronize: false, + migrationsRun: false, + dropSchema: false, + logging: false, + }) as any, + ); + + await dataSource.initialize(); + + details.push(`Database type: ${dbType}`); + if ((dbOptions as any).host) { + details.push(`Host: ${(dbOptions as any).host}`); + } + if ((dbOptions as any).database) { + details.push(`Database: ${(dbOptions as any).database}`); + } + + return { + name: 'Database', + status: worstStatus, + message: `Successfully connected to ${dbType} database`, + details, + }; + } catch (e: any) { + const errorMessage = e instanceof Error ? e.message : String(e); + details.push(`Database type: ${dbType}`); + details.push(`Error: ${errorMessage}`); + + return { + name: 'Database', + status: 'warn', + message: `Could not connect to ${dbType} database`, + details, + }; + } finally { + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + } +} diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts new file mode 100644 index 0000000000..c93973a042 --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runDependencyCheck } from './dependency-check'; + +// Mock fs-extra +vi.mock('fs-extra', () => ({ + default: { + existsSync: vi.fn(), + readJsonSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + }, +})); + +import fs from 'fs-extra'; + +describe('dependency-check', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns fail when node_modules does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await runDependencyCheck('/fake/node_modules'); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('node_modules not found'); + }); + + it('returns pass when all @vendure/* packages are same version', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue({ version: '3.6.3' }); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + const result = await runDependencyCheck('/fake/node_modules'); + + expect(result.status).toBe('pass'); + expect(result.details?.some(d => d.includes('All @vendure/* packages at 3.6.3'))).toBe(true); + }); + + it('returns fail when @vendure/* versions are mismatched', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + let callCount = 0; + vi.mocked(fs.readJsonSync).mockImplementation(() => { + callCount++; + // Return different version for one package + if (callCount === 3) { + return { version: '3.6.2' }; + } + return { version: '3.6.3' }; + }); + + const result = await runDependencyCheck('/fake/node_modules'); + + expect(result.status).toBe('fail'); + expect(result.details?.some(d => d.includes('Mismatched'))).toBe(true); + }); + + it('detects duplicate singleton packages', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + // node_modules exists + if (pathStr === '/fake/node_modules') return true; + // @vendure dir exists + if (pathStr.includes('@vendure')) return true; + // root graphql exists + if (pathStr === '/fake/node_modules/graphql/package.json') return true; + // nested graphql exists + if (pathStr.includes('msw/node_modules/graphql/package.json')) return true; + return false; + }); + vi.mocked(fs.readdirSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr === '/fake/node_modules') return ['msw'] as any; + return []; + }); + vi.mocked(fs.readJsonSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr === '/fake/node_modules/graphql/package.json') { + return { version: '16.11.0' }; + } + if (pathStr.includes('msw/node_modules/graphql/package.json')) { + return { version: '16.14.0' }; + } + return { version: '3.6.3' }; + }); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + const result = await runDependencyCheck('/fake/node_modules'); + + expect(result.status).toBe('fail'); + expect(result.details?.some(d => d.includes('Multiple graphql versions'))).toBe(true); + }); + + it('returns pass with no duplicate singleton dependencies', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue({ version: '3.6.3' }); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + const result = await runDependencyCheck('/fake/node_modules'); + + expect(result.details?.some(d => d.includes('No duplicate singleton dependencies'))).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index 58450c9d32..14e1b3ad0d 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -315,10 +315,10 @@ function detectDbTypeFromSource(cwd: string): string | undefined { try { const content = fs.readFileSync(filePath, 'utf-8'); - // Match patterns like: type: 'postgres' or type: "mysql" - const match = content.match(/type\s*:\s*['"]([^'"]+)['"]/); - if (match) { - const type = match[1]; + // Match type within dbConnectionOptions block + const dbBlockMatch = content.match(/dbConnectionOptions\s*[:{][\s\S]*?type\s*:\s*['"]([^'"]+)['"]/); + if (dbBlockMatch) { + const type = dbBlockMatch[1]; if (type in DB_DRIVER_MAP) { return type; } diff --git a/packages/cli/src/commands/doctor/checks/production-check.spec.ts b/packages/cli/src/commands/doctor/checks/production-check.spec.ts new file mode 100644 index 0000000000..b1bfa61315 --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/production-check.spec.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; + +import { runProductionCheck } from './production-check'; + +/** + * Creates a minimal RuntimeVendureConfig-like object for testing. + * Only includes the properties that the production check inspects. + */ +function createTestConfig(overrides: Record = {}): any { + return { + authOptions: { + disableAuth: false, + superadminCredentials: { + identifier: 'custom-admin', + password: 'custom-password', + }, + tokenMethod: 'bearer', + cookieOptions: { + secret: 'a-real-secret', + }, + ...overrides.authOptions, + }, + apiOptions: { + introspection: false, + adminApiPlayground: false, + shopApiPlayground: false, + adminApiDebug: false, + shopApiDebug: false, + cors: { + origin: 'https://example.com', + credentials: true, + }, + ...overrides.apiOptions, + }, + jobQueueOptions: { + jobQueueStrategy: { constructor: { name: 'BullMQJobQueueStrategy' } }, + ...overrides.jobQueueOptions, + }, + systemOptions: { + cacheStrategy: { constructor: { name: 'RedisCacheStrategy' } }, + ...overrides.systemOptions, + }, + assetOptions: { + assetStorageStrategy: { constructor: { name: 'S3AssetStorageStrategy' } }, + assetPreviewStrategy: { constructor: { name: 'SharpAssetPreviewStrategy' } }, + ...overrides.assetOptions, + }, + dbConnectionOptions: { + synchronize: false, + ...overrides.dbConnectionOptions, + }, + }; +} + +describe('production-check', () => { + it('returns pass when all settings are production-safe', async () => { + const config = createTestConfig(); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('pass'); + expect(result.details).toContain('All production checks passed'); + }); + + it('detects disableAuth enabled', async () => { + const config = createTestConfig({ + authOptions: { disableAuth: true }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('fail'); + expect(result.details?.some(d => d.includes('disableAuth'))).toBe(true); + }); + + it('detects default superadmin identifier', async () => { + const config = createTestConfig({ + authOptions: { + superadminCredentials: { + identifier: 'superadmin', + password: 'custom-password', + }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('superadmin identifier'))).toBe(true); + }); + + it('detects default superadmin password', async () => { + const config = createTestConfig({ + authOptions: { + superadminCredentials: { + identifier: 'custom-admin', + password: 'superadmin', + }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('superadmin password'))).toBe(true); + }); + + it('detects cookie auth without secret', async () => { + const config = createTestConfig({ + authOptions: { + tokenMethod: 'cookie', + cookieOptions: {}, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('cookie secret'))).toBe(true); + }); + + it('detects cookie auth in array token method', async () => { + const config = createTestConfig({ + authOptions: { + tokenMethod: ['bearer', 'cookie'], + cookieOptions: {}, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('cookie secret'))).toBe(true); + }); + + it('detects introspection enabled', async () => { + const config = createTestConfig({ + apiOptions: { introspection: true }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('introspection'))).toBe(true); + }); + + it('detects playground enabled', async () => { + const config = createTestConfig({ + apiOptions: { adminApiPlayground: true }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('playground'))).toBe(true); + }); + + it('detects debug mode enabled', async () => { + const config = createTestConfig({ + apiOptions: { shopApiDebug: true }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('debug'))).toBe(true); + }); + + it('detects broad CORS with credentials', async () => { + const config = createTestConfig({ + apiOptions: { + cors: { origin: true, credentials: true }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('CORS'))).toBe(true); + }); + + it('detects InMemoryJobQueueStrategy', async () => { + const config = createTestConfig({ + jobQueueOptions: { + jobQueueStrategy: { constructor: { name: 'InMemoryJobQueueStrategy' } }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('InMemoryJobQueueStrategy'))).toBe(true); + }); + + it('detects InMemoryCacheStrategy', async () => { + const config = createTestConfig({ + systemOptions: { + cacheStrategy: { constructor: { name: 'InMemoryCacheStrategy' } }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('InMemoryCacheStrategy'))).toBe(true); + }); + + it('detects NoAssetStorageStrategy', async () => { + const config = createTestConfig({ + assetOptions: { + assetStorageStrategy: { constructor: { name: 'NoAssetStorageStrategy' } }, + assetPreviewStrategy: { constructor: { name: 'SharpAssetPreviewStrategy' } }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('No asset storage'))).toBe(true); + }); + + it('detects synchronize enabled', async () => { + const config = createTestConfig({ + dbConnectionOptions: { synchronize: true }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('fail'); + expect(result.details?.some(d => d.includes('synchronize'))).toBe(true); + }); + + it('fail status overrides warn status', async () => { + const config = createTestConfig({ + authOptions: { disableAuth: true }, // fail + apiOptions: { introspection: true }, // warn + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('fail'); + }); +}); diff --git a/packages/cli/src/commands/doctor/checks/project-check.spec.ts b/packages/cli/src/commands/doctor/checks/project-check.spec.ts new file mode 100644 index 0000000000..db5f5b3cb9 --- /dev/null +++ b/packages/cli/src/commands/doctor/checks/project-check.spec.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runProjectCheck } from './project-check'; + +// Mock fs-extra +vi.mock('fs-extra', () => ({ + default: { + existsSync: vi.fn(), + readJsonSync: vi.fn(), + readFileSync: vi.fn(), + }, +})); + +// Mock monorepo-utils +vi.mock('../../../utilities/monorepo-utils', () => ({ + detectMonorepoStructure: vi.fn(() => ({ isMonorepo: false })), + findPackageJsonWithDependency: vi.fn(() => null), +})); + +import fs from 'fs-extra'; +import { detectMonorepoStructure, findPackageJsonWithDependency } from '../../../utilities/monorepo-utils'; + +describe('project-check', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns fail when no package.json exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await runProjectCheck(); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('No package.json'); + }); + + it('returns fail when package.json cannot be parsed', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockImplementation(() => { + throw new Error('Invalid JSON'); + }); + + const result = await runProjectCheck(); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('Failed to parse'); + }); + + it('returns fail when no @vendure/* dependencies found', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { express: '4.0.0' }, + devDependencies: {}, + }); + vi.mocked(findPackageJsonWithDependency).mockReturnValue(null); + + const result = await runProjectCheck(); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('No @vendure/* dependencies'); + }); + + it('returns pass when @vendure/* dependencies found', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { '@vendure/core': '3.6.3' }, + devDependencies: {}, + }); + + // Use --config to skip config file discovery + const result = await runProjectCheck('vendure-config.ts'); + + expect(result.status).toBe('pass'); + }); + + it('returns fail when --config points to nonexistent file', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr.endsWith('package.json')) return true; + if (pathStr.endsWith('nonexistent.ts')) return false; + return false; + }); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { '@vendure/core': '3.6.3' }, + devDependencies: {}, + }); + + const result = await runProjectCheck('nonexistent.ts'); + + expect(result.status).toBe('fail'); + expect(result.message).toContain('Specified config file not found'); + }); + + it('reports package manager from lockfile', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr.endsWith('package.json')) return true; + if (pathStr.endsWith('yarn.lock')) return true; + return false; + }); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { '@vendure/core': '3.6.3' }, + devDependencies: {}, + }); + + const result = await runProjectCheck(); + + expect(result.packageManager).toBe('yarn'); + expect(result.details).toContain('Package manager: yarn'); + }); + + it('warns when multiple lockfiles found (different managers)', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr.endsWith('package.json')) return true; + if (pathStr.endsWith('yarn.lock')) return true; + if (pathStr.endsWith('package-lock.json')) return true; + return false; + }); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { '@vendure/core': '3.6.3' }, + devDependencies: {}, + }); + + const result = await runProjectCheck(); + + expect(result.details?.some(d => d.includes('multiple lockfiles'))).toBe(true); + }); + + it('does not warn when both bun.lockb and bun.lock exist', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr.endsWith('package.json')) return true; + if (pathStr.endsWith('bun.lockb')) return true; + if (pathStr.endsWith('bun.lock')) return true; + return false; + }); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: { '@vendure/core': '3.6.3' }, + devDependencies: {}, + }); + + const result = await runProjectCheck(); + + expect(result.packageManager).toBe('bun'); + expect(result.details?.some(d => d.includes('multiple lockfiles'))).toBe(false); + }); + + it('detects monorepo structure', async () => { + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const pathStr = String(p); + if (pathStr.endsWith('package.json')) return true; + return false; + }); + vi.mocked(fs.readJsonSync).mockReturnValue({ + dependencies: {}, + devDependencies: {}, + }); + vi.mocked(detectMonorepoStructure).mockReturnValue({ + isMonorepo: true, + root: '/monorepo', + packageDir: 'packages', + }); + vi.mocked(findPackageJsonWithDependency).mockReturnValue('/monorepo/packages/app/package.json'); + + const result = await runProjectCheck(); + + expect(result.status).toBe('pass'); + expect(result.details?.some(d => d.includes('Monorepo detected'))).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/doctor/checks/project-check.ts b/packages/cli/src/commands/doctor/checks/project-check.ts index 07b294044c..064a827ea0 100644 --- a/packages/cli/src/commands/doctor/checks/project-check.ts +++ b/packages/cli/src/commands/doctor/checks/project-check.ts @@ -101,6 +101,7 @@ export async function runProjectCheck(configFlag?: string): Promise status: 'pass', message, details, + packageManager, }; } @@ -125,8 +126,10 @@ function detectLockfiles(cwd: string): string[] { if (fs.existsSync(path.join(cwd, 'package-lock.json'))) lockfiles.push('package-lock.json'); if (fs.existsSync(path.join(cwd, 'yarn.lock'))) lockfiles.push('yarn.lock'); if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) lockfiles.push('pnpm-lock.yaml'); - if (fs.existsSync(path.join(cwd, 'bun.lockb'))) lockfiles.push('bun.lockb'); - if (fs.existsSync(path.join(cwd, 'bun.lock'))) lockfiles.push('bun.lock'); + // bun.lockb (binary) and bun.lock (text) are both bun lockfiles -- count as one + if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock'))) { + lockfiles.push('bun.lock'); + } return lockfiles; } diff --git a/packages/cli/src/commands/doctor/doctor.spec.ts b/packages/cli/src/commands/doctor/doctor.spec.ts new file mode 100644 index 0000000000..26c9a28c5b --- /dev/null +++ b/packages/cli/src/commands/doctor/doctor.spec.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock @clack/prompts +vi.mock('@clack/prompts', () => ({ + log: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +import { log } from '@clack/prompts'; + +describe('doctor command internals', () => { + let mockExit: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + // Re-apply process.exit spy after resetModules + mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('resolveChecks', () => { + it('warns about unknown check names via doctorCommand', async () => { + vi.doMock('./checks/project-check', () => ({ + runProjectCheck: vi.fn().mockResolvedValue({ + name: 'Project', + status: 'fail', + message: 'No package.json', + }), + })); + vi.doMock('./formatters/console-formatter', () => ({ + formatConsoleReport: vi.fn(), + })); + vi.doMock('./formatters/json-formatter', () => ({ + formatJsonReport: vi.fn(), + })); + + const { doctorCommand } = await import('./doctor'); + + await doctorCommand({ check: ['invalid-check'] }); + + expect(vi.mocked(log.warn)).toHaveBeenCalledWith( + expect.stringContaining('Unknown check(s): invalid-check'), + ); + }); + }); + + describe('buildReport', () => { + it('marks report as failed when any check fails', async () => { + vi.doMock('./checks/project-check', () => ({ + runProjectCheck: vi.fn().mockResolvedValue({ + name: 'Project', + status: 'fail', + message: 'No package.json', + }), + })); + vi.doMock('./formatters/json-formatter', () => ({ + formatJsonReport: vi.fn(), + })); + + const { doctorCommand } = await import('./doctor'); + + await doctorCommand({ check: ['project'], format: 'json' }); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('--strict mode', () => { + it('treats warnings as failures with --strict', async () => { + vi.doMock('./checks/project-check', () => ({ + runProjectCheck: vi.fn().mockResolvedValue({ + name: 'Project', + status: 'warn', + message: 'Some warning', + }), + })); + vi.doMock('./formatters/json-formatter', () => ({ + formatJsonReport: vi.fn(), + })); + + const { doctorCommand } = await import('./doctor'); + + await doctorCommand({ check: ['project'], format: 'json', strict: true }); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('--profile validation', () => { + it('warns about unknown profile', async () => { + vi.doMock('./checks/project-check', () => ({ + runProjectCheck: vi.fn().mockResolvedValue({ + name: 'Project', + status: 'fail', + message: 'No package.json', + }), + })); + vi.doMock('./formatters/console-formatter', () => ({ + formatConsoleReport: vi.fn(), + })); + + const { doctorCommand } = await import('./doctor'); + + await doctorCommand({ check: ['project'], profile: 'staging' }); + + expect(vi.mocked(log.warn)).toHaveBeenCalledWith( + expect.stringContaining('Unknown profile: staging'), + ); + }); + }); +}); diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts index 8b4fa6c839..f3a1464a07 100644 --- a/packages/cli/src/commands/doctor/doctor.ts +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -2,6 +2,7 @@ import { log } from '@clack/prompts'; import { RuntimeVendureConfig } from '@vendure/core'; import { runConfigCheck } from './checks/config-check'; +import { runDatabaseCheck } from './checks/database-check'; import { runDependencyCheck } from './checks/dependency-check'; import { runProductionCheck } from './checks/production-check'; import { runProjectCheck } from './checks/project-check'; @@ -11,6 +12,7 @@ import { formatJsonReport } from './formatters/json-formatter'; import { CheckResult, DoctorOptions, DoctorReport } from './types'; const ALL_CHECKS = ['project', 'dependencies', 'config', 'schema', 'database'] as const; +const VALID_PROFILES = ['production'] as const; /** * Entry point for the `vendure doctor` command. @@ -18,14 +20,18 @@ const ALL_CHECKS = ['project', 'dependencies', 'config', 'schema', 'database'] a */ export async function doctorCommand(options?: DoctorOptions) { const checksToRun = resolveChecks(options?.check); + validateProfile(options?.profile); const results: CheckResult[] = []; let loadedConfig: RuntimeVendureConfig | undefined; + let packageManager: string | undefined; + let vendureVersion: string | undefined; // Check 1: Project detection & config discovery if (checksToRun.includes('project')) { const projectResult = await runProjectCheck(options?.config); results.push(projectResult); + packageManager = projectResult.packageManager; // If project check fails, skip remaining checks that depend on it if (projectResult.status === 'fail' && checksToRun.length > 1) { @@ -36,7 +42,7 @@ export async function doctorCommand(options?: DoctorOptions) { message: 'Skipped due to project check failure', }); } - outputReport(buildReport(results, options), options); + outputReport(buildReport(results, options, { vendureVersion, packageManager }), options); return; } } @@ -51,6 +57,7 @@ export async function doctorCommand(options?: DoctorOptions) { const configResult = await runConfigCheck(options?.config); results.push(configResult.check); loadedConfig = configResult.config; + vendureVersion = configResult.vendureVersion; // If config check fails, skip checks that depend on a loaded config if (configResult.check.status === 'fail') { @@ -62,7 +69,7 @@ export async function doctorCommand(options?: DoctorOptions) { message: 'Skipped due to config check failure', }); } - outputReport(buildReport(results, options), options); + outputReport(buildReport(results, options, { vendureVersion, packageManager }), options); return; } } @@ -80,7 +87,18 @@ export async function doctorCommand(options?: DoctorOptions) { } } - // Check 5 (database) will use loadedConfig when implemented. + // Check 5: Database connectivity + if (checksToRun.includes('database')) { + if (loadedConfig) { + results.push(await runDatabaseCheck(loadedConfig)); + } else { + results.push({ + name: 'Database', + status: 'skip', + message: 'Skipped (config check must run first)', + }); + } + } // Check 6: Production profile checks (only with --profile production) if (options?.profile === 'production') { @@ -95,7 +113,7 @@ export async function doctorCommand(options?: DoctorOptions) { } } - outputReport(buildReport(results, options), options); + outputReport(buildReport(results, options, { vendureVersion, packageManager }), options); } function resolveChecks(checkFlags?: string[]): string[] { @@ -110,13 +128,25 @@ function resolveChecks(checkFlags?: string[]): string[] { return valid.length > 0 ? valid : [...ALL_CHECKS]; } -function buildReport(checks: CheckResult[], options?: DoctorOptions): DoctorReport { +function validateProfile(profile?: string): void { + if (profile && !(VALID_PROFILES as readonly string[]).includes(profile)) { + log.warn(`Unknown profile: ${profile}. Valid profiles: ${VALID_PROFILES.join(', ')}`); + } +} + +function buildReport( + checks: CheckResult[], + options?: DoctorOptions, + meta?: { vendureVersion?: string; packageManager?: string }, +): DoctorReport { const hasFail = checks.some(c => c.status === 'fail'); const hasWarn = checks.some(c => c.status === 'warn'); const overallStatus = hasFail || (options?.strict && hasWarn) ? 'failed' : 'passed'; return { + vendureVersion: meta?.vendureVersion, nodeVersion: process.version, + packageManager: meta?.packageManager, checks, overallStatus, }; diff --git a/packages/cli/src/commands/doctor/formatters/console-formatter.ts b/packages/cli/src/commands/doctor/formatters/console-formatter.ts index d7966da971..b7fa9503b3 100644 --- a/packages/cli/src/commands/doctor/formatters/console-formatter.ts +++ b/packages/cli/src/commands/doctor/formatters/console-formatter.ts @@ -1,7 +1,7 @@ import { log } from '@clack/prompts'; import pc from 'picocolors'; -import { CheckResult, CheckStatus, DoctorReport } from '../types'; +import { CheckStatus, DoctorReport } from '../types'; const STATUS_LABELS: Record = { pass: pc.green('pass'), @@ -21,11 +21,11 @@ export function formatConsoleReport(report: DoctorReport): void { for (const check of report.checks) { const status = STATUS_LABELS[check.status]; const name = check.name.padEnd(16); - log.message(`${name}${status} ${check.message}`); + log.info(`${name}${status} ${check.message}`); if (check.details?.length) { for (const detail of check.details) { - log.message(pc.dim(` ${detail}`)); + log.info(pc.dim(` ${detail}`)); } } } diff --git a/packages/cli/src/commands/doctor/types.ts b/packages/cli/src/commands/doctor/types.ts index 0ac348c4c7..06d338513a 100644 --- a/packages/cli/src/commands/doctor/types.ts +++ b/packages/cli/src/commands/doctor/types.ts @@ -5,6 +5,7 @@ export interface CheckResult { status: CheckStatus; message: string; details?: string[]; + packageManager?: string; } export interface DoctorOptions { From d438056829db7b18983870971a667233652b35c9 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Mon, 25 May 2026 01:21:48 +0545 Subject: [PATCH 06/12] feat(cli): add in-memory session strategy check to production profile --- .../commands/doctor/checks/production-check.spec.ts | 13 +++++++++++++ .../src/commands/doctor/checks/production-check.ts | 12 +++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/doctor/checks/production-check.spec.ts b/packages/cli/src/commands/doctor/checks/production-check.spec.ts index b1bfa61315..7e59bc5c3f 100644 --- a/packages/cli/src/commands/doctor/checks/production-check.spec.ts +++ b/packages/cli/src/commands/doctor/checks/production-check.spec.ts @@ -205,6 +205,19 @@ describe('production-check', () => { expect(result.details?.some(d => d.includes('InMemoryCacheStrategy'))).toBe(true); }); + it('detects DefaultSessionCacheStrategy', async () => { + const config = createTestConfig({ + authOptions: { + sessionCacheStrategy: { constructor: { name: 'DefaultSessionCacheStrategy' } }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('DefaultSessionCacheStrategy'))).toBe(true); + }); + it('detects NoAssetStorageStrategy', async () => { const config = createTestConfig({ assetOptions: { diff --git a/packages/cli/src/commands/doctor/checks/production-check.ts b/packages/cli/src/commands/doctor/checks/production-check.ts index 981e7a281c..e95ee79031 100644 --- a/packages/cli/src/commands/doctor/checks/production-check.ts +++ b/packages/cli/src/commands/doctor/checks/production-check.ts @@ -93,19 +93,25 @@ export async function runProductionCheck(config: RuntimeVendureConfig): Promise< warn('Using InMemoryCacheStrategy (not shared across instances)'); } - // 10. No asset storage configured + // 10. In-memory session cache strategy + const sessionCacheStrategy = config.authOptions?.sessionCacheStrategy; + if (sessionCacheStrategy?.constructor?.name === 'DefaultSessionCacheStrategy') { + warn('Using DefaultSessionCacheStrategy (in-memory, not shared across instances)'); + } + + // 11. No asset storage configured const assetStorage = config.assetOptions?.assetStorageStrategy; if (assetStorage?.constructor?.name === 'NoAssetStorageStrategy') { warn('No asset storage strategy configured'); } - // 11. No asset preview configured + // 12. No asset preview configured const assetPreview = config.assetOptions?.assetPreviewStrategy; if (assetPreview?.constructor?.name === 'NoAssetPreviewStrategy') { warn('No asset preview strategy configured'); } - // 12. synchronize: true + // 13. synchronize: true if (config.dbConnectionOptions?.synchronize) { fail('dbConnectionOptions.synchronize is enabled (use migrations instead)'); } From 67ab71f05ec70e051690662e3e465a4730759725 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Mon, 25 May 2026 01:29:56 +0545 Subject: [PATCH 07/12] feat(cli): add @nestjs/graphql, @nestjs/typeorm to singleton checks and session strategy check --- packages/cli/src/commands/doctor/checks/dependency-check.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index 14e1b3ad0d..bd1523bc98 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -34,6 +34,8 @@ const SINGLETON_PACKAGES = [ 'typeorm', '@nestjs/core', '@nestjs/common', + '@nestjs/graphql', + '@nestjs/typeorm', '@apollo/server', ]; From 2bc96c1a19a37d152f8e552a8e1e621825cb9b63 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Mon, 25 May 2026 01:40:32 +0545 Subject: [PATCH 08/12] fix(cli): address code review feedback from CodeRabbit --- .../doctor/checks/dependency-check.ts | 5 +--- .../doctor/checks/production-check.spec.ts | 26 +++++++++++++++++++ .../doctor/checks/production-check.ts | 9 +++++-- packages/cli/src/commands/doctor/doctor.ts | 16 +++++++++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index bd1523bc98..e2a6302e3f 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -320,10 +320,7 @@ function detectDbTypeFromSource(cwd: string): string | undefined { // Match type within dbConnectionOptions block const dbBlockMatch = content.match(/dbConnectionOptions\s*[:{][\s\S]*?type\s*:\s*['"]([^'"]+)['"]/); if (dbBlockMatch) { - const type = dbBlockMatch[1]; - if (type in DB_DRIVER_MAP) { - return type; - } + return dbBlockMatch[1]; } } catch { continue; diff --git a/packages/cli/src/commands/doctor/checks/production-check.spec.ts b/packages/cli/src/commands/doctor/checks/production-check.spec.ts index 7e59bc5c3f..df0a6e30dc 100644 --- a/packages/cli/src/commands/doctor/checks/production-check.spec.ts +++ b/packages/cli/src/commands/doctor/checks/production-check.spec.ts @@ -179,6 +179,32 @@ describe('production-check', () => { expect(result.details?.some(d => d.includes('CORS'))).toBe(true); }); + it('detects wildcard CORS string with credentials', async () => { + const config = createTestConfig({ + apiOptions: { + cors: { origin: '*', credentials: true }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('CORS'))).toBe(true); + }); + + it('detects wildcard in CORS array with credentials', async () => { + const config = createTestConfig({ + apiOptions: { + cors: { origin: ['https://example.com', '*'], credentials: true }, + }, + }); + + const result = await runProductionCheck(config); + + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('CORS'))).toBe(true); + }); + it('detects InMemoryJobQueueStrategy', async () => { const config = createTestConfig({ jobQueueOptions: { diff --git a/packages/cli/src/commands/doctor/checks/production-check.ts b/packages/cli/src/commands/doctor/checks/production-check.ts index e95ee79031..62c5094773 100644 --- a/packages/cli/src/commands/doctor/checks/production-check.ts +++ b/packages/cli/src/commands/doctor/checks/production-check.ts @@ -75,8 +75,13 @@ export async function runProductionCheck(config: RuntimeVendureConfig): Promise< // 7. Broad CORS with credentials const cors = config.apiOptions.cors; - if (cors && typeof cors === 'object' && 'origin' in cors) { - if (cors.origin === true && cors.credentials === true) { + if (cors && typeof cors === 'object' && 'origin' in cors && cors.credentials === true) { + const origin = cors.origin; + if ( + origin === true || + origin === '*' || + (Array.isArray(origin) && origin.includes('*')) + ) { warn('CORS allows all origins with credentials enabled'); } } diff --git a/packages/cli/src/commands/doctor/doctor.ts b/packages/cli/src/commands/doctor/doctor.ts index f3a1464a07..0ec0df20ab 100644 --- a/packages/cli/src/commands/doctor/doctor.ts +++ b/packages/cli/src/commands/doctor/doctor.ts @@ -42,6 +42,13 @@ export async function doctorCommand(options?: DoctorOptions) { message: 'Skipped due to project check failure', }); } + if (options?.profile === 'production') { + results.push({ + name: 'Production', + status: 'skip', + message: 'Skipped due to project check failure', + }); + } outputReport(buildReport(results, options, { vendureVersion, packageManager }), options); return; } @@ -61,7 +68,7 @@ export async function doctorCommand(options?: DoctorOptions) { // If config check fails, skip checks that depend on a loaded config if (configResult.check.status === 'fail') { - const configDependentChecks = ['schema', 'database', 'production']; + const configDependentChecks = ['schema', 'database']; for (const check of configDependentChecks.filter(c => checksToRun.includes(c))) { results.push({ name: capitalize(check), @@ -69,6 +76,13 @@ export async function doctorCommand(options?: DoctorOptions) { message: 'Skipped due to config check failure', }); } + if (options?.profile === 'production') { + results.push({ + name: 'Production', + status: 'skip', + message: 'Skipped due to config check failure', + }); + } outputReport(buildReport(results, options, { vendureVersion, packageManager }), options); return; } From 487e5d0d93c0c42cef4910d8141ed8c13f3b2ac3 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Thu, 28 May 2026 23:35:09 +0545 Subject: [PATCH 09/12] fix(cli): address maintainer feedback - singleton warn, plugin names, schema wording --- .../commands/doctor/checks/config-check.ts | 44 +++++++++++++++---- .../doctor/checks/dependency-check.spec.ts | 2 +- .../doctor/checks/dependency-check.ts | 7 ++- .../commands/doctor/checks/schema-check.ts | 8 ++-- 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/doctor/checks/config-check.ts b/packages/cli/src/commands/doctor/checks/config-check.ts index e8688c3d8d..6f079e55b2 100644 --- a/packages/cli/src/commands/doctor/checks/config-check.ts +++ b/packages/cli/src/commands/doctor/checks/config-check.ts @@ -58,12 +58,19 @@ export async function runConfigCheck(configFlag?: string): Promise 0) { + parts.push(`${pluginResults.noCompatCount} plugin(s) without compatibility range`); + } + message = `Config loaded (${parts.join(', ')})`; + } return { check: { name: 'Config', status, message, details }, @@ -92,6 +99,7 @@ interface PluginCheckResult { details: string[]; hasIncompatible: boolean; hasNoCompat: boolean; + noCompatCount: number; } /** @@ -102,20 +110,23 @@ function checkPlugins(config: RuntimeVendureConfig): PluginCheckResult { const details: string[] = []; let hasIncompatible = false; let hasNoCompat = false; + let noCompatCount = 0; if (!config.plugins || config.plugins.length === 0) { details.push('No plugins configured'); - return { details, hasIncompatible, hasNoCompat }; + return { details, hasIncompatible, hasNoCompat, noCompatCount }; } details.push(`${config.plugins.length} plugin(s) loaded`); for (const plugin of config.plugins) { - const pluginName = (plugin as any).name as string; + // DynamicModule plugins (e.g. SomePlugin.init()) have the class on .module + const pluginName = getPluginName(plugin); const compatibility = getCompatibility(plugin); if (!compatibility) { hasNoCompat = true; + noCompatCount++; details.push(`Plugin "${pluginName}": no compatibility range specified`); } else if ( !satisfies(VENDURE_VERSION, compatibility, { loose: true, includePrerelease: true }) @@ -127,5 +138,20 @@ function checkPlugins(config: RuntimeVendureConfig): PluginCheckResult { } } - return { details, hasIncompatible, hasNoCompat }; + return { details, hasIncompatible, hasNoCompat, noCompatCount }; +} + +/** + * Extracts the plugin name, handling both plain classes and DynamicModule objects. + */ +function getPluginName(plugin: any): string { + // DynamicModule has { module: Type, ... } + if (plugin && plugin.module && plugin.module.name) { + return plugin.module.name; + } + // Plain class + if (plugin && plugin.name) { + return plugin.name; + } + return 'Unknown'; } diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts index c93973a042..b0ef8587e8 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts @@ -97,7 +97,7 @@ describe('dependency-check', () => { const result = await runDependencyCheck('/fake/node_modules'); - expect(result.status).toBe('fail'); + expect(result.status).toBe('warn'); expect(result.details?.some(d => d.includes('Multiple graphql versions'))).toBe(true); }); diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index e2a6302e3f..33478816b6 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -89,11 +89,14 @@ export async function runDependencyCheck(nodeModulesPath?: string): Promise 1) { - worstStatus = 'fail'; - details.push(`Multiple ${pkg} versions: ${versions.join(', ')}`); + if (worstStatus === 'pass') worstStatus = 'warn'; + details.push(`Multiple ${pkg} versions found: ${versions.join(', ')}`); } } if (duplicates.size === 0 || [...duplicates.values()].every(v => v.length <= 1)) { diff --git a/packages/cli/src/commands/doctor/checks/schema-check.ts b/packages/cli/src/commands/doctor/checks/schema-check.ts index c8d68ba79b..15ea933c52 100644 --- a/packages/cli/src/commands/doctor/checks/schema-check.ts +++ b/packages/cli/src/commands/doctor/checks/schema-check.ts @@ -32,7 +32,7 @@ export async function runSchemaCheck(config: RuntimeVendureConfig): Promise Date: Thu, 28 May 2026 23:45:11 +0545 Subject: [PATCH 10/12] fix(cli): patch vs minor version severity, visual indicators for detail lines --- .../doctor/checks/dependency-check.spec.ts | 27 +++++++++++++-- .../doctor/checks/dependency-check.ts | 15 ++++++-- .../doctor/formatters/console-formatter.ts | 34 ++++++++++++++++++- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts index b0ef8587e8..7fc3759ef1 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.spec.ts @@ -44,7 +44,7 @@ describe('dependency-check', () => { expect(result.details?.some(d => d.includes('All @vendure/* packages at 3.6.3'))).toBe(true); }); - it('returns fail when @vendure/* versions are mismatched', async () => { + it('returns warn when @vendure/* patch versions are mismatched', async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readdirSync).mockReturnValue([]); vi.mocked(fs.readFileSync).mockReturnValue(''); @@ -52,7 +52,7 @@ describe('dependency-check', () => { let callCount = 0; vi.mocked(fs.readJsonSync).mockImplementation(() => { callCount++; - // Return different version for one package + // Return different patch version for one package if (callCount === 3) { return { version: '3.6.2' }; } @@ -61,8 +61,29 @@ describe('dependency-check', () => { const result = await runDependencyCheck('/fake/node_modules'); + expect(result.status).toBe('warn'); + expect(result.details?.some(d => d.includes('Mismatched') && d.includes('patch'))).toBe(true); + }); + + it('returns fail when @vendure/* minor versions are mismatched', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.readFileSync).mockReturnValue(''); + + let callCount = 0; + vi.mocked(fs.readJsonSync).mockImplementation(() => { + callCount++; + // Return different minor version for one package + if (callCount === 3) { + return { version: '3.7.0' }; + } + return { version: '3.6.3' }; + }); + + const result = await runDependencyCheck('/fake/node_modules'); + expect(result.status).toBe('fail'); - expect(result.details?.some(d => d.includes('Mismatched'))).toBe(true); + expect(result.details?.some(d => d.includes('Mismatched') && d.includes('minor/major'))).toBe(true); }); it('detects duplicate singleton packages', async () => { diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index 33478816b6..a80c94c746 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -76,9 +76,20 @@ export async function runDependencyCheck(nodeModulesPath?: string): Promise 0) { const versions = new Set(vendureVersions.values()); if (versions.size > 1) { - worstStatus = 'fail'; const grouped = groupByVersion(vendureVersions); - details.push('Mismatched @vendure/* package versions:'); + // Check if it's only a patch mismatch (e.g. 3.6.3 vs 3.6.2) or a + // minor/major mismatch (e.g. 3.7.0 vs 3.6.3). Patch mismatches are + // unlikely to cause issues; minor/major mismatches can break things. + const majorMinors = new Set( + [...versions].map(v => v.split('.').slice(0, 2).join('.')), + ); + if (majorMinors.size > 1) { + worstStatus = 'fail'; + details.push('Mismatched @vendure/* package versions (minor/major):'); + } else { + if (worstStatus === 'pass') worstStatus = 'warn'; + details.push('Mismatched @vendure/* package versions (patch):'); + } for (const [version, pkgs] of grouped) { details.push(` ${version}: ${pkgs.join(', ')}`); } diff --git a/packages/cli/src/commands/doctor/formatters/console-formatter.ts b/packages/cli/src/commands/doctor/formatters/console-formatter.ts index b7fa9503b3..3073ba5d67 100644 --- a/packages/cli/src/commands/doctor/formatters/console-formatter.ts +++ b/packages/cli/src/commands/doctor/formatters/console-formatter.ts @@ -10,6 +10,8 @@ const STATUS_LABELS: Record = { skip: pc.dim('skip'), }; +const PADDING = ' '; + /** * Formats the doctor report for terminal output using @clack/prompts and picocolors. */ @@ -25,7 +27,7 @@ export function formatConsoleReport(report: DoctorReport): void { if (check.details?.length) { for (const detail of check.details) { - log.info(pc.dim(` ${detail}`)); + log.info(`${PADDING}${colorizeDetail(detail)}`); } } } @@ -46,3 +48,33 @@ export function formatConsoleReport(report: DoctorReport): void { log.success(msg); } } + +/** + * Applies color to a detail line based on its content. + * - Lines indicating errors/failures are red + * - Lines indicating warnings are yellow + * - Informational lines are dimmed + */ +function colorizeDetail(detail: string): string { + // Failure indicators + if ( + detail.startsWith('FAIL:') || + detail.startsWith('Error:') || + detail.includes('failed:') || + detail.includes('incompatible') || + detail.startsWith('Mismatched') + ) { + return pc.red(detail); + } + // Warning indicators + if ( + detail.startsWith('WARN:') || + detail.startsWith('Warning:') || + detail.includes('Multiple') || + detail.includes('no compatibility range') + ) { + return pc.yellow(detail); + } + // Informational + return pc.dim(detail); +} From 0ed51a1b7d9958be46eeefeaabfb78a7d72b7e43 Mon Sep 17 00:00:00 2001 From: ryrahul Date: Thu, 28 May 2026 23:51:12 +0545 Subject: [PATCH 11/12] test(cli): add e2e smoke tests for vendure doctor command --- packages/cli/e2e/doctor-command.e2e-spec.ts | 166 ++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 packages/cli/e2e/doctor-command.e2e-spec.ts diff --git a/packages/cli/e2e/doctor-command.e2e-spec.ts b/packages/cli/e2e/doctor-command.e2e-spec.ts new file mode 100644 index 0000000000..9ed8972901 --- /dev/null +++ b/packages/cli/e2e/doctor-command.e2e-spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { CliTestProject, createTestProject } from './cli-test-utils'; + +describe('CLI Doctor Command E2E', () => { + let testProject: CliTestProject; + + afterEach(() => { + if (testProject) { + testProject.cleanup(); + } + }); + + describe('project check', () => { + it('should pass project check in a valid Vendure project', async () => { + testProject = createTestProject('doctor-project-pass'); + + const result = await testProject.runCliCommand([ + 'doctor', + '--check', + 'project', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.overallStatus).toBe('passed'); + expect(report.checks).toHaveLength(1); + expect(report.checks[0].name).toBe('Project'); + expect(report.checks[0].status).toBe('pass'); + }); + + it('should fail project check in a non-Vendure directory', async () => { + testProject = createTestProject('doctor-project-fail'); + + // Overwrite package.json with no @vendure/* deps + testProject.writeFile( + 'package.json', + JSON.stringify({ + name: 'not-vendure', + version: '1.0.0', + dependencies: { express: '4.0.0' }, + }), + ); + + const result = await testProject.runCliCommand( + ['doctor', '--check', 'project', '--format', 'json'], + { expectError: true }, + ); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.overallStatus).toBe('failed'); + expect(report.checks[0].status).toBe('fail'); + }); + }); + + describe('--format json', () => { + it('should output valid JSON with all expected fields', async () => { + testProject = createTestProject('doctor-json-output'); + + const result = await testProject.runCliCommand([ + 'doctor', + '--check', + 'project', + '--format', + 'json', + ]); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout); + expect(report).toHaveProperty('nodeVersion'); + expect(report).toHaveProperty('checks'); + expect(report).toHaveProperty('overallStatus'); + expect(report.nodeVersion).toMatch(/^v\d+\.\d+\.\d+$/); + }); + }); + + describe('--strict mode', () => { + it('should treat warnings as failures with --strict', async () => { + testProject = createTestProject('doctor-strict'); + + // Create a project with multiple lockfiles to trigger a warning + testProject.writeFile('yarn.lock', ''); + testProject.writeFile('package-lock.json', '{}'); + + const result = await testProject.runCliCommand( + ['doctor', '--check', 'project', '--format', 'json', '--strict'], + { expectError: true }, + ); + + // The project check itself passes but the multiple lockfiles + // don't cause a warn status on the project check -- they're just + // informational details. So strict mode won't change the outcome here. + // This test verifies the --strict flag is accepted without error. + const report = JSON.parse(result.stdout); + expect(report).toHaveProperty('overallStatus'); + }); + }); + + describe('--help', () => { + it('should show doctor help with all options', async () => { + testProject = createTestProject('doctor-help'); + + const result = await testProject.runCliCommand(['doctor', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('--config'); + expect(result.stdout).toContain('--check'); + expect(result.stdout).toContain('--profile'); + expect(result.stdout).toContain('--format'); + expect(result.stdout).toContain('--strict'); + }); + }); + + describe('dependency check', () => { + it('should report when node_modules is missing', async () => { + testProject = createTestProject('doctor-no-modules'); + + // The default test project doesn't run npm install, + // so node_modules won't exist + const result = await testProject.runCliCommand( + ['doctor', '--check', 'dependencies', '--format', 'json'], + { expectError: true }, + ); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.checks[0].status).toBe('fail'); + expect(report.checks[0].message).toContain('node_modules not found'); + }); + }); + + describe('cascading skips', () => { + it('should skip config-dependent checks when project check fails', async () => { + testProject = createTestProject('doctor-cascade-skip'); + + // Overwrite package.json with no vendure deps + testProject.writeFile( + 'package.json', + JSON.stringify({ + name: 'not-vendure', + version: '1.0.0', + dependencies: { express: '4.0.0' }, + }), + ); + + const result = await testProject.runCliCommand( + ['doctor', '--format', 'json'], + { expectError: true }, + ); + + expect(result.exitCode).toBe(1); + const report = JSON.parse(result.stdout); + + // Project should fail + expect(report.checks[0].name).toBe('Project'); + expect(report.checks[0].status).toBe('fail'); + + // All other checks should be skipped + const skippedChecks = report.checks.filter((c: any) => c.status === 'skip'); + expect(skippedChecks.length).toBeGreaterThanOrEqual(4); + }); + }); +}); From bc4b3905d2869b773ebf6db162ff620e2eaf775e Mon Sep 17 00:00:00 2001 From: ryrahul Date: Fri, 29 May 2026 00:02:49 +0545 Subject: [PATCH 12/12] docs(cli): document .pnpm scanning behavior in dependency check --- packages/cli/src/commands/doctor/checks/dependency-check.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/doctor/checks/dependency-check.ts b/packages/cli/src/commands/doctor/checks/dependency-check.ts index a80c94c746..a30faabd7b 100644 --- a/packages/cli/src/commands/doctor/checks/dependency-check.ts +++ b/packages/cli/src/commands/doctor/checks/dependency-check.ts @@ -209,7 +209,11 @@ function findNestedPackageVersions(modulesDir: string, targetPkg: string): strin } for (const entry of entries) { - // Skip the target package itself and hidden directories + // Skip the target package itself and hidden directories (.pnpm, .cache, etc.). + // pnpm's content-addressable store (.pnpm) doesn't need direct scanning because + // pnpm symlinks packages into standard node_modules locations, and Node's fs + // operations follow symlinks transparently. Scanning .pnpm directly would produce + // false positives since every package appears there by design. if (entry === targetPkg || entry.startsWith('.')) continue; const entryPath = path.join(modulesDir, entry);