From dda001ea9211b45580b80c13448fe9de54a474a3 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 8 Jul 2026 18:46:20 -0700 Subject: [PATCH 1/2] 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.' + ); + } +}; From 3e7242a6f2ef81b5d85438b8f0211be89cf2a0bb Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Fri, 10 Jul 2026 15:52:01 -0700 Subject: [PATCH 2/2] fix(schematics): address review feedback on the prerelease pin - warn (instead of silently skipping) when the installed version can't be determined - pin the @angular/fire entry whether it sits in dependencies or devDependencies - resolve the installed version from a build-written placeholder rather than a runtime readFileSync/__dirname traversal; the build fails if the placeholder is not substituted, so a broken pin can't ship silently --- src/schematics/common.jasmine.ts | 44 +++++++++++++++++++++++++------- src/schematics/common.ts | 42 ++++++++++++++---------------- tools/build.ts | 18 ++++++++++++- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/schematics/common.jasmine.ts b/src/schematics/common.jasmine.ts index e7d66577a..b40612d04 100644 --- a/src/schematics/common.jasmine.ts +++ b/src/schematics/common.jasmine.ts @@ -5,11 +5,11 @@ import 'jasmine'; const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; -const treeWithAngularFire = (declaredVersion: string) => { +const treeWithAngularFire = (declaredVersion: string, section = 'dependencies') => { const tree = new HostTree(); tree.create('package.json', JSON.stringify({ name: 'test-app', - dependencies: { + [section]: { '@angular/fire': declaredVersion, firebase: '^12.4.0', }, @@ -17,8 +17,10 @@ const treeWithAngularFire = (declaredVersion: string) => { return tree; }; -const dependenciesIn = (tree: HostTree) => - JSON.parse(tree.readText('package.json')).dependencies; +const sectionIn = (tree: HostTree, section: string) => + JSON.parse(tree.readText('package.json'))[section]; + +const dependenciesIn = (tree: HostTree) => sectionIn(tree, 'dependencies'); describe('pinInstalledPrereleaseVersion', () => { @@ -64,12 +66,24 @@ describe('pinInstalledPrereleaseVersion', () => { expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); }); - it('does not pin when the installed version is not valid semver', () => { + it('warns and does not pin when the installed version is not valid semver', () => { + const warnSpy = spyOn(context.logger, 'warn'); const tree = treeWithAngularFire('^21.0.0-rc.0'); pinInstalledPrereleaseVersion(tree, context, 'ANGULARFIRE2_VERSION'); + expect(warnSpy).toHaveBeenCalled(); expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); }); + it('does nothing when the @angular/fire entry is not a string', () => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ + name: 'test-app', + dependencies: { '@angular/fire': { version: '^21.0.0-rc.0' } }, + })); + expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow(); + expect(dependenciesIn(tree)['@angular/fire']).toEqual({ version: '^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: {} })); @@ -82,10 +96,22 @@ describe('pinInstalledPrereleaseVersion', () => { 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'); + it('pins a prerelease range found in devDependencies', () => { + const tree = treeWithAngularFire('^21.0.0-rc.0', 'devDependencies'); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(sectionIn(tree, 'devDependencies')['@angular/fire']).toBe('21.0.0-rc.0'); + }); + + it('pins the dependencies entry when @angular/fire appears in both sections', () => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ + name: 'test-app', + dependencies: { '@angular/fire': '^21.0.0-rc.0' }, + devDependencies: { '@angular/fire': '^21.0.0-rc.0' }, + }, null, 2)); + pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); + expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); + expect(sectionIn(tree, 'devDependencies')['@angular/fire']).toBe('^21.0.0-rc.0'); }); }); diff --git a/src/schematics/common.ts b/src/schematics/common.ts index cb39d1bb0..525440321 100644 --- a/src/schematics/common.ts +++ b/src/schematics/common.ts @@ -1,5 +1,3 @@ -import { readFileSync } from 'fs'; -import { join } from 'path'; import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import { intersects as semverIntersects, @@ -73,19 +71,9 @@ 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; - } -}; +// The build writes the published version over this placeholder in the compiled schematics +// (tools/build.ts, replaceSchematicVersions); running from source leaves the placeholder. +const angularFireVersion = 'ANGULARFIRE2_VERSION'; /** * Pins the workspace's `@angular/fire` entry to the exact installed version when `ng add` wrote a @@ -96,23 +84,31 @@ const readInstalledVersion = () => { export const pinInstalledPrereleaseVersion = ( host: Tree, context: SchematicContext, - installedVersion = readInstalledVersion(), + installedVersion = angularFireVersion, ) => { 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; } + const dependencySection = ['dependencies', 'devDependencies'].find( + section => typeof packageJson[section]?.['@angular/fire'] === 'string', + ); + if (!dependencySection) { return; } + + const declaredAngularFireVersion = packageJson[dependencySection]['@angular/fire']; + if (!(declaredAngularFireVersion.startsWith('^') || declaredAngularFireVersion.startsWith('~'))) { return; } + + if (!semverValid(installedVersion)) { + context.logger.warn( + 'Could not determine the installed @angular/fire version; leaving the declared version range as-is.' + ); + return; + } if ( - semverValid(installedVersion) && semverPrerelease(installedVersion) && semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true }) ) { - packageJson.dependencies['@angular/fire'] = installedVersion; + packageJson[dependencySection]['@angular/fire'] = installedVersion; overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); context.logger.info( `Pinned @angular/fire to the exact version ${installedVersion} — a prerelease range like ` + diff --git a/tools/build.ts b/tools/build.ts index 8152ab59d..0e66812e1 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -1,6 +1,6 @@ import { spawn } from 'cross-spawn'; import { copy, writeFile } from 'fs-extra'; -import { join } from 'path'; +import { join, sep } from 'path'; import { keys as tsKeys } from 'ts-transformer-keys'; import * as esbuild from "esbuild"; @@ -273,6 +273,21 @@ async function replacePackageCoreVersion() { }); } +async function writeVersionOverPlaceholder(root: { version: string }) { + const replace = require('replace-in-file'); + const replacements = await replace({ + // Glob patterns need forward slashes even on Windows. + files: `${dest('schematics').split(sep).join('/')}/**/*.js`, + from: /ANGULARFIRE2_VERSION/g, + to: root.version, + // Zero matches should reach the guard below, not replace-in-file's generic rejection. + allowEmptyPaths: true, + }); + if (!replacements.some(replacement => replacement.hasChanged)) { + throw new Error('No ANGULARFIRE2_VERSION placeholder found in the compiled schematics — the version pin would ship disabled.'); + } +} + async function replaceSchematicVersions() { const root = await rootPackage; const packagesPath = dest('schematics', 'versions.json'); @@ -283,6 +298,7 @@ async function replaceSchematicVersions() { Object.keys(dependencies.firebaseFunctionsDependencies).forEach(name => { dependencies.firebaseFunctionsDependencies[name].version = root.dependencies[name] || root.devDependencies[name]; }); + await writeVersionOverPlaceholder(root); return writeFile(packagesPath, JSON.stringify(dependencies, null, 2)); }