From dda001ea9211b45580b80c13448fe9de54a474a3 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 8 Jul 2026 18:46:20 -0700 Subject: [PATCH] fix(schematics): pin prerelease installs to the exact version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ng add records the installed package with npm's default caret prefix. For a stable release that only drifts to reviewed patch releases, but a prerelease range like ^21.0.0-rc.0 also matches the canary build published for every merge to main — so a later fresh install could silently replace the version the user chose with an unreviewed snapshot. After install, if the workspace's @angular/fire entry is a caret or tilde range around a prerelease, rewrite it to the exact installed version (only when that version already satisfies the range). Stable ranges and hand-authored range expressions are left untouched. --- src/schematics/add/index.ts | 3 +- src/schematics/common.jasmine.ts | 91 ++++++++++++++++++++++++++++++++ src/schematics/common.ts | 58 +++++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/schematics/common.jasmine.ts diff --git a/src/schematics/add/index.ts b/src/schematics/add/index.ts index 150be7c40..2a9c926ec 100644 --- a/src/schematics/add/index.ts +++ b/src/schematics/add/index.ts @@ -1,6 +1,6 @@ import { SchematicContext, Tree } from '@angular-devkit/schematics'; import { NodePackageInstallTask, RunSchematicTask } from '@angular-devkit/schematics/tasks'; -import { addDependencies } from '../common'; +import { addDependencies, pinInstalledPrereleaseVersion } from '../common'; import { DeployOptions } from '../interfaces'; import { peerDependencies } from '../versions.json'; @@ -10,6 +10,7 @@ export const ngAdd = (options: DeployOptions) => (tree: Tree, context: Schematic peerDependencies, context ); + pinInstalledPrereleaseVersion(tree, context); const npmInstallTaskId = context.addTask(new NodePackageInstallTask()); context.addTask(new RunSchematicTask('ng-add-setup-project', options), [npmInstallTaskId]); return tree; diff --git a/src/schematics/common.jasmine.ts b/src/schematics/common.jasmine.ts new file mode 100644 index 000000000..e7d66577a --- /dev/null +++ b/src/schematics/common.jasmine.ts @@ -0,0 +1,91 @@ +import { logging } from '@angular-devkit/core'; +import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import { pinInstalledPrereleaseVersion } from './common.js'; +import 'jasmine'; + +const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; + +const treeWithAngularFire = (declaredVersion: string) => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ + name: 'test-app', + dependencies: { + '@angular/fire': declaredVersion, + firebase: '^12.4.0', + }, + }, null, 2)); + return tree; +}; + +const dependenciesIn = (tree: HostTree) => + JSON.parse(tree.readText('package.json')).dependencies; + +describe('pinInstalledPrereleaseVersion', () => { + + it('pins a caret prerelease range to the exact installed version', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); + }); + + it('pins a tilde prerelease range to the exact installed version', () => { + const tree = treeWithAngularFire('~21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); + }); + + it('leaves other dependencies untouched when pinning', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree).firebase).toBe('^12.4.0'); + }); + + it('leaves a stable caret range untouched', () => { + const tree = treeWithAngularFire('^21.0.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0'); + }); + + it('leaves an exact prerelease version untouched', () => { + const tree = treeWithAngularFire('21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); + }); + + it('leaves a hand-authored range expression untouched', () => { + const tree = treeWithAngularFire('>=21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('>=21.0.0-rc.0'); + }); + + it('does not pin when the installed version does not satisfy the declared range', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, '22.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); + }); + + it('does not pin when the installed version is not valid semver', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, 'ANGULARFIRE2_VERSION'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); + }); + + it('does nothing when @angular/fire is not a dependency', () => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ name: 'test-app', dependencies: {} })); + expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow(); + expect(dependenciesIn(tree)['@angular/fire']).toBeUndefined(); + }); + + it('does nothing when package.json is absent', () => { + const tree = new HostTree(); + expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow(); + }); + + it('skips gracefully when the installed version cannot be determined', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0'); + pinInstalledPrereleaseVersion(tree, context, undefined); + expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); + }); + +}); diff --git a/src/schematics/common.ts b/src/schematics/common.ts index 9f3544efc..cb39d1bb0 100644 --- a/src/schematics/common.ts +++ b/src/schematics/common.ts @@ -1,5 +1,12 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; -import { intersects as semverIntersects } from 'semver'; +import { + intersects as semverIntersects, + prerelease as semverPrerelease, + satisfies as semverSatisfies, + valid as semverValid, +} from 'semver'; import { FirebaseHostingSite } from './interfaces'; export const shortSiteName = (site?: FirebaseHostingSite) => site?.name?.split('/').pop(); @@ -65,3 +72,52 @@ export const addDependencies = ( overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); }; + +/** + * Reads the exact version of the installed `@angular/fire` package from its own manifest — the + * compiled schematics run from `/schematics//`, so the manifest sits two + * directories up. Returns undefined when that layout doesn't hold (e.g. running from source), + * so callers skip pinning instead of failing the schematic. + */ +const readInstalledVersion = () => { + try { + return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json')).toString()).version; + } catch { + return undefined; + } +}; + +/** + * Pins the workspace's `@angular/fire` entry to the exact installed version when `ng add` wrote a + * prerelease range. A prerelease range like `^21.0.0-rc.0` also matches the canary build published + * for every merge to main, so a later fresh install can silently replace the version the user + * chose. Stable ranges are left untouched. + */ +export const pinInstalledPrereleaseVersion = ( + host: Tree, + context: SchematicContext, + installedVersion = readInstalledVersion(), +) => { + if (!host.exists('package.json')) { return; } + const packageJson = safeReadJSON('package.json', host); + + const declaredAngularFireVersion = packageJson.dependencies?.['@angular/fire']; + if ( + typeof declaredAngularFireVersion !== 'string' || + !(declaredAngularFireVersion.startsWith('^') || declaredAngularFireVersion.startsWith('~')) + ) { return; } + + if ( + semverValid(installedVersion) && + semverPrerelease(installedVersion) && + semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true }) + ) { + packageJson.dependencies['@angular/fire'] = installedVersion; + overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); + context.logger.info( + `Pinned @angular/fire to the exact version ${installedVersion} — a prerelease range like ` + + `${declaredAngularFireVersion} also matches unreviewed canary builds, so a later install ` + + 'could silently change versions.' + ); + } +};