Skip to content
aviral gupta

// Beginner project · about 4 hours of work

Logbook CLI

You build logbook, a command-line journal. node main.js add "text" --tag x stores a tagged entry in a JSON file, list shows the entries, list --tag x filters them, and export out.json copies them to another file. It brings the whole Beginner level together: arguments with util.parseArgs, files with node:fs/promises, data on stdout and messages on stderr, meaningful exit codes, configuration from the environment, and a node:test suite that proves it works.

What the finished program does

  • node main.js add <text> [--tag <tag>]... appends an entry {id, date, text, tags} to the data file, creating the file if it does not exist, and prints Added entry <id>. The id is one more than the highest id so far; the date is the day as YYYY-MM-DD; --tag (short -t) may be given more than once.
  • node main.js list prints one line per entry, in the order added: the id, the date, the text and, when there are tags, the tags in brackets, separated by two spaces, such as 1 2026-09-30 Went running [sport]. With no entries it prints No entries.
  • node main.js list --tag <tag> shows only the entries that have that tag (every given tag, when --tag is repeated).
  • node main.js export <file> writes every entry to <file> as JSON and prints Exported <n> entries to <file>.
  • Arguments are parsed with util.parseArgs. An unknown command, an unknown option or a missing argument prints logbook: <reason> and the usage on stderr, prints nothing on stdout, and exits with code 2.
  • A data file that is not valid JSON, or that list or export finds missing, prints a clear logbook: message on stderr and exits with code 1. Nothing is overwritten.
  • The data file is LOGBOOK_FILE from the environment (so node --env-file=.env main.js works), or logbook.json in the current folder.
  • The logic lives in logbook.js as run(args, {file, now}), which returns {code, out, err} instead of printing, so the tests can call it with a temporary file and a fixed clock. main.js only prints the result and sets process.exitCode.

Starter layout

main.js
The command: reads LOGBOOK_FILE, calls run() with the arguments, prints out and err and sets process.exitCode. Given complete.
logbook.js
Your work: the options for parseArgs, load(), format() and run().
main.test.js
The acceptance tests (node:test). Run them with node --test.
package.json
{"type": "module"}, so .js files are ES modules. In the starter download.
learnrun.js
The course helper for running main.js; this project’s tests do not need it. In the starter download.

Milestones

  1. Milestone 1

    Parse the arguments

    Describe --tag (short -t, multiple) in OPTIONS, call parseArgs with allowPositionals, and turn a parse error or an unknown command into logbook: <reason> plus the usage on stderr, with exit code 2.

    Checks that pass once this milestone is done:

    • An unknown command prints the usage on stderr and exits with 2
    • An unknown option is reported by parseArgs and exits with 2
  2. Milestone 2

    Add and list

    Write load() for an existing file, then add (with the next id and today’s date, creating the file) and list with format().

    Checks that pass once this milestone is done:

    • add creates the file and stores the entry with its tags
    • list prints every entry, one per line, in the order added
  3. Milestone 3

    Filter by tag

    Keep only the entries that carry every tag given with --tag, and print No entries. when nothing is left.

    Checks that pass once this milestone is done:

    • list --tag shows only the entries with that tag
  4. Milestone 4

    Export

    Write the entries to the file named after export, pretty-printed, and report how many.

    Checks that pass once this milestone is done:

    • export writes every entry to the named JSON file
  5. Milestone 5

    Fail clearly

    Report a corrupt file and a missing one (for list and export) as logbook: <message> on stderr with exit code 1, and run the whole program once with node main.js.

    Checks that pass once this milestone is done:

    • A corrupt data file gives a clear message on stderr and exit code 1
    • list with no data file yet gives a clear message and exit code 1
    • node main.js reads LOGBOOK_FILE and sets the exit code

This project uses parts of Node.js that do not run in the browser, so you build it on your computer.

Build it on your computer

Make a folder with these starter files and Node.js 24 LTS, then work through the milestones. Run the acceptance tests at any point with:

Download the starter as one .zip (starter files, main.test.js, package.json and learnrun.js)
node --test

package.json tells Node.js that the .js files are modules; keep it in the folder.

Download learnrun.js

main.js

import {run} from './logbook.js';

// The data file comes from LOGBOOK_FILE (for example through --env-file), or logbook.json.
const file = process.env.LOGBOOK_FILE ?? 'logbook.json';
const result = await run(process.argv.slice(2), {file});
process.stdout.write(result.out);
process.stderr.write(result.err);
process.exitCode = result.code;

logbook.js

import {readFile, writeFile} from 'node:fs/promises';
import {parseArgs} from 'node:util';

export const USAGE = `Usage:
  node main.js add <text> [--tag <tag>]...
  node main.js list [--tag <tag>]
  node main.js export <file>`;

// Milestone 1: describe --tag (short -t, may be given more than once) for parseArgs.
const OPTIONS = {};

// Milestone 2 and 5: read and parse the JSON file. A missing file is an empty
// logbook only when missingOk is true; otherwise throw an Error with a clear message.
export async function load(file, {missingOk = false} = {}) {
  return [];
}

