Skip to content

Commit 2113c81

Browse files
committed
fix(@angular/cli): direct Angular schematic dependency requests to known versions
This change adds logic to redirect module resolution requests for Angular schematics to ensure that the correct versions of core schematic related packages are used. This also ensures that the runtime version of the schematics package matches the version used inside the schematic and that object instances passed into the schematic are compatible. The current set of core schematic related packages are `@angular-devkit/*` and `@schematics/angular`. Only first-party Angular schematics are currently affected by this change.
1 parent bd4319a commit 2113c81

File tree

5 files changed

+182
-26
lines changed

5 files changed

+182
-26
lines changed

packages/angular/cli/commands/update-impl.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -265,28 +265,6 @@ export class UpdateCommand extends Command<UpdateCommandSchema> {
265265

266266
// tslint:disable-next-line:no-big-function
267267
async run(options: UpdateCommandSchema & Arguments) {
268-
// Check if the @angular-devkit/schematics package can be resolved from the workspace root
269-
// This works around issues with packages containing migrations that cannot directly depend on the package
270-
// This check can be removed once the schematic runtime handles this situation
271-
try {
272-
require.resolve('@angular-devkit/schematics', { paths: [this.context.root] });
273-
} catch (e) {
274-
if (e.code === 'MODULE_NOT_FOUND') {
275-
this.logger.fatal(
276-
'The "@angular-devkit/schematics" package cannot be resolved from the workspace root directory. ' +
277-
'This may be due to an unsupported node modules structure.\n' +
278-
'Please remove both the "node_modules" directory and the package lock file; and then reinstall.\n' +
279-
'If this does not correct the problem, ' +
280-
'please temporarily install the "@angular-devkit/schematics" package within the workspace. ' +
281-
'It can be removed once the update is complete.',
282-
);
283-
284-
return 1;
285-
}
286-
287-
throw e;
288-
}
289-
290268
// Check if the current installed CLI version is older than the latest version.
291269
if (!disableVersionCheck && await this.checkCLILatestVersion(options.verbose, options.next)) {
292270
this.logger.warn(

packages/angular/cli/models/schematic-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
FileSystemCollection,
2424
FileSystemEngine,
2525
FileSystemSchematic,
26-
FileSystemSchematicDescription,
2726
NodeWorkflow,
2827
} from '@angular-devkit/schematics/tools';
2928
import * as inquirer from 'inquirer';
@@ -37,6 +36,7 @@ import { isPackageNameSafeForAnalytics } from './analytics';
3736
import { BaseCommandOptions, Command } from './command';
3837
import { Arguments, CommandContext, CommandDescription, Option } from './interface';
3938
import { parseArguments, parseFreeFormArguments } from './parser';
39+
import { SchematicEngineHost } from './schematic-engine-host';
4040

4141
export interface BaseSchematicSchema {
4242
debug?: boolean;
@@ -258,6 +258,7 @@ export abstract class SchematicCommand<
258258
...current,
259259
}),
260260
],
261+
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
261262
});
262263

