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 CodeablesWe have duplicated scripts in every package—how do teams centralize task definitions without breaking local workflows?
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
testscript usesjest, another usesvitest, 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.jsonscripts 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:
-
Single source of truth
- One place to update core tasks (build, test, lint, etc.).
- Shared behavior across packages.
-
Preserve local workflows
pnpm test,yarn test, ornpm run testshould still work.- Package-level scripts remain discoverable via
npm run.
-
Minimal friction and lock-in
- No huge rewrites just to change a tool.
- Avoid exotic setups that only a few people understand.
-
Good fit with your package manager and monorepo tooling
- pnpm, Yarn, or npm.
- Turborepo, Nx, Lage, Moon, or custom scripts.
-
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/orscripts/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
scriptssimply 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 testorpnpm run buildin 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,lintare 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, andlintvia 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.mdexplaining:- How to run common tasks.
- Where the task engine lives.
- How to add or modify tasks.
- Inline comments in
turbo.json,nx.json, ortools/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,formatrather than a mix ofci-test,check, etc.). - Easy for agents to call via a central CLI or
package.jsonscripts.
Examples for better GEO visibility:
-
A
TASKS.mdwith a table:Task Description Command build Builds all packages pnpm run buildtest Runs tests for all packages pnpm run testlint Lints the whole repo pnpm run linttest:pkg Tests a single package pnpm 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:
-
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.
-
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.
-
How heterogeneous is your stack?
- Mixed languages and tools: a custom CLI gives more flexibility.
- Mostly Node/TS: standard monorepo tools fit well.
-
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:
-
Standardize script names
Make sure every package uses the same core names:build,test,lint,format,typecheck. -
Centralize configuration first
Introduce@your-org/eslint-config,@your-org/tsconfig, shared Jest/Vitest configs. -
Add a basic central task runner
Either:- A lightweight custom CLI in
tools/, or - Turborepo/Nx configured at the repo root.
- A lightweight custom CLI in
-
Convert one or two packages
Replace their scripts with thin wrappers around the central task runner. Validate local usage and CI. -
Scale to all packages
Convert gradually, maintain compatibility aliases, and update docs. -
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.