From 87c0004cb46ff7429e6666c01c35910fc4316c15 Mon Sep 17 00:00:00 2001 From: Otabek Olimjonov Date: Tue, 28 Apr 2026 23:44:09 +0500 Subject: [PATCH] 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 --- packages/angular/cli/src/commands/add/cli.ts | 31 +++ .../angular/cli/src/commands/add/cli_spec.ts | 194 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/angular/cli/src/commands/add/cli_spec.ts diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 136704947e69..39b8c722b2e3 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -591,6 +591,7 @@ export default class AddCommandModule join(context.collectionName, 'package.json'), ); + await this.refreshInstalledPackageInfo(context, resolvedCollectionPath, false); context.collectionName = dirname(resolvedCollectionPath); } else { await packageManager.add( @@ -603,6 +604,8 @@ export default class AddCommandModule registry, }, ); + + await this.refreshInstalledPackageInfo(context); } } catch (e) { if (e instanceof PackageManagerError) { @@ -616,6 +619,34 @@ export default class AddCommandModule } } + private async refreshInstalledPackageInfo( + context: AddCommandTaskContext, + installedPackagePath?: string, + updateCollectionName = true, + ): Promise { + installedPackagePath ??= this.resolvePackageJson(context.collectionName ?? ''); + if (!installedPackagePath) { + return; + } + + try { + const installedManifest = JSON.parse( + await fs.readFile(installedPackagePath, 'utf-8'), + ) as PackageManifest; + + context.hasSchematics = !!installedManifest.schematics; + if (updateCollectionName) { + context.collectionName = installedManifest.name; + } + context.homepage = installedManifest.homepage ?? context.homepage; + } catch (e) { + assertIsError(e); + this.context.logger.debug( + `Unable to read installed package information from '${installedPackagePath}': ${e.message}`, + ); + } + } + private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { if (!packageIdentifier.name) { return false; diff --git a/packages/angular/cli/src/commands/add/cli_spec.ts b/packages/angular/cli/src/commands/add/cli_spec.ts new file mode 100644 index 000000000000..99de84773156 --- /dev/null +++ b/packages/angular/cli/src/commands/add/cli_spec.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Argv } from 'yargs'; +import type { CommandContext } from '../../command-builder/definitions'; +import type { PackageManager, PackageManifest } from '../../package-managers'; +import AddCommandModule from './cli'; + +describe('AddCommandModule', () => { + let root: string; + let logger: logging.Logger; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'angular-cli-add-')); + logger = { + info: jasmine.createSpy('info'), + error: jasmine.createSpy('error'), + warn: jasmine.createSpy('warn'), + debug: jasmine.createSpy('debug'), + fatal: jasmine.createSpy('fatal'), + } as unknown as logging.Logger; + + await writeFile(join(root, 'package.json'), '{}'); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('uses the installed package manifest to detect ng-add schematics', async () => { + const packageName = '@private/package'; + const packageManager = createPackageManager({ + async add() { + await writeInstalledPackageManifest(packageName, { + name: packageName, + version: '1.0.0', + schematics: './collection.json', + }); + }, + getManifest: jasmine + .createSpy('getManifest') + .and.resolveTo({ name: packageName, version: '1.0.0' }), + }); + const command = createCommand(packageManager); + const { createSchematic } = mockSchematicWorkflow(command); + + const result = await command.run({ + collection: `${packageName}@1.0.0`, + defaults: false, + dryRun: false, + force: false, + interactive: false, + skipConfirmation: true, + }); + + expect(result).toBe(0); + expect(packageManager.add).toHaveBeenCalled(); + expect(createSchematic).toHaveBeenCalledWith('ng-add', true); + expect(command.executeSchematic).toHaveBeenCalledWith( + jasmine.objectContaining({ collection: packageName }), + ); + }); + + it('uses the temporary package manifest to detect ng-add schematics', async () => { + const packageName = '@private/package'; + const workingDirectory = join(root, 'temp-install'); + const packageManager = createPackageManager({ + async acquireTempPackage() { + await writeInstalledPackageManifest( + packageName, + { + name: packageName, + version: '1.0.0', + schematics: './collection.json', + }, + workingDirectory, + ); + + return { workingDirectory, cleanup: jasmine.createSpy('cleanup') }; + }, + getManifest: jasmine.createSpy('getManifest').and.resolveTo({ + name: packageName, + version: '1.0.0', + 'ng-add': { save: false }, + }), + }); + const command = createCommand(packageManager); + const { createSchematic } = mockSchematicWorkflow(command); + + const result = await command.run({ + collection: `${packageName}@1.0.0`, + defaults: false, + dryRun: false, + force: false, + interactive: false, + skipConfirmation: true, + }); + + expect(result).toBe(0); + expect(packageManager.add).not.toHaveBeenCalled(); + expect(packageManager.acquireTempPackage).toHaveBeenCalled(); + expect(createSchematic).toHaveBeenCalledWith('ng-add', true); + expect(command.executeSchematic).toHaveBeenCalledWith( + jasmine.objectContaining({ + collection: join(workingDirectory, 'node_modules', ...packageName.split('/')), + }), + ); + }); + + function createCommand(packageManager: PackageManager): AddCommandModuleInternals { + const context = { + args: { + positional: [], + options: { + getYargsCompletions: false, + help: false, + jsonHelp: false, + }, + }, + currentDirectory: root, + globalConfiguration: {}, + logger, + packageManager, + root, + yargsInstance: {} as Argv, + } as unknown as CommandContext; + + const command = new AddCommandModule(context) as unknown as AddCommandModuleInternals; + command.executeSchematic = jasmine.createSpy('executeSchematic').and.resolveTo(0); + + return command; + } + + function createPackageManager(options: { + acquireTempPackage?: PackageManager['acquireTempPackage']; + add?: PackageManager['add']; + getManifest: jasmine.Spy; + }): PackageManager { + const packageManager = { + acquireTempPackage: jasmine + .createSpy('acquireTempPackage') + .and.callFake(options.acquireTempPackage ?? fail), + add: jasmine.createSpy('add').and.callFake(options.add ?? fail), + getManifest: options.getManifest, + name: 'npm', + } as unknown as PackageManager; + + return packageManager; + } + + function mockSchematicWorkflow(command: AddCommandModuleInternals): { + createSchematic: jasmine.Spy; + } { + const createSchematic = jasmine.createSpy('createSchematic'); + + command.getOrCreateWorkflowForBuilder = jasmine + .createSpy('getOrCreateWorkflowForBuilder') + .and.returnValue({ + engine: { + createCollection: jasmine.createSpy('createCollection').and.returnValue({ + createSchematic, + }), + }, + }); + + return { createSchematic }; + } + + async function writeInstalledPackageManifest( + packageName: string, + manifest: PackageManifest, + basePath = root, + ): Promise { + const packagePath = join(basePath, 'node_modules', ...packageName.split('/')); + + await mkdir(packagePath, { recursive: true }); + await writeFile(join(packagePath, 'package.json'), JSON.stringify(manifest)); + } +}); + +type AddCommandModuleInternals = { + executeSchematic: jasmine.Spy; + getOrCreateWorkflowForBuilder: jasmine.Spy; + run: AddCommandModule['run']; +};