Skip to content

Commit a22bdd9

Browse files
committed
feat: implement preinstall script checks
1 parent 263651e commit a22bdd9

6 files changed

Lines changed: 294 additions & 26 deletions

File tree

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
<span
9578
class="font-mono text-xs text-right truncate"
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 } 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

@@ -460,6 +468,20 @@ async function checkDependencyOutdated(
460468
const packument = await fetchCachedPackument(packageName)
461469
if (!packument) return null
462470

471+
const latestTag = packument['dist-tags']?.latest
472+
if (!latestTag) return null
473+
474+
// Handle "latest" constraint specially - return info with current version
475+
if (constraint === 'latest') {
476+
return {
477+
resolved: latestTag,
478+
latest: latestTag,
479+
majorsBehind: 0,
480+
minorsBehind: 0,
481+
diffType: null,
482+
}
483+
}
484+
463485
let versions = Object.keys(packument.versions)
464486
const includesPrerelease = constraintIncludesPrerelease(constraint)
465487

@@ -470,8 +492,7 @@ async function checkDependencyOutdated(
470492
const resolved = maxSatisfying(versions, constraint)
471493
if (!resolved) return null
472494

473-
const latestTag = packument['dist-tags']?.latest
474-
if (!latestTag || resolved === latestTag) return null
495+
if (resolved === latestTag) return null
475496

476497
// If resolved version is newer than latest, not outdated
477498
// (e.g., using ^2.0.0-rc when latest is 1.x)
@@ -555,3 +576,20 @@ export function getOutdatedTooltip(info: OutdatedDependencyInfo): string {
555576
}
556577
return `Patch update available (latest: ${info.latest})`
557578
}
579+
580+
/**
581+
* Get CSS class for a dependency version based on outdated status
582+
*/
583+
export function getVersionClass(info: OutdatedDependencyInfo | undefined): string {
584+
if (!info) return 'text-fg-subtle'
585+
// Green for up-to-date (e.g. "latest" constraint)
586+
if (info.majorsBehind === 0 && info.minorsBehind === 0 && info.resolved === info.latest) {
587+
return 'text-green-500 cursor-help'
588+
}
589+
// Red for major versions behind
590+
if (info.majorsBehind > 0) return 'text-red-500 cursor-help'
591+
// Orange for minor versions behind
592+
if (info.minorsBehind > 0) return 'text-orange-500 cursor-help'
593+
// Yellow for patch versions behind
594+
return 'text-yellow-500 cursor-help'
595+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,13 @@ defineOgImageComponent('Package', {
738738
:time="pkg.time"
739739
/>
740740

741+
<!-- Install Scripts Warning -->
742+
<PackageInstallScripts
743+
v-if="displayVersion?.installScripts"
744+
:package-name="pkg.name"
745+
:install-scripts="displayVersion.installScripts"
746+
/>
747+
741748
<!-- Dependencies -->
742749
<PackageDependencies
743750
v-if="hasDependencies"

app/utils/install-scripts.ts

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

shared/types/npm-registry.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ export interface SlimPackument {
3737
'keywords'?: string[]
3838
'repository'?: { type?: string; url?: string; directory?: string }
3939
'bugs'?: { url?: string; email?: string }
40-
/** Only includes dist-tag versions */
41-
'versions': Record<string, import('@npm/types').PackumentVersion>
40+
/** Only includes dist-tag versions (with installScripts info added per version) */
41+
'versions': Record<
42+
string,
43+
import('@npm/types').PackumentVersion & {
44+
/** Install scripts info (preinstall, install, postinstall) */
45+
installScripts?: {
46+
scripts: ('preinstall' | 'install' | 'postinstall')[]
47+
npxDependencies: Record<string, string>
48+
}
49+
}
50+
>
4251
}
4352

4453
/**

0 commit comments

Comments
 (0)