diff --git a/docs/generated/devkit/NxJsonConfiguration.md b/docs/generated/devkit/NxJsonConfiguration.md index 60aa6451ec..029200dd2b 100644 --- a/docs/generated/devkit/NxJsonConfiguration.md +++ b/docs/generated/devkit/NxJsonConfiguration.md @@ -37,6 +37,7 @@ Nx.json configuration - [plugins](../../devkit/documents/NxJsonConfiguration#plugins): PluginConfiguration[] - [pluginsConfig](../../devkit/documents/NxJsonConfiguration#pluginsconfig): Record> - [release](../../devkit/documents/NxJsonConfiguration#release): NxReleaseConfiguration +- [sync](../../devkit/documents/NxJsonConfiguration#sync): NxSyncConfiguration - [targetDefaults](../../devkit/documents/NxJsonConfiguration#targetdefaults): TargetDefaults - [tasksRunnerOptions](../../devkit/documents/NxJsonConfiguration#tasksrunneroptions): Object - [useDaemonProcess](../../devkit/documents/NxJsonConfiguration#usedaemonprocess): boolean @@ -240,6 +241,14 @@ Configuration for `nx release` (versioning and publishing of applications and li --- +### sync + +• `Optional` **sync**: `NxSyncConfiguration` + +Configuration for the `nx sync` command. + +--- + ### targetDefaults • `Optional` **targetDefaults**: [`TargetDefaults`](../../devkit/documents/TargetDefaults) diff --git a/docs/generated/devkit/TargetConfiguration.md b/docs/generated/devkit/TargetConfiguration.md index ac5ef2b463..05bb7fdac4 100644 --- a/docs/generated/devkit/TargetConfiguration.md +++ b/docs/generated/devkit/TargetConfiguration.md @@ -23,6 +23,7 @@ Target's configuration - [options](../../devkit/documents/TargetConfiguration#options): T - [outputs](../../devkit/documents/TargetConfiguration#outputs): string[] - [parallelism](../../devkit/documents/TargetConfiguration#parallelism): boolean +- [syncGenerators](../../devkit/documents/TargetConfiguration#syncgenerators): string[] ## Properties @@ -119,3 +120,12 @@ caching engine. Whether this target can be run in parallel with other tasks Default is true + +--- + +### syncGenerators + +• `Optional` **syncGenerators**: `string`[] + +List of generators to run before the target to ensure the workspace +is up to date. diff --git a/docs/generated/devkit/Workspace.md b/docs/generated/devkit/Workspace.md index 6e63aa780c..7a752681e8 100644 --- a/docs/generated/devkit/Workspace.md +++ b/docs/generated/devkit/Workspace.md @@ -36,6 +36,7 @@ use ProjectsConfigurations or NxJsonConfiguration - [pluginsConfig](../../devkit/documents/Workspace#pluginsconfig): Record> - [projects](../../devkit/documents/Workspace#projects): Record - [release](../../devkit/documents/Workspace#release): NxReleaseConfiguration +- [sync](../../devkit/documents/Workspace#sync): NxSyncConfiguration - [targetDefaults](../../devkit/documents/Workspace#targetdefaults): TargetDefaults - [tasksRunnerOptions](../../devkit/documents/Workspace#tasksrunneroptions): Object - [useDaemonProcess](../../devkit/documents/Workspace#usedaemonprocess): boolean @@ -328,6 +329,18 @@ Configuration for `nx release` (versioning and publishing of applications and li --- +### sync + +• `Optional` **sync**: `NxSyncConfiguration` + +Configuration for the `nx sync` command. + +#### Inherited from + +[NxJsonConfiguration](../../devkit/documents/NxJsonConfiguration).[sync](../../devkit/documents/NxJsonConfiguration#sync) + +--- + ### targetDefaults • `Optional` **targetDefaults**: [`TargetDefaults`](../../devkit/documents/TargetDefaults) diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 3434a370e5..690945889f 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -7987,9 +7987,9 @@ "disableCollapsible": false }, { - "id": "sync", - "path": "/nx-api/js/generators/sync", - "name": "sync", + "id": "typescript-sync", + "path": "/nx-api/js/generators/typescript-sync", + "name": "typescript-sync", "children": [], "isExternal": false, "disableCollapsible": false diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 8a23e50a27..739c954120 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -1283,13 +1283,13 @@ "path": "/nx-api/js/generators/setup-build", "type": "generator" }, - "/nx-api/js/generators/sync": { + "/nx-api/js/generators/typescript-sync": { "description": "Synchronize TypeScript project references based on the project graph", - "file": "generated/packages/js/generators/sync.json", + "file": "generated/packages/js/generators/typescript-sync.json", "hidden": true, - "name": "sync", - "originalFilePath": "/packages/js/src/generators/sync/schema.json", - "path": "/nx-api/js/generators/sync", + "name": "typescript-sync", + "originalFilePath": "/packages/js/src/generators/typescript-sync/schema.json", + "path": "/nx-api/js/generators/typescript-sync", "type": "generator" } }, diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 6dde766476..c8b2fe1fc7 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1267,11 +1267,11 @@ }, { "description": "Synchronize TypeScript project references based on the project graph", - "file": "generated/packages/js/generators/sync.json", + "file": "generated/packages/js/generators/typescript-sync.json", "hidden": true, - "name": "sync", - "originalFilePath": "/packages/js/src/generators/sync/schema.json", - "path": "js/generators/sync", + "name": "typescript-sync", + "originalFilePath": "/packages/js/src/generators/typescript-sync/schema.json", + "path": "js/generators/typescript-sync", "type": "generator" } ], diff --git a/docs/generated/packages/js/generators/sync.json b/docs/generated/packages/js/generators/typescript-sync.json similarity index 60% rename from docs/generated/packages/js/generators/sync.json rename to docs/generated/packages/js/generators/typescript-sync.json index f093fe3986..7895bb89ba 100644 --- a/docs/generated/packages/js/generators/sync.json +++ b/docs/generated/packages/js/generators/typescript-sync.json @@ -1,6 +1,6 @@ { - "name": "sync", - "factory": "./src/generators/sync/sync#syncGenerator", + "name": "typescript-sync", + "factory": "./src/generators/typescript-sync/typescript-sync", "schema": { "$schema": "https://json-schema.org/schema", "$id": "action", @@ -11,9 +11,10 @@ "presets": [] }, "description": "Synchronize TypeScript project references based on the project graph", + "alias": ["sync"], "hidden": true, - "implementation": "/packages/js/src/generators/sync/sync#syncGenerator.ts", + "implementation": "/packages/js/src/generators/typescript-sync/typescript-sync.ts", "aliases": [], - "path": "/packages/js/src/generators/sync/schema.json", + "path": "/packages/js/src/generators/typescript-sync/schema.json", "type": "generator" } diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index bdd51c0e5f..d785858fe4 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -476,7 +476,7 @@ - [release-version](/nx-api/js/generators/release-version) - [setup-verdaccio](/nx-api/js/generators/setup-verdaccio) - [setup-build](/nx-api/js/generators/setup-build) - - [sync](/nx-api/js/generators/sync) + - [typescript-sync](/nx-api/js/generators/typescript-sync) - [nest](/nx-api/nest) - [documents](/nx-api/nest/documents) - [Overview](/nx-api/nest/documents/overview) diff --git a/packages/js/generators.json b/packages/js/generators.json index 78f9dfbbcd..badcbfd554 100644 --- a/packages/js/generators.json +++ b/packages/js/generators.json @@ -42,10 +42,11 @@ "alias": ["build"], "description": "setup-build generator" }, - "sync": { - "factory": "./src/generators/sync/sync#syncGenerator", - "schema": "./src/generators/sync/schema.json", + "typescript-sync": { + "factory": "./src/generators/typescript-sync/typescript-sync", + "schema": "./src/generators/typescript-sync/schema.json", "description": "Synchronize TypeScript project references based on the project graph", + "alias": ["sync"], "hidden": true } } diff --git a/packages/js/src/generators/sync/sync.spec.ts b/packages/js/src/generators/sync/sync.spec.ts deleted file mode 100644 index 72316f6b51..0000000000 --- a/packages/js/src/generators/sync/sync.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { ProjectGraph, Tree, readJson, writeJson } from '@nx/devkit'; -import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; -import { syncGenerator } from './sync'; - -let projectGraph: ProjectGraph; - -jest.mock('@nx/devkit', () => ({ - ...jest.requireActual('@nx/devkit'), - createProjectGraphAsync: jest.fn(() => Promise.resolve(projectGraph)), -})); - -describe('syncGenerator()', () => { - let tree: Tree; - - beforeEach(async () => { - tree = createTreeWithEmptyWorkspace(); - projectGraph = { - nodes: { - a: { - name: 'a', - type: 'lib', - data: { - root: 'packages/a', - }, - }, - b: { - name: 'b', - type: 'lib', - data: { - root: 'packages/b', - }, - }, - }, - dependencies: { - a: [], - b: [ - { - type: 'static', - source: 'b', - target: 'a', - }, - ], - }, - }; - - writeJson(tree, 'nx.json', { - // Wire up the @nx/js/typescript plugin with default options - plugins: ['@nx/js/typescript'], - }); - - // Root tsconfigs - writeJson(tree, 'tsconfig.json', {}); - writeJson(tree, 'tsconfig.options.json', { compilerOptions: {} }); - - // Package A - writeJson(tree, 'packages/a/tsconfig.json', {}); - writeJson(tree, 'packages/a/tsconfig.lib.json', { - compilerOptions: { - outDir: '../../dist/packages/a/dist', - }, - }); - writeJson(tree, 'packages/a/package.json', { - name: 'a', - version: '0.0.0', - }); - - // Package B (depends on A) - writeJson(tree, 'packages/b/tsconfig.json', {}); - writeJson(tree, 'packages/b/tsconfig.lib.json', {}); - writeJson(tree, 'packages/b/package.json', { - name: 'b', - version: '0.0.0', - dependencies: { - a: '0.0.0', - }, - }); - }); - - it('should error if the @nx/js/typescript plugin is not configured in nx.json', async () => { - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = nxJson.plugins.filter((p) => p !== '@nx/js/typescript'); - writeJson(tree, 'nx.json', nxJson); - - await expect(syncGenerator(tree, {})).rejects.toMatchInlineSnapshot( - `[Error: The @nx/js/typescript plugin must be added to the "plugins" array in nx.json before syncing tsconfigs]` - ); - }); - - describe('root tsconfig.json', () => { - it('should sync project references to the root tsconfig.json', async () => { - expect(readJson(tree, 'tsconfig.json').references).toMatchInlineSnapshot( - `undefined` - ); - - await syncGenerator(tree, {}); - - const rootTsconfig = readJson(tree, 'tsconfig.json'); - expect(rootTsconfig.references).toMatchInlineSnapshot(` - [ - { - "path": "./packages/a", - }, - { - "path": "./packages/b", - }, - ] - `); - }); - - it('should respect existing project references in the root tsconfig.json', async () => { - writeJson(tree, 'tsconfig.json', { - // Swapped order and additional manual reference - references: [ - { path: './packages/b' }, - { path: 'packages/a' }, - { path: 'packages/c' }, - ], - }); - expect(readJson(tree, 'tsconfig.json').references).toMatchInlineSnapshot(` - [ - { - "path": "./packages/b", - }, - { - "path": "packages/a", - }, - { - "path": "packages/c", - }, - ] - `); - - await syncGenerator(tree, {}); - - const rootTsconfig = readJson(tree, 'tsconfig.json'); - expect(rootTsconfig.references).toMatchInlineSnapshot(` - [ - { - "path": "./packages/b", - }, - { - "path": "packages/a", - }, - { - "path": "packages/c", - }, - ] - `); - }); - }); - - describe('project level tsconfig.json', () => { - it('should sync project references to project level tsconfig.json files where needed', async () => { - expect( - readJson(tree, 'packages/b/tsconfig.json').references - ).toMatchInlineSnapshot(`undefined`); - - await syncGenerator(tree, {}); - - expect(readJson(tree, 'packages/b/tsconfig.json').references) - .toMatchInlineSnapshot(` - [ - { - "path": "../a", - }, - ] - `); - }); - - it('should respect existing project references in the project level tsconfig.json', async () => { - writeJson(tree, 'packages/b/tsconfig.json', { - // Swapped order and additional manual reference - references: [{ path: '../some/thing' }, { path: '../../another/one' }], - }); - expect(readJson(tree, 'packages/b/tsconfig.json').references) - .toMatchInlineSnapshot(` - [ - { - "path": "../some/thing", - }, - { - "path": "../../another/one", - }, - ] - `); - - await syncGenerator(tree, {}); - - const rootTsconfig = readJson(tree, 'packages/b/tsconfig.json'); - // The dependency reference on "a" is added to the start of the array - expect(rootTsconfig.references).toMatchInlineSnapshot(` - [ - { - "path": "../a", - }, - { - "path": "../some/thing", - }, - { - "path": "../../another/one", - }, - ] - `); - }); - }); -}); diff --git a/packages/js/src/generators/sync/sync.ts b/packages/js/src/generators/sync/sync.ts deleted file mode 100644 index af4eec7a2f..0000000000 --- a/packages/js/src/generators/sync/sync.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - Tree, - createProjectGraphAsync, - formatFiles, - joinPathFragments, - readJson, - readNxJson, - writeJson, -} from '@nx/devkit'; -import { relative } from 'node:path'; -import { PLUGIN_NAME, TscPluginOptions } from '../../plugins/typescript/plugin'; -import { SyncSchema } from './schema'; - -interface Tsconfig { - references?: Array<{ path: string }>; - compilerOptions?: { - paths?: Record; - rootDir?: string; - outDir?: string; - }; -} - -export async function syncGenerator(tree: Tree, options: SyncSchema) { - // Ensure that the plugin has been wired up in nx.json - const nxJson = readNxJson(tree); - let tscPluginConfig: { plugin: string; options?: TscPluginOptions } | string = - nxJson.plugins.find((p) => { - if (typeof p === 'string') { - return p === PLUGIN_NAME; - } - return p.plugin === PLUGIN_NAME; - }); - if (!tscPluginConfig) { - throw new Error( - `The ${PLUGIN_NAME} plugin must be added to the "plugins" array in nx.json before syncing tsconfigs` - ); - } - - const projectGraph = await createProjectGraphAsync(); - const firstPartyDeps = Object.entries(projectGraph.dependencies).filter( - ([name, data]) => !name.startsWith('npm:') && data.length > 0 - ); - - // Root tsconfig containing project references for the whole workspace - const rootTsconfigPath = 'tsconfig.json'; - const rootTsconfig = readJson(tree, rootTsconfigPath); - - const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter( - (node) => { - const projectTsconfigPath = joinPathFragments( - node.data.root, - 'tsconfig.json' - ); - return tree.exists(projectTsconfigPath); - } - ); - - if (tsconfigProjectNodeValues.length > 0) { - // Sync the root tsconfig references from the project graph (do not destroy existing references) - rootTsconfig.references = rootTsconfig.references || []; - const referencesSet = new Set( - rootTsconfig.references.map((ref) => normalizeReferencePath(ref.path)) - ); - for (const node of tsconfigProjectNodeValues) { - const normalizedPath = normalizeReferencePath(node.data.root); - // Skip the root tsconfig itself - if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) { - rootTsconfig.references.push({ path: `./${normalizedPath}` }); - } - } - writeJson(tree, rootTsconfigPath, rootTsconfig); - } - - for (const [name, data] of firstPartyDeps) { - // Get the source project nodes for the source and target - const sourceProjectNode = projectGraph.nodes[name]; - - // Find the relevant tsconfig files for the source project - const sourceProjectTsconfigPath = joinPathFragments( - sourceProjectNode.data.root, - 'tsconfig.json' - ); - - if (!tree.exists(sourceProjectTsconfigPath)) { - console.warn( - `Skipping project "${name}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}"` - ); - continue; - } - - const sourceTsconfig = readJson(tree, sourceProjectTsconfigPath); - - for (const dep of data) { - // Get the target project node - const targetProjectNode = projectGraph.nodes[dep.target]; - - if (!targetProjectNode) { - // It's an external dependency - continue; - } - - // Set defaults only in the case where we have at least one dependency so that we don't patch files when not necessary - sourceTsconfig.references = sourceTsconfig.references || []; - - // Ensure the project reference for the target is set - const relativePathToTargetRoot = relative( - sourceProjectNode.data.root, - targetProjectNode.data.root - ); - if ( - !sourceTsconfig.references.some( - (ref) => ref.path === relativePathToTargetRoot - ) - ) { - // Make sure we unshift rather than push so that dependencies are built in the right order by TypeScript when it is run directly from the root of the workspace - sourceTsconfig.references.unshift({ path: relativePathToTargetRoot }); - } - } - - // Update the source tsconfig files - writeJson(tree, sourceProjectTsconfigPath, sourceTsconfig); - } - - await formatFiles(tree); -} - -// Normalize the paths to strip leading `./` and trailing `/tsconfig.json` -function normalizeReferencePath(path: string): string { - return path.replace(/\/tsconfig.json$/, '').replace(/^\.\//, ''); -} diff --git a/packages/js/src/generators/sync/schema.d.ts b/packages/js/src/generators/typescript-sync/schema.d.ts similarity index 100% rename from packages/js/src/generators/sync/schema.d.ts rename to packages/js/src/generators/typescript-sync/schema.d.ts diff --git a/packages/js/src/generators/sync/schema.json b/packages/js/src/generators/typescript-sync/schema.json similarity index 100% rename from packages/js/src/generators/sync/schema.json rename to packages/js/src/generators/typescript-sync/schema.json diff --git a/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts b/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts new file mode 100644 index 0000000000..c3b5f9764b --- /dev/null +++ b/packages/js/src/generators/typescript-sync/typescript-sync.spec.ts @@ -0,0 +1,1178 @@ +import { + readJson, + readNxJson, + updateJson, + updateNxJson, + writeJson, + type ProjectGraph, + type Tree, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { syncGenerator } from './typescript-sync'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn(() => Promise.resolve(projectGraph)), +})); + +describe('syncGenerator()', () => { + let tree: Tree; + + function addProject( + name: string, + dependencies: string[] = [], + extraRuntimeTsConfigs: string[] = [], + root: string = `packages/${name}` + ) { + projectGraph.nodes[name] = { + name, + type: 'lib', + data: { root }, + }; + projectGraph.dependencies[name] = dependencies.map((dep) => ({ + type: 'static', + source: name, + target: dep, + })); + writeJson(tree, `${root}/tsconfig.json`, {}); + for (const runtimeTsConfigFileName of extraRuntimeTsConfigs) { + writeJson(tree, `${root}/${runtimeTsConfigFileName}`, {}); + } + writeJson(tree, `${root}/package.json`, { + name: name, + version: '0.0.0', + dependencies: dependencies.reduce( + (acc, dep) => ({ ...acc, [dep]: '0.0.0' }), + {} + ), + }); + } + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace(); + projectGraph = { + nodes: {}, + dependencies: {}, + }; + + writeJson(tree, 'nx.json', { + // Wire up the @nx/js/typescript plugin with default options + plugins: ['@nx/js/typescript'], + }); + + // Root tsconfigs + writeJson(tree, 'tsconfig.json', {}); + writeJson(tree, 'tsconfig.options.json', { compilerOptions: {} }); + + // b => a + addProject('a'); + addProject('b', ['a']); + }); + + it('should error if the @nx/js/typescript plugin is not configured in nx.json', async () => { + const nxJson = readJson(tree, 'nx.json'); + nxJson.plugins = nxJson.plugins.filter((p) => p !== '@nx/js/typescript'); + writeJson(tree, 'nx.json', nxJson); + + await expect(syncGenerator(tree)).rejects.toMatchInlineSnapshot( + `[Error: The @nx/js/typescript plugin must be added to the "plugins" array in nx.json before syncing tsconfigs]` + ); + }); + + it('should error if there is no root tsconfig.json', async () => { + tree.delete('tsconfig.json'); + + await expect(syncGenerator(tree)).rejects.toMatchInlineSnapshot( + `[Error: A "tsconfig.json" file must exist in the workspace root.]` + ); + }); + + it('should not make changes when references are set regardless their order and/or there are unformatted files', async () => { + // c => b => a + // d => b => a + // => a + // e => d => b => a + addProject('c', ['b']); + addProject('d', ['b', 'a']); + addProject('e', ['d']); + // set all expected references but in a different order + updateJson(tree, 'tsconfig.json', (json) => ({ + ...json, + references: [ + { path: './packages/c' }, + { path: './packages/a' }, + { path: './packages/b' }, + { path: './packages/e' }, + { path: './packages/d' }, + ], + })); + writeJson(tree, 'packages/a/tsconfig.lib.json', {}); + // unformatted tsconfig.json to test that it doesn't get picked up as a change + tree.write( + 'packages/b/tsconfig.json', + `{ + "references": [ { "path": "../a" } +]}` + ); + // unformatted tsconfig.lib.json to test that it doesn't get picked up as a change + tree.write( + 'packages/b/tsconfig.lib.json', + `{ + "references": [ { "path": "../a/tsconfig.lib.json" } +]}` + ); + updateJson(tree, 'packages/c/tsconfig.json', (json) => ({ + ...json, + references: [{ path: '../b' }, { path: '../a' }], + })); + writeJson(tree, 'packages/c/tsconfig.lib.json', { + references: [ + { path: '../b/tsconfig.lib.json' }, + { path: '../a/tsconfig.lib.json' }, + ], + }); + updateJson(tree, 'packages/d/tsconfig.json', (json) => ({ + ...json, + references: [{ path: '../b' }, { path: '../a' }], + })); + writeJson(tree, 'packages/d/tsconfig.lib.json', { + references: [ + { path: '../b/tsconfig.lib.json' }, + { path: '../a/tsconfig.lib.json' }, + ], + }); + updateJson(tree, 'packages/e/tsconfig.json', (json) => ({ + ...json, + references: [{ path: '../b' }, { path: '../d' }, { path: '../a' }], + })); + writeJson(tree, 'packages/e/tsconfig.lib.json', { + references: [ + { path: '../b/tsconfig.lib.json' }, + { path: '../d/tsconfig.lib.json' }, + { path: '../a/tsconfig.lib.json' }, + ], + }); + const changesBeforeSyncing = tree + .listChanges() + .map((c) => [c.path, c.type, c.content.toString('utf-8')]); + + await syncGenerator(tree); + + expect( + tree + .listChanges() + .map((c) => [c.path, c.type, c.content.toString('utf-8')]) + ).toStrictEqual(changesBeforeSyncing); + }); + + describe('root tsconfig.json', () => { + it('should sync project references to the tsconfig.json', async () => { + expect(readJson(tree, 'tsconfig.json').references).toBeUndefined(); + + await syncGenerator(tree); + + const rootTsconfig = readJson(tree, 'tsconfig.json'); + expect(rootTsconfig.references).toMatchInlineSnapshot(` + [ + { + "path": "./packages/a", + }, + { + "path": "./packages/b", + }, + ] + `); + }); + + it('should respect existing project references and discard non-existing ones in the tsconfig.json', async () => { + writeJson(tree, 'tsconfig.json', { + // Swapped order and additional manual reference + references: [ + { path: './packages/b' }, + { path: './packages/a' }, + { path: './packages/c' }, // existing extra reference to a tsconfig.json file in a non-project directory + { path: './packages/d' }, // non-existing reference + ], + }); + writeJson(tree, 'packages/c/tsconfig.json', {}); + + await syncGenerator(tree); + + const rootTsconfig = readJson(tree, 'tsconfig.json'); + expect(rootTsconfig.references).toMatchInlineSnapshot(` + [ + { + "path": "./packages/b", + }, + { + "path": "./packages/a", + }, + { + "path": "./packages/c", + }, + ] + `); + }); + }); + + describe('project level tsconfig.json', () => { + it('should sync project references to tsconfig.json files where needed', async () => { + expect( + readJson(tree, 'packages/b/tsconfig.json').references + ).toBeUndefined(); + + await syncGenerator(tree); + + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + }); + + it('should respect existing internal project references in the tsconfig.json', async () => { + writeJson(tree, 'packages/b/tsconfig.json', { + // Swapped order and additional manual reference + references: [ + { path: './some/thing' }, + { path: './another/one' }, + { path: './nested/project1' }, // external nested project reference that's not a dependency + ], + }); + addProject('project1', [], [], 'packages/b/nested/project1'); + + await syncGenerator(tree); + + const rootTsconfig = readJson(tree, 'packages/b/tsconfig.json'); + // The dependency reference on "a" is added to the start of the array + expect(rootTsconfig.references).toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "./some/thing", + }, + { + "path": "./another/one", + }, + ] + `); + }); + + it('should prune existing external project references that are no longer dependencies', async () => { + writeJson(tree, 'packages/b/tsconfig.json', { + references: [ + { path: './some/thing' }, + { path: './another/one' }, + { path: '../packages/c' }, // this is not a dependency, should be pruned + ], + }); + + await syncGenerator(tree); + + const rootTsconfig = readJson(tree, 'packages/b/tsconfig.json'); + // The dependency reference on "a" is added to the start of the array + expect(rootTsconfig.references).toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "./some/thing", + }, + { + "path": "./another/one", + }, + ] + `); + }); + + it('should collect transitive dependencies and sync project references to tsconfig.json files', async () => { + // c => b => a + // d => b => a + // => a + // e => d => b => a + addProject('c', ['b']); + addProject('d', ['b', 'a']); + addProject('e', ['d']); + + await syncGenerator(tree); + + expect( + readJson(tree, 'packages/a/tsconfig.json').references + ).toBeUndefined(); + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/d/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../d", + }, + ] + `); + }); + + describe('without custom sync generator options', () => { + it.each` + runtimeTsConfigFileName + ${'tsconfig.app.json'} + ${'tsconfig.lib.json'} + ${'tsconfig.build.json'} + ${'tsconfig.cjs.json'} + ${'tsconfig.esm.json'} + ${'tsconfig.runtime.json'} + `( + 'should sync project references to $runtimeTsConfigFileName files', + async ({ runtimeTsConfigFileName }) => { + writeJson(tree, `packages/a/${runtimeTsConfigFileName}`, {}); + writeJson(tree, `packages/b/${runtimeTsConfigFileName}`, {}); + + await syncGenerator(tree); + + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect( + readJson(tree, `packages/b/${runtimeTsConfigFileName}`).references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/${runtimeTsConfigFileName}", + }, + ] + `); + } + ); + + it('should sync project references to multiple runtime tsconfig files', async () => { + writeJson(tree, 'packages/a/tsconfig.lib.json', {}); + writeJson(tree, 'packages/b/tsconfig.cjs.json', {}); + writeJson(tree, 'packages/b/tsconfig.esm.json', {}); + + await syncGenerator(tree); + + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + ] + `); + }); + + it('should sync project references to different runtime tsconfig files', async () => { + writeJson(tree, 'packages/a/tsconfig.lib.json', {}); + writeJson(tree, 'packages/b/tsconfig.build.json', {}); + addProject('c', ['b'], ['tsconfig.cjs.json', 'tsconfig.esm.json']); + addProject('d', ['b', 'a'], ['tsconfig.runtime.json']); + addProject('e', ['c'], ['tsconfig.cjs.json', 'tsconfig.esm.json']); + addProject('f', ['c'], ['tsconfig.runtime.json']); + + await syncGenerator(tree); + + // b + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.build.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + ] + `); + // c + expect(readJson(tree, 'packages/c/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + ] + `); + // d + expect(readJson(tree, 'packages/d/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/d/tsconfig.runtime.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + ] + `); + // e + expect(readJson(tree, 'packages/e/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../c", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + { + "path": "../c/tsconfig.cjs.json", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + { + "path": "../c/tsconfig.esm.json", + }, + ] + `); + // f + expect(readJson(tree, 'packages/f/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../c", + }, + ] + `); + // in the case of "c", it will reference the first runtime tsconfig file it finds because there's no `packages/c/tsconfig.runtime.json` + expect(readJson(tree, 'packages/f/tsconfig.runtime.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + { + "path": "../b/tsconfig.build.json", + }, + { + "path": "../c/tsconfig.cjs.json", + }, + ] + `); + }); + + it.each` + runtimeTsConfigFileName + ${'tsconfig.app.json'} + ${'tsconfig.lib.json'} + ${'tsconfig.build.json'} + ${'tsconfig.cjs.json'} + ${'tsconfig.esm.json'} + ${'tsconfig.runtime.json'} + `( + 'should collect transitive dependencies and sync project references to $runtimeTsConfigFileName files', + async ({ runtimeTsConfigFileName }) => { + writeJson(tree, `packages/a/${runtimeTsConfigFileName}`, {}); + writeJson(tree, `packages/b/${runtimeTsConfigFileName}`, {}); + // c => b => a + // d => b => a + // => a + // e => d => b => a + addProject('c', ['b'], [runtimeTsConfigFileName]); + addProject('d', ['b', 'a'], [runtimeTsConfigFileName]); + addProject('e', ['d'], [runtimeTsConfigFileName]); + + await syncGenerator(tree); + + expect( + readJson(tree, 'packages/a/tsconfig.json').references + ).toBeUndefined(); + expect( + readJson(tree, `packages/a/${runtimeTsConfigFileName}`).references + ).toBeUndefined(); + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect( + readJson(tree, `packages/b/${runtimeTsConfigFileName}`).references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/${runtimeTsConfigFileName}", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect( + readJson(tree, `packages/c/${runtimeTsConfigFileName}`).references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/${runtimeTsConfigFileName}", + }, + { + "path": "../b/${runtimeTsConfigFileName}", + }, + ] + `); + expect(readJson(tree, 'packages/d/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect( + readJson(tree, `packages/d/${runtimeTsConfigFileName}`).references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/${runtimeTsConfigFileName}", + }, + { + "path": "../b/${runtimeTsConfigFileName}", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../d", + }, + ] + `); + expect( + readJson(tree, `packages/e/${runtimeTsConfigFileName}`).references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/${runtimeTsConfigFileName}", + }, + { + "path": "../b/${runtimeTsConfigFileName}", + }, + { + "path": "../d/${runtimeTsConfigFileName}", + }, + ] + `); + } + ); + + it('should not make changes to non-default runtime tsconfig files', async () => { + writeJson(tree, 'packages/a/tsconfig.lib.json', {}); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/a/tsconfig.spec.json', {}); + // default runtime tsconfig that should be updated + writeJson(tree, 'packages/b/tsconfig.lib.json', {}); + // non-default runtime tsconfig files that should not be updated + writeJson(tree, 'packages/b/tsconfig.custom.json', {}); + writeJson(tree, 'packages/b/tsconfig.spec.json', {}); + + await syncGenerator(tree); + + // assert that tsconfig.json and tsconfig.lib.json files have been updated + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.lib.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.lib.json", + }, + ] + `); + // assert that tsconfig.lib.json and tsconfig.spec.json files have not been updated + expect(readJson(tree, 'packages/b/tsconfig.custom.json')).toStrictEqual( + {} + ); + expect(readJson(tree, 'packages/b/tsconfig.spec.json')).toStrictEqual( + {} + ); + }); + }); + + describe('with custom sync generator options', () => { + it('should sync project references to configured tsconfig.custom.json files', async () => { + const nxJson = readNxJson(tree); + nxJson.sync = { + generatorOptions: { + '@nx/js:typescript-sync': { + runtimeTsConfigFileNames: ['tsconfig.custom.json'], + }, + }, + }; + updateNxJson(tree, nxJson); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/b/tsconfig.custom.json', {}); + + await syncGenerator(tree); + + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + }); + + it('should sync project references to multiple configured runtime tsconfig files', async () => { + const nxJson = readNxJson(tree); + nxJson.sync = { + generatorOptions: { + '@nx/js:typescript-sync': { + runtimeTsConfigFileNames: [ + 'tsconfig.custom.json', + 'tsconfig.custom-cjs.json', + 'tsconfig.custom-esm.json', + ], + }, + }, + }; + updateNxJson(tree, nxJson); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/b/tsconfig.custom-cjs.json', {}); + writeJson(tree, 'packages/b/tsconfig.custom-esm.json', {}); + + await syncGenerator(tree); + + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.custom-cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.custom-esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + }); + + it('should sync project references to different configured runtime tsconfig files', async () => { + const nxJson = readNxJson(tree); + nxJson.sync = { + generatorOptions: { + '@nx/js:typescript-sync': { + runtimeTsConfigFileNames: [ + 'tsconfig.custom.json', + 'tsconfig.custom-build.json', + 'tsconfig.custom-cjs.json', + 'tsconfig.custom-esm.json', + 'tsconfig.custom-runtime.json', + ], + }, + }, + }; + updateNxJson(tree, nxJson); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/b/tsconfig.custom-build.json', {}); + addProject( + 'c', + ['b'], + ['tsconfig.custom-cjs.json', 'tsconfig.custom-esm.json'] + ); + addProject('d', ['b', 'a'], ['tsconfig.custom-runtime.json']); + addProject( + 'e', + ['c'], + ['tsconfig.custom-cjs.json', 'tsconfig.custom-esm.json'] + ); + addProject('f', ['c'], ['tsconfig.custom-runtime.json']); + + await syncGenerator(tree); + + // b + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect( + readJson(tree, 'packages/b/tsconfig.custom-build.json').references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + // c + expect(readJson(tree, 'packages/c/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.custom-cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.custom-esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + ] + `); + // d + expect(readJson(tree, 'packages/d/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect( + readJson(tree, 'packages/d/tsconfig.custom-runtime.json').references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + ] + `); + // e + expect(readJson(tree, 'packages/e/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../c", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.custom-cjs.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + { + "path": "../c/tsconfig.custom-cjs.json", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.custom-esm.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + { + "path": "../c/tsconfig.custom-esm.json", + }, + ] + `); + // f + expect(readJson(tree, 'packages/f/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../c", + }, + ] + `); + // in the case of "c", it will reference the first runtime tsconfig file it finds because there's no `packages/c/tsconfig.runtime.json` + expect( + readJson(tree, 'packages/f/tsconfig.custom-runtime.json').references + ).toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom-build.json", + }, + { + "path": "../c/tsconfig.custom-cjs.json", + }, + ] + `); + }); + + it('should collect transitive dependencies and sync project references to configured tsconfig.custom.json files', async () => { + const nxJson = readNxJson(tree); + nxJson.sync = { + generatorOptions: { + '@nx/js:typescript-sync': { + runtimeTsConfigFileNames: ['tsconfig.custom.json'], + }, + }, + }; + updateNxJson(tree, nxJson); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/b/tsconfig.custom.json', {}); + // c => b => a + // d => b => a + // => a + // e => d => b => a + addProject('c', ['b'], ['tsconfig.custom.json']); + addProject('d', ['b', 'a'], ['tsconfig.custom.json']); + addProject('e', ['d'], ['tsconfig.custom.json']); + + await syncGenerator(tree); + + expect( + readJson(tree, 'packages/a/tsconfig.json').references + ).toBeUndefined(); + expect( + readJson(tree, 'packages/a/tsconfig.custom.json').references + ).toBeUndefined(); + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/c/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom.json", + }, + ] + `); + expect(readJson(tree, 'packages/d/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + ] + `); + expect(readJson(tree, 'packages/d/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom.json", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + { + "path": "../b", + }, + { + "path": "../d", + }, + ] + `); + expect(readJson(tree, 'packages/e/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + { + "path": "../b/tsconfig.custom.json", + }, + { + "path": "../d/tsconfig.custom.json", + }, + ] + `); + }); + + it('should not make changes to tsconfig files not configured as runtime', async () => { + const nxJson = readNxJson(tree); + nxJson.sync = { + generatorOptions: { + '@nx/js:typescript-sync': { + runtimeTsConfigFileNames: ['tsconfig.custom.json'], + }, + }, + }; + updateNxJson(tree, nxJson); + writeJson(tree, 'packages/a/tsconfig.custom.json', {}); + writeJson(tree, 'packages/a/tsconfig.lib.json', {}); + writeJson(tree, 'packages/a/tsconfig.spec.json', {}); + // non-default runtime tsconfig that should be updated because is in the configured list + writeJson(tree, 'packages/b/tsconfig.custom.json', {}); + // default runtime tsconfig that shouldn't be updated because is not in the configured list + writeJson(tree, 'packages/b/tsconfig.lib.json', {}); + writeJson(tree, 'packages/b/tsconfig.spec.json', {}); + + await syncGenerator(tree); + + // assert that tsconfig.json and tsconfig.custom.json files have been updated + expect(readJson(tree, 'packages/b/tsconfig.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a", + }, + ] + `); + expect(readJson(tree, 'packages/b/tsconfig.custom.json').references) + .toMatchInlineSnapshot(` + [ + { + "path": "../a/tsconfig.custom.json", + }, + ] + `); + // assert that tsconfig.lib.json and tsconfig.spec.json files have not been updated + expect(readJson(tree, 'packages/b/tsconfig.lib.json')).toStrictEqual( + {} + ); + expect(readJson(tree, 'packages/b/tsconfig.spec.json')).toStrictEqual( + {} + ); + }); + }); + }); +}); diff --git a/packages/js/src/generators/typescript-sync/typescript-sync.ts b/packages/js/src/generators/typescript-sync/typescript-sync.ts new file mode 100644 index 0000000000..7738df9899 --- /dev/null +++ b/packages/js/src/generators/typescript-sync/typescript-sync.ts @@ -0,0 +1,400 @@ +import { + createProjectGraphAsync, + formatFiles, + joinPathFragments, + logger, + readJson, + readNxJson, + writeJson, + type ExpandedPluginConfiguration, + type ProjectGraph, + type ProjectGraphProjectNode, + type Tree, +} from '@nx/devkit'; +import { dirname, normalize, relative } from 'node:path/posix'; +import type { SyncGeneratorResult } from 'nx/src/utils/sync-generators'; +import { + PLUGIN_NAME, + type TscPluginOptions, +} from '../../plugins/typescript/plugin'; + +interface Tsconfig { + references?: Array<{ path: string }>; + compilerOptions?: { + paths?: Record; + rootDir?: string; + outDir?: string; + }; +} + +const COMMON_RUNTIME_TS_CONFIG_FILE_NAMES = [ + 'tsconfig.app.json', + 'tsconfig.lib.json', + 'tsconfig.build.json', + 'tsconfig.cjs.json', + 'tsconfig.esm.json', + 'tsconfig.runtime.json', +]; + +export async function syncGenerator(tree: Tree): Promise { + // Ensure that the plugin has been wired up in nx.json + const nxJson = readNxJson(tree); + const tscPluginConfig: + | string + | ExpandedPluginConfiguration = nxJson.plugins.find( + (p) => { + if (typeof p === 'string') { + return p === PLUGIN_NAME; + } + return p.plugin === PLUGIN_NAME; + } + ); + if (!tscPluginConfig) { + throw new Error( + `The ${PLUGIN_NAME} plugin must be added to the "plugins" array in nx.json before syncing tsconfigs` + ); + } + + // Root tsconfig containing project references for the whole workspace + const rootTsconfigPath = 'tsconfig.json'; + if (!tree.exists(rootTsconfigPath)) { + throw new Error(`A "tsconfig.json" file must exist in the workspace root.`); + } + + const rootTsconfig = readJson(tree, rootTsconfigPath); + const projectGraph = await createProjectGraphAsync(); + const projectRoots = new Set(); + + const tsconfigProjectNodeValues = Object.values(projectGraph.nodes).filter( + (node) => { + projectRoots.add(node.data.root); + const projectTsconfigPath = joinPathFragments( + node.data.root, + 'tsconfig.json' + ); + return tree.exists(projectTsconfigPath); + } + ); + + // Track if any changes were made to the tsconfig files. We check the changes + // made by this generator to know if the TS config is out of sync with the + // project graph. Therefore, we don't format the files if there were no changes + // to avoid potential format-only changes that can lead to false positives. + let hasChanges = false; + + if (tsconfigProjectNodeValues.length > 0) { + const referencesSet = new Set(); + for (const ref of rootTsconfig.references ?? []) { + // reference path is relative to the tsconfig file + const resolvedRefPath = getTsConfigPathFromReferencePath( + tree, + rootTsconfigPath, + ref.path + ); + if (tree.exists(resolvedRefPath)) { + // we only keep the references that still exist + referencesSet.add(normalizeReferencePath(ref.path)); + } else { + hasChanges = true; + } + } + + for (const node of tsconfigProjectNodeValues) { + const normalizedPath = normalizeReferencePath(node.data.root); + // Skip the root tsconfig itself + if (node.data.root !== '.' && !referencesSet.has(normalizedPath)) { + referencesSet.add(normalizedPath); + hasChanges = true; + } + } + + if (hasChanges) { + rootTsconfig.references = Array.from(referencesSet).map((ref) => ({ + path: `./${ref}`, + })); + writeJson(tree, rootTsconfigPath, rootTsconfig); + } + } + + const runtimeTsConfigFileNames = + (nxJson.sync?.generatorOptions?.['@nx/js:typescript-sync'] + ?.runtimeTsConfigFileNames as string[]) ?? + COMMON_RUNTIME_TS_CONFIG_FILE_NAMES; + + const collectedDependencies = new Map(); + for (const [name, data] of Object.entries(projectGraph.dependencies)) { + if ( + !projectGraph.nodes[name] || + projectGraph.nodes[name].data.root === '.' || + !data.length + ) { + continue; + } + + // Get the source project nodes for the source and target + const sourceProjectNode = projectGraph.nodes[name]; + + // Find the relevant tsconfig file for the source project + const sourceProjectTsconfigPath = joinPathFragments( + sourceProjectNode.data.root, + 'tsconfig.json' + ); + if (!tree.exists(sourceProjectTsconfigPath)) { + if (process.env.NX_VERBOSE_LOGGING === 'true') { + logger.warn( + `Skipping project "${name}" as there is no tsconfig.json file found in the project root "${sourceProjectNode.data.root}".` + ); + } + continue; + } + + // Collect the dependencies of the source project + const dependencies = collectProjectDependencies( + tree, + name, + projectGraph, + collectedDependencies + ); + if (!dependencies.length) { + continue; + } + + for (const runtimeTsConfigFileName of runtimeTsConfigFileNames) { + const runtimeTsConfigPath = joinPathFragments( + sourceProjectNode.data.root, + runtimeTsConfigFileName + ); + if (!tree.exists(runtimeTsConfigPath)) { + continue; + } + + // Update project references for the runtime tsconfig + hasChanges = + updateTsConfigReferences( + tree, + runtimeTsConfigPath, + dependencies, + sourceProjectNode.data.root, + projectRoots, + runtimeTsConfigFileName, + runtimeTsConfigFileNames + ) || hasChanges; + } + + // Update project references for the tsconfig.json file + hasChanges = + updateTsConfigReferences( + tree, + sourceProjectTsconfigPath, + dependencies, + sourceProjectNode.data.root, + projectRoots + ) || hasChanges; + } + + if (hasChanges) { + await formatFiles(tree); + + return { + outOfSyncMessage: + 'Based on the workspace project graph, some TypeScript configuration files are missing project references to the projects they depend on.', + }; + } +} + +export default syncGenerator; + +function updateTsConfigReferences( + tree: Tree, + tsConfigPath: string, + dependencies: ProjectGraphProjectNode[], + projectRoot: string, + projectRoots: Set, + runtimeTsConfigFileName?: string, + possibleRuntimeTsConfigFileNames?: string[] +): boolean { + const tsConfig = readJson(tree, tsConfigPath); + // We have at least one dependency so we can safely set it to an empty array if not already set + const references = []; + const originalReferencesSet = new Set(); + const newReferencesSet = new Set(); + for (const ref of tsConfig.references ?? []) { + const normalizedPath = normalizeReferencePath(ref.path); + originalReferencesSet.add(normalizedPath); + // reference path is relative to the tsconfig file + const resolvedRefPath = getTsConfigPathFromReferencePath( + tree, + tsConfigPath, + ref.path + ); + if ( + isInternalProjectReference( + tree, + resolvedRefPath, + projectRoot, + projectRoots + ) + ) { + // we keep all internal references + references.push(ref); + newReferencesSet.add(normalizedPath); + } + } + + let hasChanges = false; + for (const dep of dependencies) { + // Ensure the project reference for the target is set + let referencePath = dep.data.root; + if (runtimeTsConfigFileName) { + const runtimeTsConfigPath = joinPathFragments( + dep.data.root, + runtimeTsConfigFileName + ); + if (tree.exists(runtimeTsConfigPath)) { + referencePath = runtimeTsConfigPath; + } else { + // Check for other possible runtime tsconfig file names + // TODO(leo): should we check if there are more than one runtime tsconfig files and throw an error? + for (const possibleRuntimeTsConfigFileName of possibleRuntimeTsConfigFileNames ?? + []) { + const possibleRuntimeTsConfigPath = joinPathFragments( + dep.data.root, + possibleRuntimeTsConfigFileName + ); + if (tree.exists(possibleRuntimeTsConfigPath)) { + referencePath = possibleRuntimeTsConfigPath; + break; + } + } + } + } + const relativePathToTargetRoot = relative(projectRoot, referencePath); + if (!newReferencesSet.has(relativePathToTargetRoot)) { + newReferencesSet.add(relativePathToTargetRoot); + // Make sure we unshift rather than push so that dependencies are built in the right order by TypeScript when it is run directly from the root of the workspace + references.unshift({ path: relativePathToTargetRoot }); + } + if (!originalReferencesSet.has(relativePathToTargetRoot)) { + hasChanges = true; + } + } + + hasChanges ||= newReferencesSet.size !== originalReferencesSet.size; + + if (hasChanges) { + tsConfig.references = references; + writeJson(tree, tsConfigPath, tsConfig); + } + + return hasChanges; +} + +// TODO(leo): follow up with the TypeScript team to confirm if we really need +// to reference transitive dependencies. +// Collect the dependencies of a project recursively sorted from root to leaf +function collectProjectDependencies( + tree: Tree, + projectName: string, + projectGraph: ProjectGraph, + collectedDependencies: Map +): ProjectGraphProjectNode[] { + if (collectedDependencies.has(projectName)) { + // We've already collected the dependencies for this project + return collectedDependencies.get(projectName); + } + + collectedDependencies.set(projectName, []); + + for (const dep of projectGraph.dependencies[projectName]) { + const targetProjectNode = projectGraph.nodes[dep.target]; + if (!targetProjectNode) { + // It's an npm dependency + continue; + } + + // Add the target project node to the list of dependencies for the current project + if ( + !collectedDependencies + .get(projectName) + .some((d) => d.name === targetProjectNode.name) + ) { + collectedDependencies.get(projectName).push(targetProjectNode); + } + + if (process.env.NX_DISABLE_TS_SYNC_TRANSITIVE_DEPENDENCIES === 'true') { + continue; + } + + // Recursively get the dependencies of the target project + const transitiveDependencies = collectProjectDependencies( + tree, + dep.target, + projectGraph, + collectedDependencies + ); + for (const transitiveDep of transitiveDependencies) { + if ( + !collectedDependencies + .get(projectName) + .some((d) => d.name === transitiveDep.name) + ) { + collectedDependencies.get(projectName).push(transitiveDep); + } + } + } + + return collectedDependencies.get(projectName); +} + +// Normalize the paths to strip leading `./` and trailing `/tsconfig.json` +function normalizeReferencePath(path: string): string { + return normalize(path) + .replace(/\/tsconfig.json$/, '') + .replace(/^\.\//, ''); +} + +function isInternalProjectReference( + tree: Tree, + refTsConfigPath: string, + projectRoot: string, + projectRoots: Set +): boolean { + let currentPath = getTsConfigDirName(tree, refTsConfigPath); + + if (relative(projectRoot, currentPath).startsWith('..')) { + // it's outside of the project root, so it's an external project reference + return false; + } + + while (currentPath !== projectRoot) { + if (projectRoots.has(currentPath)) { + // it's inside a nested project root, so it's and external project reference + return false; + } + currentPath = dirname(currentPath); + } + + // it's inside the project root, so it's an internal project reference + return true; +} + +function getTsConfigDirName(tree: Tree, tsConfigPath: string): string { + return tree.isFile(tsConfigPath) + ? dirname(tsConfigPath) + : normalize(tsConfigPath); +} + +function getTsConfigPathFromReferencePath( + tree: Tree, + ownerTsConfigPath: string, + referencePath: string +): string { + const resolvedRefPath = joinPathFragments( + dirname(ownerTsConfigPath), + referencePath + ); + + return tree.isFile(resolvedRefPath) + ? resolvedRefPath + : joinPathFragments(resolvedRefPath, 'tsconfig.json'); +} diff --git a/packages/js/src/plugins/typescript/plugin.spec.ts b/packages/js/src/plugins/typescript/plugin.spec.ts index e577dbbdd7..f5c0a9c354 100644 --- a/packages/js/src/plugins/typescript/plugin.spec.ts +++ b/packages/js/src/plugins/typescript/plugin.spec.ts @@ -81,6 +81,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{projectRoot}/tsconfig.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -137,6 +140,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -175,6 +181,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -216,6 +225,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -300,6 +312,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -343,6 +358,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -388,6 +406,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -433,6 +454,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -486,6 +510,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -564,6 +591,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -590,6 +620,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib/nested-project", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -830,6 +863,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{projectRoot}/tsconfig.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -880,6 +916,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/libs/my-lib/index.d.ts.map", "{workspaceRoot}/dist/libs/my-lib/index.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -924,6 +963,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "outputs": [ "{workspaceRoot}/dist/libs/my-lib", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -979,6 +1021,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{projectRoot}/tsconfig.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1067,6 +1112,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/out-tsc/libs/my-lib/specs", "{workspaceRoot}/dist/out-tsc/libs/my-lib/cypress", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1094,6 +1142,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "outputs": [ "{workspaceRoot}/dist/out-tsc/libs/my-lib/nested-project", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1146,6 +1197,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/libs/my-lib/index.d.ts.map", "{workspaceRoot}/dist/libs/my-lib/my-lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1203,6 +1257,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{workspaceRoot}/dist/libs/my-lib/my-lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1351,6 +1408,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1396,6 +1456,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1445,6 +1508,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1494,6 +1560,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1544,6 +1613,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1602,6 +1674,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1665,6 +1740,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "cwd": "libs/my-lib", }, "outputs": [], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1902,6 +1980,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{projectRoot}/tsconfig.lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -1957,6 +2038,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/libs/my-lib/index.d.ts.map", "{workspaceRoot}/dist/libs/my-lib/index.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -2006,6 +2090,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "outputs": [ "{workspaceRoot}/dist/libs/my-lib", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -2066,6 +2153,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{projectRoot}/tsconfig.lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -2127,6 +2217,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/libs/my-lib/lib.tsbuildinfo", "{workspaceRoot}/dist/libs/my-lib/other", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -2184,6 +2277,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{workspaceRoot}/dist/libs/my-lib/index.d.ts.map", "{workspaceRoot}/dist/libs/my-lib/my-lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, @@ -2246,6 +2342,9 @@ describe(`Plugin: ${PLUGIN_NAME}`, () => { "{projectRoot}/**/*.d.mts.map", "{workspaceRoot}/dist/libs/my-lib/my-lib.tsbuildinfo", ], + "syncGenerators": [ + "@nx/js:typescript-sync", + ], }, }, }, diff --git a/packages/js/src/plugins/typescript/plugin.ts b/packages/js/src/plugins/typescript/plugin.ts index a839cca3ac..dfc99de9ee 100644 --- a/packages/js/src/plugins/typescript/plugin.ts +++ b/packages/js/src/plugins/typescript/plugin.ts @@ -228,6 +228,7 @@ function buildTscTargets( context.workspaceRoot, projectRoot ), + syncGenerators: ['@nx/js:typescript-sync'], }; } } @@ -261,6 +262,7 @@ function buildTscTargets( context.workspaceRoot, projectRoot ), + syncGenerators: ['@nx/js:typescript-sync'], }; } diff --git a/packages/nx/schemas/nx-schema.json b/packages/nx/schemas/nx-schema.json index 9dc2c51a8b..c8b58942d4 100644 --- a/packages/nx/schemas/nx-schema.json +++ b/packages/nx/schemas/nx-schema.json @@ -246,6 +246,31 @@ "type": "string" } } + }, + "sync": { + "type": "object", + "description": "Configuration for the `nx sync` command", + "properties": { + "globalGenerators": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspace-wide sync generators to be run (not attached to targets)" + }, + "generatorOptions": { + "type": "object", + "description": "Options for the sync generators.", + "additionalProperties": { + "type": "object" + } + }, + "applyChanges": { + "type": "boolean", + "description": "Whether to automatically apply sync generator changes when running tasks. If not set, the user will be prompted. If set to `true`, the user will not be prompted and the changes will be applied. If set to `false`, the user will not be prompted and the changes will not be applied." + } + }, + "additionalProperties": false } }, "definitions": { diff --git a/packages/nx/schemas/project-schema.json b/packages/nx/schemas/project-schema.json index 2e62de0559..1129b364ef 100644 --- a/packages/nx/schemas/project-schema.json +++ b/packages/nx/schemas/project-schema.json @@ -153,6 +153,13 @@ } }, "additionalProperties": true + }, + "syncGenerators": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of generators to run before the target to ensure the workspace is up to date" } } } diff --git a/packages/nx/src/adapter/compat.ts b/packages/nx/src/adapter/compat.ts index 4eeade8652..1045b4d3c4 100644 --- a/packages/nx/src/adapter/compat.ts +++ b/packages/nx/src/adapter/compat.ts @@ -79,6 +79,7 @@ export const allowedWorkspaceExtensions = [ 'useDaemonProcess', 'useInferencePlugins', 'neverConnectToCloud', + 'sync', ] as const; if (!patched) { diff --git a/packages/nx/src/command-line/nx-commands.ts b/packages/nx/src/command-line/nx-commands.ts index 6ba7000c61..4a6020d049 100644 --- a/packages/nx/src/command-line/nx-commands.ts +++ b/packages/nx/src/command-line/nx-commands.ts @@ -40,6 +40,7 @@ import { yargsPrintAffectedCommand, yargsAffectedGraphCommand, } from './deprecated/command-objects'; +import { yargsSyncCheckCommand, yargsSyncCommand } from './sync/command-object'; // Ensure that the output takes up the available width of the terminal. yargs.wrap(yargs.terminalWidth()); @@ -86,6 +87,8 @@ export const commandsObject = yargs .command(yargsRunCommand) .command(yargsRunManyCommand) .command(yargsShowCommand) + .command(yargsSyncCommand) + .command(yargsSyncCheckCommand) .command(yargsViewLogsCommand) .command(yargsWatchCommand) .command(yargsNxInfixCommand) diff --git a/packages/nx/src/command-line/sync/command-object.ts b/packages/nx/src/command-line/sync/command-object.ts new file mode 100644 index 0000000000..238ad559b5 --- /dev/null +++ b/packages/nx/src/command-line/sync/command-object.ts @@ -0,0 +1,43 @@ +import type { CommandModule } from 'yargs'; + +export interface SyncArgs { + verbose?: boolean; +} + +export const yargsSyncCommand: CommandModule< + Record, + SyncArgs +> = { + command: 'sync', + describe: false, + builder: (yargs) => + yargs.option('verbose', { + type: 'boolean', + description: + 'Prints additional information about the commands (e.g., stack traces)', + }), + handler: async (args) => { + process.exit(await import('./sync').then((m) => m.syncHandler(args))); + }, +}; + +export const yargsSyncCheckCommand: CommandModule< + Record, + SyncArgs +> = { + command: 'sync:check', + describe: false, + builder: (yargs) => + yargs.option('verbose', { + type: 'boolean', + description: + 'Prints additional information about the commands (e.g., stack traces)', + }), + handler: async (args) => { + process.exit( + await import('./sync').then((m) => + m.syncHandler({ ...args, check: true }) + ) + ); + }, +}; diff --git a/packages/nx/src/command-line/sync/sync.ts b/packages/nx/src/command-line/sync/sync.ts new file mode 100644 index 0000000000..4ca84704a7 --- /dev/null +++ b/packages/nx/src/command-line/sync/sync.ts @@ -0,0 +1,46 @@ +import { createProjectGraphAsync } from '../../project-graph/project-graph'; +import { output } from '../../utils/output'; +import { handleErrors } from '../../utils/params'; +import { + collectAllRegisteredSyncGenerators, + flushSyncGeneratorChanges, + getSyncGeneratorChanges, + syncGeneratorResultsToMessageLines, +} from '../../utils/sync-generators'; +import type { SyncArgs } from './command-object'; + +interface SyncOptions extends SyncArgs { + check?: boolean; +} + +export function syncHandler(options: SyncOptions): Promise { + if (options.verbose) { + process.env.NX_VERBOSE_LOGGING = 'true'; + } + const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true'; + + return handleErrors(isVerbose, async () => { + const projectGraph = await createProjectGraphAsync(); + const syncGenerators = await collectAllRegisteredSyncGenerators( + projectGraph + ); + const results = await getSyncGeneratorChanges(syncGenerators); + + if (!results.length) { + return 0; + } + + if (options.check) { + output.error({ + title: `The workspace is out of sync`, + bodyLines: syncGeneratorResultsToMessageLines(results), + }); + + return 1; + } + + await flushSyncGeneratorChanges(results); + + return 0; + }); +} diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 0bb9764555..0cd1afeeea 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -298,6 +298,28 @@ export interface NxReleaseConfiguration { versionPlans?: boolean; } +export interface NxSyncConfiguration { + /** + * List of workspace-wide sync generators to be run (not attached to targets). + */ + globalGenerators?: string[]; + + /** + * Options for the sync generators. + */ + generatorOptions?: { + [generatorName: string]: Record; + }; + + /** + * Whether to automatically apply sync generator changes when running tasks. + * If not set, the user will be prompted. + * If set to `true`, the user will not be prompted and the changes will be applied. + * If set to `false`, the user will not be prompted and the changes will not be applied. + */ + applyChanges?: boolean; +} + /** * Nx.json configuration * @@ -456,6 +478,11 @@ export interface NxJsonConfiguration { * Set this to false to disable connection to Nx Cloud */ neverConnectToCloud?: boolean; + + /** + * Configuration for the `nx sync` command. + */ + sync?: NxSyncConfiguration; } export type PluginConfiguration = string | ExpandedPluginConfiguration; diff --git a/packages/nx/src/config/workspace-json-project-json.ts b/packages/nx/src/config/workspace-json-project-json.ts index eff58d64c4..f10464a995 100644 --- a/packages/nx/src/config/workspace-json-project-json.ts +++ b/packages/nx/src/config/workspace-json-project-json.ts @@ -236,4 +236,10 @@ export interface TargetConfiguration { * Default is true */ parallelism?: boolean; + + /** + * List of generators to run before the target to ensure the workspace + * is up to date. + */ + syncGenerators?: string[]; } diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index 2c7dcd7bc9..45e68d67bd 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -49,6 +49,23 @@ import { HandleWriteTaskRunsToHistoryMessage, } from '../message-types/task-history'; import { FORCE_SHUTDOWN } from '../message-types/force-shutdown'; +import { + GET_SYNC_GENERATOR_CHANGES, + type HandleGetSyncGeneratorChangesMessage, +} from '../message-types/get-sync-generator-changes'; +import type { SyncGeneratorChangesResult } from '../../utils/sync-generators'; +import { + GET_REGISTERED_SYNC_GENERATORS, + type HandleGetRegisteredSyncGeneratorsMessage, +} from '../message-types/get-registered-sync-generators'; +import { + UPDATE_WORKSPACE_CONTEXT, + type HandleUpdateWorkspaceContextMessage, +} from '../message-types/update-workspace-context'; +import { + FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK, + type HandleFlushSyncGeneratorChangesToDiskMessage, +} from '../message-types/flush-sync-generator-changes-to-disk'; const DAEMON_ENV_SETTINGS = { NX_PROJECT_GLOB_CACHE: 'false', @@ -343,6 +360,45 @@ export class DaemonClient { return this.sendMessageToDaemon(message); } + getSyncGeneratorChanges( + generators: string[] + ): Promise { + const message: HandleGetSyncGeneratorChangesMessage = { + type: GET_SYNC_GENERATOR_CHANGES, + generators, + }; + return this.sendToDaemonViaQueue(message); + } + + flushSyncGeneratorChangesToDisk(generators: string[]): Promise { + const message: HandleFlushSyncGeneratorChangesToDiskMessage = { + type: FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK, + generators, + }; + return this.sendToDaemonViaQueue(message); + } + + getRegisteredSyncGenerators(): Promise { + const message: HandleGetRegisteredSyncGeneratorsMessage = { + type: GET_REGISTERED_SYNC_GENERATORS, + }; + return this.sendToDaemonViaQueue(message); + } + + updateWorkspaceContext( + createdFiles: string[], + updatedFiles: string[], + deletedFiles: string[] + ): Promise { + const message: HandleUpdateWorkspaceContextMessage = { + type: UPDATE_WORKSPACE_CONTEXT, + createdFiles, + updatedFiles, + deletedFiles, + }; + return this.sendToDaemonViaQueue(message); + } + async isServerAvailable(): Promise { return new Promise((resolve) => { try { diff --git a/packages/nx/src/daemon/message-types/flush-sync-generator-changes-to-disk.ts b/packages/nx/src/daemon/message-types/flush-sync-generator-changes-to-disk.ts new file mode 100644 index 0000000000..57350094b8 --- /dev/null +++ b/packages/nx/src/daemon/message-types/flush-sync-generator-changes-to-disk.ts @@ -0,0 +1,18 @@ +export const FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK = + 'CLEAR_CACHED_SYNC_GENERATOR_CHANGES' as const; + +export type HandleFlushSyncGeneratorChangesToDiskMessage = { + type: typeof FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK; + generators: string[]; +}; + +export function isHandleFlushSyncGeneratorChangesToDiskMessage( + message: unknown +): message is HandleFlushSyncGeneratorChangesToDiskMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK + ); +} diff --git a/packages/nx/src/daemon/message-types/get-registered-sync-generators.ts b/packages/nx/src/daemon/message-types/get-registered-sync-generators.ts new file mode 100644 index 0000000000..026ed6a32b --- /dev/null +++ b/packages/nx/src/daemon/message-types/get-registered-sync-generators.ts @@ -0,0 +1,17 @@ +export const GET_REGISTERED_SYNC_GENERATORS = + 'GET_REGISTERED_SYNC_GENERATORS' as const; + +export type HandleGetRegisteredSyncGeneratorsMessage = { + type: typeof GET_REGISTERED_SYNC_GENERATORS; +}; + +export function isHandleGetRegisteredSyncGeneratorsMessage( + message: unknown +): message is HandleGetRegisteredSyncGeneratorsMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === GET_REGISTERED_SYNC_GENERATORS + ); +} diff --git a/packages/nx/src/daemon/message-types/get-sync-generator-changes.ts b/packages/nx/src/daemon/message-types/get-sync-generator-changes.ts new file mode 100644 index 0000000000..2dfe05edac --- /dev/null +++ b/packages/nx/src/daemon/message-types/get-sync-generator-changes.ts @@ -0,0 +1,17 @@ +export const GET_SYNC_GENERATOR_CHANGES = 'GET_SYNC_GENERATOR_CHANGES' as const; + +export type HandleGetSyncGeneratorChangesMessage = { + type: typeof GET_SYNC_GENERATOR_CHANGES; + generators: string[]; +}; + +export function isHandleGetSyncGeneratorChangesMessage( + message: unknown +): message is HandleGetSyncGeneratorChangesMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === GET_SYNC_GENERATOR_CHANGES + ); +} diff --git a/packages/nx/src/daemon/message-types/update-context-files.ts b/packages/nx/src/daemon/message-types/update-context-files.ts deleted file mode 100644 index 42eecea940..0000000000 --- a/packages/nx/src/daemon/message-types/update-context-files.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const GLOB = 'GLOB' as const; - -export type HandleUpdateContextMessage = { - type: typeof GLOB; - updatedFiles: string[]; - deletedFiles: string[]; -}; - -export function isHandleUpdateContextMessage( - message: unknown -): message is HandleUpdateContextMessage { - return ( - typeof message === 'object' && - message !== null && - 'type' in message && - message['type'] === GLOB - ); -} diff --git a/packages/nx/src/daemon/message-types/update-workspace-context.ts b/packages/nx/src/daemon/message-types/update-workspace-context.ts new file mode 100644 index 0000000000..d021b8340c --- /dev/null +++ b/packages/nx/src/daemon/message-types/update-workspace-context.ts @@ -0,0 +1,19 @@ +export const UPDATE_WORKSPACE_CONTEXT = 'UPDATE_WORKSPACE_CONTEXT' as const; + +export type HandleUpdateWorkspaceContextMessage = { + type: typeof UPDATE_WORKSPACE_CONTEXT; + createdFiles: string[]; + updatedFiles: string[]; + deletedFiles: string[]; +}; + +export function isHandleUpdateWorkspaceContextMessage( + message: unknown +): message is HandleUpdateWorkspaceContextMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + message['type'] === UPDATE_WORKSPACE_CONTEXT + ); +} diff --git a/packages/nx/src/daemon/server/handle-flush-sync-generator-changes-to-disk.ts b/packages/nx/src/daemon/server/handle-flush-sync-generator-changes-to-disk.ts new file mode 100644 index 0000000000..f6367c390f --- /dev/null +++ b/packages/nx/src/daemon/server/handle-flush-sync-generator-changes-to-disk.ts @@ -0,0 +1,13 @@ +import type { HandlerResult } from './server'; +import { flushSyncGeneratorChangesToDisk } from './sync-generators'; + +export async function handleFlushSyncGeneratorChangesToDisk( + generators: string[] +): Promise { + await flushSyncGeneratorChangesToDisk(generators); + + return { + response: '{}', + description: 'handleFlushSyncGeneratorChangesToDisk', + }; +} diff --git a/packages/nx/src/daemon/server/handle-get-registered-sync-generators.ts b/packages/nx/src/daemon/server/handle-get-registered-sync-generators.ts new file mode 100644 index 0000000000..f209e5e130 --- /dev/null +++ b/packages/nx/src/daemon/server/handle-get-registered-sync-generators.ts @@ -0,0 +1,11 @@ +import type { HandlerResult } from './server'; +import { getCachedRegisteredSyncGenerators } from './sync-generators'; + +export async function handleGetRegisteredSyncGenerators(): Promise { + const syncGenerators = await getCachedRegisteredSyncGenerators(); + + return { + response: JSON.stringify(syncGenerators), + description: 'handleGetSyncGeneratorChanges', + }; +} diff --git a/packages/nx/src/daemon/server/handle-get-sync-generator-changes.ts b/packages/nx/src/daemon/server/handle-get-sync-generator-changes.ts new file mode 100644 index 0000000000..ad28c98087 --- /dev/null +++ b/packages/nx/src/daemon/server/handle-get-sync-generator-changes.ts @@ -0,0 +1,20 @@ +import type { HandlerResult } from './server'; +import { getCachedSyncGeneratorChanges } from './sync-generators'; + +export async function handleGetSyncGeneratorChanges( + generators: string[] +): Promise { + const changes = await getCachedSyncGeneratorChanges(generators); + + // strip out the content of the changes and any potential callback + const result = changes.map((change) => ({ + generatorName: change.generatorName, + changes: change.changes.map((c) => ({ ...c, content: null })), + outOfSyncMessage: change.outOfSyncMessage, + })); + + return { + response: JSON.stringify(result), + description: 'handleGetSyncGeneratorChanges', + }; +} diff --git a/packages/nx/src/daemon/server/handle-update-workspace-context.ts b/packages/nx/src/daemon/server/handle-update-workspace-context.ts new file mode 100644 index 0000000000..4181d6bdb9 --- /dev/null +++ b/packages/nx/src/daemon/server/handle-update-workspace-context.ts @@ -0,0 +1,15 @@ +import { addUpdatedAndDeletedFiles } from './project-graph-incremental-recomputation'; +import type { HandlerResult } from './server'; + +export async function handleUpdateWorkspaceContext( + createdFiles: string[], + updatedFiles: string[], + deletedFiles: string[] +): Promise { + addUpdatedAndDeletedFiles(createdFiles, updatedFiles, deletedFiles); + + return { + response: '{}', + description: 'handleUpdateContextFiles', + }; +} diff --git a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts index 61120a66e6..7e839e3c60 100644 --- a/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts +++ b/packages/nx/src/daemon/server/project-graph-incremental-recomputation.ts @@ -62,6 +62,9 @@ export let currentProjectGraph: ProjectGraph | undefined; const collectedUpdatedFiles = new Set(); const collectedDeletedFiles = new Set(); +const projectGraphRecomputationListeners = new Set< + (projectGraph: ProjectGraph) => void +>(); let storedWorkspaceConfigHash: string | undefined; let waitPeriod = 100; let scheduledTimeoutId; @@ -69,8 +72,10 @@ let knownExternalNodes: Record = {}; export async function getCachedSerializedProjectGraphPromise(): Promise { try { + let wasScheduled = false; // recomputing it now on demand. we can ignore the scheduled timeout if (scheduledTimeoutId) { + wasScheduled = true; clearTimeout(scheduledTimeoutId); scheduledTimeoutId = undefined; } @@ -88,7 +93,13 @@ export async function getCachedSerializedProjectGraphPromise(): Promise 0) { notifyFileWatcherSockets(createdFiles, null, null); } + + notifyProjectGraphRecomputationListeners(projectGraph); }, waitPeriod); } } +export function registerProjectGraphRecomputationListener( + listener: (projectGraph: ProjectGraph) => void +) { + projectGraphRecomputationListeners.add(listener); +} + function computeWorkspaceConfigHash( projectsConfigurations: Record ) { @@ -413,3 +432,9 @@ async function resetInternalStateIfNxDepsMissing() { await resetInternalState(); } } + +function notifyProjectGraphRecomputationListeners(projectGraph: ProjectGraph) { + for (const listener of projectGraphRecomputationListeners) { + listener(projectGraph); + } +} diff --git a/packages/nx/src/daemon/server/server.ts b/packages/nx/src/daemon/server/server.ts index 4c2b6337a9..fcf83618a7 100644 --- a/packages/nx/src/daemon/server/server.ts +++ b/packages/nx/src/daemon/server/server.ts @@ -33,7 +33,10 @@ import { disableOutputsTracking, processFileChangesInOutputs, } from './outputs-tracking'; -import { addUpdatedAndDeletedFiles } from './project-graph-incremental-recomputation'; +import { + addUpdatedAndDeletedFiles, + registerProjectGraphRecomputationListener, +} from './project-graph-incremental-recomputation'; import { getOutputWatcherInstance, getWatcherInstance, @@ -78,6 +81,27 @@ import { handleGetTaskHistoryForHashes } from './handle-get-task-history'; import { handleWriteTaskRunsToHistory } from './handle-write-task-runs-to-history'; import { isHandleForceShutdownMessage } from '../message-types/force-shutdown'; import { handleForceShutdown } from './handle-force-shutdown'; +import { + GET_SYNC_GENERATOR_CHANGES, + isHandleGetSyncGeneratorChangesMessage, +} from '../message-types/get-sync-generator-changes'; +import { handleGetSyncGeneratorChanges } from './handle-get-sync-generator-changes'; +import { collectAndScheduleSyncGenerators } from './sync-generators'; +import { + GET_REGISTERED_SYNC_GENERATORS, + isHandleGetRegisteredSyncGeneratorsMessage, +} from '../message-types/get-registered-sync-generators'; +import { handleGetRegisteredSyncGenerators } from './handle-get-registered-sync-generators'; +import { + UPDATE_WORKSPACE_CONTEXT, + isHandleUpdateWorkspaceContextMessage, +} from '../message-types/update-workspace-context'; +import { handleUpdateWorkspaceContext } from './handle-update-workspace-context'; +import { + FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK, + isHandleFlushSyncGeneratorChangesToDiskMessage, +} from '../message-types/flush-sync-generator-changes-to-disk'; +import { handleFlushSyncGeneratorChangesToDisk } from './handle-flush-sync-generator-changes-to-disk'; let performanceObserver: PerformanceObserver | undefined; let workspaceWatcherError: Error | undefined; @@ -225,6 +249,26 @@ async function handleMessage(socket, data: string) { await handleResult(socket, 'FORCE_SHUTDOWN', () => handleForceShutdown(server) ); + } else if (isHandleGetSyncGeneratorChangesMessage(payload)) { + await handleResult(socket, GET_SYNC_GENERATOR_CHANGES, () => + handleGetSyncGeneratorChanges(payload.generators) + ); + } else if (isHandleFlushSyncGeneratorChangesToDiskMessage(payload)) { + await handleResult(socket, FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK, () => + handleFlushSyncGeneratorChangesToDisk(payload.generators) + ); + } else if (isHandleGetRegisteredSyncGeneratorsMessage(payload)) { + await handleResult(socket, GET_REGISTERED_SYNC_GENERATORS, () => + handleGetRegisteredSyncGenerators() + ); + } else if (isHandleUpdateWorkspaceContextMessage(payload)) { + await handleResult(socket, UPDATE_WORKSPACE_CONTEXT, () => + handleUpdateWorkspaceContext( + payload.createdFiles, + payload.updatedFiles, + payload.deletedFiles + ) + ); } else { await respondWithErrorAndExit( socket, @@ -482,6 +526,13 @@ export async function startServer(): Promise { ); } + // listen for project graph recomputation events to collect and schedule sync generators + registerProjectGraphRecomputationListener( + collectAndScheduleSyncGenerators + ); + // trigger an initial project graph recomputation + addUpdatedAndDeletedFiles([], [], []); + return resolve(server); } catch (err) { await handleWorkspaceChanges(err, []); diff --git a/packages/nx/src/daemon/server/sync-generators.ts b/packages/nx/src/daemon/server/sync-generators.ts new file mode 100644 index 0000000000..0f17fb2149 --- /dev/null +++ b/packages/nx/src/daemon/server/sync-generators.ts @@ -0,0 +1,273 @@ +import { readNxJson } from '../../config/nx-json'; +import type { ProjectGraph } from '../../config/project-graph'; +import type { ProjectConfiguration } from '../../config/workspace-json-project-json'; +import { FsTree } from '../../generators/tree'; +import { hashArray } from '../../hasher/file-hasher'; +import { readProjectsConfigurationFromProjectGraph } from '../../project-graph/project-graph'; +import { + collectRegisteredGlobalSyncGenerators, + collectRegisteredTaskSyncGenerators, + flushSyncGeneratorChanges, + runSyncGenerator, + type SyncGeneratorChangesResult, +} from '../../utils/sync-generators'; +import { workspaceRoot } from '../../utils/workspace-root'; +import { serverLogger } from './logger'; +import { getCachedSerializedProjectGraphPromise } from './project-graph-incremental-recomputation'; + +const syncGeneratorsCacheResultPromises = new Map< + string, + Promise +>(); +let registeredTaskSyncGenerators = new Set(); +let registeredGlobalSyncGenerators = new Set(); +const scheduledGenerators = new Set(); + +let waitPeriod = 100; +let registeredSyncGenerators: Set | undefined; +let scheduledTimeoutId: NodeJS.Timeout | undefined; +let storedProjectGraphHash: string | undefined; +let storedNxJsonHash: string | undefined; + +const log = (...messageParts: unknown[]) => { + serverLogger.log('[SYNC]:', ...messageParts); +}; + +// TODO(leo): check conflicts and reuse the Tree where possible +export async function getCachedSyncGeneratorChanges( + generators: string[] +): Promise { + try { + log('get sync generators changes on demand', generators); + // this is invoked imperatively, so we clear any scheduled run + if (scheduledTimeoutId) { + log('clearing scheduled run'); + clearTimeout(scheduledTimeoutId); + scheduledTimeoutId = undefined; + } + + // reset the wait time + waitPeriod = 100; + + let projects: Record | null; + let errored = false; + const getProjectsConfigurations = async () => { + if (projects || errored) { + return projects; + } + + const { projectGraph, error } = + await getCachedSerializedProjectGraphPromise(); + projects = projectGraph + ? readProjectsConfigurationFromProjectGraph(projectGraph).projects + : null; + errored = error !== undefined; + + return projects; + }; + + return ( + await Promise.all( + generators.map(async (generator) => { + if ( + scheduledGenerators.has(generator) || + !syncGeneratorsCacheResultPromises.has(generator) + ) { + // it's scheduled to run (there are pending changes to process) or + // it's not scheduled and there's no cached result, so run it + const projects = await getProjectsConfigurations(); + if (projects) { + log(generator, 'already scheduled or not cached, running it now'); + runGenerator(generator, projects); + } else { + log( + generator, + 'already scheduled or not cached, project graph errored' + ); + /** + * This should never happen. This is invoked imperatively, and by + * the time it is invoked, the project graph would have already + * been requested. If it errored, it would have been reported and + * this wouldn't have been invoked. We handle it just in case. + * + * Since the project graph would be reported by the relevant + * handlers separately, we just ignore the error, don't cache + * any result and return an empty result, the next time this is + * invoked the process will repeat until it eventually recovers + * when the project graph is fixed. + */ + return Promise.resolve({ changes: [], generatorName: generator }); + } + } else { + log( + generator, + 'not scheduled and has cached result, returning cached result' + ); + } + + return syncGeneratorsCacheResultPromises.get(generator); + }) + ) + ).flat(); + } catch (e) { + console.error(e); + syncGeneratorsCacheResultPromises.clear(); + + return []; + } +} + +export async function flushSyncGeneratorChangesToDisk( + generators: string[] +): Promise { + log('flush sync generators changes', generators); + + const results = await getCachedSyncGeneratorChanges(generators); + + for (const generator of generators) { + syncGeneratorsCacheResultPromises.delete(generator); + } + + await flushSyncGeneratorChanges(results); +} + +export function collectAndScheduleSyncGenerators( + projectGraph: ProjectGraph +): void { + if (!projectGraph) { + // If the project graph is not available, we can't collect and schedule + // sync generators. The project graph error will be reported separately. + return; + } + + log('collect registered sync generators'); + collectAllRegisteredSyncGenerators(projectGraph); + + // a change imply we need to re-run all the generators + // make sure to schedule all the collected generators + scheduledGenerators.clear(); + for (const generator of registeredSyncGenerators) { + scheduledGenerators.add(generator); + } + + log('scheduling:', [...scheduledGenerators]); + + if (scheduledTimeoutId) { + // we have a scheduled run already, so we don't need to do anything + return; + } + + scheduledTimeoutId = setTimeout(async () => { + scheduledTimeoutId = undefined; + if (waitPeriod < 4000) { + waitPeriod = waitPeriod * 2; + } + + if (scheduledGenerators.size === 0) { + // no generators to run + return; + } + + const { projects } = + readProjectsConfigurationFromProjectGraph(projectGraph); + + for (const generator of scheduledGenerators) { + runGenerator(generator, projects); + } + + await Promise.all(syncGeneratorsCacheResultPromises.values()); + }, waitPeriod); +} + +export async function getCachedRegisteredSyncGenerators(): Promise { + log('get registered sync generators'); + if (!registeredSyncGenerators) { + log('no registered sync generators, collecting them'); + const { projectGraph } = await getCachedSerializedProjectGraphPromise(); + collectAllRegisteredSyncGenerators(projectGraph); + } else { + log('registered sync generators already collected, returning them'); + } + + return [...registeredSyncGenerators]; +} + +function collectAllRegisteredSyncGenerators(projectGraph: ProjectGraph): void { + const projectGraphHash = hashProjectGraph(projectGraph); + if (storedProjectGraphHash !== projectGraphHash) { + storedProjectGraphHash = projectGraphHash; + registeredTaskSyncGenerators = + collectRegisteredTaskSyncGenerators(projectGraph); + } else { + log('project graph hash is the same, not collecting task sync generators'); + } + + const nxJson = readNxJson(); + const nxJsonHash = hashArray(nxJson.sync?.globalGenerators?.sort() ?? []); + if (storedNxJsonHash !== nxJsonHash) { + storedNxJsonHash = nxJsonHash; + registeredGlobalSyncGenerators = + collectRegisteredGlobalSyncGenerators(nxJson); + } else { + log('nx.json hash is the same, not collecting global sync generators'); + } + + const generators = new Set([ + ...registeredTaskSyncGenerators, + ...registeredGlobalSyncGenerators, + ]); + + if (!registeredSyncGenerators) { + registeredSyncGenerators = generators; + return; + } + + for (const generator of registeredSyncGenerators) { + if (!generators.has(generator)) { + registeredSyncGenerators.delete(generator); + syncGeneratorsCacheResultPromises.delete(generator); + } + } + + for (const generator of generators) { + if (!registeredSyncGenerators.has(generator)) { + registeredSyncGenerators.add(generator); + } + } +} + +function runGenerator( + generator: string, + projects: Record +): void { + log('running scheduled generator', generator); + // remove it from the scheduled set + scheduledGenerators.delete(generator); + const tree = new FsTree( + workspaceRoot, + false, + `running sync generator ${generator}` + ); + + // run the generator and cache the result + syncGeneratorsCacheResultPromises.set( + generator, + runSyncGenerator(tree, generator, projects).then((result) => { + log(generator, 'changes:', result.changes.map((c) => c.path).join(', ')); + return result; + }) + ); +} + +function hashProjectGraph(projectGraph: ProjectGraph): string { + const stringifiedProjects = Object.entries(projectGraph.nodes) + .sort(([projectNameA], [projectNameB]) => + projectNameA.localeCompare(projectNameB) + ) + .map( + ([projectName, projectConfig]) => + `${projectName}:${JSON.stringify(projectConfig)}` + ); + + return hashArray(stringifiedProjects); +} diff --git a/packages/nx/src/tasks-runner/run-command.ts b/packages/nx/src/tasks-runner/run-command.ts index a607b26a5f..9ad8c806b6 100644 --- a/packages/nx/src/tasks-runner/run-command.ts +++ b/packages/nx/src/tasks-runner/run-command.ts @@ -1,3 +1,5 @@ +import { prompt } from 'enquirer'; +import * as ora from 'ora'; import { join } from 'path'; import { NxJsonConfiguration, @@ -11,12 +13,18 @@ import { TargetDependencyConfig } from '../config/workspace-json-project-json'; import { daemonClient } from '../daemon/client/client'; import { createTaskHasher } from '../hasher/create-task-hasher'; import { hashTasksThatDoNotDependOnOutputsOfOtherTasks } from '../hasher/hash-task'; +import { createProjectGraphAsync } from '../project-graph/project-graph'; import { NxArgs } from '../utils/command-line-utils'; import { isRelativePath } from '../utils/fileutils'; import { isCI } from '../utils/is-ci'; import { isNxCloudUsed } from '../utils/nx-cloud-utils'; import { output } from '../utils/output'; import { handleErrors } from '../utils/params'; +import { + flushSyncGeneratorChanges, + getSyncGeneratorChanges, + syncGeneratorResultsToMessageLines, +} from '../utils/sync-generators'; import { workspaceRoot } from '../utils/workspace-root'; import { createTaskGraph } from './create-task-graph'; import { CompositeLifeCycle, LifeCycle } from './life-cycle'; @@ -35,6 +43,7 @@ import { } from './task-graph-utils'; import { TasksRunner, TaskStatus } from './tasks-runner'; import { shouldStreamOutput } from './utils'; +import chalk = require('chalk'); async function getTerminalOutputLifeCycle( initiatingProject: string, @@ -146,7 +155,7 @@ function createTaskGraphAndRunValidations( export async function runCommand( projectsToRun: ProjectGraphProjectNode[], - projectGraph: ProjectGraph, + currentProjectGraph: ProjectGraph, { nxJson }: { nxJson: NxJsonConfiguration }, nxArgs: NxArgs, overrides: any, @@ -159,14 +168,16 @@ export async function runCommand( async () => { const projectNames = projectsToRun.map((t) => t.name); - const taskGraph = createTaskGraphAndRunValidations( - projectGraph, - extraTargetDependencies ?? {}, - projectNames, - nxArgs, - overrides, - extraOptions - ); + const { projectGraph, taskGraph } = + await ensureWorkspaceIsInSyncAndGetGraphs( + currentProjectGraph, + nxJson, + projectNames, + nxArgs, + overrides, + extraTargetDependencies, + extraOptions + ); const tasks = Object.values(taskGraph.tasks); const { lifeCycle, renderIsDone } = await getTerminalOutputLifeCycle( @@ -198,6 +209,171 @@ export async function runCommand( return status; } +async function ensureWorkspaceIsInSyncAndGetGraphs( + projectGraph: ProjectGraph, + nxJson: NxJsonConfiguration, + projectNames: string[], + nxArgs: NxArgs, + overrides: any, + extraTargetDependencies: Record, + extraOptions: { excludeTaskDependencies: boolean; loadDotEnvFiles: boolean } +): Promise<{ + projectGraph: ProjectGraph; + taskGraph: TaskGraph; +}> { + let taskGraph = createTaskGraphAndRunValidations( + projectGraph, + extraTargetDependencies ?? {}, + projectNames, + nxArgs, + overrides, + extraOptions + ); + + if (process.env.NX_ENABLE_SYNC_GENERATORS !== 'true') { + return { projectGraph, taskGraph }; + } + + // collect unique syncGenerators from the tasks + const uniqueSyncGenerators = new Set(); + for (const { target } of Object.values(taskGraph.tasks)) { + const { syncGenerators } = + projectGraph.nodes[target.project].data.targets[target.target]; + if (!syncGenerators) { + continue; + } + + for (const generator of syncGenerators) { + uniqueSyncGenerators.add(generator); + } + } + + if (!uniqueSyncGenerators.size) { + // There are no sync generators registered in the tasks to run + return { projectGraph, taskGraph }; + } + + const syncGenerators = Array.from(uniqueSyncGenerators); + const results = await getSyncGeneratorChanges(syncGenerators); + if (!results.length) { + // There are no changes to sync, workspace is up to date + return { projectGraph, taskGraph }; + } + + const outOfSyncTitle = 'The workspace is out of sync'; + const resultBodyLines = syncGeneratorResultsToMessageLines(results); + const fixMessage = + 'You can manually run `nx sync` to update your workspace or you can set `sync.applyChanges` to `true` in your `nx.json` to apply the changes automatically when running tasks.'; + const willErrorOnCiMessage = 'Please note that this will be an error on CI.'; + + if (isCI() || !process.stdout.isTTY) { + // If the user is running in CI or is running in a non-TTY environment we + // throw an error to stop the execution of the tasks. + throw new Error( + `${outOfSyncTitle}\n${resultBodyLines.join('\n')}\n${fixMessage}` + ); + } + + if (nxJson.sync?.applyChanges === false) { + // If the user has set `sync.applyChanges` to `false` in their `nx.json` + // we don't prompt the them and just log a warning informing them that + // the workspace is out of sync and they have it set to not apply changes + // automatically. + output.warn({ + title: outOfSyncTitle, + bodyLines: [ + ...resultBodyLines, + 'Your workspace is set to not apply changes automatically (`sync.applyChanges` is set to `false` in your `nx.json`).', + willErrorOnCiMessage, + fixMessage, + ], + }); + return { projectGraph, taskGraph }; + } + + output.warn({ + title: outOfSyncTitle, + bodyLines: [ + ...resultBodyLines, + nxJson.sync?.applyChanges === true + ? 'Proceeding to sync the changes automatically (`sync.applyChanges` is set to `true` in your `nx.json`).' + : willErrorOnCiMessage, + ], + }); + + const applyChanges = + nxJson.sync?.applyChanges === true || + (await promptForApplyingSyncGeneratorChanges()); + + if (applyChanges) { + const spinner = ora('Syncing the workspace...'); + spinner.start(); + + // Flush sync generator changes to disk + await flushSyncGeneratorChanges(results); + + // Re-create project graph and task graph + projectGraph = await createProjectGraphAsync(); + taskGraph = createTaskGraphAndRunValidations( + projectGraph, + extraTargetDependencies ?? {}, + projectNames, + nxArgs, + overrides, + extraOptions + ); + + if (nxJson.sync?.applyChanges === true) { + spinner.succeed(`The workspace was synced successfully! + +Please make sure to commit the changes to your repository or this will error on CI.`); + } else { + // The user was prompted and we already logged a message about erroring on CI + // so here we just tell them to commit the changes. + spinner.succeed(`The workspace was synced successfully! + +Please make sure to commit the changes to your repository.`); + } + } else { + output.warn({ + title: 'Syncing the workspace was skipped', + bodyLines: [ + 'This could lead to unexpected results or errors when running tasks.', + fixMessage, + ], + }); + } + + return { projectGraph, taskGraph }; +} + +async function promptForApplyingSyncGeneratorChanges(): Promise { + const promptConfig = { + name: 'applyChanges', + type: 'select', + message: + 'Would you like to sync the changes to get your worskpace up to date?', + choices: [ + { + name: 'yes', + message: 'Yes, sync the changes and run the tasks', + }, + { + name: 'no', + message: 'No, run the tasks without syncing the changes', + }, + ], + footer: () => + chalk.dim( + '\nYou can skip this prompt by setting the `sync.applyChanges` option in your `nx.json`.' + ), + }; + + return await prompt<{ applyChanges: 'yes' | 'no' }>([promptConfig]).then( + ({ applyChanges }) => applyChanges === 'yes' + ); +} + function setEnvVarsBasedOnArgs(nxArgs: NxArgs, loadDotEnvFiles: boolean) { if ( nxArgs.outputStyle == 'stream' || diff --git a/packages/nx/src/utils/sync-generators.ts b/packages/nx/src/utils/sync-generators.ts new file mode 100644 index 0000000000..867d8433b8 --- /dev/null +++ b/packages/nx/src/utils/sync-generators.ts @@ -0,0 +1,258 @@ +import { performance } from 'perf_hooks'; +import { parseGeneratorString } from '../command-line/generate/generate'; +import { getGeneratorInformation } from '../command-line/generate/generator-utils'; +import type { GeneratorCallback } from '../config/misc-interfaces'; +import { readNxJson } from '../config/nx-json'; +import type { ProjectGraph } from '../config/project-graph'; +import type { ProjectConfiguration } from '../config/workspace-json-project-json'; +import { daemonClient } from '../daemon/client/client'; +import { isOnDaemon } from '../daemon/is-on-daemon'; +import { + flushChanges, + FsTree, + type FileChange, + type Tree, +} from '../generators/tree'; +import { + createProjectGraphAsync, + readProjectsConfigurationFromProjectGraph, +} from '../project-graph/project-graph'; +import { updateContextWithChangedFiles } from './workspace-context'; +import { workspaceRoot } from './workspace-root'; +import chalk = require('chalk'); + +export type SyncGeneratorResult = void | { + callback?: GeneratorCallback; + outOfSyncMessage?: string; +}; + +export type SyncGenerator = ( + tree: Tree +) => SyncGeneratorResult | Promise; + +export type SyncGeneratorChangesResult = { + changes: FileChange[]; + generatorName: string; + callback?: GeneratorCallback; + outOfSyncMessage?: string; +}; + +export async function getSyncGeneratorChanges( + generators: string[] +): Promise { + performance.mark('get-sync-generators-changes:start'); + let results: SyncGeneratorChangesResult[]; + + if (!daemonClient.enabled()) { + results = await runSyncGenerators(generators); + } else { + results = await daemonClient.getSyncGeneratorChanges(generators); + } + + performance.mark('get-sync-generators-changes:end'); + performance.measure( + 'get-sync-generators-changes', + 'get-sync-generators-changes:start', + 'get-sync-generators-changes:end' + ); + + return results.filter((r) => r.changes.length > 0); +} + +export async function flushSyncGeneratorChanges( + results: SyncGeneratorChangesResult[] +): Promise { + if (isOnDaemon() || !daemonClient.enabled()) { + await flushSyncGeneratorChangesToDisk(results); + } else { + await daemonClient.flushSyncGeneratorChangesToDisk( + results.map((r) => r.generatorName) + ); + } +} + +export async function collectAllRegisteredSyncGenerators( + projectGraph: ProjectGraph +): Promise { + if (!daemonClient.enabled()) { + return [ + ...collectRegisteredTaskSyncGenerators(projectGraph), + ...collectRegisteredGlobalSyncGenerators(), + ]; + } + + return await daemonClient.getRegisteredSyncGenerators(); +} + +export async function runSyncGenerator( + tree: FsTree, + generatorSpecifier: string, + projects: Record +): Promise { + performance.mark(`run-sync-generator:${generatorSpecifier}:start`); + const { collection, generator } = parseGeneratorString(generatorSpecifier); + const { implementationFactory } = getGeneratorInformation( + collection, + generator, + workspaceRoot, + projects + ); + const implementation = implementationFactory() as SyncGenerator; + const result = await implementation(tree); + + let callback: GeneratorCallback | undefined; + let outOfSyncMessage: string | undefined; + if (result && typeof result === 'object') { + callback = result.callback; + outOfSyncMessage = result.outOfSyncMessage; + } + + performance.mark(`run-sync-generator:${generatorSpecifier}:end`); + performance.measure( + `run-sync-generator:${generatorSpecifier}`, + `run-sync-generator:${generatorSpecifier}:start`, + `run-sync-generator:${generatorSpecifier}:end` + ); + + return { + changes: tree.listChanges(), + generatorName: generatorSpecifier, + callback, + outOfSyncMessage, + }; +} + +export function collectRegisteredTaskSyncGenerators( + projectGraph: ProjectGraph +): Set { + const taskSyncGenerators = new Set(); + + for (const { + data: { targets }, + } of Object.values(projectGraph.nodes)) { + if (!targets) { + continue; + } + + for (const target of Object.values(targets)) { + if (!target.syncGenerators) { + continue; + } + + for (const generator of target.syncGenerators) { + taskSyncGenerators.add(generator); + } + } + } + + return taskSyncGenerators; +} + +export function collectRegisteredGlobalSyncGenerators( + nxJson = readNxJson() +): Set { + const globalSyncGenerators = new Set(); + + if (!nxJson.sync?.globalGenerators?.length) { + return globalSyncGenerators; + } + + for (const generator of nxJson.sync.globalGenerators) { + globalSyncGenerators.add(generator); + } + + return globalSyncGenerators; +} + +export function syncGeneratorResultsToMessageLines( + results: SyncGeneratorChangesResult[] +): string[] { + const messageLines: string[] = []; + + for (const result of results) { + messageLines.push( + `The ${chalk.bold( + result.generatorName + )} sync generator identified ${chalk.bold(result.changes.length)} file${ + result.changes.length === 1 ? '' : 's' + } in the workspace that ${ + result.changes.length === 1 ? 'is' : 'are' + } out of sync${result.outOfSyncMessage ? ':' : '.'}` + ); + if (result.outOfSyncMessage) { + messageLines.push(result.outOfSyncMessage); + } + messageLines.push(''); + } + + return messageLines; +} + +async function runSyncGenerators( + generators: string[] +): Promise { + const tree = new FsTree(workspaceRoot, false, 'running sync generators'); + const projectGraph = await createProjectGraphAsync(); + const { projects } = readProjectsConfigurationFromProjectGraph(projectGraph); + + const results: SyncGeneratorChangesResult[] = []; + for (const generator of generators) { + const result = await runSyncGenerator(tree, generator, projects); + results.push(result); + } + + return results; +} + +async function flushSyncGeneratorChangesToDisk( + results: SyncGeneratorChangesResult[] +): Promise { + performance.mark('flush-sync-generator-changes-to-disk:start'); + const { changes, createdFiles, updatedFiles, deletedFiles, callbacks } = + processSyncGeneratorResults(results); + // Write changes to disk + flushChanges(workspaceRoot, changes); + + // Run the callbacks + if (callbacks.length) { + for (const callback of callbacks) { + await callback(); + } + } + + // Update the context files + await updateContextWithChangedFiles(createdFiles, updatedFiles, deletedFiles); + performance.mark('flush-sync-generator-changes-to-disk:end'); + performance.measure( + 'flush sync generator changes to disk', + 'flush-sync-generator-changes-to-disk:start', + 'flush-sync-generator-changes-to-disk:end' + ); +} + +function processSyncGeneratorResults(results: SyncGeneratorChangesResult[]) { + const changes: FileChange[] = []; + const createdFiles: string[] = []; + const updatedFiles: string[] = []; + const deletedFiles: string[] = []; + const callbacks: GeneratorCallback[] = []; + + for (const result of results) { + if (result.callback) { + callbacks.push(result.callback); + } + + for (const change of result.changes) { + changes.push(change); + if (change.type === 'CREATE') { + createdFiles.push(change.path); + } else if (change.type === 'UPDATE') { + updatedFiles.push(change.path); + } else if (change.type === 'DELETE') { + deletedFiles.push(change.path); + } + } + } + + return { changes, createdFiles, updatedFiles, deletedFiles, callbacks }; +} diff --git a/packages/nx/src/utils/workspace-context.ts b/packages/nx/src/utils/workspace-context.ts index ca5f0914e1..0866f3c0c1 100644 --- a/packages/nx/src/utils/workspace-context.ts +++ b/packages/nx/src/utils/workspace-context.ts @@ -74,6 +74,30 @@ export async function hashWithWorkspaceContext( return daemonClient.hashGlob(globs, exclude); } +export async function updateContextWithChangedFiles( + createdFiles: string[], + updatedFiles: string[], + deletedFiles: string[] +) { + if (!daemonClient.enabled()) { + updateFilesInContext([...createdFiles, ...updatedFiles], deletedFiles); + } else if (isOnDaemon()) { + // make sure to only import this when running on the daemon + const { addUpdatedAndDeletedFiles } = await import( + '../daemon/server/project-graph-incremental-recomputation' + ); + // update files for the incremental graph recomputation on the daemon + addUpdatedAndDeletedFiles(createdFiles, updatedFiles, deletedFiles); + } else { + // daemon is enabled but we are not running on it, ask the daemon to update the context + await daemonClient.updateWorkspaceContext( + createdFiles, + updatedFiles, + deletedFiles + ); + } +} + export function updateFilesInContext( updatedFiles: string[], deletedFiles: string[]