Skip to content
aviral gupta

// B2.2 · ~36 min · Beginner

Installing, package-lock.json and npm ci

After this lesson you can add and remove packages, read package-lock.json, reinstall a project exactly with npm ci, and run a package command with npx.

Lesson 2 of 5 in B2 npm and packages

You will be able to

  • Install and remove packages with npm install, --save-dev and npm uninstall, and say where they end up
  • Read package-lock.json: version, resolved, integrity and dev, and why it is committed
  • Reinstall exactly with npm ci, predict when it stops, and run package commands with npx
  1. Warm-up · Activity 1 of 7

    Warm-up from module B1: you imported node:fs and node:util without installing anything. In a new project with nothing installed yet, which of these imports fails until you install a package with npm?

  2. Predict · Activity 2 of 7

    Predict before you read on. greet-1.2.0.tgz is a package made with npm pack. app/package.json has no "dependencies" yet. You run this command in app. What is "dependencies" afterwards?

    npm install ../greet/greet-1.2.0.tgz
  3. Practice · Activity 3 of 7

    check is a tool you use only while developing. Fill in the flag so that npm saves it in "devDependencies".

    npm install ____ ../check/check-0.3.1.tgz
    npm install ../check/check-0.3.1.tgz
  4. Practice · Activity 4 of 7

    Match each field of a package entry in package-lock.json to what it records.

  5. Practice · Activity 5 of 7

    What does the last command do?

    npm install ../greet/greet-1.2.0.tgz
    rm package-lock.json
    npm ci
  6. Brain teaser · Activity 6 of 7

    Brain teaser. greet is installed and in package-lock.json. Then someone adds "check": "file:../check/check-0.3.1.tgz" to "dependencies" by hand, in an editor, and does not run npm install. What does this command do?

    npm ci
  7. Apply · Activity 7 of 7

    Mini-task, offline. Make a folder greet with package.json ("name": "greet", "version": "1.2.0", "type": "module", "exports": "./index.js") and an index.js that exports greet(name). Run npm pack in it. Next to it, make app with npm init -y and "type": "module", install ../greet/greet-1.2.0.tgz and import greet in main.js. Run it. Then delete node_modules, run npm ci and run main.js again.

    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

What npm install records

This program plays the part of your terminal, and needs no network. It makes a tiny package, greet 1.2.0, packs it with npm pack, and installs the tarball into an app in a temporary folder. Then it prints what npm wrote into package.json and package-lock.json, and runs the app, which imports greet by its package name.

main.js

// Plays the part of your terminal: packs a tiny local package, installs it
// into an app and shows what npm recorded. No registry, no network.
import {execSync} from "node:child_process";
import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";

const root = mkdtempSync(join(tmpdir(), "deps-"));
const put = (file, text) => {
  mkdirSync(join(root, file, ".."), {recursive: true});
  writeFileSync(join(root, file), text);
};
const run = (command, dir) => execSync(command, {cwd: join(root, dir), encoding: "utf8"});

// A package of our own, greet 1.2.0, packed into greet-1.2.0.tgz.
put("greet/package.json", JSON.stringify({name: "greet", version: "1.2.0", type: "module", exports: "./index.js"}));
put("greet/index.js", 'export const greet = (name) => "Hello, " + name + "!";\n');
run("npm pack --silent", "greet");

// The app that installs it.
put("app/package.json", JSON.stringify({name: "app", version: "1.0.0", private: true, type: "module"}));
put("app/main.js", 'import {greet} from "greet";\nconsole.log(greet("Ada"));\n');
run("npm install --silent --no-audit --no-fund ../greet/greet-1.2.0.tgz", "app");

const pkg = JSON.parse(readFileSync(join(root, "app/package.json"), "utf8"));
const lock = JSON.parse(readFileSync(join(root, "app/package-lock.json"), "utf8"));
const entry = lock.packages["node_modules/greet"];
console.log("dependencies:", pkg.dependencies);
console.log("lockfileVersion:", lock.lockfileVersion);
console.log("locked:", entry.version, "from", entry.resolved);
console.log("integrity:", entry.integrity.slice(0, 7) + "...");
console.log(run("node main.js", "app").trim());

Run it with

node main.js

Output

dependencies: { greet: 'file:../greet/greet-1.2.0.tgz' }
lockfileVersion: 3
locked: 1.2.0 from file:../greet/greet-1.2.0.tgz
integrity: sha512-...
Hello, Ada!
  • package.json keeps what you asked for: the path of the tarball.
  • package-lock.json keeps what was installed: the exact version 1.2.0, where it came from and a sha512 hash of the file.
  • import {greet} from "greet" works because npm unpacked the package into app/node_modules/greet.
  • Delete app/node_modules and run npm ci in app: the same tree comes back from the lock.

Exercises

Exercise 1 of 2

The versions in a lock file

