Skip to content

Commit 1ffdc05

Browse files
authored
feat: detect and display install scripts (#82)
1 parent d193569 commit 1ffdc05

File tree

7 files changed

+369
-26
lines changed

7 files changed

+369
-26
lines changed

app/components/PackageDependencies.vue

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
<script setup lang="ts">
2-
import { useOutdatedDependencies, getOutdatedTooltip } from '~/composables/useNpmRegistry'
3-
import type { OutdatedDependencyInfo } from '~/composables/useNpmRegistry'
4-
52
const props = defineProps<{
63
packageName: string
74
dependencies?: Record<string, string>
@@ -13,20 +10,6 @@ const props = defineProps<{
1310
// Fetch outdated info for dependencies
1411
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
1512
16-
/**
17-
* Get CSS class for a dependency version based on outdated status
18-
*/
19-
function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
20-
if (!info) return 'text-fg-subtle'
21-
22-
// Red for major versions behind
23-
if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
24-
// Orange for minor versions behind
25-
if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
26-
// Yellow for patch versions behind
27-
return 'text-yellow-500 cursor-help'
28-
}
29-
3013
// Expanded state for each section
3114
const depsExpanded = ref(false)
3215
const peerDepsExpanded = ref(false)
@@ -89,7 +72,7 @@ const sortedOptionalDependencies = computed(() => {
8972
:title="getOutdatedTooltip(outdatedDeps[dep])"
9073
aria-hidden="true"
9174
>
92-
<span class="i-carbon-warning-alt w-3 h-3" />
75+
<span class="i-carbon-warning-alt w-3 h-3 block" />
9376
</span>
9477
<NuxtLink
9578
:to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }"
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
packageName: string
4+
installScripts: {
5+
scripts: ('preinstall' | 'install' | 'postinstall')[]
6+
content?: Record<string, string>
7+
npxDependencies: Record<string, string>
8+
}
9+
}>()
10+
11+
const outdatedNpxDeps = useOutdatedDependencies(() => props.installScripts.npxDependencies)
12+
const hasNpxDeps = computed(() => Object.keys(props.installScripts.npxDependencies).length > 0)
13+
const sortedNpxDeps = computed(() => {
14+
return Object.entries(props.installScripts.npxDependencies).sort(([a], [b]) => a.localeCompare(b))
15+
})
16+
17+
const isExpanded = ref(false)
18+
</script>
19+
20+
<template>
21+
<section aria-labelledby="install-scripts-heading">
22+
<h2
23+
id="install-scripts-heading"
24+
class="text-xs text-fg-subtle uppercase tracking-wider mb-3 flex items-center gap-2"
25+
>
26+
<span class="i-carbon-warning-alt w-3 h-3 text-yellow-500" aria-hidden="true" />
27+
Install Scripts
28+
</h2>
29+
30+
<!-- Script list: name as label, content below -->
31+
<dl class="space-y-2 m-0">
32+
<div v-for="scriptName in installScripts.scripts" :key="scriptName">
33+
<dt class="font-mono text-xs text-fg-muted">{{ scriptName }}</dt>
34+
<dd
35+
tabindex="0"
36+
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"
37+
:title="installScripts.content?.[scriptName]"
38+
>
39+
{{ installScripts.content?.[scriptName] || '(script)' }}
40+
</dd>
41+
</div>
42+
</dl>
43+
44+
<!-- npx packages (expandable) -->
45+
<div v-if="hasNpxDeps" class="mt-3">
46+
<button
47+
type="button"
48+
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"
49+
:aria-expanded="isExpanded"
50+
aria-controls="npx-packages-details"
51+
@click="isExpanded = !isExpanded"
52+
>
53+
<span
54+
class="i-carbon-chevron-right w-3 h-3 transition-transform duration-200"
55+
:class="{ 'rotate-90': isExpanded }"
56+
aria-hidden="true"
57+
/>
58+
{{ sortedNpxDeps.length }} npx package{{ sortedNpxDeps.length !== 1 ? 's' : '' }}
59+
</button>
60+
61+
<ul
62+
v-show="isExpanded"
63+
id="npx-packages-details"
64+
class="mt-2 space-y-1 list-none m-0 p-0 pl-4"
65+
>
66+
<li
67+
v-for="[dep, version] in sortedNpxDeps"
68+
:key="dep"
69+
class="flex items-center justify-between py-0.5 text-sm gap-2"
70+
>
71+
<NuxtLink
72+
:to="{ name: 'package', params: { package: dep.split('/') } }"
73+
class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0"
74+
>
75+
{{ dep }}
76+
</NuxtLink>
77+
<span class="flex items-center gap-1">
78+
<span
79+
v-if="
80+
outdatedNpxDeps[dep] &&
81+
outdatedNpxDeps[dep].resolved !== outdatedNpxDeps[dep].latest
82+
"
83+
class="shrink-0"
84+
:class="getVersionClass(outdatedNpxDeps[dep])"
85+
:title="getOutdatedTooltip(outdatedNpxDeps[dep])"
86+
aria-hidden="true"
87+
>
88+
<span class="i-carbon-warning-alt w-3 h-3 block" />
89+
</span>
90+
<span
91+
class="font-mono text-xs text-right truncate"
92+
:class="getVersionClass(outdatedNpxDeps[dep])"
93+
:title="
94+
outdatedNpxDeps[dep]
95+
? outdatedNpxDeps[dep].resolved === outdatedNpxDeps[dep].latest
96+
? `currently ${outdatedNpxDeps[dep].latest}`
97+
: getOutdatedTooltip(outdatedNpxDeps[dep])
98+
: version
99+
"
100+
>
101+
{{ version }}
102+
</span>
103+
</span>
104+
</li>
105+
</ul>
106+
</div>
107+
</section>
108+
</template>

app/composables/useNpmRegistry.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import type { ReleaseType } from 'semver'
1212
import { maxSatisfying, prerelease, major, minor, diff, gt } from 'semver'
1313
import { compareVersions, isExactVersion } from '~/utils/versions'
14+
import { extractInstallScriptsInfo } from '~/utils/install-scripts'
1415

1516
const NPM_REGISTRY = 'https://registry.npmjs.org'
1617
const NPM_API = 'https://api.npmjs.org'
@@ -113,14 +114,21 @@ function transformPackument(pkg: Packument, requestedVersion?: string | null): S
113114
includedVersions.add(requestedVersion)
114115
}
115116

116-
// Build filtered versions object
117+
// Build filtered versions object with install scripts info per version
117118
const filteredVersions: Record<string, PackumentVersion> = {}
118119
for (const v of includedVersions) {
119120
const version = pkg.versions[v]
120121
if (version) {
121-
// Strip readme and scripts from each version to reduce size
122-
const { readme: _readme, scripts: _scripts, ...slimVersion } = version
123-
filteredVersions[v] = slimVersion as PackumentVersion
122+
// Strip readme from each version, extract install scripts info
123+
const { readme: _readme, scripts, ...slimVersion } = version
124+
125+
// Extract install scripts info (which scripts exist + npx deps)
126+
const installScripts = scripts ? extractInstallScriptsInfo(scripts) : null
127+
128+
filteredVersions[v] = {
129+
...slimVersion,
130+
installScripts: installScripts ?? undefined,
131+
} as PackumentVersion
124132
}
125133
}
126134

@@ -491,6 +499,20 @@ async function checkDependencyOutdated(
491499
const packument = await fetchCachedPackument(packageName)
492500
if (!packument) return null
493501

502+
const latestTag = packument['dist-tags']?.latest
503+
if (!latestTag) return null
504+
505+
// Handle "latest" constraint specially - return info with current version
506+
if (constraint === 'latest') {
507+
return {
508+
resolved: latestTag,
509+
latest: latestTag,
510+
majorsBehind: 0,
511+
minorsBehind: 0,
512+
diffType: null,
513+
}
514+
}
515+
494516
let versions = Object.keys(packument.versions)
495517
const includesPrerelease = constraintIncludesPrerelease(constraint)
496518

@@ -501,8 +523,7 @@ async function checkDependencyOutdated(
501523
const resolved = maxSatisfying(versions, constraint)
502524
if (!resolved) return null
503525

504-
const latestTag = packument['dist-tags']?.latest
505-
if (!latestTag || resolved === latestTag) return null
526+
if (resolved === latestTag) return null
506527

507528
// If resolved version is newer than latest, not outdated
508529
// (e.g., using ^2.0.0-rc when latest is 1.x)
@@ -586,3 +607,20 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string {
586607
}
587608
return `Patch update available (latest: ${info.latest})`
588609
}
610+
611+
/**
612+
* Get CSS class for a dependency version based on outdated status
613+
*/
614+
export function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
615+
if (!info) return 'text-fg-subtle'
616+
// Green for up-to-date (e.g. "latest" constraint)
617+
if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) {
618+
return 'text-green-500 cursor-help'
619+
}
620+
// Red for major versions behind
621+
if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
622+
// Orange for minor versions behind
623+
if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
624+
// Yellow for patch versions behind
625+
return 'text-yellow-500 cursor-help'
626+
}

