diff --git a/.gitignore b/.gitignore index 61d6a54..2d2d16b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ coverage lib node_modules +patchpulse.config.json STREAMING_TODO.md \ No newline at end of file diff --git a/.patchpulse.config.json b/.patchpulse.config.json deleted file mode 100644 index 6cab01b..0000000 --- a/.patchpulse.config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "skip": [] -} diff --git a/.prettierignore b/.prettierignore index 9f9352f..24084cc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,3 @@ lib node_modules -.patchpulse.config.json.example \ No newline at end of file +patchpulse.config.json.example \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a913699..4d3b97d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patch-pulse", - "version": "2.6.0", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patch-pulse", - "version": "2.6.0", + "version": "2.6.1", "license": "MIT", "dependencies": { "chalk": "5.4.1" @@ -16,7 +16,7 @@ }, "devDependencies": { "@eslint/js": "9.29.0", - "@types/node": "24.0.3", + "@types/node": "24.0.4", "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "@vitest/coverage-v8": "3.2.4", @@ -28,6 +28,10 @@ "tsx": "4.20.3", "typescript": "5.8.3", "vitest": "3.2.4" + }, + "optionalDependencies": { + "node": "20.x", + "node-semver": "latest" } }, "node_modules/@ampproject/remapping": { @@ -1249,9 +1253,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", - "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", + "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", "dev": true, "license": "MIT", "dependencies": { @@ -2917,6 +2921,38 @@ "dev": true, "license": "MIT" }, + "node_modules/node": { + "version": "20.19.3", + "resolved": "https://registry.npmjs.org/node/-/node-20.19.3.tgz", + "integrity": "sha512-d+u0OgI4bt3iqxbb6RtNR6Tg1UWdZmT+mrWV4mu+3x8Q4eMEf4XpFGl5rJxrSu4r6tQ4pYDVnuwLx1hkqBsWsw==", + "hasInstallScript": true, + "license": "ISC", + "optional": true, + "dependencies": { + "node-bin-setup": "^1.0.0" + }, + "bin": { + "node": "bin/node" + }, + "engines": { + "npm": ">=5.0.0" + } + }, + "node_modules/node-bin-setup": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/node-bin-setup/-/node-bin-setup-1.1.4.tgz", + "integrity": "sha512-vWNHOne0ZUavArqPP5LJta50+S8R261Fr5SvGul37HbEDcowvLjwdvd0ZeSr0r2lTSrPxl6okq9QUw8BFGiAxA==", + "license": "ISC", + "optional": true + }, + "node_modules/node-semver": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-semver/-/node-semver-1.0.0.tgz", + "integrity": "sha512-RB7VKO1hfCLiCxzXD2IynGcqFGaxLLbKibDmeZwQnnHX1JYA1PozO125hm+++AWDs03PKa7fV8YkMUtg2rbquw==", + "deprecated": "please use 'semver'", + "license": "ISC", + "optional": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 4de84c8..7cfad90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "patch-pulse", - "version": "2.6.1", + "version": "2.7.0", "description": "Check for outdated npm dependencies", "type": "module", "bin": { @@ -58,7 +58,7 @@ }, "devDependencies": { "@eslint/js": "9.29.0", - "@types/node": "24.0.3", + "@types/node": "24.0.4", "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "@vitest/coverage-v8": "3.2.4", diff --git a/.patchpulse.config.json.example b/patchpulse.config.json.example similarity index 100% rename from .patchpulse.config.json.example rename to patchpulse.config.json.example diff --git a/src/core/dependency-checker.ts b/src/core/dependency-checker.ts index 85f91c8..5a12eeb 100644 --- a/src/core/dependency-checker.ts +++ b/src/core/dependency-checker.ts @@ -29,25 +29,29 @@ export async function checkDependencyVersions( for (let i = 0; i < packageNames.length; i += concurrencyLimit) { const batch = packageNames.slice(i, i + concurrencyLimit); - const batchPromises = batch.map(async name => { - const version = dependencies[name]; + const batchPromises = batch.map(async packageName => { + const version = dependencies[packageName]; - // Check if package should be skipped - const isSkipped = config ? shouldSkipPackage(name, config) : false; + const isSkipped = shouldSkipPackage({ packageName, config }); let latestVersion: string | undefined; let isOutdated = false; let updateType: 'patch' | 'minor' | 'major' | undefined; if (!isSkipped) { - latestVersion = await getLatestVersion(name); - isOutdated = latestVersion - ? isVersionOutdated(version, latestVersion) - : false; - updateType = - latestVersion && isOutdated + latestVersion = await getLatestVersion(packageName); + + // Don't try to compare versions if current version is not a standard semver + const isStandardSemver = /^\d+\.\d+\.\d+/.test(version); + if (!isStandardSemver) { + isOutdated = false; + updateType = undefined; + } else if (latestVersion) { + isOutdated = isVersionOutdated(version, latestVersion); + updateType = isOutdated ? getUpdateType(version, latestVersion) : undefined; + } } // Update progress for each completed package @@ -57,7 +61,7 @@ export async function checkDependencyVersions( ); return { - name, + packageName, currentVersion: version, latestVersion, isOutdated, @@ -88,6 +92,12 @@ function displayResults(dependencyInfos: DependencyInfo[]): void { } else if (!dep.latestVersion) { status = chalk.red('NOT FOUND'); versionInfo = `${dep.currentVersion} (not found on npm registry)`; + } else if (dep.currentVersion === 'latest') { + status = chalk.cyan('LATEST TAG'); + versionInfo = `latest → ${chalk.cyan(dep.latestVersion)} (actual latest version)`; + } else if (!/^\d+\.\d+\.\d+/.test(dep.currentVersion)) { + status = chalk.blue('VERSION RANGE'); + versionInfo = `${dep.currentVersion} → ${chalk.cyan(dep.latestVersion)} (latest available)`; } else if (dep.isOutdated) { const updateTypeColor = { major: chalk.yellow, @@ -102,7 +112,7 @@ function displayResults(dependencyInfos: DependencyInfo[]): void { } console.log( - `${status} ${chalk.white(dep.name)} ${chalk.gray(versionInfo)}` + `${status} ${chalk.white(dep.packageName)} ${chalk.gray(versionInfo)}` ); } } diff --git a/src/gen/version.gen.ts b/src/gen/version.gen.ts index affe0cc..a040d70 100644 --- a/src/gen/version.gen.ts +++ b/src/gen/version.gen.ts @@ -1,2 +1,2 @@ // Auto-generated file - do not edit manually -export const VERSION = '2.6.1'; +export const VERSION = '2.7.0'; diff --git a/src/index.ts b/src/index.ts index a38abe0..1787d11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,7 @@ import chalk from 'chalk'; import { join } from 'path'; import { checkDependencyVersions } from './core/dependency-checker'; -import { - mergeConfigs, - parseCliConfig, - readConfigFile, -} from './services/config'; +import { getConfig } from './services/config'; import { checkForCliUpdate } from './services/npm'; import { readPackageJson } from './services/package'; import { type DependencyInfo } from './types'; @@ -32,10 +28,7 @@ async function main(): Promise { const packageJson = await readPackageJson(packageJsonPath); const allDependencies: DependencyInfo[] = []; - // Read configuration - const fileConfig = readConfigFile(); - const cliConfig = parseCliConfig(process.argv.slice(2)); - const config = mergeConfigs(fileConfig, cliConfig); + const config = getConfig(); const dependencyTypeLabels: Record = { dependencies: 'Dependencies', diff --git a/src/services/__tests__/config.test.ts b/src/services/__tests__/config.test.ts index 0da2575..050eaa9 100644 --- a/src/services/__tests__/config.test.ts +++ b/src/services/__tests__/config.test.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + getConfig, mergeConfigs, parseCliConfig, readConfigFile, @@ -23,10 +24,17 @@ describe('Configuration Service', () => { vi.clearAllMocks(); }); + describe('getConfig', () => { + it('should return the config', () => { + const config = getConfig(); + expect(config).toEqual({ skip: [] }); + }); + }); + describe('readConfigFile', () => { it('should return null when no config file exists', () => { vi.mocked(existsSync).mockReturnValue(false); - vi.mocked(join).mockReturnValue('/test/.patchpulse.config.json'); + vi.mocked(join).mockReturnValue('/test/patchpulse.config.json'); const result = readConfigFile('/test'); @@ -114,7 +122,7 @@ describe('Configuration Service', () => { const result = mergeConfigs(fileConfig, cliConfig); expect(result).toEqual({ - skip: ['express', 'test-*'], + skip: ['lodash', '@types/*', 'express', 'test-*'], }); }); @@ -149,9 +157,9 @@ describe('Configuration Service', () => { skip: ['lodash', 'express'], }; - expect(shouldSkipPackage('lodash', config)).toBe(true); - expect(shouldSkipPackage('express', config)).toBe(true); - expect(shouldSkipPackage('chalk', config)).toBe(false); + expect(shouldSkipPackage({ packageName: 'lodash', config })).toBe(true); + expect(shouldSkipPackage({ packageName: 'express', config })).toBe(true); + expect(shouldSkipPackage({ packageName: 'chalk', config })).toBe(false); }); it('should skip packages matching patterns', () => { @@ -159,12 +167,25 @@ describe('Configuration Service', () => { skip: ['@types/*', 'test-*'], }; - expect(shouldSkipPackage('@types/node', config)).toBe(true); - expect(shouldSkipPackage('test-utils', config)).toBe(true); - expect(shouldSkipPackage('@typescript-eslint/parser', config)).toBe( - false - ); - expect(shouldSkipPackage('chalk', config)).toBe(false); + expect( + shouldSkipPackage({ + packageName: '@types/node', + config, + }) + ).toBe(true); + expect( + shouldSkipPackage({ + packageName: 'test-utils', + config, + }) + ).toBe(true); + expect( + shouldSkipPackage({ + packageName: '@typescript-eslint/parser', + config, + }) + ).toBe(false); + expect(shouldSkipPackage({ packageName: 'chalk', config })).toBe(false); }); it('should handle invalid regex patterns gracefully', () => { @@ -172,7 +193,12 @@ describe('Configuration Service', () => { skip: ['[invalid-regex'], }; - expect(shouldSkipPackage('test-package', config)).toBe(false); + expect( + shouldSkipPackage({ + packageName: 'test-package', + config, + }) + ).toBe(false); }); it('should check both exact matches and patterns', () => { @@ -180,9 +206,14 @@ describe('Configuration Service', () => { skip: ['lodash', '@types/*'], }; - expect(shouldSkipPackage('lodash', config)).toBe(true); - expect(shouldSkipPackage('@types/node', config)).toBe(true); - expect(shouldSkipPackage('chalk', config)).toBe(false); + expect(shouldSkipPackage({ packageName: 'lodash', config })).toBe(true); + expect( + shouldSkipPackage({ + packageName: '@types/node', + config, + }) + ).toBe(true); + expect(shouldSkipPackage({ packageName: 'chalk', config })).toBe(false); }); it('should treat patterns without regex chars as exact matches', () => { @@ -190,9 +221,25 @@ describe('Configuration Service', () => { skip: ['lodash', 'test-package'], }; - expect(shouldSkipPackage('lodash', config)).toBe(true); - expect(shouldSkipPackage('test-package', config)).toBe(true); - expect(shouldSkipPackage('test-package-extra', config)).toBe(false); + expect(shouldSkipPackage({ packageName: 'lodash', config })).toBe(true); + expect( + shouldSkipPackage({ + packageName: 'test-package', + config, + }) + ).toBe(true); + expect( + shouldSkipPackage({ + packageName: 'test-package-extra', + config, + }) + ).toBe(false); }); }); + + it('should handle no defined config skip parameter', () => { + expect(shouldSkipPackage({ packageName: 'lodash', config: {} })).toBe( + false + ); + }); }); diff --git a/src/services/config.ts b/src/services/config.ts index dd1dab7..39254d8 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -12,7 +12,18 @@ const CONFIG_FILENAMES = [ ]; /** - * Reads configuration from .patchpulse.config.json file + * Get the config from the config file and merged with the CLI config + * @param argv - The command line arguments + * @returns The merged configuration + */ +export function getConfig() { + const fileConfig = readConfigFile(); + const cliConfig = parseCliConfig(process.argv.slice(2)); + return mergeConfigs(fileConfig, cliConfig); +} + +/** + * Reads configuration from patchpulse.config.json file * @param cwd - The current working directory * @returns The configuration from the file */ @@ -61,7 +72,7 @@ export function parseCliConfig(args: string[]): PatchPulseConfig { } /** - * Merges file config and CLI config, with CLI taking precedence + * Merges file config and CLI config, combining skip arrays from both sources * @param fileConfig - The configuration from the file * @param cliConfig - The configuration from the CLI * @returns The merged configuration @@ -79,11 +90,14 @@ export function mergeConfigs( merged.skip.push(...fileConfig.skip); } - // CLI config overrides file config + // Add CLI config values (merge instead of override) if (cliConfig.skip) { - merged.skip = cliConfig.skip; + merged.skip.push(...cliConfig.skip); } + // Remove duplicates while preserving order + merged.skip = [...new Set(merged.skip)]; + return merged; } @@ -110,36 +124,42 @@ function validateConfig(config: any): PatchPulseConfig { * Checks if a package should be skipped based on configuration * @param packageName - The name of the package to check * @param config - The configuration to use + * @param version - The version of the package to check * @returns True if the package should be skipped, false otherwise */ -export function shouldSkipPackage( - packageName: string, - config: PatchPulseConfig -): boolean { - return ( - config.skip?.some(pattern => { - // If the pattern contains regex special characters (other than * and ?), treat as regex - if (/[. +?^${}()|[\]]/.test(pattern.replace(['*', '?'].join('|'), ''))) { - try { - const regex = new RegExp(pattern); - return regex.test(packageName); - } catch { - return packageName.includes(pattern); - } - } else if (pattern.includes('*') || pattern.includes('?')) { - // Convert glob to regex - const regexPattern = - '^' + - pattern - .replace(/([.+^${}()|[\\]])/g, '\\$1') // Escape regex special chars - .replace(/\*/g, '.*') // * => .* - .replace(/\?/g, '.') + // ? => . - '$'; - const regex = new RegExp(regexPattern); +export function shouldSkipPackage({ + packageName, + config = {}, +}: { + packageName: string; + config: PatchPulseConfig | undefined; +}): boolean { + if (!config.skip) { + return false; + } + + return config.skip.some(pattern => { + // If the pattern contains regex special characters (other than * and ?), treat as regex + if (/[. +?^${}()|[\]]/.test(pattern.replace(['*', '?'].join('|'), ''))) { + try { + const regex = new RegExp(pattern); return regex.test(packageName); - } else { - return packageName === pattern; + } catch { + return packageName.includes(pattern); } - }) ?? false - ); + } else if (pattern.includes('*') || pattern.includes('?')) { + // Convert glob to regex + const regexPattern = + '^' + + pattern + .replace(/([.+^${}()|[\\]])/g, '\\$1') // Escape regex special chars + .replace(/\*/g, '.*') // * => .* + .replace(/\?/g, '.') + // ? => . + '$'; + const regex = new RegExp(regexPattern); + return regex.test(packageName); + } else { + return packageName === pattern; + } + }); } diff --git a/src/types.ts b/src/types.ts index 693c4f9..f95a9a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ export interface PackageJson { } export interface DependencyInfo { - name: string; + packageName: string; currentVersion: string; latestVersion?: string; isOutdated: boolean; diff --git a/src/ui/display/__tests__/summary.test.ts b/src/ui/display/__tests__/summary.test.ts index 39b02e2..9133081 100644 --- a/src/ui/display/__tests__/summary.test.ts +++ b/src/ui/display/__tests__/summary.test.ts @@ -16,14 +16,14 @@ describe('displaySummary', () => { it('should display summary with all dependency types', () => { const dependencies: DependencyInfo[] = [ { - name: 'chalk', + packageName: 'chalk', currentVersion: '5.0.0', latestVersion: '5.0.0', isOutdated: false, isSkipped: false, }, { - name: 'lodash', + packageName: 'lodash', currentVersion: '4.17.0', latestVersion: '4.17.21', isOutdated: true, @@ -31,7 +31,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'express', + packageName: 'express', currentVersion: '4.17.0', latestVersion: '4.18.0', isOutdated: true, @@ -39,7 +39,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'react', + packageName: 'react', currentVersion: '17.0.0', latestVersion: '18.0.0', isOutdated: true, @@ -47,14 +47,14 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'unknown-pkg', + packageName: 'unknown-pkg', currentVersion: '1.0.0', latestVersion: undefined, isOutdated: false, isSkipped: false, }, { - name: 'skipped-pkg', + packageName: 'skipped-pkg', currentVersion: '1.0.0', latestVersion: '2.0.0', isOutdated: true, @@ -84,14 +84,14 @@ describe('displaySummary', () => { it('should display summary with only up-to-date packages', () => { const dependencies: DependencyInfo[] = [ { - name: 'chalk', + packageName: 'chalk', currentVersion: '5.0.0', latestVersion: '5.0.0', isOutdated: false, isSkipped: false, }, { - name: 'lodash', + packageName: 'lodash', currentVersion: '4.17.21', latestVersion: '4.17.21', isOutdated: false, @@ -121,7 +121,7 @@ describe('displaySummary', () => { it('should display summary with only outdated packages', () => { const dependencies: DependencyInfo[] = [ { - name: 'lodash', + packageName: 'lodash', currentVersion: '4.17.0', latestVersion: '4.17.21', isOutdated: true, @@ -129,7 +129,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'express', + packageName: 'express', currentVersion: '4.17.0', latestVersion: '4.18.0', isOutdated: true, @@ -137,7 +137,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'react', + packageName: 'react', currentVersion: '17.0.0', latestVersion: '18.0.0', isOutdated: true, @@ -166,14 +166,14 @@ describe('displaySummary', () => { it('should display summary with only unknown packages', () => { const dependencies: DependencyInfo[] = [ { - name: 'unknown-pkg1', + packageName: 'unknown-pkg1', currentVersion: '1.0.0', latestVersion: undefined, isOutdated: false, isSkipped: false, }, { - name: 'unknown-pkg2', + packageName: 'unknown-pkg2', currentVersion: '2.0.0', latestVersion: undefined, isOutdated: false, @@ -201,14 +201,14 @@ describe('displaySummary', () => { it('should display summary with only skipped packages', () => { const dependencies: DependencyInfo[] = [ { - name: 'skipped-pkg1', + packageName: 'skipped-pkg1', currentVersion: '1.0.0', latestVersion: '2.0.0', isOutdated: true, isSkipped: true, }, { - name: 'skipped-pkg2', + packageName: 'skipped-pkg2', currentVersion: '1.0.0', latestVersion: '1.0.0', isOutdated: false, @@ -260,7 +260,7 @@ describe('displaySummary', () => { it('should handle outdated packages without update type breakdown', () => { const dependencies: DependencyInfo[] = [ { - name: 'lodash', + packageName: 'lodash', currentVersion: '4.17.0', latestVersion: '4.17.21', isOutdated: true, @@ -279,7 +279,7 @@ describe('displaySummary', () => { it('should handle mixed update types correctly', () => { const dependencies: DependencyInfo[] = [ { - name: 'major1', + packageName: 'major1', currentVersion: '1.0.0', latestVersion: '2.0.0', isOutdated: true, @@ -287,7 +287,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'major2', + packageName: 'major2', currentVersion: '1.0.0', latestVersion: '3.0.0', isOutdated: true, @@ -295,7 +295,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'minor1', + packageName: 'minor1', currentVersion: '1.0.0', latestVersion: '1.1.0', isOutdated: true, @@ -303,7 +303,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'patch1', + packageName: 'patch1', currentVersion: '1.0.0', latestVersion: '1.0.1', isOutdated: true, @@ -311,7 +311,7 @@ describe('displaySummary', () => { isSkipped: false, }, { - name: 'patch2', + packageName: 'patch2', currentVersion: '1.0.0', latestVersion: '1.0.2', isOutdated: true, @@ -330,28 +330,28 @@ describe('displaySummary', () => { it('should exclude skipped packages from up-to-date and unknown counts', () => { const dependencies: DependencyInfo[] = [ { - name: 'up-to-date', + packageName: 'up-to-date', currentVersion: '1.0.0', latestVersion: '1.0.0', isOutdated: false, isSkipped: false, }, { - name: 'up-to-date-skipped', + packageName: 'up-to-date-skipped', currentVersion: '1.0.0', latestVersion: '1.0.0', isOutdated: false, isSkipped: true, }, { - name: 'unknown', + packageName: 'unknown', currentVersion: '1.0.0', latestVersion: undefined, isOutdated: false, isSkipped: false, }, { - name: 'unknown-skipped', + packageName: 'unknown-skipped', currentVersion: '1.0.0', latestVersion: undefined, isOutdated: false, @@ -375,14 +375,14 @@ describe('displaySummary', () => { it('should include skipped packages in total count', () => { const dependencies: DependencyInfo[] = [ { - name: 'pkg1', + packageName: 'pkg1', currentVersion: '1.0.0', latestVersion: '1.0.0', isOutdated: false, isSkipped: false, }, { - name: 'pkg2', + packageName: 'pkg2', currentVersion: '1.0.0', latestVersion: '2.0.0', isOutdated: true, diff --git a/src/ui/display/help.ts b/src/ui/display/help.ts index 1a64608..5e9551e 100644 --- a/src/ui/display/help.ts +++ b/src/ui/display/help.ts @@ -25,7 +25,7 @@ ${chalk.cyan.bold.underline('🔧 Configuration Options:')} ${chalk.white('-s, --skip ')} ${chalk.gray('Skip packages (supports exact names and patterns)')} ${chalk.cyan.bold.underline('📁 Configuration File:')} - Create a \`.patchpulse.config.json\` file in your project root: + Create a \`patchpulse.config.json\` file in your project root: ${chalk.gray('{')} ${chalk.gray('"skip": ["lodash", "@types/*", "test-*"]')} ${chalk.gray('}')}