← Complete Projects

阅读中文版本

Build a Persistent Todo CLI with Node.js and JSON Storage

A complete command-line todo manager with add, list, done, and remove commands, disk persistence, filtering, and unit tests for every command path.

beginner~4 hnodejsclijsontesting

Project goal

Build a todo CLI that stores tasks in a JSON file, supports add/list/done/remove/search commands with filters by status and tag, and never corrupts the store on concurrent or failed writes.

Final features

  • add writes a task with id, title, optional tags, and created timestamp, then prints the id
  • list filters by --status and --tag and prints tasks grouped by pending or done
  • done marks one or many ids complete and reports ids that were already done or missing
  • remove deletes tasks and writes atomically via a temp file plus rename
  • search finds tasks by case-insensitive substring across title and tags

Architecture

A thin CLI layer parses argv into a command object, a service layer owns all task logic and validation, and a storage module serializes reads and writes through a single write queue with atomic rename. Commands never touch the JSON file directly, so swapping the store for SQLite later touches one module.

Prerequisites

  • Node.js 20 LTS or later
  • npm or pnpm package manager
  • Familiarity with async/await and file system basics

Dependencies

  • node >=20 — Runtime with built-in fs, path, and util modules
  • commander ^12.0.0 — Argv parsing with subcommands and options
  • vitest ^2.1.0 — Unit tests for service and storage layers

Directory structure

todo-cli/
├── bin/todo.js
├── src/
│   ├── commands.js
│   ├── service.js
│   └── storage.js
├── test/
│   ├── service.test.js
│   └── storage.test.js
├── package.json
└── README.md

Phases

1. Storage foundation

Implement the JSON store with atomic writes and a serialized write queue so overlapping commands cannot interleave.

Files: src/storage.js

const { writeFile, rename, mkdir } = require('node:fs/promises');
const path = require('node:path');

