Escena Viva is now 1.0.0, with its v1.0.0 git tag, and its package.json is nearly complete: name, version, main, engines, dependencies, devDependencies. One gap is left, and it is the one used most day to day: "scripts": { }. That empty object is the reason why, today, starting the server means someone has to remember node src/server/server.js; running the occupancy report means node src/reports/occupancy.js; and the sales pipeline means a path only the person who wrote it knows.

In this lesson we turn scripts into the project's single interface. When we finish, anyone who clones the repository will be able to type npm run and see the complete list of things they can do, without opening a README or asking anyone. And along the way we will settle the question left open in lesson 05-02: why "lint": "eslint src" works even though you never installed ESLint globally.

Contents

  1. What the scripts field really is
  2. Built-in scripts versus custom scripts
  3. Escena Viva's scripts, one by one
  4. The extended PATH: node_modules/.bin
  5. Passing arguments with --
  6. Environment variables in scripts
  7. pre and post hooks (and the postinstall warning)
  8. Chaining commands and surviving Windows
  9. ESLint and Prettier: what each one solves
  10. Common mistakes and tips
  11. Exercises
  12. Conclusion

  1. What the scripts field really is

scripts is an object where every key is a name and every value is a shell line. When you run npm run <name>, npm:

  1. Looks up the key <name> in the current directory's package.json.
  2. Prepares a special environment (npm_* variables and an extended PATH, which we will get to).
  3. Launches that line with the system shell (/bin/sh on POSIX, cmd.exe on Windows unless configured otherwise).
  4. Returns the command's exit code: 0 is success, anything else is failure.

There is no extra magic. It is not a new language: it is shell. What is valuable is not the mechanism but the convention: everyone expects to find the project's commands there.

Command What it does
npm run Lists every available script with its contents
npm run <name> Runs the <name> script
npm run <name> -- <args> Runs the script passing it extra arguments
npm start Shortcut for npm run start
npm test Shortcut for npm run test
npm run <name> --silent Runs it hiding npm's echo

  1. Built-in scripts versus custom scripts

npm recognizes a handful of built-in names that can be invoked without the word run:

Name Short invocation Special behavior
start npm start If you do not define it, npm tries node server.js
test npm test If you do not define it, it fails with a warning
stop npm stop No default
restart npm restart Runs stop, then restart, then start

Everything else is custom and requires npm run. That is: npm dev does not exist (npm will try to read it as one of its own subcommands and fail), you have to write npm run dev.

A historical detail that is still alive: if you do not define start, npm looks for server.js at the root. Escena Viva keeps its server in src/server/server.js, so the default is useless to us and we must declare it explicitly. That is the right thing to do anyway: an explicit script documents the entry point.

  1. Escena Viva's scripts, one by one

This is the complete package.json after this lesson:

{
  "name": "escena-viva",
  "version": "1.0.0",
  "description": "Ticket sales platform for cultural events",
  "main": "src/server/server.js",
  "type": "commonjs",
  "private": true,
  "engines": { "node": ">=24.5.0 <25" },
  "scripts": {
    "start": "node src/server/server.js",
    "dev": "node --watch src/server/server.js",
    "catalog": "node src/catalog.js",
    "report": "node src/reports/occupancy.js",
    "sales": "node src/reports/sales-pipeline.js",
    "lint": "eslint src",
    "format": "prettier --write \"src/**/*.js\"",
    "format:check": "prettier --check \"src/**/*.js\"",
    "check": "npm run lint && npm run format:check",
    "test": "echo \"No tests yet — Module 9\" && exit 1"
  },
  "dependencies": { "dotenv": "^17.2.1" },
  "devDependencies": { "prettier": "^3.6.2" }
}

Let's go command by command.

start

"start": "node src/server/server.js"

Boots the HTTP server we built in Module 4. It matches the main field, and that is no accident: main declares the package's entry point, start declares how the process runs. A server in production is brought up with npm start (or directly with node, see the warning below).

dev

"dev": "node --watch src/server/server.js"

--watch is a native Node flag (stable since Node 22) that restarts the process whenever any file the loaded module depends on changes. For years this required installing nodemon as a development dependency; today no external dependency is needed, which fits Escena Viva's philosophy: zero dependencies where the platform already delivers.

Differences worth knowing:

Aspect node --watch nodemon
Installation None, it ships with Node Development dependency
What it watches The loaded module graph Configurable file patterns
Configuration --watch-path, --watch-preserve-output nodemon.json with rich rules
Other languages No Yes (it can restart any command)

For our case, --watch is more than enough. If one day you also need to watch data/events.json, add --watch-path=./data.

catalog

"catalog": "node src/catalog.js"

The Module 1 catalog CLI: it prints the three events with their sessions to stdout. Putting it in scripts turns it into part of the project's interface instead of a loose file you have to discover by reading the tree.

