Emmanuel Akinfulubi

Shipping it all 2 written

Turning a folder into a package someone else can install, and one project built, tested and shipped end to end.

49 Packaging: exports, npm pack and a single executable

Lesson 23 showed that an npm package is a tarball of a folder with a package.json in it. Anyone can make one; making a good one means deciding three things on purpose: which files go in it, which parts of your code other people may use, and what a version number promises. Then, for a tool you want to hand to people who do not have Node at all, there is one more option: a single executable file.

What goes in the tarball

npm pack --dry-run, before and after "files"

ran at build time

../slugger/package.json

{
  "name": "slugger",
  "version": "1.0.0",
  "description": "Turn titles into URL slugs",
  "type": "module",
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "default": "./src/index.js"
    }
  },
  "engines": { "node": ">=22" },
  "license": "MIT"
}

../slugger/README.md

# slugger

Turn titles into URL slugs.

../slugger/src/index.js

import { clean } from "./internal.js";

export function slugify(title) {
  return clean(title).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}

../slugger/src/internal.js

// Not part of the public API: free to change in any release.
export const clean = (text) => text.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "");

../slugger/types/index.d.ts

/** Turn a title into a URL slug: "Crème Brûlée" -> "creme-brulee". */
export declare function slugify(title: string): string;

../slugger/test/slugify.test.js

import { test } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "../src/index.js";

test("slugify", () => assert.equal(slugify("Crème Brûlée"), "creme-brulee"));

../slugger/TODO.txt

rename internal.js before 2.0

../slugger/.env

NPM_TOKEN=pretend-token-do-not-publish

The terminal, as the build ran it

cd ../slugger && npm pack --dry-run
npm notice
npm notice 📦  slugger@1.0.0
npm notice Tarball Contents
npm notice 39B .env
npm notice 39B README.md
npm notice 30B TODO.txt
npm notice 274B package.json
npm notice 148B src/index.js
npm notice 163B src/internal.js
npm notice 200B test/slugify.test.js
npm notice 130B types/index.d.ts
npm notice Tarball Details
npm notice name: slugger
npm notice version: 1.0.0
npm notice filename: slugger-1.0.0.tgz
npm notice package size: 839 B
npm notice unpacked size: 1.0 kB
npm notice shasum: 027d336a421f2ad835fb8ef0ed59ee73d87a919c
npm notice integrity: sha512-DN+klKEyOGZhP[...]+poGo9aPqnUdQ==
npm notice total files: 8
npm notice
slugger-1.0.0.tgz
npm pkg set 'files[]=src' 'files[]=types'
npm pkg get files
[
  "src",
  "types"
]
npm pack --dry-run
npm notice
npm notice 📦  slugger@1.0.0
npm notice Tarball Contents
npm notice 39B README.md
npm notice 321B package.json
npm notice 148B src/index.js
npm notice 163B src/internal.js
npm notice 130B types/index.d.ts
npm notice Tarball Details
npm notice name: slugger
npm notice version: 1.0.0
npm notice filename: slugger-1.0.0.tgz
npm notice package size: 665 B
npm notice unpacked size: 801 B
npm notice shasum: b36aa1151834f35ba02d294dc828b8b2160c5a32
npm notice integrity: sha512-eExzexL2ysN7L[...]TNq/fojmtA4ew==
npm notice total files: 5
npm notice
slugger-1.0.0.tgz

npm pack --dry-run lists exactly what npm publish would upload, without making anything. The first run is the reason to always look: with no "files" field, npm includes every file in the folder that is not ignored, and this folder has no .gitignore. So the package would have shipped the tests, a to-do list, and a .env holding an npm token — to a public registry, where published versions are copied by mirrors within minutes. Leaked credentials in published packages are a real and recurring problem.

"files" turns the rule around into an allowlist: only src and types go in, plus the few files npm always includes (package.json, the README, a licence). Anything new you add to the folder later stays out unless you add it to the list. Run npm pack --dry-run before every publish.

The public API: exports

Using the package, and reaching past its exports

ran at build time

../slugger/package.json

{
  "name": "slugger",
  "version": "1.0.0",
  "description": "Turn titles into URL slugs",
  "type": "module",
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "default": "./src/index.js"
    }
  },
  "files": ["src", "types"],
  "engines": { "node": ">=22" },
  "license": "MIT"
}

../slugger/README.md

# slugger

Turn titles into URL slugs.

