Skip to content

Commit ccef2f8

Browse files
committed
refactor(@angular/cli): Change modernize to only return instructions.
1 parent 5229e0d commit ccef2f8

2 files changed

Lines changed: 99 additions & 370 deletions

File tree

packages/angular/cli/src/commands/mcp/tools/modernize.ts

Lines changed: 64 additions & 195 deletions
Original file line numberDiff line numberDiff line change
@@ -1,237 +1,126 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import { z } from 'zod';
3-
import path from 'node:path';
4-
import fs from 'node:fs';
5-
import os from 'node:os';
6-
import { NodeWorkflow } from '@angular-devkit/schematics/tools';
7-
import { NodeJsSyncHost } from '@angular-devkit/core/node';
8-
import { virtualFs } from '@angular-devkit/core';
93

10-
enum SchematicTarget {
11-
Code,
12-
Template,
4+
interface Transformation {
5+
name: string;
6+
description: string;
7+
documentationUrl: string;
8+
instructions?: string;
139
}
1410

15-
enum SchematicRunner {
16-
Migration,
17-
Collection,
18-
}
19-
20-
const TRANSFORMATIONS = [
11+
const TRANSFORMATIONS: Array<Transformation> = [
2112
{
2213
name: 'control-flow-migration',
2314
description:
2415
'Migrates from `*ngIf`, `*ngFor`, and `*ngSwitch` to the new `@if`, `@for`, and `@switch` block syntax in templates.',
25-
target: SchematicTarget.Template,
26-
runner: SchematicRunner.Collection,
27-
includedByDefault: true,
28-
documentation: 'https://angular.dev/reference/migrations/control-flow',
16+
documentationUrl: 'https://angular.dev/reference/migrations/control-flow',
2917
},
3018
{
3119
name: 'self-closing-tags-migration',
3220
description:
3321
'Converts tags for elements with no content to be self-closing (e.g., `<app-foo></app-foo>` becomes `<app-foo />`).',
34-
target: SchematicTarget.Template,
35-
runner: SchematicRunner.Collection,
36-
includedByDefault: true,
37-
documentation: 'https://angular.dev/reference/migrations/self-closing-tags',
22+
documentationUrl: 'https://angular.dev/reference/migrations/self-closing-tags',
3823
},
3924
{
4025
name: 'test-bed-get',
4126
description:
4227
'Updates `TestBed.get` to the preferred and type-safe `TestBed.inject` in TypeScript test files.',
43-
target: SchematicTarget.Code,
44-
runner: SchematicRunner.Migration,
45-
includedByDefault: true,
46-
documentation: 'https://angular.dev/guide/testing/dependency-injection',
28+
documentationUrl: 'https://angular.dev/guide/testing/dependency-injection',
4729
},
4830
{
4931
name: 'inject-flags',
5032
description:
5133
'Updates `inject` calls from using the InjectFlags enum to a more modern and readable options object.',
52-
target: SchematicTarget.Code,
53-
runner: SchematicRunner.Migration,
54-
includedByDefault: true,
55-
documentation: 'https://angular.dev/reference/migrations/inject-function',
34+
documentationUrl: 'https://angular.dev/reference/migrations/inject-function',
5635
},
5736
{
5837
name: 'output-migration',
5938
description: 'Converts `@Output` declarations to the new functional `output()` syntax.',
60-
target: SchematicTarget.Code,
61-
runner: SchematicRunner.Collection,
62-
includedByDefault: true,
63-
documentation: 'https://angular.dev/reference/migrations/outputs',
39+
documentationUrl: 'https://angular.dev/reference/migrations/outputs',
6440
},
6541
{
6642
name: 'signal-input-migration',
6743
description: 'Migrates `@Input` declarations to the new signal-based `input()` syntax.',
68-
target: SchematicTarget.Code,
69-
runner: SchematicRunner.Collection,
70-
includedByDefault: true,
71-
documentation: 'https://angular.dev/reference/migrations/signal-inputs',
44+
documentationUrl: 'https://angular.dev/reference/migrations/signal-inputs',
7245
},
7346
{
7447
name: 'signal-queries-migration',
7548
description:
7649
'Migrates `@ViewChild` and `@ContentChild` queries to their signal-based `viewChild` and `contentChild` versions.',
77-
target: SchematicTarget.Code,
78-
runner: SchematicRunner.Collection,
79-
includedByDefault: true,
80-
documentation: 'https://angular.dev/reference/migrations/signal-queries',
50+
documentationUrl: 'https://angular.dev/reference/migrations/signal-queries',
8151
},
8252
{
8353
name: 'standalone',
8454
description:
85-
'Converts the application to use standalone components, directives, and pipes. This is a three-step process. After each step, you should verify that your application builds and runs correctly. Full instructions at https://angular.dev/reference/migrations/standalone',
86-
target: SchematicTarget.Code,
87-
runner: SchematicRunner.Collection,
88-
includedByDefault: false,
89-
documentation: 'https://angular.dev/reference/migrations/standalone',
55+
'Converts the application to use standalone components, directives, and pipes. This is a three-step process. After each step, you should verify that your application builds and runs correctly.',
56+
instructions: `This migration requires running a cli schematic multiple times. Run the commands in the order listed below, verifying that your code builds and runs between each step:
57+
58+
1. Run \`ng g @angular/core:standalone\` and select "Convert all components, directives and pipes to standalone"
59+
2. Run \`ng g @angular/core:standalone\` and select "Remove unnecessary NgModule classes"
60+
3. Run \`ng g @angular/core:standalone\` and select "Bootstrap the project using standalone APIs"
61+
`,
62+
documentationUrl: 'https://angular.dev/reference/migrations/standalone',
9063
},
9164
{
9265
name: 'zoneless',
9366
description: 'Migrates the application to be zoneless.',
94-
includedByDefault: false,
95-
documentation: 'https://angular.dev/guide/zoneless',
67+
documentationUrl: 'https://angular.dev/guide/zoneless',
9668
},
97-
] as const;
98-
99-
const ALL_TRANSFORMATIONS = TRANSFORMATIONS.map((t) => t.name);
69+
];
10070

10171
const modernizeInputSchema = z.object({
102-
files: z.array(
103-
z.object({
104-
name: z.string().describe('The name of the file.'),
105-
content: z.string().describe('The content of the file.'),
106-
}),
107-
),
108-
transformations: z.array(z.enum(ALL_TRANSFORMATIONS as [string, ...string[]])).optional(),
109-
mode: z
110-
.enum(['convert-to-standalone', 'prune-ng-modules', 'standalone-bootstrap'])
111-
.optional()
112-
.describe('The mode to use for the standalone transformation.'),
72+
// Casting to [string, ...string[]] since the enum definition requires a nonempty array.
73+
transformations: z
74+
.array(z.enum(TRANSFORMATIONS.map((t) => t.name) as [string, ...string[]]))
75+
.optional(),
11376
});
11477

11578
export type ModernizeInput = z.infer<typeof modernizeInputSchema>;
11679

117-
// Extracted logic for testability
118-
export async function runModernization(
119-
input: ModernizeInput,
120-
workflow?: NodeWorkflow,
121-
tempDir?: string,
122-
) {
123-
const ownTempDir = !tempDir;
124-
tempDir ??= fs.mkdtempSync(path.join(os.tmpdir(), 'angular-cli-modernize-'));
80+
export async function runModernization(input: ModernizeInput) {
12581
try {
126-
const fileNames = input.files.map((f) => f.name);
127-
const tsconfig = {
128-
compilerOptions: {
129-
target: 'es2022',
130-
module: 'esnext',
131-
lib: ['es2022', 'dom'],
132-
skipLibCheck: true,
133-
esModuleInterop: true,
134-
allowSyntheticDefaultImports: true,
135-
experimentalDecorators: true,
136-
emitDecoratorMetadata: true,
137-
useDefineForClassFields: false,
138-
},
139-
files: fileNames,
140-
};
141-
fs.writeFileSync(path.join(tempDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
142-
for (const file of input.files) {
143-
const filePath = path.join(tempDir, file.name);
144-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
145-
fs.writeFileSync(filePath, file.content);
82+
if (!input.transformations || input.transformations.length === 0) {
83+
const instructions = [
84+
'See https://angular.dev/best-practices for Angular best practices. You can call this tool if you have specific transformation you want to run.',
85+
];
86+
return {
87+
content: [
88+
{
89+
type: 'text' as const,
90+
text: JSON.stringify({
91+
instructions,
92+
}),
93+
},
94+
],
95+
structuredContent: {
96+
instructions,
97+
},
98+
};
14699
}
147100

148-
workflow ??= new NodeWorkflow(
149-
new virtualFs.ScopedHost(new NodeJsSyncHost(), path.normalize(tempDir) as any),
150-
{
151-
packageManager: 'pnpm',
152-
dryRun: false,
153-
},
101+
const transformationsToRun = TRANSFORMATIONS.filter((t) =>
102+
input.transformations!.includes(t.name),
154103
);
155104

156-
const angularCorePath = path.dirname(require.resolve('@angular/core/package.json'));
157-
const collectionPaths = {
158-
[SchematicRunner.Migration]: path.join(angularCorePath, 'schematics/migrations.json'),
159-
[SchematicRunner.Collection]: path.join(angularCorePath, 'schematics/collection.json'),
160-
};
161-
162-
const transformationsToRun =
163-
input.transformations && input.transformations.length > 0
164-
? TRANSFORMATIONS.filter((t) => input.transformations!.includes(t.name))
165-
: TRANSFORMATIONS.filter((t) => t.includedByDefault);
166-
167-
const hasCodeFiles = input.files.some((f) => f.name.endsWith('.ts'));
168-
const hasTemplateFiles = input.files.some(
169-
(f) => f.name.endsWith('.html') || f.name.endsWith('.ng.html'),
170-
);
171-
172-
let instructions: string | undefined;
173-
const documentation = new Set<string>();
105+
const allInstructions: string[] = [];
174106

175107
for (const transformation of transformationsToRun) {
176-
if (transformation.documentation) {
177-
documentation.add(transformation.documentation);
108+
let transformationInstructions = '';
109+
if (transformation.instructions) {
110+
transformationInstructions = transformation.instructions;
111+
} else {
112+
// If no instructions are included, default to running a cli schematic with the transformation name.
113+
const command = `ng generate @angular/core:${transformation.name}`;
114+
transformationInstructions = `To run the ${transformation.name} migration, execute the following command: \`${command}\`.`;
178115
}
179-
180-
let options: { [key: string]: unknown } = { path: '/' };
181-
if (transformation.name === 'standalone') {
182-
const mode = input.mode ?? 'convert-to-standalone';
183-
options = { ...options, mode };
184-
185-
if (mode === 'convert-to-standalone') {
186-
instructions =
187-
'The first step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "prune-ng-modules"` to continue.';
188-
} else if (mode === 'prune-ng-modules') {
189-
instructions =
190-
'The second step of the `standalone` migration has been performed. Please verify that your application builds and runs correctly. Then, run this tool again with `mode: "standalone-bootstrap"` to complete the migration.';
191-
} else {
192-
instructions = 'The `standalone` migration has been completed.';
193-
}
194-
} else if (transformation.name === 'zoneless') {
195-
instructions =
196-
'The `zoneless` migration is a manual process. Please follow the instructions at https://angular.dev/guide/zoneless to complete the migration.';
197-
continue; // Don't run a schematic for zoneless
116+
if (transformation.documentationUrl) {
117+
transformationInstructions += `\nFor more information, see ${transformation.documentationUrl}.`;
198118
}
199-
200-
if (
201-
(transformation.target === SchematicTarget.Code && !hasCodeFiles) ||
202-
(transformation.target === SchematicTarget.Template && !hasTemplateFiles)
203-
) {
204-
continue;
205-
}
206-
207-
await workflow
208-
.execute({
209-
collection: collectionPaths[transformation.runner],
210-
schematic: transformation.name,
211-
options,
212-
})
213-
.toPromise();
119+
allInstructions.push(transformationInstructions);
214120
}
215121

216-
const updatedFiles = input.files.map((file) => {
217-
const filePath = path.join(tempDir!, file.name);
218-
if (!fs.existsSync(filePath)) {
219-
return { name: file.name, content: undefined, changed: true };
220-
}
221-
const updatedContent = fs.readFileSync(filePath, 'utf8');
222-
const changed = updatedContent !== file.content;
223-
224-
return {
225-
name: file.name,
226-
content: changed ? updatedContent : undefined,
227-
changed,
228-
};
229-
});
230-
231122
const structuredContent = {
232-
files: updatedFiles,
233-
instructions,
234-
documentation: documentation.size > 0 ? [...documentation].join('\n') : undefined,
123+
instructions: allInstructions.length ? allInstructions : undefined,
235124
};
236125

237126
return {
@@ -250,10 +139,6 @@ export async function runModernization(
250139
structuredContent: {},
251140
isError: true,
252141
};
253-
} finally {
254-
if (ownTempDir && tempDir) {
255-
fs.rmSync(tempDir, { recursive: true, force: true });
256-
}
257142
}
258143
}
259144

@@ -272,33 +157,17 @@ export function registerModernizeTool(server: McpServer): void {
272157
'* When the user asks for a specific transformation: When the transformation list is populated, these specific ones will be ran on the inputs.\n' +
273158
'</Use Cases>\n' +
274159
'<Transformations>\n' +
275-
'<Default Transformations>\n' +
276-
TRANSFORMATIONS.filter((t) => t.includedByDefault)
277-
.map((t) => `* ${t.name}: ${t.description}`)
278-
.join('\n') +
279-
'\n</Default Transformations>\n' +
280-
'<On-Request Transformations>\n' +
281-
TRANSFORMATIONS.filter((t) => !t.includedByDefault)
282-
.map((t) => `* ${t.name}: ${t.description}`)
283-
.join('\n') +
284-
'/\n<On-Request Transformations>\n' +
160+
TRANSFORMATIONS.map((t) => `* ${t.name}: ${t.description}`).join('\n') +
285161
'\n</Transformations>\n',
286162
annotations: {
287163
readOnlyHint: true,
288164
},
289165
inputSchema: modernizeInputSchema.shape,
290166
outputSchema: {
291-
files: z
292-
.array(
293-
z.object({
294-
name: z.string().describe('The name of the file.'),
295-
content: z.string().optional().describe('The updated content of the file.'),
296-
changed: z.boolean().describe('Whether the file was changed.'),
297-
}),
298-
)
299-
.optional(),
300-
instructions: z.string().optional().describe('Additional instructions.'),
301-
documentation: z.string().optional().describe('A link to relevant documentation.'),
167+
instructions: z
168+
.array(z.string())
169+
.optional()
170+
.describe('A list of instructions on how to run the migrations.'),
302171
},
303172
},
304173
(input) => runModernization(input as ModernizeInput),

0 commit comments

Comments
 (0)