263264
const getProjectName = () => {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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+
import { RuleFactory } from '@angular-devkit/schematics';
9+
import { NodeModulesEngineHost } from '@angular-devkit/schematics/tools';
10+
import { readFileSync } from 'fs';
11+
import { dirname, resolve } from 'path';
12+
import { Script } from 'vm';
13+
14+
/**
15+
* Environment variable to control schematic package redirection
16+
* Default: Angular schematics only
17+
*/
18+
const schematicRedirectVariable = process.env['NG_SCHEMATIC_REDIRECT']?.toLowerCase();
19+
20+
function shouldWrapSchematic(schematicFile: string): boolean {
21+
// Check environment variable if present
22+
if (schematicRedirectVariable !== undefined) {
23+
switch (schematicRedirectVariable) {
24+
case '0':
25+
case 'false':
26+
case 'off':
27+
case 'none':
28+
return false;
29+
case 'all':
30+
return true;
31+
}
32+
}
33+
34+
// Default is only first-party Angular schematic packages
35+
// Angular schematics are safe to use in the wrapped VM context
36+
return /[\/\\]node_modules[\/\\]@(?:angular|schematics|nguniversal)[\/\\]/.test(
37+
schematicFile,
38+
);
39+
}
40+
41+
export class SchematicEngineHost extends NodeModulesEngineHost {
42+
protected _resolveReferenceString(refString: string, parentPath: string) {
43+
const [path, name] = refString.split('#', 2);
44+
// Mimic behavior of ExportStringRef class used in default behavior
45+
const fullPath = path[0] === '.' ? resolve(parentPath ?? process.cwd(), path) : path;
46+
47+
const schematicFile = require.resolve(fullPath, { paths: [parentPath] });
48+
49+
if (shouldWrapSchematic(schematicFile)) {
50+
const schematicPath = dirname(schematicFile);
51+
52+
const moduleCache = new Map<string, unknown>();
53+
const factoryInitializer = wrap(
54+
schematicFile,
55+
schematicPath,
56+
moduleCache,
57+
name || 'default',
58+
) as () => RuleFactory<{}>;
59+
60+
const factory = factoryInitializer();
61+
if (!factory || typeof factory !== 'function') {
62+
return null;
63+
}
64+
65+
return { ref: factory, path: schematicPath };
66+
}
67+
68+
// All other schematics use default behavior
69+
return super._resolveReferenceString(refString, parentPath);
70+
}
71+
}
72+
73+
/**
74+
* Wrap a JavaScript file in a VM context to allow specific Angular dependencies to be redirected.
75+
* This VM setup is ONLY intended to redirect dependencies.
76+
*
77+
* @param schematicFile A JavaScript schematic file path that should be wrapped.
78+
* @param schematicDirectory A directory that will be used as the location of the JavaScript file.
79+
* @param moduleCache A map to use for caching repeat module usage and proper `instanceof` support.
80+
* @param exportName An optional name of a specific export to return. Otherwise, return all exports.
81+
*/
82+
function wrap(
83+
schematicFile: string,
84+
schematicDirectory: string,
85+
moduleCache: Map<string, unknown>,
86+
exportName?: string,
87+
): () => unknown {
88+
const { createRequire, createRequireFromPath } = require('module');
89+
// Node.js 10.x does not support `createRequire` so fallback to `createRequireFromPath`
90+
// `createRequireFromPath` is deprecated in 12+ and can be removed once 10.x support is removed
91+
const scopedRequire = createRequire?.(schematicFile) || createRequireFromPath(schematicFile);
92+
93+
const customRequire = function (id: string) {
94+
if (id.startsWith('@angular-devkit/') || id.startsWith('@schematics/')) {
95+
// Resolve from inside the `@angular/cli` project
96+
const packagePath = require.resolve(id);
97+
98+
return require(packagePath);
99+
} else if (id.startsWith('.') || id.startsWith('@angular/cdk')) {
100+
// Wrap relative files inside the schematic collection
101+
// Also wrap `@angular/cdk`, it contains helper utilities that import core schematic packages
102+
103+
// Resolve from the original file
104+
const modulePath = scopedRequire.resolve(id);
105+
106+
// Use cached module if available
107+
const cachedModule = moduleCache.get(modulePath);
108+
if (cachedModule) {
109+
return cachedModule;
110+
}
111+
112+
// Do not wrap vendored third-party packages
113+
if (
114+
!/[\/\\]node_modules[\/\\]@schematics[\/\\]angular[\/\\]third_party[\/\\]/.test(modulePath)
115+
) {
116+
// Wrap module and save in cache
117+
const wrappedModule = wrap(modulePath, dirname(modulePath), moduleCache)();
118+
moduleCache.set(modulePath, wrappedModule);
119+
120+
return wrappedModule;
121+
}
122+
}
123+
124+
// All others are required directly from the original file
125+
return scopedRequire(id);
126+
};
127+
128+
// Setup a wrapper function to capture the module's exports
129+
const schematicCode = readFileSync(schematicFile, 'utf8');
130+
// `module` is required due to @angular/localize ng-add being in UMD format
131+
const headerCode =
132+
'(function() {\nvar exports = Object.create(null);\nvar module = { exports };\n';
133+
const footerCode = exportName ? `\nreturn exports['${exportName}'];});` : '\nreturn exports;});';
134+
135+
const script = new Script(headerCode + schematicCode + footerCode, {
136+
filename: schematicFile,
137+
lineOffset: 3,
138+
});
139+
140+
const context = {
141+
__dirname: schematicDirectory,
142+
__filename: schematicFile,
143+
Buffer,
144+
console,
145+
process,
146+
get global() {
147+
return this;
148+
},
149+
require: customRequire,
150+
};
151+
152+
const exportsFactory = script.runInNewContext(context);
153+
154+
return exportsFactory;
155+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { join } from 'path';
2+
import { expectFileToMatch } from '../../utils/fs';
3+
import { ng } from '../../utils/process';
4+
import { installPackage, uninstallPackage } from '../../utils/packages';
5+
import { isPrereleaseCli } from '../../utils/project';
6+
7+
export default async function () {
8+
const componentDir = join('src', 'app', 'test-component');
9+
10+
// Install old and incompatible version
11+
// Must directly use npm registry since old versions are not hosted locally
12+
await installPackage('@schematics/angular@7', 'https://registry.npmjs.org')
13+
14+
const tag = await isPrereleaseCli() ? '@next' : '';
15+
await ng('add', `@angular/material${tag}`);
16+
await expectFileToMatch('package.json', /@angular\/material/);
17+
18+
// Clean up existing cdk package
19+
// Not doing so can cause adding material to fail if an incompatible cdk is present
20+
await uninstallPackage('@angular/cdk');
21+
}

tests/legacy-cli/e2e/utils/packages.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ export async function installWorkspacePackages(updateWebdriver = true): Promise<
2727
}
2828
}
2929

30-
export async function installPackage(specifier: string): Promise<ProcessOutput> {
30+
export async function installPackage(specifier: string, registry?: string): Promise<ProcessOutput> {
31+
const registryOption = registry ? [`--registry=${registry}`] : [];
3132
switch (getActivePackageManager()) {
3233
case 'npm':
33-
return silentNpm('install', specifier);
34+
return silentNpm('install', specifier, ...registryOption);
3435
case 'yarn':
35-
return silentYarn('add', specifier);
36+
return silentYarn('add', specifier, ...registryOption);
3637
}
3738
}
3839

0 commit comments

Comments
 (0)