../slugger/src/index.js

import { clean } from "./internal.js";

export function slugify(title) {
  return clean(title).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}

../slugger/src/internal.js

// Not part of the public API: free to change in any release.
export const clean = (text) => text.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "");

../slugger/types/index.d.ts

/** Turn a title into a URL slug: "Crème Brûlée" -> "creme-brulee". */
export declare function slugify(title: string): string;

../slugger/test/slugify.test.js

import { test } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "../src/index.js";

test("slugify", () => assert.equal(slugify("Crème Brûlée"), "creme-brulee"));

../slugger/TODO.txt

rename internal.js before 2.0

../slugger/.env

NPM_TOKEN=pretend-token-do-not-publish

package.json

{ "name": "app", "version": "1.0.0", "private": true }

main.mjs

import { slugify } from "slugger";

console.log(slugify("Crème Brûlée & Friends"));

deep.mjs

import { clean } from "slugger/src/internal.js";

console.log(clean("Café"));

legacy.cjs

// A CommonJS project using the ES-module-only package.
const { slugify } = require("slugger");

console.log("from require:", slugify("Hello, CommonJS"));

The terminal, as the build ran it

cd ../slugger && npm pack
npm notice
npm notice 📦  slugger@1.0.0
npm notice Tarball Contents
npm notice 39B README.md
npm notice 303B package.json
npm notice 148B src/index.js
npm notice 163B src/internal.js
npm notice 130B types/index.d.ts
npm notice Tarball Details
npm notice name: slugger
npm notice version: 1.0.0
npm notice filename: slugger-1.0.0.tgz
npm notice package size: 665 B
npm notice unpacked size: 783 B
npm notice shasum: 3160702b0f875c9d536647a069d41010470094de
npm notice integrity: sha512-x2kVD2vhqIDlb[...]AppZOcnriEedw==
npm notice total files: 5
npm notice
slugger-1.0.0.tgz
cd ../app && npm install ../slugger/slugger-1.0.0.tgz

added 1 package in 311ms
node main.mjs
creme-brulee-friends
node deep.mjs   # exits 1
node:internal/modules/esm/resolve:315
  return new ERR_PACKAGE_PATH_NOT_EXPORTED(
         ^

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/internal.js' is not defined by "exports" in /app/node_modules/slugger/package.json imported from /app/deep.mjs
    at exportsNotFound (node:internal/modules/esm/resolve:315:10)
    at packageExportsResolve (node:internal/modules/esm/resolve:663:9)
    at packageResolve (node:internal/modules/esm/resolve:792:12)
    at moduleResolve (node:internal/modules/esm/resolve:873:18)
    at defaultResolve (node:internal/modules/esm/resolve:1006:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
    at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
    at #resolve (node:internal/modules/esm/loader:690:17)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35) {
  code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
}

Node.js v26.7.0
node legacy.cjs
from require: hello-commonjs

"exports" is the package's front door. "." is what import ... from "slugger" means, and nothing else can be imported at all: deep.mjs reached for src/internal.js, a file that is right there on disk, and Node refused with ERR_PACKAGE_PATH_NOT_EXPORTED. That refusal is what lets you rename, split or delete internal files in any release without breaking anyone, because nobody could have depended on them.

Each entry can be a set of conditions, tried in order. "types" is read by the TypeScript checker, which finds the .d.ts file describing slugify (lesson 47: a package ships JavaScript, and its types beside it). Node does not know that condition, skips it, and uses "default". And the last command shows why a package written as ES modules no longer needs a second, CommonJS copy: require() of an ES module works in this Node, so CommonJS users can load it too, as long as it has no top-level await (lesson 21).

What a version promises

npm version

ran at build time

../slugger/package.json

{
  "name": "slugger",
  "version": "1.0.0",
  "description": "Turn titles into URL slugs",
  "type": "module",
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "default": "./src/index.js"
    }
  },
  "files": ["src", "types"],
  "engines": { "node": ">=22" },
  "license": "MIT"
}

../slugger/README.md

# slugger

Turn titles into URL slugs.

../slugger/src/index.js

import { clean } from "./internal.js";

export function slugify(title) {
  return clean(title).replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}

../slugger/src/internal.js

// Not part of the public API: free to change in any release.
export const clean = (text) => text.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "");

../slugger/types/index.d.ts

/** Turn a title into a URL slug: "Crème Brûlée" -> "creme-brulee". */
export declare function slugify(title: string): string;

