Skip to content

Commit 87c0004

Browse files
fix(@angular/cli): detect ng-add schematics after install
Refresh ng-add schematic detection from the installed package manifest after installation. Some private registries can omit the schematics field from metadata returned by package manager view commands. This caused the CLI to skip ng-add actions on the first run even though the installed package included a valid collection. Keep the existing registry manifest lookup for version, peer dependency, save, and homepage data, but treat the package.json that was actually installed as authoritative for whether schematics are present. Apply the same refresh to temporary installs used by packages with ng-add.save=false so both install paths behave consistently. Add focused AddCommandModule coverage for regular and temporary installs where registry metadata omits schematics and the installed package provides them. Fixes #33060
1 parent 7da255d commit 87c0004

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

packages/angular/cli/src/commands/add/cli.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ export default class AddCommandModule
591591
join(context.collectionName, 'package.json'),
592592
);
593593

594+
await this.refreshInstalledPackageInfo(context, resolvedCollectionPath, false);
594595
context.collectionName = dirname(resolvedCollectionPath);
595596
} else {
596597
await packageManager.add(
@@ -603,6 +604,8 @@ export default class AddCommandModule
603604
registry,
604605
},
605606
);
607+
608+
await this.refreshInstalledPackageInfo(context);
606609
}
607610
} catch (e) {
608611
if (e instanceof PackageManagerError) {
@@ -616,6 +619,34 @@ export default class AddCommandModule
616619
}
617620
}
618621