report and sales

"report": "node src/reports/occupancy.js",
"sales": "node src/reports/sales-pipeline.js"

report computes occupancy (7 sessions, capacity 3000, 1811 sold). sales launches the stream pipeline that reads data/sales.csv and produces the per-session aggregate. Both write data to stdout and diagnostics to stderr, the course's convention, so this still works:

npm run report --silent > occupancy-report.txt

The --silent matters here: without it, npm prints two header lines (> [email protected] report and the command) that would sneak into your file. npm sends them to stdout, not stderr, so if a script is meant to be redirected, get into the habit of using --silent (or its short form -s).

lint

"lint": "eslint src"

This is the promise we made in 05-02. ESLint is not installed globally and yet the script will work as soon as you add ESLint to devDependencies. The full explanation is in section 4.

format and format:check

"format": "prettier --write \"src/**/*.js\"",
"format:check": "prettier --check \"src/**/*.js\""

--write rewrites the files; --check only checks and fails if something is not formatted. The first is for development, the second for continuous integration, where you never want a machine modifying files: you want it to tell you they are wrong.

Notice the quotes around the pattern. Without them, the POSIX shell would expand src/**/*.js before Prettier ever saw it, and the result would depend on the shell's globstar setting and on the operating system. With quotes, the pattern reaches Prettier intact and Prettier expands it itself, identically on every platform. It is a small detail with real consequences.

The colon in format:check means nothing to npm: it is just a visual convention for grouping variants of the same script. npm run format:check is a name like any other.

check

"check": "npm run lint && npm run format:check"

A composition script: it does no work of its own, it chains two others. This is the pattern worth internalizing: small single-purpose scripts, plus scripts that combine them. If tomorrow we add type checking, it joins the chain without touching the others.

test

"test": "echo \"No tests yet — Module 9\" && exit 1"

An honest test. There are two bad alternatives:

  • Leaving npm's default test (echo \"Error: no test specified\" && exit 1), which says nothing useful.
  • Writing "test": "exit 0" so CI goes green. That is lying: the green light stops meaning anything.

Our script states the reason and exits with code 1. CI will fail, which is exactly what should happen in a 1.0.0 project with no tests, and the message says where the solution is. In Module 9 this script becomes node --test test/ and the red turns green for a good reason.

  1. The extended PATH: node_modules/.bin

Here is the promised answer. Many packages declare executables in their own package.json:

{
  "name": "prettier",
  "bin": { "prettier": "./bin/prettier.cjs" }
}

When npm installs a package with a bin field, it creates a link in node_modules/.bin/. After installing Prettier, the project has:

ls node_modules/.bin
# prettier

And when you run npm run <something>, npm prepends that directory to the child process's PATH. You can see it for yourself:

"where": "node -e \"console.log(process.env.PATH.split(':')[0])\""
npm run where --silent
# /home/your-user/escena-viva/node_modules/.bin

That is why "format": "prettier --write ..." finds prettier with no global install and without writing ./node_modules/.bin/prettier. And that is why "lint": "eslint src" will work as soon as ESLint is in devDependencies.

flowchart LR
  A["npm run format"] --> B["PATH = node_modules/.bin : original PATH"]
  B --> C["shell looks for 'prettier'"]
  C --> D["node_modules/.bin/prettier"]
  D --> E["node_modules/prettier/bin/prettier.cjs"]

Three practical consequences:

  • The version that runs is the project's, not the system's. Two projects with Prettier 2 and Prettier 3 coexist without conflicts.
  • You do not need npx inside scripts. npx prettier works, but it adds an unnecessary resolution step. Inside scripts, write the plain binary name.
  • If a command fails with command not found inside a script, it almost always means the package is missing from the dependencies, not that a global install is missing.

  1. Passing arguments with --

This does not do what it looks like:

npm run catalog --venue="Sala Boveda"

npm reads --venue as one of its own options (one it does not know) and does not pass it to the script. The -- separator marks the boundary:

npm run catalog -- --venue="Sala Boveda"

Now the command executed is node src/catalog.js --venue="Sala Boveda" and the arguments reach process.argv. With a script like this:

'use strict';

// Reads --venue from argv; returns null when it was not passed.
function readVenueFromArgs(args) {
  const found = args.find((arg) => arg.startsWith('--venue='));
  return found ? found.slice('--venue='.length) : null;
}

module.exports = { readVenueFromArgs };

The filter works the same whether you launch it with node directly or with npm run ... --. A mnemonic: everything before -- is for npm; everything after it is for your program.

  1. Environment variables in scripts

npm injects into the child process a set of variables derived from the manifest and from the configuration:

Variable Contents
npm_package_name escena-viva
npm_package_version 1.0.0
npm_lifecycle_event The name of the running script
npm_config_* Any npm configuration option
PATH Extended with node_modules/.bin

