Agent skill

Dist Build Migration

by nrwl in nrwl/nx

Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.

MITAuto-check: notesDevelopment

Install Dist Build Migration

skills CLI
$ npx skills add nrwl/nx --skill dist-build-migration -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install nrwl/nx dist-build-migration --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/nrwl/nx.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/dist-build-migration .claude/skills/dist-build-migration && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
dist-build-migration
GitHub stars
29k
Token cost
~8.3k tokens
SKILL.md length
3,204 words
Files
1
Skills in repo
21
Repo updated
First seen
Licence
MIT

At a glance

Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.

  • Works in 12 steps: Preflight: check workspace:* deps for… → Read current state → Identify entry points → …
  • Development work in your project
  • SKILL.md covers Argument and Steps
  • Calls pnpm, nx and jq

What it does

Dist Build Migration is an agent skill from nrwl/nx. Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.

Its SKILL.md is about 8.3k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Development. It works with Nx, npm, TypeScript and pnpm. The repository describes itself as: The Monorepo Platform that amplifies both developers and AI agents. Nx optimizes your builds, scales your CI, and fixes failed PRs automatically. Ship in half the time. The licence is MIT.

When your agent uses it

  • Development work in your project

Example prompts

  • “/dist-build-migration”

Requirements

  • Pre-approved tools (allowed-tools): Bash, Read, Glob, Grep, Agent, Edit, Write

Workflow steps

12 steps, taken from the step headings in SKILL.md.

  1. Preflight: check workspace:* deps for unmigrated packages
  2. Read current state
  3. Identify entry points
  4. Update tsconfig.lib.json
  5. Update tsconfig.spec.json (if exists)
  6. Update package.json
  7. Update project.json
  8. Update eslint config
  9. Update assets.json (if exists)
  10. Add files field to package.json
  11. Rename README.md and update build command
  12. Update root .gitignore

What it can do on your machine

Read from SKILL.md and the folder at commit db71d69. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves these tools, so the agent can use them without asking each time:

    • Bash
    • Read
    • Glob
    • Grep
    • Agent
    • Edit
    • Write

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • pnpm
    • nx
    • jq
    • node
    • tsc
    • git

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    No URLs in SKILL.md. Its commands use pnpm and git, which can reach the network depending on how they are called.

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Dist Build Migration loads about 8.3k tokens when it runs. Until then it costs about 38 tokens; SKILL.md has 3,204 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~38
When it runs · the whole SKILL.md, loaded when a task matches
~8.3k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check: notes

The automated check noted patterns worth knowing about, such as sudo or a known installer.

  • NotePre-approves every shell command (allowed-tools: Bash)SKILL.md
    allowed-tools: Bash, Read, Glob, Grep, Agent, Edit, Write

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from nrwl/nx at commit db71d69, republished under its MIT licence (© nrwl). 3,204 words, ~8,305 tokens.

Download SKILL.mdSave it as .claude/skills/dist-build-migration/SKILL.md (or your agent's skills folder).
name
dist-build-migration
description
Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.
allowed-tools
Bash, Read, Glob, Grep, Agent, Edit, Write

Migrate Package to Local Dist Build

You are migrating an Nx monorepo package from building to ../../dist/packages/<name> to building locally to packages/<name>/dist/. This matches the pattern already used by nx and devkit.

Argument

The user provides a package name (e.g., js, webpack, angular). The package lives at packages/<name>/.

Steps

0. Preflight: check workspace:* deps for unmigrated packages

Read packages/<name>/package.json and list every workspace:* dep (in dependencies, devDependencies, peerDependencies).

For each such dep, look at the target package's project.json. If it does not override release.version.manifestRootsToUpdate to ["packages/{projectName}"], that target package is still on the old layout. You must migrate those packages too (apply this skill to each), in the same PR.

Why: With preserveLocalDependencyProtocols: true (the new pattern), nx release version does not substitute workspace:* in your manifest. At publish time, pnpm resolves workspace:* by reading the target's source packages/<dep>/package.json. The default manifestRootsToUpdate: ["dist/packages/{projectName}"] only bumps the dist copy, so pnpm picks up the unbumped source 0.0.1 and publishes your package with a dep on a version that does not exist in the registry. Local registry installs then fail with ERR_PNPM_NO_MATCHING_VERSION.

A workspace:* dep on a still-on-old-layout package is a hard blocker — migrate it before continuing.

1. Read current state

Read these files for the target package:

  • packages/<name>/package.json
  • packages/<name>/project.json
  • packages/<name>/tsconfig.lib.json
  • packages/<name>/tsconfig.spec.json (if exists)
  • packages/<name>/.eslintrc.json (if exists)
  • packages/<name>/assets.json (if exists)
  • packages/<name>/.npmignore (if exists)
  • packages/<name>/.gitignore (if exists)

Also read the reference implementations:

  • packages/devkit/tsconfig.lib.json
  • packages/devkit/package.json
  • packages/devkit/project.json
  • packages/devkit/.npmignore

Run pnpm nx show target <name>:build-base to see the inferred build target. Run pnpm nx show target <name>:build to see the full build target.

2. Identify entry points

Look at the package's root .ts files and any existing exports field. Common entry points:

  • index.ts (main)
  • testing.ts
  • internal.ts
  • ngcli-adapter.ts
  • Any other .ts files at the package root that re-export from src/

Also check for migrations.json and generators.json/executors.json — these need exports entries too.

3. Update tsconfig.lib.json

Transform from the old pattern to the new pattern:

Before:

json
{
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "../../dist/packages/<name>",
    "tsBuildInfoFile": "../../dist/packages/<name>/tsconfig.tsbuildinfo"
  }
}

