Skip to main content

Command Palette

Search for a command to run...

Turning a Node.js Migration into a Reusable Skill

Published
9 min read

Library migrations are a common part of engineering work. You might be upgrading a framework, moving to a new SDK, or standardizing a runtime. These changes are often mechanical, but they span multiple files and configurations.

With the right model, a single migration isn’t that hard anymore. Given a clear prompt, an LLM can often refactor imports, update configs, and get a project building again in one shot.

But real engineering work rarely stops at one repository.

What’s harder is enforcing a standard consistently across multiple codebases. For example making sure Dockerfiles are updated, CI is aligned, legacy patterns don’t creep back in, and future changes don’t silently drift from the intended architecture.

That’s where Skills become interesting.

What We’ll Build

In this tutorial, we’ll migrate a Node.js service to a Node 20 + pure ESM standard and encode that migration into a reusable Skill.

By the end, we’ll have a project that enforces Node 20 in Docker and CI, automatically detects CommonJS drift and allows the same migration to be applied to other repositories.

Let’s start with the baseline project.

Step 1: Setting Up Two Sample Repositories

To get started, we’ll be creating two separate projects: backend-service and admin-cli. Do note that the same setup can be applied across multiple repositories that need the same standards enforced.

Both projects intentionally use:

  • Node 18

  • CommonJS (require, module.exports)

  • TypeScript

  • Jest

  • Docker with node:18

  • GitHub Actions running Node 18

This gives us realistic surface area.

A. Create backend-service repo

The structure looks like this:

backend-service/
├── src/
│   ├── server.ts
│   ├── utils/logger.ts
│   └── __tests__/server.test.ts
├── Dockerfile
├── package.json
├── tsconfig.json
└── .github/workflows/ci.yml

It builds and tests successfully:

npm run build
npm test

Here’s an example of the current CommonJS usage:


// src/server.ts
const { log } = require("./utils/logger");

function startServer() {
  log("Server started");
  return true;
}

module.exports = { startServer };

Dockerfile:

FROM node:18

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

CMD ["node", "dist/index.js"]

This is important. Real repos have Docker and migration must update this.

.github/workflows/ci.yml file:

name: CI

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm install
      - run: npm run build
      - run: npm test

B. Create admin-cli repo

This repo will intentionally be different. It will use a CLI entry point and have a slightly modified tsconfig and Jest files. It will even have a small Scripts folder. This will help create variation to show the power of Skills.

The structure of admin-cli is as follows:

admin-cli/
├── src/
│   ├── cli.ts
│   ├── commands/
│   │   ├── sync.ts
│   │   └── deploy.ts
│   ├── utils/logger.ts
│   └── tests/
├── Dockerfile
├── package.json
├── tsconfig.json
└── .github/workflows/ci.yml

It also uses CommonJS while Docker and CI still reference Node 18.

Again, the project builds and tests successfully:

image-1

Why This Setup Matters

Both repositories are valid and functional with no errors. But neither follows the new Node 20 + pure ESM standard. That’s the situation many teams face where the code works but it doesn’t align with evolving engineering standards.

In the next step, we’ll attempt a one-shot migration using a raw prompt and see how far that gets us.

Step 2: One-Shot Migration (Raw Prompt Approach)

Let’s start with the most straightforward approach.

Inside backend-service, run a Pochi task with the following prompt:


Migrate this project from Node 18 + CommonJS to Node 20 + strict ESM.

Requirements:
1. Update package.json:
- Set engines.node to ">=20"
- Add "type": "module"
2. Update tsconfig.json:
- Use "module": "NodeNext"
- Use "moduleResolution": "NodeNext"
3. Convert all source files to ESM:
- Replace require() with import
- Replace module.exports with export
- Add explicit .js extensions in imports
4. Update jest.config.js to ESM format.
5. Update Dockerfile:
- Use node:20
6. Update GitHub Actions:
- Use node-version: 20
7. Ensure:
- No require() remains
- No module.exports remains
- Project builds successfully
- Tests pass
Do not remove functionality.

We ran this using a strong model (claude-4-5-opus), and the migration completed successfully.

Now verify:

npm run build
npm test

Both pass. For additional validation:

grep -R "require(" src
grep -R "module.exports" src
grep -R "node:18" .
grep -R "node-version: 18" .

All checks return clean results. The one-shot migration worked.

With a capable model and a clear specification, this approach is effective.

In the next step, we’ll run the same prompt on a second repository and then examine what happens when standards evolve or drift over time.

Step 3: Raw Migration on admin-cli

Let’s run the same migration prompt that we ran for backend-service to admin-cli.

We use the exact same specification as before. Pochi generates the necessary diffs and completes the migration successfully.

Let’s verify. Run:

npm run build
npm test
grep -R "require(" src
grep -R "module.exports" src
grep -R "CommonJS" tsconfig.json

All checks pass. Both repositories are now running Node 20, using pure ESM and free of CommonJS patterns

However, it’s worth noticing where the migration logic actually lives. It exists only inside the prompt we wrote. The specification for upgrading to Node 20, enforcing ESM, updating Docker and CI - all of that is encoded in chat history rather than in the repository itself.

If another repository needs the same standard applied, we would need to reuse or recreate that prompt. If more repositories are added over time, the same migration would need to be repeated manually. Secondly, if a new teammate joins and starts working on the codebase, there’s nothing in the project itself that signals the expected standard beyond what currently passes CI.