A real use: having the server log its version at startup without reading package.json at run time.

// Inside src/server/server.js, at startup.
const version = process.env.npm_package_version ?? 'unknown';
process.stderr.write(`Escena Viva ${version} listening on ${HOST}:${PORT}\n`);

Watch out for the trap: if someone starts it with node src/server/server.js instead of npm start, that variable does not exist. Hence the ?? with a fallback value. Never make business logic depend on an npm_* variable; use them for diagnostics only.

npm_lifecycle_event lets a single file behave differently depending on how it was called, though two explicit scripts are usually preferable.

Configuration options also come through. If you run npm run report --format=csv, npm exposes npm_config_format=csv. It is a real mechanism, but prefer -- and process.argv: it is explicit, portable and it works when you run the script without npm.

  1. pre and post hooks

For any script x, npm automatically runs prex before it and postx after it, whenever they exist. It works with the built-in names and with custom ones:

"prestart": "node -e \"require('node:fs').accessSync('data/events.json')\"",
"start": "node src/server/server.js"

If the data file does not exist, prestart fails, and npm does not run start. An early, clear failure instead of a booted server that blows up on the first request.

The best-known hooks are the ones in the install lifecycle:

Hook When it fires
preinstall Before dependencies are installed
install / postinstall After they are installed
prepare After a local npm install and before npm publish
prepublishOnly Only before publishing

The security warning

postinstall is powerful and that is exactly why it is dangerous. When you install a package, its postinstall runs on your machine with your permissions, without asking you anything. It can read your ~/.npmrc (where your npm token lives), your environment variables, your SSH keys.

This is not theory: it is the usual vector for supply-chain attacks. A maintainer with a compromised account publishes a patch version with a malicious postinstall, and thousands of machines run it within hours.

Immediate measures, which we will expand on in lesson 05-06:

  • In CI and production, npm ci --ignore-scripts when the project tolerates it.
  • Review the postinstall of new dependencies before accepting them.
  • Do not keep secrets in the environment of the machine that installs packages.

In your own scripts, postinstall is fine for project tasks (creating a directory, generating a derived file). The problem is somebody else's code, not the mechanism.

  1. Chaining commands and surviving Windows

The shell operators work exactly as they are:

Operator Meaning
a && b Runs b only if a exits with code 0
a || b Runs b only if a fails
a ; b Always runs b
a & b Runs both in parallel (not in cmd.exe)

&& is the one you want 95% of the time: it chains and stops at the first failure. || is for fallbacks: "report": "node src/reports/occupancy.js || echo 'report unavailable' 1>&2".

Portability is the real problem. These things break on Windows:

POSIX construct Problem on Windows Solution
rm -rf dist rm does not exist node:fs or the rimraf package
NODE_ENV=production node x.js Invalid syntax in cmd cross-env
cmd1 & cmd2 (parallel) & is sequential npm-run-all --parallel
cat file | node x.js Different behavior Read the file from Node
Single quotes cmd.exe does not interpret them Escaped double quotes

Our Escena Viva scripts are deliberately portable: they only invoke node, eslint and prettier, and they use escaped double quotes. If one day you need inline environment variables:

"dev:verbose": "cross-env LOG_LEVEL=debug node --watch src/server/server.js"

A general rule: if a script starts to look like a program, write it as a program under scripts/ and call it with node. JavaScript is portable; the shell is not.

  1. ESLint and Prettier: what each one solves

They are often confused and they do not compete:

ESLint Prettier
Question it answers Does this code have problems? Is this code well presented?
Detects Unused variables, await in loops, uncaught promises Nothing semantic
Modifies Only what it knows how to fix (--fix) All formatting, always
Debatable Yes, every rule is a team decision No, and that is the point

The modern setup is simple: Prettier owns formatting, ESLint owns correctness, and the style rules in ESLint that would clash with Prettier are switched off. With formatting out of the conversation, code reviews stop arguing about quotes and talk about what matters.

For Escena Viva, ESLint would come in like this:

npm install --save-dev eslint

And the "lint": "eslint src" script starts working with nothing else, thanks to the PATH from section 4. Configuring the rules is a topic in itself and we will not develop it here: what matters for this lesson is that the tool is invoked from scripts and lives in devDependencies.

Common Mistakes and Tips

  • Typing npm dev. Only start, test, stop and restart can drop the run. For everything else, npm run dev.
  • Forgetting the -- when passing arguments. npm run catalog --venue=X never reaches your program; npm run catalog -- --venue=X does.
  • Redirecting without --silent. npm's headers go to stdout and pollute the output file.
  • Writing "test": "exit 0". A false green is worse than an honest red: it destroys trust in CI.
  • Giant scripts of five chained commands. Split them into small scripts and compose with &&. They debug better and get reused.
  • Assuming bash. sh is not bash and cmd.exe is neither of the two. When in doubt, move the logic into a .js file.
  • Depending on npm_package_version in your logic. It only exists if it was started through npm. Use it for diagnostics, with a fallback.
  • Tip: run npm run with no arguments when you enter someone else's project. It is the most reliable documentation you will find, because if it were out of date, it would not work.
  • Tip: name scripts after what they do for the business (report, sales, catalog), not after the tool they use. The tool will change; the intent will not.