app/pages/[...package].vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,13 @@ defineOgImageComponent('Package', {
876876
:time="pkg.time"
877877
/>
878878

879+
<!-- Install Scripts Warning -->
880+
<PackageInstallScripts
881+
v-if="displayVersion?.installScripts"
882+
:package-name="pkg.name"
883+
:install-scripts="displayVersion.installScripts"
884+
/>
885+
879886
<!-- Dependencies -->
880887
<PackageDependencies
881888
v-if="hasDependencies"

app/utils/install-scripts.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Utilities for detecting install scripts in package.json.
3+
*
4+
* Install scripts (preinstall, install, postinstall) run automatically
5+
* when a package is installed as a dependency - important for security awareness.
6+
*
7+
* Also extracts npx package calls from those scripts.
8+
*/
9+
10+
import type { InstallScriptsInfo } from '#shared/types'
11+
12+
// Scripts that run when installing a package as a dependency
13+
const INSTALL_SCRIPTS = new Set(['preinstall', 'install', 'postinstall'])
14+
15+
// Pattern to match npx commands with various flags
16+
// Captures the package name (with optional scope and version)
17+
const NPX_PATTERN = /\bnpx\s+(?:--?\w+(?:=\S+)?\s+)*(@?[\w.-]+(?:\/[\w.-]+)?(?:@[\w.^~<>=|-]+)?)/g
18+
19+
// Pattern to extract package name and version from captured group
20+
const PACKAGE_VERSION_PATTERN = /^(@[\w.-]+\/[\w.-]+|[\w.-]+)(?:@(.+))?$/
21+
22+
/**
23+
* Extract packages from npx calls in install scripts.
24+
* Only considers preinstall, install, postinstall - scripts that run for end-users.
25+
*
26+
* @param scripts - The scripts object from package.json
27+
* @returns Record of package name to version (or "latest" if none specified)
28+
*/
29+
export function extractNpxDependencies(
30+
scripts: Record<string, string> | undefined,
31+
): Record<string, string> {
32+
if (!scripts) return {}
33+
34+
const npxPackages: Record<string, string> = {}
35+
36+
for (const [scriptName, script] of Object.entries(scripts)) {
37+
// Only check scripts that run during installation
38+
if (!INSTALL_SCRIPTS.has(scriptName)) continue
39+
// Reset regex state
40+
NPX_PATTERN.lastIndex = 0
41+
42+
let match: RegExpExecArray | null
43+
while ((match = NPX_PATTERN.exec(script)) !== null) {
44+
const captured = match[1]
45+
if (!captured) continue
46+
47+
// Extract package name and version
48+
const parsed = PACKAGE_VERSION_PATTERN.exec(captured)
49+
if (parsed && parsed[1]) {
50+
const packageName = parsed[1]
51+
const version = parsed[2] || 'latest'
52+
53+
// Skip common built-in commands that aren't packages
54+
if (isBuiltinCommand(packageName)) continue
55+
56+
// Only add if not already present (first occurrence wins)
57+
if (!(packageName in npxPackages)) {
58+
npxPackages[packageName] = version
59+
}
60+
}
61+
}
62+
}
63+
64+
return npxPackages
65+
}
66+
67+
/**
68+
* Check if a command is a built-in/common command that isn't an npm package
69+
*/
70+
function isBuiltinCommand(name: string): boolean {
71+
const builtins = new Set([
72+
// Common shell commands that might be mistakenly captured
73+
'env',
74+
'node',
75+
'npm',
76+
'yarn',
77+
'pnpm',
78+
// npx flags that might look like packages
79+
'yes',
80+
'no',
81+
'quiet',
82+
'shell',
83+
])
84+
return builtins.has(name)
85+
}
86+
87+
/**
88+
* Extract install script information from package.json scripts.
89+
* Returns info about which install scripts exist and any npx packages they call.
90+
*
91+
* @param scripts - The scripts object from package.json
92+
* @returns Info about install scripts and npx dependencies, or null if no install scripts
93+
*/
94+
export function extractInstallScriptsInfo(
95+
scripts: Record<string, string> | undefined,
96+
): InstallScriptsInfo | null {
97+
if (!scripts) return null
98+
99+
const presentScripts: ('preinstall' | 'install' | 'postinstall')[] = []
100+
const content: Record<string, string> = {}
101+
102+
for (const scriptName of INSTALL_SCRIPTS) {
103+
if (scripts[scriptName]) {
104+
presentScripts.push(scriptName as 'preinstall' | 'install' | 'postinstall')
105+
content[scriptName] = scripts[scriptName]
106+
}
107+
}
108+
109+
if (presentScripts.length === 0) return null
110+
111+
return {
112+
scripts: presentScripts,
113+
content,
114+
npxDependencies: extractNpxDependencies(scripts),
115+
}
116+
}

0 commit comments

Comments
 (0)