From b98e6488ce184ca6022e751ff736365abb669e2c Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Thu, 19 Mar 2026 17:41:11 +0200 Subject: [PATCH 1/3] feat: add streaming package loading with batched npm registry requests Add progressive package loading that emits events as packages are checked, enabling real-time progress updates in the UI. The PackageDetector now supports streaming via streamOutdatedPackages() which emits initial, batch, and complete events. Packages are fetched in configurable batches with concurrency control (default 20 batch size, 5 concurrent requests), improving perceived performance for large dependency trees. --- src/core/package-detector.ts | 352 ++++++++++++++++-------- src/core/upgrade-runner.ts | 86 +++++- src/interactive-ui.ts | 236 +++++++++++----- src/services/npm-registry.ts | 65 +++++ src/types.ts | 72 ++++- src/ui/input-handler.ts | 4 +- src/ui/renderer/index.ts | 13 +- src/ui/renderer/package-list.ts | 44 ++- src/ui/state/state-manager.ts | 12 +- test/unit/core/package-detector.test.ts | 158 +++++++++++ test/unit/services/npm-registry.test.ts | 42 ++- test/unit/ui/package-list.test.ts | 56 ++++ 12 files changed, 920 insertions(+), 220 deletions(-) create mode 100644 test/unit/core/package-detector.test.ts create mode 100644 test/unit/ui/package-list.test.ts diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index f3156a7..a72db72 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -1,5 +1,13 @@ import * as semver from 'semver' -import { PackageInfo, PackageJson, UpgradeOptions } from '../types' +import { + DependencyEntry, + PackageInfo, + PackageLoadProgress, + StreamOutdatedPackagesBatchItem, + StreamOutdatedPackagesCallback, + StreamOutdatedPackagesInitialPayload, + UpgradeOptions, +} from '../types' import { findPackageJson, readPackageJson, @@ -7,18 +15,26 @@ import { collectAllDependenciesAsync, findClosestMinorVersion, } from '../utils' -import { getAllPackageData } from '../services' +import { getAllPackageDataBatched, PackageVersionData } from '../services' import { isPackageIgnored } from '../config' import { ConsoleUtils } from '../ui/utils' import { debugLog } from '../utils' +interface PreparedDependencies { + allDependencies: DependencyEntry[] + uniquePackages: string[] + currentVersions: Map +} + export class PackageDetector { private packageJsonPath: string | null = null - private packageJson: PackageJson | null = null + private packageJson: Record | null = null private cwd: string private excludePatterns: string[] private ignorePackages: string[] private maxDepth: number + private readonly batchSize = 10 + private readonly batchConcurrency = 5 constructor(options?: UpgradeOptions) { this.cwd = options?.cwd || process.cwd() @@ -36,18 +52,128 @@ export class PackageDetector { } public async getOutdatedPackages(): Promise { + const packages: PackageInfo[] = [] + + await this.streamOutdatedPackages((event) => { + if (event.type === 'batch') { + event.payload.batch.forEach((item) => { + packages.push(...item.packageInfo) + }) + } else if (event.type === 'complete') { + packages.splice(0, packages.length, ...event.payload.packages) + } + }) + + return packages + } + + public async streamOutdatedPackages(onEvent: StreamOutdatedPackagesCallback): Promise { if (!this.packageJson) { throw new Error('No package.json found in current directory') } - const packages: PackageInfo[] = [] const t0 = Date.now() debugLog.info('PackageDetector', `Starting scan in ${this.cwd}`) - // Always check all package.json files recursively with timeout protection + const prepared = await this.prepareDependencies() + const initialPayload: StreamOutdatedPackagesInitialPayload = { + allDependencies: prepared.allDependencies, + uniquePackages: prepared.uniquePackages, + currentVersions: prepared.currentVersions, + progress: this.createProgressSnapshot(prepared.uniquePackages.length, 0, 0, true), + } + + onEvent({ type: 'initial', payload: initialPayload }) + + const packageLookup = new Map() + let resolved = 0 + let failed = 0 + + const tFetch = Date.now() + debugLog.info('PackageDetector', 'fetching version data via npm registry in batches') + + await getAllPackageDataBatched( + prepared.uniquePackages, + (batch) => { + const batchItems: StreamOutdatedPackagesBatchItem[] = batch.map((batchItem) => { + const packageInfo = this.resolvePackageGroup( + batchItem.packageName, + prepared.allDependencies, + batchItem.data + ) + packageLookup.set(batchItem.packageName, packageInfo) + resolved++ + + const isFailed = batchItem.data.latestVersion === 'unknown' + if (isFailed) { + failed++ + } + + return { + packageName: batchItem.packageName, + packageInfo, + failed: isFailed, + } + }) + + const progress = this.createProgressSnapshot( + prepared.uniquePackages.length, + resolved, + failed, + resolved < prepared.uniquePackages.length + ) + + onEvent({ + type: 'batch', + payload: { + batch: batchItems, + progress, + }, + }) + }, + prepared.currentVersions, + { + batchSize: this.batchSize, + concurrency: this.batchConcurrency, + } + ) + + debugLog.perf( + 'PackageDetector', + `registry fetch (${resolved}/${prepared.uniquePackages.length} resolved)`, + tFetch + ) + + const finalPackages = prepared.uniquePackages.flatMap((packageName) => packageLookup.get(packageName) ?? []) + const progress = this.createProgressSnapshot( + prepared.uniquePackages.length, + resolved, + failed, + false + ) + + debugLog.perf( + 'PackageDetector', + `total scan complete (${finalPackages.filter((p) => p.isOutdated).length} outdated of ${finalPackages.length} deps)`, + t0 + ) + + onEvent({ + type: 'complete', + payload: { + packages: finalPackages, + progress, + }, + }) + + ConsoleUtils.clearProgress() + return finalPackages + } + + private async prepareDependencies(): Promise { this.showProgress('🔍 Scanning repository for package.json files...') const tScan = Date.now() - const allPackageJsonFiles = await this.findPackageJsonFilesWithTimeout(30000) // 30 second timeout + const allPackageJsonFiles = await this.findPackageJsonFilesWithTimeout(30000) debugLog.perf('PackageDetector', `file scan (${allPackageJsonFiles.length} files)`, tScan, { files: allPackageJsonFiles, }) @@ -55,7 +181,6 @@ export class PackageDetector { `🔍 Found ${allPackageJsonFiles.length} package.json file${allPackageJsonFiles.length === 1 ? '' : 's'}` ) - // Step 2: Collect all dependencies from package.json files (parallelized) this.showProgress('🔍 Reading dependencies from package.json files...') const tDeps = Date.now() const allDepsRaw = await collectAllDependenciesAsync(allPackageJsonFiles, { @@ -64,13 +189,13 @@ export class PackageDetector { }) debugLog.perf('PackageDetector', `dependency collection (${allDepsRaw.length} raw deps)`, tDeps) - // Step 3: Get unique package names while filtering out workspace references and ignored packages this.showProgress('🔍 Identifying unique packages...') const uniquePackageNames = new Set() - const allDeps: typeof allDepsRaw = [] + const allDependencies: DependencyEntry[] = [] let ignoredCount = 0 const seenWorkspaceRefs = new Set() const seenIgnored = new Set() + for (const dep of allDepsRaw) { if (this.isWorkspaceReference(dep.version)) { const key = `${dep.name}@${dep.version}` @@ -80,6 +205,7 @@ export class PackageDetector { } continue } + if (this.ignorePackages.length > 0 && isPackageIgnored(dep.name, this.ignorePackages)) { ignoredCount++ if (!seenIgnored.has(dep.name)) { @@ -88,128 +214,138 @@ export class PackageDetector { } continue } - allDeps.push(dep) + + allDependencies.push({ + name: dep.name, + version: dep.version, + type: dep.type as DependencyEntry['type'], + packageJsonPath: dep.packageJsonPath, + }) uniquePackageNames.add(dep.name) } + if (ignoredCount > 0) { this.showProgress(`🔍 Skipped ${ignoredCount} ignored package(s)`) } - const packageNames = Array.from(uniquePackageNames) + + const uniquePackages = Array.from(uniquePackageNames).sort((a, b) => { + const aIsScoped = a.startsWith('@') + const bIsScoped = b.startsWith('@') + if (aIsScoped && !bIsScoped) return -1 + if (!aIsScoped && bIsScoped) return 1 + return a.localeCompare(b) + }) + debugLog.info( 'PackageDetector', - `${packageNames.length} unique packages to check, ${ignoredCount} ignored` + `${uniquePackages.length} unique packages to check, ${ignoredCount} ignored` ) const currentVersions = new Map() - for (const dep of allDeps) { + for (const dep of allDependencies) { if (!currentVersions.has(dep.name)) { currentVersions.set(dep.name, dep.version) } } - const tFetch = Date.now() - debugLog.info('PackageDetector', 'fetching version data via npm registry') - const allPackageData = await getAllPackageData( - packageNames, - (_currentPackage: string, completed: number, total: number) => { - this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`) - }, - currentVersions - ) - debugLog.perf( - 'PackageDetector', - `registry fetch (${allPackageData.size}/${packageNames.length} resolved)`, - tFetch - ) + return { + allDependencies, + uniquePackages, + currentVersions, + } + } - const loggedOutdated = new Set() + private resolvePackageGroup( + packageName: string, + allDependencies: DependencyEntry[], + packageData: PackageVersionData | undefined + ): PackageInfo[] { + const dependencies = allDependencies.filter((dep) => dep.name === packageName) const loggedNoData = new Set() - try { - for (const dep of allDeps) { - try { - const packageData = allPackageData.get(dep.name) - if (!packageData) { - if (!loggedNoData.has(dep.name)) { - loggedNoData.add(dep.name) - debugLog.warn('PackageDetector', `no data returned for ${dep.name} — skipping`) - } - continue + const loggedOutdated = new Set() + + return dependencies.map((dep) => { + try { + if (!packageData || packageData.latestVersion === 'unknown') { + if (!loggedNoData.has(dep.name)) { + loggedNoData.add(dep.name) + debugLog.warn('PackageDetector', `no data returned for ${dep.name} — marking unavailable`) } - const { latestVersion, allVersions } = packageData - - // Find closest minor version (same major, higher minor) that satisfies the current range - // Falls back to patch updates if no minor updates are available - const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions) - - const installedClean = semver.coerce(dep.version)?.version || dep.version - const minorClean = closestMinorVersion - ? semver.coerce(closestMinorVersion)?.version || closestMinorVersion - : null - const latestClean = semver.coerce(latestVersion)?.version || latestVersion - - const hasRangeUpdate = minorClean !== null && minorClean !== installedClean - const hasMajorUpdate = semver.major(latestClean) > semver.major(installedClean) - const isOutdated = hasRangeUpdate || hasMajorUpdate - - if (isOutdated) { - const outdatedKey = `${dep.name}@${dep.version}` - if (!loggedOutdated.has(outdatedKey)) { - loggedOutdated.add(outdatedKey) - debugLog.info( - 'PackageDetector', - `outdated: ${dep.name} ${dep.version} → range:${closestMinorVersion ?? '-'} latest:${latestVersion}` - ) - } + return this.createFailedPackageInfo(dep) + } + + const { latestVersion, allVersions } = packageData + const closestMinorVersion = findClosestMinorVersion(dep.version, allVersions) + + const installedClean = semver.coerce(dep.version)?.version || dep.version + const minorClean = closestMinorVersion + ? semver.coerce(closestMinorVersion)?.version || closestMinorVersion + : null + const latestClean = semver.coerce(latestVersion)?.version || latestVersion + + const hasRangeUpdate = minorClean !== null && minorClean !== installedClean + const hasMajorUpdate = + semver.valid(latestClean) !== null && + semver.valid(installedClean) !== null && + semver.major(latestClean) > semver.major(installedClean) + const isOutdated = hasRangeUpdate || hasMajorUpdate + + if (isOutdated) { + const outdatedKey = `${dep.name}@${dep.version}` + if (!loggedOutdated.has(outdatedKey)) { + loggedOutdated.add(outdatedKey) + debugLog.info( + 'PackageDetector', + `outdated: ${dep.name} ${dep.version} → range:${closestMinorVersion ?? '-'} latest:${latestVersion}` + ) } + } - packages.push({ - name: dep.name, - currentVersion: dep.version, // Keep original version specifier with prefix - rangeVersion: closestMinorVersion || dep.version, - latestVersion, - type: dep.type as - | 'dependencies' - | 'devDependencies' - | 'optionalDependencies' - | 'peerDependencies', - packageJsonPath: dep.packageJsonPath, - isOutdated, - hasRangeUpdate, - hasMajorUpdate, - }) - } catch (error) { - debugLog.error('PackageDetector', `error processing ${dep.name}`, error) - // Skip packages that can't be checked (private packages, etc.) - packages.push({ - name: dep.name, - currentVersion: dep.version, - rangeVersion: 'unknown', - latestVersion: 'unknown', - type: dep.type as - | 'dependencies' - | 'devDependencies' - | 'optionalDependencies' - | 'peerDependencies', - packageJsonPath: dep.packageJsonPath, - isOutdated: false, - hasRangeUpdate: false, - hasMajorUpdate: false, - }) + return { + name: dep.name, + currentVersion: dep.version, + rangeVersion: closestMinorVersion || dep.version, + latestVersion, + type: dep.type, + packageJsonPath: dep.packageJsonPath, + isOutdated, + hasRangeUpdate, + hasMajorUpdate, } + } catch (error) { + debugLog.error('PackageDetector', `error processing ${dep.name}`, error) + return this.createFailedPackageInfo(dep) } + }) + } - const outdatedCount = packages.filter((p) => p.isOutdated).length - debugLog.perf( - 'PackageDetector', - `total scan complete (${outdatedCount} outdated of ${packages.length} deps)`, - t0 - ) - return packages - } catch (error) { - this.showProgress('❌ Failed to check packages\n') - debugLog.error('PackageDetector', 'fatal error during package check', error) - throw error + private createFailedPackageInfo(dep: DependencyEntry): PackageInfo { + return { + name: dep.name, + currentVersion: dep.version, + rangeVersion: 'unknown', + latestVersion: 'unknown', + type: dep.type, + packageJsonPath: dep.packageJsonPath, + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + } + } + + private createProgressSnapshot( + total: number, + resolved: number, + failed: number, + isLoading: boolean + ): PackageLoadProgress { + return { + discovered: total, + resolved, + total, + failed, + isLoading, } } @@ -224,7 +360,6 @@ export class PackageDetector { this.excludePatterns, this.maxDepth, (currentDir: string, foundCount: number) => { - // Show scanning progress with current directory and count const truncatedDir = currentDir.length > 50 ? '...' + currentDir.slice(-47) : currentDir this.showProgress(`🔍 Scanning ${truncatedDir} (found ${foundCount})`) @@ -250,7 +385,6 @@ export class PackageDetector { } private isWorkspaceReference(version: string): boolean { - // Check for common workspace reference patterns return ( version.includes('workspace:') || version === '*' || diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index 625f463..65ca4b6 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' -import { UpgradeOptions, PackageManagerInfo } from '../types' +import { PackageInfo, PackageLoadProgress, PackageSelectionState, UpgradeOptions, PackageManagerInfo } from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' /** @@ -36,25 +36,79 @@ export class UpgradeRunner { // Check prerequisites this.checkPrerequisites() - // Detect packages - const packages = await this.detector.getOutdatedPackages() + const progress: PackageLoadProgress = { + discovered: 0, + resolved: 0, + total: 0, + failed: 0, + isLoading: true, + } + let selectionStates: PackageSelectionState[] = [] + let refreshUI: (() => void) | undefined + let latestPackages: PackageInfo[] = [] + let previousSelections: Map | undefined - // Display packages table - await this.ui.displayPackagesTable(packages) + const selectionPromise = new Promise((resolve, reject) => { + const streamPromise = this.detector.streamOutdatedPackages((event) => { + if (event.type === 'initial') { + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + + selectionStates = [] + + this.ui + .selectPackagesToUpgradeProgressive(selectionStates, progress, (refresh) => { + refreshUI = refresh + }) + .then(resolve) + .catch(reject) + } + + if (event.type === 'batch') { + latestPackages = latestPackages + .filter((pkg) => !event.payload.batch.some((item) => item.packageName === pkg.name)) + .concat(event.payload.batch.flatMap((item) => item.packageInfo)) + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + this.ui.appendOutdatedBatchToSelectionStates( + selectionStates, + event.payload.batch, + previousSelections + ) + refreshUI?.() + } + + if (event.type === 'complete') { + latestPackages = event.payload.packages + progress.discovered = event.payload.progress.discovered + progress.resolved = event.payload.progress.resolved + progress.total = event.payload.progress.total + progress.failed = event.payload.progress.failed + progress.isLoading = event.payload.progress.isLoading + refreshUI?.() + } + }) - const outdatedPackages = this.detector.getOutdatedPackagesOnly(packages) - if (outdatedPackages.length === 0) { + streamPromise.catch(reject) + }) + + let selectedChoices: any[] = await selectionPromise + const outdatedPackages = this.detector.getOutdatedPackagesOnly(latestPackages) + if (outdatedPackages.length === 0 && selectedChoices.length === 0) { + console.log(chalk.green('✅ All packages are up to date!')) return } // Interactive selection and confirmation loop - let selectedChoices: any[] = [] let shouldProceed: boolean | null = false - let previousSelections: Map | undefined while (true) { - // Interactive selection - selectedChoices = await this.ui.selectPackagesToUpgrade(packages, previousSelections) if (selectedChoices.length === 0) { console.log(chalk.yellow('No packages selected. Exiting...')) @@ -62,7 +116,7 @@ export class UpgradeRunner { } // Validate selected choices before confirmation - this.validateSelectedChoices(selectedChoices, packages) + this.validateSelectedChoices(selectedChoices, latestPackages) // Store current selections for potential return to selection previousSelections = new Map() @@ -85,7 +139,11 @@ export class UpgradeRunner { // User pressed N or ESC - go back to selection with current selections preserved console.clear() console.log(chalk.bold.blue('🚀 inup\n')) - // previousSelections is already set from above + selectedChoices = progress.isLoading + ? await this.ui.selectPackagesToUpgradeProgressive(selectionStates, progress, (refresh) => { + refreshUI = refresh + }) + : await this.ui.selectPackagesToUpgrade(latestPackages, previousSelections) continue } @@ -99,7 +157,7 @@ export class UpgradeRunner { } // Perform upgrade - await this.upgrader.upgradePackages(selectedChoices, packages) + await this.upgrader.upgradePackages(selectedChoices, latestPackages) } catch (error) { console.error(chalk.red(`Error: ${error}`)) process.exit(1) diff --git a/src/interactive-ui.ts b/src/interactive-ui.ts index e13ba6d..e89132b 100644 --- a/src/interactive-ui.ts +++ b/src/interactive-ui.ts @@ -3,10 +3,12 @@ import chalk from 'chalk' import * as semver from 'semver' const keypress = require('keypress') import { + PackageLoadProgress, PackageInfo, PackageUpgradeChoice, PackageSelectionState, PackageManagerInfo, + StreamOutdatedPackagesBatchItem, } from './types' import { Key } from 'node:readline' import { @@ -39,88 +41,157 @@ export class InteractiveUI { packages: PackageInfo[], previousSelections?: Map ): Promise { - const outdatedPackages = packages.filter((p) => p.isOutdated) - - if (outdatedPackages.length === 0) { + const selectionStates = this.createSelectionStates(packages, previousSelections, false) + if (selectionStates.length === 0) { return [] } - // Deduplicate packages by name and version specifier, but track all package.json paths - const uniquePackages = new Map< - string, - { - pkg: PackageInfo - packageJsonPaths: Set - type: PackageInfo['type'] - } - >() - - for (const pkg of outdatedPackages) { - const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` - if (!uniquePackages.has(key)) { - uniquePackages.set(key, { - pkg, - packageJsonPaths: new Set([pkg.packageJsonPath]), - type: pkg.type, - }) - } else { - uniquePackages.get(key)!.packageJsonPaths.add(pkg.packageJsonPath) - } - } - - // Convert to array and sort alphabetically by name (@scoped packages first, then unscoped) - const deduplicatedPackages = Array.from(uniquePackages.values()).map( - ({ pkg, packageJsonPaths, type }) => ({ - ...pkg, - packageJsonPaths: Array.from(packageJsonPaths), - type, - }) - ) - - deduplicatedPackages.sort((a, b) => { - const aIsScoped = a.name.startsWith('@') - const bIsScoped = b.name.startsWith('@') - - // If one is scoped and the other isn't, scoped comes first - if (aIsScoped && !bIsScoped) return -1 - if (!aIsScoped && bIsScoped) return 1 + const selectedStates = await this.interactiveTableSelector(selectionStates) + return this.createUpgradeChoices(selectedStates) + } - // Both scoped or both unscoped - sort alphabetically - return a.name.localeCompare(b.name) - }) + public createSelectionStates( + packages: PackageInfo[], + previousSelections?: Map, + includeUpToDate: boolean = true + ): PackageSelectionState[] { + const relevantPackages = includeUpToDate ? packages : packages.filter((p) => p.isOutdated) + const uniquePackages = this.deduplicatePackages(relevantPackages) - // Create selection states for each unique package - const selectionStates: PackageSelectionState[] = deduplicatedPackages.map((pkg) => { + return Array.from(uniquePackages.values()).map(({ pkg, packageJsonPaths }) => { const currentClean = semver.coerce(pkg.currentVersion)?.version || pkg.currentVersion const rangeClean = semver.coerce(pkg.rangeVersion)?.version || pkg.rangeVersion const latestClean = semver.coerce(pkg.latestVersion)?.version || pkg.latestVersion - - // Use previous selection if available, otherwise default to 'none' const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` const previousSelection = previousSelections?.get(key) || 'none' return { name: pkg.name, - packageJsonPath: pkg.packageJsonPaths[0], // Use first path for display - packageJsonPaths: pkg.packageJsonPaths, // Store all paths for upgrading - currentVersionSpecifier: pkg.currentVersion, // Keep original with prefix + packageJsonPath: pkg.packageJsonPath, + packageJsonPaths: Array.from(packageJsonPaths), + currentVersionSpecifier: pkg.currentVersion, currentVersion: currentClean, rangeVersion: rangeClean, latestVersion: latestClean, selectedOption: previousSelection, + loadState: 'ready', hasRangeUpdate: pkg.hasRangeUpdate, hasMajorUpdate: pkg.hasMajorUpdate, type: pkg.type, } }) + } - // Use custom interactive table selector (simplified - no grouping) - const selectedStates = await this.interactiveTableSelector(selectionStates) + public createPendingSelectionStates( + packages: Array>, + previousSelections?: Map + ): PackageSelectionState[] { + const uniquePackages = this.deduplicatePackages( + packages.map((pkg) => ({ + ...pkg, + rangeVersion: pkg.currentVersion, + latestVersion: pkg.currentVersion, + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + })) + ) + + return Array.from(uniquePackages.values()).map(({ pkg, packageJsonPaths }) => { + const currentClean = semver.coerce(pkg.currentVersion)?.version || pkg.currentVersion + const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` + const previousSelection = previousSelections?.get(key) || 'none' + + return { + name: pkg.name, + packageJsonPath: pkg.packageJsonPath, + packageJsonPaths: Array.from(packageJsonPaths), + currentVersionSpecifier: pkg.currentVersion, + currentVersion: currentClean, + rangeVersion: 'loading', + latestVersion: 'loading', + selectedOption: previousSelection, + loadState: 'pending', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: pkg.type, + } + }) + } + + public appendOutdatedBatchToSelectionStates( + selectionStates: PackageSelectionState[], + batch: StreamOutdatedPackagesBatchItem[], + previousSelections?: Map + ): void { + const outdatedStates = this.createSelectionStates( + batch.flatMap((batchItem) => batchItem.packageInfo).filter((pkg) => pkg.isOutdated), + previousSelections, + false + ) + + if (outdatedStates.length === 0) { + return + } + + const seen = new Set( + selectionStates.map((state) => `${state.name}@${state.currentVersionSpecifier}@${state.type}`) + ) + + outdatedStates.forEach((state) => { + const key = `${state.name}@${state.currentVersionSpecifier}@${state.type}` + if (!seen.has(key)) { + selectionStates.push(state) + seen.add(key) + } + }) + } + + public async selectPackagesToUpgradeProgressive( + selectionStates: PackageSelectionState[], + progress: PackageLoadProgress, + attachRefresh: (refresh: () => void) => void + ): Promise { + const selectedStates = await this.interactiveTableSelector( + selectionStates, + progress, + attachRefresh + ) + return this.createUpgradeChoices(selectedStates) + } - // Convert to PackageUpgradeChoice[] - create one choice per package.json path + private deduplicatePackages( + packages: PackageInfo[] + ): Map }> { + const uniquePackages = new Map }>() + + for (const pkg of packages) { + const key = `${pkg.name}@${pkg.currentVersion}@${pkg.type}` + if (!uniquePackages.has(key)) { + uniquePackages.set(key, { + pkg, + packageJsonPaths: new Set([pkg.packageJsonPath]), + }) + } else { + uniquePackages.get(key)!.packageJsonPaths.add(pkg.packageJsonPath) + } + } + + return new Map( + Array.from(uniquePackages.entries()).sort(([, a], [, b]) => { + const aIsScoped = a.pkg.name.startsWith('@') + const bIsScoped = b.pkg.name.startsWith('@') + if (aIsScoped && !bIsScoped) return -1 + if (!aIsScoped && bIsScoped) return 1 + return a.pkg.name.localeCompare(b.pkg.name) + }) + ) + } + + private createUpgradeChoices(selectedStates: PackageSelectionState[]): PackageUpgradeChoice[] { const choices: PackageUpgradeChoice[] = [] selectedStates - .filter((state) => state.selectedOption !== 'none') + .filter((state) => state.loadState === 'ready' && state.selectedOption !== 'none') .forEach((state) => { const targetVersion = state.selectedOption === 'range' ? state.rangeVersion : state.latestVersion @@ -129,7 +200,6 @@ export class InteractiveUI { targetVersion ) - // Create a choice for each package.json path where this package appears const pathsToUpdate = state.packageJsonPaths || [state.packageJsonPath] pathsToUpdate.forEach((packageJsonPath) => { choices.push({ @@ -155,11 +225,14 @@ export class InteractiveUI { } private async interactiveTableSelector( - selectionStates: PackageSelectionState[] + selectionStates: PackageSelectionState[], + loadingProgress?: PackageLoadProgress, + attachRefresh?: (refresh: () => void) => void ): Promise { return new Promise((resolve) => { - const states = [...selectionStates] + const states = selectionStates const stateManager = new StateManager(0, this.getTerminalHeight()) + let isResolved = false // No grouping needed - packages are already filtered by type // This simplifies scrolling and avoids rendering issues @@ -215,24 +288,26 @@ export class InteractiveUI { // Opening modal - load package info asynchronously stateManager.toggleInfoModal() const currentState = filteredStates[uiState.currentRow] - stateManager.setModalLoading(true) + const canFetchMetadata = currentState?.loadState === 'ready' + stateManager.setModalLoading(canFetchMetadata) renderInterface() - // Fetch metadata asynchronously - changelogFetcher - .fetchPackageMetadata(currentState.name, currentState.latestVersion) - .then((metadata) => { - if (metadata) { - currentState.description = metadata.description - currentState.homepage = metadata.homepage - currentState.repository = metadata.releaseNotes - currentState.weeklyDownloads = metadata.weeklyDownloads - currentState.author = metadata.author as string | undefined - currentState.license = metadata.license - } - stateManager.setModalLoading(false) - renderInterface() - }) + if (currentState && canFetchMetadata) { + changelogFetcher + .fetchPackageMetadata(currentState.name, currentState.latestVersion) + .then((metadata) => { + if (metadata) { + currentState.description = metadata.description + currentState.homepage = metadata.homepage + currentState.repository = metadata.releaseNotes + currentState.weeklyDownloads = metadata.weeklyDownloads + currentState.author = metadata.author as string | undefined + currentState.license = metadata.license + } + stateManager.setModalLoading(false) + renderInterface() + }) + } } else { // Closing modal stateManager.toggleInfoModal() @@ -295,6 +370,7 @@ export class InteractiveUI { } const handleConfirm = (selectedStates: PackageSelectionState[]) => { + isResolved = true // Reset terminal colors process.stdout.write(getTerminalResetCode()) CursorUtils.show() @@ -309,6 +385,7 @@ export class InteractiveUI { } const handleCancel = () => { + isResolved = true // Reset terminal colors process.stdout.write(getTerminalResetCode()) CursorUtils.show() @@ -414,7 +491,8 @@ export class InteractiveUI { uiState.filterMode, uiState.filterQuery, states.length, - terminalWidth + terminalWidth, + loadingProgress ) // Print all lines @@ -440,6 +518,12 @@ export class InteractiveUI { // Setup keypress handling try { + attachRefresh?.(() => { + if (!isResolved) { + renderInterface() + } + }) + keypress(process.stdin) if (process.stdin.setRawMode) { process.stdin.setRawMode(true) diff --git a/src/services/npm-registry.ts b/src/services/npm-registry.ts index 69be6c4..a7b4d21 100644 --- a/src/services/npm-registry.ts +++ b/src/services/npm-registry.ts @@ -2,6 +2,7 @@ import * as semver from 'semver' import { NPM_REGISTRY_URL, REQUEST_TIMEOUT } from '../config' import { getAllPackageDataFromJsdelivr } from './jsdelivr-registry' import { ConsoleUtils } from '../ui/utils' +import { OnBatchReadyCallback, RegistryBatchOptions, RegistryBatchProgressItem } from '../types' export interface PackageVersionData { latestVersion: string @@ -160,6 +161,70 @@ export async function getAllPackageData( return packageData } +async function runWithConcurrencyLimit( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) { + return + } + + const limit = Math.max(1, Math.min(concurrency, items.length)) + let nextIndex = 0 + + const runWorker = async (): Promise => { + while (nextIndex < items.length) { + const currentIndex = nextIndex++ + await worker(items[currentIndex], currentIndex) + } + } + + await Promise.all(Array.from({ length: limit }, () => runWorker())) +} + +export async function getAllPackageDataBatched( + packageNames: string[], + onBatchReady?: OnBatchReadyCallback, + currentVersions?: Map, + options: RegistryBatchOptions = {} +): Promise> { + const packageData = new Map() + + if (packageNames.length === 0) { + return packageData + } + + const batchSize = Math.max(1, options.batchSize ?? 20) + const concurrency = Math.max(1, options.concurrency ?? 5) + const total = packageNames.length + let completedCount = 0 + + for (let batchStart = 0; batchStart < packageNames.length; batchStart += batchSize) { + const batchIndex = Math.floor(batchStart / batchSize) + const batchNames = packageNames.slice(batchStart, batchStart + batchSize) + const batchResults: RegistryBatchProgressItem[] = new Array(batchNames.length) + + await runWithConcurrencyLimit(batchNames, concurrency, async (packageName, itemIndex) => { + const data = await getFreshPackageData(packageName, currentVersions?.get(packageName)) + packageData.set(packageName, data) + completedCount++ + batchResults[itemIndex] = { + packageName, + data, + completed: completedCount, + total, + batchIndex, + itemIndex, + } + }) + + onBatchReady?.(batchResults.filter(Boolean)) + } + + return packageData +} + /** * Retained for backward compatibility. Registry responses are fresh-by-default. */ diff --git a/src/types.ts b/src/types.ts index 2894824..8cd6246 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,10 +16,25 @@ export interface PackageInfo { license?: string // Package license } +export type DependencyType = + | 'dependencies' + | 'devDependencies' + | 'optionalDependencies' + | 'peerDependencies' + +export interface DependencyEntry { + name: string + version: string + type: DependencyType + packageJsonPath: string +} + +export type PackageLoadState = 'pending' | 'ready' | 'failed' + export interface PackageUpgradeChoice { name: string packageJsonPath: string // Path to the package.json file to upgrade - dependencyType: 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' + dependencyType: DependencyType upgradeType: 'none' | 'range' | 'latest' targetVersion: string currentVersionSpecifier: string // Original version specifier with prefix @@ -34,9 +49,10 @@ export interface PackageSelectionState { rangeVersion: string latestVersion: string selectedOption: 'none' | 'range' | 'latest' + loadState: PackageLoadState hasRangeUpdate: boolean hasMajorUpdate: boolean - type: 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' + type: DependencyType description?: string // Package description from npm registry homepage?: string // Package homepage URL repository?: string // GitHub/repository URL for releases @@ -86,6 +102,52 @@ export interface PackageJson { [key: string]: any } -export type OnBatchReadyCallback = ( - batch: Array<{ name: string; data: { latestVersion: string; allVersions: string[] } }> -) => void +export interface PackageLoadProgress { + discovered: number + resolved: number + total: number + failed: number + isLoading: boolean +} + +export interface StreamOutdatedPackagesInitialPayload { + allDependencies: DependencyEntry[] + uniquePackages: string[] + currentVersions: Map + progress: PackageLoadProgress +} + +export interface StreamOutdatedPackagesBatchItem { + packageName: string + packageInfo: PackageInfo[] + failed: boolean +} + +export type StreamOutdatedPackagesEvent = + | { type: 'initial'; payload: StreamOutdatedPackagesInitialPayload } + | { + type: 'batch' + payload: { + batch: StreamOutdatedPackagesBatchItem[] + progress: PackageLoadProgress + } + } + | { type: 'complete'; payload: { packages: PackageInfo[]; progress: PackageLoadProgress } } + +export type StreamOutdatedPackagesCallback = (event: StreamOutdatedPackagesEvent) => void + +export interface RegistryBatchOptions { + batchSize?: number + concurrency?: number +} + +export interface RegistryBatchProgressItem { + packageName: string + data: { latestVersion: string; allVersions: string[] } + completed: number + total: number + batchIndex: number + itemIndex: number +} + +export type OnBatchReadyCallback = (batch: RegistryBatchProgressItem[]) => void diff --git a/src/ui/input-handler.ts b/src/ui/input-handler.ts index 7489ee7..e5db423 100644 --- a/src/ui/input-handler.ts +++ b/src/ui/input-handler.ts @@ -182,7 +182,9 @@ export class InputHandler { case 'return': // Check if any packages are selected - const selectedCount = states.filter((s) => s.selectedOption !== 'none').length + const selectedCount = states.filter( + (s) => s.loadState === 'ready' && s.selectedOption !== 'none' + ).length if (selectedCount === 0) { // Do nothing if no packages selected return diff --git a/src/ui/renderer/index.ts b/src/ui/renderer/index.ts index 0792bc4..b1c71e8 100644 --- a/src/ui/renderer/index.ts +++ b/src/ui/renderer/index.ts @@ -1,4 +1,9 @@ -import { PackageSelectionState, RenderableItem, PackageManagerInfo } from '../../types' +import { + PackageLoadProgress, + PackageSelectionState, + RenderableItem, + PackageManagerInfo, +} from '../../types' import * as PackageList from './package-list' import * as Confirmation from './confirmation' import * as Modal from './modal' @@ -32,7 +37,8 @@ export class UIRenderer { filterMode?: boolean, filterQuery?: string, totalPackagesBeforeFilter?: number, - terminalWidth: number = 80 + terminalWidth: number = 80, + loadingProgress?: PackageLoadProgress ): string[] { return PackageList.renderInterface( states, @@ -46,7 +52,8 @@ export class UIRenderer { filterMode, filterQuery, totalPackagesBeforeFilter, - terminalWidth + terminalWidth, + loadingProgress ) } diff --git a/src/ui/renderer/package-list.ts b/src/ui/renderer/package-list.ts index 2186799..469e0a5 100644 --- a/src/ui/renderer/package-list.ts +++ b/src/ui/renderer/package-list.ts @@ -1,5 +1,10 @@ import chalk from 'chalk' -import { PackageSelectionState, RenderableItem, PackageInfo } from '../../types' +import { + PackageInfo, + PackageLoadProgress, + PackageSelectionState, + RenderableItem, +} from '../../types' import { VersionUtils } from '../utils' import { getThemeColor } from '../themes-colors' @@ -54,6 +59,8 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i const isCurrentSelected = state.selectedOption === 'none' const isRangeSelected = state.selectedOption === 'range' const isLatestSelected = state.selectedOption === 'latest' + const isPending = state.loadState === 'pending' + const isFailed = state.loadState === 'failed' // Current version dot and version (show original specifier with prefix) const currentDot = isCurrentSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') @@ -62,7 +69,13 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Range version dot and version let rangeDot = '' let rangeVersionText = '' - if (state.hasRangeUpdate) { + if (isPending) { + rangeDot = getThemeColor('dotEmpty')('◌') + rangeVersionText = chalk.gray('loading') + } else if (isFailed) { + rangeDot = getThemeColor('dotEmpty')('◌') + rangeVersionText = chalk.gray('unavailable') + } else if (state.hasRangeUpdate) { rangeDot = isRangeSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') const rangeVersionWithPrefix = VersionUtils.applyVersionPrefix( state.currentVersionSpecifier, @@ -77,7 +90,13 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Latest version dot and version let latestDot = '' let latestVersionText = '' - if (state.hasMajorUpdate) { + if (isPending) { + latestDot = getThemeColor('dotEmpty')('◌') + latestVersionText = chalk.gray('loading') + } else if (isFailed) { + latestDot = getThemeColor('dotEmpty')('◌') + latestVersionText = chalk.gray('unavailable') + } else if (state.hasMajorUpdate) { latestDot = isLatestSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') const latestVersionWithPrefix = VersionUtils.applyVersionPrefix( state.currentVersionSpecifier, @@ -137,7 +156,7 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Range version section with dashes only if needed let rangeSection = '' - if (state.hasRangeUpdate) { + if (isPending || isFailed || state.hasRangeUpdate) { rangeSection = `${rangeDot} ${rangeVersionText}` const rangeSectionLength = VersionUtils.getVisualLength(rangeSection) + 1 // +1 for space before padding const rangePadding = Math.max(0, rangeColumnWidth - rangeSectionLength) @@ -150,7 +169,7 @@ export function renderPackageLine(state: PackageSelectionState, index: number, i // Latest version section with dashes only if needed let latestSection = '' - if (state.hasMajorUpdate) { + if (isPending || isFailed || state.hasMajorUpdate) { latestSection = `${latestDot} ${latestVersionText}` const latestSectionLength = VersionUtils.getVisualLength(latestSection) + 1 // +1 for space before padding const latestPadding = Math.max(0, latestColumnWidth - latestSectionLength) @@ -198,7 +217,8 @@ export function renderInterface( filterMode?: boolean, filterQuery?: string, totalPackagesBeforeFilter?: number, - terminalWidth: number = 80 + terminalWidth: number = 80, + loadingProgress?: PackageLoadProgress ): string[] { const output: string[] = [] @@ -390,6 +410,18 @@ export function renderInterface( } } + if (loadingProgress?.isLoading) { + const loadingLabel = `Loading packages... (${loadingProgress.resolved}/${loadingProgress.total} checked)` + const failedLabel = + loadingProgress.failed > 0 ? ` ${loadingProgress.failed} unavailable` : '' + const loadingLine = + ' ' + + getThemeColor('textSecondary')(loadingLabel) + + (failedLabel ? chalk.yellow(failedLabel) : '') + const loadingPadding = Math.max(0, terminalWidth - VersionUtils.getVisualLength(loadingLine)) + output.push(loadingLine + ' '.repeat(loadingPadding)) + } + return output } diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index 635b6f9..e7ef943 100644 --- a/src/ui/state/state-manager.ts +++ b/src/ui/state/state-manager.ts @@ -114,7 +114,7 @@ export class StateManager { const currentRow = this.navigationManager.getCurrentRow() const currentState = states[currentRow] - if (!currentState) return + if (!currentState || currentState.loadState !== 'ready') return if (direction === 'left') { // Move selection left with wraparound: latest -> range -> none -> latest @@ -159,7 +159,7 @@ export class StateManager { bulkSelectMinor(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - if (state.hasRangeUpdate) { + if (state.loadState === 'ready' && state.hasRangeUpdate) { state.selectedOption = 'range' } }) @@ -168,9 +168,9 @@ export class StateManager { bulkSelectLatest(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - if (state.hasMajorUpdate) { + if (state.loadState === 'ready' && state.hasMajorUpdate) { state.selectedOption = 'latest' - } else if (state.hasRangeUpdate) { + } else if (state.loadState === 'ready' && state.hasRangeUpdate) { state.selectedOption = 'range' } }) @@ -179,7 +179,9 @@ export class StateManager { bulkUnselectAll(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { - state.selectedOption = 'none' + if (state.loadState === 'ready') { + state.selectedOption = 'none' + } }) } diff --git a/test/unit/core/package-detector.test.ts b/test/unit/core/package-detector.test.ts new file mode 100644 index 0000000..20c22c4 --- /dev/null +++ b/test/unit/core/package-detector.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + findPackageJson: vi.fn(), + readPackageJson: vi.fn(), + findAllPackageJsonFilesAsync: vi.fn(), + collectAllDependenciesAsync: vi.fn(), + findClosestMinorVersion: vi.fn(), + getAllPackageDataBatched: vi.fn(), +})) + +vi.mock('../../../src/utils', () => ({ + findPackageJson: mocks.findPackageJson, + readPackageJson: mocks.readPackageJson, + findAllPackageJsonFilesAsync: mocks.findAllPackageJsonFilesAsync, + collectAllDependenciesAsync: mocks.collectAllDependenciesAsync, + findClosestMinorVersion: mocks.findClosestMinorVersion, + debugLog: { + info: vi.fn(), + perf: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../../../src/services', () => ({ + getAllPackageDataBatched: mocks.getAllPackageDataBatched, +})) + +vi.mock('../../../src/config', () => ({ + isPackageIgnored: vi.fn(() => false), +})) + +vi.mock('../../../src/ui/utils', () => ({ + ConsoleUtils: { + showProgress: vi.fn(), + clearProgress: vi.fn(), + }, +})) + +import { PackageDetector } from '../../../src/core/package-detector' + +describe('PackageDetector streaming', () => { + beforeEach(() => { + mocks.findPackageJson.mockReturnValue('/repo/package.json') + mocks.readPackageJson.mockReturnValue({ name: 'fixture' }) + mocks.findAllPackageJsonFilesAsync.mockResolvedValue(['/repo/package.json']) + mocks.collectAllDependenciesAsync.mockResolvedValue([ + { + name: 'zod', + version: '^3.0.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + }, + { + name: '@scope/pkg', + version: '^1.0.0', + type: 'devDependencies', + packageJsonPath: '/repo/package.json', + }, + ]) + mocks.findClosestMinorVersion.mockImplementation((version: string, versions: string[]) => versions[0] ?? version) + mocks.getAllPackageDataBatched.mockImplementation( + async ( + packageNames: string[], + onBatchReady: (batch: any[]) => void, + _currentVersions: Map, + options: { batchSize: number; concurrency: number } + ) => { + expect(packageNames).toEqual(['@scope/pkg', 'zod']) + expect(options).toEqual({ batchSize: 10, concurrency: 5 }) + + onBatchReady([ + { + packageName: '@scope/pkg', + data: { latestVersion: '2.0.0', allVersions: ['1.2.0', '1.0.0'] }, + completed: 1, + total: 2, + batchIndex: 0, + itemIndex: 0, + }, + ]) + + onBatchReady([ + { + packageName: 'zod', + data: { latestVersion: 'unknown', allVersions: [] }, + completed: 2, + total: 2, + batchIndex: 0, + itemIndex: 1, + }, + ]) + + return new Map([ + ['@scope/pkg', { latestVersion: '2.0.0', allVersions: ['1.2.0', '1.0.0'] }], + ['zod', { latestVersion: 'unknown', allVersions: [] }], + ]) + } + ) + }) + + it('emits initial, batch, and complete events in stable order', async () => { + const detector = new PackageDetector({ cwd: '/repo' }) + const eventTypes: string[] = [] + const batchPackageNames: string[][] = [] + + const packages = await detector.streamOutdatedPackages((event) => { + eventTypes.push(event.type) + + if (event.type === 'initial') { + expect(event.payload.uniquePackages).toEqual(['@scope/pkg', 'zod']) + expect(event.payload.progress).toMatchObject({ + total: 2, + resolved: 0, + isLoading: true, + }) + } + + if (event.type === 'batch') { + batchPackageNames.push(event.payload.batch.map((item) => item.packageName)) + } + + if (event.type === 'complete') { + expect(event.payload.progress).toMatchObject({ + total: 2, + resolved: 2, + failed: 1, + isLoading: false, + }) + } + }) + + expect(eventTypes).toEqual(['initial', 'batch', 'batch', 'complete']) + expect(batchPackageNames).toEqual([['@scope/pkg'], ['zod']]) + expect(packages.map((pkg) => pkg.name)).toEqual(['@scope/pkg', 'zod']) + expect(packages[0]).toMatchObject({ + name: '@scope/pkg', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, + }) + expect(packages[1]).toMatchObject({ + name: 'zod', + latestVersion: 'unknown', + isOutdated: false, + }) + }) + + it('keeps getOutdatedPackages compatible with the streamed implementation', async () => { + const detector = new PackageDetector({ cwd: '/repo' }) + const packages = await detector.getOutdatedPackages() + + expect(packages).toHaveLength(2) + expect(packages[0].name).toBe('@scope/pkg') + expect(packages[1].name).toBe('zod') + }) +}) diff --git a/test/unit/services/npm-registry.test.ts b/test/unit/services/npm-registry.test.ts index f3fd2bd..fca8937 100644 --- a/test/unit/services/npm-registry.test.ts +++ b/test/unit/services/npm-registry.test.ts @@ -7,7 +7,11 @@ vi.mock('../../../src/services/jsdelivr-registry', () => ({ getAllPackageDataFromJsdelivr: getAllPackageDataFromJsdelivrMock, })) -import { getAllPackageData, clearPackageCache } from '../../../src/services/npm-registry' +import { + clearPackageCache, + getAllPackageData, + getAllPackageDataBatched, +} from '../../../src/services/npm-registry' describe('npm-registry', () => { const fetchMock = vi.fn() @@ -178,4 +182,40 @@ describe('npm-registry', () => { { package: 'demo-pkg', completed: 2, total: 2 }, ]) }) + + it('emits batched results in request order', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('pkg-a')) { + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {}, '1.1.0': {} } }), + }) + } + + if (url.includes('pkg-b')) { + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '2.0.0': {}, '2.1.0': {} } }), + }) + } + + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify({ versions: { '3.0.0': {}, '3.1.0': {} } }), + }) + }) + + const batches: string[][] = [] + const result = await getAllPackageDataBatched( + ['pkg-a', 'pkg-b', 'pkg-c'], + (batch) => { + batches.push(batch.map((item) => item.packageName)) + }, + undefined, + { batchSize: 2, concurrency: 1 } + ) + + expect(batches).toEqual([['pkg-a', 'pkg-b'], ['pkg-c']]) + expect(Array.from(result.keys())).toEqual(['pkg-a', 'pkg-b', 'pkg-c']) + }) }) diff --git a/test/unit/ui/package-list.test.ts b/test/unit/ui/package-list.test.ts new file mode 100644 index 0000000..07a1224 --- /dev/null +++ b/test/unit/ui/package-list.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { renderPackageLine } from '../../../src/ui/renderer/package-list' +import { PackageSelectionState } from '../../../src/types' + +const baseState: PackageSelectionState = { + name: 'demo-pkg', + packageJsonPath: '/repo/package.json', + packageJsonPaths: ['/repo/package.json'], + currentVersionSpecifier: '^1.0.0', + currentVersion: '1.0.0', + rangeVersion: '1.1.0', + latestVersion: '2.0.0', + selectedOption: 'none', + loadState: 'ready', + hasRangeUpdate: true, + hasMajorUpdate: true, + type: 'dependencies', +} + +describe('package-list renderer', () => { + it('renders pending rows with loading placeholders', () => { + const line = renderPackageLine( + { + ...baseState, + loadState: 'pending', + rangeVersion: 'loading', + latestVersion: 'loading', + hasRangeUpdate: false, + hasMajorUpdate: false, + }, + 0, + true, + 120 + ) + + expect(line).toContain('loading') + }) + + it('renders failed rows as unavailable and keeps layout stable', () => { + const line = renderPackageLine( + { + ...baseState, + loadState: 'failed', + rangeVersion: 'unknown', + latestVersion: 'unknown', + hasRangeUpdate: false, + hasMajorUpdate: false, + }, + 0, + false, + 120 + ) + + expect(line).toContain('unavailable') + }) +}) From f3b6a7298b6b29cd8ada073ab05c1a620ffe192e Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Thu, 19 Mar 2026 17:55:23 +0200 Subject: [PATCH 2/3] feat(core): support progressive batch sizes for package loading Add ability to configure a growing batch-size sequence when loading packages from npm registry, allowing smaller batches initially that gradually increase for better performance with large package sets --- src/core/package-detector.ts | 4 ++-- src/services/npm-registry.ts | 14 +++++++++++--- src/types.ts | 1 + test/unit/core/package-detector.test.ts | 4 ++-- test/unit/services/npm-registry.test.ts | 21 +++++++++++++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index a72db72..dce1baa 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -33,7 +33,7 @@ export class PackageDetector { private excludePatterns: string[] private ignorePackages: string[] private maxDepth: number - private readonly batchSize = 10 + private readonly batchSizes = [10, 15, 20, 25] private readonly batchConcurrency = 5 constructor(options?: UpgradeOptions) { @@ -133,7 +133,7 @@ export class PackageDetector { }, prepared.currentVersions, { - batchSize: this.batchSize, + batchSizes: this.batchSizes, concurrency: this.batchConcurrency, } ) diff --git a/src/services/npm-registry.ts b/src/services/npm-registry.ts index a7b4d21..bb9afb0 100644 --- a/src/services/npm-registry.ts +++ b/src/services/npm-registry.ts @@ -195,13 +195,18 @@ export async function getAllPackageDataBatched( return packageData } - const batchSize = Math.max(1, options.batchSize ?? 20) + const batchSizes = + options.batchSizes && options.batchSizes.length > 0 + ? options.batchSizes.map((size) => Math.max(1, size)) + : [Math.max(1, options.batchSize ?? 20)] const concurrency = Math.max(1, options.concurrency ?? 5) const total = packageNames.length let completedCount = 0 + let batchStart = 0 + let batchIndex = 0 - for (let batchStart = 0; batchStart < packageNames.length; batchStart += batchSize) { - const batchIndex = Math.floor(batchStart / batchSize) + while (batchStart < packageNames.length) { + const batchSize = batchSizes[Math.min(batchIndex, batchSizes.length - 1)] const batchNames = packageNames.slice(batchStart, batchStart + batchSize) const batchResults: RegistryBatchProgressItem[] = new Array(batchNames.length) @@ -220,6 +225,9 @@ export async function getAllPackageDataBatched( }) onBatchReady?.(batchResults.filter(Boolean)) + + batchStart += batchSize + batchIndex++ } return packageData diff --git a/src/types.ts b/src/types.ts index 8cd6246..e0fd4ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,6 +138,7 @@ export type StreamOutdatedPackagesCallback = (event: StreamOutdatedPackagesEvent export interface RegistryBatchOptions { batchSize?: number + batchSizes?: number[] concurrency?: number } diff --git a/test/unit/core/package-detector.test.ts b/test/unit/core/package-detector.test.ts index 20c22c4..f4077e3 100644 --- a/test/unit/core/package-detector.test.ts +++ b/test/unit/core/package-detector.test.ts @@ -65,10 +65,10 @@ describe('PackageDetector streaming', () => { packageNames: string[], onBatchReady: (batch: any[]) => void, _currentVersions: Map, - options: { batchSize: number; concurrency: number } + options: { batchSizes: number[]; concurrency: number } ) => { expect(packageNames).toEqual(['@scope/pkg', 'zod']) - expect(options).toEqual({ batchSize: 10, concurrency: 5 }) + expect(options).toEqual({ batchSizes: [10, 15, 20, 25], concurrency: 5 }) onBatchReady([ { diff --git a/test/unit/services/npm-registry.test.ts b/test/unit/services/npm-registry.test.ts index fca8937..209741a 100644 --- a/test/unit/services/npm-registry.test.ts +++ b/test/unit/services/npm-registry.test.ts @@ -218,4 +218,25 @@ describe('npm-registry', () => { expect(batches).toEqual([['pkg-a', 'pkg-b'], ['pkg-c']]) expect(Array.from(result.keys())).toEqual(['pkg-a', 'pkg-b', 'pkg-c']) }) + + it('supports a growing batch-size sequence', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ versions: { '1.0.0': {}, '1.1.0': {} } }), + }) + + const packageNames = Array.from({ length: 50 }, (_, index) => `pkg-${index + 1}`) + const batches: number[] = [] + + await getAllPackageDataBatched( + packageNames, + (batch) => { + batches.push(batch.length) + }, + undefined, + { batchSizes: [10, 15, 20, 25], concurrency: 5 } + ) + + expect(batches).toEqual([10, 15, 20, 5]) + }) }) From 19779696a34e8a6572b9ba6fb0f225409266fdc4 Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Thu, 19 Mar 2026 17:59:00 +0200 Subject: [PATCH 3/3] style(core): apply consistent formatting to package-detector and upgrade-runner --- src/core/package-detector.ts | 13 ++++++++++--- src/core/upgrade-runner.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index dce1baa..0d16ac2 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -67,7 +67,9 @@ export class PackageDetector { return packages } - public async streamOutdatedPackages(onEvent: StreamOutdatedPackagesCallback): Promise { + public async streamOutdatedPackages( + onEvent: StreamOutdatedPackagesCallback + ): Promise { if (!this.packageJson) { throw new Error('No package.json found in current directory') } @@ -144,7 +146,9 @@ export class PackageDetector { tFetch ) - const finalPackages = prepared.uniquePackages.flatMap((packageName) => packageLookup.get(packageName) ?? []) + const finalPackages = prepared.uniquePackages.flatMap( + (packageName) => packageLookup.get(packageName) ?? [] + ) const progress = this.createProgressSnapshot( prepared.uniquePackages.length, resolved, @@ -269,7 +273,10 @@ export class PackageDetector { if (!packageData || packageData.latestVersion === 'unknown') { if (!loggedNoData.has(dep.name)) { loggedNoData.add(dep.name) - debugLog.warn('PackageDetector', `no data returned for ${dep.name} — marking unavailable`) + debugLog.warn( + 'PackageDetector', + `no data returned for ${dep.name} — marking unavailable` + ) } return this.createFailedPackageInfo(dep) diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index 65ca4b6..cb932bb 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -2,7 +2,13 @@ import chalk from 'chalk' import { PackageDetector } from './package-detector' import { InteractiveUI } from '../interactive-ui' import { PackageUpgrader } from './upgrader' -import { PackageInfo, PackageLoadProgress, PackageSelectionState, UpgradeOptions, PackageManagerInfo } from '../types' +import { + PackageInfo, + PackageLoadProgress, + PackageSelectionState, + UpgradeOptions, + PackageManagerInfo, +} from '../types' import { PackageManagerDetector } from '../services/package-manager-detector' /** @@ -109,7 +115,6 @@ export class UpgradeRunner { let shouldProceed: boolean | null = false while (true) { - if (selectedChoices.length === 0) { console.log(chalk.yellow('No packages selected. Exiting...')) return @@ -140,9 +145,13 @@ export class UpgradeRunner { console.clear() console.log(chalk.bold.blue('🚀 inup\n')) selectedChoices = progress.isLoading - ? await this.ui.selectPackagesToUpgradeProgressive(selectionStates, progress, (refresh) => { - refreshUI = refresh - }) + ? await this.ui.selectPackagesToUpgradeProgressive( + selectionStates, + progress, + (refresh) => { + refreshUI = refresh + } + ) : await this.ui.selectPackagesToUpgrade(latestPackages, previousSelections) continue }