Skip to content
This repository was archived by the owner on Feb 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"pnpm-upgrade-interactive": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"build": "rm -rf dist && tsc",
"dev": "tsc --watch",
"start": "node dist/cli.js",
"prepare": "pnpm build",
Expand Down Expand Up @@ -60,8 +60,8 @@
"inquirer": "^13.2.1",
"keypress": "^0.2.1",
"nanospinner": "^1.2.2",
"p-limit": "^7.2.0",
"semver": "^7.7.3"
"semver": "^7.7.3",
"undici": "^7.19.1"
},
"engines": {
"node": ">=20.0.0"
Expand Down
26 changes: 9 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import chalk from 'chalk'
import { readFileSync } from 'fs'
import { join } from 'path'
import { PnpmUpgradeInteractive } from './index'
import { checkForUpdateAsync } from './version-check'
import { checkForUpdateAsync } from './services'

const packageJson = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'))

Expand Down
9 changes: 9 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Constants for npm registry queries and configuration
*/

export const NPM_REGISTRY_URL = 'https://registry.npmjs.org'
export const JSDELIVR_CDN_URL = 'https://cdn.jsdelivr.net/npm'
export const MAX_CONCURRENT_REQUESTS = 80
export const CACHE_TTL = 5 * 60 * 1000 // 5 minutes in milliseconds
export const REQUEST_TIMEOUT = 30000 // 30 seconds in milliseconds
7 changes: 7 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Core business logic
*/

export * from './upgrade-runner'
export * from './package-detector'
export * from './upgrader'
58 changes: 42 additions & 16 deletions src/package-detector.ts → src/core/package-detector.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as semver from 'semver'
import { PackageInfo, PackageJson, PnpmUpgradeOptions } from './types'
import { PackageInfo, PackageJson, PnpmUpgradeOptions } from '../types'
import {
findPackageJson,
readPackageJson,
findAllPackageJsonFiles,
collectAllDependencies,
getAllPackageData,
collectAllDependenciesAsync,
findClosestMinorVersion,
} from './utils'
} from '../utils'
import { getAllPackageDataFromJsdelivr } from '../services'

