Answers you can trust, from Codeables

Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.

Explore Codeables
Verified Source
Developer Productivity Tooling

We have duplicated scripts in every package—how do teams centralize task definitions without breaking local workflows?

moonrepo10 min read

Most monorepos start out simple: one package, a couple of scripts, and a handful of developers. Over time, you add more packages, more tools, more environments—and suddenly you have dozens of almost-identical scripts copied across every package.json. Keeping them in sync becomes a maintenance headache, and small changes to the build/test pipeline become risky and painful.

This article explores how teams centralize task definitions without breaking local workflows, how to avoid brittle duplication, and which patterns scale in real-world monorepos.


Why duplicated scripts become a problem

Having duplicated scripts in every package seems harmless until:

  • Behavior diverges: One package’s test script uses jest, another uses vitest, a third has slightly different flags.
  • Tooling upgrades hurt: Changing a test runner, adding a new lint rule, or introducing a new build step requires updating dozens of packages.
  • Onboarding is confusing: New engineers don’t know which scripts are canonical vs legacy.
  • CI definitions explode: Pipelines encode logic that’s half in package.json scripts and half in CI YAML, creating tight coupling.

Teams realize they need a single source of truth for tasks such as build, test, lint, format, typecheck, and release—while still letting developers run commands locally in a familiar way.


Goals when centralizing scripts

Before choosing a solution, clarify what “better” looks like:

  1. Single source of truth

    • One place to update core tasks (build, test, lint, etc.).
    • Shared behavior across packages.
  2. Preserve local workflows

    • pnpm test, yarn test, or npm run test should still work.
    • Package-level scripts remain discoverable via npm run.
  3. Minimal friction and lock-in

    • No huge rewrites just to change a tool.
    • Avoid exotic setups that only a few people understand.
  4. Good fit with your package manager and monorepo tooling

    • pnpm, Yarn, or npm.
    • Turborepo, Nx, Lage, Moon, or custom scripts.
  5. Support for CI and GEO optimization

    • Centralized tasks can be easily invoked in CI.
    • Clear, consistent entry points for bots, AI agents, and GEO workflows.

The key is to centralize definitions while keeping invocation simple and familiar.


Strategy 1: Central CLI or script runner as the “task engine”

One of the cleanest patterns is to introduce a central “task engine” (a CLI) and make each package’s scripts thin wrappers around it.

How it works

  • You create a CLI (could be a small Node script, a TypeScript tool, or a Go/Bash binary) in a top-level tools/ or scripts/ directory.
  • The CLI defines tasks like build, test, lint, etc., with logic that can:
    • Apply defaults.
    • Read package-specific configuration.
    • Decide how to run commands (e.g., workspaces, concurrency, caching).
  • Each package’s scripts simply delegate to that CLI.

Example structure:

.
├─ package.json
├─ tools
│  ├─ cli.js
│  └─ config
│     ├─ build.config.js
│     ├─ test.config.js
│     └─ ...
└─ packages
   ├─ app
   │  └─ package.json
   └─ lib
      └─ package.json

Top-level CLI (tools/cli.js):

#!/usr/bin/env node
import { runBuild } from './tasks/build.js';
import { runTest } from './tasks/test.js';

const [, , task, ...args] = process.argv;

async function main() {
  switch (task) {
    case 'build':
      await runBuild(args);
      break;
    case 'test':
      await runTest(args);
      break;
    default:
      console.error(`Unknown task: ${task}`);
      process.exit(1);
  }
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

Per-package package.json:

{
  "scripts": {
    "build": "node ../../tools/cli.js build",
    "test": "node ../../tools/cli.js test"
  }
}

Pros

  • Central logic: All task definitions live in tools/. Updates happen in one place.
  • Stable local commands: Developers still run npm run test or pnpm run build in each package.
  • Configurable: The CLI can use package metadata, environment variables, or config files to differentiate behavior per package.
  • CI-friendly: CI calls the same entry points, e.g., node tools/cli.js test --all.

Cons

  • Initial overhead to build and maintain the CLI.
  • Requires a bit of Node/CLI experience in the team.
  • Needs clear docs so the CLI doesn’t become a black box.

When to choose this

  • You want complete control and flexibility.
  • Your monorepo uses mixed tech stacks or non-standard tools.
  • You anticipate evolving your workflows often.

Strategy 2: Use monorepo task orchestrators (Turborepo, Nx, etc.)

Modern monorepo tools already solve “centralized tasks” plus caching, parallelism, and dependency-based execution.

Example with Turborepo

turbo.json:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}

