DEV Community

Wilson Xu
Wilson Xu

Posted on

Managing 30 CLI Tools in a Monorepo: Lessons from the Trenches

Managing 30 CLI Tools in a Monorepo: Lessons from the Trenches

When you publish your first CLI tool to npm, everything feels simple. One repo, one package.json, one build step. By the time you hit five tools, you start noticing friction -- duplicated configs, inconsistent patterns, version drift across shared dependencies. At thirty tools, you either have a system or you have chaos.

Over the past year, I have built and maintained a collection of over thirty npm CLI tools from a single monorepo. What started as scattered repositories eventually consolidated into a unified workspace that handles TypeScript compilation, testing, versioning, and publishing for every tool from one root. This article covers the real architecture, the real tradeoffs, and the real numbers from that journey.

Why Monorepos Make Sense for CLI Collections

The argument for monorepos usually centers on large application codebases -- Google, Meta, and similar companies managing millions of lines of code in a single repository. But monorepos are arguably even more compelling for collections of small, related packages.

CLI tools share a surprising amount of infrastructure. Argument parsing, colored output, error formatting, configuration file loading, update checking -- these patterns repeat across every tool. When each tool lives in its own repository, you end up with one of two outcomes: either you duplicate this code everywhere (and fix bugs in one place while forgetting the others), or you extract shared packages and now manage a dependency graph across dozens of repos.

Neither is sustainable. A monorepo collapses both problems. Shared code lives in internal packages that are always at the latest version. A change to your error handling utility is immediately available to all thirty tools without publishing, version bumping, or coordinating updates.

There are practical benefits beyond code sharing. You get atomic commits that span multiple tools -- if a breaking change in your shared config parser requires updates in twelve tools, that is one commit, one review, one merge. Your CI runs against the actual dependency graph, not stale published versions. And onboarding a new contributor means cloning one repo, not figuring out which of thirty repos contains the code they need to change.

Workspace Setup: Choosing Your Package Manager

The three major workspace implementations -- npm workspaces, Yarn workspaces, and pnpm workspaces -- all solve the same fundamental problem: linking local packages together so they resolve as if they were installed from the registry. The differences are in performance, strictness, and disk usage.

After testing all three, I settled on pnpm workspaces. The decision came down to two factors: disk efficiency through content-addressable storage (critical when thirty tools share many of the same dependencies), and strict dependency isolation that prevents phantom dependencies -- packages that work locally because they are hoisted but fail when published because they are not declared in package.json.

The workspace configuration is minimal. A pnpm-workspace.yaml at the root:

packages:
  - "tools/*"
  - "packages/*"
Enter fullscreen mode Exit fullscreen mode