And if the standard evolves, for example, upgrading to Node 22 or changing testing conventions, we would once again have to propagate those changes through prompts across every codebase.

The migration worked, but the standard isn’t yet encoded as something reusable or enforceable.

Let’s address that next.

Step 5: Simulate Real-World Drift

Even after a successful migration, nothing prevents new CommonJS code from being introduced later. To simulate this, imagine a teammate merges a small utility file that uses require().

Add a new file src/utils/legacyHelper.ts:

const fs = require("fs");

function readFile(path: string) {
  return fs.readFileSync(path, "utf-8");
}

module.exports = { readFile };

export {};

Now rebuild the project.

Depending on your configuration, the build may still succeed. TypeScript does not always block require() immediately, especially if the file compiles without type errors. However, if you manually check:

`grep -R "require(" src`

You’ll now see the CommonJS usage again.

The project has drifted. There’s no automated enforcement in place to prevent this from happening.

Step 6: Turning the Migration Into a Reusable Skill

Instead of relying on a prompt stored in chat history, we’ll encode this standard as a reusable Skill.

We’ll create a small repository that defines our engineering standard in one place.

mkdir org-engineering-standards
cd org-engineering-standards
git init

Create the following structure:


org-engineering-standards/
└── skills/
    └── node20-esm-org-standard/
        └── SKILL.md

Inside skills/node20-esm-org-standard/SKILL.md, we’ll define the full specification for our Node 20 + strict ESM standard.


---
name: node20-esm-org-standard
description: Upgrade a Node.js TypeScript service to the org-wide Node 20 + strict ESM standard with CI enforcement and migration reporting.
---

# Node 20 + Pure ESM Org Standard

Upgrade this repository to the organization-wide Node 20 + strict ESM standard.

This includes:

- Node 20 runtime
- Pure ESM ("type": "module")
- NodeNext module resolution
- No CommonJS usage
- CI enforcement
- Docker enforcement
- Migration report artifact

---

## Execution Steps

### 1. Upgrade Runtime

- Update Dockerfile base image to `node:20`
- Update GitHub Actions workflow to use Node 20
- Add to package.json:

```json
"engines": {
  "node": ">=20"
}
```

---

### 2. Enforce Pure ESM

- Add `"type": "module"` to package.json
- Convert all `require()` to `import`
- Convert all `module.exports` to `export`
- Add explicit `.js` extensions to relative imports
- Update tsconfig.json:

```json
{
  "module": "NodeNext",
  "moduleResolution": "NodeNext"
}
```

---

### 3. Add Drift Detection Script

Add to package.json scripts:

```json
"check:esm": "grep -R \"require(\" src && echo '❌ CommonJS detected' && exit 1 || echo '✅ ESM clean'"
```

---

### 4. Enforce in CI

In GitHub Actions workflow, add step:

```yaml
- run: npm run check:esm
```

This must fail CI if CommonJS is detected.

---

### 5. Add Migration Report Generator

Create:

`scripts/generate-migration-report.js`

With contents:

```js
import fs from "fs";

const report = {
  nodeVersion: process.version,
  esm: true,
  timestamp: new Date().toISOString()
};

fs.writeFileSync("migration-report.json", JSON.stringify(report, null, 2));
console.log("Migration report generated.");
```

Add to package.json scripts:

```json
"generate:report": "node scripts/generate-migration-report.js"
```

---

### 6. Validation Checklist

After applying this skill:

- No `require(` remains in src
- No `module.exports` remains
- Docker uses node:20
- CI uses Node 20
- tsconfig uses NodeNext
- Build succeeds
- Tests pass

Now that the Skill is defined, let’s apply it to one of our existing repositories.

Switch back to admin-cli:

cd ../admin-cli

Add the Skill from the central standards repository:

npx skills add ../org-engineering-standards --skill node20-esm-org-standard --agent pochi

This installs the Skill into:

admin-cli/.pochi/skills/node20-esm-org-standard/

By default, this is added as a symlink. That means if we update the Skill in org-engineering-standards, those updates are automatically reflected in every repository using it.

The standard now lives in one place.

Step 7: Reintroduce Drift

To demonstrate enforcement, let’s reintroduce CommonJS usage in admin-cli.

For example, modify or add a file that uses:

const fs = require("fs");

At this point, the repository is no longer compliant with the org standard.

Now inside admin-cli, you can prompt Pochi:

Prompt: Use node20-esm-org-standard skill to ensure this project complies fully with org standard.

Let the Skill execute.

We can see it properly executed the required changes:

You can even verify:

npm run check:esm

You should see that the build succeeds and check:esmreports no CommonJS usage

The repository has been brought back into compliance using the encoded standard rather than a manually written migration prompt.

Conclusion

In this tutorial, we migrated two Node.js services from Node 18 + CommonJS to Node 20 + strict ESM. We first used a one-shot prompt to perform the migration. With a strong model and a clear specification, it worked well.

But that migration logic lived only inside the prompt. When we simulated drift (by reintroducing require()), nothing automatically enforced the standard. The repository had no encoded knowledge of what correct meant beyond passing builds and tests.

By turning the migration into a Skill, we changed that. The standard became reusable across repositories, centrally defined and enforceable via CI.

Instead of repeating a migration prompt, we now apply a defined engineering standard. And because the Skill is shared via symlink, updates to the standard can propagate across repositories without rewriting prompts.