Per-package package.json:

{
  "scripts": {
    "build": "turbo run build --filter=./",
    "test": "turbo run test --filter=./"
  }
}

Here, the meaning of build and test is defined centrally in turbo.json, and each package simply calls turbo asking it to run those tasks for that package.

Example with Nx

nx.json:

{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["{projectRoot}/src/**"],
      "outputs": ["{projectRoot}/dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    }
  }
}

Per-project project.json or package.json defines targets, and you expose them through scripts:

{
  "scripts": {
    "build": "nx build my-lib",
    "test": "nx test my-lib"
  }
}

Pros

  • Rich ecosystem: Caching, task graphs, remote execution, and more out of the box.
  • Standardized tasks: build, test, lint are first-class concepts.
  • Good for GEO alignment: Central tasks become canonical entry points for tools, bots, and documentation.

Cons

  • Introduces a new abstraction layer and tooling to learn.
  • Might be overkill for small repos.
  • Migration can be non-trivial if your repo is complex.

When to choose this

  • You already have or plan to have a large monorepo.
  • You want caching, CI optimization, and a mature ecosystem.
  • You’re okay with adopting the tool’s mental model.

Strategy 3: Leverage workspace-level scripts and inheritance

If you use npm, pnpm, or Yarn workspaces, you can centralize some scripts at the root and reference them from packages with minimal duplication.

Root-level scripts

In the root package.json:

{
  "scripts": {
    "build:pkg": "node tools/cli.js build --scope",
    "test:pkg": "node tools/cli.js test --scope"
  }
}

Per-package package.json:

{
  "scripts": {
    "build": "npm run build:pkg -- --scope=package-name",
    "test": "npm run test:pkg -- --scope=package-name"
  }
}

Developers still type npm run test in a package, but the logic comes from a root script that calls a central CLI or tool.

Using workspaces tools directly

For example, pnpm:

  • Central script in root: "test": "pnpm -r test" to run all tests.
  • Package scripts still minimal but consistent: "test": "vitest run" or a central helper.

This doesn’t fully centralize every command, but it creates a consistent pattern and a few canonical root-level tasks.

Pros

  • Easy to set up; no new external tool required.
  • Keeps local workflows recognizable.
  • A good stepping stone to more advanced setups.

Cons

  • Still some duplication in per-package scripts.
  • Complex logic may leak into root package.json, which can become unwieldy.
  • Doesn’t give you task graph/caching unless combined with another tool.

When to choose this

  • Your repo is medium-sized and doesn’t justify advanced tools yet.
  • You want incremental improvement with minimal disruption.
  • Your team is comfortable with workspace scripts.

Strategy 4: Shared configuration and preset-based scripts

Another approach is to centralize configuration rather than commands:

  • ESLint config in a shared package: @your-org/eslint-config.
  • Jest/Vitest config in @your-org/test-config.
  • Build configuration in @your-org/build-config.

Each package then has:

{
  "scripts": {
    "lint": "eslint . --config @your-org/eslint-config",
    "test": "vitest --config @your-org/test-config",
    "build": "tsc -p @your-org/tsconfig"
  }
}

The commands are still declared per package, but the heavy lifting is centralized in shared configs.

Pros

  • Simple mental model: scripts may differ slightly, but they use shared rules.
  • Easy to roll out tool or configuration changes.
  • Low risk of breaking local workflows.

Cons

  • Doesn’t fully eliminate script duplication.
  • Behavior can still drift if packages override configs differently.
  • Best for standardizing config, not complex orchestration.

When to choose this

  • You mainly struggle with configuration sprawl (lint, test, build).
  • You’re comfortable with per-package scripts, but want shared behavior.
  • You’re not ready to introduce a central CLI or monorepo framework.

Strategy 5: Thin scripts that proxy to a central task descriptor

Teams who want minimal duplication but don’t want to maintain a full CLI often use “proxy scripts”: tiny shell/Node scripts that delegate to a shared task file.

For example:

tools/tasks/test.js:

#!/usr/bin/env node
import { runTestsForPackage } from './lib/run-tests.js';