622+
private async refreshInstalledPackageInfo(
623+
context: AddCommandTaskContext,
624+
installedPackagePath?: string,
625+
updateCollectionName = true,
626+
): Promise<void> {
627+
installedPackagePath ??= this.resolvePackageJson(context.collectionName ?? '');
628+
if (!installedPackagePath) {
629+
return;
630+
}
631+
632+
try {
633+
const installedManifest = JSON.parse(
634+
await fs.readFile(installedPackagePath, 'utf-8'),
635+
) as PackageManifest;
636+
637+
context.hasSchematics = !!installedManifest.schematics;
638+
if (updateCollectionName) {
639+
context.collectionName = installedManifest.name;
640+
}
641+
context.homepage = installedManifest.homepage ?? context.homepage;
642+
} catch (e) {
643+
assertIsError(e);
644+
this.context.logger.debug(
645+
`Unable to read installed package information from '${installedPackagePath}': ${e.message}`,
646+
);
647+
}
648+
}
649+
619650
private async isProjectVersionValid(packageIdentifier: npa.Result): Promise<boolean> {
620651
if (!packageIdentifier.name) {
621652
return false;
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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.dev/license
7+
*/
8+
9+
import { logging } from '@angular-devkit/core';
10+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
11+
import { tmpdir } from 'node:os';
12+
import { join } from 'node:path';
13+
import type { Argv } from 'yargs';
14+
import type { CommandContext } from '../../command-builder/definitions';
15+
import type { PackageManager, PackageManifest } from '../../package-managers';
16+
import AddCommandModule from './cli';
17+
18+
describe('AddCommandModule', () => {
19+
let root: string;
20+
let logger: logging.Logger;
21+
22+
beforeEach(async () => {
23+
root = await mkdtemp(join(tmpdir(), 'angular-cli-add-'));
24+
logger = {
25+
info: jasmine.createSpy('info'),
26+
error: jasmine.createSpy('error'),
27+
warn: jasmine.createSpy('warn'),
28+
debug: jasmine.createSpy('debug'),
29+
fatal: jasmine.createSpy('fatal'),
30+
} as unknown as logging.Logger;
31+
32+
await writeFile(join(root, 'package.json'), '{}');
33+
});
34+
35+
afterEach(async () => {
36+
await rm(root, { recursive: true, force: true });
37+
});
38+
39+
it('uses the installed package manifest to detect ng-add schematics', async () => {
40+
const packageName = '@private/package';
41+
const packageManager = createPackageManager({
42+
async add() {
43+
await writeInstalledPackageManifest(packageName, {
44+
name: packageName,
45+
version: '1.0.0',
46+
schematics: './collection.json',
47+
});
48+
},
49+
getManifest: jasmine
50+
.createSpy('getManifest')
51+
.and.resolveTo({ name: packageName, version: '1.0.0' }),
52+
});
53+
const command = createCommand(packageManager);
54+
const { createSchematic } = mockSchematicWorkflow(command);
55+
56+
const result = await command.run({
57+
collection: `${packageName}@1.0.0`,
58+
defaults: false,
59+
dryRun: false,
60+
force: false,
61+
interactive: false,
62+
skipConfirmation: true,
63+
});
64+
65+
expect(result).toBe(0);
66+
expect(packageManager.add).toHaveBeenCalled();
67+
expect(createSchematic).toHaveBeenCalledWith('ng-add', true);
68+
expect(command.executeSchematic).toHaveBeenCalledWith(
69+
jasmine.objectContaining({ collection: packageName }),
70+
);
71+
});
72+
73+
it('uses the temporary package manifest to detect ng-add schematics', async () => {
74+
const packageName = '@private/package';
75+
const workingDirectory = join(root, 'temp-install');
76+
const packageManager = createPackageManager({
77+
async acquireTempPackage() {
78+
await writeInstalledPackageManifest(
79+
packageName,
80+
{
81+
name: packageName,
82+
version: '1.0.0',
83+
schematics: './collection.json',
84+
},
85+
workingDirectory,
86+
);
87+
88+
return { workingDirectory, cleanup: jasmine.createSpy('cleanup') };
89+
},
90+
getManifest: jasmine.createSpy('getManifest').and.resolveTo({
91+
name: packageName,
92+
version: '1.0.0',
93+
'ng-add': { save: false },
94+
}),
95+
});
96+
const command = createCommand(packageManager);
97+
const { createSchematic } = mockSchematicWorkflow(command);
98+
99+
const result = await command.run({
100+
collection: `${packageName}@1.0.0`,
101+
defaults: false,
102+
dryRun: false,
103+
force: false,
104+
interactive: false,
105+
skipConfirmation: true,
106+
});
107+
108+
expect(result).toBe(0);
109+
expect(packageManager.add).not.toHaveBeenCalled();
110+
expect(packageManager.acquireTempPackage).toHaveBeenCalled();
111+
expect(createSchematic).toHaveBeenCalledWith('ng-add', true);
112+
expect(command.executeSchematic).toHaveBeenCalledWith(
113+
jasmine.objectContaining({
114+
collection: join(workingDirectory, 'node_modules', ...packageName.split('/')),
115+
}),
116+
);
117+
});
118+
119+
function createCommand(packageManager: PackageManager): AddCommandModuleInternals {
120+
const context = {
121+
args: {
122+
positional: [],
123+
options: {
124+
getYargsCompletions: false,
125+
help: false,
126+
jsonHelp: false,
127+
},
128+
},
129+
currentDirectory: root,
130+
globalConfiguration: {},
131+
logger,
132+
packageManager,
133+
root,
134+
yargsInstance: {} as Argv,
135+
} as unknown as CommandContext;
136+
137+
const command = new AddCommandModule(context) as unknown as AddCommandModuleInternals;
138+
command.executeSchematic = jasmine.createSpy('executeSchematic').and.resolveTo(0);
139+
140+
return command;
141+
}
142+
143+
function createPackageManager(options: {
144+
acquireTempPackage?: PackageManager['acquireTempPackage'];
145+
add?: PackageManager['add'];
146+
getManifest: jasmine.Spy;
147+
}): PackageManager {
148+
const packageManager = {
149+
acquireTempPackage: jasmine
150+
.createSpy('acquireTempPackage')
151+
.and.callFake(options.acquireTempPackage ?? fail),
152+
add: jasmine.createSpy('add').and.callFake(options.add ?? fail),
153+
getManifest: options.getManifest,
154+
name: 'npm',
155+
} as unknown as PackageManager;
156+
157+
return packageManager;
158+
}
159+
160+
function mockSchematicWorkflow(command: AddCommandModuleInternals): {
161+
createSchematic: jasmine.Spy;
162+
} {
163+
const createSchematic = jasmine.createSpy('createSchematic');
164+
165+
command.getOrCreateWorkflowForBuilder = jasmine
166+
.createSpy('getOrCreateWorkflowForBuilder')
167+
.and.returnValue({
168+
engine: {
169+
createCollection: jasmine.createSpy('createCollection').and.returnValue({
170+
createSchematic,
171+
}),
172+
},
173+
});
174+
175+
return { createSchematic };
176+
}
177+
178+
async function writeInstalledPackageManifest(
179+
packageName: string,
180+
manifest: PackageManifest,
181+
basePath = root,
182+
): Promise<void> {
183+
const packagePath = join(basePath, 'node_modules', ...packageName.split('/'));
184+
185+
await mkdir(packagePath, { recursive: true });
186+
await writeFile(join(packagePath, 'package.json'), JSON.stringify(manifest));
187+
}
188+
});
189+
190+
type AddCommandModuleInternals = {
191+
executeSchematic: jasmine.Spy;
192+
getOrCreateWorkflowForBuilder: jasmine.Spy;
193+
run: AddCommandModule['run'];
194+
};

0 commit comments

Comments
 (0)