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

});
58 changes: 57 additions & 1 deletion src/schematics/common.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 `<package root>/schematics/<entry>/`, 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 = () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional/nit, design suggestion: tools/build.ts already stamps values into versions.json at build time, and add/index.ts already imports from it. Adding angularFireVersion there would let you replace the readFileSync/__dirname traversal with a plain JSON import, same pattern already in use and no filesystem-path assumptions.

try {
return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json')).toString()).version;
} catch {
return undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: if the file read fails for any reason, readInstalledVersion returns undefined with no log output, so the user gets no indication the pin was skipped. A context.logger.warn in the catch would help debuggability.

}
};

/**
* 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'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function only reads from packageJson.dependencies. If @angular/fire somehow ends up in devDependencies, the pin is silently skipped. Unlikely via ng add, but worth a follow-up.

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.'
);
}
};
Loading