Write lockedVersions(lock), which takes a parsed package-lock.json and returns an object that maps each top-level package name to its locked version, such as {greet: "1.2.0"}. Top-level packages are the keys of lock.packages that start with node_modules/ and contain no further /node_modules/. Keep scoped names like @acme/log whole. Skip the project itself, the key "".

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
  1. Hint 1

    Object.entries(lock.packages) gives you [path, entry] pairs to loop over.

  2. Hint 2

    Skip paths that do not start with "node_modules/". Cut that prefix off to get the name.

  3. Hint 3

    A name that still contains "/node_modules/" belongs to a nested package: skip it. "@acme/log" contains a slash but no "/node_modules/", so it stays.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Maps each top-level package in a parsed package-lock.json to its version.
export function lockedVersions(lock) {
  const versions = {};
  for (const [path, entry] of Object.entries(lock.packages ?? {})) {
    if (!path.startsWith("node_modules/")) continue;
    const name = path.slice("node_modules/".length);
    if (name.includes("/node_modules/")) continue;
    versions[name] = entry.version;
  }
  return versions;
}

const lock = {lockfileVersion: 3, packages: {"": {name: "app"}, "node_modules/greet": {version: "1.2.0"}}};
console.log(lockedVersions(lock));
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

// Maps each top-level package in a parsed package-lock.json to its version.
export function lockedVersions(lock) {
  const versions = {};
  return versions;
}

const lock = {lockfileVersion: 3, packages: {"": {name: "app"}, "node_modules/greet": {version: "1.2.0"}}};
console.log(lockedVersions(lock));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {lockedVersions} from './main.js';

const lock = {
  name: 'app',
  lockfileVersion: 3,
  packages: {
    '': {name: 'app', version: '1.0.0'},
    'node_modules/check': {version: '0.3.1', dev: true},
    'node_modules/greet': {version: '1.2.0'},
    'node_modules/@acme/log': {version: '2.0.0'},
    'node_modules/greet/node_modules/tiny': {version: '0.1.0'}
  }
};

test('maps each top-level package to its version', () => {
  const got = lockedVersions(lock);
  assert.deepEqual(got, {check: '0.3.1', greet: '1.2.0', '@acme/log': '2.0.0'}, `lockedVersions(lock) returned ${JSON.stringify(got)}`);
});

