DEV Community

Cover image for Building a Task Tracker CLI app with Node.js
Abraham Adedamola Olawale
Abraham Adedamola Olawale

Posted on

Building a Task Tracker CLI app with Node.js

Introduction

As developers, we often find ourselves juggling multiple tasks, bugs, and features simultaneously. While there are countless GUI-based task management tools available, sometimes the fastest way to track your work is right from your terminal. In this tutorial, I'll walk you through building a fully functional command-line task tracker using Node.js.

By the end of this post, you'll understand how to create a CLI tool with persistent storage, handle command-line arguments, implement CRUD operations, and manage application state using the file system. This project is inspired by the Task Tracker project on roadmap.sh, and the complete source code is available on GitHub.

Why Build a CLI Tool?

Before diving into the code, let's consider why CLI tools are valuable for developers:

Speed and Efficiency: CLI tools eliminate the overhead of launching applications and navigating through interfaces. A simple command gets the job done instantly.

Developer-Friendly: Most developers spend significant time in the terminal. Having task management integrated into your workflow means less context switching.

Lightweight: No heavy dependencies, no complex UI frameworksβ€”just pure functionality. CLI tools are fast to start and consume minimal resources.

Learning Opportunity: Building a CLI app teaches you fundamental concepts like file I/O, argument parsing, data persistence, and modular architecture.

Prerequisites

Before we begin, make sure you have:

  • Node.js installed (v14 or higher recommended)
  • Basic understanding of JavaScript and Node.js
  • Familiarity with command-line operations
  • A text editor or IDE of your choice

Project Overview

Our task tracker will support the following operations:

  • Add tasks with automatic ID assignment
  • Update task descriptions by ID
  • Mark tasks as in-progress or done
  • Delete tasks permanently
  • List all tasks or filter by status (todo, in-progress, done)
  • Persistent storage using a local JSON file

The tech stack is intentionally simple: Node.js, the File System (fs) module, JSON for data storage, and ES Modules for clean, modern JavaScript.

Setting Up the Project

Let's start by creating the project structure:

mkdir task-tracker-cli
cd task-tracker-cli
npm init -y
Enter fullscreen mode Exit fullscreen mode

Your project structure should look like this:

task-tracker-cli/
β”œβ”€β”€ bin/
β”‚   └── index.js
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ commands/
β”‚   β”‚   β”œβ”€β”€ create-task.js
β”‚   β”‚   β”œβ”€β”€ delete-task.js
β”‚   β”‚   β”œβ”€β”€ list-task.js
β”‚   β”‚   β”œβ”€β”€ update-task.js
β”‚   β”‚   └── update-task-status.js
β”‚   └── utils/
β”‚       └── utils.js
β”œβ”€β”€ package.json
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ tasks.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

This modular structure separates concerns: the bin/ directory contains the CLI entry point, src/commands/ houses individual command implementations, and src/utils/ provides shared utility functions for file operations and data management.

Update your package.json to include the following:

{
  "name": "task-tracker-cli",
  "version": "1.0.0",
  "description": "A CLI tool for tracking tasks",
  "type": "module",
  "bin": {
    "task-cli": "./bin/index.js"
  },
  "scripts": {
    "link": "npm link"
  },
  "keywords": ["cli", "task-tracker", "nodejs"],
  "author": "Your Name",
  "license": "ISC"
}
Enter fullscreen mode Exit fullscreen mode

The "type": "module" field enables ES Module syntax, and the "bin" field tells npm which file to execute when the task-cli command is run.

Building the Modular Architecture

One of the strengths of this project is its modular structure. Instead of cramming everything into one file, we'll separate concerns into dedicated modules. This makes the code easier to maintain, test, and extend.

Creating the Utility Module

First, let's build the foundation with utility functions for file operations and data management.

Create src/utils/utils.js:

import fs from "fs/promises";
import path from "path";

const TASKS_FILE = path.join(process.cwd(), "tasks.json");

// read tasks from the JSON file
async function readTasks() {
  try {
    const data = await fs.readFile(TASKS_FILE, "utf8");
    if (!data) {
      return [];
    }
    return JSON.parse(data);
  } catch (err) {
    if (err.code === "ENOENT") {
      return [];
    }
    throw err;
  }
}

// write tasks to the JSON file
async function writeTasks(tasks) {
  await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2));
}

// assign a unique ID to a new task
async function assignId() {
  const tasks = await readTasks();
  const maxId =
    tasks.length > 0 ? Math.max(...tasks.map((t) => parseInt(t.id) || 0)) : 0;
  return (maxId + 1).toString();
}

// check if a task with same description exists
async function taskExists(task) {
    const tasks = await readTasks();
    return tasks.some(t => t.description.toLowerCase() === task.toLowerCase());
}

