| |

npm 10 ๐ŸŒ Managing npm scripts (start, test, custom scripts)

โฑ๏ธ Estimated read: 14 min | |

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

CommandScriptHook Order
npm startstart (or node server.js)prestart, start, poststart
npm testtestpretest, test, posttest
npm run buildbuildprebuild, build, postbuild
npm run <name><name>pre<name>, <name>, post<name>

Environment Injected

VariableValue
PATHnode_modules/.bin prepended
npm_package_namepackage.json name
npm_package_versionpackage.json version
npm_lifecycle_eventCurrent script name
INIT_CWDDirectory where npm run was invoked

Lifecycle Scripts (Automatic)

ScriptWhen
preinstallBefore dependency install
installAfter dependency install
postinstallAfter install completes
prepareBefore pack/publish, and local npm install
prepublishOnlyBefore npm publish only

Argument Passing

CommandResult
npm test -- --watchjest --watch
npm run build -- --prodtsc --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

PitfallWhy It HappensFix
npm build does nothingNot a shorthand commandnpm run build
Local CLI not foundnode_modules/.bin not on PATH outside scriptUse npx outside scripts
Args consumed by npmMissing -- separatornpm test -- --watch
Script fails on WindowsUnix shell syntaxUse rimraf, cross-env
prepare runs unexpectedlyRuns on local npm installUse 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

ItemValue
start shorthandnpm start
test shorthandnpm test
Custom scriptnpm run <name>
Pre/post hookspre<name>, post<name>
PATH modificationnode_modules/.bin prepended
Argument separator--
Default startnode server.js if file exists
Lifecycle scriptsprepare, postinstall, etc.
Working directoryPackage root
INIT_CWDInvocation directory

Key takeaways:

  • A script is a shell command with node_modules/.bin on the PATH. 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 start and npm test are the only shorthands. Every other script requires npm run <name>. npm build is not a command .
  • Pre/post hooks fire automatically for any script. prebuild runs before build, posttest after test. A failing pre hook aborts the sequence .
  • Arguments pass with --. npm test -- --watch passes --watch to the test runner, not to npm. The pre and post hooks do not receive the arguments .
  • The environment includes npm_package_* variables. A script can read the project name, version, and lifecycle event without parsing package.json .
  • Lifecycle scripts run on install and publish, not on demand. prepare runs on local npm install and before publishing; prepublishOnly runs only on publish. These are distinct from user scripts like build and test .
  • Portability requires discipline. Unix shell syntax fails on Windows cmd.exe. Use cross-platform tools like rimraf and cross-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!