diff --git a/README.md b/README.md index 69f5d82..f3cbe71 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,17 @@ That's it! Patch Pulse scans your `package.json` and shows which dependencies ar # Check all dependencies npx patch-pulse -# Show help -npx patch-pulse --help +``` # Show version + npx patch-pulse --version # Skip specific packages -npx patch-pulse --skip "lodash,@types/*" -``` + +npx patch-pulse --skip "lodash,@types/\*" + +```` **Checks:** `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies` @@ -48,9 +50,11 @@ Patch Pulse supports configuration files for persistent settings. Create one of ```json { - "skip": ["lodash", "@types/*", "test-*"] + "skip": ["lodash", "@types/*", "test-*"], + "packageManager": "npm", + "noUpdatePrompt": false } -``` +```` ### Skip Patterns @@ -60,13 +64,26 @@ The `skip` array supports multiple pattern types: - **Glob patterns**: `"@types/*"`, `"test-*"`, `"*-dev"` - **Regex patterns**: `".*-dev"`, `"^@angular/.*"`, `"zone\\.js"` +### Package Manager + +The `packageManager` option allows you to override the package manager detection. + +- `npm` +- `pnpm` +- `yarn` +- `bun` + +### No Update Prompt + +The `noUpdatePrompt` option allows you to skip the update prompt. + ### CLI vs File Configuration CLI arguments override file configuration: ```bash # This will override any skip settings in patchpulse.config.json -npx patch-pulse --skip "react,react-dom" +npx patch-pulse --skip "react,react-dom" --package-manager pnpm --no-update-prompt ``` ## Example diff --git a/package-lock.json b/package-lock.json index 4d3b97d..f39a453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patch-pulse", - "version": "2.6.1", + "version": "2.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patch-pulse", - "version": "2.6.1", + "version": "2.8.0", "license": "MIT", "dependencies": { "chalk": "5.4.1" @@ -28,10 +28,6 @@ "tsx": "4.20.3", "typescript": "5.8.3", "vitest": "3.2.4" - }, - "optionalDependencies": { - "node": "20.x", - "node-semver": "latest" } }, "node_modules/@ampproject/remapping": { @@ -1547,33 +1543,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -1699,16 +1668,13 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2122,6 +2088,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2163,19 +2145,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2327,6 +2296,19 @@ "node": ">=8.6.0" } }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2482,16 +2464,16 @@ } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" } }, "node_modules/globals": { @@ -2921,38 +2903,6 @@ "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", @@ -3724,25 +3674,76 @@ "punycode": "^2.1.0" } }, - "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", + "fdir": "^6.4.6", "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3751,14 +3752,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3799,57 +3800,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -3923,6 +3873,48 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", @@ -3936,6 +3928,81 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/vite": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4026,6 +4093,22 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4061,19 +4144,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7cfad90..cfb35fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "patch-pulse", - "version": "2.7.0", + "version": "2.8.0", "description": "Check for outdated npm dependencies", "type": "module", "bin": { diff --git a/src/gen/version.gen.ts b/src/gen/version.gen.ts index a040d70..f4a8914 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.7.0'; +export const VERSION = '2.8.0'; diff --git a/src/index.ts b/src/index.ts index 1787d11..efb1b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,18 @@ import { checkDependencyVersions } from './core/dependency-checker'; import { getConfig } from './services/config'; import { checkForCliUpdate } from './services/npm'; import { readPackageJson } from './services/package'; +import { + detectPackageManager, + getPackageManagerInfo, + updateDependencies, +} from './services/package-manager'; import { type DependencyInfo } from './types'; import { displayHelp } from './ui/display/help'; import { displayLicense } from './ui/display/license'; import { displaySummary } from './ui/display/summary'; import { displayThankYouMessage } from './ui/display/thankYouMessage'; import { displayUnknownArguments } from './ui/display/unknownArguments'; +import { displayUpdatePrompt } from './ui/display/updatePrompt'; import { displayVersion } from './ui/display/version'; import { getUnknownArgs } from './utils/getUnknownArgs'; import { hasAnyFlag } from './utils/hasAnyFlag'; @@ -56,6 +62,63 @@ async function main(): Promise { if (allDependencies.length > 0) { displaySummary(allDependencies); + + // Check if we should show the update prompt + if (!config.noUpdatePrompt) { + // Detect package manager + const packageManager = config.packageManager + ? getPackageManagerInfo(config.packageManager) + : detectPackageManager(); + + // Show update prompt + const updateType = await displayUpdatePrompt( + allDependencies, + packageManager + ); + + if (updateType) { + const outdatedDeps = allDependencies.filter( + d => d.isOutdated && !d.isSkipped + ); + + let depsToUpdate: Array<{ + packageName: string; + latestVersion: string; + }> = []; + + if (updateType === 'patch') { + depsToUpdate = outdatedDeps + .filter(d => d.updateType === 'patch' && d.latestVersion) + .map(d => ({ + packageName: d.packageName, + latestVersion: d.latestVersion!, + })); + } else if (updateType === 'minor') { + depsToUpdate = outdatedDeps + .filter( + d => + (d.updateType === 'minor' || d.updateType === 'patch') && + d.latestVersion + ) + .map(d => ({ + packageName: d.packageName, + latestVersion: d.latestVersion!, + })); + } else if (updateType === 'all') { + depsToUpdate = outdatedDeps + .filter(d => d.latestVersion) + .map(d => ({ + packageName: d.packageName, + latestVersion: d.latestVersion!, + })); + } + + if (depsToUpdate.length > 0) { + await updateDependencies(depsToUpdate, packageManager); + } + } + } + displayThankYouMessage(); } else { console.log(chalk.yellow('āš ļø No dependencies found to check')); @@ -66,6 +129,9 @@ async function main(): Promise { } catch { // Silently fail for CLI updates, i.e. don't let CLI update errors stop the main flow } + + // Ensure the process exits properly + process.exit(0); } catch (error) { console.error(chalk.red(`Error: ${error}`)); process.exit(1); @@ -85,6 +151,9 @@ const validFlags = [ '--license', '-s', '--skip', + '--package-manager', + '--update-prompt', + '--no-update-prompt', ]; const unknownArgs = getUnknownArgs(args, validFlags); if (unknownArgs.length > 0) { diff --git a/src/services/__tests__/package-manager.test.ts b/src/services/__tests__/package-manager.test.ts new file mode 100644 index 0000000..2a75351 --- /dev/null +++ b/src/services/__tests__/package-manager.test.ts @@ -0,0 +1,163 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + detectPackageManager, + getPackageManagerInfo, +} from '../package-manager'; + +// Mock fs module +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock path module +vi.mock('path', () => ({ + join: vi.fn(), +})); + +describe('package-manager', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('detectPackageManager', () => { + it('should detect npm when package-lock.json exists', () => { + const mockExistsSync = vi.mocked(existsSync); + const mockJoin = vi.mocked(join); + + mockJoin.mockImplementation((dir: string, file: string) => { + return `${dir}/${file}`; + }); + mockExistsSync.mockImplementation((path: any) => { + return path === '/test/package-lock.json'; + }); + + const result = detectPackageManager('/test'); + + expect(result).toEqual({ + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }); + }); + + it('should detect pnpm when pnpm-lock.yaml exists', () => { + const mockExistsSync = vi.mocked(existsSync); + const mockJoin = vi.mocked(join); + + mockJoin.mockImplementation((dir: string, file: string) => { + return `${dir}/${file}`; + }); + mockExistsSync.mockImplementation((path: any) => { + return path === '/test/pnpm-lock.yaml'; + }); + + const result = detectPackageManager('/test'); + + expect(result).toEqual({ + name: 'pnpm', + lockFiles: ['pnpm-lock.yaml'], + addCommand: 'pnpm add', + }); + }); + + it('should detect yarn when yarn.lock exists', () => { + const mockExistsSync = vi.mocked(existsSync); + const mockJoin = vi.mocked(join); + + mockJoin.mockImplementation((dir: string, file: string) => { + return `${dir}/${file}`; + }); + mockExistsSync.mockImplementation((path: any) => { + return path === '/test/yarn.lock'; + }); + + const result = detectPackageManager('/test'); + + expect(result).toEqual({ + name: 'yarn', + lockFiles: ['yarn.lock'], + addCommand: 'yarn add', + }); + }); + + it('should detect bun when bun.lockb exists', () => { + const mockExistsSync = vi.mocked(existsSync); + const mockJoin = vi.mocked(join); + + mockJoin.mockImplementation((dir: string, file: string) => { + return `${dir}/${file}`; + }); + mockExistsSync.mockImplementation((path: any) => { + return path === '/test/bun.lockb'; + }); + + const result = detectPackageManager('/test'); + + expect(result).toEqual({ + name: 'bun', + lockFiles: ['bun.lock', 'bun.lockb'], + addCommand: 'bun add', + }); + }); + + it('should return npm as default when no lock file exists', () => { + const mockExistsSync = vi.mocked(existsSync); + const mockJoin = vi.mocked(join); + + mockJoin.mockReturnValue('/test/nonexistent'); + mockExistsSync.mockReturnValue(false); + + const result = detectPackageManager('/test'); + + expect(result).toEqual({ + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }); + }); + }); + + describe('getPackageManagerInfo', () => { + it('should return npm info', () => { + const result = getPackageManagerInfo('npm'); + + expect(result).toEqual({ + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }); + }); + + it('should return pnpm info', () => { + const result = getPackageManagerInfo('pnpm'); + + expect(result).toEqual({ + name: 'pnpm', + lockFiles: ['pnpm-lock.yaml'], + addCommand: 'pnpm add', + }); + }); + + it('should return yarn info', () => { + const result = getPackageManagerInfo('yarn'); + + expect(result).toEqual({ + name: 'yarn', + lockFiles: ['yarn.lock'], + addCommand: 'yarn add', + }); + }); + + it('should return bun info', () => { + const result = getPackageManagerInfo('bun'); + + expect(result).toEqual({ + name: 'bun', + lockFiles: ['bun.lock', 'bun.lockb'], + addCommand: 'bun add', + }); + }); + }); +}); diff --git a/src/services/config.ts b/src/services/config.ts index 39254d8..94b41ff 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -1,8 +1,11 @@ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { type PackageManager } from './package-manager'; export interface PatchPulseConfig { skip?: string[]; + packageManager?: PackageManager; + noUpdatePrompt?: boolean; } const CONFIG_FILENAMES = [ @@ -54,6 +57,7 @@ export function readConfigFile( export function parseCliConfig(args: string[]): PatchPulseConfig { const config: PatchPulseConfig = {}; + // Parse skip argument const skipIndex = args.indexOf('--skip'); const shortSkipIndex = args.indexOf('-s'); const skipArgIndex = skipIndex !== -1 ? skipIndex : shortSkipIndex; @@ -68,6 +72,26 @@ export function parseCliConfig(args: string[]): PatchPulseConfig { } } + // Parse package manager argument + const packageManagerIndex = args.indexOf('--package-manager'); + if (packageManagerIndex !== -1 && packageManagerIndex + 1 < args.length) { + const packageManagerValue = args[packageManagerIndex + 1]; + if (!packageManagerValue.startsWith('-')) { + if (['npm', 'pnpm', 'yarn', 'bun'].includes(packageManagerValue)) { + config.packageManager = packageManagerValue as any; + } + } + } + + // Parse no update prompt argument + if (args.includes('--no-update-prompt')) { + config.noUpdatePrompt = true; + } + // Parse update prompt argument (overrides noUpdatePrompt) + if (args.includes('--update-prompt')) { + config.noUpdatePrompt = false; + } + return config; } @@ -80,23 +104,37 @@ export function parseCliConfig(args: string[]): PatchPulseConfig { export function mergeConfigs( fileConfig: PatchPulseConfig | null, cliConfig: PatchPulseConfig -): Required { - const merged: Required = { +): PatchPulseConfig { + const merged: PatchPulseConfig = { skip: [], }; // Add file config values if (fileConfig?.skip) { - merged.skip.push(...fileConfig.skip); + merged.skip!.push(...fileConfig.skip); } // Add CLI config values (merge instead of override) if (cliConfig.skip) { - merged.skip.push(...cliConfig.skip); + merged.skip!.push(...cliConfig.skip); } // Remove duplicates while preserving order - merged.skip = [...new Set(merged.skip)]; + merged.skip = [...new Set(merged.skip!)]; + + // Handle packageManager (CLI takes precedence) + if (cliConfig.packageManager) { + merged.packageManager = cliConfig.packageManager; + } else if (fileConfig?.packageManager) { + merged.packageManager = fileConfig.packageManager; + } + + // Handle noUpdatePrompt (CLI takes precedence) + if (cliConfig.noUpdatePrompt !== undefined) { + merged.noUpdatePrompt = cliConfig.noUpdatePrompt; + } else if (fileConfig?.noUpdatePrompt !== undefined) { + merged.noUpdatePrompt = fileConfig.noUpdatePrompt; + } return merged; } @@ -117,6 +155,14 @@ function validateConfig(config: any): PatchPulseConfig { ); } + if (typeof config.packageManager === 'string') { + validated.packageManager = config.packageManager; + } + + if (typeof config.noUpdatePrompt === 'boolean') { + validated.noUpdatePrompt = config.noUpdatePrompt; + } + return validated; } diff --git a/src/services/package-manager.ts b/src/services/package-manager.ts new file mode 100644 index 0000000..cf5a645 --- /dev/null +++ b/src/services/package-manager.ts @@ -0,0 +1,157 @@ +import chalk from 'chalk'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export interface PackageManagerInfo { + name: PackageManager; + lockFiles: string[]; + addCommand: string; +} + +const PACKAGE_MANAGERS: Record = { + npm: { + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }, + pnpm: { + name: 'pnpm', + lockFiles: ['pnpm-lock.yaml'], + addCommand: 'pnpm add', + }, + bun: { + name: 'bun', + lockFiles: ['bun.lock', 'bun.lockb'], + addCommand: 'bun add', + }, + yarn: { + name: 'yarn', + lockFiles: ['yarn.lock'], + addCommand: 'yarn add', + }, +}; + +/** + * Detects the package manager being used in the current directory + * @param cwd - The current working directory + * @returns The detected package manager info or npm as default + */ +export function detectPackageManager( + cwd: string = process.cwd() +): PackageManagerInfo { + for (const [, info] of Object.entries(PACKAGE_MANAGERS)) { + // Check if any of the lock files exist + const hasLockFile = info.lockFiles.some(lockFile => { + const lockFilePath = join(cwd, lockFile); + return existsSync(lockFilePath); + }); + + if (hasLockFile) { + return info; + } + } + + // Default to npm if no lock file is found + return PACKAGE_MANAGERS.npm; +} + +/** + * Gets package manager info by name + * @param name - The package manager name + * @returns The package manager info + */ +export function getPackageManagerInfo( + name: PackageManager +): PackageManagerInfo { + return PACKAGE_MANAGERS[name]; +} + +/** + * Runs a package manager command + * @param command - The command to run + * @param args - The arguments for the command + * @returns Promise that resolves when the command completes + */ +export function runPackageManagerCommand( + command: string, + args: string[] +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + shell: true, + }); + + child.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Command failed with exit code ${code}`)); + } + }); + + child.on('error', error => { + reject(error); + }); + }); +} + +/** + * Updates dependencies based on update type + * @param dependencies - Array of dependencies to update + * @param updateType - The type of update to perform ('patch', 'minor', 'all') + * @param packageManager - The package manager to use + * @returns Promise that resolves when all updates are complete + */ +export async function updateDependencies( + dependencies: Array<{ packageName: string; latestVersion: string }>, + packageManager: PackageManagerInfo +): Promise { + if (dependencies.length === 0) { + console.log(chalk.yellow('No dependencies to update')); + return; + } + + console.log( + chalk.cyan( + `\nšŸ”„ Updating ${dependencies.length} dependencies using ${packageManager.name}...` + ) + ); + + try { + // Build the arguments array for the package manager + const args: string[] = []; + + // Add the appropriate subcommand based on package manager + if (packageManager.name === 'npm') { + args.push('install'); + args.push('--save-exact'); + } else if (packageManager.name === 'pnpm') { + args.push('add'); + } else if (packageManager.name === 'yarn') { + args.push('add'); + } else if (packageManager.name === 'bun') { + args.push('add'); + } + + // Add all the package@version strings + for (const dep of dependencies) { + args.push(`${dep.packageName}@${dep.latestVersion}`); + } + + // Run the command + await runPackageManagerCommand(packageManager.name, args); + + console.log( + chalk.green( + `\nāœ… Successfully updated ${dependencies.length} dependencies!` + ) + ); + } catch (error) { + console.error(chalk.red(`Failed to update dependencies: ${error}`)); + throw error; + } +} diff --git a/src/ui/display/__tests__/updatePrompt.test.ts b/src/ui/display/__tests__/updatePrompt.test.ts new file mode 100644 index 0000000..3fbb36a --- /dev/null +++ b/src/ui/display/__tests__/updatePrompt.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type PackageManagerInfo } from '../../../services/package-manager'; +import { type DependencyInfo } from '../../../types'; +import { displayUpdatePrompt } from '../updatePrompt'; + +// Mock process.stdin +const mockStdin = { + setRawMode: vi.fn(), + resume: vi.fn(), + setEncoding: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + isRaw: false, + isPaused: vi.fn(() => false), + pause: vi.fn(), +}; + +// Stub the global process.stdin +vi.stubGlobal('process', { + stdin: mockStdin, +}); + +describe('updatePrompt', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null when no outdated dependencies', async () => { + const dependencies: DependencyInfo[] = [ + { + packageName: 'test-package', + currentVersion: '1.0.0', + latestVersion: '1.0.0', + isOutdated: false, + }, + ]; + + const packageManager: PackageManagerInfo = { + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }; + + const result = await displayUpdatePrompt(dependencies, packageManager); + expect(result).toBeNull(); + }); + + it('should show patch update option when patch dependencies exist', async () => { + const dependencies: DependencyInfo[] = [ + { + packageName: 'test-package', + currentVersion: '1.0.0', + latestVersion: '1.0.1', + isOutdated: true, + updateType: 'patch', + }, + ]; + + const packageManager: PackageManagerInfo = { + name: 'npm', + lockFiles: ['package-lock.json'], + addCommand: 'npm install', + }; + + // Mock the stdin.on to capture the callback and simulate key press + let keyPressCallback: ((key: string) => void) | undefined; + mockStdin.on.mockImplementation((event, callback) => { + if (event === 'data') { + keyPressCallback = callback; + } + }); + + // Start the promise + const promise = displayUpdatePrompt(dependencies, packageManager); + + // Wait a bit for the function to set up, then simulate pressing 'p' key + await new Promise(resolve => setTimeout(resolve, 10)); + if (keyPressCallback) { + keyPressCallback('p'); + } + + const result = await promise; + expect(result).toBe('patch'); + expect(mockStdin.setRawMode).toHaveBeenCalledWith(true); + expect(mockStdin.resume).toHaveBeenCalled(); + expect(mockStdin.setEncoding).toHaveBeenCalledWith('utf8'); + }); +}); diff --git a/src/ui/display/help.ts b/src/ui/display/help.ts index 5e9551e..a1d0206 100644 --- a/src/ui/display/help.ts +++ b/src/ui/display/help.ts @@ -23,23 +23,33 @@ ${chalk.cyan.bold.underline('āš™ļø Options:')} ${chalk.cyan.bold.underline('šŸ”§ Configuration Options:')} ${chalk.white('-s, --skip ')} ${chalk.gray('Skip packages (supports exact names and patterns)')} + ${chalk.white('--package-manager ')} ${chalk.gray('Override detected package manager (npm, pnpm, yarn, bun)')} + ${chalk.white('--no-update-prompt')} ${chalk.gray('Skip update prompt after summary (exit immediately)')} + ${chalk.white('--update-prompt')} ${chalk.gray('Force update prompt after summary (even if config disables it)')} ${chalk.cyan.bold.underline('šŸ“ Configuration File:')} Create a \`patchpulse.config.json\` file in your project root: ${chalk.gray('{')} - ${chalk.gray('"skip": ["lodash", "@types/*", "test-*"]')} + ${chalk.gray('"skip": ["lodash", "@types/*", "test-*"],')} + ${chalk.gray('"packageManager": "npm",')} + ${chalk.gray('"noUpdatePrompt": true')} ${chalk.gray('}')} ${chalk.cyan.bold.underline('šŸ“ Description:')} Reads the \`package.json\` file in the current directory and displays information about your project's dependencies, including version - status and update availability. + status and update availability. After the summary, you can choose + to update patch, minor, or all outdated dependencies, unless + --no-update-prompt is set (in which case the CLI exits after summary). ${chalk.cyan.bold.underline('šŸ’” Examples:')} ${chalk.white('npx patch-pulse')} ${chalk.gray('# Check dependencies in current directory')} ${chalk.white('npx patch-pulse --version')} ${chalk.gray('# Show version information')} ${chalk.white('npx patch-pulse --license')} ${chalk.gray('# Show license information')} ${chalk.white('npx patch-pulse --skip "lodash,@types/*"')} ${chalk.gray('# Skip specific packages and patterns')} + ${chalk.white('npx patch-pulse --package-manager pnpm')} ${chalk.gray('# Use pnpm for updates (overrides automatic package manager detection)')} + ${chalk.white('npx patch-pulse --no-update-prompt')} ${chalk.gray('# Exit after summary (no update prompt)')} + ${chalk.white('npx patch-pulse --update-prompt')} ${chalk.gray('# Force update prompt after summary (overrides patchpulse.config.json file)')} ${chalk.cyan.bold.underline('šŸ”— Links:')} ${chalk.blue('šŸ“š Docs:')} ${chalk.white.underline('https://github.com/PatchPulse/cli')} diff --git a/src/ui/display/updatePrompt.ts b/src/ui/display/updatePrompt.ts new file mode 100644 index 0000000..f3fa10b --- /dev/null +++ b/src/ui/display/updatePrompt.ts @@ -0,0 +1,199 @@ +import chalk from 'chalk'; +import { type PackageManagerInfo } from '../../services/package-manager'; +import { type DependencyInfo } from '../../types'; +import { pluralize } from '../../utils/pluralize'; +import { displayHelp } from './help'; +import { displayVersion } from './version'; + +export interface UpdateOptions { + patch: Array<{ packageName: string; latestVersion: string }>; + minor: Array<{ packageName: string; latestVersion: string }>; + all: Array<{ packageName: string; latestVersion: string }>; +} + +/** + * Displays the interactive update prompt after the summary + * @param dependencies - Array of outdated dependencies + * @param packageManager - The detected package manager + * @returns Promise that resolves with the selected update type or null if cancelled + */ +export function displayUpdatePrompt( + dependencies: DependencyInfo[], + packageManager: PackageManagerInfo +): Promise<'patch' | 'minor' | 'all' | null> { + return new Promise(resolve => { + const outdatedDeps = dependencies.filter(d => d.isOutdated && !d.isSkipped); + + if (outdatedDeps.length === 0) { + resolve(null); + return; + } + + const updateOptions = categorizeUpdates(outdatedDeps); + + function showOptions() { + // Count how many different types of updates we have + const hasPatch = updateOptions.patch.length > 0; + const hasMinor = updateOptions.minor.length > 0; + const hasMajor = + updateOptions.all.length > + updateOptions.patch.length + updateOptions.minor.length; + const updateTypesCount = [hasPatch, hasMinor, hasMajor].filter( + Boolean + ).length; + + // Show individual options + if (updateOptions.patch.length > 0) { + console.log( + ` ${chalk.cyan('p')} - Update ${pluralize(updateOptions.patch.length, 'outdated patch dependency', 'outdated patch dependencies')}` + ); + } + if (updateOptions.minor.length > 0) { + console.log( + ` ${chalk.cyan('m')} - Update ${pluralize(updateOptions.minor.length, 'outdated minor dependency', 'outdated minor dependencies')}` + ); + } + + // Show "all" option if there are multiple types OR if there are only major updates + if ( + updateOptions.all.length > 0 && + (updateTypesCount > 1 || (!hasPatch && !hasMinor && hasMajor)) + ) { + const allText = + updateOptions.all.length === 1 + ? `Update ${updateOptions.all.length} ${pluralize(updateOptions.all.length, 'outdated dependency', 'outdated dependencies')}` + : `Update all ${updateOptions.all.length} ${pluralize(updateOptions.all.length, 'outdated dependency', 'outdated dependencies')}`; + + console.log(` ${chalk.cyan('a')} - ${allText}`); + } + + console.log(); + console.log( + ` ${chalk.gray('h')} - Show help | ${chalk.gray('v')} - Show version | ${chalk.gray('q')} - Quit` + ); + console.log(); + console.log(chalk.white('Press a key to select an option...')); + } + + showOptions(); + + // Set up raw mode for single key press detection + const stdin = process.stdin; + + // Save current terminal settings + const wasRaw = stdin.isRaw; + const wasPaused = stdin.isPaused(); + + // Set up raw mode + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + const handleKeyPress = (key: string) => { + const choice = key.toLowerCase(); + + switch (choice) { + case 'p': + if (updateOptions.patch.length > 0) { + cleanup(); + resolve('patch'); + } else { + console.log(chalk.red('\nNo patch updates available')); + } + break; + case 'm': + if (updateOptions.minor.length > 0) { + cleanup(); + resolve('minor'); + } else { + console.log(chalk.red('\nNo minor updates available')); + } + break; + case 'a': + if (updateOptions.all.length > 0) { + cleanup(); + resolve('all'); + } else { + console.log(chalk.red('\nNo updates available')); + } + break; + case 'q': + cleanup(); + resolve(null); + break; + case 'h': + cleanup(); + displayHelp(); + console.log(); + // Re-display the options and continue + showOptions(); + // Re-setup the key listener + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', handleKeyPress); + break; + case 'v': + cleanup(); + displayVersion(); + console.log(); + // Re-display the options and continue + showOptions(); + // Re-setup the key listener + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', handleKeyPress); + break; + case '\u0003': // Ctrl+C + cleanup(); + resolve(null); + break; + default: + // Ignore other keys + break; + } + }; + + const cleanup = () => { + stdin.setRawMode(wasRaw); + if (wasPaused) { + stdin.pause(); + } + stdin.removeListener('data', handleKeyPress); + }; + + stdin.on('data', handleKeyPress); + }); +} + +/** + * Categorizes dependencies by update type + * @param dependencies - Array of outdated dependencies + * @returns Object with categorized dependencies + */ +function categorizeUpdates(dependencies: DependencyInfo[]): UpdateOptions { + const patch: Array<{ packageName: string; latestVersion: string }> = []; + const minor: Array<{ packageName: string; latestVersion: string }> = []; + const all: Array<{ packageName: string; latestVersion: string }> = []; + + for (const dep of dependencies) { + if (!dep.latestVersion) continue; + + const updateEntry = { + packageName: dep.packageName, + latestVersion: dep.latestVersion, + }; + + all.push(updateEntry); + + if (dep.updateType === 'patch') { + patch.push(updateEntry); + } else if (dep.updateType === 'minor') { + minor.push(updateEntry); + } + // Major updates are not included in patch or minor categories + } + + return { patch, minor, all }; +} diff --git a/src/utils/pluralize.ts b/src/utils/pluralize.ts new file mode 100644 index 0000000..72c8fe0 --- /dev/null +++ b/src/utils/pluralize.ts @@ -0,0 +1,14 @@ +/** + * Pluralizes a word based on the count + * @param count - The count of the word + * @param singular - The singular form of the word + * @param plural - The plural form of the word + * @returns The pluralized word + */ +export function pluralize( + count: number, + singular: string, + plural: string +): string { + return count === 1 ? singular : plural; +}