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..b40612d04 --- /dev/null +++ b/src/schematics/common.jasmine.ts @@ -0,0 +1,117 @@ +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, section = 'dependencies') => { + const tree = new HostTree(); + tree.create('package.json', JSON.stringify({ + name: 'test-app', + [section]: { + '@angular/fire': declaredVersion, + firebase: '^12.4.0', + }, + }, null, 2)); + return tree; +}; + +const sectionIn = (tree: HostTree, section: string) => + JSON.parse(tree.readText('package.json'))[section]; + +const dependenciesIn = (tree: HostTree) => sectionIn(tree, '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('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: {} })); + 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('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 9f3544efc..525440321 100644 --- a/src/schematics/common.ts +++ b/src/schematics/common.ts @@ -1,5 +1,10 @@ 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 +70,50 @@ export const addDependencies = ( overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); }; + +// 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 + * 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 = angularFireVersion, +) => { + if (!host.exists('package.json')) { return; } + const packageJson = safeReadJSON('package.json', host); + + 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 ( + semverPrerelease(installedVersion) && + semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true }) + ) { + 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 ` + + `${declaredAngularFireVersion} also matches unreviewed canary builds, so a later install ` + + 'could silently change versions.' + ); + } +}; 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)); }