async function load(file) {
  try {
    return JSON.parse(await require('node:fs/promises').readFile(file, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return { tasks: [], nextId: 1 };
    throw err;
  }
}

let chain = Promise.resolve();
function write(file, data) {
  chain = chain.then(async () => {
    const tmp = file + '.tmp';
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(tmp, JSON.stringify(data, null, 2));
    await rename(tmp, file);
  });
  return chain;
}

Expected behavior: Two concurrent writes both persist and the file always parses; a simulated write failure leaves the previous file intact.

2. Task service

Implement add, list, done, remove, and search as pure functions over the store data, returning plain results instead of printing.

Files: src/service.js

function addTask(store, title, tags = []) {
  if (!title || !title.trim()) throw new Error('title is required');
  const task = { id: store.nextId, title: title.trim(), tags, done: false, createdAt: new Date().toISOString() };
  store.tasks.push(task);
  store.nextId += 1;
  return task;
}

function listTasks(store, { status = 'all', tag } = {}) {
  return store.tasks.filter((t) =>
    (status === 'all' || (status === 'done') === t.done) && (!tag || t.tags.includes(tag)));
}

Expected behavior: Service tests pass for every filter combination and invalid titles are rejected with clear errors.

3. CLI wiring

Wire commander subcommands to the service, map errors to exit code 1 with stderr messages, and keep stdout machine-friendly.

Files: bin/todo.js, src/commands.js

const { program } = require('commander');
const { openStore } = require('./storage');
const svc = require('./service');

program.command('add <title...>').option('--tag <t>', 'repeatable', collect, [])
  .action(async (words, opts) => {
    const store = await openStore();
    const task = svc.addTask(store, words.join(' '), opts.tag);
    await saveStore(store);
    console.log(task.id);
  });

Expected behavior: todo add "ship release" --tag work prints an id; todo list --status pending shows it; unknown commands print help.

4. Tests and hardening

Cover storage atomicity, service filters, and CLI end-to-end runs against a temp directory, then fix every failure the tests expose.

Files: test/service.test.js, test/storage.test.js

test('done is idempotent and reports unknown ids', () => {
  const store = { tasks: [{ id: 1, done: false }], nextId: 2 };
  expect(svc.doneTasks(store, [1, 1, 9])).toEqual({ updated: [1], alreadyDone: [], missing: [9] });
});

Expected behavior: vitest run reports all green, including the double-done and unknown-id edge cases.

Testing

Run npx vitest. Storage tests spawn parallel writes to one file and assert the final parse succeeds. Service tests are pure data-in/data-out and cover filter cross products. CLI tests execute bin/todo.js as a child process against a temp store directory, asserting stdout shape and exit codes for both happy paths and validation errors.

Troubleshooting

store.json becomes empty after a crash

The atomic rename in storage.js prevents this; check that no code path writes the file directly with fs.writeFile on the final path.

tags repeat or lose order

Use the commander collect accumulator shown in phase 3 instead of splitting a comma string, which drops empty tags.

Windows tests fail with EPERM on rename

Close any watchers on the store file in tests and retry rename once after a 50ms delay before failing.

Deployment

Publish to npm with a bin entry or install globally from a clone with npm link. There is no server component; the store location defaults to the OS data directory and can be overridden with TODO_STORE for portable or CI use.

Known limitations

  • A single JSON file caps comfortable use around ten thousand tasks, where full-file rewrite cost becomes visible.
  • Concurrent processes share no lock file, so cross-process writes rely on rename atomicity but can lose the last write.

Project overview

A todo list is the hello world of state: small enough to finish in an afternoon, rich enough to force every real decision a persistent CLI has to make. What happens when the process dies mid-write? How do filters compose without becoming a combinatorial mess? Where does printing belong so the domain logic stays testable? This project answers each of those questions with a structure you can lift into any command-line tool.

The final product supports five commands — add, list, done, remove, and search — with filtering by status and tag, disk persistence in a single JSON file, and a test suite covering every command path including the failure modes. Tasks carry an id, title, optional tags, and a creation timestamp. The done command is idempotent and reports which ids were already complete or missing instead of failing silently.

The design keeps three strict layers:

  • The CLI layer (bin/todo.js plus src/commands.js) only translates argv into calls and results into output. It owns commander and process.exit, and nothing else.
  • The service layer (src/service.js) is pure functions over a plain store object. Every function takes the store as its first argument and returns either a new fact or a plain result object. No console.log, no disk access, no clocks.
  • The storage layer (src/storage.js) is the only code that knows a file exists. It serializes writes through a promise queue and commits them with an atomic rename.

The payoff for this separation is concrete: swapping the JSON store for SQLite later touches exactly one module, the domain logic is testable without touching disk, and a wiring bug can never corrupt data because commands never write the file directly.

The store document itself is deliberately boring — two top-level keys and nothing else:

{
  "tasks": [
    { "id": 1, "title": "ship release", "tags": ["work"], "done": false, "createdAt": "2026-10-22T09:41:00.000Z" }
  ],
  "nextId": 2
}

nextId lives in the file rather than being derived as max(id) + 1 so that removing the highest task cannot reissue its id to a new task — a subtle aliasing bug that would silently reconnect old references (shell history, notes, scripts) to unrelated new tasks. Ids are stable forever, even for removed tasks, which is the contract every downstream consumer relies on.

Setup

Verify the prerequisites before starting: Node.js 20 LTS or later, and npm or pnpm as your package manager. You will also want familiarity with async/await and the Node file system basics, since the storage layer leans on both.

The runtime dependency footprint is deliberately three packages:

Dependency Version Why it is here
node >=20 Runtime with built-in fs/promises, path, and util modules
commander ^12.0.0 Argv parsing with subcommands and repeatable options
vitest ^2.1.0 Unit tests for the service and storage layers

Create the project and install:

mkdir todo-cli && cd todo-cli
npm init -y
npm install commander@^12.0.0
npm install -D vitest@^2.1.0

Configure the environment in package.json so the binary and the test runner are wired up — no other configuration is required, and the store location defaults to the OS data directory unless TODO_STORE overrides it:

{
  "name": "todo-cli",
  "bin": { "todo": "bin/todo.js" },
  "scripts": { "test": "vitest run" }
}

The directory structure matches the layer boundaries, so each phase below has an obvious home for its code.

Phase 1: Storage foundation

The storage module implements the JSON store with atomic writes and a serialized write queue, so overlapping commands within one process cannot interleave two read-modify-write cycles.

// src/storage.js
const { writeFile, rename, mkdir, readFile } = require('node:fs/promises');
const path = require('node:path');

async function load(file) {
  try {
    return JSON.parse(await readFile(file, 'utf8'));
  } catch (err) {
    if (err.code === 'ENOENT') return { tasks: [], nextId: 1 };
    throw err;
  }
}

let chain = Promise.resolve();
function write(file, data) {
  chain = chain.then(async () => {
    const tmp = file + '.tmp';
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(tmp, JSON.stringify(data, null, 2));
    await rename(tmp, file);
  });
  return chain;
}

async function openStore(file) {
  return load(file);
}

module.exports = { openStore, write, load };

Two decisions deserve explanation. First, the atomic rename: writing JSON directly to the store file has a failure window — if the process is killed between truncation and the final byte, the file is left empty or half-written and every task is gone. Writing the full payload to a sibling temp file and renaming it over the target closes that window, because rename within a directory is atomic on every mainstream platform: readers see either the old complete file or the new complete file, never a mixture.

Second, the promise chain: a todo add racing a todo done in the same process would otherwise interleave their read-modify-write cycles and lose one update. Chaining every write onto a single promise serializes them without introducing a lock file or a queue library. Cross-process safety is explicitly out of scope — that boundary is documented as a limitation rather than half-solved, which is the honest engineering choice for a single-user CLI.

Verify the phase before moving on: two concurrent write calls to the same file must both persist, the file must always parse afterward, and a simulated write failure (make the temp path unwritable) must leave the previous file intact.

Phase 2: Task service

The service layer implements add, list, done, remove, and search as pure functions over the store data, returning plain results instead of printing.

// src/service.js
function addTask(store, title, tags = []) {
  if (!title || !title.trim()) throw new Error('title is required');
  const task = {
    id: store.nextId,
    title: title.trim(),
    tags,
    done: false,
    createdAt: new Date().toISOString(),
  };
  store.tasks.push(task);
  store.nextId += 1;
  return task;
}

function listTasks(store, { status = 'all', tag } = {}) {
  return store.tasks.filter((t) =>
    (status === 'all' || (status === 'done') === t.done) &&
    (!tag || t.tags.includes(tag)));
}

function doneTasks(store, ids) {
  const updated = [];
  const alreadyDone = [];
  const missing = [];
  for (const id of ids) {
    const task = store.tasks.find((t) => t.id === id);
    if (!task) missing.push(id);
    else if (task.done) alreadyDone.push(id);
    else { task.done = true; updated.push(id); }
  }
  return { updated, alreadyDone, missing };
}

function searchTasks(store, needle) {
  const q = needle.toLowerCase();
  return store.tasks.filter(
    (t) => t.title.toLowerCase().includes(q) || t.tags.some((g) => g.toLowerCase().includes(q)));
}

module.exports = { addTask, listTasks, doneTasks, searchTasks };

The interesting contract here is doneTasks. Duplicate ids, already-complete tasks, and unknown ids are all first-class outcomes instead of exceptions: the function returns updated, alreadyDone, and missing arrays, which the CLI renders as a human summary and the tests assert exactly. That single decision is what makes the whole command idempotent — running todo done 1 1 9 twice produces the same store state and the same report, and the caller learns what actually happened instead of guessing.

Note also what the service layer does not contain: no console.log, no Date.now outside the record constructor, no file system. That rule is what makes the entire domain testable with plain data-in/data-out assertions — no mocking of stdout, no temp files for logic tests — and failures point at logic rather than plumbing.

The remaining two operations follow the same shape. removeTasks(store, ids) mirrors doneTasks exactly: it returns { removed, missing } instead of throwing on unknown ids, because deleting a task that is already gone is the normal outcome of running a cleanup command twice. searchTasks is a filter with a case-insensitive substring match across title and tags — deliberately substring rather than fuzzy, because a todo search that surprises you is worse than one that occasionally misses.

One rule of thumb keeps the layer honest as it grows: if a new function needs anything besides the store, its arguments, and plain data it computes, that need belongs in the caller. The day a service function wants the current time for a due-date comparison, the clock gets passed in — that is what keeps the test suite deterministic without mocking libraries.

Phase 3: CLI wiring

The wiring layer binds commander subcommands to the service, maps errors to exit code 1 with stderr messages, and keeps stdout machine-friendly so the output can be piped.

// bin/todo.js
#!/usr/bin/env node
const { program } = require('commander');
const os = require('node:os');
const path = require('node:path');
const { openStore, write } = require('../src/storage');
const svc = require('../src/service');

const STORE = process.env.TODO_STORE
  ?? path.join(os.homedir(), '.local', 'share', 'todo-cli', 'store.json');

function collect(value, previous) {
  return [...previous, value];
}

program.command('add <title...>')
  .option('--tag <tag>', 'tag for the task (repeatable)', collect, [])
  .action(async (words, opts) => {
    const store = await openStore(STORE);
    const task = svc.addTask(store, words.join(' '), opts.tag);
    await write(STORE, store);
    console.log(task.id);
  });

program.command('done <ids...>')
  .action(async (ids) => {
    const store = await openStore(STORE);
    const result = svc.doneTasks(store, ids.map(Number));
    await write(STORE, store);
    const parts = [`done: ${result.updated.join(' ') || '(none)'}`];
    if (result.alreadyDone.length) parts.push(`already done: ${result.alreadyDone.join(' ')}`);
    if (result.missing.length) parts.push(`missing: ${result.missing.join(' ')}`);
    console.log(parts.join('\n'));
  });

program.command('list')
  .option('--status <status>', 'pending, done, or all')
  .option('--tag <tag>', 'filter by tag')
  .action(async (opts) => {
    const store = await openStore(STORE);
    for (const t of svc.listTasks(store, opts)) {
      console.log(`${t.done ? 'x' : ' '} ${t.id} ${t.title}${t.tags.length ? ' [' + t.tags.join(', ') + ']' : ''}`);
    }
  });

program.parseAsync(process.argv).catch((err) => {
  console.error(err.message);
  process.exit(1);
});

Three conventions keep this layer honest. Every command opens the store, mutates it through the service, and writes it back through the storage queue — never directly. Errors print to stderr and set exit code 1, so shell pipelines can detect failure. And stdout stays machine-friendly: add prints exactly the new id, which is precisely what the end-to-end tests assert.

After installing with npm link (or npm install -g .), todo add "ship release" --tag work should print an id, todo list --status pending should show it, and an unknown command should print help — those three behaviors are the acceptance test for this phase.

Phase 4: Tests and hardening

The final phase covers storage atomicity, service filters, and CLI end-to-end runs against a temp directory, then fixes every failure the tests expose.

// test/service.test.js
import { describe, it, expect } from 'vitest';
import { addTask, doneTasks, listTasks } from '../src/service.js';

const makeStore = () => ({ tasks: [], nextId: 1 });

describe('done', () => {
  it('is idempotent and reports unknown ids', () => {
    const store = { tasks: [{ id: 1, done: false }], nextId: 2 };
    expect(doneTasks(store, [1, 1, 9])).toEqual({
      updated: [1], alreadyDone: [], missing: [9],
    });
    expect(doneTasks(store, [1, 9])).toEqual({
      updated: [], alreadyDone: [1], missing: [9],
    });
  });
});

describe('list', () => {
  it('filters by status and tag together', () => {
    const store = makeStore();
    addTask(store, 'a', ['work']);
    addTask(store, 'b', ['home']);
    store.tasks[0].done = true;
    expect(listTasks(store, { status: 'pending', tag: 'home' }).map((t) => t.title))
      .toEqual(['b']);
  });
});

The storage tests spawn parallel writes to one file and assert the final parse succeeds. The CLI tests execute bin/todo.js as a child process against a temp store directory (TODO_STORE pointed at a mkdtemp path), asserting stdout shape and exit codes for both happy paths and validation errors — this is the tier where a forgotten await on the write queue actually surfaces, because the child process exit reveals an unfinished write.

Hardening closes with the crash simulation: a test writes, kills the process mid-write with a child spawn, and proves the store still parses afterward thanks to the rename. On Windows, rename can fail with EPERM if a watcher holds the file open — close any watchers in tests and retry the rename once after a short delay before failing.

Run the whole suite with npx vitest; all tiers together stay under a second, which is the budget that lets the suite run on every save.

Design trade-offs

Three deliberate trade-offs shaped this project, and each has an alternative worth knowing.

Single JSON file over SQLite. The file store makes the project dependency-light and the data trivially inspectable, and it teaches atomic-write technique you will reuse everywhere. The cost is scale: a full-file rewrite per command caps comfortable use around ten thousand tasks, where rewrite latency becomes visible. Because commands only talk to the storage interface, the migration to SQLite is a one-module change rather than a rewrite.

In-process serialization over file locking. The promise chain makes concurrent commands safe within one process with zero extra machinery. It deliberately does not protect against two separate processes writing at once — for a single-user CLI that is the right scope, and the limitation is documented instead of half-solved with a lock file nobody maintains.

Pure service functions over a repository class. A class with injected repositories is the textbook pattern, but for five operations over one array it buys nothing. Plain functions over a plain object keep the mental model at “data in, data out” and make every test a one-liner to construct. The moment a second storage backend appears, the interface extracted from these functions is the repository — the abstraction is earned rather than anticipated.

Testing strategy

Three test tiers each catch a different class of bug. Pure service tests enumerate the filter cross product (status × tag) and every validation error — they run in microseconds and fail with exact data diffs. Storage tests prove the invariants that are invisible from the outside: parallel writes serialize, crashes leave a parseable file, and a failed write leaves the previous state intact. CLI tests run the real binary against a temp directory and assert the process-level contract — stdout shape, exit codes, and store contents after the command.

The end-to-end tier is intentionally thin: one test per command plus one failure mode each. Everything else lives in the fast tiers, which keeps the whole suite fast enough to run continuously while still covering every path the frontmatter promises: add with and without tags, list filters, done idempotency and unknown-id reporting, remove, and search across title and tags.

Concretely, the suite that ships with the project asserts: an empty title is rejected with a clear error; list --status pending --tag work returns the cross product of both filters and not just one; done 1 1 9 reports updated, already-done, and missing in a single pass; removing the highest-id task does not reissue that id on the next add; search finds a task by a tag fragment while ignoring case; the store file parses after a mid-write kill; and the add command prints exactly one line — the id — so scripts can capture it with $(todo add ...). Each of those assertions encodes a decision from the phases above, which is what makes the test suite a specification rather than a formality.

Extending the project

The seams left by the layering are where the next features go. A todo export command is a ten-line addition to the CLI layer that renders the store through the service — no storage change. Due dates and priorities are new fields on the task object plus new service functions; the list filter grows one branch. Undo is a write-ahead log in the storage layer, again invisible to the service.

Two extensions need real design rather than plumbing. Recurring tasks require a generator that materializes the next instance on list — the store stays plain and the illusion lives in the service. Multi-machine sync is where the single-file decision ends: the migration path is SQLite plus a conflict rule (last-write-wins on task granularity is usually enough), and the storage interface means the CLI and service layers survive unchanged.

Publishing is the last step: add a files field to package.json, publish to npm with the bin entry, or install globally from a clone with npm link. There is no server component and the store location defaults to the OS data directory, overridable with TODO_STORE for portable or CI use.