Exercises

Exercise 1: a verify-data script with a hook

Add to Escena Viva a verify-data script that checks that data/events.json exists, is valid JSON and contains exactly 3 events with 7 sessions in total. It must print the summary to stdout and exit with code 1 if anything fails. Hook it up so it runs automatically before report.

Exercise 2: arguments and --

Modify src/catalog.js to accept --venue=<name> and filter the sessions of that venue. Check that it works both with plain node and with npm run. Explain why one of the two forms needs --.

Exercise 3: composition and portability

Design a publish-reports script that runs check, then report and then sales, stopping at the first failure. Then review this proposal from a teammate and say which three things will break on Windows:

"publish-reports": "rm -rf output && mkdir output && NODE_ENV=prod node src/reports/occupancy.js > output/occupancy.txt"

Solutions

Solution 1

// src/utils/verify-data.js
'use strict';

const fs = require('node:fs');
const { EVENTS_FILE } = require('../config/paths.js');

const EXPECTED_EVENTS = 3;
const EXPECTED_SESSIONS = 7;

// Reads the seed file and validates its shape; throws when something is off.
function verifyData() {
  const raw = fs.readFileSync(EVENTS_FILE, 'utf8');
  const events = JSON.parse(raw);

  if (!Array.isArray(events)) {
    throw new Error('events.json does not contain an array');
  }
  const totalSessions = events.reduce((sum, ev) => sum + ev.sessions.length, 0);

  if (events.length !== EXPECTED_EVENTS || totalSessions !== EXPECTED_SESSIONS) {
    throw new Error(
      `Expected ${EXPECTED_EVENTS} events and ${EXPECTED_SESSIONS} sessions; ` +
        `there are ${events.length} and ${totalSessions}`
    );
  }
  return { events: events.length, sessions: totalSessions };
}

if (require.main === module) {
  try {
    const summary = verifyData();
    process.stdout.write(`Data is correct: ${summary.events} events, ${summary.sessions} sessions\n`);
  } catch (error) {
    process.stderr.write(`Invalid data: ${error.message}\n`);
    process.exit(1);
  }
}

module.exports = { verifyData };
"verify-data": "node src/utils/verify-data.js",
"prereport": "npm run verify-data --silent"

The prereport hook cuts the chain before report tries to work with corrupt data.

Solution 2

// Fragment of src/catalog.js
const venueFilter = readVenueFromArgs(process.argv.slice(2));
const visibleSessions = venueFilter
  ? sessions.filter((session) => session.venue === venueFilter)
  : sessions;
node src/catalog.js --venue="Teatro Almendra"
npm run catalog -- --venue="Teatro Almendra"

The npm run form needs -- because npm consumes arguments starting with -- as options of its own. With node there is no intermediary: everything after the file goes straight into process.argv.

Solution 3

"publish-reports": "npm run check && npm run report --silent && npm run sales --silent"

The three problems with the teammate's proposal on Windows:

  1. rm -rf does not exist in cmd.exe; you have to use node -e "fs.rmSync('output', { recursive: true, force: true })" or rimraf.
  2. NODE_ENV=prod command is not valid syntax in cmd.exe; it requires cross-env.
  3. mkdir output fails if the directory already exists, with different behavior depending on the shell; fs.mkdirSync(..., { recursive: true }) is preferable.

On top of that, the > redirection without --silent would push npm's headers into output/occupancy.txt.

Conclusion

Escena Viva's package.json has no gaps left. scripts is now the project's single interface: npm start brings up the server, npm run dev reloads it with node --watch without depending on anyone, npm run catalog, npm run report and npm run sales expose the work from modules 1 and 3, npm run check composes linting and formatting, and npm test fails with an honest message that points at Module 9.

Along the way you have understood the mechanism holding it up: npm runs shell with a PATH that starts at node_modules/.bin, and that is why the project's tools are invoked by name with no global install. You know how to separate npm's arguments from yours with --, how to use npm_package_version for diagnostics, how to chain with && without sacrificing portability, and to distrust somebody else's postinstall.

In the next lesson, Creating and Publishing Packages, we switch sides of the counter. So far we have consumed packages; now we are going to publish one. We will extract src/utils/format.js —with formatPrice, formatDate and generateTicketCode— into a package of its own, @escena-viva/format, and see how you decide what is public API with exports, what really gets uploaded with files, how to inspect the tarball with npm pack before it is too late, and why unpublishing a package is almost impossible.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved