forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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
10import type { InstallScriptsInfo } from '#shared/types'
11
12// Scripts that run when installing a package as a dependency
13const 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)
17const NPX_PATTERN = /\bnpx\s+(?:--?\w+(?:=\S+)?\s+)*(@?[\w.-]+(?:\/[\w.-]+)?(?:@[\w.^~<>=|-]+)?)/g
18
19// Pattern to extract package name and version from captured group
20const 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 */
29export 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 */
70function 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 */
94export 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}
117
118/**
119 * Pattern to match scripts that are just `node <file-path>`
120 * Captures the file path (relative paths with alphanumeric chars, dots, hyphens, underscores, and slashes)
121 */
122const NODE_SCRIPT_PATTERN = /^node\s+([\w./-]+)$/
123
124/**
125 * Get the file path for an install script link.
126 * - If the script is `node <file-path>`, returns that file path
127 * - Otherwise, returns 'package.json'
128 *
129 * @param scriptContent - The content of the script
130 * @returns The file path to link to in the code tab
131 */
132export function getInstallScriptFilePath(scriptContent: string): string {
133 const match = NODE_SCRIPT_PATTERN.exec(scriptContent)
134
135 if (match?.[1]) {
136 // Script is `node <file-path>`, link to that file
137 // Normalize path: strip leading ./
138 const filePath = match[1].replace(/^\.\//, '')
139
140 // Fall back to package.json if path contains navigational elements (the client-side routing can't handle these well)
141 if (filePath.includes('../') || filePath.includes('./')) {
142 return 'package.json'
143 }
144
145 return filePath
146 }
147
148 // Default: link to package.json
149 return 'package.json'
150}
151
152/**
153 * Parse an install script into a prefix and a linkable file path.
154 * - If the script is `node <file-path>`, returns { prefix: 'node ', filePath: '<file-path>' }
155 * so only the file path portion can be rendered as a link.
156 * - Otherwise, returns null (the entire script content should link to package.json).
157 *
158 * @param scriptContent - The content of the script
159 * @returns Parsed parts, or null if no node file path was extracted
160 */
161export function parseNodeScript(
162 scriptContent: string,
163): { prefix: string; filePath: string } | null {
164 const match = NODE_SCRIPT_PATTERN.exec(scriptContent)
165
166 if (match?.[1]) {
167 const filePath = match[1].replace(/^\.\//, '')
168
169 // Fall back if path contains navigational elements
170 if (filePath.includes('../') || filePath.includes('./')) {
171 return null
172 }
173
174 // Reconstruct the prefix (everything before the captured file path)
175 const prefix = scriptContent.slice(0, match.index + match[0].indexOf(match[1]))
176 return { prefix, filePath }
177 }
178
179 return null
180}