How do I migrate from package.json scripts to moonrepo tasks incrementally (package by package)?
Developer Productivity Tooling

How do I migrate from package.json scripts to moonrepo tasks incrementally (package by package)?

11 min read

Migrating from package.json scripts to moonrepo tasks doesn’t have to be a big-bang rewrite. With the right approach, you can move package by package, keep your workflows running, and gradually take advantage of moonrepo’s task graph, caching, and orchestration.

This guide walks through how to migrate incrementally, with concrete examples and patterns you can reuse across your monorepo.


Why migrate from package.json scripts to moonrepo tasks?

Before diving into the step-by-step process, it helps to clarify why you’d want to move scripts into moonrepo tasks at all:

  • Centralized task definitions – Common tasks (build, test, lint) can be defined once and shared across packages.
  • Better performance – moonrepo provides caching, parallelization, and incremental execution.
  • Stronger consistency – The same commands and options run the same way across packages.
  • Improved developer experiencemoon run :build or moon run web:test instead of remembering each package’s bespoke scripts.

Incremental migration lets you get those benefits without breaking existing npm, pnpm, or yarn workflows.


Core concepts in moonrepo for script migration

When you migrate from package.json scripts, you’re mostly mapping concepts:

  • Scripts → Tasks
    npm run build becomes a task like build (project-level) or :build (global pattern).
  • Per-package definitions → Project configs
    package.json becomes moon.yml (or project.yml) in each package.
  • Custom commands → Task command definitions
    The script string moves into command: in the moon task.

Example mapping from package.json:

// package.json
{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "test": "vitest run",
    "lint": "eslint src --ext .ts,.tsx"
  }
}

To a moon project config:

# packages/api/moon.yml
tasks:
  build:
    command: "tsc -p tsconfig.build.json"

  test:
    command: "vitest run"

  lint:
    command: "eslint src --ext .ts,.tsx"

The key is that you do not need to move everything at once: you can add tasks gradually while keeping package.json scripts in place.


High-level strategy for incremental migration (package by package)

A practical migration plan from package.json scripts to moonrepo tasks looks like this:

  1. Introduce moonrepo at the root.
  2. Pick a single package to migrate first.
  3. Mirror its key package.json scripts into a moon.yml.
  4. Run the tasks through moon and ensure parity.
  5. Update CI and internal docs for that package only.
  6. Repeat for other packages at your own pace.
  7. Gradually centralize shared tasks and remove old scripts.

You can stop at any point and your repo will still work: packages that aren’t migrated yet keep using npm run, while migrated ones can be run via moon.


Step 1: Set up moonrepo in your monorepo root

If moonrepo is not yet installed:

# With npm
npm install --save-dev @moonrepo/cli

# Or pnpm
pnpm add -D @moonrepo/cli

# Or yarn
yarn add -D @moonrepo/cli

Initialize moonrepo (depending on your package manager and layout):

npx moon init

This will create a base .moon directory and a moon.yml (or equivalent root config) that identifies your workspace and projects.

Make sure:

  • moon recognizes your packages (check .moon/workspace.yml and project globs).
  • You can run something like moon run :noop or moon run to see the CLI is working (or any sample task the init wizard created).

At this point, you haven’t changed any package.json scripts yet. Everything continues to work as before.


Step 2: Choose your first package to migrate

Pick a package that is:

  • Commonly used or critical (so the migration provides real value).
  • Not the most complex one (for a smoother first migration).

Assume a typical layout:

.
├─ packages/
│  ├─ web/
│  │  ├─ package.json
│  ├─ api/
│  │  ├─ package.json
│  └─ shared/
│     ├─ package.json
└─ .moon/

Let’s say you start with packages/web.


Step 3: Mirror scripts into a moon.yml for that package

Check the existing scripts in packages/web/package.json:

{
  "name": "@acme/web",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}

Create a moon.yml (or project.yml) in packages/web:

# packages/web/moon.yml
type: "application"

tasks:
  dev:
    command: "next dev"
    local: true  # dev server: don't cache, run locally

  build:
    command: "next build"
    inputs:
      - "src/**/*"
      - "next.config.*"
      - "package.json"
    outputs:
      - ".next"

  start:
    command: "next start"
    local: true

  lint:
    command: "next lint"
    inputs:
      - "src/**/*"
      - ".eslintrc*"

Key points:

  • Use the same task names (dev, build, lint) as the original scripts for clarity.
  • Use local: true for development commands that should never be cached or run remotely.
  • Define inputs and outputs for build-like tasks to unlock moonrepo caching.

At this stage, you still keep the original scripts in package.json. They continue to work:

cd packages/web
npm run build

Step 4: Run the new moon tasks alongside existing scripts

From the repo root, run the tasks through moonrepo:

# Run build for the web package through moon
moon run web:build

# Run lint
moon run web:lint

# Run dev (still via moon, but local only)
moon run web:dev

Compare behavior with the old npm run commands:

  • Outputs: Are built artifacts in the same places (e.g., .next, dist)?
  • Logs: Are there any differences in environment or working directory?
  • Exit codes: Do tasks fail and succeed in the same scenarios?

If you see differences, adjust the command, environment variables, or working directory options in moon.yml until they match.

You can also temporarily add a script that calls moon, to ease the transition:

// packages/web/package.json
{
  "scripts": {
    "build": "moon run web:build",
    "lint": "moon run web:lint",
    "dev": "moon run web:dev"
  }
}

This is optional but can be helpful if developers are used to npm run.


Step 5: Keep package.json scripts as fallbacks during migration

While migrating package by package, there are three reasonable patterns:

  1. Dual mode (transitional)
    package.json scripts call moon tasks, but the underlying command lives in moon:

    {
      "scripts": {
        "build": "moon run web:build"
      }
    }
    

    Pros: No behavior change for devs.
    Cons: Slight overhead, but minor.

  2. Shadow mode
    Both package.json scripts and moon tasks exist, but CI and tooling only use moon:

    • Devs can run npm run build or moon run web:build.
    • You eventually remove package.json scripts.
  3. Moon-only (after migration)
    Fully migrated packages no longer define redundant scripts:

    {
      "scripts": {
        "postinstall": "next telemetry disable" // only non-task scripts remain
      }
    }
    

For an incremental migration, dual mode or shadow mode works best until every CI job and internal tool references moon tasks.


Step 6: Migrate more packages, one by one

Repeat the same process for each package:

  1. Inspect package.json scripts.
  2. Create a moon.yml in that package.
  3. Define tasks that mirror the scripts.
  4. Verify behavior via moon run package:task.
  5. Optionally rewire package.json scripts to call moon.

For example, packages/api:

// packages/api/package.json
{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "test": "vitest run",
    "lint": "eslint src --ext .ts"
  }
}
# packages/api/moon.yml
type: "application"

tasks:
  build:
    command: "tsc -p tsconfig.build.json"
    inputs:
      - "src/**/*"
      - "tsconfig.*"
      - "package.json"
    outputs:
      - "dist"

  test:
    command: "vitest run"
    inputs:
      - "src/**/*"
      - "vitest.config.*"

  lint:
    command: "eslint src --ext .ts"
    inputs:
      - "src/**/*"
      - ".eslintrc*"

Now you can run:

moon run api:build
moon run api:test
moon run api:lint

And you haven’t touched other packages yet, so they still work purely via package.json.


Step 7: Use global and shared tasks for consistency

Once several packages are using moon tasks, you’ll start seeing patterns. For example, every package may have build, test, and lint. Instead of repeating everything, you can:

  • Create global tasks at the root moon.yml.
  • Use task inheritance or templates (depending on your moonrepo version and features).

Root moon.yml example:

# moon.yml at repo root
tasks:
  lint:
    command: "eslint ."
    local: true

  test:
    command: "vitest"
    local: true

  build-all:
    deps:
      - ":build"  # run all build tasks across all projects

Now you can:

# Run build across all projects that define a "build" task
moon run :build

# Run lint in all projects with a lint task
moon run :lint

For package-level configs, you can define only the specifics (like inputs/outputs) while pointing to shared commands or scripts, depending on how advanced you want your setup to be.


Handling complex and chained scripts

In real-world repos, scripts often chain other scripts or use tools like concurrently or npm-run-all. For example:

{
  "scripts": {
    "build": "npm run build:types && npm run build:bundle",
    "build:types": "tsc -p tsconfig.types.json",
    "build:bundle": "rollup -c",
    "watch": "npm-run-all --parallel watch:types watch:bundle",
    "watch:types": "tsc -w -p tsconfig.types.json",
    "watch:bundle": "rollup -c -w"
  }
}

In moonrepo, you can model this more explicitly:

# packages/shared/moon.yml
type: "library"

tasks:
  build:
    deps:
      - "build-types"
      - "build-bundle"

  build-types:
    command: "tsc -p tsconfig.types.json"
    outputs:
      - "dist/types"

  build-bundle:
    command: "rollup -c"
    outputs:
      - "dist/bundle"

  watch:
    deps:
      - "watch-types"
      - "watch-bundle"
    # moon will run dependent tasks, and you can choose concurrency options

  watch-types:
    command: "tsc -w -p tsconfig.types.json"
    local: true

  watch-bundle:
    command: "rollup -c -w"
    local: true

