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:
- Discovers all workspaces in a monorepo (npm/yarn/pnpm workspaces)
- Builds a dependency graph between packages
- Detects which packages changed since a git ref
- Runs tasks (build, test, lint) in correct dependency order
- 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;
}
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;
}
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;
}
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;
}
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();
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
What We Learned
Building this tool reveals the three algorithms at the heart of every monorepo tool:
-
Workspace discovery — glob pattern matching against
package.jsonworkspace configs - Topological sort — ordering packages so dependencies build before dependents
-
Change detection — using
git diffto 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)