After:

json
{
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": ".",
    "declarationDir": "dist",
    "declarationMap": false,
    "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo",
    "types": ["node"],
    "composite": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true
  },
  "exclude": ["node_modules", "dist", ...existing excludes, ".eslintrc.json"],
  "include": ["*.ts", "src/**/*.ts"]
}

Important: Adjust include based on the package's actual structure. If the package has directories like bin/, plugins/, etc. at the root level (like nx does), include those too.

4. Update tsconfig.spec.json (if exists)

Change outDir from ../../dist/packages/<name>/spec to dist/spec.

5. Update package.json

Key changes:

  • Add "type": "commonjs" near the top (after private)
  • Change "main" to "./dist/index.js"
  • Change "types" to "./dist/index.d.ts"
  • Add "typesVersions" for backwards compatibility with moduleResolution: "node" consumers
  • Add "exports" map with entries for each entry point

Each export entry follows this pattern:

json
"./entry-name": {
  "@nx/nx-source": "./entry-name.ts",
  "types": "./entry-name.d.ts",
  "default": "./dist/entry-name.js"
}

The main entry (.) uses ./index.ts, ./index.d.ts, ./dist/index.js.

Always include:

json
"./package.json": "./package.json"

Include "./migrations.json": "./migrations.json" if the package has migrations.

Note: The @nx/nx-source condition is a custom condition used for source-level resolution within the workspace (so other packages import from source, not dist).