../slugger/test/slugify.test.js

import { test } from "node:test";
import assert from "node:assert/strict";
import { slugify } from "../src/index.js";

test("slugify", () => assert.equal(slugify("Crème Brûlée"), "creme-brulee"));

../slugger/TODO.txt

rename internal.js before 2.0

../slugger/.env

NPM_TOKEN=pretend-token-do-not-publish

The terminal, as the build ran it

cd ../slugger && npm version patch --no-git-tag-version
v1.0.1
npm version minor --no-git-tag-version
v1.1.0
npm version major --no-git-tag-version
v2.0.0
npm pkg get version
"2.0.0"

A version is a promise to everyone whose package.json says "^1.0.0" (lesson 23): they will get every 1.x release automatically. npm version bumps the number in package.json (and, in a git repository without --no-git-tag-version, also commits and tags it). The hard part is choosing which number.

BumpWhenExamples
patch, 1.0.1fixes, with nothing newa bug fix; a faster implementation
minor, 1.1.0new things, nothing removeda new export; a new optional argument
major, 2.0.0anything that could break a userremoving or renaming an export; changing "exports"; changing what a function returns; raising "engines"

The last column of the last row is where people get caught. Adding "exports" to a package that did not have one is a major change, because it closes every deep import that used to work. So is dropping support for an old Node version. When in doubt, it is major.

Publishing

shown, not run
npm login                          # once, in a browser
npm pack --dry-run                 # look, every time
npm publish --access public        # upload this version; it can never be replaced
npm deprecate slugger@1.0.0 "use 1.0.1, which fixes a bug"

Publishing uploads to the npm registry, and this site has no network. The tarball it would upload is the one npm pack made above.

A published version is permanent: you can deprecate it, and for a short time unpublish it, but the same version number can never be reused for different code. That is what makes a lock file (lesson 23) trustworthy.

A single executable

Some programs are for people who have never heard of Node: an internal tool, a command-line utility for a customer. A single executable application is one file containing a copy of the Node binary and your script, which runs with nothing installed. Node builds one itself, with --build-sea:

node --build-sea, on the node that built this site

ran at build time

hello.js

const name = process.argv[2] ?? "world";
console.log(`Hello, ${name}, from a single executable`);

sea-config.json

{ "main": "hello.js", "output": "hello" }

The terminal, as the build ran it

node --build-sea sea-config.json   # exits 9
node: Single executable application is disabled.

This is the one panel in the course that could not do what it describes. The node that builds this site was compiled with the feature switched off, and says so. The official downloads from nodejs.org include it, and on one of those the same command writes an executable file called hello:

A single executable, on a node that supports it

shown, not run
# sea-config.json:  { "main": "hello.js", "output": "hello" }
node --build-sea sea-config.json     # writes ./hello: a copy of node, with hello.js inside
codesign --sign - hello              # macOS only: sign the new file so the system will run it
./hello Ada                          # runs hello.js, on a machine with no node installed

The node on the machine that built this site refuses --build-sea, as the panel above shows, so these steps were not run. Everything else on these pages was.

The result is large — it contains the whole of Node, so expect tens of megabytes even for a two-line script — and it runs only on the operating system and processor it was built on, so you build one per platform. The embedded script is a single file (CommonJS unless the config says "mainFormat": "module"), so a program with many files is usually bundled into one first; the Node documentation lists the other settings. For most programs, publishing a package is simpler; for a tool handed to people who do not want Node, this is the built-in way.

  • Use "files" as an allowlist, and check npm pack --dry-run before every publish.
  • "exports" defines the only paths that can be imported. Everything else is private.
  • Ship JavaScript with a "types" condition pointing at .d.ts files.
  • An ES-module-only package works for CommonJS users through require(esm).
  • Patch for fixes, minor for additions, major for anything that could break someone.
  • node --build-sea makes one executable containing Node and your script.

50 One real project, end to end

Forty-nine lessons, one program. tally is a small command-line tool that reads your spending from CSV files, keeps it in a SQLite database, and tells you where the money went. It is deliberately ordinary — the kind of program you might actually write for yourself — and every part of it is something an earlier lesson ran. This lesson reads it, tests it, uses it, and installs it as a command.

The whole project

tally: every file, and its tests

ran at build time

package.json

