Skip to content
aviral gupta

// B3.5 · ~45 min · Beginner

Build: a notes CLI that stores JSON on disk

You build a notes command that keeps each note as its own JSON file: saved safely, listed in order, shown, deleted and cleared, with clear exit codes.

Lesson 5 of 5 in B3 Files and paths

End of the module

You will be able to

  • Store one JSON file per note: slug names, a folder made by mkdir, and temp-file-plus-rename writes
  • List, show and delete notes with readdir, stat, readFile and rm, skipping entries that are not notes
  • Wire it into a command with util.parseArgs, messages on stderr and exit codes 0, 1 and 2
  1. Warm-up · Activity 1 of 7

    Warm-up from module B3: which statements are true? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on. The finished tool has no notes folder yet. You run the command below. Which file is in notes afterwards?

    node main.js save "Buy Milk!" --body "two litres"
  3. Practice · Activity 3 of 7

    Fill in the replacement so that every run of characters other than a-z and 0-9 becomes one -, and " Buy Milk! " becomes buy-milk.

    const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "____").replace(/^-+|-+$/g, "");
    const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "").replace(/^-+|-+$/g, "");
  4. Practice · Activity 4 of 7

    The finished tool has no notes yet. Match each command line to what it does.

  5. Practice · Activity 5 of 7

    What does the last command print?

    node main.js save "Buy milk" --body "two litres"
    node main.js save "buy  MILK" --body "one litre"
    node main.js list
  6. Brain teaser · Activity 6 of 7

    Brain teaser. A run crashed before its rename and left a half-written temp file. notes now holds buy-milk.json, .call-ada.json.4012.tmp and an empty folder archive. What does node main.js list print?

  7. Apply · Activity 7 of 7

    Mini-task. Add a command archive <slug> to run(): it moves notes/<slug>.json into the folder notes/archive, which it creates when needed, and prints archived and the slug. list must not show archived notes; check that it does not, and say why. A missing note prints no note: and the name, with exit code 1. Reuse isSlug, so archive ../main is refused.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The same shape, a different tool: bookmarks

Before you build the notes CLI, here is the same storage idea on a smaller scale. Every bookmark is its own file, bookmarks/<name>.json. save makes the folder if needed and replaces the file through a temp file and rename; list keeps only .json files, sorted, with their size from stat. The demo starts from an empty folder, adds a subfolder that list must skip, saves a name twice, and tries to remove a file that does not exist.

main.js

import {mkdir, readdir, rename, rm, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';

const dir = join(import.meta.dirname, 'bookmarks');

// One file per bookmark, replaced in one step.
async function save(name, url) {
  await mkdir(dir, {recursive: true});
  const temp = join(dir, `.${name}.json.${process.pid}.tmp`);
  await writeFile(temp, JSON.stringify({url}, null, 2) + '\n');
  await rename(temp, join(dir, `${name}.json`));
}

// Only .json files count, sorted, each with its size.
async function list() {
  const lines = [];
  for (const entry of await readdir(dir, {withFileTypes: true})) {
    if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
    const {size} = await stat(join(dir, entry.name));
    lines.push(`${entry.name} ${size} bytes`);
  }
  return lines.sort().join(' | ');
}

await rm(dir, {recursive: true, force: true}); // a clean start for the demo
await save('nodejs', 'https://nodejs.org');
await save('mdn', 'https://developer.mozilla.org');
await mkdir(join(dir, 'old'), {recursive: true}); // a folder, not a bookmark
console.log(await list());
await save('nodejs', 'https://nodejs.org/en/learn'); // same name: replaced
console.log(await list());
try {
  await rm(join(dir, 'python.json'));
} catch (error) {
  console.log('rm python.json:', error.code);
}
console.log('in bookmarks:', (await readdir(dir)).sort().join(', '));

Run it with

node main.js

Output

mdn.json 45 bytes | nodejs.json 34 bytes
mdn.json 45 bytes | nodejs.json 43 bytes
rm python.json: ENOENT
in bookmarks: mdn.json, nodejs.json, old
  • The folder old exists, but list skipped it, because it is not a file ending in .json.
  • Saving nodejs again replaced the file: still two bookmarks, and nodejs.json grew from 34 to 43 bytes.
  • rm on a missing file rejects with ENOENT; the notes CLI turns that into no note: and exit code 1.
  • No temp file is left in the folder: each one was renamed to its real name.

Exercises

Exercise 1 of 4

Step 1: names from titles

Write slugify(title): lower-case the title, replace every run of characters other than a-z and 0-9 with one -, and remove any - at both ends. " Buy Milk! " gives buy-milk. If nothing is left, throw an Error that names the title. Also write isSlug(text), true only for such names: buy-milk and 2026, but not ../main, Buy-Milk or a--b. Both are pure, so they run in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    title.toLowerCase().replace(/[^a-z0-9]+/g, '-') turns every run of other characters into one -.

  2. Hint 2

    A second replace(/^-+|-+$/g, '') removes - at the start and at the end.

  3. Hint 3

    For isSlug, test the whole text with /^[a-z0-9]+(-[a-z0-9]+)*$/: groups of letters and digits, one - between them.

Show a solution

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

// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
  return slug;
}

// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}

console.log(slugify('  Buy Milk!  '), isSlug('../main'));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
  return title.toLowerCase();
}

// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
  return text !== '';
}

console.log(slugify('  Buy Milk!  '), isSlug('../main'));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {isSlug, slugify} from './main.js';

test('"  Buy Milk!  " becomes buy-milk', () => {
  assert.equal(slugify('  Buy Milk!  '), 'buy-milk', `slugify returned ${JSON.stringify(slugify('  Buy Milk!  '))}`);
});

test('runs of other characters become one -', () => {
  assert.equal(slugify('Ideas: 2026 -- draft'), 'ideas-2026-draft', `slugify returned ${JSON.stringify(slugify('Ideas: 2026 -- draft'))}`);
});

test('a title without letters or digits throws an Error naming it', () => {
  assert.throws(() => slugify('!!!'), {message: /!!!/}, 'slugify("!!!") should throw an Error whose message contains !!!');
});

test('isSlug accepts slugs only', () => {
  for (const text of ['buy-milk', '2026', 'a1-b2']) assert.equal(isSlug(text), true, `isSlug(${JSON.stringify(text)}) should be true`);
  for (const text of ['../main', 'Buy-Milk', 'a--b', '-a', 'a-', '', 'a b']) assert.equal(isSlug(text), false, `isSlug(${JSON.stringify(text)}) should be false`);
});

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Exercise 2 of 4

Step 2: check a note file

A note file can be edited by hand, so loadNote checks what JSON.parse gives back before it trusts it. Write validateNote(value): when value is a plain object, not null and not an array, with a title that is a non-empty string and a body that is a string, return a new object with just title and body. Otherwise throw an Error that says what is wrong. It is pure, so it runs in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    typeof null is 'object', and so is typeof [], so test value === null and Array.isArray(value) as well.

  2. Hint 2

    value.title.trim() === '' catches a title that is only spaces; check typeof value.title === 'string' first.

  3. Hint 3

    Build the result yourself, {title: value.title, body: value.body}, so other properties are dropped.

Show a solution

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

// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
  if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
  if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
  return {title: value.title, body: value.body};
}

console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
  return value;
}

