Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/schematics/add/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand Down
117 changes: 117 additions & 0 deletions src/schematics/common.jasmine.ts
Original file line number Diff line number Diff line change
@@ -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');
});

});
54 changes: 53 additions & 1 deletion src/schematics/common.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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.'
);
}
};
18 changes: 17 additions & 1 deletion tools/build.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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');
Expand All @@ -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));
}

Expand Down
Loading