export class PackageDetector {
private packageJsonPath: string | null = null
Expand Down Expand Up @@ -43,11 +43,13 @@ export class PackageDetector {
// Always check all package.json files recursively with timeout protection
this.showProgress('🔍 Scanning repository for package.json files...')
const allPackageJsonFiles = this.findPackageJsonFilesWithTimeout(30000) // 30 second timeout
this.showProgress(`🔍 Found ${allPackageJsonFiles.length} package.json file${allPackageJsonFiles.length === 1 ? '' : 's'}`)
this.showProgress(
`🔍 Found ${allPackageJsonFiles.length} package.json file${allPackageJsonFiles.length === 1 ? '' : 's'}`
)

// Step 2: Collect all dependencies from package.json files
// Step 2: Collect all dependencies from package.json files (parallelized)
this.showProgress('🔍 Reading dependencies from package.json files...')
const allDepsRaw = collectAllDependencies(allPackageJsonFiles, {
const allDepsRaw = await collectAllDependenciesAsync(allPackageJsonFiles, {
includePeerDeps: this.includePeerDeps,
includeOptionalDeps: this.includeOptionalDeps,
})
Expand All @@ -64,12 +66,26 @@ export class PackageDetector {
}
const packageNames = Array.from(uniquePackageNames)

// Step 4: Fetch all package data in one call per package
const allPackageData = await getAllPackageData(packageNames, (currentPackage: string, completed: number, total: number) => {
const percentage = Math.round((completed / total) * 100)
const truncatedPackage = currentPackage.length > 40 ? currentPackage.substring(0, 37) + '...' : currentPackage
this.showProgress(`🌐 Fetching ${percentage}% (${truncatedPackage})`)
})
// Step 4: Fetch all package data in one call per package using jsdelivr CDN
// Create a map of package names to their current versions for major version optimization
const currentVersions = new Map<string, string>()
for (const dep of allDeps) {
// Use the first occurrence of each package's version
if (!currentVersions.has(dep.name)) {
currentVersions.set(dep.name, dep.version)
}
}

const allPackageData = await getAllPackageDataFromJsdelivr(
packageNames,
currentVersions,
(currentPackage: string, completed: number, total: number) => {
const percentage = Math.round((completed / total) * 100)
const truncatedPackage =
currentPackage.length > 40 ? currentPackage.substring(0, 37) + '...' : currentPackage
this.showProgress(`🌐 Fetching ${percentage}% (${truncatedPackage})`)
}
)

try {
for (const dep of allDeps) {
Expand Down Expand Up @@ -98,7 +114,11 @@ export class PackageDetector {
currentVersion: dep.version, // Keep original version specifier with prefix
rangeVersion: closestMinorVersion || dep.version,
latestVersion,
type: dep.type as 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies',
type: dep.type as
| 'dependencies'
| 'devDependencies'
| 'optionalDependencies'
| 'peerDependencies',
packageJsonPath: dep.packageJsonPath,
isOutdated,
hasRangeUpdate,
Expand All @@ -111,7 +131,11 @@ export class PackageDetector {
currentVersion: dep.version,
rangeVersion: 'unknown',
latestVersion: 'unknown',
type: dep.type as 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies',
type: dep.type as
| 'dependencies'
| 'devDependencies'
| 'optionalDependencies'
| 'peerDependencies',
packageJsonPath: dep.packageJsonPath,
isOutdated: false,
hasRangeUpdate: false,
Expand Down Expand Up @@ -142,7 +166,9 @@ export class PackageDetector {
}
)
} catch (err) {
throw new Error(`Failed to scan for package.json files: ${err}. Try using --exclude patterns to skip problematic directories.`)
throw new Error(
`Failed to scan for package.json files: ${err}. Try using --exclude patterns to skip problematic directories.`
)
}
}

Expand Down
145 changes: 145 additions & 0 deletions src/core/upgrade-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import chalk from 'chalk'
import { PackageDetector } from './package-detector'
import { InteractiveUI } from '../interactive-ui'
import { PackageUpgrader } from './upgrader'
import { PnpmUpgradeOptions } from '../types'

/**
* Main orchestrator for the pnpm upgrade interactive process
*/
export class PnpmUpgradeInteractive {
private detector: PackageDetector
private ui: InteractiveUI
private upgrader: PackageUpgrader
private options?: PnpmUpgradeOptions

constructor(options?: PnpmUpgradeOptions) {
this.detector = new PackageDetector(options)
this.ui = new InteractiveUI()
this.upgrader = new PackageUpgrader()
this.options = options
}

public async run(): Promise<void> {
try {
// Check prerequisites
this.checkPrerequisites()

// Detect packages
const packages = await this.detector.getOutdatedPackages()

// Display packages table
await this.ui.displayPackagesTable(packages)

const outdatedPackages = this.detector.getOutdatedPackagesOnly(packages)
if (outdatedPackages.length === 0) {
return
}

// Interactive selection and confirmation loop
let selectedChoices: any[] = []
let shouldProceed: boolean | null = false
let previousSelections: Map<string, 'none' | 'range' | 'latest'> | undefined

while (true) {
// Interactive selection (pass options for filtering)
selectedChoices = await this.ui.selectPackagesToUpgrade(packages, previousSelections, {
includePeerDeps: this.options?.includePeerDeps,
includeOptionalDeps: this.options?.includeOptionalDeps,
})

if (selectedChoices.length === 0) {
console.log(chalk.yellow('No packages selected. Exiting...'))
return
}

// Validate selected choices before confirmation
this.validateSelectedChoices(selectedChoices, packages)

// Store current selections for potential return to selection
previousSelections = new Map()
// Convert selectedChoices back to selection state format
// Group by package name and version specifier
const choiceMap = new Map<string, 'range' | 'latest'>()
selectedChoices.forEach((choice) => {
const key = `${choice.name}@${choice.currentVersionSpecifier}`
choiceMap.set(key, choice.upgradeType as 'range' | 'latest')
})
// Convert to the format expected by selectPackagesToUpgrade
choiceMap.forEach((upgradeType, key) => {
previousSelections!.set(key, upgradeType)
})

// Confirm upgrade
shouldProceed = await this.ui.confirmUpgrade(selectedChoices)

if (shouldProceed === null) {
// User pressed N or ESC - go back to selection with current selections preserved
console.clear()
console.log(chalk.bold.blue('🚀 pnpm-upgrade-interactive\n'))
// previousSelections is already set from above
continue
}

if (!shouldProceed) {
console.log(chalk.yellow('Upgrade cancelled.'))
return
}

// User confirmed - break out of loop and proceed
break
}

// Perform upgrade
await this.upgrader.upgradePackages(selectedChoices, packages)
} catch (error) {
console.error(chalk.red(`Error: ${error}`))
process.exit(1)
}
}

private checkPrerequisites(): void {
// Check if package.json exists
if (!this.detector.hasPackageJson()) {
throw new Error('No package.json found in current directory')
}
}

private validateSelectedChoices(selectedChoices: any[], allPackages: any[]): void {
// Validate that all selected packages have valid target versions
const invalidChoices = selectedChoices.filter((choice) => {
const packageInfo = allPackages.find(
(pkg) => pkg.name === choice.name && pkg.packageJsonPath === choice.packageJsonPath
)
return !packageInfo || !choice.targetVersion
})

if (invalidChoices.length > 0) {
throw new Error(
`Invalid selections detected: ${invalidChoices.map((c) => c.name).join(', ')}. Please review your selections.`
)
}

// Print summary of what will be upgraded
const packageJsonPaths = new Set(selectedChoices.map((c) => c.packageJsonPath))
const uniquePackages = new Set(selectedChoices.map((c) => c.name))

console.log('\n' + chalk.bold('📋 Upgrade Summary'))
console.log(chalk.gray('─'.repeat(50)))
console.log(`${chalk.cyan(uniquePackages.size.toString())} package(s) will be upgraded`)
console.log(
`${chalk.cyan(packageJsonPaths.size.toString())} package.json file(s) will be modified`
)

const rangeUpgrades = selectedChoices.filter((c) => c.upgradeType === 'range').length
const majorUpgrades = selectedChoices.filter((c) => c.upgradeType === 'latest').length

if (rangeUpgrades > 0) {
console.log(` ${chalk.yellow('●')} ${rangeUpgrades} minor/patch upgrade(s)`)
}
if (majorUpgrades > 0) {
console.log(` ${chalk.red('●')} ${majorUpgrades} major upgrade(s)`)
}
console.log(chalk.gray('─'.repeat(50)))
}
}
4 changes: 2 additions & 2 deletions src/upgrader.ts → src/core/upgrader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import chalk from 'chalk'
import { createSpinner } from 'nanospinner'
import { existsSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import { PackageInfo, PackageUpgradeChoice } from './types'
import { executeCommand, findWorkspaceRoot, readPackageJson } from './utils'
import { PackageInfo, PackageUpgradeChoice } from '../types'
import { executeCommand, findWorkspaceRoot, readPackageJson } from '../utils'

export class PackageUpgrader {
public async upgradePackages(
Expand Down
Loading
Loading