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
19 changes: 1 addition & 18 deletions app/components/PackageDependencies.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
<script setup lang="ts">
import { useOutdatedDependencies, getOutdatedTooltip } from '~/composables/useNpmRegistry'
import type { OutdatedDependencyInfo } from '~/composables/useNpmRegistry'

const props = defineProps<{
packageName: string
dependencies?: Record<string, string>
Expand All @@ -13,20 +10,6 @@ const props = defineProps<{
// Fetch outdated info for dependencies
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)

/**
* Get CSS class for a dependency version based on outdated status
*/
function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
if (!info) return 'text-fg-subtle'

// 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'
}

// Expanded state for each section
const depsExpanded = ref(false)
const peerDepsExpanded = ref(false)
Expand Down Expand Up @@ -89,7 +72,7 @@ const sortedOptionalDependencies = computed(() => {
:title="getOutdatedTooltip(outdatedDeps[dep])"
aria-hidden="true"
>
<span class="i-carbon-warning-alt w-3 h-3" />
<span class="i-carbon-warning-alt w-3 h-3 block" />
</span>
<NuxtLink
:to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }"
Expand Down
108 changes: 108 additions & 0 deletions app/components/PackageInstallScripts.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
installScripts: {
scripts: ('preinstall' | 'install' | 'postinstall')[]
content?: Record<string, string>
npxDependencies: Record<string, string>
}
}>()

const outdatedNpxDeps = useOutdatedDependencies(() => props.installScripts.npxDependencies)
const hasNpxDeps = computed(() => Object.keys(props.installScripts.npxDependencies).length > 0)
const sortedNpxDeps = computed(() => {
return Object.entries(props.installScripts.npxDependencies).sort(([a], [b]) => a.localeCompare(b))
})

const isExpanded = ref(false)
</script>

<template>
<section aria-labelledby="install-scripts-heading">
<h2
id="install-scripts-heading"
class="text-xs text-fg-subtle uppercase tracking-wider mb-3 flex items-center gap-2"
>
<span class="i-carbon-warning-alt w-3 h-3 text-yellow-500" aria-hidden="true" />
Install Scripts
</h2>

<!-- Script list: name as label, content below -->
<dl class="space-y-2 m-0">
<div v-for="scriptName in installScripts.scripts" :key="scriptName">
<dt class="font-mono text-xs text-fg-muted">{{ scriptName }}</dt>
<dd
tabindex="0"
class="font-mono text-sm text-fg-subtle m-0 truncate focus:whitespace-normal focus:overflow-visible cursor-help rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
:title="installScripts.content?.[scriptName]"
>
{{ installScripts.content?.[scriptName] || '(script)' }}
</dd>
</div>
</dl>

<!-- npx packages (expandable) -->
<div v-if="hasNpxDeps" class="mt-3">
<button
type="button"
class="flex items-center gap-1.5 text-xs text-fg-muted hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
:aria-expanded="isExpanded"
aria-controls="npx-packages-details"
@click="isExpanded = !isExpanded"
>
<span
class="i-carbon-chevron-right w-3 h-3 transition-transform duration-200"
:class="{ 'rotate-90': isExpanded }"
aria-hidden="true"
/>
{{ sortedNpxDeps.length }} npx package{{ sortedNpxDeps.length !== 1 ? 's' : '' }}
</button>

<ul
v-show="isExpanded"
id="npx-packages-details"
class="mt-2 space-y-1 list-none m-0 p-0 pl-4"
>
<li
v-for="[dep, version] in sortedNpxDeps"
:key="dep"
class="flex items-center justify-between py-0.5 text-sm gap-2"
>
<NuxtLink
:to="{ name: 'package', params: { package: dep.split('/') } }"
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0"
>
{{ dep }}
</NuxtLink>
<span class="flex items-center gap-1">
<span
v-if="
outdatedNpxDeps[dep] &&
outdatedNpxDeps[dep].resolved !== outdatedNpxDeps[dep].latest
"
class="shrink-0"
:class="getVersionClass(outdatedNpxDeps[dep])"
:title="getOutdatedTooltip(outdatedNpxDeps[dep])"
aria-hidden="true"
>
<span class="i-carbon-warning-alt w-3 h-3 block" />
</span>
<span
class="font-mono text-xs text-right truncate"
:class="getVersionClass(outdatedNpxDeps[dep])"
:title="
outdatedNpxDeps[dep]
? outdatedNpxDeps[dep].resolved === outdatedNpxDeps[dep].latest
? `currently ${outdatedNpxDeps[dep].latest}`
: getOutdatedTooltip(outdatedNpxDeps[dep])
: version
"
>
{{ version }}
</span>
</span>
</li>
</ul>
</div>
</section>
</template>
50 changes: 44 additions & 6 deletions app/composables/useNpmRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, PackumentVersion> = {}
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
}
}

Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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'
}
7 changes: 7 additions & 0 deletions app/pages/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,13 @@ defineOgImageComponent('Package', {
:time="pkg.time"
/>

<!-- Install Scripts Warning -->
<PackageInstallScripts
v-if="displayVersion?.installScripts"
:package-name="pkg.name"
:install-scripts="displayVersion.installScripts"
/>

<!-- Dependencies -->
<PackageDependencies
v-if="hasDependencies"
Expand Down
116 changes: 116 additions & 0 deletions app/utils/install-scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Utilities for detecting install scripts in package.json.
*
* Install scripts (preinstall, install, postinstall) run automatically
* when a package is installed as a dependency - important for security awareness.
*
* Also extracts npx package calls from those scripts.
*/

import type { InstallScriptsInfo } from '#shared/types'

// Scripts that run when installing a package as a dependency
const INSTALL_SCRIPTS = new Set(['preinstall', 'install', 'postinstall'])

// Pattern to match npx commands with various flags
// Captures the package name (with optional scope and version)
const NPX_PATTERN = /\bnpx\s+(?:--?\w+(?:=\S+)?\s+)*(@?[\w.-]+(?:\/[\w.-]+)?(?:@[\w.^~<>=|-]+)?)/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<string, string> | undefined,
): Record<string, string> {
if (!scripts) return {}

const npxPackages: Record<string, string> = {}

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<string, string> | undefined,
): InstallScriptsInfo | null {
if (!scripts) return null

const presentScripts: ('preinstall' | 'install' | 'postinstall')[] = []
const content: Record<string, string> = {}

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),
}
}
Loading