{
  "name": "tally",
  "version": "1.0.0",
  "description": "Add up your expenses from CSV files",
  "type": "module",
  "bin": { "tally": "./src/cli.js" },
  "files": ["src"],
  "scripts": {
    "test": "node --test"
  },
  "engines": { "node": ">=24" },
  "license": "MIT"
}

expenses.csv

date,category,description,amount
2026-08-02,Food,Groceries,54.20
2026-08-05,transport,Train to Leeds,38.50
2026-08-11,food,"Dinner, with Ada",42.00
2026-08-19,rent,August rent,950.00
2026-09-01,rent,September rent,950.00
2026-09-03,food,Groceries,61.75
2026-09-09,transport,Bus pass,abc
2026-09-14,fun,"Cinema ""Metropolis"" tickets",19.00
2026-09-31,food,Lunch,8.40
2026-09-22,transport,Taxi,16.80

src/csv.js

// Split one line of CSV into fields. Handles "quoted, fields" and "" inside quotes.
export function parseCsvLine(line) {
  const fields = [];
  let field = "";
  let quoted = false;
  for (let i = 0; i < line.length; i++) {
    const char = line[i];
    if (quoted) {
      if (char === '"' && line[i + 1] === '"') { field += '"'; i++; }
      else if (char === '"') quoted = false;
      else field += char;
    } else if (char === '"') quoted = true;
    else if (char === ",") { fields.push(field); field = ""; }
    else field += char;
  }
  if (quoted) throw new SyntaxError("a quoted field is never closed");
  fields.push(field);
  return fields;
}

// Date.parse accepts "2026-09-31" and quietly means 1 October, so check the round trip.
function isRealDate(text) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(text) || Number.isNaN(Date.parse(text))) return false;
  return new Date(text).toISOString().slice(0, 10) === text;
}

// Turn the fields of one expense line into a checked record, or throw saying why not.
export function toExpense([date, category, description, amount, ...extra]) {
  if (extra.length > 0 || amount === undefined) throw new Error("expected 4 fields: date,category,description,amount");
  if (!isRealDate(date)) throw new Error(`"${date}" is not a date like 2026-09-30`);
  if (!/^\d+(\.\d{1,2})?$/.test(amount)) throw new Error(`"${amount}" is not an amount like 12.50`);
  if (category.trim() === "") throw new Error("the category is empty");
  return {
    date,
    category: category.trim().toLowerCase(),
    description: description.trim(),
    pence: Math.round(Number(amount) * 100),
  };
}

src/store.js

import { DatabaseSync } from "node:sqlite";

export function openStore(file) {
  const db = new DatabaseSync(file);
  db.exec(`
    CREATE TABLE IF NOT EXISTS expenses (
      id          INTEGER PRIMARY KEY,
      date        TEXT    NOT NULL,
      category    TEXT    NOT NULL,
      description TEXT    NOT NULL,
      pence       INTEGER NOT NULL CHECK (pence >= 0)
    ) STRICT
  `);
  const insert = db.prepare("INSERT INTO expenses (date, category, description, pence) VALUES (?, ?, ?, ?)");

  return {
    // All or nothing: a crash halfway through leaves no half-imported file.
    add(expenses) {
      db.exec("BEGIN");
      try {
        for (const e of expenses) insert.run(e.date, e.category, e.description, e.pence);
        db.exec("COMMIT");
      } catch (err) {
        db.exec("ROLLBACK");
        throw err;
      }
    },
    totals(by) {
      const group = by === "month" ? "substr(date, 1, 7)" : "category";   // never user text
      return db.prepare(`
        SELECT ${group} AS name, count(*) AS count, sum(pence) AS pence
        FROM expenses GROUP BY name ORDER BY pence DESC, name
      `).all().map((row) => ({ ...row }));
    },
    close: () => db.close(),
  };
}

src/cli.js

#!/usr/bin/env node
import { parseArgs } from "node:util";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { parseCsvLine, toExpense } from "./csv.js";
import { openStore } from "./store.js";

const HELP = `usage: tally <command> [options]

commands:
  import <file.csv>        add the expenses in a CSV file
  report [--by category]   totals by category (the default) or by month

options:
  --db <file>              the database to use (default: tally.db)
  -h, --help               show this help`;

class UsageError extends Error {}

const pounds = (pence) => (pence / 100).toFixed(2).padStart(10);

