|
#!/usr/bin/env node |
|
// dep-scanner |
|
// Searches pnpm-lock.yaml, yarn.lock, package-lock.json and package.json files for NPM packages |
|
// Usage: dep-scanner -p [email protected],pkg2 -f somefile.txt |
|
|
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const readline = require('readline'); |
|
|
|
// --- Helpers --------------------------------------------------------------- |
|
function usage() { |
|
console.log('Usage: dep-scanner [-p pkg@version,...] [-f file] [-r root]'); |
|
console.log(' -p comma-separated packages (version optional). Example: -p [email protected],@scope/[email protected],pkg2'); |
|
console.log(' -f file with newline-separated entries, format: package=version or package@version (version optional)'); |
|
console.log(' -r root path to start searching from (defaults to current working directory)'); |
|
console.log(''); |
|
console.log('Examples:'); |
|
console.log(' # scan packages listed in a file'); |
|
console.log(' node tmp/dep-scanner -f my-packages.txt -r /path/to/repo'); |
|
console.log(''); |
|
console.log(' # pipe clipboard (macOS pbpaste) or any stream into the scanner (use -f - or --stdin)'); |
|
console.log(' pbpaste | node tmp/dep-scanner -f - -r /path/to/repo'); |
|
console.log(' pbpaste | node tmp/dep-scanner --stdin -r /path/to/repo'); |
|
console.log(''); |
|
console.log(' # auto-detect piped stdin (no -p/-f)'); |
|
console.log(' pbpaste | node tmp/dep-scanner -r /path/to/repo'); |
|
console.log(' -h show help'); |
|
} |
|
|
|
function escapeRegExp(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
function parsePkgSpecFromArg(spec) { |
|
// Handle scoped packages correctly. If spec starts with '@', a version (optional) is |
|
// after the last '@'. Examples: |
|
// pkg -> name=pkg, version=null |
|
// [email protected] -> name=pkg, version=1.0.0 |
|
// @scope/pkg -> name=@scope/pkg, version=null |
|
// @scope/[email protected] -> name=@scope/pkg, version=1.2.3 |
|
if (!spec || typeof spec !== 'string') return null; |
|
const lastAt = spec.lastIndexOf('@'); |
|
if (spec[0] === '@') { |
|
// scoped name; need to find second @ (if any). If only one @ at position 0 it's scoped no version. |
|
if (lastAt === 0) return { name: spec, version: null }; |
|
} |
|
if (lastAt > 0) { |
|
const name = spec.slice(0, lastAt); |
|
const version = spec.slice(lastAt + 1); |
|
return { name: name.trim(), version: version.trim() || null }; |
|
} |
|
return { name: spec.trim(), version: null }; |
|
} |
|
|
|
function parsePkgSpecFromFileLine(line) { |
|
// Accept formats like: |
|
// name=1.2.3 |
|
// [email protected] |
|
// name |
|
// Comments ("#") are ignored. Blank lines return null. |
|
const cleaned = line.split('#')[0].trim(); // strip comments |
|
if (!cleaned) return null; |
|
// If it contains '=', use that as delimiter |
|
if (cleaned.includes('=')) { |
|
const parts = cleaned.split('='); |
|
const name = parts[0].trim(); |
|
const version = parts.slice(1).join('=').trim(); |
|
return { name, version: version || null }; |
|
} |
|
// If it contains a version via @ (handle scoped packages correctly), delegate to parsePkgSpecFromArg |
|
// e.g. [email protected] or @scope/[email protected] |
|
const atIndex = cleaned.lastIndexOf('@'); |
|
if (atIndex > 0) { |
|
// not a leading @ of a scoped package without version |
|
return parsePkgSpecFromArg(cleaned); |
|
} |
|
// fallback: just the name |
|
return { name: cleaned, version: null }; |
|
} |
|
|
|
const pkgNameRx = /^(@[a-z0-9][a-z0-9-_.]*\/)?[a-z0-9][a-z0-9-_.]*$/i; |
|
function isValidPackageName(name) { |
|
// Basic validation for npm package names and scoped names. |
|
// Allow letters, numbers, underscores, dots, dashes; scoped: @scope/name |
|
if (!name || typeof name !== 'string') return false; |
|
// no trailing slash |
|
if (name.endsWith('/')) return false; |
|
return pkgNameRx.test(name); |
|
} |
|
|
|
const versionRx = /^\d+\.\d+\.\d+([\-+][A-Za-z0-9-.]+)?$/; |
|
function isValidVersion(version) { |
|
if (!version) return true; // version optional |
|
// Basic semver strict check: x.y.z (allow pre/build metadata) |
|
return versionRx.test(version); |
|
} |
|
|
|
// --- File discovery ------------------------------------------------------- |
|
const skip = ['node_modules', '.git', 'tmp', 'logs', 'dist', 'build']; |
|
function shouldSkipDir(name) { |
|
return skip.includes(name); |
|
} |
|
|
|
function findTargetFiles(startDir) { |
|
const results = []; |
|
const stack = [startDir]; |
|
while (stack.length) { |
|
const dir = stack.pop(); |
|
let entries; |
|
try { |
|
entries = fs.readdirSync(dir, { withFileTypes: true }); |
|
} catch (err) { |
|
continue; |
|
} |
|
for (const ent of entries) { |
|
const full = path.join(dir, ent.name); |
|
if (ent.isDirectory()) { |
|
if (!shouldSkipDir(ent.name)) stack.push(full); |
|
continue; |
|
} |
|
if (!ent.isFile()) continue; |
|
// include common lockfiles from npm/yarn/pnpm and package.json files |
|
if (['pnpm-lock.yaml', 'yarn.lock', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'].includes(ent.name)) { |
|
results.push(full); |
|
} |
|
} |
|
} |
|
return results; |
|
} |
|
|
|
// --- Searching logic ------------------------------------------------------ |
|
function buildRegexesForPackage(pkg) { |
|
const nameEsc = escapeRegExp(pkg.name); |
|
const res = { name: pkg.name, version: pkg.version, patterns: [] }; |
|
if (pkg.version) { |
|
const vEsc = escapeRegExp(pkg.version); |
|
// common patterns: name@version, name=version, "name": "version", name": "^version, name": "~version |
|
res.patterns.push(new RegExp(nameEsc + '\\s*@\\s*' + vEsc, 'i')); |
|
res.patterns.push(new RegExp(nameEsc + '\\s*[=:]\\s*"?' + vEsc + '"?', 'i')); |
|
res.patterns.push(new RegExp('\"' + nameEsc + '\"\\s*:\\s*"?' + vEsc + '"?', 'i')); |
|
res.patterns.push(new RegExp(nameEsc + '[^\n]{0,60}' + vEsc, 'i')); |
|
} else { |
|
// version not provided; search for occurrences of name possibly followed by @version |
|
res.patterns.push(new RegExp(nameEsc + '\\s*@\\d+\\.\\d+\\.\\d+', 'i')); |
|
res.patterns.push(new RegExp('\"' + nameEsc + '\"', 'i')); // package.json key |
|
res.patterns.push(new RegExp(nameEsc + '[\\/\\-]?', 'i')); |
|
} |
|
return res; |
|
} |
|
|
|
function extractVersionFromSnippet(snippet, pkgName) { |
|
// Try to extract a semver after an @ or after :/="= |
|
const rxAt = new RegExp(escapeRegExp(pkgName) + '\\s*@\\s*(\\d+\\.\\d+\\.\\d+[A-Za-z0-9-_.+]*)', 'i'); |
|
const m1 = snippet.match(rxAt); |
|
if (m1 && m1[1]) return m1[1]; |
|
const rxVal = /[:=]\s*"?(\d+\.\d+\.\d+[A-Za-z0-9-_.+]*)"?/; |
|
const m2 = snippet.match(rxVal); |
|
if (m2 && m2[1]) return m2[1]; |
|
// last resort, search any semver-like token nearby |
|
const rxAny = /(\d+\.\d+\.\d+[A-Za-z0-9-_.+]*)/; |
|
const m3 = snippet.match(rxAny); |
|
if (m3 && m3[1]) return m3[1]; |
|
return null; |
|
} |
|
|
|
function searchFileForPackage(filePath, pkgEntry) { |
|
const txt = fs.readFileSync(filePath, 'utf8'); |
|
const lines = txt.split(/\r?\n/); |
|
const hits = []; |
|
const patterns = pkgEntry.patterns; |
|
for (let i = 0; i < lines.length; i++) { |
|
const line = lines[i]; |
|
for (const p of patterns) { |
|
if (p.test(line)) { |
|
// capture a small snippet: line plus +/- 2 neighbors |
|
const start = Math.max(0, i - 2); |
|
const end = Math.min(lines.length - 1, i + 2); |
|
const snippet = lines.slice(start, end + 1).join('\n'); |
|
const foundVersion = pkgEntry.version || extractVersionFromSnippet(snippet, pkgEntry.name); |
|
hits.push({ line: i + 1, snippet: snippet.trim(), version: foundVersion }); |
|
break; // don't double-count same line for this package |
|
} |
|
} |
|
} |
|
return hits; |
|
} |
|
|
|
// --- CLI ------------------------------------------------------------------ |
|
async function main() { |
|
const argv = process.argv.slice(2); |
|
if (!argv.length) { |
|
usage(); |
|
process.exit(1); |
|
} |
|
|
|
let pArg = null; |
|
let fArg = null; |
|
let rArg = null; |
|
let stdinFlag = false; |
|
for (let i = 0; i < argv.length; i++) { |
|
const a = argv[i]; |
|
if (a === '-h' || a === '--help') { |
|
usage(); |
|
process.exit(0); |
|
} |
|
if (a === '-p') { |
|
pArg = argv[++i]; |
|
continue; |
|
} |
|
if (a === '-f') { |
|
fArg = argv[++i]; |
|
continue; |
|
} |
|
if (a === '-r' || a === '--root') { |
|
rArg = argv[++i]; |
|
continue; |
|
} |
|
if (a === '--stdin') { |
|
stdinFlag = true; |
|
continue; |
|
} |
|
// allow combined form -p=... |
|
if (a.startsWith('-p=')) pArg = a.slice(3); |
|
if (a.startsWith('-f=')) fArg = a.slice(3); |
|
if (a.startsWith('-r=')) rArg = a.slice(3); |
|
if (a === '-f' && argv[i+1] === '-') { |
|
// allow -f - as shorthand; handled by fArg assignment above |
|
} |
|
} |
|
|
|
const targets = []; |
|
const errors = []; |
|
const warnings = []; |
|
|
|
if (pArg) { |
|
const parts = pArg.split(',').map(s => s.trim()).filter(Boolean); |
|
for (const raw of parts) { |
|
const parsed = parsePkgSpecFromArg(raw); |
|
if (!parsed) { |
|
errors.push(`Invalid package spec '${raw}' in -p`); |
|
continue; |
|
} |
|
if (!isValidPackageName(parsed.name)) { |
|
errors.push(`Invalid package name '${parsed.name}' in -p`); |
|
continue; |
|
} |
|
if (!isValidVersion(parsed.version)) { |
|
errors.push(`Invalid version '${parsed.version}' for package '${parsed.name}' in -p`); |
|
continue; |
|
} |
|
targets.push(parsed); |
|
} |
|
} |
|
// If stdin should be read: conditions: |
|
// - user passed --stdin, or |
|
// - user passed -f - (fArg === '-') , or |
|
// - no -p/-f provided and stdin is piped (non-TTY) |
|
const shouldReadStdin = stdinFlag || fArg === '-' || (!pArg && !fArg && !process.stdin.isTTY); |
|
if (shouldReadStdin) { |
|
// read stdin line-by-line |
|
try { |
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); |
|
let idx = 0; |
|
for await (const ln of rl) { |
|
idx++; |
|
const parsed = parsePkgSpecFromFileLine(ln); |
|
if (!parsed) continue; // blank or comment |
|
if (!isValidPackageName(parsed.name)) { |
|
warnings.push(`Warning: invalid package name on stdin:${idx} -> '${ln.trim()}' (ignored)`); |
|
continue; |
|
} |
|
if (!isValidVersion(parsed.version)) { |
|
warnings.push(`Warning: invalid version on stdin:${idx} -> '${ln.trim()}' (ignored)`); |
|
continue; |
|
} |
|
targets.push(parsed); |
|
} |
|
} catch (err) { |
|
console.error('Failed to read stdin:', err && err.message ? err.message : err); |
|
process.exit(7); |
|
} |
|
} |
|
|
|
// If fArg is a real filename (not '-') process it now |
|
if (fArg && fArg !== '-') { |
|
// read file and process lines; invalid lines should only warn and be ignored |
|
let lines; |
|
try { |
|
const raw = fs.readFileSync(fArg, 'utf8'); |
|
lines = raw.split(/\r?\n/); |
|
} catch (err) { |
|
console.error(`Could not read file '${fArg}': ${err.message}`); |
|
process.exit(2); |
|
} |
|
lines.forEach((ln, idx) => { |
|
const parsed = parsePkgSpecFromFileLine(ln); |
|
if (!parsed) return; // blank or comment |
|
if (!isValidPackageName(parsed.name)) { |
|
warnings.push(`Warning: invalid package name on ${fArg}:${idx + 1} -> '${ln.trim()}' (ignored)`); |
|
return; |
|
} |
|
if (!isValidVersion(parsed.version)) { |
|
warnings.push(`Warning: invalid version on ${fArg}:${idx + 1} -> '${ln.trim()}' (ignored)`); |
|
return; |
|
} |
|
targets.push(parsed); |
|
}); |
|
} |
|
|
|
if (!targets.length) { |
|
console.error('No valid package targets provided. Exiting.'); |
|
errors.forEach(e => console.error(e)); |
|
warnings.forEach(w => console.warn(w)); |
|
process.exit(3); |
|
} |
|
|
|
// Show warnings and errors |
|
if (errors.length) { |
|
errors.forEach(e => console.error(e)); |
|
process.exit(4); |
|
} |
|
warnings.forEach(w => console.warn(w)); |
|
|
|
// discover files (allow overriding start directory) |
|
const start = rArg ? path.resolve(rArg) : process.cwd(); |
|
if (rArg) { |
|
try { |
|
const st = fs.statSync(start); |
|
if (!st.isDirectory()) { |
|
console.error(`Provided root '${rArg}' is not a directory`); |
|
process.exit(6); |
|
} |
|
} catch (err) { |
|
console.error(`Provided root '${rArg}' does not exist or is not accessible: ${err.message}`); |
|
process.exit(6); |
|
} |
|
} |
|
const files = findTargetFiles(start); |
|
if (!files.length) { |
|
console.error('No target files (pnpm-lock.yaml, yarn.lock, package.json) found under ' + start); |
|
process.exit(5); |
|
} |
|
console.log('Discovered target files:'); |
|
files.forEach(f => console.log(' - ' + f)); |
|
|
|
// build regex objects |
|
const built = targets.map(t => buildRegexesForPackage(t)); |
|
|
|
const results = {}; |
|
for (const b of built) { |
|
results[b.name + (b.version ? '@' + b.version : '')] = { pkg: b, hits: [] }; |
|
} |
|
|
|
for (const fpath of files) { |
|
for (const b of built) { |
|
const hits = searchFileForPackage(fpath, b); |
|
if (hits && hits.length) { |
|
results[b.name + (b.version ? '@' + b.version : '')].hits.push({ file: fpath, hits }); |
|
} |
|
} |
|
} |
|
|
|
// Print summary |
|
console.log('Search summary:'); |
|
Object.keys(results).forEach(key => { |
|
const entry = results[key]; |
|
if (!entry.hits.length) { |
|
console.log(`- ${key}: NOT FOUND`); |
|
return; |
|
} |
|
console.log(`- ${key}: FOUND in ${entry.hits.length} file(s):`); |
|
for (const fh of entry.hits) { |
|
console.log(` - ${fh.file}`); |
|
for (const h of fh.hits) { |
|
console.log(` line ${h.line}: version=${h.version || '(unknown)'} snippet: ${h.snippet.replace(/\n/g, ' -> ').slice(0, 250)}`); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
// Run main |
|
if (require.main === module) { |
|
main().catch(err => { |
|
console.error('Unhandled error:', err && err.stack ? err.stack : err); |
|
process.exit(99); |
|
}); |
|
} |