Skip to content

Commit 63baf75

Browse files
committed
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 `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 with either `dev` or `peer` values.
1 parent 21622b8 commit 63baf75

File tree

3 files changed

+354
-0
lines changed

3 files changed

+354
-0
lines changed

packages/schematics/angular/BUILD.bazel

Lines changed: 1 addition & 0 deletions
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",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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: Record<string, string> = (manifest[type] ??= Object.create(null));
68+
69+
if (dependencySection[name] === specifier) {
70+
// Already present with same specifier
71+
return;
72+
} else if (dependencySection[name]) {
73+
// Already present but different specifier
74+
throw new Error(`Package dependency "${name}" already exists with a different specifier.`);
75+
} else {
76+
// Add new dependency in alphabetical order
77+
const entries = Object.entries(dependencySection);
78+
entries.push([name, specifier]);
79+
entries.sort((a, b) => a[0].localeCompare(b[0]));
80+
manifest[type] = Object.fromEntries(entries);
81+
}
82+
83+
tree.overwrite(packageJsonPath, JSON.stringify(manifest, null, 2));
84+
85+
const installPaths = installTasks.get(context) ?? new Set<string>();
86+
if (!installPaths.has(packageJsonPath)) {
87+
context.addTask(
88+
new NodePackageInstallTask({ workingDirectory: path.dirname(packageJsonPath) }),
89+
);
90+
installPaths.add(packageJsonPath);
91+
installTasks.set(context, installPaths);
92+
}
93+
};
94+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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 { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
19+
import { DependencyType, addDependency } from './dependency';
20+
21+
async function testRule(rule: Rule, tree: Tree): Promise<TaskConfigurationGenerator[]> {
22+
const tasks: TaskConfigurationGenerator[] = [];
23+
const context = {
24+
addTask(task: TaskConfigurationGenerator) {
25+
tasks.push(task);
26+
},
27+
};
28+
29+
await callRule(rule, tree, context as unknown as SchematicContext).toPromise();
30+
31+
return tasks;
32+
}
33+
34+
describe('addDependency', () => {
35+
it('adds a package to "dependencies" by default', async () => {
36+
const tree = new EmptyTree();
37+
tree.create(
38+
'/package.json',
39+
JSON.stringify({
40+
dependencies: {},
41+
}),
42+
);
43+
44+
const rule = addDependency('@angular/core', '^14.0.0');
45+
46+
await testRule(rule, tree);
47+
48+
expect(tree.readJson('/package.json')).toEqual({
49+
dependencies: { '@angular/core': '^14.0.0' },
50+
});
51+
});
52+
53+
it('throws if a package is already present with a different specifier', async () => {
54+
const tree = new EmptyTree();
55+
tree.create(
56+
'/package.json',
57+
JSON.stringify({
58+
dependencies: { '@angular/core': '^13.0.0' },
59+
}),
60+
);
61+
62+
const rule = addDependency('@angular/core', '^14.0.0');
63+
64+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
65+
undefined,
66+
'Package dependency "@angular/core" already exists with a different specifier.',
67+
);
68+
});
69+
70+
it('adds a package version with other packages in alphabetical order', async () => {
71+
const tree = new EmptyTree();
72+
tree.create(
73+
'/package.json',
74+
JSON.stringify({
75+
dependencies: { '@angular/common': '^14.0.0', '@angular/router': '^14.0.0' },
76+
}),
77+
);
78+
79+
const rule = addDependency('@angular/core', '^14.0.0');
80+
81+
await testRule(rule, tree);
82+
83+
expect(
84+
Object.entries((tree.readJson('/package.json') as { dependencies: {} }).dependencies),
85+
).toEqual([
86+
['@angular/common', '^14.0.0'],
87+
['@angular/core', '^14.0.0'],
88+
['@angular/router', '^14.0.0'],
89+
]);
90+
});
91+
92+
it('adds a dependency section if not present', async () => {
93+
const tree = new EmptyTree();
94+
tree.create('/package.json', JSON.stringify({}));
95+
96+
const rule = addDependency('@angular/core', '^14.0.0');
97+
98+
await testRule(rule, tree);
99+
100+
expect(tree.readJson('/package.json')).toEqual({
101+
dependencies: { '@angular/core': '^14.0.0' },
102+
});
103+
});
104+
105+
it('adds a package to "devDependencies" when "type" is "dev"', async () => {
106+
const tree = new EmptyTree();
107+
tree.create(
108+
'/package.json',
109+
JSON.stringify({
110+
dependencies: {},
111+
devDependencies: {},
112+
}),
113+
);
114+
115+
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Dev });
116+
117+
await testRule(rule, tree);
118+
119+
expect(tree.readJson('/package.json')).toEqual({
120+
dependencies: {},
121+
devDependencies: { '@angular/core': '^14.0.0' },
122+
});
123+
});
124+
125+
it('adds a package to "peerDependencies" when "type" is "peer"', async () => {
126+
const tree = new EmptyTree();
127+
tree.create(
128+
'/package.json',
129+
JSON.stringify({
130+
devDependencies: {},
131+
peerDependencies: {},
132+
}),
133+
);
134+
135+
const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Peer });
136+
137+
await testRule(rule, tree);
138+
139+
expect(tree.readJson('/package.json')).toEqual({
140+
devDependencies: {},
141+
peerDependencies: { '@angular/core': '^14.0.0' },
142+
});
143+
});
144+
145+
it('uses specified manifest when provided via "manifestPath" option', async () => {
146+
const tree = new EmptyTree();
147+
tree.create('/package.json', JSON.stringify({}));
148+
tree.create('/abc/package.json', JSON.stringify({}));
149+
150+
const rule = addDependency('@angular/core', '^14.0.0', {
151+
packageJsonPath: '/abc/package.json',
152+
});
153+
154+
await testRule(rule, tree);
155+
156+
expect(tree.readJson('/package.json')).toEqual({});
157+
expect(tree.readJson('/abc/package.json')).toEqual({
158+
dependencies: { '@angular/core': '^14.0.0' },
159+
});
160+
});
161+
162+
it('schedules a package install task', async () => {
163+
const tree = new EmptyTree();
164+
tree.create('/package.json', JSON.stringify({}));
165+
166+
const rule = addDependency('@angular/core', '^14.0.0');
167+
168+
const tasks = await testRule(rule, tree);
169+
170+
expect(tasks[0]).toBeInstanceOf(NodePackageInstallTask);
171+
expect(tasks.length).toBe(1);
172+
});
173+
174+
it('schedules a package install task with working directory when "manifestPath" is used', async () => {
175+
const tree = new EmptyTree();
176+
tree.create('/abc/package.json', JSON.stringify({}));
177+
178+
const rule = addDependency('@angular/core', '^14.0.0', {
179+
packageJsonPath: '/abc/package.json',
180+
});
181+
182+
const tasks = await testRule(rule, tree);
183+
184+
// Should be an array with a single element with working directory set
185+
expect(tasks).toEqual([jasmine.objectContaining({ workingDirectory: '/abc' })]);
186+
});
187+
188+
it('does not schedule a package install task if version is the same', async () => {
189+
const tree = new EmptyTree();
190+
tree.create(
191+
'/package.json',
192+
JSON.stringify({
193+
dependencies: { '@angular/core': '^14.0.0' },
194+
}),
195+
);
196+
197+
const rule = addDependency('@angular/core', '^14.0.0');
198+
199+
const tasks = await testRule(rule, tree);
200+
201+
expect(tasks).toEqual([]);
202+
});
203+
204+
it('only schedules one package install task for the same manifest path', async () => {
205+
const tree = new EmptyTree();
206+
tree.create('/package.json', JSON.stringify({}));
207+
208+
const rule = chain([
209+
addDependency('@angular/core', '^14.0.0'),
210+
addDependency('@angular/common', '^14.0.0'),
211+
]);
212+
213+
const tasks = await testRule(rule, tree);
214+
215+
expect(tasks).toEqual([jasmine.objectContaining({ workingDirectory: '/' })]);
216+
});
217+
218+
it('schedules a package install task for each manifest path present', async () => {
219+
const tree = new EmptyTree();
220+
tree.create('/package.json', JSON.stringify({}));
221+
tree.create('/abc/package.json', JSON.stringify({}));
222+
223+
const rule = chain([
224+
addDependency('@angular/core', '^14.0.0'),
225+
addDependency('@angular/common', '^14.0.0', { packageJsonPath: '/abc/package.json' }),
226+
]);
227+
228+
const tasks = await testRule(rule, tree);
229+
230+
expect(tasks).toEqual([
231+
jasmine.objectContaining({ workingDirectory: '/' }),
232+
jasmine.objectContaining({ workingDirectory: '/abc' }),
233+
]);
234+
});
235+
236+
it('throws an error when the default manifest path does not exist', async () => {
237+
const tree = new EmptyTree();
238+
239+
const rule = addDependency('@angular/core', '^14.0.0');
240+
241+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
242+
undefined,
243+
`Path "/package.json" does not exist.`,
244+
);
245+
});
246+
247+
it('throws an error when the specified manifest path does not exist', async () => {
248+
const tree = new EmptyTree();
249+
250+
const rule = addDependency('@angular/core', '^14.0.0', {
251+
packageJsonPath: '/abc/package.json',
252+
});
253+
254+
await expectAsync(testRule(rule, tree)).toBeRejectedWithError(
255+
undefined,
256+
`Path "/abc/package.json" does not exist.`,
257+
);
258+
});
259+
});

0 commit comments

Comments
 (0)