Skip to content

Commit dda001e

Browse files
fix(schematics): pin prerelease installs to the exact version
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.
1 parent b57868d commit dda001e

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

src/schematics/add/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SchematicContext, Tree } from '@angular-devkit/schematics';
22
import { NodePackageInstallTask, RunSchematicTask } from '@angular-devkit/schematics/tasks';
3-
import { addDependencies } from '../common';
3+
import { addDependencies, pinInstalledPrereleaseVersion } from '../common';
44
import { DeployOptions } from '../interfaces';
55
import { peerDependencies } from '../versions.json';
66

@@ -10,6 +10,7 @@ export const ngAdd = (options: DeployOptions) => (tree: Tree, context: Schematic
1010
peerDependencies,
1111
context
1212
);
13+
pinInstalledPrereleaseVersion(tree, context);
1314
const npmInstallTaskId = context.addTask(new NodePackageInstallTask());
1415
context.addTask(new RunSchematicTask('ng-add-setup-project', options), [npmInstallTaskId]);
1516
return tree;

src/schematics/common.jasmine.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { logging } from '@angular-devkit/core';
2+
import { HostTree, SchematicContext } from '@angular-devkit/schematics';
3+
import { pinInstalledPrereleaseVersion } from './common.js';
4+
import 'jasmine';
5+
6+
const context = { logger: new logging.Logger('test') } as unknown as SchematicContext;
7+
8+
const treeWithAngularFire = (declaredVersion: string) => {
9+
const tree = new HostTree();
10+
tree.create('package.json', JSON.stringify({
11+
name: 'test-app',
12+
dependencies: {
13+
'@angular/fire': declaredVersion,
14+
firebase: '^12.4.0',
15+
},
16+
}, null, 2));
17+
return tree;
18+
};
19+
20+
const dependenciesIn = (tree: HostTree) =>
21+
JSON.parse(tree.readText('package.json')).dependencies;
22+
23+
describe('pinInstalledPrereleaseVersion', () => {
24+
25+
it('pins a caret prerelease range to the exact installed version', () => {
26+
const tree = treeWithAngularFire('^21.0.0-rc.0');
27+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
28+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
29+
});
30+
31+
it('pins a tilde prerelease range to the exact installed version', () => {
32+
const tree = treeWithAngularFire('~21.0.0-rc.0');
33+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
34+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
35+
});
36+
37+
it('leaves other dependencies untouched when pinning', () => {
38+
const tree = treeWithAngularFire('^21.0.0-rc.0');
39+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
40+
expect(dependenciesIn(tree).firebase).toBe('^12.4.0');
41+
});
42+
43+
it('leaves a stable caret range untouched', () => {
44+
const tree = treeWithAngularFire('^21.0.0');
45+
pinInstalledPrereleaseVersion(tree, context, '21.0.0');
46+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0');
47+
});
48+
49+
it('leaves an exact prerelease version untouched', () => {
50+
const tree = treeWithAngularFire('21.0.0-rc.0');
51+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
52+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
53+
});
54+
55+
it('leaves a hand-authored range expression untouched', () => {
56+
const tree = treeWithAngularFire('>=21.0.0-rc.0');
57+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
58+
expect(dependenciesIn(tree)['@angular/fire']).toBe('>=21.0.0-rc.0');
59+
});
60+
61+
it('does not pin when the installed version does not satisfy the declared range', () => {
62+
const tree = treeWithAngularFire('^21.0.0-rc.0');
63+
pinInstalledPrereleaseVersion(tree, context, '22.0.0-rc.0');
64+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0');
65+
});
66+
67+
it('does not pin when the installed version is not valid semver', () => {
68+
const tree = treeWithAngularFire('^21.0.0-rc.0');
69+
pinInstalledPrereleaseVersion(tree, context, 'ANGULARFIRE2_VERSION');
70+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0');
71+
});
72+
73+
it('does nothing when @angular/fire is not a dependency', () => {
74+
const tree = new HostTree();
75+
tree.create('package.json', JSON.stringify({ name: 'test-app', dependencies: {} }));
76+
expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow();
77+
expect(dependenciesIn(tree)['@angular/fire']).toBeUndefined();
78+
});
79+
80+
it('does nothing when package.json is absent', () => {
81+
const tree = new HostTree();
82+
expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow();
83+
});
84+
85+
it('skips gracefully when the installed version cannot be determined', () => {
86+
const tree = treeWithAngularFire('^21.0.0-rc.0');
87+
pinInstalledPrereleaseVersion(tree, context, undefined);
88+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0');
89+
});
90+
91+
});

src/schematics/common.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { readFileSync } from 'fs';
2+
import { join } from 'path';
13
import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
2-
import { intersects as semverIntersects } from 'semver';
4+
import {
5+
intersects as semverIntersects,
6+
prerelease as semverPrerelease,
7+
satisfies as semverSatisfies,
8+
valid as semverValid,
9+
} from 'semver';
310
import { FirebaseHostingSite } from './interfaces';
411

512
export const shortSiteName = (site?: FirebaseHostingSite) => site?.name?.split('/').pop();
@@ -65,3 +72,52 @@ export const addDependencies = (
6572

6673
overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson));
6774
};
75+
76+
/**
77+
* Reads the exact version of the installed `@angular/fire` package from its own manifest — the
78+
* compiled schematics run from `<package root>/schematics/<entry>/`, so the manifest sits two
79+
* directories up. Returns undefined when that layout doesn't hold (e.g. running from source),
80+
* so callers skip pinning instead of failing the schematic.
81+
*/
82+
const readInstalledVersion = () => {
83+
try {
84+
return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json')).toString()).version;
85+
} catch {
86+
return undefined;
87+
}
88+
};
89+
90+
/**
91+
* Pins the workspace's `@angular/fire` entry to the exact installed version when `ng add` wrote a
92+
* prerelease range. A prerelease range like `^21.0.0-rc.0` also matches the canary build published
93+
* for every merge to main, so a later fresh install can silently replace the version the user
94+
* chose. Stable ranges are left untouched.
95+
*/
96+
export const pinInstalledPrereleaseVersion = (
97+
host: Tree,
98+
context: SchematicContext,
99+
installedVersion = readInstalledVersion(),
100+
) => {
101+
if (!host.exists('package.json')) { return; }
102+
const packageJson = safeReadJSON('package.json', host);
103+
104+
const declaredAngularFireVersion = packageJson.dependencies?.['@angular/fire'];
105+
if (
106+
typeof declaredAngularFireVersion !== 'string' ||
107+
!(declaredAngularFireVersion.startsWith('^') || declaredAngularFireVersion.startsWith('~'))
108+
) { return; }
109+
110+
if (
111+
semverValid(installedVersion) &&
112+
semverPrerelease(installedVersion) &&
113+
semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true })
114+
) {
115+
packageJson.dependencies['@angular/fire'] = installedVersion;
116+
overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson));
117+
context.logger.info(
118+
`Pinned @angular/fire to the exact version ${installedVersion} — a prerelease range like ` +
119+
`${declaredAngularFireVersion} also matches unreviewed canary builds, so a later install ` +
120+
'could silently change versions.'
121+
);
122+
}
123+
};

0 commit comments

Comments
 (0)