Skip to content
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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,14 @@ inup [options]
-i, --ignore <packages> Ignore packages (comma-separated, glob supported)
--max-depth <number> Maximum scan depth for package discovery (default: 10)
--package-manager <name> Force package manager (npm, yarn, pnpm, bun)
--no-cache Bypass cached package metadata and fetch fresh registry data
--debug Write verbose debug logs
```

## 🔒 Privacy

We don't track anything. Ever.

The only network requests made are to the npm registry and jsDelivr CDN to fetch package version data. That's it.
Version checks and package metadata are fetched from the npm registry. When needed for immutable exact-version manifests, inup may also fetch a pinned `package.json` from jsDelivr. Weekly download counts come from the npm downloads API.

## 📄 License

Expand Down
1 change: 0 additions & 1 deletion src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,3 @@ export const REQUEST_TIMEOUT = 60000 // 60 seconds in milliseconds
export const JSDELIVR_RETRY_TIMEOUTS = [2000, 3500] // short retry budget to keep fallback fast
export const JSDELIVR_RETRY_DELAYS = [150] // tiny backoff between jsDelivr retries in ms
export const JSDELIVR_POOL_TIMEOUT = 60000 // keep-alive/connect lifecycle should be looser than per-request timeouts
export const DEFAULT_REGISTRY: 'jsdelivr' | 'npm' = 'jsdelivr'
31 changes: 10 additions & 21 deletions src/core/package-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
collectAllDependenciesAsync,
findClosestMinorVersion,
} from '../utils'
import { getAllPackageDataFromJsdelivr, getAllPackageData } from '../services'
import { DEFAULT_REGISTRY, isPackageIgnored } from '../config'
import { getAllPackageData } from '../services'
import { isPackageIgnored } from '../config'
import { ConsoleUtils } from '../ui/utils'
import { debugLog } from '../utils'

Expand Down Expand Up @@ -100,33 +100,22 @@ export class PackageDetector {
`${packageNames.length} unique packages to check, ${ignoredCount} ignored`
)

// Step 4: Fetch all package data in one call per package
// 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 tFetch = Date.now()
debugLog.info('PackageDetector', `fetching version data via ${DEFAULT_REGISTRY}`)
const allPackageData =
DEFAULT_REGISTRY === 'jsdelivr'
? await getAllPackageDataFromJsdelivr(
packageNames,
currentVersions,
(_currentPackage: string, completed: number, total: number) => {
this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`)
}
)
: await getAllPackageData(
packageNames,
(_currentPackage: string, completed: number, total: number) => {
this.showProgress(`🌐 Checking versions... (${completed}/${total} packages)`)
}
)
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)`,
Expand Down
26 changes: 14 additions & 12 deletions src/interactive-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,20 @@ export class InteractiveUI {
renderInterface()

// Fetch metadata asynchronously
changelogFetcher.fetchPackageMetadata(currentState.name).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()
})
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()
Expand Down
115 changes: 78 additions & 37 deletions src/services/changelog-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import chalk from 'chalk'
import { JSDELIVR_CDN_URL } from '../config/constants'
import { NPM_REGISTRY_URL } from '../config/constants'
import { fetchExactPackageManifest } from './jsdelivr-registry'

export interface PackageMetadata {
description: string
Expand Down Expand Up @@ -29,43 +29,80 @@ export interface PackageMetadata {
export class ChangelogFetcher {
private cache: Map<string, PackageMetadata> = new Map()
private failureCache: Set<string> = new Set() // Track packages that failed to fetch
private inFlight: Map<string, Promise<PackageMetadata | null>> = new Map()

private getCacheKey(packageName: string, version?: string): string {
return `${packageName}@${version?.trim() || 'latest'}`
}

/**
* Fetch package metadata from npm registry
* Uses a cached approach to avoid repeated requests
*/
async fetchPackageMetadata(packageName: string): Promise<PackageMetadata | null> {
async fetchPackageMetadata(
packageName: string,
version?: string
): Promise<PackageMetadata | null> {
const cacheKey = this.getCacheKey(packageName, version)

// Check if we already have this in cache
if (this.cache.has(packageName)) {
return this.cache.get(packageName)!
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)!
}

// Check if we already failed to fetch this
if (this.failureCache.has(packageName)) {
if (this.failureCache.has(cacheKey)) {
return null
}

const inFlight = this.inFlight.get(cacheKey)
if (inFlight) {
return await inFlight
}

const lookupPromise = this.fetchAndCachePackageMetadata(packageName, version).finally(() => {
this.inFlight.delete(cacheKey)
})
this.inFlight.set(cacheKey, lookupPromise)
return await lookupPromise
}

private async fetchAndCachePackageMetadata(
packageName: string,
version?: string
): Promise<PackageMetadata | null> {
const cacheKey = this.getCacheKey(packageName, version)

try {
// Fetch from npm registry
const response = await this.fetchFromRegistry(packageName)
const response = await this.fetchPackageManifest(packageName, version)

if (!response) {
this.failureCache.add(packageName)
this.failureCache.add(cacheKey)
return null
}

const repositoryUrl = this.extractRepositoryUrl(response.repository?.url || '')
const repository = response.repository as { url?: string; type?: string } | undefined
const bugs = response.bugs as { url?: string } | undefined
const keywords = Array.isArray(response.keywords) ? (response.keywords as string[]) : []
const author =
typeof response.author === 'object' && response.author !== null
? ((response.author as { name?: string }).name ?? response.author)
: response.author
const repositoryUrl = this.extractRepositoryUrl(repository?.url || '')
const npmUrl = `https://www.npmjs.com/package/${encodeURIComponent(packageName)}`
const issuesUrl = repositoryUrl ? `${repositoryUrl}/issues` : undefined

