Warm-up · Activity 1 of 7
// B2.1 · ~34 min · Beginner
package.json and npm scripts
After this lesson you can create and read a package.json, run its scripts with npm, and decide when node --run is enough.
Lesson 1 of 5 in B2 npm and packages
You will be able to
- Create package.json with npm init -y and read name, version, private, type, dependencies and devDependencies
- Run scripts with npm run, npm test and npm start, pass arguments after --, and predict the pre and post scripts
- Choose between npm run and node --run, knowing what node --run leaves out on purpose
Predict · Activity 2 of 7
Predict before you read on. This is package.json, and step.js prints the words it is given. You run npm run -s build (-s only hides npm's own > lines). What is printed, line by line?
{ "name": "demo", "version": "1.0.0", "scripts": { "prebuild": "node step.js pre", "build": "node step.js build", "postbuild": "node step.js post" } }Practice · Activity 3 of 7
The "greet" script is node greet.js. Fill in the gap so that greet.js receives --name Ada, and npm does not take --name as its own option.
npm run greet ____ --name Adanpm run greet --name AdaPractice · Activity 4 of 7
Match each package.json entry to what it does.
Practice · Activity 5 of 7
In this project you type npm run dev. What happens?
{ "name": "demo", "version": "1.0.0", "scripts": { "start": "node main.js", "test": "node --test" } }Brain teaser · Activity 6 of 7
Brain teaser. The same project as before: "prebuild", "build" and "postbuild" each run step.js. What does this command print?
node --run buildApply · Activity 7 of 7
Mini-task. In a new folder run npm init -y. In package.json, change "type" to "module", add "private": true, replace the "test" script with node --test and add a "pretest" script that runs echo checking. Write sum.js with an exported sum function and sum.test.js with one test. Run npm test, then node --run test, and compare the output.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
npm run and node --run, side by side
This program plays the part of your terminal. It writes a small project into a temporary folder: a "build" script with a "prebuild" and a "postbuild", each running step.js, which prints the words it gets and the npm_package_version variable. Then it runs the same command with npm run and with node --run and prints what each wrote to stdout. You need Node.js and npm, and no network.
main.js
// Plays the part of your terminal: makes a small project in a temporary
// folder and runs the same script with npm run and with node --run.
import {execSync} from "node:child_process";
import {mkdtempSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
const dir = mkdtempSync(join(tmpdir(), "scripts-"));
const pkg = {
name: "demo",
version: "1.0.0",
private: true,
scripts: {
prebuild: "node step.js prebuild",
build: "node step.js build",
postbuild: "node step.js postbuild"
}
};
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2));
// step.js prints the words it got and the version that npm passes on.
writeFileSync(join(dir, "step.js"), 'console.log(process.argv.slice(2).join(" "), "| version:", process.env.npm_package_version);\n');
for (const command of ["npm run build -- --fast", "node --run build -- --fast"]) {
console.log("$ " + command);
console.log(execSync(command, {cwd: dir, encoding: "utf8"}).trim());
}
Run it with
node main.jsOutput
$ npm run build -- --fast
> demo@1.0.0 prebuild
> node step.js prebuild
prebuild | version: 1.0.0
> demo@1.0.0 build
> node step.js build --fast
build --fast | version: 1.0.0
> demo@1.0.0 postbuild
> node step.js postbuild
postbuild | version: 1.0.0
$ node --run build -- --fast
build --fast | version: undefined- npm run prints > demo@1.0.0 and the command before each script; npm run -s would hide those lines.
- --fast reaches build only: npm passes arguments after -- to the named script, not to prebuild or postbuild.
- node --run ran build alone, and npm_package_version is undefined there: it sets no npm_ variables.
- Both commands appended --fast to the same "build" entry of package.json.
Exercises
Exercise 1 of 2
What npm run will run
Write runOrder(pkg, name). pkg is a parsed package.json. Return the script names that npm run <name> runs, in order: pre<name> if it exists, then <name>, then post<name> if it exists. If "scripts" has no <name>, or there is no "scripts" at all, throw an Error whose message is Missing script: "<name>", as npm does. The last line of main.js prints the order for a small project.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Use pkg.scripts ?? {} so that a package.json without "scripts" does not crash your code.
Hint 2
name in scripts tells you whether a script exists. Throw first if it does not.
Hint 3
Build the three candidate names, "pre" + name, name and "post" + name, and keep only those that are in scripts.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The script names that npm run <name> runs, in order.
export function runOrder(pkg, name) {
const scripts = pkg.scripts ?? {};
if (!(name in scripts)) throw new Error('Missing script: "' + name + '"');
return ["pre" + name, name, "post" + name].filter((script) => script in scripts);
}
const pkg = {scripts: {prebuild: "node clean.js", build: "node build.js", postbuild: "echo done"}};
console.log(runOrder(pkg, "build"));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// The script names that npm run <name> runs, in order.
export function runOrder(pkg, name) {
return [name];
}
const pkg = {scripts: {prebuild: "node clean.js", build: "node build.js", postbuild: "echo done"}};
console.log(runOrder(pkg, "build"));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runOrder} from './main.js';
const pkg = {
name: 'demo',
scripts: {prebuild: 'node clean.js', build: 'node build.js', postbuild: 'echo done', test: 'node --test', posttest: 'echo tested', start: 'node main.js'}
};
test('runs prebuild, build and postbuild in that order', () => {
const got = runOrder(pkg, 'build');
assert.deepEqual(got, ['prebuild', 'build', 'postbuild'], `runOrder(pkg, "build") returned ${JSON.stringify(got)}`);
});
test('leaves out a pre or post script that does not exist', () => {
const test = runOrder(pkg, 'test');
assert.deepEqual(test, ['test', 'posttest'], `runOrder(pkg, "test") returned ${JSON.stringify(test)}`);
const start = runOrder(pkg, 'start');
assert.deepEqual(start, ['start'], `runOrder(pkg, "start") returned ${JSON.stringify(start)}`);
});
test('throws Missing script for a script that is not there', () => {
assert.throws(() => runOrder(pkg, 'dev'), {message: 'Missing script: "dev"'}, 'runOrder(pkg, "dev") should throw an Error with the message Missing script: "dev"');
});
test('also throws when package.json has no "scripts" at all', () => {
assert.throws(() => runOrder({name: 'empty'}, 'test'), {message: 'Missing script: "test"'}, 'runOrder({name: "empty"}, "test") should throw Missing script: "test"');
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 2 of 2
Check what npm init -y gave you
Write checkPackage(pkg), which returns a list of problems for a parsed package.json, in this order: 'type: set it to "module"' when "type" is not "module"; 'private: set it to true' when "private" is not true; 'test: write a real test script' when there is no "test" script or it is still the placeholder that npm init -y writes, kept in PLACEHOLDER. A package.json without problems gives [].
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.
Hints
Hint 1
Check the three rules one after another with if, and push the message of each rule that fails.
Hint 2
pkg.private !== true is also true when "private" is missing.
Hint 3
pkg.scripts?.test is undefined when there is no "scripts" object, so !test covers both missing cases.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
// The "test" script that npm init -y writes.
const PLACEHOLDER = 'echo "Error: no test specified" && exit 1';
export function checkPackage(pkg) {
const problems = [];
if (pkg.type !== "module") problems.push('type: set it to "module"');
if (pkg.private !== true) problems.push("private: set it to true");
const test = pkg.scripts?.test;
if (!test || test === PLACEHOLDER) problems.push("test: write a real test script");
return problems;
}
console.log(checkPackage({name: "demo", version: "1.0.0", type: "commonjs", scripts: {test: PLACEHOLDER}}));
Run it on your computer
Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.js
// The "test" script that npm init -y writes.
const PLACEHOLDER = 'echo "Error: no test specified" && exit 1';
export function checkPackage(pkg) {
const problems = [];
return problems;
}
console.log(checkPackage({name: "demo", version: "1.0.0", type: "commonjs", scripts: {test: PLACEHOLDER}}));
main.test.js
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {checkPackage} from './main.js';
// Exactly what npm init -y wrote with npm 11.19.0.
const fromInit = {
name: 'demo',
version: '1.0.0',
description: '',
main: 'index.js',
scripts: {test: 'echo "Error: no test specified" && exit 1'},
keywords: [],
author: '',
license: 'ISC',
type: 'commonjs'
};
test('finds all three problems in what npm init -y writes', () => {
const got = checkPackage(fromInit);
assert.deepEqual(got, ['type: set it to "module"', 'private: set it to true', 'test: write a real test script'], `checkPackage(fromInit) returned ${JSON.stringify(got)}`);
});
test('returns an empty list for a finished package.json', () => {
const got = checkPackage({name: 'demo', version: '1.0.0', private: true, type: 'module', scripts: {test: 'node --test'}});
assert.deepEqual(got, [], `a finished package.json gave ${JSON.stringify(got)}`);
});
test('treats a missing test script like the placeholder', () => {
const got = checkPackage({private: true, type: 'module'});
assert.deepEqual(got, ['test: write a real test script'], `a package.json without scripts gave ${JSON.stringify(got)}`);
});
package.json
{
"type": "module"
}
package.json tells Node.js that the .js files are modules; keep it in the folder.
Run the program:
node main.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon mistakes
A comma after the last entry of package.json
// package.json as it was saved, with a comma after the last script.
const text = `{
"name": "demo",
"scripts": {
"start": "node main.js",
}
}`;
const pkg = JSON.parse(text);
console.log(pkg.scripts.start);
What Node.js prints
SyntaxError: Expected double-quoted property name in JSON at position 66 (line 5 column 3)Why, and the fix
package.json is JSON, not JavaScript: no comma after the last entry and no comments. JSON.parse rejects this text, and so does npm: npm start stops with npm error code EJSONPARSE and the note that package.json must be actual JSON. Delete the comma after "node main.js".
Relying on a pre script under node --run
// main.js, started by the "build" script. The "prebuild" script writes
// version.txt first, but only npm run build runs prebuild.
import {readFileSync} from "node:fs";
const version = readFileSync("version.txt", "utf8").trim();
console.log("building version", version);
What Node.js prints
Error: ENOENT: no such file or directory, open 'version.txt'Why, and the fix
npm run build runs prebuild, which writes version.txt, before build. node --run build runs only build, so the file is missing, exactly as when you run node main.js by hand. Run such a project with npm run build, or make the step explicit in one script: "build": "node version.js && node main.js".
Reading npm_package_version under node --run
// main.js, started by "start": "node main.js". npm start sets
// npm_package_version; node --run start and node main.js do not.
const [major] = process.env.npm_package_version.split(".");
console.log("major version", major);
What Node.js prints
TypeError: Cannot read properties of undefined (reading 'split')Why, and the fix
npm sets npm_package_name, npm_package_version and npm_lifecycle_event for its scripts. node --run leaves them out on purpose and sets only NODE_RUN_SCRIPT_NAME and NODE_RUN_PACKAGE_JSON_PATH, so the variable is undefined. To work however it is started, read the version from package.json itself instead of from the environment.
JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.