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
../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
../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
../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.
| Bump | When | Examples |
|---|---|---|
| patch, 1.0.1 | fixes, with nothing new | a bug fix; a faster implementation |
| minor, 1.1.0 | new things, nothing removed | a new export; a new optional argument |
| major, 2.0.0 | anything that could break a user | removing 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.
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:
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:
# 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 checknpm pack --dry-runbefore every publish. "exports"defines the only paths that can be imported. Everything else is private.- Ship JavaScript with a
"types"condition pointing at.d.tsfiles. - 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-seamakes 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
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
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
../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
--jsonoption onreport, 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
UNIQUEconstraint (lessons 30 and 39). - Type annotations, checked by
tscand 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.