From 6e35c2787b7c60a9d29d6c1b7ba118c727b3cf44 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:26:50 -0400 Subject: [PATCH] fix(@angular/cli): fallback to local package.json for schematic detection on first run Private package registries frequently strip out custom non-npm metadata properties such as "schematics" or "ng-add" from their remote API responses. This causes `ng add` to bypass executing schematics on the first run. This fix adds a fallback check immediately after package installation: if the registry did not report `hasSchematics` as `true`, the CLI falls back to resolving and reading the physically installed package's `package.json` on disk as the single source of truth. Additionally, if the local manifest specifies `ng-add.save: false` (but it was persistently installed due to registry omissions), it programmatically prunes the package from `dependencies` or `devDependencies` post-execution, and executes a silent `packageManager.install()` to cleanly remove the physical package files and update the lockfile. Fixes #33060 --- packages/angular/cli/src/commands/add/cli.ts | 46 +++++++- .../add/add-registry-stripped-schematics.ts | 109 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index 136704947e69..5153d78130d6 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -245,6 +245,22 @@ export default class AddCommandModule const result = await tasks.run(taskContext); assert(result.collectionName, 'Collection name should always be available'); + let shouldCleanUp = false; + if (!result.hasSchematics && !options.dryRun) { + const packageJsonPath = this.resolvePackageJson(result.collectionName); + if (packageJsonPath && existsSync(packageJsonPath)) { + try { + const localManifest = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); + if (localManifest.schematics) { + result.hasSchematics = true; + if (localManifest['ng-add']?.save === false) { + shouldCleanUp = true; + } + } + } catch {} + } + } + // Check if the installed package has actual add actions and not just schematic support if (result.hasSchematics && !options.dryRun) { const workflow = this.getOrCreateWorkflowForBuilder(result.collectionName); @@ -299,7 +315,35 @@ export default class AddCommandModule return; } - return this.executeSchematic({ ...options, collection: result.collectionName }); + const schematicExitCode = await this.executeSchematic({ + ...options, + collection: result.collectionName, + }); + + if (shouldCleanUp) { + logger.info(`Cleaning up temporary dependency '${result.collectionName}'...`); + + // 1. Remove from root package.json + const projectManifest = await this.getProjectManifest(); + if (projectManifest) { + if (projectManifest.dependencies) { + delete projectManifest.dependencies[result.collectionName]; + } + if (projectManifest.devDependencies) { + delete projectManifest.devDependencies[result.collectionName]; + } + + await fs.writeFile( + join(this.context.root, 'package.json'), + JSON.stringify(projectManifest, null, 2), + ); + } + + // 2. Silent install pass to prune files from node_modules and update the lockfile + await this.context.packageManager.install({ ignoreScripts: true }); + } + + return schematicExitCode; } catch (e) { if (e instanceof CommandError) { logger.error(e.message); diff --git a/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts new file mode 100644 index 000000000000..35d3384464bd --- /dev/null +++ b/tests/e2e/tests/commands/add/add-registry-stripped-schematics.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path'; +import { promises as fs } from 'node:fs'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToExist, expectFileNotToExist, rimraf } from '../../../utils/fs'; +import { ng, silentNpm } from '../../../utils/process'; +import { mktempd } from '../../../utils/utils'; + +export default async function () { + const testRegistry = getGlobalVariable('package-registry'); + const tmpRoot = getGlobalVariable('tmp-root'); + + // 1. Create a temp directory for the custom package + const pkgDir = await mktempd('registry-stripped-pkg-', tmpRoot); + + try { + // 2. Write the package files + const packageJson = { + name: '@angular-devkit/ng-add-registry-stripped', + version: '1.0.0', + schematics: './collection.json', + 'ng-add': { + save: false, + }, + }; + + const collectionJson = { + schematics: { + 'ng-add': { + factory: './index.js', + description: 'Add test empty file to your application.', + }, + }, + }; + + const indexJs = ` + exports.default = function() { + return function(tree) { + tree.create('schematic-executed-successfully.txt', 'Registry Stripped schematic works!'); + return tree; + }; + }; + `; + + await fs.writeFile(join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + await fs.writeFile(join(pkgDir, 'collection.json'), JSON.stringify(collectionJson, null, 2)); + await fs.writeFile(join(pkgDir, 'index.js'), indexJs); + + // 3. Pack the package + const packResult = await silentNpm(['pack'], { cwd: pkgDir }); + const tarballName = packResult.stdout.trim().split('\n').pop() || ''; + + // 4. Publish the package to the local verdaccio registry + // Verdaccio has publish: $all for @angular-devkit/* so this will succeed + await silentNpm(['publish', `--registry=${testRegistry}`], { cwd: pkgDir }); + + // 5. Strip "schematics" and "ng-add" from Verdaccio's metadata on disk + const verdaccioDbPath = join( + tmpRoot, + 'registry', + 'storage', + '@angular-devkit', + 'ng-add-registry-stripped', + 'package.json', + ); + + const verdaccioDb = JSON.parse(await fs.readFile(verdaccioDbPath, 'utf-8')); + + // Strip from the top-level versions list + if (verdaccioDb.versions) { + for (const versionKey of Object.keys(verdaccioDb.versions)) { + delete verdaccioDb.versions[versionKey].schematics; + delete verdaccioDb.versions[versionKey]['ng-add']; + } + } + + // Write back the modified metadata + await fs.writeFile(verdaccioDbPath, JSON.stringify(verdaccioDb, null, 2), 'utf-8'); + + // 6. Execute `ng add` on the registry-stripped package + // Ensure file doesn't already exist + await expectFileNotToExist('schematic-executed-successfully.txt'); + + await ng('add', '@angular-devkit/ng-add-registry-stripped', '--skip-confirmation'); + + // 7. Assertions + // A. The schematic executed successfully + await expectFileToExist('schematic-executed-successfully.txt'); + + // B. The dependency was pruned from package.json since save: false + const rootPackageJson = JSON.parse(await fs.readFile('package.json', 'utf-8')); + const hasDep = + (rootPackageJson.dependencies && + rootPackageJson.dependencies['@angular-devkit/ng-add-registry-stripped']) || + (rootPackageJson.devDependencies && + rootPackageJson.devDependencies['@angular-devkit/ng-add-registry-stripped']); + + if (hasDep) { + throw new Error( + 'Package @angular-devkit/ng-add-registry-stripped was not cleaned up from package.json dependencies!', + ); + } + + // C. The dependency was pruned from node_modules physical folder + await expectFileNotToExist('node_modules/@angular-devkit/ng-add-registry-stripped'); + } finally { + // Cleanup temp package source folder + await rimraf(pkgDir); + } +}