DEV Community

Wilson Xu
Wilson Xu

Posted on

Build a Monorepo Management CLI with Node.js

Build a Monorepo Management CLI with Node.js

Monorepos are everywhere — Vercel, Google, Meta, and most modern open source projects use them. But managing a monorepo means juggling workspaces, dependency versions, build orders, and change detection across dozens of packages. Tools like Turborepo and Nx handle this, but understanding how they work under the hood makes you a better developer.

In this article, we'll build a lightweight monorepo management CLI that handles the core operations: workspace discovery, dependency graph resolution, change detection, and ordered task execution. Along the way, you'll learn the algorithms and data structures that power production monorepo tools.

What We're Building

monoman — a CLI that:

  1. Discovers all workspaces in a monorepo (npm/yarn/pnpm workspaces)
  2. Builds a dependency graph between packages
  3. Detects which packages changed since a git ref
  4. Runs tasks (build, test, lint) in correct dependency order
  5. Supports parallel execution with dependency-aware scheduling

Step 1: Workspace Discovery

Every monorepo tool starts by finding all packages. npm/yarn/pnpm workspaces define packages through glob patterns in the root package.json:

// lib/workspaces.js
import { readFile } from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { glob } from 'glob';

export async function discoverWorkspaces(rootDir) {
  const rootPkg = JSON.parse(
    await readFile(join(rootDir, 'package.json'), 'utf-8')
  );

  // Support npm/yarn and pnpm workspace formats
  const patterns = rootPkg.workspaces?.packages
    || rootPkg.workspaces
    || [];

  if (patterns.length === 0) {
    // Check pnpm-workspace.yaml
    try {
      const yaml = await readFile(join(rootDir, 'pnpm-workspace.yaml'), 'utf-8');
      const match = yaml.match(/packages:\s*\n((?:\s+-\s+.+\n?)+)/);
      if (match) {
        patterns.push(...match[1].match(/- (.+)/g).map(m => m.slice(2).trim().replace(/['"]/g, '')));
      }
    } catch {}
  }

  const workspaces = [];

  for (const pattern of patterns) {
    const matches = await glob(pattern, { cwd: rootDir });
    for (const match of matches) {
      const pkgPath = join(rootDir, match, 'package.json');
      try {
        const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
        workspaces.push({
          name: pkg.name,
          version: pkg.version,
          path: resolve(rootDir, match),
          relativePath: match,
          dependencies: {
            ...pkg.dependencies,
            ...pkg.devDependencies,
          },
          scripts: pkg.scripts || {},
        });
      } catch {}
    }
  }

  return workspaces;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build the Dependency Graph

The dependency graph tells us which packages depend on which. This is a directed acyclic graph (DAG) — and getting the ordering right is critical for builds:

// lib/graph.js
export function buildDependencyGraph(workspaces) {
  const names = new Set(workspaces.map(w => w.name));
  const graph = new Map(); // package name → Set of internal dependencies

  for (const workspace of workspaces) {
    const internalDeps = new Set();

    for (const dep of Object.keys(workspace.dependencies)) {
      if (names.has(dep)) {
        internalDeps.add(dep);
      }
    }

    graph.set(workspace.name, internalDeps);
  }

  return graph;
}

export function topologicalSort(graph) {
  const sorted = [];
  const visited = new Set();
  const visiting = new Set();

  function visit(name) {
    if (visited.has(name)) return;
    if (visiting.has(name)) {
      throw new Error(`Circular dependency detected involving: ${name}`);
    }

    visiting.add(name);
    const deps = graph.get(name) || new Set();
    for (const dep of deps) {
      visit(dep);
    }
    visiting.delete(name);
    visited.add(name);
    sorted.push(name);
  }

  for (const name of graph.keys()) {
    visit(name);
  }

  return sorted;
}

export function getAffectedPackages(graph, changedPackages) {
  const affected = new Set(changedPackages);

  // Find all packages that depend on changed packages (reverse deps)
  let changed = true;
  while (changed) {
    changed = false;
    for (const [pkg, deps] of graph) {
      if (affected.has(pkg)) continue;
      for (const dep of deps) {
        if (affected.has(dep)) {
          affected.add(pkg);
          changed = true;
          break;
        }
      }
    }
  }

  return affected;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Git-Based Change Detection

This is the killer feature that makes monorepo tools fast — only rebuild what changed:

// lib/changes.js
import { execSync } from 'node:child_process';

export function getChangedFiles(since = 'main') {
  try {
    const output = execSync(
      `git diff --name-only ${since}...HEAD`,
      { encoding: 'utf-8' }
    ).trim();

    if (!output) return [];
    return output.split('\n');
  } catch {
    // If the ref doesn't exist, consider everything changed
    return null;
  }
}

export function getChangedPackages(workspaces, changedFiles) {
  if (changedFiles === null) {
    // Everything changed (fallback)
    return new Set(workspaces.map(w => w.name));
  }

  const changed = new Set();

  for (const file of changedFiles) {
    for (const workspace of workspaces) {
      if (file.startsWith(workspace.relativePath + '/')) {
        changed.add(workspace.name);
        break;
      }
    }
  }

  return changed;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Task Execution Engine

Run tasks in dependency order, with optional parallelism:

// lib/runner.js
import { spawn } from 'node:child_process';
import chalk from 'chalk';

async function runTask(workspace, script) {
  return new Promise((resolve, reject) => {
    const label = chalk.cyan(`[${workspace.name}]`);
    console.error(`${label} Running ${script}...`);

    const child = spawn('npm', ['run', script], {
      cwd: workspace.path,
      stdio: ['ignore', 'pipe', 'pipe'],
    });

    let stdout = '';
    let stderr = '';

    child.stdout.on('data', (data) => {
      stdout += data;
      process.stdout.write(`${label} ${data}`);
    });

    child.stderr.on('data', (data) => {
      stderr += data;
      process.stderr.write(`${label} ${data}`);
    });

    child.on('close', (code) => {
      if (code === 0) {
        console.error(`${label} ${chalk.green('')} ${script} completed`);
        resolve({ workspace: workspace.name, script, success: true, stdout, stderr });
      } else {
        console.error(`${label} ${chalk.red('')} ${script} failed (exit ${code})`);
        reject({ workspace: workspace.name, script, success: false, code, stdout, stderr });
      }
    });
  });
}

export async function runInOrder(workspaces, graph, script, options = {}) {
  const order = topologicalSort(graph);
  const workspaceMap = new Map(workspaces.map(w => [w.name, w]));
  const results = [];

  for (const name of order) {
    const workspace = workspaceMap.get(name);
    if (!workspace) continue;
    if (!workspace.scripts[script]) continue;
    if (options.filter && !options.filter.has(name)) continue;

    try {
      const result = await runTask(workspace, script);
      results.push(result);
    } catch (error) {
      results.push(error);
      if (!options.continueOnError) {
        throw new Error(`Task "${script}" failed in ${name}`);
      }
    }
  }

  return results;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: The CLI Interface

#!/usr/bin/env node
// bin/monoman.js

import { program } from 'commander';
import chalk from 'chalk';
import { discoverWorkspaces } from '../lib/workspaces.js';
import { buildDependencyGraph, topologicalSort, getAffectedPackages } from '../lib/graph.js';
import { getChangedFiles, getChangedPackages } from '../lib/changes.js';
import { runInOrder } from '../lib/runner.js';

program
  .name('monoman')
  .description('Lightweight monorepo management');

program
  .command('list')
  .description('List all workspaces')
  .option('--json', 'Output as JSON')
  .action(async (options) => {
    const workspaces = await discoverWorkspaces(process.cwd());

    if (options.json) {
      console.log(JSON.stringify(workspaces.map(w => ({
        name: w.name,
        version: w.version,
        path: w.relativePath,
      })), null, 2));
    } else {
      for (const w of workspaces) {
        console.log(`  ${chalk.cyan(w.name)} ${chalk.gray(w.version)} ${chalk.gray(w.relativePath)}`);
      }
      console.log(chalk.gray(`\n  ${workspaces.length} packages found`));
    }
  });

program
  .command('graph')
  .description('Show dependency graph')
  .action(async () => {
    const workspaces = await discoverWorkspaces(process.cwd());
    const graph = buildDependencyGraph(workspaces);
    const order = topologicalSort(graph);

    console.log(chalk.bold('\n  Build Order:\n'));
    order.forEach((name, i) => {
      const deps = graph.get(name);
      const depStr = deps.size > 0 ? chalk.gray(` → ${[...deps].join(', ')}`) : '';
      console.log(`  ${i + 1}. ${chalk.cyan(name)}${depStr}`);
    });
    console.log();
  });

program
  .command('changed')
  .description('Show packages changed since a git ref')
  .option('--since <ref>', 'Git ref to compare against', 'main')
  .option('--affected', 'Include packages affected by changes (transitive)')
  .action(async (options) => {
    const workspaces = await discoverWorkspaces(process.cwd());
    const files = getChangedFiles(options.since);
    let changed = getChangedPackages(workspaces, files);

    if (options.affected) {
      const graph = buildDependencyGraph(workspaces);
      changed = getAffectedPackages(graph, changed);
    }

    for (const name of changed) {
      console.log(`  ${chalk.yellow(name)}`);
    }
    console.log(chalk.gray(`\n  ${changed.size} packages ${options.affected ? 'affected' : 'changed'}`));
  });

program
  .command('run <script>')
  .description('Run a script across workspaces in dependency order')
  .option('--since <ref>', 'Only run for packages changed since ref')
  .option('--affected', 'Include transitively affected packages')
  .option('--continue-on-error', 'Continue if a task fails')
  .action(async (script, options) => {
    const workspaces = await discoverWorkspaces(process.cwd());
    const graph = buildDependencyGraph(workspaces);

    let filter;
    if (options.since) {
      const files = getChangedFiles(options.since);
      filter = getChangedPackages(workspaces, files);
      if (options.affected) {
        filter = getAffectedPackages(graph, filter);
      }
    }

    try {
      const results = await runInOrder(workspaces, graph, script, {
        filter,
        continueOnError: options.continueOnError,
      });

      const passed = results.filter(r => r.success).length;
      const failed = results.filter(r => !r.success).length;

      console.log(`\n  ${chalk.green(`${passed} passed`)}${failed > 0 ? `, ${chalk.red(`${failed} failed`)}` : ''}`);
      process.exit(failed > 0 ? 1 : 0);
    } catch (error) {
      console.error(chalk.red(`\n  ${error.message}`));
      process.exit(1);
    }
  });

program.parse();
Enter fullscreen mode Exit fullscreen mode

Usage

# List all packages
monoman list

# Show build order
monoman graph

# See what changed
monoman changed --since main
monoman changed --since main --affected

# Build only changed packages
monoman run build --since main --affected

# Test everything in order
monoman run test

# CI pipeline
monoman run build --since origin/main --affected
monoman run test --since origin/main --affected --continue-on-error
Enter fullscreen mode Exit fullscreen mode

What We Learned

Building this tool reveals the three algorithms at the heart of every monorepo tool:

  1. Workspace discovery — glob pattern matching against package.json workspace configs
  2. Topological sort — ordering packages so dependencies build before dependents
  3. Change detection — using git diff to identify modified packages, then tracing the dependency graph to find everything affected

These same algorithms power Turborepo's pipeline scheduling, Nx's affected commands, and Lerna's dependency-aware task running. Understanding them demystifies these tools and helps you debug when they behave unexpectedly.


Wilson Xu builds developer tools and publishes them on npm. Follow his writing at dev.to/chengyixu.

Top comments (0)