console.log(validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'}));

main.test.js

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

test('a good note comes back with title and body only', () => {
  const got = validateNote({title: 'Buy milk', body: 'two litres', colour: 'red'});
  assert.deepEqual(got, {title: 'Buy milk', body: 'two litres'}, `validateNote returned ${JSON.stringify(got)}`);
});

test('an empty body is fine', () => {
  const got = validateNote({title: 'Call Ada', body: ''});
  assert.deepEqual(got, {title: 'Call Ada', body: ''}, `validateNote returned ${JSON.stringify(got)}`);
});

test('null, an array and a string are not notes', () => {
  for (const value of [null, [], 'Buy milk']) assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) should throw`);
});

test('a missing or empty title throws', () => {
  for (const value of [{body: 'x'}, {title: '', body: 'x'}, {title: '   ', body: 'x'}, {title: 42, body: 'x'}]) {
    assert.throws(() => validateNote(value), Error, `validateNote(${JSON.stringify(value)}) should throw`);
  }
});

test('a body that is not a string throws', () => {
  assert.throws(() => validateNote({title: 'Buy milk'}), Error, 'validateNote without a body should throw');
  assert.throws(() => validateNote({title: 'Buy milk', body: 2}), Error, 'validateNote with body 2 should throw');
});

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Exercise 3 of 4

Step 3: the listing

Write formatListing(records, by = "name"). Each record is {slug, title, size, mtimeMs}. Return one line per note, slug, two spaces, title, two spaces, and the size as (50 bytes), each line ending with a newline. Sort by slug as sort() sorts strings, or newest first when by is "newest". No records give "no notes yet" and a newline. Do not change the array you were given. It is pure, so it runs in the browser too.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Sort a copy, [...records].sort(order), because sort() changes the array it is called on.

  2. Hint 2

    For newest first: (a, b) => b.mtimeMs - a.mtimeMs. For slugs: (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0).

  3. Hint 3

    Build each line with r.slug + ' ' + r.title + ' (' + r.size + ' bytes)', join them with a newline, and add one at the end.

Show a solution

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

// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
  if (records.length === 0) return 'no notes yet\n';
  const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
  return [...records].sort(order).map((r) => r.slug + '  ' + r.title + '  (' + r.size + ' bytes)').join('\n') + '\n';
}

const records = [
  {slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
  {slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
  return records.map((r) => r.slug).join('\n');
}

const records = [
  {slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
  {slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];
console.log(formatListing(records));

main.test.js

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

const records = [
  {slug: 'call-ada', title: 'Call Ada', size: 49, mtimeMs: 200},
  {slug: 'buy-milk', title: 'Buy milk', size: 50, mtimeMs: 100}
];

test('sorted by slug, one line per note', () => {
  const got = formatListing(records);
  assert.equal(got, 'buy-milk  Buy milk  (50 bytes)\ncall-ada  Call Ada  (49 bytes)\n', `formatListing returned ${JSON.stringify(got)}`);
});

test('newest first with "newest"', () => {
  const got = formatListing(records, 'newest');
  assert.equal(got, 'call-ada  Call Ada  (49 bytes)\nbuy-milk  Buy milk  (50 bytes)\n', `formatListing returned ${JSON.stringify(got)}`);
});

test('no records give no notes yet', () => {
  assert.equal(formatListing([]), 'no notes yet\n', `formatListing([]) returned ${JSON.stringify(formatListing([]))}`);
});

test('the array passed in keeps its order', () => {
  const copy = [...records];
  formatListing(copy);
  assert.deepEqual(copy, records, 'formatListing changed the order of the array it was given');
});

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Exercise 4 of 4

Step 4: the store on disk

notes.js holds steps 1 to 3, and main.js has run() and its wiring. Write the file-system part. saveNote creates the folder with mkdir and {recursive: true}, writes indented JSON plus a newline to a temp file in the same folder, and renames it over <slug>.json. listNotes returns a record for every .json file, and an empty list for a missing folder. clearNotes deletes every note, then the folder with rmdir if it is empty. Check with node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Build the temp path next to the note: join(dir, '.' + slug + '.json.' + process.pid + '.tmp'), then rename(temp, join(dir, slug + '.json')).

  2. Hint 2

    In listNotes, catch ENOENT from readdir and return []; skip entries with !entry.isFile() || !entry.name.endsWith(".json").

  3. Hint 3

    In clearNotes, call deleteNote for each record of listNotes, then rmdir(dir), ignoring ENOENT and ENOTEMPTY.

Show a solution

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

import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';

const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';

async function loadNote(dir, slug) {
  return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}

async function deleteNote(dir, slug) {
  await rm(join(dir, slug + '.json'));
}

// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
  const slug = slugify(note.title);
  await mkdir(dir, {recursive: true});
  const temp = join(dir, '.' + slug + '.json.' + process.pid + '.tmp');
  await writeFile(temp, JSON.stringify(note, null, 2) + '\n');
  await rename(temp, join(dir, slug + '.json'));
  return slug;
}

// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
  let entries;
  try {
    entries = await readdir(dir, {withFileTypes: true});
  } catch (error) {
    if (error.code === 'ENOENT') return [];
    throw error;
  }
  const records = [];
  for (const entry of entries) {
    if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
    const slug = entry.name.slice(0, -'.json'.length);
    const info = await stat(join(dir, entry.name));
    records.push({slug, title: (await loadNote(dir, slug)).title, size: info.size, mtimeMs: info.mtimeMs});
  }
  return records;
}

// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {
  for (const {slug} of await listNotes(dir)) await deleteNote(dir, slug);
  try {
    await rmdir(dir);
  } catch (error) {
    if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error;
  }
}

// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
  let values, positionals;
  try {
    ({values, positionals} = parseArgs({
      args,
      options: {
        body: {type: 'string', default: ''},
        dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
        newest: {type: 'boolean'}
      },
      allowPositionals: true
    }));
  } catch (error) {
    return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
  }
  const [command, name, ...rest] = positionals;
  const named = name !== undefined && rest.length === 0;
  const {dir} = values;
  try {
    if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
    if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
    if (command === 'clear' && name === undefined) {
      await clearNotes(dir);
      return {out: 'cleared\n', err: '', code: 0};
    }
    if ((command === 'show' || command === 'delete') && named) {
      if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
      if (command === 'delete') {
        await deleteNote(dir, name);
        return {out: 'deleted ' + name + '\n', err: '', code: 0};
      }
      const note = await loadNote(dir, name);
      return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
    }
  } catch (error) {
    if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
    return {out: '', err: 'error: ' + error.message + '\n', code: 1};
  }
  return {out: '', err: USAGE + '\n', code: 2};
}

// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile} from 'node:fs/promises';
import {join} from 'node:path';
import {parseArgs} from 'node:util';
import {formatListing, isSlug, slugify, validateNote} from './notes.js';

const USAGE = 'Usage: node main.js <save|list|show|delete|clear> [title or slug] [--body <text>] [--newest] [--dir <folder>]';

async function loadNote(dir, slug) {
  return validateNote(JSON.parse(await readFile(join(dir, slug + '.json'), 'utf8')));
}

async function deleteNote(dir, slug) {
  await rm(join(dir, slug + '.json'));
}

// One file per note: <dir>/<slug>.json, replaced in one step.
async function saveNote(dir, note) {
  const slug = slugify(note.title);
  await writeFile(join(dir, slug + '.json'), JSON.stringify(note));
  return slug;
}

// A record for every *.json file in dir; a missing dir means no notes yet.
async function listNotes(dir) {
  return [];
}

// Deletes every note, then the folder itself if nothing else is left in it.
async function clearNotes(dir) {}

// What to print, and the exit code: 0 = done, 1 = a note problem, 2 = wrong usage.
async function run(args) {
  let values, positionals;
  try {
    ({values, positionals} = parseArgs({
      args,
      options: {
        body: {type: 'string', default: ''},
        dir: {type: 'string', default: join(import.meta.dirname, 'notes')},
        newest: {type: 'boolean'}
      },
      allowPositionals: true
    }));
  } catch (error) {
    return {out: '', err: error.message + '\n' + USAGE + '\n', code: 2};
  }
  const [command, name, ...rest] = positionals;
  const named = name !== undefined && rest.length === 0;
  const {dir} = values;
  try {
    if (command === 'save' && named) return {out: 'saved ' + (await saveNote(dir, {title: name, body: values.body})) + '\n', err: '', code: 0};
    if (command === 'list' && name === undefined) return {out: formatListing(await listNotes(dir), values.newest ? 'newest' : 'name'), err: '', code: 0};
    if (command === 'clear' && name === undefined) {
      await clearNotes(dir);
      return {out: 'cleared\n', err: '', code: 0};
    }
    if ((command === 'show' || command === 'delete') && named) {
      if (!isSlug(name)) return {out: '', err: 'not a note name: ' + name + '\n', code: 2};
      if (command === 'delete') {
        await deleteNote(dir, name);
        return {out: 'deleted ' + name + '\n', err: '', code: 0};
      }
      const note = await loadNote(dir, name);
      return {out: note.title + '\n\n' + note.body + '\n', err: '', code: 0};
    }
  } catch (error) {
    if (error.code === 'ENOENT') return {out: '', err: 'no note: ' + name + '\n', code: 1};
    return {out: '', err: 'error: ' + error.message + '\n', code: 1};
  }
  return {out: '', err: USAGE + '\n', code: 2};
}

// The only lines that touch the real process.
const {out, err, code} = await run(process.argv.slice(2));
process.stdout.write(out);
process.stderr.write(err);
process.exitCode = code;

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

// A folder of its own for this test run, inside the exercise folder.
const dir = 'test-notes-' + Date.now() + '-' + Math.floor(Math.random() * 1e6);
const notes = async (...args) => runMain({args: [...args, '--dir', dir]});

test('list before any save prints no notes yet', async () => {
  const got = await notes('list');
  assert.equal(got, 'no notes yet\n', `list printed ${JSON.stringify(got)}`);
});

test('save creates the folder and prints saved buy-milk', async () => {
  const got = await notes('save', 'Buy milk', '--body', 'two litres');
  assert.equal(got, 'saved buy-milk\n', `save printed ${JSON.stringify(got)}`);
});

test('list shows the note with the size of its indented JSON', async () => {
  const got = await notes('list');
  assert.equal(got, 'buy-milk  Buy milk  (50 bytes)\n', `list printed ${JSON.stringify(got)}`);
});

test('a second note: list is sorted by slug', async () => {
  await notes('save', 'Call Ada', '--body', 'ring back');
  const got = await notes('list');
  assert.equal(got, 'buy-milk  Buy milk  (50 bytes)\ncall-ada  Call Ada  (49 bytes)\n', `list printed ${JSON.stringify(got)}`);
});

test('saving the same slug again replaces the note', async () => {
  await notes('save', 'buy  MILK', '--body', 'one litre');
  const got = (await notes('list')).split('\n')[0];
  assert.equal(got, 'buy-milk  buy  MILK  (50 bytes)', `the first line of list was ${JSON.stringify(got)}`);
  const shown = await notes('show', 'buy-milk');
  assert.equal(shown, 'buy  MILK\n\none litre\n', `show printed ${JSON.stringify(shown)}`);
});

test('clear removes every note', async () => {
  const cleared = await notes('clear');
  assert.equal(cleared, 'cleared\n', `clear printed ${JSON.stringify(cleared)}`);
  const got = await notes('list');
  assert.equal(got, 'no notes yet\n', `after clear, list printed ${JSON.stringify(got)}`);
});

notes.js

// Pure functions: no fs, no process, so they run anywhere.

// A file-safe name from a title: small letters and digits, words joined by -.
export function slugify(title) {
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  if (slug === '') throw new Error('the title "' + title + '" has no letters or digits');
  return slug;
}

// True only for a name that slugify could have made, such as buy-milk.
export function isSlug(text) {
  return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(text);
}

// Checks a value read from a note file; returns {title, body} or throws.
export function validateNote(value) {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('a note must be an object');
  if (typeof value.title !== 'string' || value.title.trim() === '') throw new Error('a note needs a title');
  if (typeof value.body !== 'string') throw new Error('the body of a note must be text');
  return {title: value.title, body: value.body};
}

// The output of list: one line per note, sorted by slug or newest first.
export function formatListing(records, by = 'name') {
  if (records.length === 0) return 'no notes yet\n';
  const order = by === 'newest' ? (a, b) => b.mtimeMs - a.mtimeMs : (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
  return [...records].sort(order).map((r) => r.slug + '  ' + r.title + '  (' + r.size + ' bytes)').join('\n') + '\n';
}

package.json

{
  "type": "module"
}

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

Run the program:

node main.js

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

node --test
Download learnrun.js

Common mistakes

Calling stat with the bare name

import {mkdir, readdir, stat, writeFile} from 'node:fs/promises';

await mkdir('notes', {recursive: true});
await writeFile('notes/buy-milk.json', '{}\n');
for (const name of await readdir('notes')) {
  console.log(name, (await stat(name)).size);
}

What Node.js prints

Error: ENOENT: no such file or directory, stat 'buy-milk.json'

Why, and the fix

readdir gives names relative to the folder it read, and stat resolves a bare name against the current folder, where buy-milk.json does not exist. Join the folder back on: stat(join("notes", name)). The same holds for readFile and rm.

Listing a folder that is not there yet

import {readdir} from 'node:fs/promises';

const names = await readdir('notes');
console.log(names.length, 'notes');

What Node.js prints

Error: ENOENT: no such file or directory, scandir 'notes'

Why, and the fix

Before the first save there is no notes folder, and readdir rejects with ENOENT. For a list, that simply means no notes: wrap readdir in try/catch, return [] when error.code is ENOENT, and throw any other error again.

Forgetting allowPositionals

import {parseArgs} from 'node:util';

const {values, positionals} = parseArgs({args: ['save', 'Buy milk', '--body', 'two litres'], options: {body: {type: 'string'}}});
console.log(values, positionals);

What Node.js prints

TypeError [ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL]: Unexpected argument 'save'. This command does not take positional arguments

Why, and the fix

By default, parseArgs accepts options only, and the command save and the title are positionals. Pass allowPositionals: true; then positionals is ["save", "Buy milk"]. In run(), the error would be caught and reported as wrong usage with exit code 2.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

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

#

At least 20 characters.

Only if you want a reply.

Key ideas

One file per note

The tool keeps every note in its own file, notes/<slug>.json, next to main.js. The slug comes from the title: slugify("Buy Milk!") is buy-milk, small letters and digits joined by -. A note is easy to find, and saving the same title again replaces it instead of adding a second one. Letters outside a-z, such as ü, become separators: Grüße gives gr-e. saveNote creates the folder with mkdir and {recursive: true}, writes JSON.stringify(note, null, 2) to a temp file in the same folder, then renames it over the note file, so a crash never leaves half a note. --dir points the tool at another folder; the tests use it.

Listing means filtering

list reads the folder with readdir and withFileTypes. A missing folder is not an error: ENOENT means no notes yet. Not every entry is a note: a folder such as archive, or a temp file such as .call-ada.json.4012.tmp left by a crash, is skipped, because only files whose names end in .json count. For each note, stat gives size and mtimeMs, and readFile plus validateNote give the title. formatListing sorts by slug, or newest first with --newest, because readdir promises no order. show and delete accept only names that isSlug approves, so show ../main never reaches a file outside the folder. clear deletes the notes one by one, then the folder with rmdir if it is empty.

The same command shape as in B1

run(args) returns {out, err, code}, as the tip calculator of B1.5 did. parseArgs with allowPositionals gives the command and the title or slug, plus --body, --dir and --newest. A result goes to stdout with exit code 0. A note problem, such as a missing note (ENOENT) or a title without letters or digits, gives one line on stderr and exit code 1. Wrong usage, such as an unknown command, an unknown option or a name like ../main, gives stderr and exit code 2. Only the last four lines touch the real process. The pure parts, slugify, isSlug, validateNote and formatListing, live in notes.js and run in the browser too.

Sources

Last reviewed October 4, 2026