test('returns an empty object when nothing is installed', () => {
  const got = lockedVersions({lockfileVersion: 3, packages: {'': {name: 'app'}}});
  assert.deepEqual(got, {}, `a lock with only the project 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.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

Would npm ci complain?

npm ci stops when package.json and the lock disagree. Write outOfSync(pkg, lock), a simple version of that check. Collect what package.json asks for in "dependencies" and "devDependencies", and what the root entry of the lock, lock.packages[""], recorded in the same two fields. Return the sorted names whose specifier differs, or that appear on only one side. In sync 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
  1. Hint 1

    Spread both fields into one object: {...pkg.dependencies, ...pkg.devDependencies}. Spreading undefined is fine.

  2. Hint 2

    Do the same with lock.packages[""], then collect all names from both objects in a Set.

  3. Hint 3

    Keep a name when asked[name] !== locked[name]; a missing side is undefined, so it counts as different. Sort the result.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Names that package.json and the root entry of package-lock.json disagree on.
export function outOfSync(pkg, lock) {
  const root = lock.packages?.[""] ?? {};
  const asked = {...pkg.dependencies, ...pkg.devDependencies};
  const locked = {...root.dependencies, ...root.devDependencies};
  const names = new Set([...Object.keys(asked), ...Object.keys(locked)]);
  return [...names].filter((name) => asked[name] !== locked[name]).sort();
}

const pkg = {dependencies: {greet: "file:../greet/greet-1.2.0.tgz", check: "file:../check/check-0.3.1.tgz"}};
const lock = {packages: {"": {dependencies: {greet: "file:../greet/greet-1.2.0.tgz"}}}};
console.log(outOfSync(pkg, lock));
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

// Names that package.json and the root entry of package-lock.json disagree on.
export function outOfSync(pkg, lock) {
  return [];
}

const pkg = {dependencies: {greet: "file:../greet/greet-1.2.0.tgz", check: "file:../check/check-0.3.1.tgz"}};
const lock = {packages: {"": {dependencies: {greet: "file:../greet/greet-1.2.0.tgz"}}}};
console.log(outOfSync(pkg, lock));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {outOfSync} from './main.js';

const GREET = 'file:../greet/greet-1.2.0.tgz';
const CHECK = 'file:../check/check-0.3.1.tgz';
const lock = {lockfileVersion: 3, packages: {'': {name: 'app', dependencies: {greet: GREET}, devDependencies: {check: CHECK}}}};

test('returns [] when package.json and the lock agree', () => {
  const got = outOfSync({dependencies: {greet: GREET}, devDependencies: {check: CHECK}}, lock);
  assert.deepEqual(got, [], `outOfSync returned ${JSON.stringify(got)} for matching files`);
});

test('names a dependency added to package.json by hand', () => {
  const got = outOfSync({dependencies: {greet: GREET, extra: 'file:../extra'}, devDependencies: {check: CHECK}}, lock);
  assert.deepEqual(got, ['extra'], `outOfSync returned ${JSON.stringify(got)}`);
});

test('names a dependency whose specifier changed or that was removed', () => {
  const got = outOfSync({dependencies: {greet: 'file:../greet/greet-1.3.0.tgz'}}, lock);
  assert.deepEqual(got, ['check', 'greet'], `outOfSync returned ${JSON.stringify(got)}; expected both names, sorted`);
});

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.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Running a fresh clone before installing

// main.js of a project you just cloned. package.json lists greet,
// but node_modules is not in the repository.
import {greet} from "greet";

console.log(greet("Ada"));

What Node.js prints

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'greet' imported from

Why, and the fix

A bare name like greet is looked up in node_modules, and node_modules is not committed. After cloning, run npm ci, which installs exactly what package-lock.json records, or npm install when there is no lock yet. Then run the program again.

Looking a package up by its name in "packages"

const lock = {
  name: "app",
  version: "1.0.0",
  lockfileVersion: 3,
  requires: true,
  packages: {
    "": {
      name: "app",
      version: "1.0.0",
      dependencies: {greet: "file:../greet/greet-1.2.0.tgz"},
      devDependencies: {check: "file:../check/check-0.3.1.tgz"}
    },
    "node_modules/check": {version: "0.3.1", resolved: "file:../check/check-0.3.1.tgz", integrity: "sha512-4izY", dev: true},
    "node_modules/greet": {version: "1.2.0", resolved: "file:../greet/greet-1.2.0.tgz", integrity: "sha512-4I5b"}
  }
};

// The keys of "packages" are paths, not names.
console.log(lock.packages["greet"].version);

What Node.js prints

TypeError: Cannot read properties of undefined (reading 'version')

Why, and the fix

The keys of "packages" are folder paths: "" for the project, "node_modules/greet" for greet. lock.packages["greet"] is undefined, so reading .version from it fails. Use lock.packages["node_modules/greet"].version, which is "1.2.0".

Reading the old "dependencies" section

const lock = {
  name: "app",
  version: "1.0.0",
  lockfileVersion: 3,
  requires: true,
  packages: {
    "": {
      name: "app",
      version: "1.0.0",
      dependencies: {greet: "file:../greet/greet-1.2.0.tgz"},
      devDependencies: {check: "file:../check/check-0.3.1.tgz"}
    },
    "node_modules/check": {version: "0.3.1", resolved: "file:../check/check-0.3.1.tgz", integrity: "sha512-4izY", dev: true},
    "node_modules/greet": {version: "1.2.0", resolved: "file:../greet/greet-1.2.0.tgz", integrity: "sha512-4I5b"}
  }
};

// Code written for old lock files reads lock.dependencies.
console.log(Object.keys(lock.dependencies));

What Node.js prints

TypeError: Cannot convert undefined or null to object

Why, and the fix

The "dependencies" section is legacy data for lockfileVersion 1. npm 9 and later write lockfileVersion 3, which has only "packages", so lock.dependencies is undefined and Object.keys cannot take it. Read lock.packages and its "node_modules/..." keys instead.

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.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

npm install puts packages into node_modules

npm install <package> installs a package and what it depends on into node_modules and adds it to "dependencies" in package.json; with --save-dev (-D) it goes to "devDependencies". Without a name, npm install installs everything package.json lists. Packages usually come from the npm registry; this lesson uses a tarball made with npm pack, so it works offline: npm install ../greet/greet-1.2.0.tgz is saved as "file:../greet/greet-1.2.0.tgz". Commands a package ships are linked into node_modules/.bin, where npm scripts and npx find them; your shell does not. npm uninstall greet removes it from node_modules, package.json and package-lock.json.

package-lock.json records the exact tree

npm writes package-lock.json whenever it changes node_modules or package.json. You commit it, and not node_modules: the lock lets teammates and CI rebuild the same tree. Under "packages", the key "" is your project and "node_modules/greet" an installed package: "version" is the exact version, "resolved" where it came from, "integrity" a sha512 hash of the package file, and "dev": true marks what only devDependencies need. npm 9 and later write "lockfileVersion": 3, without the old "dependencies" section. When the lock satisfies package.json, npm install uses the locked versions; when they conflict, package.json wins and npm updates the lock.

npm ci installs exactly what the lock says

npm ci is the clean install for CI and fresh clones. It stops when there is no package-lock.json. It deletes node_modules first, installs exactly what the lock lists and never writes package.json or the lock. If the two disagree, say after someone edited package.json by hand, it stops instead of updating the lock: run npm install and commit the new lock. It installs whole projects only, never one package, and --omit=dev leaves devDependencies out. npx hello runs the command hello from an installed package; for a package that is not installed, npx asks first, then fetches it from the registry into npm's cache.

Sources

Last reviewed September 30, 2026