This establishes two categories: tools/* contains the publishable CLI tools, and packages/* contains internal shared packages that are never published to npm. The directory structure looks like this:

monorepo/
  tools/
    websnap/
    ghbounty/
    devpitch/
    pricemon/
    ... (30+ tools)
  packages/
    cli-utils/
    shared-config/
    test-helpers/
  tsconfig.base.json
  pnpm-workspace.yaml
  .changeset/
Enter fullscreen mode Exit fullscreen mode

Each tool has its own package.json with a name, version, bin field, and dependencies. Internal packages are referenced using the workspace:* protocol:

{
  "name": "websnap-reader",
  "version": "1.4.2",
  "bin": { "websnap": "./dist/index.js" },
  "dependencies": {
    "@mytools/cli-utils": "workspace:*",
    "commander": "^12.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

When pnpm publishes, workspace:* is automatically replaced with the actual version number. This means local development uses linked packages while published versions reference concrete versions on npm.

Shared TypeScript Configuration

TypeScript configuration is one of the first things that drifts across separate repos. One tool targets ES2020, another CommonJS, a third has strict mode disabled because someone could not figure out a type error six months ago. In a monorepo, you centralize this.

The root tsconfig.base.json contains every shared compiler option:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
Enter fullscreen mode Exit fullscreen mode

Each tool extends this with a minimal tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../../packages/cli-utils" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The references field enables TypeScript project references, which are essential at scale. Without them, compiling one tool would type-check every file in the monorepo. With references, TypeScript only checks the tool's own source files and uses pre-built .d.ts files from referenced packages. For thirty tools, this cuts compilation time from minutes to seconds.

Common Patterns Extracted to Internal Packages

After building a dozen CLI tools, patterns emerge that belong in shared packages. Here are the three internal packages that eliminated the most duplication.

@mytools/cli-utils handles the universal CLI concerns. It exports a createCLI function that wraps Commander.js with standardized error handling, version checking, and help formatting. Every tool's entry point looks nearly identical:

import { createCLI } from '@mytools/cli-utils';

const cli = createCLI({
  name: 'websnap',
  version: '1.4.2',
  description: 'Capture and read web snapshots',
});

cli
  .command('capture <url>')
  .option('-o, --output <path>', 'Output file path')
  .action(async (url, options) => {
    // tool-specific logic
  });

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

The createCLI wrapper adds consistent behavior: unhandled promise rejections produce clean error messages instead of stack traces, --verbose enables debug logging across all tools, and --no-color disables ANSI codes. This is maybe forty lines of code, but having it consistent across thirty tools is worth the extraction.

@mytools/shared-config provides runtime configuration loading. Many CLI tools need to read from config files (.toolrc, tool.config.js, environment variables). This package provides a unified loadConfig function that searches the standard locations, merges environment variables with appropriate prefixes, and returns typed configuration objects.

@mytools/test-helpers contains testing utilities: functions to capture stdout/stderr during CLI execution, temporary directory management, mock filesystem helpers, and snapshot testing utilities tailored to CLI output.

Building and Publishing All Tools at Once

Building thirty TypeScript packages could be slow if done naively. The key optimization is parallelism combined with caching.

The build command uses pnpm's built-in topological sorting:

pnpm --filter './tools/*' --filter './packages/*' run build
Enter fullscreen mode Exit fullscreen mode

pnpm resolves the dependency graph and builds packages in the correct order -- shared packages first, then tools that depend on them -- while parallelizing independent builds. On a modern machine, building all thirty tools takes about forty-five seconds. Individual tool builds take two to four seconds.

For publishing, the process is more nuanced. You never want to publish all thirty tools on every commit. This is where changesets become essential, which I will cover in the versioning section.

Versioning Strategies: Independent vs. Lockstep

There are two schools of thought on versioning in a monorepo. Lockstep versioning (every package shares the same version, all bump together) works for tightly coupled packages like Babel or Angular. Independent versioning (each package has its own version, bumped only when it changes) works better for loosely coupled tools.

CLI tool collections are definitively in the "independent" category. If I fix a bug in websnap-reader, there is no reason to bump the version of pricemon. Users of pricemon should not see a new version that contains no changes to their tool.

The practical implementation uses Changesets, which is the best versioning tool I have found for independent versioning in monorepos. The workflow:

  1. Make changes to one or more tools.
  2. Run pnpm changeset and describe what changed. The CLI asks which packages are affected and whether the changes are patch, minor, or major.
  3. This creates a markdown file in .changeset/ describing the change.
  4. When ready to release, run pnpm changeset version, which consumes all pending changesets, bumps the appropriate versions, and updates changelogs.
  5. Run pnpm changeset publish to publish only the packages with new versions.

The beauty of changesets is that they decouple "deciding what changed" from "releasing." Multiple developers can add changesets over days or weeks, and the release is a single atomic operation.

A typical changeset file looks like this:

---
"websnap-reader": patch
"@mytools/cli-utils": minor
---

Fixed URL encoding in websnap capture command. Added --timeout flag to cli-utils createCLI wrapper.
Enter fullscreen mode Exit fullscreen mode

When changeset version runs, it also handles transitive bumps. If @mytools/cli-utils gets a minor bump, every tool that depends on it gets at least a patch bump with an updated dependency version. This is something you would have to coordinate manually across thirty separate repos.

CI/CD: Only Build and Test Changed Packages

Running CI for all thirty tools on every pull request is wasteful and slow. The monorepo CI pipeline should be smart about what changed.

pnpm provides filtering by changed packages:

pnpm --filter '...[origin/main]' run build
pnpm --filter '...[origin/main]' run test
Enter fullscreen mode Exit fullscreen mode

The ...[origin/main] syntax means "packages that changed since origin/main, plus all packages that depend on them." If you change @mytools/cli-utils, this will build and test every tool that imports it. If you change only websnap-reader, only that tool is built and tested.

In GitHub Actions, the workflow looks like this:

name: CI
on: [pull_request]
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter '...[origin/main]' run build
      - run: pnpm --filter '...[origin/main]' run test
      - run: pnpm --filter '...[origin/main]' run lint
Enter fullscreen mode Exit fullscreen mode

The fetch-depth: 0 is critical -- without full git history, the diff comparison fails. The --frozen-lockfile flag ensures CI uses the exact dependency versions from the lockfile.

Average CI times dropped from eighteen minutes (building everything) to three minutes (building only changed packages). For PRs that touch a single tool with no shared dependency changes, CI finishes in under ninety seconds.

npm Publishing Automation with Changesets

The publish pipeline is a separate workflow triggered on merges to main:

name: Release
on:
  push:
    branches: [main]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: 'https://registry.npmjs.org'
      - run: pnpm install --frozen-lockfile
      - run: pnpm run build
      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

The changesets/action GitHub Action handles two scenarios. If there are pending changesets, it opens a "Version Packages" PR that bumps versions and updates changelogs. When that PR is merged, it publishes the bumped packages to npm.

This gives you a review step before publishing -- you can inspect the version bumps and changelog entries before they go live. For a collection of thirty tools used by real people, this review step has prevented several accidental breaking version bumps.

One critical detail: every tool's package.json must have "publishConfig": { "access": "public" } if you are publishing under an npm scope (like @mytools/). Without this, npm defaults to restricted access and the publish fails silently.

Code Sharing: Utilities and Error Handling

Beyond the three core internal packages, there are smaller patterns worth centralizing.

Spinner and progress output. CLI tools that perform network requests or heavy computation should show progress. Rather than each tool implementing its own spinner, a shared withSpinner utility standardizes the UX:

import { withSpinner } from '@mytools/cli-utils';

const result = await withSpinner(
  'Fetching repository data...',
  () => fetchRepoData(repoUrl)
);
Enter fullscreen mode Exit fullscreen mode

Error classification. Not all errors deserve the same treatment. Network errors should suggest checking connectivity. Authentication errors should point to credential setup. Unknown errors should print a stack trace and suggest filing an issue. A shared error handler classifies errors consistently:

import { handleError } from '@mytools/cli-utils';

try {
  await performAction();
} catch (error) {
  handleError(error, { tool: 'websnap', verbose: flags.verbose });
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

The handleError function inspects the error type and produces appropriate output. An ENOTFOUND error prints "Network error: unable to reach [hostname]. Check your internet connection." An EACCES error prints "Permission denied: [path]. Try running with sudo or fix file permissions." This consistency across thirty tools means users get helpful, predictable error messages regardless of which tool they are using.

Update notifications. Every tool should check for updates without blocking execution. A shared utility handles this with a non-blocking background check that prints a message after the tool's main output if a new version is available. This is wired into the createCLI wrapper so individual tools do not need any update-checking code.

Testing Strategy Across 30+ Packages

Testing CLI tools has unique challenges. The output is text sent to stdout/stderr. Side effects include file creation, network requests, and process exit codes. Traditional unit testing frameworks need augmentation.

Each tool has three test categories:

Unit tests cover pure logic -- argument parsing, data transformation, formatting. These are standard Vitest tests with no special infrastructure. They run in milliseconds.

Integration tests execute the CLI as a subprocess and assert on stdout, stderr, and exit codes:

import { execCLI } from '@mytools/test-helpers';

test('capture command outputs file path', async () => {
  const result = await execCLI('websnap', [
    'capture', 'https://example.com', '-o', 'out.html'
  ]);
  expect(result.exitCode).toBe(0);
  expect(result.stdout).toContain('Saved to out.html');
});
Enter fullscreen mode Exit fullscreen mode

The execCLI helper resolves the tool's binary from the workspace, sets up a temporary working directory, and captures all output. This catches issues that unit tests miss -- like forgetting the shebang line or having a misconfigured bin field.

Snapshot tests capture the full output of help text and error messages. When a tool's --help output changes unexpectedly, the snapshot test fails. This is particularly valuable in a monorepo where a shared utility change could alter the help output of all thirty tools.

The test suite runs with Vitest's workspace mode, which natively supports monorepo structures. A root vitest.workspace.ts configures test discovery:

export default [
  'tools/*/vitest.config.ts',
  'packages/*/vitest.config.ts',
];
Enter fullscreen mode Exit fullscreen mode

