npm 10 ๐ Managing npm scripts (start, test, custom scripts)
An npm script is a named shell command stored in package.json and executed by npm with a modified environment. The modification is the point: npm prepends node_modules/.bin to the PATH before running the script, so any binary provided by a locally installed dependency can be invoked by name without the full path . This is why a test script can say jest instead of ./node_modules/.bin/jest, and why a build script can call tsc without a global TypeScript install. The script is not a Node.js API; it is a shell command, and npm’s contribution is the environment and the lifecycle hooks that surround it.
Two scripts have shorthand commands: npm start runs the start script, and npm test runs the test script. Every other script requires npm run <name>, even if the name looks like a standard taskโnpm build does nothing unless build happens to be a lifecycle hook npm recognizes, which it is not . The shorthand exists because start and test are the two commands that every Node project is expected to support. If no start script is defined but a server.js file exists at the package root, npm defaults start to node server.js .
The pre/post hook mechanism is the feature that turns scripts from single commands into sequences. For any script named foo, npm runs prefoo, then foo, then postfoo if they exist . This applies to arbitrary scripts and to the built-in ones: pretest runs before test, prestart before start, and so on . The hooks are not a dependency graphโprefoo failing stops the sequence, but there is no parallel execution, no retry, and no conditional logic beyond what the shell commands themselves implement.
This chapter covers the scripts field structure, the start and test shorthands, the pre/post hook mechanism, the environment npm injects (including node_modules/.bin on PATH and npm_package_* variables), argument passing with --, and the lifecycle scripts that run during install and publish rather than on demand. It assumes familiarity with package.json and basic shell commands.
Key point: A script is a shell command with node_modules/.bin prepended to PATH. npm start and npm test are shorthand for npm run start and npm run test. Every other script needs npm run <name>. The pre/post hooks fire automatically for any script, but they are sequential, not a build system.
Why npm scripts exist as a task runner
The binary resolution problem. A dependency that provides a CLI tool installs its executable into node_modules/.bin with a symlink to the package’s actual entry point . Outside an npm script, that directory is not on the PATH, so typing the tool’s name fails. npm scripts solve this by modifying the PATH for the duration of the script, making every locally installed binary available by name . The alternativeโtyping ./node_modules/.bin/tool in every scriptโis verbose and breaks on Windows, where the path separator and executable extension differ.
The lifecycle hook problem. Many tasks need to run in sequence: lint before test, build before publish, clean before build. A shell script can chain commands with &&, but the chain is opaque and not discoverable from package.json. The pre/post hooks make the sequence declarative: pretest is visible in the scripts block, runs automatically, and is listed alongside the test script it precedes . This is a minimal task graph, not a full build system, but it covers the common cases without an external dependency.
The discoverability problem. A Makefile is a separate file with its own syntax. An npm script is a key in package.json that npm run lists when called without arguments . A developer who clones a repository can run npm run and see every available task. This is the same discoverability that make provides with tab completion, but it is built into the package manifest and requires no additional tooling.
The trade-off. npm scripts are shell commands, not JavaScript functions. They are platform-dependent unless written carefully: rm -rf fails on Windows cmd.exe, and environment variable syntax differs between shells. The script-shell configuration can set a specific shell, and tools like cross-env exist to normalize environment variable setting across platforms, but the fundamental limitation remains. A script that works on the developer’s macOS machine may fail on the CI runner’s Linux container if it uses a shell feature that differs between /bin/sh and bash . The discipline is to keep scripts simple and portable, or to accept that the project targets a specific platform.
a. start, test, and the shorthand commands
npm start runs the start script. If no start script is defined and a server.js file exists at the package root, npm runs node server.js . This default is a historical convention from the early Node.js era when server.js was the standard entry point for a web application. The prestart and poststart hooks run around it, even when the default is used .
{
"scripts": {
"start": "node dist/index.js",
"pretest": "eslint .",
"test": "jest",
"posttest": "jest --coverage --json --outputFile=coverage.json"
}
}
npm test runs the test script with the same hook behavior: pretest, test, posttest . The test script is special in one additional way: npm’s own npm test command is the standard way to run a project’s tests, and CI systems often default to it. A project that defines test as something other than a test runnerโa linter, for exampleโwill confuse both developers and tooling.
The shorthands exist for these two names only. npm build is not a command; if a build script exists, it must be run with npm run build. The run command without arguments lists the available scripts, separating lifecycle scripts like test and start from directly-run scripts . This listing is the discovery mechanism: a new contributor runs npm run and sees every task the project defines.
b. The pre/post hooks and argument passing
For any script foo, npm looks for prefoo and postfoo in the scripts object. If they exist, they run before and after foo respectively . The hooks are not recursive: preprefoo is not a thing. They run in the same shell and environment as the main script, and a non-zero exit from prefoo aborts the sequence before foo runs.
{
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc",
"postbuild": "node scripts/copy-assets.js"
}
}
Running npm run build executes rimraf dist, then tsc, then node scripts/copy-assets.js. The prebuild hook cleans the output directory; the postbuild hook copies assets that the TypeScript compiler does not handle. Neither hook needs to be mentioned in the build script itself.
Arguments pass to the main script, not to the hooks. The -- separator is required for arguments that begin with a dash, because npm parses its own flags before the script name . npm test -- --grep="pattern" passes --grep="pattern" to the test runner, not to npm. The pre and post hooks do not receive these arguments . A script that needs to accept a file path or a filter must read it from process.argv in the tool it invokes, or from an environment variable set before the command.
c. The environment npm injects
The most important environment change is the PATH. npm prepends node_modules/.bin to the existing PATH, so every locally installed binary is available by name . This is why a test script can call jest directly, and why a lint script can call eslint without a global install. The binary in node_modules/.bin is a symlink to the package’s actual executable, created during npm install .
npm also injects environment variables derived from package.json. The npm_package_name, npm_package_version, and other npm_package_* variables are available in the script’s environment . A script that needs the project version can read process.env.npm_package_version rather than parsing package.json itself. The npm_lifecycle_event variable holds the name of the script being run, which is useful for a single script file that behaves differently depending on whether it was invoked as prebuild or postbuild .
The working directory is always the package root, regardless of where npm run was invoked. A script that needs to know the directory the developer was in when they ran the command can read INIT_CWD, which holds the full path of the invocation directory . This matters for scripts that operate on the current directory, such as a file watcher or a scaffolding tool.
Complete Example Session
// ============================================
// PART 1: THE SCRIPTS BLOCK
// ============================================
{
"scripts": {
"start": "node dist/index.js",
"pretest": "eslint src/",
"test": "jest",
"posttest": "node scripts/report-coverage.js",
"build": "tsc",
"prebuild": "rimraf dist",
"postbuild": "cp -r public dist/public",
"lint": "eslint src/ --fix",
"dev": "nodemon src/index.ts"
}
}
// ============================================
// PART 2: SHORTHAND COMMANDS
// ============================================
// npm start โ runs "start" (or node server.js if absent)
// npm test โ runs "test"
// npm run build โ requires "run"
// npm run dev โ requires "run"
// npm run (no args) โ lists all scripts
// ============================================
// PART 3: PRE/POST HOOKS
// ============================================
// npm run build executes:
// prebuild โ rimraf dist
// build โ tsc
// postbuild โ cp -r public dist/public
//
// npm test executes:
// pretest โ eslint src/
// test โ jest
// posttest โ node scripts/report-coverage.js
// ============================================
// PART 4: ARGUMENT PASSING
// ============================================
// Pass args to the test runner:
// npm test -- --watch
// npm test -- --grep="auth"
//
// The -- separates npm's args from the script's.
// Args go to "test", not to "pretest" or "posttest".
// ============================================
// PART 5: PATH INJECTION
// ============================================
// "test": "jest" works because:
// node_modules/.bin/jest โ symlink to jest package
// npm prepends node_modules/.bin to PATH
// "jest" resolves to the local binary
//
// Equivalent (verbose, cross-platform issues):
// "test": "./node_modules/.bin/jest"
// ============================================
// PART 6: ENVIRONMENT VARIABLES
// ============================================
// npm_package_name = "my-app"
// npm_package_version = "1.0.0"
// npm_lifecycle_event = "test" (or "pretest", "posttest")
//
// A script can read these:
// node -e "console.log(process.env.npm_package_version)"
// ============================================
// PART 7: LIFECYCLE SCRIPTS (INSTALL/PUBLISH)
// ============================================
// These run automatically, not on demand:
// preinstall โ before dependencies install
// install โ after dependencies install
// postinstall โ after install completes
// prepare โ before pack/publish, and on local npm install
// prepublishOnly โ before publish only
//
// "prepare" is the modern replacement for "prepublish".
// ============================================
// PART 8: THE server.js DEFAULT
// ============================================
// If no "start" script and server.js exists:
// npm start โ node server.js
//
// prestart and poststart still run.
// ============================================
// PART 9: PLATFORM PORTABILITY
// ============================================
// Bad: "clean": "rm -rf dist" (fails on Windows cmd)
// Good: "clean": "rimraf dist" (cross-platform package)
//
// Bad: "dev": "NODE_ENV=dev node ." (Unix syntax)
// Good: "dev": "cross-env NODE_ENV=dev node ."
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. "scripts" in package.json defines named tasks.
// 2. npm start / npm test are shorthands.
// 3. npm run <name> runs any other script.
// 4. pre<name> and post<name> run automatically.
// 5. node_modules/.bin is prepended to PATH.
// 6. npm_package_* variables are injected.
// 7. Arguments pass with --.
// 8. Lifecycle scripts (prepare, postinstall) run on install/publish.
Quick Reference
Script Execution
| Command | Script | Hook Order |
|---|---|---|
npm start | start (or node server.js) | prestart, start, poststart |
npm test | test | pretest, test, posttest |
npm run build | build | prebuild, build, postbuild |
npm run <name> | <name> | pre<name>, <name>, post<name> |
Environment Injected
| Variable | Value |
|---|---|
PATH | node_modules/.bin prepended |
npm_package_name | package.json name |
npm_package_version | package.json version |
npm_lifecycle_event | Current script name |
INIT_CWD | Directory where npm run was invoked |
Lifecycle Scripts (Automatic)
| Script | When |
|---|---|
preinstall | Before dependency install |
install | After dependency install |
postinstall | After install completes |
prepare | Before pack/publish, and local npm install |
prepublishOnly | Before npm publish only |
Argument Passing
| Command | Result |
|---|---|
npm test -- --watch | jest --watch |
npm run build -- --prod | tsc --prod |
npm test --grep="x" | npm parses --grep, not the script |
Best Practices
โ Do This:
// Use local binaries by name in scripts. // โ
"test": "jest", "lint": "eslint ."
// Use pre/post for sequential setup. // โ
"prebuild": "rimraf dist", "build": "tsc"
// Use cross-platform tools. // โ
"clean": "rimraf dist", "dev": "cross-env NODE_ENV=dev node ."
// Pass args with --. // โ
// npm test -- --watch
// Use prepare for install-time setup. // โ
"prepare": "husky install"
โ Don’t Do This:
// Assume npm build works without "run". // โ
// npm build โ not a command; use npm run build.
// Use Unix-only shell syntax. // โ
"clean": "rm -rf dist" // Fails on Windows.
// Put long-running servers in pretest. // โ
"pretest": "node server.js" // Hangs; use a test setup.
// Rely on pre/post for parallel work. // โ
// Hooks are sequential; use a task runner for parallelism.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
npm build does nothing | Not a shorthand command | npm run build |
| Local CLI not found | node_modules/.bin not on PATH outside script | Use npx outside scripts |
| Args consumed by npm | Missing -- separator | npm test -- --watch |
| Script fails on Windows | Unix shell syntax | Use rimraf, cross-env |
prepare runs unexpectedly | Runs on local npm install | Use prepublishOnly for publish-only |
Real-World Examples
1. Basic Test Script
"test": "jest"
2. Pre-Test Lint
"pretest": "eslint src/", "test": "jest"
3. Build with Clean
"prebuild": "rimraf dist", "build": "tsc"
4. Dev Server
"dev": "nodemon src/index.ts"
5. Cross-Platform Env
"dev": "cross-env NODE_ENV=development node ."
6. Argument Passing
npm test -- --watch
7. Prepare Hook
"prepare": "husky install"
8. Default Start
// No "start" script, server.js exists โ node server.js
9. Version in Script
node -e "console.log(process.env.npm_package_version)"
10. List Scripts
npm run
Visual
Script Execution Environment
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ npm run build โ
โ โ โ
โ โผ โ
โ Create child shell โ
โ โ โ
โ โโโ PATH = node_modules/.bin:$PATH โ
โ โโโ npm_package_name=my-app โ
โ โโโ npm_package_version=1.0.0 โ
โ โโโ npm_lifecycle_event=build โ
โ โ โ
โ โผ โ
โ Execute "prebuild" โ "build" โ "postbuild" โ
โ โ
โ Working directory: package root. โ
โ INIT_CWD: where npm run was invoked. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Pre/Post Hook Sequence
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ npm run compress โ
โ โ โ
โ โผ โ
โ precompress โ runs first โ
โ โ โ
โ โผ โ
โ compress โ main script โ
โ โ โ
โ โผ โ
โ postcompress โ runs after โ
โ โ
โ Non-zero exit in precompress aborts. โ
โ Arguments go to compress, not hooks. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Shorthand vs run
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SHORTHAND REQUIRES "run" โ
โ โ
โ npm start npm run build โ
โ npm test npm run lint โ
โ npm stop npm run dev โ
โ npm restart npm run clean โ
โ โ
โ Only start and test (and stop/restart) โ
โ have shorthand commands. โ
โ โ
โ npm run (no args) lists all scripts. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Lifecycle Scripts vs User Scripts
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AUTOMATIC (lifecycle) โ
โ preinstall, install, postinstall โ
โ prepare, prepublishOnly โ
โ โ run on npm install / publish โ
โ โ
โ ON DEMAND (user) โ
โ start, test, build, lint โ
โ โ run when invoked โ
โ โ
โ Both support pre/post hooks. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
start shorthand | npm start |
test shorthand | npm test |
| Custom script | npm run <name> |
| Pre/post hooks | pre<name>, post<name> |
PATH modification | node_modules/.bin prepended |
| Argument separator | -- |
Default start | node server.js if file exists |
| Lifecycle scripts | prepare, postinstall, etc. |
| Working directory | Package root |
INIT_CWD | Invocation directory |
Key takeaways:
- A script is a shell command with
node_modules/.binon thePATH. npm prepends that directory so locally installed binaries can be invoked by name. This is the primary reason scripts exist rather than raw shell commands . npm startandnpm testare the only shorthands. Every other script requiresnpm run <name>.npm buildis not a command .- Pre/post hooks fire automatically for any script.
prebuildruns beforebuild,posttestaftertest. A failingprehook aborts the sequence . - Arguments pass with
--.npm test -- --watchpasses--watchto the test runner, not to npm. Thepreandposthooks do not receive the arguments . - The environment includes
npm_package_*variables. A script can read the project name, version, and lifecycle event without parsingpackage.json. - Lifecycle scripts run on install and publish, not on demand.
prepareruns on localnpm installand before publishing;prepublishOnlyruns only on publish. These are distinct from user scripts likebuildandtest. - Portability requires discipline. Unix shell syntax fails on Windows
cmd.exe. Use cross-platform tools likerimrafandcross-env, or accept a platform-specific target.
Remember: npm scripts are named shell commands with a modified environment. node_modules/.bin on the PATH makes local binaries available by name. The pre/post hooks make sequences declarative, but they are sequential, not parallel. npm start and npm test are shorthands; everything else needs npm run. Arguments pass after --. The working directory is always the package root, and INIT_CWD holds the invocation directory. Lifecycle scripts (prepare, postinstall) run automatically on install and publish; user scripts (build, lint) run when invoked. The scripts block is a task runner, not a build systemโwhen the sequence needs parallel execution or file-based dependency graphs, reach for a dedicated tool.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!