diff --git a/.yarn/versions/5888d405.yml b/.yarn/versions/5888d405.yml new file mode 100644 index 000000000000..27151cad2ce9 --- /dev/null +++ b/.yarn/versions/5888d405.yml @@ -0,0 +1,7 @@ +releases: + "@yarnpkg/plugin-essentials": minor + +declined: + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/cli" diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/features/beforeHooks.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/features/beforeHooks.test.ts new file mode 100644 index 000000000000..bbd28f986b1b --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-specs/sources/features/beforeHooks.test.ts @@ -0,0 +1,223 @@ +import {PortablePath, npath, xfs} from '@yarnpkg/fslib'; +import {stringifySyml} from '@yarnpkg/parsers'; + +describe(`Features`, () => { + describe(`Before Hooks`, () => { + describe(`beforeWorkspaceDependencyAddition`, () => { + test( + `it should allow plugins to modify descriptor before addition`, + makeTemporaryEnv({}, async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`add`, `no-deps`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`no-deps`]: `^1.0.0`, + }, + }); + }), + ); + + test( + `it should work with dev dependencies`, + makeTemporaryEnv({}, async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`add`, `no-deps`, `-D`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + devDependencies: { + [`no-deps`]: `^1.0.0`, + }, + }); + }), + ); + + test( + `it should not affect other packages`, + makeTemporaryEnv({}, async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`add`, `one-fixed-dep`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`one-fixed-dep`]: `^2.0.0`, + }, + }); + }), + ); + }); + + describe(`beforeWorkspaceDependencyReplacement`, () => { + test( + `it should allow plugins to modify descriptor before replacement via yarn add`, + makeTemporaryEnv( + { + dependencies: { + 'no-deps': `^1.0.0`, + }, + }, + async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`add`, `no-deps@^1.5.0`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`no-deps`]: `^2.0.0`, + }, + }); + }, + ), + ); + + test( + `it should allow plugins to modify descriptor before replacement via yarn up`, + makeTemporaryEnv( + { + dependencies: { + 'no-deps': `^1.0.0`, + }, + }, + async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`up`, `no-deps`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`no-deps`]: `^2.0.0`, + }, + }); + }, + ), + ); + }); + + describe(`beforeWorkspaceDependencyRemoval`, () => { + test( + `it should allow plugins to block removal by throwing`, + makeTemporaryEnv( + { + dependencies: { + 'no-deps': `^2.0.0`, + }, + }, + async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await expect(run(`remove`, `no-deps`)).rejects.toThrow(); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + 'no-deps': `^2.0.0`, + }, + }); + }, + ), + ); + + test( + `it should allow removal of other packages`, + makeTemporaryEnv( + { + dependencies: { + 'one-fixed-dep': `^1.0.0`, + }, + }, + async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`remove`, `one-fixed-dep`); + + const manifest = await xfs.readJsonPromise(`${path}/package.json` as PortablePath); + expect(manifest.dependencies).toBeUndefined(); + }, + ), + ); + }); + + describe(`Multiple hooks interaction`, () => { + test( + `it should handle both addition and replacement hooks in yarn add`, + makeTemporaryEnv( + { + dependencies: { + 'no-deps': `^1.0.0`, + }, + }, + async ({path, run}) => { + const pluginPath = npath.toPortablePath(require.resolve(`@yarnpkg/monorepo/scripts/plugin-before-hooks-test.js`)); + const pluginContent = await xfs.readFilePromise(pluginPath); + await xfs.writeFilePromise(`${path}/plugin-before-hooks.js` as PortablePath, pluginContent); + + await xfs.writeFilePromise(`${path}/.yarnrc.yml` as PortablePath, stringifySyml({ + plugins: [`./plugin-before-hooks.js`], + })); + + await run(`add`, `no-deps@^1.5.0`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`no-deps`]: `^2.0.0`, + }, + }); + + await run(`add`, `one-fixed-dep`); + + await expect(xfs.readJsonPromise(`${path}/package.json` as PortablePath)).resolves.toMatchObject({ + dependencies: { + [`no-deps`]: `^2.0.0`, + [`one-fixed-dep`]: expect.any(String), + }, + }); + }, + ), + ); + }); + }); +}); diff --git a/packages/plugin-essentials/sources/commands/add.ts b/packages/plugin-essentials/sources/commands/add.ts index b50def73e0ad..c80ac52a23bb 100644 --- a/packages/plugin-essentials/sources/commands/add.ts +++ b/packages/plugin-essentials/sources/commands/add.ts @@ -194,6 +194,34 @@ export default class AddCommand extends BaseCommand { }); const results = await Promise.all(targetList.map(async target => { + const current = workspace.manifest[target].get(request.identHash); + const isReplacement = typeof current !== `undefined`; + + for (const plugin of configuration.plugins.values()) { + const hooks = plugin.hooks as Hooks; + + if (isReplacement) { + if (!hooks?.beforeWorkspaceDependencyReplacement) + continue; + + await hooks.beforeWorkspaceDependencyReplacement( + workspace, + target, + current, + request, + ); + } else { + if (!hooks?.beforeWorkspaceDependencyAddition) + continue; + + await hooks.beforeWorkspaceDependencyAddition( + workspace, + target, + request, + ); + } + } + const suggestedDescriptors = await suggestUtils.getSuggestedDescriptors(request, {project, workspace, cache, fixed, target, modifier, strategies, maxResults}); return {request, suggestedDescriptors, target}; })); diff --git a/packages/plugin-essentials/sources/commands/remove.ts b/packages/plugin-essentials/sources/commands/remove.ts index 23145c1f20db..2254e8a1fe80 100644 --- a/packages/plugin-essentials/sources/commands/remove.ts +++ b/packages/plugin-essentials/sources/commands/remove.ts @@ -117,7 +117,19 @@ export default class RemoveCommand extends BaseCommand { if (typeof removedDescriptor === `undefined`) throw new Error(`Assertion failed: Expected the descriptor to be registered`); - workspace.manifest[target].delete(identHash); + for (const plugin of configuration.plugins.values()) { + const hooks = plugin.hooks as Hooks; + if (!hooks?.beforeWorkspaceDependencyRemoval) + continue; + + await hooks.beforeWorkspaceDependencyRemoval( + workspace, + target, + removedDescriptor, + ); + } + + workspace.manifest[target].delete(removedDescriptor.identHash); afterWorkspaceDependencyRemovalList.push([ workspace, diff --git a/packages/plugin-essentials/sources/commands/up.ts b/packages/plugin-essentials/sources/commands/up.ts index adc95c12406f..08988456ab5d 100644 --- a/packages/plugin-essentials/sources/commands/up.ts +++ b/packages/plugin-essentials/sources/commands/up.ts @@ -211,6 +211,19 @@ export default class UpCommand extends BaseCommand { const request = structUtils.makeDescriptor(ident, pseudoDescriptor.range); allSuggestionsPromises.push(Promise.resolve().then(async () => { + for (const plugin of configuration.plugins.values()) { + const hooks = plugin.hooks as Hooks; + if (!hooks?.beforeWorkspaceDependencyReplacement) + continue; + + await hooks.beforeWorkspaceDependencyReplacement( + workspace, + target, + existingDescriptor, + request, + ); + } + return [ workspace, target, diff --git a/packages/plugin-essentials/sources/index.ts b/packages/plugin-essentials/sources/index.ts index d3a330165791..17d22768e594 100644 --- a/packages/plugin-essentials/sources/index.ts +++ b/packages/plugin-essentials/sources/index.ts @@ -121,6 +121,43 @@ export interface Hooks { descriptor: Descriptor, ) => Promise; + /** + * Called before a new dependency is added to a workspace. Note that this + * hook is only called by the CLI commands like `yarn add` - manually adding + * the dependencies into the manifest and running `yarn install` won't + * trigger it. + */ + beforeWorkspaceDependencyAddition?: ( + workspace: Workspace, + target: suggestUtils.Target, + descriptor: Descriptor, + ) => Promise; + + /** + * Called before a dependency range is replaced inside a workspace. Note that + * this hook is only called by the CLI commands like `yarn add` or `yarn up` - + * manually updating the dependencies from the manifest and running + * `yarn install` won't trigger it. + */ + beforeWorkspaceDependencyReplacement?: ( + workspace: Workspace, + target: suggestUtils.Target, + fromDescriptor: Descriptor, + toDescriptor: Descriptor, + ) => Promise; + + /** + * Called before a dependency range is removed from a workspace. Note that + * this hook is only called by the CLI commands like `yarn remove` - manually + * removing the dependencies from the manifest and running `yarn install` + * won't trigger it. + */ + beforeWorkspaceDependencyRemoval?: ( + workspace: Workspace, + target: suggestUtils.Target, + descriptor: Descriptor, + ) => Promise; + /** * Called by `yarn info`. The `extra` field is the set of parameters passed * to the `-X,--extra` flag. Calling `registerData` will add a new set of diff --git a/scripts/plugin-before-hooks-test.js b/scripts/plugin-before-hooks-test.js new file mode 100644 index 000000000000..726ba3ad2e32 --- /dev/null +++ b/scripts/plugin-before-hooks-test.js @@ -0,0 +1,26 @@ +module.exports = { + name: `@yarnpkg/plugin-before-hooks-test`, + factory: () => { + return { + hooks: { + beforeWorkspaceDependencyAddition: async (workspace, target, descriptor) => { + if (descriptor.name === `no-deps`) { + descriptor.range = `^1.0.0`; + } + }, + + beforeWorkspaceDependencyReplacement: async (workspace, target, fromDescriptor, toDescriptor) => { + if (toDescriptor.name === `no-deps`) { + toDescriptor.range = `^2.0.0`; + } + }, + + beforeWorkspaceDependencyRemoval: async (workspace, target, descriptor) => { + if (descriptor.name === `no-deps`) { + throw new Error(`Cannot remove no-deps - it is protected`); + } + }, + }, + }; + }, +};