Across all packages, the full test suite contains around 420 tests and runs in about seventy seconds. With the changed-packages filter in CI, most PRs run thirty to fifty tests in under fifteen seconds.

Performance: Keeping Install Times Fast

When someone installs one of your CLI tools with npm install -g websnap-reader, they should not be waiting for two hundred transitive dependencies to download. Monorepo tooling can inadvertently bloat published packages if you are not careful.

Three practices keep install times under five seconds for every tool:

Dependency auditing. Each tool's package.json should only list runtime dependencies it actually imports. Development dependencies (TypeScript, Vitest, ESLint) belong in the workspace root, not in individual tools. A periodic audit script checks for unused dependencies:

pnpm --filter './tools/*' exec -- npx depcheck --ignores="@types/*"
Enter fullscreen mode Exit fullscreen mode

Bundle size tracking. A CI check compares the packed size of each tool against a baseline. If a PR increases the packed size by more than twenty percent, it flags for review. This caught a case where someone added lodash as a dependency to use a single function -- a quick refactor to use native JavaScript saved 70KB from every installation.

Minimal shared packages. Internal packages should be lean. @mytools/cli-utils bundles to about 4KB. If a shared utility pulls in heavy dependencies, those dependencies propagate to every tool that uses it. We enforce a 10KB limit on internal package build output.