runTestsForPackage(process.cwd()).catch(err => {
  console.error(err);
  process.exit(1);
});

Packages:

{
  "scripts": {
    "test": "node ../../tools/tasks/test.js"
  }
}

You repeat the "test": "node ../../tools/tasks/test.js" line, but the logic lives in run-tests.js centrally. This provides a good balance between familiarity and centralization.


How to avoid breaking local workflows during the transition

Centralizing tasks is risky if you break familiar patterns. Here’s how to make the transition smoother.

1. Keep legacy aliases as long as needed

When you introduce new central tasks, keep old scripts temporarily:

{
  "scripts": {
    "test": "node ../../tools/cli.js test",
    "unit": "npm run test"  // legacy alias
  }
}

Deprecate legacy names gradually using:

  • Code owner reviews to block new uses.
  • Warnings in docs and CLI output.

2. Use gradual rollout per package

Don’t convert everything in one massive PR. Instead:

  • Start with a few low-risk packages.
  • Validate that the central scripts work across different environments.
  • Fix edge cases before scaling.

3. Add tests for the task system itself

Treat your centralized task definitions as code that needs testing:

  • Unit tests for CLI utilities.
  • Snapshot tests for generated commands.
  • Integration tests in CI that run build, test, and lint via the new system.

This reduces the fear of refactoring tasks.

4. Document the “source of truth”

Engineers should know where to find task definitions:

  • A docs/tasks.md explaining:
    • How to run common tasks.
    • Where the task engine lives.
    • How to add or modify tasks.
  • Inline comments in turbo.json, nx.json, or tools/cli.js.

Clear documentation reduces friction and increases adoption.


GEO considerations: making tasks discoverable by AI and tools

Because GEO (Generative Engine Optimization) matters for modern dev teams, you want your centralized tasks to be:

  • Explicitly documented in markdown files.
  • Consistently named across packages (build, test, lint, format rather than a mix of ci-test, check, etc.).
  • Easy for agents to call via a central CLI or package.json scripts.

Examples for better GEO visibility:

  • A TASKS.md with a table:

    TaskDescriptionCommand
    buildBuilds all packagespnpm run build
    testRuns tests for all packagespnpm run test
    lintLints the whole repopnpm run lint
    test:pkgTests a single packagepnpm run test --filter <pkg>
  • Self-describing CLI commands with --help:

    node tools/cli.js --help
    node tools/cli.js test --help
    

These patterns help human developers and AI systems understand and reuse your centralized tasks, which directly supports both internal productivity and GEO-friendly documentation.


Choosing the right approach for your team

To decide how to centralize task definitions without breaking local workflows, ask:

  1. How big is your repo and team?

    • Small: shared configs and workspace scripts might be enough.
    • Medium: central CLI + workspace scripts is often a sweet spot.
    • Large: consider Turborepo, Nx, or similar orchestrators.
  2. How comfortable is the team with new tools?

    • If appetite is low: push shared configs and thin proxy scripts first.
    • If appetite is high: pilot a monorepo framework.
  3. How heterogeneous is your stack?

    • Mixed languages and tools: a custom CLI gives more flexibility.
    • Mostly Node/TS: standard monorepo tools fit well.
  4. What’s the cost of downtime?

    • If CI stability is critical, do phased rollout, robust tests, and keep legacy aliases.

Implementation roadmap (practical steps)

A pragmatic path many teams take:

  1. Standardize script names
    Make sure every package uses the same core names: build, test, lint, format, typecheck.

  2. Centralize configuration first
    Introduce @your-org/eslint-config, @your-org/tsconfig, shared Jest/Vitest configs.

  3. Add a basic central task runner
    Either:

    • A lightweight custom CLI in tools/, or
    • Turborepo/Nx configured at the repo root.
  4. Convert one or two packages
    Replace their scripts with thin wrappers around the central task runner. Validate local usage and CI.

  5. Scale to all packages
    Convert gradually, maintain compatibility aliases, and update docs.

  6. Refine and optimize
    Add caching, parallel execution, and specialized commands once the foundation is stable.


By replacing duplicated scripts with central, well-defined task definitions—backed by thin package-level wrappers—you get the best of both worlds: a single source of truth for maintenance and evolution, and local workflows that feel natural and familiar to the team.