一键导入
dist-build-migration
Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Migrate an Nx package to build to a local dist/ directory with nodenext module resolution, exports map, and @nx/nx-source condition.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
| 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 |
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.
The user provides a package name (e.g., js, webpack, angular). The package lives at packages/<name>/.
workspace:* deps for unmigrated packagesRead 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.
Read these files for the target package:
packages/<name>/package.jsonpackages/<name>/project.jsonpackages/<name>/tsconfig.lib.jsonpackages/<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.jsonpackages/devkit/package.jsonpackages/devkit/project.jsonpackages/devkit/.npmignoreRun 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.
Look at the package's root .ts files and any existing exports field. Common entry points:
index.ts (main)testing.tsinternal.tsngcli-adapter.ts.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.
tsconfig.lib.jsonTransform from the old pattern to the new pattern:
Before:
{
"compilerOptions": {
"module": "commonjs",
"outDir": "../../dist/packages/<name>",
"tsBuildInfoFile": "../../dist/packages/<name>/tsconfig.tsbuildinfo"
}
}
After:
{
"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.
tsconfig.spec.json (if exists)Change outDir from ../../dist/packages/<name>/spec to dist/spec.
package.jsonKey changes:
"type": "commonjs" near the top (after private)"main" to "./dist/index.js""types" to "./dist/index.d.ts""typesVersions" for backwards compatibility with moduleResolution: "node" consumers"exports" map with entries for each entry pointEach export entry follows this pattern:
"./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:
"./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):
"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).
project.jsonAdd these sections:
{
"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:
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.
Add dist to the ignores. For flat config (eslint.config.mjs):
{ ignores: ['**/__fixtures__/**', 'dist'] },
For legacy .eslintrc.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.
assets.json (if exists)Change outDir from "dist/packages/<name>" to "packages/<name>/dist".
files field to package.jsonInstead of using .npmignore, add a "files" field to package.json (matching the nx package pattern). Remove .npmignore if it exists.
"files": [
"dist",
"!dist/tsconfig.tsbuildinfo",
"migrations.json"
]
Adjust based on the package's needs:
"executors.json" and/or "generators.json" if the package has thempackage.json and README.md automatically — no need to list themIf the package has a README.md at its root and uses the copy-readme.js script in its build target:
README.md to readme-template.md (git mv)node ./scripts/copy-readme.js <name> packages/<name>/readme-template.md packages/<name>/README.md
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.
.gitignoreUnder 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.
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.
scripts/nx-release.tsTwo things to do here:
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.
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>/.
Search for imports from @nx/<name>/src/ across all other packages. These internal imports need to be updated:
internal.ts or the appropriate entry pointUse: grep -r "from '@nx/<name>/src/" packages/ --include="*.ts" -l to find affected files.
Also check for imports in:
e2e/ testsscripts/tools/workspace-plugin/astro-docs/examples/./src/* and route internal consumers through ./internalWhen 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.
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)../src/* paths and those packages can't be migrated to local-dist yet. Lock down only once the immediate runtime-resolution surface is contained.1. Inventory the subpath imports. Scan for from '@nx/<name>/src/...', plus runtime require(), dynamic import(), and jest/vi.mock-family calls:
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.
# 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:
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:
// 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).
// eslint-disable-next-line @typescript-eslint/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:
{
"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:
# 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.)
# 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.require('@nx/<name>/src/...') — same rewrite..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:
# 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:
# 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>.const shared = require('@nx/<name>/internal') followed by shared.publicSymbol — same fix.@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:
import [type] { ... } from '@nx/<name>/src/<anything>'export [type] { ... } from '@nx/<name>/src/<anything>'import('@nx/<name>/src/<anything>')require('@nx/<name>/src/<anything>')jest.mock|unmock|doMock|dontMock|requireActual|requireMock|importActual|importMock(...) and the vi. equivalentsRoute 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:
exports of src/index.ts) in the migration.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.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:
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.@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:
grep -rln "patchModuleFederationRequestPath\|Module._resolveFilename" examples/ e2e/ packages/
Any file that monkeypatches resolution is a candidate for needing the redirect.
After steps 1–9:
# 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>.
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:
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).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:
// 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.
ensurePackage + await import(...) pairsSearch 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:
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.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:
// 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.
add-extra-dependencies if the package has onescripts/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:
packages/<name>/project.json.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).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.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.
Run:
pnpm nx run-many -t test,build,lint -p <name>
Then:
pnpm nx affected -t build,test,lint
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.
Deep code review of a single open PR in nrwl/nx. Sets up an isolated worktree, runs the pr-review-toolkit review agents, the reproduce-verifier agent (grounds the review in the linked issues and, when runnable locally, executes the repro on master vs PR), and the alternative-approach agent (independently designs competing solutions and contrasts them with the PR's choice), surfaces only critical and important findings (plus strengths; nice-to-have suggestions are dropped), and saves a GitHub-flavored draft to ~/.nx-pr-reviews/<NUMBER>.md for the reviewer to read (nothing is posted). Use when you want a thorough review of one PR.
Migrate several repos to a target nx version (e.g. 23.0.0-beta.25) in one coordinated pass — delegates `nx migrate` + migrations to a Polygraph child agent per repo, then pushes branches and opens linked draft PRs. Use when asked to upgrade/migrate multiple repos to a specific nx version, or when working a Polygraph session whose goal is an nx version bump across repos.
Link workspace packages in monorepos (npm, yarn, pnpm, bun). USE WHEN: (1) you just created or generated new packages and need to wire up their dependencies, (2) user imports from a sibling package and needs to add it as a dependency, (3) you get resolution errors for workspace packages (@org/*) like "cannot find module", "failed to resolve import", "TS2307", or "cannot resolve". DO NOT patch around with tsconfig paths or manual package.json edits - use the package manager's workspace commands to fix actual linking.
Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring — it integrates with Nx Cloud self-healing which those tools cannot access.
Generate code using nx generators. INVOKE IMMEDIATELY when user mentions scaffolding, setup, structure, creating apps/libs, or setting up project structure. Trigger words - scaffold, setup, create a new app, create a new lib, project structure, generate, add a new project. ALWAYS use this BEFORE calling nx_docs or exploring - this skill handles discovery internally.
Import, merge, or combine repositories into an Nx workspace using nx import. USE WHEN the user asks to adopt Nx across repos, move projects into a monorepo, or bring code/history from another repository.