Mini Project: A Task Manager CLI Backed by JSON

Advanced
16 min

Mini Project: A Task Manager CLI Backed by JSON

This closing project combines the skills from the whole course into one small program: a command-line task manager whose entire database is a JSON file. You will design the file, describe it with a JSON Schema, load and save it safely, expose commands with Node's built-in argument parser, and export records as JSON Lines that jq can query.

Project Setup

Create the project and the four files it needs: cli.js (commands), store.js (load, validate, save), schema.json (the contract) and tasks.json (the data, created on first run).

bash
mkdir tasks-cli && cd tasks-cli npm init -y npm pkg set type=module bin.tasks=./cli.js npm install ajv

type: module enables import and top-level await; bin.tasks lets npm link install the command as tasks.

The Data File and Its Schema

Design the document first. A top-level version field leaves room for migrations, and the tasks live in an array of flat objects:

json
{ "version": 1, "tasks": [ { "id": 1, "title": "Write the JSON course", "done": false, "createdAt": "2026-09-27T09:00:00.000Z" } ] }

schema.json makes that shape enforceable: additionalProperties: false rejects unexpected keys, and the constraints on id and title catch corrupted data early:

json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["version", "tasks"], "additionalProperties": false, "properties": { "version": { "const": 1 }, "tasks": { "type": "array", "items": { "type": "object", "required": ["id", "title", "done", "createdAt"], "additionalProperties": false, "properties": { "id": { "type": "integer", "minimum": 1 }, "title": { "type": "string", "minLength": 1, "maxLength": 200 }, "done": { "type": "boolean" }, "createdAt": { "type": "string" } } } } } }

The Store: Load, Validate, Save Atomically

store.js is the only module that touches the disk. A missing file is a normal first run, invalid JSON or shape fails loudly, and every save goes through a temporary file and rename so a crash never leaves a half-written database.

javascript
import { readFile, writeFile, rename } from "node:fs/promises"; import Ajv from "ajv/dist/2020.js"; // the build that understands draft 2020-12 const FILE = new URL("./tasks.json", import.meta.url); const schema = JSON.parse(await readFile(new URL("./schema.json", import.meta.url), "utf8")); const validate = new Ajv().compile(schema); export async function load() { let data; try { data = JSON.parse(await readFile(FILE, "utf8")); } catch (err) { if (err.code !== "ENOENT") throw err; // SyntaxError and others propagate data = { version: 1, tasks: [] }; } if (!validate(data)) throw new Error("tasks.json is invalid: " + JSON.stringify(validate.errors[0])); return data; } export async function save(data) { if (!validate(data)) throw new Error("refusing to save invalid data"); const tmp = new URL("./tasks.json.tmp", import.meta.url); await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8"); await rename(tmp, FILE); }

Validating on save as well as on load means a buggy command can never persist bad data. The ajv/dist/2020.js import matters: the default export supports only draft-07.

The Commands

cli.js, shown in full at the top of this lesson, uses parseArgs from node:util to separate the command and its arguments from flags. Each command loads the document, changes the in-memory object, and calls save. list --json prints the raw array so the output can feed other tools, and export writes one minified task per line to tasks.jsonl.

bash
node cli.js add Write the JSON course node cli.js add Review pull requests node cli.js done 1 node cli.js list # [x] #1 Write the JSON course # [ ] #2 Review pull requests node cli.js list --json | jq -r '.[] | select(.done | not) | .title' # Review pull requests

Open tasks.json afterwards: it is pretty-printed, ends with a newline and diffs cleanly in git.

Extending the Project

Each extension maps to a chapter of this course:

  • Migrations. Bump version to 2, add a tags array to the schema, and write a migrate() step in load() that upgrades old files.
  • Dates. Add ajv-formats and set "format": "date-time" on createdAt; revive it into a Date with a reviver when you need date arithmetic.
  • A REST layer. Wrap load and save in an Express app with express.json() and res.json(), then call it with fetch.
  • A real database. Move the tasks into a PostgreSQL jsonb column or a MongoDB collection when several users need concurrent writes.
Quick Quiz
Question 1 of 3

Why does `store.js` validate the document both when loading and when saving?

Key Takeaways

  • Design the JSON document first, with a version field, then encode the shape as a JSON Schema.
  • Keep all file access in one module that validates on load and on save and writes atomically via a temp file and rename.
  • Treat a missing file as a first run and any other error as a real failure.
  • Offer a --json output mode so your tool composes with jq and other programs.
  • The same pattern scales from a CLI to a REST API to a database-backed service.

Next lesson: What to learn next — continue with the REST APIs, Express.js, MongoDB and TypeScript courses, where every request body, document and config file you handle is the JSON you now master.