The median install time across all thirty tools is 3.2 seconds. The largest tool (which depends on puppeteer-core) takes 8 seconds. The smallest tools install in under 2 seconds.

Real Numbers from Managing Our 30+ Tool Collection

Here are concrete metrics from twelve months of monorepo operation:

  • Total tools published: 32
  • Total npm downloads (lifetime): ~45,000
  • Releases in the past year: 187 (averaging 3.6 per week)
  • Shared internal packages: 3
  • Lines of shared utility code: ~1,200
  • Lines of duplicated code eliminated: ~8,500 (estimated from pre-monorepo state)
  • Average CI time (full build): 47 seconds
  • Average CI time (changed packages only): 3.1 minutes for shared package changes, 87 seconds for single-tool changes
  • Time to add a new tool: ~15 minutes (scaffold, configure, first commit)
  • Time to publish a new release: ~4 minutes (changeset, version, publish)

The most impactful metric is the scaffolding time. Adding a new CLI tool to the monorepo takes fifteen minutes because the infrastructure already exists. TypeScript config: extend the base. Testing: import the helpers. Building: it is already wired up. Publishing: add a changeset. Compare this to creating a new repository from scratch -- setting up CI, configuring TypeScript, adding a publish workflow, duplicating utility code -- which typically took two to three hours.

The disk savings from pnpm are also significant. With npm, our node_modules consumed 2.1GB across all workspaces. With pnpm's content-addressable store, the same dependencies occupy 340MB. On CI runners where you pay for storage and bandwidth, this translates directly to faster pipelines and lower costs.

Mistakes and What I Would Do Differently

Starting with npm workspaces instead of pnpm. npm workspaces hoisted dependencies aggressively, leading to phantom dependency issues that only surfaced when publishing. Switching to pnpm early would have avoided weeks of debugging "works on my machine" issues where a tool ran fine locally but crashed for users because an undeclared dependency was not installed.

Not extracting shared packages soon enough. For the first ten tools, I copied utility code between packages. By the time I extracted @mytools/cli-utils, I had ten slightly different versions of the same error handling code to reconcile. Extract shared code at tool number three, not tool number ten.

Over-engineering the shared layer. Early versions of @mytools/cli-utils tried to abstract too much -- a generic plugin system, a complex configuration schema, an event-based lifecycle. Most tools need argument parsing, error handling, and colored output. The simple version is better. I deleted more code from the shared layer than I added.

Not enforcing commit conventions from day one. Changesets work best when commit messages are descriptive. Without conventional commits enforced by a hook, the auto-generated changelogs were sometimes unhelpful ("fix stuff", "updates"). Adding commitlint with Husky improved changelog quality immediately.

Ignoring .npmignore until it was too late. Several early releases included test fixtures, TypeScript source files, and tsconfig.json in the published package. This bloated install sizes and confused users who saw source files alongside compiled output. A shared .npmignore template in the monorepo root, copied to each tool during scaffolding, solved this:

src/
tests/
*.test.ts
tsconfig.json
.eslintrc
Enter fullscreen mode Exit fullscreen mode

Conclusion

A monorepo is not a silver bullet, and it introduces its own complexity -- workspace configuration, build orchestration, selective CI, coordinated publishing. But for a collection of related CLI tools, the benefits are overwhelming. Shared infrastructure eliminates duplication. Atomic changes prevent version drift. Automated publishing reduces release friction to near zero.

If you are maintaining more than five related npm packages, the investment in monorepo infrastructure pays for itself within a month. If you are approaching thirty, it is not optional -- it is the only way to stay sane.

The tools and practices described here -- pnpm workspaces, TypeScript project references, changesets, filtered CI -- are all production-tested and freely available. The learning curve is real but short. And once the infrastructure is in place, adding the thirty-first tool is as easy as adding the third.


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

Top comments (0)