// find a task by its ID
async function findTaskById(id) {
    const tasks = await readTasks();
    return tasks.find(t => t.id === id);
}

export { assignId, readTasks, writeTasks, taskExists, findTaskById };
Enter fullscreen mode Exit fullscreen mode

This utility module provides the core functions that all commands will use: file initialization, reading and writing tasks, ID generation, and task lookup.

Implementing Command Modules

Now let's create separate modules for each command. This approach follows the Single Responsibility Principleβ€”each module does one thing well.

Create src/commands/create-task.js:

import { assignId, readTasks, writeTasks, taskExists } from "../utils/utils.js";

async function createTask(task) {
  const taskData = {
    id: await assignId(),
    description: task,
    status: "todo",
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  try {
    const tasks = await readTasks();
    if (await taskExists(task)) {
      return "Task already exists.";
    }
    tasks.push(taskData);
    await writeTasks(tasks);
    return `Task added successfully (ID: ${taskData.id})`;
  } catch (err) {
    console.error("Error creating task:", err);
    throw err;
  }
}

export default createTask;
Enter fullscreen mode Exit fullscreen mode

Create src/commands/update-task.js:

import { readTasks, writeTasks, findTaskById } from "../utils/utils.js";

async function updateTask(id, newDesc) {
    try {
        if (!(await findTaskById(id))) {
            return `Task with ID ${id} not found.`;
        }

        const tasks = await readTasks();
        const updatedTasks = tasks.map(task => {
            if (task.id === id) {
                return {
                    ...task,
                    description: newDesc,
                    updatedAt: new Date().toISOString()
                };
            }
            return task;
        });
        await writeTasks(updatedTasks);
        return `Task with ID ${id} updated successfully.`;
    } catch (err) {
        console.error("Error updating task:", err);
        throw err;
    }
}

export default updateTask;
Enter fullscreen mode Exit fullscreen mode

Create src/commands/delete-task.js:

import { readTasks, writeTasks, findTaskById } from "../utils/utils.js";

async function deleteTask(id) {
  try {
    if (!(await findTaskById(id))) {
      return `Task with ID ${id} not found`;
    }

    const tasks = await readTasks();
    const filteredTasks = tasks.filter((task) => task.id !== id);
    await writeTasks(filteredTasks);
    return `Task with ID ${id} deleted successfully.`;
  } catch (err) {
    console.error("Error deleting task:", err);
    throw err;
  }
}

export default deleteTask;
Enter fullscreen mode Exit fullscreen mode

Create src/commands/update-task-status.js:

import { readTasks, writeTasks, findTaskById } from "../utils/utils.js";

async function updateTaskStatus(id, newStatus) {
  try {
    if (!(await findTaskById(id))) {
      return `Task with ID ${id} not found.`;
    }

    const tasks = await readTasks();
    const updatedTasks = tasks.map((task) => {
      if (task.id === id) {
        return {
          ...task,
          status: newStatus,
          updatedAt: new Date().toISOString()
        };
      }
      return task;
    });
    await writeTasks(updatedTasks);
    return `Task with ID ${id} status updated to ${newStatus} successfully.`;
  } catch (err) {
    console.error("Error updating task status:", err);
    throw err;
  }
}

export default updateTaskStatus;
Enter fullscreen mode Exit fullscreen mode

Create src/commands/list-task.js:

import { readTasks } from "../utils/utils.js";

async function listTasks(statusFilter = null) {
  try {
    const tasks = await readTasks();

    if (statusFilter) {
      const filteredTasks = tasks.filter((task) => task.status == statusFilter);
      return filteredTasks;
    } else {
      return tasks;
    }
  } catch (err) {
    console.error("Error listing tasks:", err);
    throw err;
  }
}

export default listTasks;
Enter fullscreen mode Exit fullscreen mode

This modular approach offers several benefits: each command is isolated and easy to test, adding new commands doesn't require modifying existing code, the codebase is easier to navigate and understand, and team members can work on different commands simultaneously without conflicts.

Creating the CLI Interface

Now let's create the command-line interface that ties everything together. This is the entry point that users interact with.

Create bin/index.js:

#!/usr/bin/env node

import createTask from "../src/commands/create-task.js";
import deleteTask from "../src/commands/delete-task.js";
import updateTask from "../src/commands/update-task.js";
import updateTaskStatus from "../src/commands/update-task-status.js";
import listTasks from "../src/commands/list-task.js";

console.log("Welcome to my CLI tool!");
console.log("This tool helps you manage your projects efficiently.");
console.log("Use 'task-cli --help' to see available commands.");

const args = process.argv.slice(2);
const command = args[0];

if (command === "add") {
  const value = args[1];
  if (value) {
    createTask(value)
      .then((message) => {
        console.log(message);
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  } else {
    console.error("Please provide a task description.");
  }
}

if (command == "update") {
  const id = args[1];
  const newDesc = args[2];

  if (id && newDesc) {
    updateTask(id, newDesc)
      .then((message) => {
        console.log(message);
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  } else {
    console.error("Please provide both task ID and new task description.");
  }
}

if (command == "delete") {
  const id = args[1];

  if (id) {
    deleteTask(id)
      .then((message) => {
        console.log(message);
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  } else {
    console.error("Please provide the task ID to delete.");
  }
}

if (command == "mark-in-progress") {
  const id = args[1];
  const newStatus = "in-progress";
  if (id) {
    updateTaskStatus(id, newStatus)
      .then((message) => {
        console.log(message);
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  } else {
    console.error("Please provide the task ID to update status.");
  }
} else if (command == "mark-done") {
  const id = args[1];
  const newStatus = "done";
  if (id) {
    updateTaskStatus(id, newStatus)
      .then((message) => {
        console.log(message);
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  } else {
    console.error("Please provide the task ID to update status.");
  }
}

if (command == "list") {
  const statusFilter = args[1] || null;
  if (statusFilter && !["todo", "in-progress", "done"].includes(statusFilter)) {
    console.error(
      "Invalid status filter. Use 'todo', 'in-progress', or 'done'."
    );
  } else {
    listTasks(statusFilter)
      .then((tasks) => {
        if (tasks && tasks.length > 0) {
          console.log("Tasks:", tasks);
        } else {
          console.log("No tasks found.");
        }
      })
      .catch((err) => {
        console.error("Error:", err);
      });
  }
}

if (command == "--help") {
  const help = `
# Adding a new task
task-cli add "Buy groceries"
# Output: Task added successfully (ID: 1)

# Updating and deleting tasks
task-cli update 1 "Buy groceries and cook dinner"
task-cli delete 1

# Marking a task as in progress or done
task-cli mark-in-progress 1
task-cli mark-done 1

# Listing all tasks
task-cli list

# Listing tasks by status
task-cli list done
task-cli list todo
task-cli list in-progress

    `;
  console.log(help);
}

if (!command) {
  console.error("No command provided. Use '--help' to see available commands.");
} else {
  const validCommands = [
    "add",
    "update",
    "delete",
    "mark-in-progress",
    "mark-done",
    "list",
    "--help",
  ];
  if (!validCommands.includes(command)) {
    console.error(
      `Invalid command: ${command}. Use '--help' to see available commands.`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The shebang line #!/usr/bin/env node at the top tells the system to execute this file using Node.js. The bin/index.js file acts as a router, parsing commands and delegating work to the appropriate command modules.

Making the CLI Globally Available

To use your CLI tool from anywhere in your terminal, run:

npm link
Enter fullscreen mode Exit fullscreen mode

This creates a symbolic link from your global node_modules to your project, making the task-cli command available system-wide.

Usage Examples

Now let's see the task tracker in action:

Adding tasks:

task-cli add "Implement user authentication"
# Output: Task added successfully (ID: 1)

task-cli add "Write unit tests"
# Output: Task added successfully (ID: 2)

task-cli add "Update documentation"
# Output: Task added successfully (ID: 3)
Enter fullscreen mode Exit fullscreen mode

Listing all tasks:

task-cli list
# Output:
Tasks: [
  {
    id: '1',
    description: 'Implement user authentication',
    status: 'todo',
    createdAt: '2026-01-03T14:44:19.324Z',
    updatedAt: '2026-01-03T14:44:19.329Z'
  },
  {
    id: '2',
    description: 'Write unit tests',
    status: 'todo',
    createdAt: '2026-01-03T14:44:44.451Z',
    updatedAt: '2026-01-03T14:44:44.456Z'
  },
  {
    id: '3',
    description: 'Update documentation',
    status: 'todo',
    createdAt: '2026-01-03T14:45:00.989Z',
    updatedAt: '2026-01-03T14:45:00.996Z'
  }
]
Enter fullscreen mode Exit fullscreen mode

Updating task status:

task-cli mark-in-progress 1
# Output: Task with ID 1 status updated to in-progress successfully.

task-cli mark-done 1
# Output: Task with ID 1 status updated to done successfully.
Enter fullscreen mode Exit fullscreen mode

Filtering by status:

task-cli list done
# Output:
Tasks: [
  {
    id: '1',
    description: 'Implement user authentication',
    status: 'done',
    createdAt: '2026-01-03T14:44:19.324Z',
    updatedAt: '2026-01-03T14:47:03.203Z'
  }
]
Enter fullscreen mode Exit fullscreen mode

Updating task description:

task-cli update 2 "Write comprehensive unit tests"
# Output: Task with ID 2 updated successfully.
Enter fullscreen mode Exit fullscreen mode

Deleting a task:

task-cli delete 3
# Output: Task with ID 3 deleted successfully.
Enter fullscreen mode Exit fullscreen mode

Key Implementation Details

Modular Architecture

The project follows a clean separation of concerns with three distinct layers:

Entry Point (bin/index.js): Handles command-line argument parsing and routing. It validates input and delegates work to appropriate command modules.

Command Modules (src/commands/): Each command has its own file with a single responsibility. This makes the codebase easier to navigate, test, and extend.

Utility Layer (src/utils/utils.js): Provides shared functionality for file operations, data management, and common tasks like ID generation and task lookup.

This architecture allows you to add new commands without touching existing code. Want to add a mark-todo command? Just create a new file in the commands folder and import it in the main CLI file.

File-Based Persistence

The application uses a simple JSON file (tasks.json) for data storage. This approach offers several advantages for a CLI tool: no external database setup required, human-readable data format, easy backup and version control, and zero configuration.

Asynchronous Operations

All file operations use async/await syntax with the fs/promises API. This ensures non-blocking I/O operations and provides cleaner error handling compared to callback-based approaches.

Data Validation

The application includes several validation checks: preventing duplicate task descriptions, verifying task existence before updates or deletions, validating required arguments for each command, and providing clear error messages for invalid operations.

ID Management

The assignId() function ensures each task gets a unique identifier by finding the maximum existing ID and incrementing it. This simple approach works well for a local, single-user application.

Status Lifecycle

Tasks follow a clear progression: todo (initial state) β†’ in-progress (actively being worked on) β†’ done (completed). The system allows direct transitions to any state, giving users flexibility in their workflow.

Challenges and Solutions

During development, I encountered several interesting challenges:

Making the CLI Globally Accessible: One of the first hurdles was figuring out how to run my CLI tool from anywhere in the terminal, not just from the project directory. Initially, I could only run the app using node bin/index.js, which wasn't the seamless experience I wanted. The solution was understanding npm's bin configuration in package.json and using npm link.

Parsing Command-Line Arguments: Understanding how to capture and process user input from the command line was initially confusing. I needed to figure out how to read commands like task-cli add "Finish documentation" and extract both the command (add) and the arguments (Finish documentation). After diving into the Node.js documentation, I discovered process.argv. This array contains all command-line arguments, but the first two elements are always the Node.js executable path and the script path. So I used process.argv.slice(2) to get only the user's input:

const args = process.argv.slice(2);
const command = args[0];  // 'add'
const taskDescription = args[1];  // 'Finish documentation'
Enter fullscreen mode Exit fullscreen mode

Testing Your CLI

While this tutorial doesn't include a full test suite, you should test these scenarios:

  • Adding tasks with various descriptions (short, long, special characters)
  • Updating non-existent tasks (should show error)
  • Deleting tasks and verifying they're removed
  • Listing tasks with different status filters
  • Adding duplicate tasks (should be prevented)
  • Running commands without required arguments

Future Enhancements

This project provides a solid foundation for more advanced features:

Priority Levels: Add high, medium, low priority flags to tasks.

Due Dates: Implement deadlines and sorting by due date.

Tags and Categories: Organize tasks with labels like "bug", "feature", "urgent".

Search Functionality: Find tasks by keyword in descriptions.

Task Dependencies: Link tasks that depend on others.

Export/Import: Share task lists between machines or with teammates.

Cloud Sync: Store tasks in a cloud database for multi-device access.

Colored Output: Use chalk or similar libraries for better visual distinction.

Interactive Mode: Implement a REPL-style interface for rapid task management.

Conclusion

Building a CLI task tracker is an excellent way to learn Node.js fundamentals while creating a genuinely useful tool. Throughout this tutorial, we covered file system operations, command-line argument parsing, data persistence with JSON, error handling, and creating globally accessible npm commands.

The beauty of CLI tools lies in their simplicity and speed. Once you've built this foundation, you can extend it with features that match your specific workflow. The code is clean, modular, and ready for enhancement.

I encourage you to clone the repository, experiment with the code, and add your own features. Building tools for yourself is one of the most rewarding aspects of programming.

Resources

Connect With Me

Have questions or suggestions? Feel free to reach out:

Happy coding, and may your tasks always be well-tracked! πŸš€


If you found this tutorial helpful, please star the repository and share it with other developers learning Node.js!

Top comments (0)