Skip to content

Commit 3fa38b0

Browse files
clydindgp1130
authored andcommitted
feat(@schematics/angular): introduce addDependency rule to utilities
An `addDependency` schematics rule has been added to the newly introduced `utility` subpath export for the `@schematics/angular` package. This new rule allows for adding a package as a dependency to a `package.json`. By default the `package.json` located at the schematic's root will be used. The `packageJsonPath` option can be used to explicitly specify a `package.json` in a 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 development or peer dependencies.
1 parent df9fc8f commit 3fa38b0

File tree

3 files changed

+375
-0
lines changed

3 files changed

+375
-0
lines changed

packages/schematics/angular/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ ts_library(
112112
"//packages/angular_devkit/core",
113113
"//packages/angular_devkit/core/node/testing",
114114
"//packages/angular_devkit/schematics",
115+
"//packages/angular_devkit/schematics/tasks",
115116
"//packages/angular_devkit/schematics/testing",
116117
"//packages/schematics/angular/third_party/github.com/Microsoft/TypeScript",
117118
"@npm//@types/browserslist",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { Rule, SchematicContext } from '@angular-devkit/schematics';
10+
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
11+
import * as path from 'path';
12+
13+
const installTasks = new WeakMap<SchematicContext, Set<string>>();
14+
15+
interface MinimalPackageManifest {
16+
dependencies?: Record<string, string>;
17+
devDependencies?: Record<string, string>;
18+
peerDependencies?: Record<string, string>;
19+
}
20+
21+
/**
22+
* An enum used to specify the type of a dependency found within a package manifest
23+
* file (`package.json`).
24+
*/
25+
export enum DependencyType {
26+
Default = 'dependencies',
27+
Dev = 'devDependencies',
28+
Peer = 'peerDependencies',
29+
}
30+
31+
/**
32+
* Adds a package as a dependency to a `package.json`. By default the `package.json` located
33+
* at the schematic's root will be used. The `manifestPath` option can be used to explicitly specify
34+
* a `package.json` in different location. The type of the dependency can also be specified instead
35+
* of the default of the `dependencies` section by using the `type` option for either `devDependencies`
36+
* or `peerDependencies`.
37+
*
38+
* When using this rule, {@link NodePackageInstallTask} does not need to be included directly by
39+
* a schematic. A package manager install task will be automatically scheduled as needed.
40+
*
41+
* @param name The name of the package to add.
42+
* @param specifier The package specifier for the package to add. Typically a SemVer range.
43+
* @param options An optional object that can contain the `type` of the dependency
44+
* and/or a path (`packageJsonPath`) of a manifest file (`package.json`) to modify.
45+
* @returns A Schematics {@link Rule}
46+
*/
47+
export function addDependency(
48+
name: string,
49+
specifier: string,
50+
options: {
51+
/**
52+
* The type of the dependency determines the section of the `package.json` to which the
53+
* dependency will be added. Defaults to {@link DependencyType.Default} (`dependencies`).
54+
*/
55+
type?: DependencyType;
56+
/**
57+
* The path of the package manifest file (`package.json`) that will be modified.
58+
* Defaults to `/package.json`.
59+
*/
60+
packageJsonPath?: string;
61+
} = {},
62+
): Rule {
63+
const { type = DependencyType.Default, packageJsonPath = '/package.json' } = options;
64+
65+
return (tree, context) => {
66+
const manifest = tree.readJson(packageJsonPath) as MinimalPackageManifest;
67+
const dependencySection = manifest[type];
68+
69+
if (!dependencySection) {
70+
// Section is not present. The dependency can be added to a new object literal for the section.
71+
manifest[type] = { [name]: specifier };
72+
} else if (dependencySection[name] === specifier) {
73+
// Already present with same specifier
74+
return;
75+
} else if (dependencySection[name]) {
76+
// Already present but different specifier
77+
throw new Error(`Package dependency "${name}" already exists with a different specifier.`);
78+
} else {
79+
// Add new dependency in alphabetical order
80+
const entries = Object.entries(dependencySection);
81+
entries.push([name, specifier]);
82+
entries.sort((a, b) => a[0].localeCompare(b[0]));
83+
manifest[type] = Object.fromEntries(entries);
84+
}
85+
86+
tree.overwrite(packageJsonPath, JSON.stringify(manifest, null, 2));
87+
88+
const installPaths = installTasks.get(context) ?? new Set<string>();
89+
if (!installPaths.has(packageJsonPath)) {
90+
context.addTask(
91+
new NodePackageInstallTask({ workingDirectory: path.dirname(packageJsonPath) }),
92+
);
93+
installPaths.add(packageJsonPath);
94+
installTasks.set(context, installPaths);
95+
}
96+
};
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
EmptyTree,
11+
Rule,
12+
SchematicContext,
13+
TaskConfigurationGenerator,
14+
Tree,
15+
callRule,
16+
chain,
17+
} from '@angular-devkit/schematics';
18+
import { DependencyType, addDependency } from './dependency';
19+
20+
async function testRule(rule: Rule, tree: Tree): Promise<TaskConfigurationGenerator[]> {
21+
const tasks: TaskConfigurationGenerator[] = [];
22+
const context = {
23+
addTask(task: TaskConfigurationGenerator) {
24+
tasks.push(task);
25+
},
26+
};
27+
28+
await callRule(rule, tree, context as unknown as SchematicContext).toPromise();
29+
30+
return tasks;
31+
}
32+
33+
describe('addDependency', () => {
34+
it('adds a package to "dependencies" by default', async () => {
35+
const tree = new EmptyTree();
36+
tree.create(
37+
'/package.json',
38+
JSON.stringify({
39+
dependencies: {},
40+
}),
41+
);
42+
43+
const rule = addDependency('@angular/core', '^14.0.0');
44+
45+
await testRule(rule, tree);
46+
47+
expect(tree.readJson('/package.json')).toEqual({
48+
dependencies: { '@angular/core': '^14.0.0' },
49+
});
50+
});
51+
52+
it('throws if a package is already present with a different specifier', async () => {
53+
const tree = new EmptyTree();
54+
tree.create(
55+
'/package.json',
56+
JSON.stringify({
57+
dependencies: { '@angular/core': '^13.0.0' },
58+
}),
59+
);
60+
61+
const rule = addDependency('@angular/core', '^14.0.0');
62+
63+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
64+
undefined,
65+
'Package dependency "@angular/core" already exists with a different specifier.',
66+
);
67+
});
68+
69+
it('adds a package version with other packages in alphabetical order', async () => {
70+
const tree = new EmptyTree();
71+
tree.create(
72+
'/package.json',
73+
JSON.stringify({
74+
dependencies: { '@angular/common': '^14.0.0', '@angular/router': '^14.0.0' },
75+
}),
76+
);
77+
78+
const rule = addDependency('@angular/core', '^14.0.0');
79+
80+
await testRule(rule, tree);
81+
82+
expect(
83+
Object.entries((tree.readJson('/package.json') as { dependencies: {} }).dependencies),
84+
).toEqual([
85+
['@angular/common', '^14.0.0'],
86+
['@angular/core', '^14.0.0'],
87+
['@angular/router', '^14.0.0'],
88+
]);
89+
});
90+
91+
it('adds a dependency section if not present', async () => {
92+
const tree = new EmptyTree();
93+
tree.create('/package.json', JSON.stringify({}));
94+
95+
const rule = addDependency('@angular/core', '^14.0.0');
96+
97+
await testRule(rule, tree);
98+
99+
expect(tree.readJson('/package.json')).toEqual({
100+
dependencies: { '@angular/core': '^14.0.0' },
101+
});
102+
});
103+
104+
it('adds a package to "devDependencies" when "type" is "dev"', async () => {
105+
const tree = new EmptyTree();
106+
tree.create(
107+
'/package.json',
108+
JSON.stringify({
109+
dependencies: {},
110+
devDependencies: {},
111+
}),
112+
);
113+
114+
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Dev });
115+
116+
await testRule(rule, tree);
117+
118+
expect(tree.readJson('/package.json')).toEqual({
119+
dependencies: {},
120+
devDependencies: { '@angular/core': '^14.0.0' },
121+
});
122+
});
123+
124+
it('adds a package to "peerDependencies" when "type" is "peer"', async () => {
125+
const tree = new EmptyTree();
126+
tree.create(
127+
'/package.json',
128+
JSON.stringify({
129+
devDependencies: {},
130+
peerDependencies: {},
131+
}),
132+
);
133+
134+
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Peer });
135+
136+
await testRule(rule, tree);
137+
138+
expect(tree.readJson('/package.json')).toEqual({
139+
devDependencies: {},
140+
peerDependencies: { '@angular/core': '^14.0.0' },
141+
});
142+
});
143+
144+
it('uses specified manifest when provided via "manifestPath" option', async () => {
145+
const tree = new EmptyTree();
146+
tree.create('/package.json', JSON.stringify({}));
147+
tree.create('/abc/package.json', JSON.stringify({}));
148+
149+
const rule = addDependency('@angular/core', '^14.0.0', {
150+
packageJsonPath: '/abc/package.json',
151+
});
152+
153+
await testRule(rule, tree);
154+
155+
expect(tree.readJson('/package.json')).toEqual({});
156+
expect(tree.readJson('/abc/package.json')).toEqual({
157+
dependencies: { '@angular/core': '^14.0.0' },
158+
});
159+
});
160+
161+
it('schedules a package install task', async () => {
162+
const tree = new EmptyTree();
163+
tree.create('/package.json', JSON.stringify({}));
164+
165+
const rule = addDependency('@angular/core', '^14.0.0');
166+
167+
const tasks = await testRule(rule, tree);
168+
169+
expect(tasks.map((task) => task.toConfiguration())).toEqual([
170+
{
171+
name: 'node-package',
172+
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }),
173+
},
174+
]);
175+
});
176+
177+
it('schedules a package install task with working directory when "packageJsonPath" is used', async () => {
178+
const tree = new EmptyTree();
179+
tree.create('/abc/package.json', JSON.stringify({}));
180+
181+
const rule = addDependency('@angular/core', '^14.0.0', {
182+
packageJsonPath: '/abc/package.json',
183+
});
184+
185+
const tasks = await testRule(rule, tree);
186+
187+
expect(tasks.map((task) => task.toConfiguration())).toEqual([
188+
{
189+
name: 'node-package',
190+
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }),
191+
},
192+
]);
193+
});
194+
195+
it('does not schedule a package install task if version is the same', async () => {
196+
const tree = new EmptyTree();
197+
tree.create(
198+
'/package.json',
199+
JSON.stringify({
200+
dependencies: { '@angular/core': '^14.0.0' },
201+
}),
202+
);
203+
204+
const rule = addDependency('@angular/core', '^14.0.0');
205+
206+
const tasks = await testRule(rule, tree);
207+
208+
expect(tasks).toEqual([]);
209+
});
210+
211+
it('only schedules one package install task for the same manifest path', async () => {
212+
const tree = new EmptyTree();
213+
tree.create('/package.json', JSON.stringify({}));
214+
215+
const rule = chain([
216+
addDependency('@angular/core', '^14.0.0'),
217+
addDependency('@angular/common', '^14.0.0'),
218+
]);
219+
220+
const tasks = await testRule(rule, tree);
221+
222+
expect(tasks.map((task) => task.toConfiguration())).toEqual([
223+
{
224+
name: 'node-package',
225+
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }),
226+
},
227+
]);
228+
});
229+
230+
it('schedules a package install task for each manifest path present', async () => {
231+
const tree = new EmptyTree();
232+
tree.create('/package.json', JSON.stringify({}));
233+
tree.create('/abc/package.json', JSON.stringify({}));
234+
235+
const rule = chain([
236+
addDependency('@angular/core', '^14.0.0'),
237+
addDependency('@angular/common', '^14.0.0', { packageJsonPath: '/abc/package.json' }),
238+
]);
239+
240+
const tasks = await testRule(rule, tree);
241+
242+
expect(tasks.map((task) => task.toConfiguration())).toEqual([
243+
{
244+
name: 'node-package',
245+
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }),
246+
},
247+
{
248+
name: 'node-package',
249+
options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }),
250+
},
251+
]);
252+
});
253+
254+
it('throws an error when the default manifest path does not exist', async () => {
255+
const tree = new EmptyTree();
256+
257+
const rule = addDependency('@angular/core', '^14.0.0');
258+
259+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
260+
undefined,
261+
`Path "/package.json" does not exist.`,
262+
);
263+
});
264+
265+
it('throws an error when the specified manifest path does not exist', async () => {
266+
const tree = new EmptyTree();
267+
268+
const rule = addDependency('@angular/core', '^14.0.0', {
269+
packageJsonPath: '/abc/package.json',
270+
});
271+
272+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
273+
undefined,
274+
`Path "/abc/package.json" does not exist.`,
275+
);
276+
});
277+
});

0 commit comments

Comments
 (0)