const metadata: PackageMetadata = {
description: response.description || 'No description available',
homepage: response.homepage,
repository: response.repository,
bugs: response.bugs,
keywords: response.keywords || [],
author: response.author?.name || response.author,
license: response.license,
description:
typeof response.description === 'string' && response.description
? response.description
: 'No description available',
homepage: typeof response.homepage === 'string' ? response.homepage : undefined,
repository,
bugs,
keywords,
author: typeof author === 'string' ? author : undefined,
license: typeof response.license === 'string' ? response.license : undefined,
repositoryUrl,
npmUrl,
issuesUrl,
Expand All @@ -86,24 +123,34 @@ export class ChangelogFetcher {
// Ignore download stats errors - optional data
}

this.cache.set(packageName, metadata)
this.cache.set(cacheKey, metadata)
return metadata
} catch (error) {
} catch {
// Cache the failure to avoid retrying
this.failureCache.add(packageName)
this.failureCache.add(cacheKey)
return null
}
}

/**
* Fetch data from jsdelivr CDN
* Returns the package data by fetching package.json directly from jsdelivr
* Fetch metadata from a lightweight manifest endpoint.
*/
private async fetchFromRegistry(packageName: string): Promise<any> {
private async fetchPackageManifest(
packageName: string,
version?: string
): Promise<Record<string, unknown> | null> {
try {
// Fetch package.json directly from jsdelivr CDN (resolves to latest automatically)
const normalizedVersion = version?.trim()
if (normalizedVersion) {
const jsdelivrManifest = await fetchExactPackageManifest(packageName, normalizedVersion)
if (jsdelivrManifest) {
return jsdelivrManifest
}
}

const npmPath = normalizedVersion ? normalizedVersion : 'latest'
const response = await fetch(
`${JSDELIVR_CDN_URL}/${encodeURIComponent(packageName)}@latest/package.json`,
`${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/${encodeURIComponent(npmPath)}`,
{
method: 'GET',
headers: {
Expand All @@ -116,17 +163,7 @@ export class ChangelogFetcher {
return null
}

const pkgData = (await response.json()) as Record<string, unknown>

return {
description: pkgData.description,
homepage: pkgData.homepage as string | undefined,
repository: pkgData.repository as any,
bugs: pkgData.bugs as any,
keywords: (pkgData.keywords || []) as string[],
author: pkgData.author as any,
license: pkgData.license as string | undefined,
}
return (await response.json()) as Record<string, unknown>
} catch {
return null
}
Expand Down Expand Up @@ -188,7 +225,10 @@ export class ChangelogFetcher {
* Get repository release URL for a package
*/
getRepositoryReleaseUrl(packageName: string, version: string): string | null {
const metadata = this.cache.get(packageName)
const metadata =
this.cache.get(this.getCacheKey(packageName, version)) ??
this.cache.get(this.getCacheKey(packageName)) ??
this.cache.get(packageName)
if (!metadata || !metadata.releaseNotes) {
return null
}
Expand Down Expand Up @@ -240,6 +280,7 @@ export class ChangelogFetcher {
clearCache(): void {
this.cache.clear()
this.failureCache.clear()
this.inFlight.clear()
}
}

Expand Down
2 changes: 0 additions & 2 deletions src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,3 @@ export * from './npm-registry'
export * from './jsdelivr-registry'
export * from './changelog-fetcher'
export * from './version-checker'
export * from './persistent-cache'
export * from './cache-manager'
Loading
Loading