async function importFile(store, file) {
  const good = [];
  const problems = [];
  let lineNumber = 0;
  const lines = createInterface({ input: createReadStream(file), crlfDelay: Infinity });
  for await (const line of lines) {
    lineNumber++;
    if (lineNumber === 1 || line.trim() === "") continue;          // the header, and blank lines
    try {
      good.push(toExpense(parseCsvLine(line)));
    } catch (err) {
      problems.push(`line ${lineNumber}: ${err.message}`);
    }
  }
  store.add(good);
  console.log(`imported ${good.length} expenses from ${file}`);
  for (const problem of problems) console.error(`skipped ${problem}`);
  return problems.length === 0 ? 0 : 1;
}

function report(store, by) {
  const rows = store.totals(by);
  let total = 0;
  for (const row of rows) {
    console.log(`${row.name.padEnd(12)} ${String(row.count).padStart(3)} ${pounds(row.pence)}`);
    total += row.pence;
  }
  console.log(`${"total".padEnd(12)} ${" ".repeat(3)} ${pounds(total)}`);
  return 0;
}

async function main(argv) {
  const { values, positionals } = parseArgs({
    args: argv,
    allowPositionals: true,
    options: {
      db: { type: "string", default: "tally.db" },
      by: { type: "string", default: "category" },
      help: { type: "boolean", short: "h" },
    },
  });
  const [command, ...rest] = positionals;
  if (values.help || command === undefined) {
    console.log(HELP);
    return values.help ? 0 : 2;
  }
  if (command === "import" && rest.length !== 1) throw new UsageError("import needs exactly one file");
  if (command !== "import" && command !== "report") throw new UsageError(`unknown command "${command}"`);
  if (values.by !== "category" && values.by !== "month") throw new UsageError(`--by must be category or month, not "${values.by}"`);

  // Every mistake in the command line is reported above, before anything is opened or written.
  const store = openStore(values.db);
  try {
    return command === "import" ? await importFile(store, rest[0]) : report(store, values.by);
  } finally {
    store.close();
  }
}

try {
  process.exitCode = await main(process.argv.slice(2));
} catch (err) {
  if (err instanceof UsageError || err.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
    console.error(`tally: ${err.message}\n(run "tally --help" for usage)`);
    process.exitCode = 2;
  } else if (err.code === "ENOENT") {
    console.error(`tally: cannot read ${err.path}: no such file`);
    process.exitCode = 1;
  } else {
    throw err;
  }
}

test/csv.test.js

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseCsvLine, toExpense } from "../src/csv.js";

describe("parseCsvLine", () => {
  it("splits on commas", () => {
    assert.deepEqual(parseCsvLine("a,b,c"), ["a", "b", "c"]);
  });
  it("keeps commas inside quotes", () => {
    assert.deepEqual(parseCsvLine('2026-08-11,food,"Dinner, with Ada",42.00'), ["2026-08-11", "food", "Dinner, with Ada", "42.00"]);
  });
  it('reads "" inside quotes as one quote', () => {
    assert.deepEqual(parseCsvLine('"say ""hi"""'), ['say "hi"']);
  });
  it("refuses a quote that is never closed", () => {
    assert.throws(() => parseCsvLine('a,"b'), SyntaxError);
  });
});

describe("toExpense", () => {
  it("converts pounds to whole pence", () => {
    assert.equal(toExpense(["2026-09-03", "Food", "Groceries", "61.75"]).pence, 6175);
  });
  it("lower-cases the category", () => {
    assert.equal(toExpense(["2026-09-03", " Food ", "x", "1"]).category, "food");
  });
  it("refuses 31 September, which Date.parse would accept", () => {
    assert.throws(() => toExpense(["2026-09-31", "food", "x", "1"]), /not a date/);
  });
  it("refuses an amount that is not money", () => {
    for (const amount of ["abc", "-5", "1.234", ""]) {
      assert.throws(() => toExpense(["2026-09-03", "food", "x", amount]), /not an amount/, amount);
    }
  });
});

test/cli.test.js

import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));

// Run the real command, as a user would, and collect everything about how it ended.
function tally(args, cwd) {
  return new Promise((resolve) => {
    execFile(process.execPath, [CLI, ...args], { cwd }, (err, stdout, stderr) => {
      resolve({ code: err ? err.code : 0, stdout, stderr });
    });
  });
}

