@@ -6,16 +6,40 @@ import type {
66 NpmSearchResult ,
77 NpmDownloadCount ,
88 NpmPerson ,
9+ PackageVersionInfo ,
910} from '#shared/types'
11+ import type { ReleaseType } from 'semver'
12+ import { maxSatisfying , prerelease , major , minor , diff , gt } from 'semver'
13+ import { compareVersions } from '~/utils/versions'
1014
1115const NPM_REGISTRY = 'https://registry.npmjs.org'
1216const NPM_API = 'https://api.npmjs.org'
1317
18+ // Cache for packument fetches to avoid duplicate requests across components
19+ const packumentCache = new Map < string , Promise < Packument | null > > ( )
20+
21+ /**
22+ * Fetch a package's full packument data.
23+ * Uses caching to avoid duplicate requests.
24+ */
1425async function fetchNpmPackage ( name : string ) : Promise < Packument > {
1526 const encodedName = encodePackageName ( name )
1627 return await $fetch < Packument > ( `${ NPM_REGISTRY } /${ encodedName } ` )
1728}
1829
30+ /**
31+ * Fetch a package's packument with caching (returns null on error).
32+ * This is useful for batch operations where some packages might not exist.
33+ */
34+ async function fetchCachedPackument ( name : string ) : Promise < Packument | null > {
35+ const cached = packumentCache . get ( name )
36+ if ( cached ) return cached
37+
38+ const promise = fetchNpmPackage ( name ) . catch ( ( ) => null )
39+ packumentCache . set ( name , promise )
40+ return promise
41+ }
42+
1943async function searchNpmPackages (
2044 query : string ,
2145 options : {
@@ -45,7 +69,11 @@ async function fetchNpmDownloads(
4569 return await $fetch < NpmDownloadCount > ( `${ NPM_API } /downloads/point/${ period } /${ encodedName } ` )
4670}
4771
48- function encodePackageName ( name : string ) : string {
72+ /**
73+ * Encode a package name for use in npm registry URLs.
74+ * Handles scoped packages (e.g., @scope/name -> @scope%2Fname).
75+ */
76+ export function encodePackageName ( name : string ) : string {
4977 if ( name . startsWith ( '@' ) ) {
5078 return `@${ encodeURIComponent ( name . slice ( 1 ) ) } `
5179 }
@@ -326,3 +354,198 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
326354 { default : ( ) => emptySearchResponse } ,
327355 )
328356}
357+
358+ // ============================================================================
359+ // Package Versions
360+ // ============================================================================
361+
362+ // Cache for full version lists
363+ const allVersionsCache = new Map < string , Promise < PackageVersionInfo [ ] > > ( )
364+
365+ /**
366+ * Fetch all versions of a package from the npm registry.
367+ * Returns version info sorted by version (newest first).
368+ * Results are cached to avoid duplicate requests.
369+ */
370+ export async function fetchAllPackageVersions ( packageName : string ) : Promise < PackageVersionInfo [ ] > {
371+ const cached = allVersionsCache . get ( packageName )
372+ if ( cached ) return cached
373+
374+ const promise = ( async ( ) => {
375+ const encodedName = encodePackageName ( packageName )
376+ const data = await $fetch < { versions : Record < string , unknown > ; time : Record < string , string > } > (
377+ `${ NPM_REGISTRY } /${ encodedName } ` ,
378+ )
379+
380+ return Object . keys ( data . versions )
381+ . filter ( v => data . time [ v ] )
382+ . map ( version => ( {
383+ version,
384+ time : data . time [ version ] ,
385+ hasProvenance : false , // Would need to check dist.attestations for each version
386+ } ) )
387+ . sort ( ( a , b ) => compareVersions ( b . version , a . version ) )
388+ } ) ( )
389+
390+ allVersionsCache . set ( packageName , promise )
391+ return promise
392+ }
393+
394+ // ============================================================================
395+ // Outdated Dependencies
396+ // ============================================================================
397+
398+ /** Information about an outdated dependency */
399+ export interface OutdatedDependencyInfo {
400+ /** The resolved version that satisfies the constraint */
401+ resolved : string
402+ /** The latest available version */
403+ latest : string
404+ /** How many major versions behind */
405+ majorsBehind : number
406+ /** How many minor versions behind (when same major) */
407+ minorsBehind : number
408+ /** The type of version difference */
409+ diffType : ReleaseType | null
410+ }
411+
412+ /**
413+ * Check if a version constraint explicitly includes a prerelease tag.
414+ * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
415+ */
416+ function constraintIncludesPrerelease ( constraint : string ) : boolean {
417+ return (
418+ / - ( a l p h a | b e t a | r c | n e x t | c a n a r y | d e v | p r e v i e w | p r e | e x p e r i m e n t a l ) / i. test ( constraint ) ||
419+ / - \d / . test ( constraint )
420+ )
421+ }
422+
423+ /**
424+ * Check if a constraint is a non-semver value (git URL, file path, etc.)
425+ */
426+ function isNonSemverConstraint ( constraint : string ) : boolean {
427+ return (
428+ constraint . startsWith ( 'git' ) ||
429+ constraint . startsWith ( 'http' ) ||
430+ constraint . startsWith ( 'file:' ) ||
431+ constraint . startsWith ( 'npm:' ) ||
432+ constraint . startsWith ( 'link:' ) ||
433+ constraint . startsWith ( 'workspace:' ) ||
434+ constraint . includes ( '/' )
435+ )
436+ }
437+
438+ /**
439+ * Check if a dependency is outdated.
440+ * Returns null if up-to-date or if we can't determine.
441+ *
442+ * A dependency is only considered "outdated" if the resolved version
443+ * is older than the latest version. If the resolved version is newer
444+ * (e.g., using ^2.0.0-rc when latest is 1.x), it's not outdated.
445+ */
446+ async function checkDependencyOutdated (
447+ packageName : string ,
448+ constraint : string ,
449+ ) : Promise < OutdatedDependencyInfo | null > {
450+ if ( isNonSemverConstraint ( constraint ) ) {
451+ return null
452+ }
453+
454+ const packument = await fetchCachedPackument ( packageName )
455+ if ( ! packument ) return null
456+
457+ let versions = Object . keys ( packument . versions )
458+ const includesPrerelease = constraintIncludesPrerelease ( constraint )
459+
460+ if ( ! includesPrerelease ) {
461+ versions = versions . filter ( v => ! prerelease ( v ) )
462+ }
463+
464+ const resolved = maxSatisfying ( versions , constraint )
465+ if ( ! resolved ) return null
466+
467+ const latestTag = packument [ 'dist-tags' ] ?. latest
468+ if ( ! latestTag || resolved === latestTag ) return null
469+
470+ // If resolved version is newer than latest, not outdated
471+ // (e.g., using ^2.0.0-rc when latest is 1.x)
472+ if ( gt ( resolved , latestTag ) ) {
473+ return null
474+ }
475+
476+ const diffType = diff ( resolved , latestTag )
477+ const majorsBehind = major ( latestTag ) - major ( resolved )
478+ const minorsBehind = majorsBehind === 0 ? minor ( latestTag ) - minor ( resolved ) : 0
479+
480+ return {
481+ resolved,
482+ latest : latestTag ,
483+ majorsBehind,
484+ minorsBehind,
485+ diffType,
486+ }
487+ }
488+
489+ /**
490+ * Composable to check for outdated dependencies.
491+ * Returns a reactive map of dependency name to outdated info.
492+ */
493+ export function useOutdatedDependencies (
494+ dependencies : MaybeRefOrGetter < Record < string , string > | undefined > ,
495+ ) {
496+ const outdated = ref < Record < string , OutdatedDependencyInfo > > ( { } )
497+
498+ async function fetchOutdatedInfo ( deps : Record < string , string > | undefined ) {
499+ if ( ! deps || Object . keys ( deps ) . length === 0 ) {
500+ outdated . value = { }
501+ return
502+ }
503+
504+ const results : Record < string , OutdatedDependencyInfo > = { }
505+ const entries = Object . entries ( deps )
506+ const batchSize = 5
507+
508+ for ( let i = 0 ; i < entries . length ; i += batchSize ) {
509+ const batch = entries . slice ( i , i + batchSize )
510+ const batchResults = await Promise . all (
511+ batch . map ( async ( [ name , constraint ] ) => {
512+ const info = await checkDependencyOutdated ( name , constraint )
513+ return [ name , info ] as const
514+ } ) ,
515+ )
516+
517+ for ( const [ name , info ] of batchResults ) {
518+ if ( info ) {
519+ results [ name ] = info
520+ }
521+ }
522+ }
523+
524+ outdated . value = results
525+ }
526+
527+ watch (
528+ ( ) => toValue ( dependencies ) ,
529+ deps => {
530+ fetchOutdatedInfo ( deps )
531+ } ,
532+ { immediate : true } ,
533+ )
534+
535+ return outdated
536+ }
537+
538+ /**
539+ * Get tooltip text for an outdated dependency
540+ */
541+ export function getOutdatedTooltip ( info : OutdatedDependencyInfo ) : string {
542+ if ( info . majorsBehind > 0 ) {
543+ const s = info . majorsBehind === 1 ? '' : 's'
544+ return `${ info . majorsBehind } major version${ s } behind (latest: ${ info . latest } )`
545+ }
546+ if ( info . minorsBehind > 0 ) {
547+ const s = info . minorsBehind === 1 ? '' : 's'
548+ return `${ info . minorsBehind } minor version${ s } behind (latest: ${ info . latest } )`
549+ }
550+ return `Patch update available (latest: ${ info . latest } )`
551+ }
0 commit comments