-
Notifications
You must be signed in to change notification settings - Fork 11.9k
feat(@schematics/angular): introduce addDependency
rule to utilities
#23067
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.io/license | ||
*/ | ||
|
||
import { Rule, SchematicContext } from '@angular-devkit/schematics'; | ||
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; | ||
import * as path from 'path'; | ||
|
||
const installTasks = new WeakMap<SchematicContext, Set<string>>(); | ||
|
||
interface MinimalPackageManifest { | ||
dependencies?: Record<string, string>; | ||
devDependencies?: Record<string, string>; | ||
peerDependencies?: Record<string, string>; | ||
} | ||
|
||
/** | ||
* An enum used to specify the type of a dependency found within a package manifest | ||
* file (`package.json`). | ||
*/ | ||
export enum DependencyType { | ||
Default = 'dependencies', | ||
Dev = 'devDependencies', | ||
Peer = 'peerDependencies', | ||
} | ||
|
||
/** | ||
* Adds a package as a dependency to a `package.json`. By default the `package.json` located | ||
* at the schematic's root will be used. The `manifestPath` option can be used to explicitly specify | ||
* a `package.json` in different location. The type of the dependency can also be specified instead | ||
* of the default of the `dependencies` section by using the `type` option for either `devDependencies` | ||
* or `peerDependencies`. | ||
* | ||
* When using this rule, {@link NodePackageInstallTask} does not need to be included directly by | ||
* a schematic. A package manager install task will be automatically scheduled as needed. | ||
* | ||
* @param name The name of the package to add. | ||
* @param specifier The package specifier for the package to add. Typically a SemVer range. | ||
* @param options An optional object that can contain the `type` of the dependency | ||
* and/or a path (`packageJsonPath`) of a manifest file (`package.json`) to modify. | ||
* @returns A Schematics {@link Rule} | ||
*/ | ||
export function addDependency( | ||
name: string, | ||
specifier: string, | ||
options: { | ||
/** | ||
* The type of the dependency determines the section of the `package.json` to which the | ||
* dependency will be added. Defaults to {@link DependencyType.Default} (`dependencies`). | ||
*/ | ||
type?: DependencyType; | ||
/** | ||
* The path of the package manifest file (`package.json`) that will be modified. | ||
* Defaults to `/package.json`. | ||
*/ | ||
packageJsonPath?: string; | ||
} = {}, | ||
): Rule { | ||
const { type = DependencyType.Default, packageJsonPath = '/package.json' } = options; | ||
|
||
return (tree, context) => { | ||
const manifest = tree.readJson(packageJsonPath) as MinimalPackageManifest; | ||
const dependencySection = manifest[type]; | ||
|
||
if (!dependencySection) { | ||
// Section is not present. The dependency can be added to a new object literal for the section. | ||
manifest[type] = { [name]: specifier }; | ||
} else if (dependencySection[name] === specifier) { | ||
// Already present with same specifier | ||
return; | ||
} else if (dependencySection[name]) { | ||
// Already present but different specifier | ||
throw new Error(`Package dependency "${name}" already exists with a different specifier.`); | ||
} else { | ||
// Add new dependency in alphabetical order | ||
const entries = Object.entries(dependencySection); | ||
entries.push([name, specifier]); | ||
entries.sort((a, b) => a[0].localeCompare(b[0])); | ||
manifest[type] = Object.fromEntries(entries); | ||
} | ||
|
||
tree.overwrite(packageJsonPath, JSON.stringify(manifest, null, 2)); | ||
|
||
const installPaths = installTasks.get(context) ?? new Set<string>(); | ||
if (!installPaths.has(packageJsonPath)) { | ||
context.addTask( | ||
new NodePackageInstallTask({ workingDirectory: path.dirname(packageJsonPath) }), | ||
); | ||
installPaths.add(packageJsonPath); | ||
installTasks.set(context, installPaths); | ||
} | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,277 @@ | ||
/** | ||
* @license | ||
* Copyright Google LLC All Rights Reserved. | ||
* | ||
* Use of this source code is governed by an MIT-style license that can be | ||
* found in the LICENSE file at https://angular.io/license | ||
*/ | ||
|
||
import { | ||
EmptyTree, | ||
Rule, | ||
SchematicContext, | ||
TaskConfigurationGenerator, | ||
Tree, | ||
callRule, | ||
chain, | ||
} from '@angular-devkit/schematics'; | ||
import { DependencyType, addDependency } from './dependency'; | ||
|
||
async function testRule(rule: Rule, tree: Tree): Promise<TaskConfigurationGenerator[]> { | ||
const tasks: TaskConfigurationGenerator[] = []; | ||
const context = { | ||
addTask(task: TaskConfigurationGenerator) { | ||
tasks.push(task); | ||
}, | ||
}; | ||
|
||
await callRule(rule, tree, context as unknown as SchematicContext).toPromise(); | ||
|
||
return tasks; | ||
} | ||
|
||
describe('addDependency', () => { | ||
it('adds a package to "dependencies" by default', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
dependencies: {}, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect(tree.readJson('/package.json')).toEqual({ | ||
dependencies: { '@angular/core': '^14.0.0' }, | ||
}); | ||
}); | ||
|
||
it('throws if a package is already present with a different specifier', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
dependencies: { '@angular/core': '^13.0.0' }, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
await expectAsync(testRule(rule, tree)).toBeRejectedWithError( | ||
undefined, | ||
'Package dependency "@angular/core" already exists with a different specifier.', | ||
); | ||
}); | ||
|
||
it('adds a package version with other packages in alphabetical order', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
dependencies: { '@angular/common': '^14.0.0', '@angular/router': '^14.0.0' }, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect( | ||
Object.entries((tree.readJson('/package.json') as { dependencies: {} }).dependencies), | ||
).toEqual([ | ||
['@angular/common', '^14.0.0'], | ||
['@angular/core', '^14.0.0'], | ||
['@angular/router', '^14.0.0'], | ||
]); | ||
}); | ||
|
||
it('adds a dependency section if not present', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/package.json', JSON.stringify({})); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect(tree.readJson('/package.json')).toEqual({ | ||
dependencies: { '@angular/core': '^14.0.0' }, | ||
}); | ||
}); | ||
|
||
it('adds a package to "devDependencies" when "type" is "dev"', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
dependencies: {}, | ||
devDependencies: {}, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Dev }); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect(tree.readJson('/package.json')).toEqual({ | ||
dependencies: {}, | ||
devDependencies: { '@angular/core': '^14.0.0' }, | ||
}); | ||
}); | ||
|
||
it('adds a package to "peerDependencies" when "type" is "peer"', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
devDependencies: {}, | ||
peerDependencies: {}, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Peer }); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect(tree.readJson('/package.json')).toEqual({ | ||
devDependencies: {}, | ||
peerDependencies: { '@angular/core': '^14.0.0' }, | ||
}); | ||
}); | ||
|
||
it('uses specified manifest when provided via "manifestPath" option', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/package.json', JSON.stringify({})); | ||
tree.create('/abc/package.json', JSON.stringify({})); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0', { | ||
packageJsonPath: '/abc/package.json', | ||
}); | ||
|
||
await testRule(rule, tree); | ||
|
||
expect(tree.readJson('/package.json')).toEqual({}); | ||
expect(tree.readJson('/abc/package.json')).toEqual({ | ||
dependencies: { '@angular/core': '^14.0.0' }, | ||
}); | ||
}); | ||
|
||
it('schedules a package install task', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/package.json', JSON.stringify({})); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
const tasks = await testRule(rule, tree); | ||
|
||
expect(tasks.map((task) => task.toConfiguration())).toEqual([ | ||
{ | ||
name: 'node-package', | ||
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), | ||
}, | ||
]); | ||
}); | ||
|
||
it('schedules a package install task with working directory when "packageJsonPath" is used', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/abc/package.json', JSON.stringify({})); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0', { | ||
packageJsonPath: '/abc/package.json', | ||
}); | ||
|
||
const tasks = await testRule(rule, tree); | ||
|
||
expect(tasks.map((task) => task.toConfiguration())).toEqual([ | ||
{ | ||
name: 'node-package', | ||
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }), | ||
}, | ||
]); | ||
}); | ||
|
||
it('does not schedule a package install task if version is the same', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create( | ||
'/package.json', | ||
JSON.stringify({ | ||
dependencies: { '@angular/core': '^14.0.0' }, | ||
}), | ||
); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
const tasks = await testRule(rule, tree); | ||
|
||
expect(tasks).toEqual([]); | ||
}); | ||
|
||
it('only schedules one package install task for the same manifest path', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/package.json', JSON.stringify({})); | ||
|
||
const rule = chain([ | ||
addDependency('@angular/core', '^14.0.0'), | ||
addDependency('@angular/common', '^14.0.0'), | ||
]); | ||
|
||
const tasks = await testRule(rule, tree); | ||
|
||
expect(tasks.map((task) => task.toConfiguration())).toEqual([ | ||
{ | ||
name: 'node-package', | ||
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), | ||
}, | ||
]); | ||
}); | ||
|
||
it('schedules a package install task for each manifest path present', async () => { | ||
const tree = new EmptyTree(); | ||
tree.create('/package.json', JSON.stringify({})); | ||
tree.create('/abc/package.json', JSON.stringify({})); | ||
|
||
const rule = chain([ | ||
addDependency('@angular/core', '^14.0.0'), | ||
addDependency('@angular/common', '^14.0.0', { packageJsonPath: '/abc/package.json' }), | ||
]); | ||
|
||
const tasks = await testRule(rule, tree); | ||
|
||
expect(tasks.map((task) => task.toConfiguration())).toEqual([ | ||
{ | ||
name: 'node-package', | ||
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), | ||
}, | ||
{ | ||
name: 'node-package', | ||
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }), | ||
}, | ||
]); | ||
}); | ||
|
||
it('throws an error when the default manifest path does not exist', async () => { | ||
const tree = new EmptyTree(); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0'); | ||
|
||
await expectAsync(testRule(rule, tree)).toBeRejectedWithError( | ||
undefined, | ||
`Path "/package.json" does not exist.`, | ||
); | ||
}); | ||
|
||
it('throws an error when the specified manifest path does not exist', async () => { | ||
const tree = new EmptyTree(); | ||
|
||
const rule = addDependency('@angular/core', '^14.0.0', { | ||
packageJsonPath: '/abc/package.json', | ||
}); | ||
|
||
await expectAsync(testRule(rule, tree)).toBeRejectedWithError( | ||
undefined, | ||
`Path "/abc/package.json" does not exist.`, | ||
); | ||
}); | ||
}); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.