describe("the tally command", () => {
  let dir;
  beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "tally-")); });   // a fresh folder, and database, per test
  afterEach(() => rm(dir, { recursive: true, force: true }));

  it("imports a file and reports totals by category", async () => {
    await writeFile(join(dir, "in.csv"), "date,category,description,amount\n2026-09-01,rent,Rent,950.00\n2026-09-03,food,Groceries,61.75\n2026-09-04,food,Lunch,8.25\n");
    assert.equal((await tally(["import", "in.csv"], dir)).code, 0);

    const { code, stdout } = await tally(["report"], dir);
    assert.equal(code, 0);
    assert.deepEqual(stdout.trim().split("\n").map((line) => line.split(/\s+/)), [
      ["rent", "1", "950.00"],
      ["food", "2", "70.00"],
      ["total", "1020.00"],
    ]);
  });

  it("skips bad lines, says which, and exits 1", async () => {
    await writeFile(join(dir, "in.csv"), "date,category,description,amount\n2026-09-03,food,Groceries,61.75\n2026-09-09,transport,Bus,abc\n");
    const { code, stdout, stderr } = await tally(["import", "in.csv"], dir);
    assert.equal(code, 1);
    assert.match(stdout, /imported 1 expenses/);
    assert.match(stderr, /line 3: "abc" is not an amount/);
  });

  it("exits 2 for a usage mistake", async () => {
    const { code, stderr } = await tally(["report", "--by", "colour"], dir);
    assert.equal(code, 2);
    assert.match(stderr, /--by must be category or month/);
  });
});

The terminal, as the build ran it

find . -type f | sort
./expenses.csv
./package.json
./src/cli.js
./src/csv.js
./src/store.js
./test/cli.test.js
./test/csv.test.js
npm test

> tally@1.0.0 test
> node --test

▶ the tally command
  ✔ imports a file and reports totals by category (220.808784ms)
  ✔ skips bad lines, says which, and exits 1 (106.490349ms)
  ✔ exits 2 for a usage mistake (99.44316ms)
✔ the tally command (428.520343ms)
▶ parseCsvLine
  ✔ splits on commas (2.289317ms)
  ✔ keeps commas inside quotes (0.186871ms)
  ✔ reads "" inside quotes as one quote (0.164487ms)
  ✔ refuses a quote that is never closed (0.478933ms)
✔ parseCsvLine (4.587235ms)
▶ toExpense
  ✔ converts pounds to whole pence (1.236507ms)
  ✔ lower-cases the category (0.206528ms)
  ✔ refuses 31 September, which Date.parse would accept (0.229824ms)
  ✔ refuses an amount that is not money (0.316062ms)
✔ toExpense (2.361327ms)
ℹ tests 11
ℹ suites 3
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 552.165298

Three modules, each with one job, and nothing installed. Reading them in the order the data flows:

src/csv.js turns one line of text into a checked record, or throws saying exactly what is wrong with it. It knows nothing about files or databases, which is what makes it easy to test. The checks are the validation habit of lesson 37: say what a good value looks like, and refuse everything else. Money becomes whole pence at the edge (lesson 5), so no floating-point sum is ever stored. And one check exists because of a surprise found while writing it: Date.parse("2026-09-31") does not fail, it quietly returns 1 October, so the code converts the date back and compares.

src/store.js is the database (lesson 39): a STRICT table with a CHECK on the amount, placeholders for every value, and each import written in one transaction, so a crash halfway through leaves nothing half-imported. The one piece of SQL built from a string, the GROUP BY expression, is chosen from two constants in the code and never contains anything the user typed (lesson 40).

src/cli.js is the only module that knows it is a command. It parses arguments with parseArgs (lesson 31), streams the CSV a line at a time with readline, so a file of any size works (lesson 35), and reports through its exit code: 0 for success, 1 when something failed, 2 when the command itself was wrong. Results go to standard output and problems to standard error, so tally report > totals.txt captures the report and still shows the problems.

The tests are at two levels (lesson 41). csv.test.js checks the parsing functions directly, including every edge found on the way — quotes, a quote inside quotes, 31 September. And cli.test.js runs the real command as a separate process, in a fresh temporary folder for each test, exactly as a user would, and checks what it printed and how it exited. The first kind tells you precisely what broke; the second tells you the program as a whole still works.

Using it

Importing, reporting, and three mistakes

ran at build time

Also on disk, exactly as printed in an earlier panel: package.json, expenses.csv, src/csv.js, src/store.js, src/cli.js, test/csv.test.js, test/cli.test.js

The terminal, as the build ran it

