fix(core): handle package manager workspaces configuration in move generator (#30268)

## Current Behavior

Moving a project included in the package manager workspaces setup to a
new destination that's not matched by that configuration results in the
project not included in the package manager workspaces setup.

## Expected Behavior

Moving a project included in the package manager workspaces setup to a
new destination that's not matched by that configuration should result
in the new destination included in the workspaces setup.

## Related Issue(s)

Fixes #
This commit is contained in:
Leosvel Pérez Espinosa 2025-03-11 14:05:30 +01:00 committed by GitHub
parent d1a7ac96ce
commit 432a645d21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 337 additions and 49 deletions

View File

@ -60,7 +60,7 @@ export async function detoxApplicationGeneratorInternal(
);
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.e2eProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.e2eProjectRoot);
}
sortPackageJsonFields(host, options.e2eProjectRoot);

View File

@ -65,7 +65,7 @@ export async function expoApplicationGeneratorInternal(
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isTsSolutionSetup) {
addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
}
const lintTask = await addLinting(host, {

View File

@ -70,7 +70,7 @@ export async function expoLibraryGeneratorInternal(
}
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.projectRoot);
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
}
const initTask = await init(host, { ...options, skipFormat: true });

View File

@ -2303,6 +2303,28 @@ describe('lib', () => {
);
});
it('should add the project root to the package manager workspaces config when a more generic pattern would match other projects that were not previously included', async () => {
tree.write(
'not-included-dir/some-other-project-not-included/package.json',
'{}'
);
await libraryGenerator(tree, {
...defaultOptions,
directory: 'not-included-dir/my-lib',
bundler: 'tsc',
unitTestRunner: 'none',
linter: 'none',
});
expect(readJson(tree, 'package.json').workspaces).toContain(
'not-included-dir/my-lib'
);
expect(readJson(tree, 'package.json').workspaces).not.toContain(
'not-included-dir/*'
);
});
it('should not add a pattern for a project that already matches an existing pattern', async () => {
updateJson(tree, 'package.json', (json) => {
json.workspaces = ['packages/**'];

View File

@ -100,12 +100,6 @@ export async function libraryGeneratorInternal(
);
const options = await normalizeOptions(tree, schema);
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
createFiles(tree, options);
await configureProject(tree, options);
@ -114,6 +108,12 @@ export async function libraryGeneratorInternal(
tasks.push(addProjectDependencies(tree, options));
}
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
await addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
if (options.bundler === 'rollup') {
const { configurationGenerator } = ensurePackage('@nx/rollup', nxVersion);
await configurationGenerator(tree, {

View File

@ -25,7 +25,16 @@ export function getProjectPackageManagerWorkspaceState(
return 'no-workspaces';
}
const patterns = getGlobPatternsFromPackageManagerWorkspaces(
const patterns = getPackageManagerWorkspacesPatterns(tree);
const isIncluded = patterns.some((p) =>
picomatch(p)(join(projectRoot, 'package.json'))
);
return isIncluded ? 'included' : 'excluded';
}
export function getPackageManagerWorkspacesPatterns(tree: Tree): string[] {
return getGlobPatternsFromPackageManagerWorkspaces(
tree.root,
(path) => readJson(tree, path, { expectComments: true }),
(path) => {
@ -35,11 +44,6 @@ export function getProjectPackageManagerWorkspaceState(
},
(path) => tree.exists(path)
);
const isIncluded = patterns.some((p) =>
picomatch(p)(join(projectRoot, 'package.json'))
);
return isIncluded ? 'included' : 'excluded';
}
export function isUsingPackageManagerWorkspaces(tree: Tree): boolean {

View File

@ -1,4 +1,5 @@
import {
globAsync,
joinPathFragments,
offsetFromRoot,
output,
@ -11,6 +12,7 @@ import {
import { basename, dirname, join } from 'node:path/posix';
import { FsTree } from 'nx/src/generators/tree';
import {
getPackageManagerWorkspacesPatterns,
getProjectPackageManagerWorkspaceState,
isUsingPackageManagerWorkspaces,
} from '../package-manager-workspaces';
@ -211,7 +213,7 @@ export function updateTsconfigFiles(
}
}
export function addProjectToTsSolutionWorkspace(
export async function addProjectToTsSolutionWorkspace(
tree: Tree,
projectDir: string
) {
@ -220,11 +222,26 @@ export function addProjectToTsSolutionWorkspace(
return;
}
// If dir is "libs/foo" then use "libs/*" so we don't need so many entries in the workspace file.
// If dir is nested like "libs/shared/foo" then we add "libs/shared/*".
// If the dir is just "foo" then we have to add it as is.
// If dir is "libs/foo", we try to use "libs/*" but we only do it if it's
// safe to do so. So, we first check if adding that pattern doesn't result
// in extra projects being matched. If extra projects are matched, or the
// dir is just "foo" then we add it as is.
const baseDir = dirname(projectDir);
const pattern = baseDir === '.' ? projectDir : `${baseDir}/*`;
let pattern = projectDir;
if (baseDir !== '.') {
const patterns = getPackageManagerWorkspacesPatterns(tree);
const projectsBefore = await globAsync(tree, patterns);
patterns.push(`${baseDir}/*/package.json`);
const projectsAfter = await globAsync(tree, patterns);
if (projectsBefore.length + 1 === projectsAfter.length) {
// Adding the pattern to the parent directory only results in one extra
// project being matched, which is the project we're adding. It's safe
// to add the pattern to the parent directory.
pattern = `${baseDir}/*`;
}
}
if (tree.exists('pnpm-workspace.yaml')) {
const { load, dump } = require('@zkochan/js-yaml');
const workspaceFile = tree.read('pnpm-workspace.yaml', 'utf-8');

View File

@ -73,7 +73,7 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isTsSolutionSetup) {
addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
}
const e2eTask = await addE2e(host, options);

View File

@ -168,7 +168,7 @@ export async function libraryGeneratorInternal(host: Tree, rawOptions: Schema) {
);
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.projectRoot);
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
}
sortPackageJsonFields(host, options.projectRoot);

View File

@ -516,7 +516,7 @@ export async function applicationGeneratorInternal(tree: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
}
updateTsConfigOptions(tree, options);

View File

@ -259,7 +259,7 @@ export async function e2eProjectGeneratorInternal(
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.e2eProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.e2eProjectRoot);
}
if (!options.skipFormat) {

View File

@ -57,7 +57,7 @@ export async function libraryGeneratorInternal(tree: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.projectRoot);
await addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
const tasks: GeneratorCallback[] = [];

View File

@ -151,7 +151,7 @@ export async function applicationGenerator(tree: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
}
tasks.push(

View File

@ -258,7 +258,7 @@ export async function e2eProjectGeneratorInternal(host: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isTsSolutionSetup) {
addProjectToTsSolutionWorkspace(host, options.projectRoot);
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
}
if (!options.skipFormat) {

View File

@ -69,7 +69,7 @@ export async function reactNativeApplicationGeneratorInternal(
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isTsSolutionSetup) {
addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
}
const lintTask = await addLinting(host, {

View File

@ -88,7 +88,7 @@ export async function reactNativeLibraryGeneratorInternal(
}
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.projectRoot);
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
}
const lintTask = await addLinting(host, {

View File

@ -72,12 +72,6 @@ export async function applicationGeneratorInternal(
const options = await normalizeOptions(tree, schema);
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
}
showPossibleWarnings(tree, options);
const initTask = await reactInitGenerator(tree, {
@ -115,6 +109,12 @@ export async function applicationGeneratorInternal(
await createApplicationFiles(tree, options);
addProject(tree, options);
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
await addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
}
if (options.style === 'tailwind') {
const twTask = await setupTailwindGenerator(tree, {
project: options.projectName,

View File

@ -62,7 +62,7 @@ export async function libraryGeneratorInternal(host: Tree, schema: Schema) {
const options = await normalizeOptions(host, schema);
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.projectRoot);
await addProjectToTsSolutionWorkspace(host, options.projectRoot);
}
if (options.publishable === true && !schema.importPath) {

View File

@ -85,7 +85,7 @@ export async function remixApplicationGeneratorInternal(
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.projectRoot);
await addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
if (!options.isUsingTsSolutionConfig) {

View File

@ -30,7 +30,7 @@ export async function remixLibraryGeneratorInternal(
const options = await normalizeOptions(tree, schema);
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.projectRoot);
await addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
const jsInitTask = await jsInitGenerator(tree, {

View File

@ -56,7 +56,7 @@ export async function applicationGeneratorInternal(
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(tree, options.appProjectRoot);
}
const nxJson = readNxJson(tree);

View File

@ -56,7 +56,7 @@ export async function libraryGeneratorInternal(tree: Tree, schema: Schema) {
// If we are using the new TS solution
// We need to update the workspace file (package.json or pnpm-workspaces.yaml) to include the new project
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(tree, options.projectRoot);
await addProjectToTsSolutionWorkspace(tree, options.projectRoot);
}
if (options.isUsingTsSolutionConfig) {

View File

@ -301,7 +301,7 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) {
const options = await normalizeOptions(host, schema);
if (options.isUsingTsSolutionConfig) {
addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
await addProjectToTsSolutionWorkspace(host, options.appProjectRoot);
}
const tasks: GeneratorCallback[] = [];

View File

@ -39,8 +39,10 @@
},
"dependencies": {
"@nx/devkit": "file:../devkit",
"@zkochan/js-yaml": "0.0.7",
"chalk": "^4.1.0",
"enquirer": "~2.3.6",
"picomatch": "4.0.2",
"tslib": "^2.3.0",
"yargs-parser": "21.1.1"
},

View File

@ -1,14 +1,23 @@
import 'nx/src/internal-testing-utils/mock-project-graph';
import {
detectPackageManager,
readJson,
readProjectConfiguration,
Tree,
updateJson,
updateProjectConfiguration,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { moveGenerator } from './move';
jest.mock('@nx/devkit', () => ({
...jest.requireActual('@nx/devkit'),
createProjectGraphAsync: jest.fn().mockImplementation(async () => ({
nodes: {},
dependencies: {},
})),
detectPackageManager: jest.fn(),
}));
// nx-ignore-next-line
const { libraryGenerator } = require('@nx/js');
@ -16,6 +25,9 @@ describe('move', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
(detectPackageManager as jest.Mock).mockImplementation((...args) =>
jest.requireActual('@nx/devkit').detectPackageManager(...args)
);
});
it('should update jest config when moving down directories', async () => {
@ -195,4 +207,93 @@ describe('move', () => {
// check that the project.json file is not present
expect(tree.exists('packages/lib1/project.json')).toBeFalsy();
});
it('should add new destination to the package manager workspaces config when it does not match any existing pattern and it was previously included', async () => {
updateJson(tree, 'package.json', (json) => {
json.workspaces = ['libs/*'];
return json;
});
await libraryGenerator(tree, { directory: 'libs/lib1' });
await moveGenerator(tree, {
projectName: 'lib1',
destination: 'packages/lib1',
updateImportPath: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson.workspaces).toStrictEqual(['libs/*', 'packages/*']);
});
it('should add new destination to the pnpm workspaces config when it does not match any existing pattern and it was previously included', async () => {
(detectPackageManager as jest.Mock).mockReturnValue('pnpm');
tree.write(
'pnpm-workspace.yaml',
`packages:
- 'libs/*'`
);
await libraryGenerator(tree, { directory: 'libs/lib1' });
await moveGenerator(tree, {
projectName: 'lib1',
destination: 'packages/lib1',
updateImportPath: true,
});
expect(tree.read('pnpm-workspace.yaml', 'utf-8')).toMatchInlineSnapshot(`
"packages:
- 'libs/*'
- 'packages/*'
"
`);
});
it('should add the project root to the package manager workspaces config when a more generic pattern would match other projects that were not previously included', async () => {
updateJson(tree, 'package.json', (json) => {
json.workspaces = ['libs/*'];
return json;
});
await libraryGenerator(tree, { directory: 'libs/lib1' });
// extra project that's not part of the package manager workspaces
await libraryGenerator(tree, { directory: 'packages/some-package' });
await moveGenerator(tree, {
projectName: 'lib1',
destination: 'packages/lib1',
updateImportPath: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson.workspaces).toStrictEqual(['libs/*', 'packages/lib1']);
});
it('should not add new destination to the package manager workspaces config when it was not previously included', async () => {
updateJson(tree, 'package.json', (json) => {
json.workspaces = ['apps/*'];
return json;
});
await libraryGenerator(tree, { directory: 'libs/lib1' });
await moveGenerator(tree, {
projectName: 'lib1',
destination: 'packages/lib1',
updateImportPath: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson.workspaces).toStrictEqual(['apps/*']);
});
it('should not configure package manager workspaces if it was not previously configured', async () => {
await libraryGenerator(tree, { directory: 'libs/lib1' });
await moveGenerator(tree, {
projectName: 'lib1',
destination: 'packages/lib1',
updateImportPath: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson.workspaces).toBeUndefined();
});
});

View File

@ -1,11 +1,20 @@
import {
formatFiles,
installPackagesTask,
readProjectConfiguration,
removeProjectConfiguration,
Tree,
type GeneratorCallback,
type Tree,
} from '@nx/devkit';
import { isProjectIncludedInPackageManagerWorkspaces } from '../../utilities/package-manager-workspaces';
import { addProjectToTsSolutionWorkspace } from '../../utilities/typescript/ts-solution-setup';
import { checkDestination } from './lib/check-destination';
import { createProjectConfigurationInNewDestination } from './lib/create-project-configuration-in-new-destination';
import {
maybeExtractJestConfigBase,
maybeExtractTsConfigBase,
maybeMigrateEslintConfigIfRootProject,
} from './lib/extract-base-configs';
import { moveProjectFiles } from './lib/move-project-files';
import { normalizeSchema } from './lib/normalize-schema';
import { runAngularPlugin } from './lib/run-angular-plugin';
@ -20,15 +29,14 @@ import { updatePackageJson } from './lib/update-package-json';
import { updateProjectRootFiles } from './lib/update-project-root-files';
import { updateReadme } from './lib/update-readme';
import { updateStorybookConfig } from './lib/update-storybook-config';
import {
maybeMigrateEslintConfigIfRootProject,
maybeExtractJestConfigBase,
maybeExtractTsConfigBase,
} from './lib/extract-base-configs';
import { Schema } from './schema';
export async function moveGenerator(tree: Tree, rawSchema: Schema) {
let projectConfig = readProjectConfiguration(tree, rawSchema.projectName);
const wasIncludedInWorkspaces = isProjectIncludedInPackageManagerWorkspaces(
tree,
projectConfig.root
);
const schema = await normalizeSchema(tree, rawSchema, projectConfig);
checkDestination(tree, schema, rawSchema.destination);
@ -61,9 +69,29 @@ export async function moveGenerator(tree: Tree, rawSchema: Schema) {
await runAngularPlugin(tree, schema);
let task: GeneratorCallback;
if (wasIncludedInWorkspaces) {
// check if the new destination is included in the package manager workspaces
const isIncludedInWorkspaces = isProjectIncludedInPackageManagerWorkspaces(
tree,
schema.destination
);
if (!isIncludedInWorkspaces) {
// the new destination is not included in the package manager workspaces
// so we need to add it and run a package install to ensure the symlink
// is created
await addProjectToTsSolutionWorkspace(tree, schema.destination);
task = () => installPackagesTask(tree, true);
}
}
if (!schema.skipFormat) {
await formatFiles(tree);
}
if (task) {
return task;
}
}
export default moveGenerator;

View File

@ -0,0 +1,49 @@
import { detectPackageManager, readJson, type Tree } from '@nx/devkit';
import { join } from 'node:path/posix';
import { getGlobPatternsFromPackageManagerWorkspaces } from 'nx/src/plugins/package-json';
import { PackageJson } from 'nx/src/utils/package-json';
import picomatch = require('picomatch');
export function isProjectIncludedInPackageManagerWorkspaces(
tree: Tree,
projectRoot: string
): boolean {
if (!isUsingPackageManagerWorkspaces(tree)) {
return false;
}
const patterns = getPackageManagerWorkspacesPatterns(tree);
return patterns.some((p) => picomatch(p)(join(projectRoot, 'package.json')));
}
export function getPackageManagerWorkspacesPatterns(tree: Tree): string[] {
return getGlobPatternsFromPackageManagerWorkspaces(
tree.root,
(path) => readJson(tree, path, { expectComments: true }),
(path) => {
const content = tree.read(path, 'utf-8');
const { load } = require('@zkochan/js-yaml');
return load(content, { filename: path });
},
(path) => tree.exists(path)
);
}
export function isUsingPackageManagerWorkspaces(tree: Tree): boolean {
return isWorkspacesEnabled(tree);
}
export function isWorkspacesEnabled(tree: Tree): boolean {
const packageManager = detectPackageManager(tree.root);
if (packageManager === 'pnpm') {
return tree.exists('pnpm-workspace.yaml');
}
// yarn and npm both use the same 'workspaces' property in package.json
if (tree.exists('package.json')) {
const packageJson = readJson<PackageJson>(tree, 'package.json');
return !!packageJson?.workspaces;
}
return false;
}

View File

@ -1,11 +1,17 @@
import {
detectPackageManager,
globAsync,
readJson,
type Tree,
workspaceRoot,
} from '@nx/devkit';
import { dirname } from 'node:path/posix';
import { FsTree } from 'nx/src/generators/tree';
import { type PackageJson } from 'nx/src/utils/package-json';
import {
getPackageManagerWorkspacesPatterns,
isProjectIncludedInPackageManagerWorkspaces,
} from '../package-manager-workspaces';
function isUsingPackageManagerWorkspaces(tree: Tree): boolean {
return isWorkspacesEnabled(tree);
@ -75,3 +81,62 @@ export function isUsingTsSolutionSetup(tree?: Tree): boolean {
isWorkspaceSetupWithTsSolution(tree)
);
}
export async function addProjectToTsSolutionWorkspace(
tree: Tree,
projectDir: string
) {
const isIncluded = isProjectIncludedInPackageManagerWorkspaces(
tree,
projectDir
);
if (isIncluded) {
return;
}
// If dir is "libs/foo", we try to use "libs/*" but we only do it if it's
// safe to do so. So, we first check if adding that pattern doesn't result
// in extra projects being matched. If extra projects are matched, or the
// dir is just "foo" then we add it as is.
const baseDir = dirname(projectDir);
let pattern = projectDir;
if (baseDir !== '.') {
const patterns = getPackageManagerWorkspacesPatterns(tree);
const projectsBefore = await globAsync(tree, patterns);
patterns.push(`${baseDir}/*/package.json`);
const projectsAfter = await globAsync(tree, patterns);
if (projectsBefore.length + 1 === projectsAfter.length) {
// Adding the pattern to the parent directory only results in one extra
// project being matched, which is the project we're adding. It's safe
// to add the pattern to the parent directory.
pattern = `${baseDir}/*`;
}
}
if (tree.exists('pnpm-workspace.yaml')) {
const { load, dump } = require('@zkochan/js-yaml');
const workspaceFile = tree.read('pnpm-workspace.yaml', 'utf-8');
const yamlData = load(workspaceFile) ?? {};
yamlData.packages ??= [];
if (!yamlData.packages.includes(pattern)) {
yamlData.packages.push(pattern);
tree.write(
'pnpm-workspace.yaml',
dump(yamlData, { indent: 2, quotingType: '"', forceQuotes: true })
);
}
} else {
// Update package.json
const packageJson = readJson(tree, 'package.json');
if (!packageJson.workspaces) {
packageJson.workspaces = [];
}
if (!packageJson.workspaces.includes(pattern)) {
packageJson.workspaces.push(pattern);
tree.write('package.json', JSON.stringify(packageJson, null, 2));
}
}
}