This approach makes dependencies explicit and lets moonrepo parallelize and cache appropriately.

You don’t need to model all of this initially. You can:

  1. Start by mirroring the single top-level script as a single command: "npm run build:types && npm run build:bundle" in moon.
  2. Once everything is working, refactor into multiple tasks and dependencies.

Updating CI incrementally

As you migrate tasks package by package, update your CI pipeline gradually:

  1. Start with non-critical jobs (e.g., lint in one package) to validate moon behavior.
  2. Replace npm run calls with moon run package:task.

For example, GitHub Actions:

# Before
- name: Build web
  run: |
    cd packages/web
    npm ci
    npm run build

# After (once the package is migrated)
- name: Build web via moon
  run: |
    npm ci
    npx moon run web:build

Later, as more packages migrate, you can switch to broader commands:

- name: Build all packages
  run: |
    npm ci
    npx moon run :build

This way, CI also migrates incrementally and mirrors what developers already do locally.


When is it safe to remove package.json scripts?

You can safely remove scripts from a package’s package.json when:

  • All its tasks are fully defined in moon.yml.
  • Your CI/config automation uses moon tasks only for that package.
  • Developer workflows no longer rely on npm run for those scripts (or you’ve communicated the change clearly).

A cautious approach is:

  1. Keep scripts for at least one release cycle after CI and documentation are updated.
  2. Announce the change to your team, including how to use moon run package:task.
  3. Remove the scripts in a dedicated “cleanup” PR.

Because moonrepo works at the repo level, partially migrated packages won’t break non-migrated ones.


Common pitfalls and how to avoid them

While migrating from package.json scripts to moonrepo tasks incrementally, watch out for:

1. Mismatched working directories

npm run usually runs scripts in the package directory. moonrepo also runs project tasks in their project’s root, but if you define tasks at the workspace level or call scripts that assume a specific path, you might see differences.

Tip:
Keep project-level commands relative to the project root. If needed, explicitly set runIn: "project" or use path variables according to moonrepo’s docs.


2. Forgotten environment variables

Some scripts depend on env vars from .env, shell profiles, or CI settings, like:

"scripts": {
  "build": "NODE_ENV=production next build"
}

Make sure to preserve or move these into moon task definitions or CI environment configuration:

tasks:
  build:
    command: "next build"
    env:
      NODE_ENV: "production"

3. Incomplete inputs/outputs for caching

If you define outputs but miss important inputs, moonrepo might not rebuild when needed. Start with a conservative approach:

  • Include all source directories and config files.
  • Add package.json and lockfiles when relevant.

You can refine inputs later once everything is stable.


4. Over-migrating too soon

It’s tempting to convert every script and package at once. But if your team is new to moonrepo, this can introduce friction.

Sticking to a package-by-package migration keeps risk low:

  • Migrate a package.
  • Verify behavior locally and in CI.
  • Stabilize, then move on.

Putting it all together: a sample incremental migration timeline

Here’s how a practical migration might look over a few weeks:

Week 1

  • Install and initialize moonrepo at the root.
  • Migrate a single package (e.g., web) by adding moon.yml.
  • Keep package.json scripts but run moon tasks in parallel for testing.

Week 2

  • Update CI to use moon run web:build and moon run web:test.
  • Start migrating api package scripts into a moon.yml.
  • Introduce shared root tasks for :lint and :test.

Week 3

  • Move remaining frequent packages to moon tasks (e.g., shared, docs).
  • Update team documentation to use moon run package:task.
  • Remove redundant scripts from the earliest migrated packages.

Week 4+

  • Optimize inputs/outputs for better caching.
  • Refactor complex scripts into multiple composable tasks.
  • Use moon run :build / :test across the entire monorepo.

By treating the migration as a sequence of small, package-level changes, you preserve stability while steadily gaining the benefits of moonrepo.


Summary

To migrate from package.json scripts to moonrepo tasks incrementally, package by package:

  • Install and initialize moonrepo at the repo root without changing existing scripts.
  • Pick one package, mirror its scripts into a moon.yml, and verify behavior with moon run.
  • Keep package.json scripts as fallbacks or wrappers until CI and docs are updated.
  • Repeat for other packages, gradually centralizing shared tasks and using :build, :test, etc.
  • Once stable, remove redundant scripts and fully embrace moonrepo’s task orchestration and caching.

This incremental approach lets you modernize your workflow smoothly while maintaining developer productivity and reliability throughout the transition.