node src/cli.js --help
usage: tally <command> [options]

commands:
  import <file.csv>        add the expenses in a CSV file
  report [--by category]   totals by category (the default) or by month

options:
  --db <file>              the database to use (default: tally.db)
  -h, --help               show this help
node src/cli.js import expenses.csv   # exits 1
imported 8 expenses from expenses.csv
skipped line 8: "abc" is not an amount like 12.50
skipped line 10: "2026-09-31" is not a date like 2026-09-30
node src/cli.js report
rent           2    1900.00
food           3     157.95
transport      2      55.30
fun            1      19.00
total               2132.25
node src/cli.js report --by month
2026-08        4    1084.70
2026-09        4    1047.55
total               2132.25
node src/cli.js report --by colour   # exits 2
tally: --by must be category or month, not "colour"
(run "tally --help" for usage)
node src/cli.js import missing.csv   # exits 1
tally: cannot read missing.csv: no such file

The import kept the eight good lines and named the two bad ones with their line numbers — the amount abc and the date that does not exist — then exited 1, so a script running it would know something needs attention. "Food" and "food" landed in one category because the category is lower-cased on the way in; the quoted description with a comma in it was read correctly. The three mistakes each got a message a person can act on and the right exit code, and none of them printed a stack trace: a stack trace is for the programmer, and the user of a command is usually someone else.

Installed as a command

npm pack, and tally used from another folder

ran at build time

../books/package.json

{ "name": "books", "version": "1.0.0", "private": true }

../books/september.csv

date,category,description,amount
2026-09-02,food,Groceries,48.10
2026-09-05,books,"Node, from zero",24.99
2026-09-12,food,Market,17.35

Also on disk, exactly as printed in an earlier panel: package.json, expenses.csv, src/csv.js, src/store.js, src/cli.js, test/csv.test.js, test/cli.test.js

The terminal, as the build ran it

npm pack
npm notice
npm notice 📦  tally@1.0.0
npm notice Tarball Contents
npm notice 277B package.json
npm notice 3.2kB src/cli.js
npm notice 1.6kB src/csv.js
npm notice 1.2kB src/store.js
npm notice Tarball Details
npm notice name: tally
npm notice version: 1.0.0
npm notice filename: tally-1.0.0.tgz
npm notice package size: 2.8 kB
npm notice unpacked size: 6.3 kB
npm notice shasum: 4cac488e986cc544e6c83894e614973fa8928137
npm notice integrity: sha512-4a7woT04rjACz[...]Nw5h/zjuaV4MA==
npm notice total files: 4
npm notice
tally-1.0.0.tgz
cd ../books && npm install ../app/tally-1.0.0.tgz

added 1 package in 311ms
npx tally import september.csv
imported 3 expenses from september.csv
npx tally report
food           2      65.45
books          1      24.99
total                 90.44
ls
node_modules
package-lock.json
package.json
september.csv
tally.db

"files" kept the tarball to the source code (lesson 49), and "bin" made tally a command in any project that installs it (lesson 23). In ../books it created its own tally.db, because it works in whatever folder it is run from. With npm install -g from the registry it would be a command everywhere; everything else about it is already done.

Where it could go next

Every one of these is a change you now know how to make, and each points at the lesson that covers it:

  • A --json option on report, for other programs to read (lesson 16).
  • An HTTP API in front of the same store, so a web page can show the totals (lessons 36 and 37), shutting down cleanly on SIGTERM (lesson 48).
  • Refusing to import the same file twice, with a table of file hashes and a UNIQUE constraint (lessons 30 and 39).
  • Type annotations, checked by tsc and run directly by Node (lesson 47).
  • Budgets, with a warning when a category goes over — and the tests for it written first.

From here. The Node documentation at nodejs.org is written for people who already know what this course covered, and it is where every detail left out here lives. Most real projects will add packages from npm — a web framework, a database driver, a validation library — and each of them is built on the modules these pages ran bare. When one of them surprises you, the fastest way to understand it is the way this course worked: write the smallest program that shows the behaviour, run it, and believe what it prints.

  • Keep the parts that decide things (parsing, validation) apart from the parts that do things (files, databases, the command line), and test each.
  • Check input at the edge, store money as integers, write in transactions, and never build SQL from user text.
  • A command reports with its exit code and uses standard error for problems.
  • Test the functions directly, and the program as a whole from the outside.
  • Package with "bin" and "files", and it installs like any other tool.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.