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
- What the
scriptsfield really is - Built-in scripts versus custom scripts
- Escena Viva's scripts, one by one
- The extended
PATH:node_modules/.bin - Passing arguments with
-- - Environment variables in scripts
preandposthooks (and thepostinstallwarning)- Chaining commands and surviving Windows
- ESLint and Prettier: what each one solves
- Common mistakes and tips
- Exercises
- Conclusion
- What the
scripts field really is
scripts field really isscripts is an object where every key is a name and every value is a shell line. When you run npm run <name>, npm:
- Looks up the key
<name>in the current directory'spackage.json. - Prepares a special environment (
npm_*variables and an extendedPATH, which we will get to). - Launches that line with the system shell (
/bin/shon POSIX,cmd.exeon Windows unless configured otherwise). - Returns the command's exit code:
0is 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 |
- 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.
- 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
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
--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
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 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:
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
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
--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
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
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.
- The extended
PATH: node_modules/.bin
PATH: node_modules/.binHere is the promised answer. Many packages declare executables in their own package.json:
When npm installs a package with a bin field, it creates a link in node_modules/.bin/. After installing Prettier, the project has:
And when you run npm run <something>, npm prepends that directory to the child process's PATH. You can see it for yourself:
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
npxinside scripts.npx prettierworks, but it adds an unnecessary resolution step. Insidescripts, write the plain binary name. - If a command fails with
command not foundinside a script, it almost always means the package is missing from the dependencies, not that a global install is missing.
- Passing arguments with
--
--This does not do what it looks like:
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:
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.
- 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.
pre and post hooks
pre and post hooksFor 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-scriptswhen the project tolerates it. - Review the
postinstallof 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.
- 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:
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.
- 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:
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. Onlystart,test,stopandrestartcan drop therun. For everything else,npm run dev. - Forgetting the
--when passing arguments.npm run catalog --venue=Xnever reaches your program;npm run catalog -- --venue=Xdoes. - 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.
shis notbashandcmd.exeis neither of the two. When in doubt, move the logic into a.jsfile. - Depending on
npm_package_versionin your logic. It only exists if it was started through npm. Use it for diagnostics, with a fallback. - Tip: run
npm runwith 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 };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;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
The three problems with the teammate's proposal on Windows:
rm -rfdoes not exist incmd.exe; you have to usenode -e "fs.rmSync('output', { recursive: true, force: true })"orrimraf.NODE_ENV=prod commandis not valid syntax incmd.exe; it requirescross-env.mkdir outputfails 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
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
