diff --git a/app/components/PackageDependencies.vue b/app/components/PackageDependencies.vue index ca9d6e0900..5e17ce623e 100644 --- a/app/components/PackageDependencies.vue +++ b/app/components/PackageDependencies.vue @@ -1,7 +1,4 @@ + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index 6b78f4d1a9..22c016171f 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -11,6 +11,7 @@ import type { import type { ReleaseType } from 'semver' import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver' import { compareVersions, isExactVersion } from '~/utils/versions' +import { extractInstallScriptsInfo } from '~/utils/install-scripts' const NPM_REGISTRY = 'https://registry.npmjs.org' const NPM_API = 'https://api.npmjs.org' @@ -113,14 +114,21 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S includedVersions.add(requestedVersion) } - // Build filtered versions object + // Build filtered versions object with install scripts info per version const filteredVersions: Record = {} for (const v of includedVersions) { const version = pkg.versions[v] if (version) { - // Strip readme and scripts from each version to reduce size - const { readme: _readme, scripts: _scripts, ...slimVersion } = version - filteredVersions[v] = slimVersion as PackumentVersion + // Strip readme from each version, extract install scripts info + const { readme: _readme, scripts, ...slimVersion } = version + + // Extract install scripts info (which scripts exist + npx deps) + const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null + + filteredVersions[v] = { + ...slimVersion, + installScripts: installScripts ?? undefined, + } as PackumentVersion } } @@ -491,6 +499,20 @@ async function checkDependencyOutdated( const packument = await fetchCachedPackument(packageName) if (!packument) return null + const latestTag = packument['dist-tags']?.latest + if (!latestTag) return null + + // Handle "latest" constraint specially - return info with current version + if (constraint === 'latest') { + return { + resolved: latestTag, + latest: latestTag, + majorsBehind: 0, + minorsBehind: 0, + diffType: null, + } + } + let versions = Object.keys(packument.versions) const includesPrerelease = constraintIncludesPrerelease(constraint) @@ -501,8 +523,7 @@ async function checkDependencyOutdated( const resolved = maxSatisfying(versions, constraint) if (!resolved) return null - const latestTag = packument['dist-tags']?.latest - if (!latestTag || resolved === latestTag) return null + if (resolved === latestTag) return null // If resolved version is newer than latest, not outdated // (e.g., using ^2.0.0-rc when latest is 1.x) @@ -586,3 +607,20 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string { } return `Patch update available (latest: ${info.latest})` } + +/** + * Get CSS class for a dependency version based on outdated status + */ +export function getVersionClass(info: OutdatedDependencyInfo | undefined): string { + if (!info) return 'text-fg-subtle' + // Green for up-to-date (e.g. "latest" constraint) + if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) { + return 'text-green-500 cursor-help' + } + // Red for major versions behind + if (info.majorsBehind > 0) return 'text-red-500 cursor-help' + // Orange for minor versions behind + if (info.minorsBehind > 0) return 'text-orange-500 cursor-help' + // Yellow for patch versions behind + return 'text-yellow-500 cursor-help' +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 3799b95466..dbe71d1747 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -876,6 +876,13 @@ defineOgImageComponent('Package', { :time="pkg.time" /> + + + =|-]+)?)/g + +// Pattern to extract package name and version from captured group +const PACKAGE_VERSION_PATTERN = /^(@[\w.-]+\/[\w.-]+|[\w.-]+)(?:@(.+))?$/ + +/** + * Extract packages from npx calls in install scripts. + * Only considers preinstall, install, postinstall - scripts that run for end-users. + * + * @param scripts - The scripts object from package.json + * @returns Record of package name to version (or "latest" if none specified) + */ +export function extractNpxDependencies( + scripts: Record | undefined, +): Record { + if (!scripts) return {} + + const npxPackages: Record = {} + + for (const [scriptName, script] of Object.entries(scripts)) { + // Only check scripts that run during installation + if (!INSTALL_SCRIPTS.has(scriptName)) continue + // Reset regex state + NPX_PATTERN.lastIndex = 0 + + let match: RegExpExecArray | null + while ((match = NPX_PATTERN.exec(script)) !== null) { + const captured = match[1] + if (!captured) continue + + // Extract package name and version + const parsed = PACKAGE_VERSION_PATTERN.exec(captured) + if (parsed && parsed[1]) { + const packageName = parsed[1] + const version = parsed[2] || 'latest' + + // Skip common built-in commands that aren't packages + if (isBuiltinCommand(packageName)) continue + + // Only add if not already present (first occurrence wins) + if (!(packageName in npxPackages)) { + npxPackages[packageName] = version + } + } + } + } + + return npxPackages +} + +/** + * Check if a command is a built-in/common command that isn't an npm package + */ +function isBuiltinCommand(name: string): boolean { + const builtins = new Set([ + // Common shell commands that might be mistakenly captured + 'env', + 'node', + 'npm', + 'yarn', + 'pnpm', + // npx flags that might look like packages + 'yes', + 'no', + 'quiet', + 'shell', + ]) + return builtins.has(name) +} + +/** + * Extract install script information from package.json scripts. + * Returns info about which install scripts exist and any npx packages they call. + * + * @param scripts - The scripts object from package.json + * @returns Info about install scripts and npx dependencies, or null if no install scripts + */ +export function extractInstallScriptsInfo( + scripts: Record | undefined, +): InstallScriptsInfo | null { + if (!scripts) return null + + const presentScripts: ('preinstall' | 'install' | 'postinstall')[] = [] + const content: Record = {} + + for (const scriptName of INSTALL_SCRIPTS) { + if (scripts[scriptName]) { + presentScripts.push(scriptName as 'preinstall' | 'install' | 'postinstall') + content[scriptName] = scripts[scriptName] + } + } + + if (presentScripts.length === 0) return null + + return { + scripts: presentScripts, + content, + npxDependencies: extractNpxDependencies(scripts), + } +} diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts index 5dadc33633..9b38370281 100644 --- a/shared/types/npm-registry.ts +++ b/shared/types/npm-registry.ts @@ -6,6 +6,8 @@ * @see https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md */ +import type { PackumentVersion } from '@npm/types' + // Re-export official npm types for packument/manifest export type { Packument, @@ -15,6 +17,18 @@ export type { PackageJSON, } from '@npm/types' +/** Install scripts info (preinstall, install, postinstall) */ +export interface InstallScriptsInfo { + scripts: ('preinstall' | 'install' | 'postinstall')[] + content: Record + npxDependencies: Record +} + +/** PackumentVersion with additional install scripts info */ +export type SlimPackumentVersion = PackumentVersion & { + installScripts?: InstallScriptsInfo +} + /** * Slimmed down Packument for client-side use. * Strips unnecessary fields to reduce payload size. @@ -37,8 +51,8 @@ export interface SlimPackument { 'keywords'?: string[] 'repository'?: { type?: string; url?: string; directory?: string } 'bugs'?: { url?: string; email?: string } - /** Only includes dist-tag versions */ - 'versions': Record + /** Only includes dist-tag versions (with installScripts info added per version) */ + 'versions': Record } /** diff --git a/test/unit/install-scripts.spec.ts b/test/unit/install-scripts.spec.ts new file mode 100644 index 0000000000..019de47ec0 --- /dev/null +++ b/test/unit/install-scripts.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { extractInstallScriptsInfo } from '../../app/utils/install-scripts' + +describe('extractInstallScriptsInfo', () => { + it('returns null when no install scripts exist', () => { + expect(extractInstallScriptsInfo(undefined)).toBeNull() + expect(extractInstallScriptsInfo({})).toBeNull() + expect(extractInstallScriptsInfo({ build: 'vite build', test: 'vitest' })).toBeNull() + }) + + it('detects all install script types with content', () => { + const scripts = { + preinstall: 'node check.js', + install: 'node-gyp rebuild', + postinstall: 'node setup.js', + build: 'vite build', // should be ignored + } + const result = extractInstallScriptsInfo(scripts) + expect(result).toEqual({ + scripts: ['preinstall', 'install', 'postinstall'], + content: { + preinstall: 'node check.js', + install: 'node-gyp rebuild', + postinstall: 'node setup.js', + }, + npxDependencies: {}, + }) + }) + + it('extracts npx packages with versions and flags', () => { + const scripts = { + preinstall: 'npx only-allow pnpm', + postinstall: 'npx -y prisma@5.0.0 generate && npx --yes @scope/pkg db push', + } + const result = extractInstallScriptsInfo(scripts) + expect(result).toEqual({ + scripts: ['preinstall', 'postinstall'], + content: { + preinstall: 'npx only-allow pnpm', + postinstall: 'npx -y prisma@5.0.0 generate && npx --yes @scope/pkg db push', + }, + npxDependencies: { + 'only-allow': 'latest', + 'prisma': '5.0.0', + '@scope/pkg': 'latest', + }, + }) + }) + + it('ignores npx in non-install scripts and built-in commands', () => { + const scripts = { + prepare: 'npx husky install', // ignored - not install script + postinstall: 'npx node script.js', // node is filtered as builtin + } + const result = extractInstallScriptsInfo(scripts) + expect(result).toEqual({ + scripts: ['postinstall'], + content: { postinstall: 'npx node script.js' }, + npxDependencies: {}, + }) + }) + + it('extracts npx packages with dots in names', () => { + const scripts = { + postinstall: 'npx vue.js@3.0.0 && npx @scope/pkg.name generate', + } + const result = extractInstallScriptsInfo(scripts) + expect(result).toEqual({ + scripts: ['postinstall'], + content: { postinstall: 'npx vue.js@3.0.0 && npx @scope/pkg.name generate' }, + npxDependencies: { + 'vue.js': '3.0.0', + '@scope/pkg.name': 'latest', + }, + }) + }) +})