// Milestone 2: one line per entry, such as "1  2026-09-30  Went running  [sport]".
export function format(entry) {
  return '';
}

// Runs one command and returns {code, out, err} instead of printing.
// Milestones 1 to 5: parse args, then handle add, list and export.
export async function run(args, {file, now = () => new Date()}) {
  return {code: 0, out: '', err: ''};
}

package.json

{
  "type": "module"
}

Acceptance tests

The project is done when every check in main.test.js passes. Read them before you start: they are the spec, written as code.

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {mkdtempSync, readFileSync, writeFileSync, existsSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {run} from './logbook.js';

// A fresh folder per test, and a fixed clock, so dates are predictable.
const folder = () => mkdtempSync(join(tmpdir(), 'logbook-'));
const now = () => new Date('2026-09-30T12:00:00Z');

test('An unknown command prints the usage on stderr and exits with 2', async () => {
  const r = await run(['remove'], {file: join(folder(), 'log.json'), now});
  assert.equal(r.code, 2, `the exit code was ${r.code}`);
  assert.match(r.err, /Usage:/, `stderr was ${JSON.stringify(r.err)}`);
  assert.equal(r.out, '', `stdout should stay empty, but was ${JSON.stringify(r.out)}`);
});

test('An unknown option is reported by parseArgs and exits with 2', async () => {
  const r = await run(['list', '--colour'], {file: join(folder(), 'log.json'), now});
  assert.equal(r.code, 2, `the exit code was ${r.code}`);
  assert.match(r.err, /--colour/, `stderr should name the option, but was ${JSON.stringify(r.err)}`);
});

test('add creates the file and stores the entry with its tags', async () => {
  const file = join(folder(), 'log.json');
  const r = await run(['add', 'Fixed the login bug', '--tag', 'work', '-t', 'bug'], {file, now});
  assert.equal(r.code, 0, `the exit code was ${r.code}; stderr: ${r.err}`);
  assert.equal(r.out, 'Added entry 1.\n', `stdout was ${JSON.stringify(r.out)}`);
  const saved = JSON.parse(readFileSync(file, 'utf8'));
  assert.deepEqual(saved, [{id: 1, date: '2026-09-30', text: 'Fixed the login bug', tags: ['work', 'bug']}], `the file holds ${JSON.stringify(saved)}`);
});

test('list prints every entry, one per line, in the order added', async () => {
  const file = join(folder(), 'log.json');
  await run(['add', 'Read the fs docs', '--tag', 'learn'], {file, now});
  await run(['add', 'Went running'], {file, now});
  const r = await run(['list'], {file, now});
  assert.equal(r.code, 0, `the exit code was ${r.code}; stderr: ${r.err}`);
  assert.equal(r.out, '1  2026-09-30  Read the fs docs  [learn]\n2  2026-09-30  Went running\n', `stdout was ${JSON.stringify(r.out)}`);
});

test('list --tag shows only the entries with that tag', async () => {
  const file = join(folder(), 'log.json');
  await run(['add', 'Read the fs docs', '--tag', 'learn'], {file, now});
  await run(['add', 'Went running', '--tag', 'sport'], {file, now});
  const r = await run(['list', '--tag', 'sport'], {file, now});
  assert.equal(r.out, '2  2026-09-30  Went running  [sport]\n', `stdout was ${JSON.stringify(r.out)}`);
  const none = await run(['list', '--tag', 'music'], {file, now});
  assert.equal(none.out, 'No entries.\n', `with no match, stdout was ${JSON.stringify(none.out)}`);
});

test('export writes every entry to the named JSON file', async () => {
  const dir = folder();
  const file = join(dir, 'log.json');
  await run(['add', 'First'], {file, now});
  await run(['add', 'Second', '--tag', 'x'], {file, now});
  const out = join(dir, 'out.json');
  const r = await run(['export', out], {file, now});
  assert.equal(r.code, 0, `the exit code was ${r.code}; stderr: ${r.err}`);
  assert.equal(r.out, `Exported 2 entries to ${out}.\n`, `stdout was ${JSON.stringify(r.out)}`);
  assert.ok(existsSync(out), 'the export file was not written');
  const exported = JSON.parse(readFileSync(out, 'utf8'));
  assert.deepEqual(exported.map((e) => e.text), ['First', 'Second'], `the export holds ${JSON.stringify(exported)}`);
});

test('A corrupt data file gives a clear message on stderr and exit code 1', async () => {
  const file = join(folder(), 'log.json');
  writeFileSync(file, '[{"id": 1,');
  const r = await run(['list'], {file, now});
  assert.equal(r.code, 1, `the exit code was ${r.code}`);
  assert.equal(r.err, `logbook: ${file} is not valid JSON\n`, `stderr was ${JSON.stringify(r.err)}`);
});

test('list with no data file yet gives a clear message and exit code 1', async () => {
  const file = join(folder(), 'log.json');
  const r = await run(['list'], {file, now});
  assert.equal(r.code, 1, `the exit code was ${r.code}`);
  assert.equal(r.err, `logbook: no logbook at ${file}; add an entry first\n`, `stderr was ${JSON.stringify(r.err)}`);
});

test('node main.js reads LOGBOOK_FILE and sets the exit code', () => {
  const dir = folder();
  const env = {...process.env, LOGBOOK_FILE: join(dir, 'log.json')};
  const main = fileURLToPath(new URL('./main.js', import.meta.url));
  const added = spawnSync(process.execPath, [main, 'add', 'From the shell'], {env, encoding: 'utf8'});
  assert.equal(added.status, 0, `add exited with ${added.status}; stderr: ${added.stderr}`);
  assert.equal(added.stdout, 'Added entry 1.\n', `add printed ${JSON.stringify(added.stdout)}`);
  const bad = spawnSync(process.execPath, [main, 'dance'], {env, encoding: 'utf8'});
  assert.equal(bad.status, 2, `an unknown command exited with ${bad.status}`);
  assert.equal(bad.stdout, '', `an unknown command printed ${JSON.stringify(bad.stdout)} on stdout`);
});

Run the finished program

node main.js add "Went running" --tag sport
Reference solution

Try the milestones first. This solution passes every acceptance test and the type checker.

main.js

import {run} from './logbook.js';

// The data file comes from LOGBOOK_FILE (for example through --env-file), or logbook.json.
const file = process.env.LOGBOOK_FILE ?? 'logbook.json';
const result = await run(process.argv.slice(2), {file});
process.stdout.write(result.out);
process.stderr.write(result.err);
process.exitCode = result.code;

logbook.js

import {readFile, writeFile} from 'node:fs/promises';
import {parseArgs} from 'node:util';

export const USAGE = `Usage:
  node main.js add <text> [--tag <tag>]...
  node main.js list [--tag <tag>]
  node main.js export <file>`;

const OPTIONS = {tag: {type: 'string', short: 't', multiple: true}};

// Reads the entries. A missing file is an empty logbook only when missingOk is true
// (add creates it); otherwise it is an error, and so is a corrupt file.
export async function load(file, {missingOk = false} = {}) {
  let text;
  try {
    text = await readFile(file, 'utf8');
  } catch (error) {
    if (error.code !== 'ENOENT') throw error;
    if (missingOk) return [];
    throw new Error(`no logbook at ${file}; add an entry first`);
  }
  let entries;
  try {
    entries = JSON.parse(text);
  } catch {
    throw new Error(`${file} is not valid JSON`);
  }
  if (!Array.isArray(entries)) throw new Error(`${file} does not hold a list of entries`);
  return entries;
}

// One line per entry: id, date, text, then the tags in brackets.
export function format(entry) {
  const tags = entry.tags.length ? `  [${entry.tags.join(', ')}]` : '';
  return `${entry.id}  ${entry.date}  ${entry.text}${tags}`;
}

// Runs one command. Returns what to print and the exit code instead of printing,
// so tests can call it directly.
export async function run(args, {file, now = () => new Date()}) {
  const usage = (message) => ({code: 2, out: '', err: `logbook: ${message}\n${USAGE}\n`});
  let parsed;
  try {
    parsed = parseArgs({args, options: OPTIONS, allowPositionals: true});
  } catch (error) {
    return usage(error.message);
  }
  const [command, ...rest] = parsed.positionals;
  const tags = parsed.values.tag ?? [];
  try {
    if (command === 'add') {
      const text = rest.join(' ').trim();
      if (!text) return usage('add needs the text of the entry');
      const entries = await load(file, {missingOk: true});
      const id = entries.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
      entries.push({id, date: now().toISOString().slice(0, 10), text, tags});
      await writeFile(file, JSON.stringify(entries, null, 2) + '\n');
      return {code: 0, out: `Added entry ${id}.\n`, err: ''};
    }
    if (command === 'list') {
      const entries = (await load(file)).filter((entry) => tags.every((tag) => entry.tags.includes(tag)));
      if (entries.length === 0) return {code: 0, out: 'No entries.\n', err: ''};
      return {code: 0, out: entries.map(format).join('\n') + '\n', err: ''};
    }
    if (command === 'export') {
      if (rest.length !== 1) return usage('export needs exactly one file name');
      const entries = await load(file);
      await writeFile(rest[0], JSON.stringify(entries, null, 2) + '\n');
      return {code: 0, out: `Exported ${entries.length} entries to ${rest[0]}.\n`, err: ''};
    }
    return usage(command ? `unknown command "${command}"` : 'no command given');
  } catch (error) {
    return {code: 1, out: '', err: `logbook: ${error.message}\n`};
  }
}

Take it further

  • Add a delete <id> command that fails with exit code 1 when the id does not exist.
  • Write the data file atomically: write to a temporary file in the same folder, then rename it over the old one (Module B3).
  • Add --since YYYY-MM-DD to list, and a stats command that counts entries per tag.
  • Add a "logbook" script to package.json and try it with node --run logbook -- list.

Projects are practice: your checks run in your browser or on your computer and never count toward a certificate.