Add a typesVersions field for consumers using moduleResolution: "node" (which doesn't read exports):

json
"typesVersions": {
  "*": {
    "testing": ["dist/testing.d.ts"],
    "ngcli-adapter": ["dist/ngcli-adapter.d.ts"]
  }
}

Add an entry for each subpath export (excluding ., ./package.json, and ./migrations.json).

6. Update project.json

Add these sections:

json
{
  "release": {
    "version": {
      "generator": "@nx/js:release-version",
      "preserveLocalDependencyProtocols": true,
      "manifestRootsToUpdate": ["packages/{projectName}"]
    }
  },
  "targets": {
    "nx-release-publish": {
      "options": {
        "packageRoot": "packages/{projectName}"
      }
    }
  }
}

Do not override build-base.outputs in project.json. The @nx/js/typescript plugin reads outDir and tsBuildInfoFile from tsconfig.lib.json and infers the correct outputs (including the tsbuildinfo and the full set of file extensions). A hand-written override is almost always less complete than the inferred set.

If the package already has a hand-written build-base.outputs array, delete it — don't try to patch it. An incomplete override that omits dist/tsconfig.tsbuildinfo causes a sandbox violation in every consumer that has a TypeScript project reference to this package: their tsc --build reads the referenced project's .tsbuildinfo, but dependentTasksOutputFiles can only collect it if this package declares it as an output.

Verify the inferred outputs include the tsbuildinfo:

bash
pnpm nx show project <name> --json | jq '.targets["build-base"].outputs'
# Must include "{projectRoot}/dist/tsconfig.tsbuildinfo"

Update the existing build target's outputs if they reference {workspaceRoot}/dist/packages/<name> — they should now reference {projectRoot}/dist/.

Also update dependsOn in the build target: replace "^build" with "^build" if it isn't already, and make sure "build-base" is listed.

7. Update eslint config

Add dist to the ignores. For flat config (eslint.config.mjs):

js
{ ignores: ['**/__fixtures__/**', 'dist'] },

For legacy .eslintrc.json:

json
"ignorePatterns": ["!**/*", "node_modules", "dist"]

Do not add *.d.ts or **/*.d.ts — the base config already ignores **/dist, and tsconfig.lib.json (Step 4) sends all generated .d.ts files into dist, so they're already out of scope. Hand-authored .d.ts files in src/ (e.g. schema.d.ts) generally don't need ignoring.

8. Update assets.json (if exists)

Change outDir from "dist/packages/<name>" to "packages/<name>/dist".

9. Add files field to package.json

Instead of using .npmignore, add a "files" field to package.json (matching the nx package pattern). Remove .npmignore if it exists.

json
"files": [
  "dist",
  "!dist/tsconfig.tsbuildinfo",
  "migrations.json"
]

Adjust based on the package's needs:

  • Add "executors.json" and/or "generators.json" if the package has them
  • Add any other non-TS files that need to be published
  • npm always includes package.json and README.md automatically — no need to list them
10. Rename README.md and update build command

If the package has a README.md at its root and uses the copy-readme.js script in its build target:

  1. Rename README.md to readme-template.md (git mv)
  2. Update the build command to pass explicit paths:
    node ./scripts/copy-readme.js <name> packages/<name>/readme-template.md packages/<name>/README.md
  3. Update the build target outputs to ["{projectRoot}/README.md"]

The script's default behavior reads packages/<name>/README.md and writes to dist/packages/<name>/README.md — both wrong for the new layout. Passing explicit args fixes both.

11. Update root .gitignore

Under the section that lists generated README files (look for packages/nx/README.md), add:

packages/<name>/README.md

The generated README is written next to source (not into dist/), so it needs its own ignore.

Do not add a packages/<name>/**/*.d.ts rule. The root .gitignore already has a top-level dist entry that ignores every dist/ directory in the repo — and tsconfig.lib.json (Step 4) sets declarationDir: "dist", so all generated .d.ts files land there. Adding a package-wide **/*.d.ts rule plus ! re-includes for hand-authored .d.ts files (like committed schema.d.ts source files) is redundant defense-in-depth.

12. Update docs generation paths

Check astro-docs/src/plugins/utils/ for any code that references .d.ts files from the package. The docs generation reads .d.ts entry points to build API reference pages. Paths that previously pointed to dist/packages/<name>/foo.d.ts (workspace root dist) or packages/<name>/foo.d.ts (package root) now need to point to packages/<name>/dist/foo.d.ts.

For example, devkit-generation.ts had to be updated to look for packages/devkit/dist/index.d.ts instead of packages/devkit/index.d.ts.

13. Update scripts/nx-release.ts

Two things to do here:

  1. Add the package to packagesToReset. That array (around scripts/nx-release.ts:76) is the snapshot/restore list — every package whose source package.json gets mutated by nx release (because it now publishes from packages/<name>/ directly, not dist/packages/<name>/) must be in this list. Otherwise the release will leave packages/<name>/package.json dirty in the working tree after running. Easy to forget — and there is no test that catches it.

  2. Update any package-specific paths. If the package has special release handling (like devkit's hackFixForDevkitPeerDependencies), update any paths from ./dist/packages/<name>/ to ./packages/<name>/.

14. Update imports across the workspace

Search for imports from @nx/<name>/src/ across all other packages. These internal imports need to be updated:

  • If the imported thing is re-exported through a public entry point (index.ts, internal.ts, etc.), update the import to use that entry point
  • If not, consider adding it to internal.ts or the appropriate entry point

Use: grep -r "from '@nx/<name>/src/" packages/ --include="*.ts" -l to find affected files.

Also check for imports in:

  • e2e/ tests
  • scripts/
  • tools/workspace-plugin/
  • astro-docs/
  • examples/
14b. (Optional) Lock down ./src/* and route internal consumers through ./internal

When you ship the migration, the package's exports map exposes everything under ./src/* if you keep the wildcard. That's a 100s-of-symbols-wide semi-private surface that pins the implementation layout forever — consumers (first-party and external) can reach into any source file. The cleaner long-term shape, matching @nx/devkit/@nx/workspace, is to drop the wildcard and route internal consumers through a single curated ./internal entry. Skip this step if you'd rather defer (e.g. the package has very heavy internal usage and you'd prefer a smaller PR), but plan a follow-up.

When to lock down vs defer
  • Lock down in the same PR if internal subpath imports number in the low hundreds AND the package isn't workspace:*-pinned by other not-yet-migrated packages whose dist code would crash at runtime against the older published version (see "Published-version mismatch" below).
  • Defer to a follow-up PR if the inventory is huge OR if dist-output code in other workspace packages depends on the OLD ./src/* paths and those packages can't be migrated to local-dist yet. Lock down only once the immediate runtime-resolution surface is contained.
Step-by-step

1. Inventory the subpath imports. Scan for from '@nx/<name>/src/...', plus runtime require(), dynamic import(), and jest/vi.mock-family calls:

bash
grep -rEln "from ['\"]@nx/<name>/src/" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.mjs" packages/ e2e/ scripts/
grep -rEln "(require|jest\.mock|jest\.requireActual)\(['\"]@nx/<name>/src/" packages/ e2e/ scripts/

Compile a subpath → set-of-imported-symbols map. About 30 distinct subpaths and 60 symbols is typical for a package the size of @nx/js.

2. Identify runtime-string-resolved subpaths. Some subpaths are referenced by string default values the nx runtime resolves later (not static imports). The classic example: packages/nx/src/command-line/release/config/config.ts has DEFAULT_VERSION_ACTIONS_PATH = '@nx/js/src/release/version-actions'. These strings are also baked into pre-existing user nx.json files and you cannot rewrite them via a migration. Keep those exact subpaths as explicit non-wildcard entries in the exports map (not under ./internal), and have the migration skip rewriting them.

bash
# Search for string-default usages of the subpath in nx core
grep -rEn "['\"]@nx/<name>/src/[^'\"]+['\"]" packages/nx/src/ --include="*.ts"

3. Build packages/<name>/internal.ts at the package ROOT (not inside src/, to mirror @nx/devkit/internal). Re-export every symbol callers reach for via @nx/<name>/src/*, BUT only symbols not already exported from packages/<name>/src/index.ts. Anything already public stays public — the migration sends those callers to @nx/<name>, not @nx/<name>/internal.

To compute the public set:

bash
grep -E "^export " packages/<name>/src/index.ts

…and recursively expand any export * lines. The "public-export reachability" calculation is fiddly enough that a small Python script with a recursive expand is worth it (see PR #35538 commit history for an example).

Curate the new file:

ts
// Semi-private surface for first-party Nx packages.
//
// External plugins should NOT import from here — this entry is curated for
// internal consumers and may change without semver protection. Mirrors
// `@nx/devkit/internal`.

// Re-exports of nx-source internals (need `no-restricted-imports` overrides).
// oxlint-disable-next-line no-restricted-imports
export { ... } from 'nx/src/plugins/.../something';

export { walkTsconfigExtendsChain, type RawTsconfigJsonCache } from './src/utils/typescript/raw-tsconfig';
// ... and so on, grouping by area.

Delete any pre-existing packages/<name>/src/internal.ts once its exports have been folded in.

4. Update packages/<name>/package.json. Drop wildcards, add ./internal, keep runtime-string subpaths as explicit entries:

jsonc
{
  "exports": {
    ".": {
      "@nx/nx-source": "./src/index.ts",
      "types": "./dist/src/index.d.ts",
      "default": "./dist/src/index.js",
    },
    "./package.json": "./package.json",
    "./migrations.json": "./migrations.json",
    "./generators.json": "./generators.json",
    "./executors.json": "./executors.json",
    // Public side-channels (whatever you already had).
    "./babel": {
      "@nx/nx-source": "./babel.ts",
      "types": "./dist/babel.d.ts",
      "default": "./dist/babel.js",
    },
    // The new curated entry.
    "./internal": {
      "@nx/nx-source": "./internal.ts",
      "types": "./dist/internal.d.ts",
      "default": "./dist/internal.js",
    },
    // Runtime-string-resolved subpath kept for back-compat.
    "./src/release/version-actions": {
      "@nx/nx-source": "./src/release/version-actions.ts",
      "types": "./dist/src/release/version-actions.d.ts",
      "default": "./dist/src/release/version-actions.js",
    },
    // DROPPED: "./src/*", "./src/*.js", "./src/*/schema", "./src/*/schema.json"
  },
}

Also strip src/* glob entries from typesVersions. Replace with explicit non-wildcard entries that mirror the kept exports.

5. Codemod consumers in two passes. Mechanical sed-style first, then a smarter split:

bash
# Pass 1: every `from '@nx/<name>/src/...'` → `from '@nx/<name>/internal'`,
# except the preserved subpaths from step 2.
# (Use a Python/TS script — sed is fine for the simple cases too.)
bash
# Pass 2: split mixed imports. Any line like
#   import { libraryGenerator, ensureTypescript } from '@nx/<name>/internal';
# where `libraryGenerator` is publicly exported from `src/index.ts` becomes:
#   import { libraryGenerator } from '@nx/<name>';
#   import { ensureTypescript } from '@nx/<name>/internal';

Also handle these non-static cases:

  • jest.mock('@nx/<name>/src/...', ...) and jest.requireActual(...) — same rewrite. The whole mock surface is now @nx/<name>/internal, so ...jest.requireActual('@nx/<name>/internal') spreads more than the original site mocked, but that's fine in practice.
  • Runtime require('@nx/<name>/src/...') — same rewrite.
  • Template-string fixtures inside .spec.ts files — careful! Don't let your codemod rewrite literal from "@nx/<name>/internal" substrings that test the migration (it'll flip quote style and break the test). Either skip *.spec.ts files containing fixtures, or operate at AST level.

6. Collapse duplicate imports. After the two-pass codemod, many files end up with two import { ... } from '@nx/<name>/internal' lines (or two from '@nx/<name>'). Run a third pass to merge same-source-same-type-prefix imports:

python
# Match lines (anchored): `^import [type ]{ ... } from '@nx/<name>[/internal]';$`
# Group by (is_type_only, source). For each group with >1 entry: keep the first
# occurrence's position, merge the named bindings (dedupe), delete the others.
# Don't merge across type/non-type — the semantics differ.

7. Public-symbol audit. After splitting, internal.ts must not re-export anything already exported from src/index.ts. If it does, namespace consumers (import * as shared from '@nx/<name>/internal') will see only the curated set and shared.publiclyExportedSymbol becomes undefined. Cross-check:

bash
# Symbols in internal.ts that are ALSO in the recursive index.ts export set
# are a bug. Remove them from internal.ts. The codemod from step 5 should
# have already routed their callers to `@nx/<name>`, but verify nothing is
# left pointing at `@nx/<name>/internal` for these.

The three load-bearing patterns to verify:

  • import * as shared from '@nx/<name>/internal' followed by shared.publicSymbol — fix by changing source to @nx/<name>.
  • Runtime const shared = require('@nx/<name>/internal') followed by shared.publicSymbol — same fix.
  • Named imports of public symbols from @nx/<name>/internal — already split by step 5; verify nothing slipped through.

8. Ship a migration. Add packages/<name>/src/migrations/update-<version>/rewrite-<name>-internal-subpath-imports.ts based on the workspace move-typescript-compilation-import template. It needs to handle:

  • Static import [type] { ... } from '@nx/<name>/src/<anything>'
  • export [type] { ... } from '@nx/<name>/src/<anything>'
  • Dynamic import('@nx/<name>/src/<anything>')
  • require('@nx/<name>/src/<anything>')
  • jest.mock|unmock|doMock|dontMock|requireActual|requireMock|importActual|importMock(...) and the vi. equivalents

Route by symbol, not blindly to ./internal. Some symbols reachable via @nx/<name>/src/* are public — they're exported from packages/<name>/src/index.ts and ship on the main @nx/<name> entry. A migration that rewrites every @nx/<name>/src/* import to @nx/<name>/internal silently breaks any consumer importing a public symbol that way, because internal.ts deliberately does not re-export public symbols (step 7). Instead:

  • Hard-code the public symbol set (the recursively-expanded exports of src/index.ts) in the migration.
  • For a named import/export declaration, partition the named bindings: public symbols go to @nx/<name>, the rest to @nx/<name>/internal. Classify an orig as alias binding by orig. If both groups are non-empty, replace the single declaration with two — one per target — preserving any import type / export type modifier.
  • A namespace import (import * as ns), a default import, export *, every call expression (require, dynamic import, jest.mock family), and typeof import('...') type queries (ImportTypeNode) reference the module as a whole and can't be symbol-split — route them to @nx/<name>/internal.

Skip the preserved subpaths from step 2 (e.g. @nx/<name>/src/release/version-actions). Use ts.createSourceFile for AST-based detection so you don't rewrite literals inside comments or template strings.

Don't forget typeof import('...'). It parses as an ImportTypeNode, not a CallExpression, so it's a separate AST branch from the require/dynamic-import handling. Real-world consumers use the idiom const m = require('@nx/<name>/src/x') as typeof import('@nx/<name>/src/x') to get a typed runtime require — if the codemod only rewrites the runtime arg, the type arg stays pointing at the now-removed ./src/* wildcard and the consumer fails to type-check. Handle it explicitly: walk ImportTypeNodes and rewrite node.argument.literal when the string starts with @nx/<name>/src/.

Register in packages/<name>/migrations.json with version: <current beta>. The description should state the routing rule: named public-symbol imports/exports go to @nx/<name>, everything else to @nx/<name>/internal.

Add a spec covering: public-symbol import (→ @nx/<name>), internal-symbol import (→ @nx/<name>/internal), mixed import split into two, aliased bindings classified by original name, type-only split, export { ... } from (public / internal / mixed), export *, namespace import, default import, single-quoted, double-quoted, deep subpath, .js extension, require(), dynamic import(), typeof import() type queries (→ /internal), a <typeof import()>require() cast in tandem (catches the regression where the runtime arg gets rewritten but the type arg doesn't), the full jest mock family (it.each over MOCK_HELPER_METHODS), the full vi mock family, jest.mock('...', factory) with a factory argument, a non-mock jest.* call left alone, an import + jest.mock in the same file, preserved subpaths, non-@nx/<name> imports, unrelated string literals inside comments. Make sure every entry in PUBLIC_SYMBOLS and every entry in MOCK_HELPER_METHODS is exercised at least once — drift from hardcoded sets is the most likely silent regression.

9. Watch for the published-version-mismatch gotcha in example/test builds.

The workspace's root node_modules/@nx/<name> is the published version (root package.json pins it to a real release tag, not workspace:*). When code at dist/packages/<X>/... does require('@nx/<name>/internal') at runtime, Node walks up from dist/ and finds workspace-root node_modules/@nx/<name> — the published copy. If that version was released BEFORE this PR, it has no internal.js and resolution fails.

Symptom:

Error: Cannot find module '@nx/<name>/internal'
  requireStack: [
    '/path/to/workspace/dist/packages/<X>/src/utils/foo.js',
    ...
  ]
}

This bites specifically for examples or e2e flows that load dist/packages/<other-package>/... artifacts (e.g. an angular-rspack module-federation example that monkey-patches Module._resolveFilename to redirect @nx/<other-package> to dist). If the other-package's dist code does require('@nx/<name>/internal'), you'll hit this.

Two fixes:

  • (Preferred, if applicable.) Migrate the other package to local-dist too. Then its built code lives at packages/<other>/dist/..., walks up to packages/<other>/node_modules/@nx/<name> (a workspace symlink to source), and resolution finds the new internal.js because workspace source has it.
  • (Band-aid for the in-between window.) If migrating the other package is out of scope, extend the example's existing request-path patch to also redirect @nx/<name>/internal to the workspace source packages/<name>/dist/internal. Document it as a temporary measure tied to the same TODO that exists for the other-package redirect.

Search aggressively for this pattern after step 8:

bash
grep -rln "patchModuleFederationRequestPath\|Module._resolveFilename" examples/ e2e/ packages/

Any file that monkeypatches resolution is a candidate for needing the redirect.

Show full SKILL.md (803 more words)Show less
Validation

After steps 1–9:

bash
# Build the package (emits dist/internal.{js,d.ts})
pnpm nx run <name>:build-base

# Lint the package — @nx/dependency-checks may complain that the package
# "uses itself" because of the dynamic self-reference in versions.ts. Add
# `@nx/<name>` to `ignoredDependencies` in the dependency-checks rule config
# (with a comment explaining: self-reference for require(join('@nx/<name>', 'package.json'))).
pnpm nx run <name>:lint

# Spec the migration
pnpm nx test <name> -- --testPathPatterns=rewrite-<name>-internal-subpath-imports

# Full affected — catches consumers, example monkey-patches, and any
# missed split-mixed-imports.
pnpm nx affected -t build,lint --base=<base-sha-before-migration>

If nx affected fails on a single example test with Cannot find module '@nx/<name>/internal', that's step 9 — extend the example's request-path patch.

If nx affected fails on a package with TS2339: Property 'foo' does not exist on type 'typeof import(".../internal")', that's step 7 — a shared.publicSymbol call survived. Find it (grep -rn 'shared\.<symbol>' packages/) and rewrite the namespace source to @nx/<name>.

15. Audit require('../../package.json') (or similar relative paths to the package.json)

Search for require\(['"]\.\..*package\.json inside packages/<name>/src/. Any TS source file that reads the package's own package.json via a relative path is a layout-fragility bug that this migration triggers:

  • Before migration: source packages/<name>/src/utils/versions.ts → built dist/packages/<name>/src/utils/versions.js. '../../package.json' resolves to dist/packages/<name>/package.json (which the old build path copied there).
  • After migration: source unchanged → built packages/<name>/dist/src/utils/versions.js. '../../package.json' now resolves to packages/<name>/dist/package.json — doesn't exist. Every consumer that pulls in nxVersion/NX_VERSION/etc. crashes at module-load time with Cannot find module '../../package.json'. This breaks e2e tests broadly because most generators load versions.ts.

Fix: replace the relative path with a package-name self-reference, using the dynamic join() form so eslint's @nx/enforce-module-boundaries doesn't trip on it:

ts
// Before
export const nxVersion = require('../../package.json').version;

// After
import { join } from 'path';
export const nxVersion = require(join('@nx/<name>', 'package.json')).version;

A literal require('@nx/<name>/package.json') works at runtime but trips enforce-module-boundaries's noSelfCircularDependencies check — the rule statically pattern-matches self-imports and fires before checking whether the import resolves to a non-main entry. The dynamic join() form is opaque to the static check, matches @nx/devkit's established pattern, and resolves to the same path at runtime.

Node resolves @nx/<name>/package.json via node_modules (workspace symlink in dev, real install in published), and the package.json's exports map already declares ./package.json (you ensured this in Step 5). Works identically in source and dist contexts.

Reference implementations:

  • packages/nx/src/utils/versions.ts — require('nx/package.json').version (works because nx is the project's own name; the static rule's entry-point check is lenient for the top-level nx package specifically)
  • packages/devkit/src/utils/package-json.ts — NX_VERSION = require(join('nx', 'package.json')).version (dynamic form)

This was the source of the workspace-migration e2e regressions (PR #35643) and is one of the most-failure-prone steps to forget. Audit aggressively.

15b. Audit ensurePackage + await import(...) pairs

Search for ensurePackage\(['"]@nx/ inside packages/<name>/src/. For every match, look at the next 5–20 lines for a await import('@nx/<other>/...') pulling from the same package. This pattern is broken under nodenext:

  • Before migration: module: commonjs made TypeScript downlevel await import('@nx/<other>') to Promise.resolve(require('@nx/<other>')). The synchronous require() honors Module._initPaths, which is exactly where ensurePackage registers the on-demand temp install. Resolution succeeds.
  • After migration: module: nodenext preserves import() as a true ESM dynamic import. ESM resolution ignores Module._initPaths — it walks up node_modules from the importing file's location only. The temp install lives in a different temp dir, so the import fails with Cannot find package '@nx/<other>'.

Fix: replace the dynamic import with a synchronous require(). The ensurePackage side effect makes it findable via _initPaths, and require() honors that:

ts
// Before
ensurePackage('@nx/eslint', nxVersion);
const { foo, bar } = await import('@nx/eslint/internal');

// After
ensurePackage('@nx/eslint', nxVersion);
// `require()` honors Module._initPaths (which ensurePackage updates); ESM
// dynamic `import()` doesn't, so it can't see the temp install.
const {
  foo,
  bar,
}: typeof import('@nx/eslint/internal') = require('@nx/eslint/internal');

Collapse multiple successive await import()s of the same module into one require() destructuring while you're at it.

This was the source of the M2 e2e regressions (Playwright/Web/React generators crashed at Cannot find package '@nx/eslint' from ignore-vite-temp-files.js and ignore-vitest-temp-files.js). One-line failure mode, but it can sit hidden in any code path that the unit-test suite doesn't exercise — only the published-then-installed flow exposes it. Audit every ensurePackage callsite.

16. Preserve add-extra-dependencies if the package has one

scripts/add-dependency-to-build.js is a release-time hack that injects an extra dep into the published package.json (e.g., it adds nx to @nx/workspace's dependencies). It is not dead code — without it the transitive resolution chain breaks for downstream consumers.

Concretely: created workspaces depend on @nx/js, which transitively depends on @nx/workspace. When the fork in generate-preset.ts runs nx g @nx/workspace:preset, Node's require.resolve('@nx/workspace/package.json') only finds the transitively-installed package because pnpm hoists nx along with @nx/workspace into .pnpm/node_modules/ — and nx is hoisted there only because the published @nx/workspace/package.json declares it as a regular dependency. Drop that injection and the fork in the new workspace fails with Unable to resolve @nx/workspace:preset → unable to find tsconfig.base.json.

When migrating a package that has the add-extra-dependencies target:

  1. Keep the target in packages/<name>/project.json.
  2. Update scripts/add-dependency-to-build.js: change the pkgPath from ../dist/packages/<package>/package.json to ../packages/<package>/package.json (the source manifest is now the published manifest under the local-dist layout).
  3. Keep the pnpm nx run-many -t add-extra-dependencies --parallel 8 invocations in scripts/nx-release.ts (both the GitHub-release path and the local-publish path) — they fire between runNxReleaseVersion and nx run nx:expand-deps.
  4. Confirm the snapshot/reset list (packagesToReset) covers this package so the injection is undone after publish.

If the package does not have the target, leave the script and the run-many calls alone — they no-op for any project without the target.

17. Verify

Run:

bash
pnpm nx run-many -t test,build,lint -p <name>

Then:

bash
pnpm nx affected -t build,test,lint
Summary of the pattern

The core idea is simple: instead of building to a shared dist/packages/<name>/ at the workspace root, each package builds to its own packages/<name>/dist/. The exports map with @nx/nx-source condition lets workspace packages resolve to .ts source files during development, while external consumers get the built .js from dist/. This is like giving each package its own "output mailbox" instead of sharing one big mailbox.

© nrwl, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in .claude/skills/dist-build-migration of nrwl/nx.

Open the folder on GitHubat commit db71d69

Compare with similar skills

Dist Build Migration next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Dist Build Migration compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Dist Build Migration this skillnrwl/nx29k—~8.3kAutomated safety check: NotesMIT
Link Workspace Packagesnomcopter/react-mosaic4.8k6 repos~760Automated safety check: PassCustom licence
Testing Changespnpm/pnpm37k—~1.1kAutomated safety check: PassMIT
Linea Dependency MaintenanceConsensys-Incorporated/linea-attestation-registry1771 repos~3.7kAutomated safety check: WarnMIT
Releaseseasonedcc/remix-forms514—~1.3kAutomated safety check: PassMIT
TS SDK Authormindfold-ai/Trellis15k—~6.1kAutomated safety check: PassMIT

Similar skills

  • Link Workspace Packages

    nomcopter/react-mosaic

    Link workspace packages in monorepos (npm, yarn, pnpm, bun).

    4.8k GitHub starsUsed in 6 repos~760 tokens
    DevelopmentAuto-check passed
  • Run the tests that cover a change in the pnpm repository, in the Rust workspace (pnpm/, pnpr/) or the TypeScript CLI (pnpm11/), and recognize the cases where a scoped run passes without testing…

    37k GitHub stars~1.1k tokensUpdated today
    DevelopmentAuto-check passed
  • Linea Dependency Maintenance

    Consensys-Incorporated/linea-attestation-registry

    Safely plan and execute dependency maintenance for JavaScript/TypeScript (npm, pnpm) and GitHub Actions, including npm lockfiles, pnpm workspaces, catalogs, overrides, SHA-pinned action versions…

    177 GitHub starsUsed in 1 repo~3.7k tokens
    DevelopmentAuto-check: warnings
  • Release

    seasonedcc/remix-forms

    Release a new version of the remix-forms npm package. An agent skill from seasonedcc/remix-forms.

    514 GitHub stars~1.3k tokensUpdated 5 mo ago
    DevelopmentAuto-check passed
  • TS SDK Author

    mindfold-ai/Trellis

    Design, build, verify, and publish production-grade TypeScript SDKs as npm packages inside a pnpm monorepo.

    15k GitHub stars~6.1k tokensUpdated 10 days ago
    DevelopmentAuto-check passed
  • Antfu

    oyjt/uniapp-vue3-template

    Anthony Fu's {Opinionated} preferences and best practices for web development

    627 GitHub stars~1.3k tokensUpdated 4 mo ago
    DevelopmentAuto-check passed
  • Monitor CI

    nrwl/nx

    Monitor Nx Cloud CI pipeline and handle self-healing fixes. An agent skill from nrwl/nx.

    29k GitHub starsUsed in 6 repos~4.7k tokens
    Auto-check passed
  • Nx Import

    nrwl/nx

    Import, merge, or combine repositories into an Nx workspace using nx import.

    29k GitHub starsUsed in 6 repos~3.5k tokens
    Auto-check passed
  • Run Nx generators with prioritization for workspace-plugin generators.

    29k GitHub starsUsed in 2 repos~592 tokens
    Auto-check: notes
  • Author or scope a first-party Nx migration. An agent skill from nrwl/nx.

    29k GitHub stars~12k tokensUpdated today
    Auto-check: notes
  • Generate code using nx generators. An agent skill from nrwl/nx.

    29k GitHub starsUsed in 1 repo~2.2k tokens
    Auto-check passed
  • Check modified Nx documentation pages against the astro-docs style guide.

    29k GitHub stars~1.3k tokensUpdated today
    Auto-check passed

Categories

Questions about Dist Build Migration

What does Dist Build Migration do?

Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition. Dist Build Migration is an agent skill from nrwl/nx. Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.

When should I use Dist Build Migration?

Dist Build Migration fits situations like: development work in your project.

How do I install Dist Build Migration in Claude Code?

Run `npx skills add nrwl/nx --skill dist-build-migration -a claude-code`. Or copy the skill folder (.claude/skills/dist-build-migration in nrwl/nx) into .claude/skills/dist-build-migration in your project. Claude Code loads it when a task matches its description.

How do I install Dist Build Migration in Codex?

Run `npx skills add nrwl/nx --skill dist-build-migration -a codex`. Or copy the skill folder (.claude/skills/dist-build-migration in nrwl/nx) into .agents/skills/dist-build-migration in your project. Codex loads it when a task matches its description.

Can I use Dist Build Migration in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add nrwl/nx --skill dist-build-migration -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/dist-build-migration, .gemini/skills/dist-build-migration, .github/skills/dist-build-migration and .opencode/skills/dist-build-migration in your project.

What does Dist Build Migration need to run?

Going by SKILL.md and its folder, Dist Build Migration needs the command-line tools its instructions call (pnpm, nx, jq, node, tsc and git). Its frontmatter pre-approves these tools: Bash, Read, Glob, Grep, Agent, Edit, Write.

Does Dist Build Migration access the network?

SKILL.md contains no URLs. Its commands use git, which can reach the network depending on how they are called. This is read from the text; nothing was executed.

Is Dist Build Migration safe to install?

Our automated static check of SKILL.md found notes only (pre-approves every shell command (allowed-tools: bash)), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.

What licence does Dist Build Migration use?

Dist Build Migration is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Dist Build Migration use?

About 8.3k tokens (SKILL.md is roughly 33k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Dist Build Migration?

Skills that share tags, products or a category with Dist Build Migration: Link Workspace Packages (nomcopter/react-mosaic, 4.8k stars), Testing Changes (pnpm/pnpm, 37k stars), Linea Dependency Maintenance (Consensys-Incorporated/linea-attestation-registry, 177 stars) and Release (seasonedcc/remix-forms, 514 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Dist Build Migration?

nrwl (a GitHub organization) maintains it in nrwl/nx, which has 29,399 GitHub stars. The repository holds 21 skills in this directory. The repository was last updated on October 9, 2026.

Source: nrwl/nx on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.