Skip to content

Commit e9c6ee4

Browse files
Merge branch 'main' into feat/ng-add-firestore-scaffolding
2 parents 3a5b2bb + 62d3929 commit e9c6ee4

4 files changed

Lines changed: 189 additions & 3 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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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, section = 'dependencies') => {
9+
const tree = new HostTree();
10+
tree.create('package.json', JSON.stringify({
11+
name: 'test-app',
12+
[section]: {
13+
'@angular/fire': declaredVersion,
14+
firebase: '^12.4.0',
15+
},
16+
}, null, 2));
17+
return tree;
18+
};
19+
20+
const sectionIn = (tree: HostTree, section: string) =>
21+
JSON.parse(tree.readText('package.json'))[section];
22+
23+
const dependenciesIn = (tree: HostTree) => sectionIn(tree, 'dependencies');
24+
25+
describe('pinInstalledPrereleaseVersion', () => {
26+
27+
it('pins a caret prerelease range to the exact installed version', () => {
28+
const tree = treeWithAngularFire('^21.0.0-rc.0');
29+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
30+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
31+
});
32+
33+
it('pins a tilde prerelease range to the exact installed version', () => {
34+
const tree = treeWithAngularFire('~21.0.0-rc.0');
35+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
36+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
37+
});
38+
39+
it('leaves other dependencies untouched when pinning', () => {
40+
const tree = treeWithAngularFire('^21.0.0-rc.0');
41+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
42+
expect(dependenciesIn(tree).firebase).toBe('^12.4.0');
43+
});
44+
45+
it('leaves a stable caret range untouched', () => {
46+
const tree = treeWithAngularFire('^21.0.0');
47+
pinInstalledPrereleaseVersion(tree, context, '21.0.0');
48+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0');
49+
});
50+
51+
it('leaves an exact prerelease version untouched', () => {
52+
const tree = treeWithAngularFire('21.0.0-rc.0');
53+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
54+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
55+
});
56+
57+
it('leaves a hand-authored range expression untouched', () => {
58+
const tree = treeWithAngularFire('>=21.0.0-rc.0');
59+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
60+
expect(dependenciesIn(tree)['@angular/fire']).toBe('>=21.0.0-rc.0');
61+
});
62+
63+
it('does not pin when the installed version does not satisfy the declared range', () => {
64+
const tree = treeWithAngularFire('^21.0.0-rc.0');
65+
pinInstalledPrereleaseVersion(tree, context, '22.0.0-rc.0');
66+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0');
67+
});
68+
69+
it('warns and does not pin when the installed version is not valid semver', () => {
70+
const warnSpy = spyOn(context.logger, 'warn');
71+
const tree = treeWithAngularFire('^21.0.0-rc.0');
72+
pinInstalledPrereleaseVersion(tree, context, 'ANGULARFIRE2_VERSION');
73+
expect(warnSpy).toHaveBeenCalled();
74+
expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0');
75+
});
76+
77+
it('does nothing when the @angular/fire entry is not a string', () => {
78+
const tree = new HostTree();
79+
tree.create('package.json', JSON.stringify({
80+
name: 'test-app',
81+
dependencies: { '@angular/fire': { version: '^21.0.0-rc.0' } },
82+
}));
83+
expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow();
84+
expect(dependenciesIn(tree)['@angular/fire']).toEqual({ version: '^21.0.0-rc.0' });
85+
});
86+
87+
it('does nothing when @angular/fire is not a dependency', () => {
88+
const tree = new HostTree();
89+
tree.create('package.json', JSON.stringify({ name: 'test-app', dependencies: {} }));
90+
expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow();
91+
expect(dependenciesIn(tree)['@angular/fire']).toBeUndefined();
92+
});
93+
94+
it('does nothing when package.json is absent', () => {
95+
const tree = new HostTree();
96+
expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow();
97+
});
98+
99+
it('pins a prerelease range found in devDependencies', () => {
100+
const tree = treeWithAngularFire('^21.0.0-rc.0', 'devDependencies');
101+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
102+
expect(sectionIn(tree, 'devDependencies')['@angular/fire']).toBe('21.0.0-rc.0');
103+
});
104+
105+
it('pins the dependencies entry when @angular/fire appears in both sections', () => {
106+
const tree = new HostTree();
107+
tree.create('package.json', JSON.stringify({
108+
name: 'test-app',
109+
dependencies: { '@angular/fire': '^21.0.0-rc.0' },
110+
devDependencies: { '@angular/fire': '^21.0.0-rc.0' },
111+
}, null, 2));
112+
pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0');
113+
expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0');
114+
expect(sectionIn(tree, 'devDependencies')['@angular/fire']).toBe('^21.0.0-rc.0');
115+
});
116+
117+
});

src/schematics/common.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
2-
import { intersects as semverIntersects } from 'semver';
2+
import {
3+
intersects as semverIntersects,
4+
prerelease as semverPrerelease,
5+
satisfies as semverSatisfies,
6+
valid as semverValid,
7+
} from 'semver';
38
import { FirebaseHostingSite } from './interfaces';
49

510
export const shortSiteName = (site?: FirebaseHostingSite) => site?.name?.split('/').pop();
@@ -65,3 +70,50 @@ export const addDependencies = (
6570

6671
overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson));
6772
};
73+
74+
// The build writes the published version over this placeholder in the compiled schematics
75+
// (tools/build.ts, replaceSchematicVersions); running from source leaves the placeholder.
76+
const angularFireVersion = 'ANGULARFIRE2_VERSION';
77+
78+
/**
79+
* Pins the workspace's `@angular/fire` entry to the exact installed version when `ng add` wrote a
80+
* prerelease range. A prerelease range like `^21.0.0-rc.0` also matches the canary build published
81+
* for every merge to main, so a later fresh install can silently replace the version the user
82+
* chose. Stable ranges are left untouched.
83+
*/
84+
export const pinInstalledPrereleaseVersion = (
85+
host: Tree,
86+
context: SchematicContext,
87+
installedVersion = angularFireVersion,
88+
) => {
89+
if (!host.exists('package.json')) { return; }
90+
const packageJson = safeReadJSON('package.json', host);
91+
92+
const dependencySection = ['dependencies', 'devDependencies'].find(
93+
section => typeof packageJson[section]?.['@angular/fire'] === 'string',
94+
);
95+
if (!dependencySection) { return; }
96+
97+
const declaredAngularFireVersion = packageJson[dependencySection]['@angular/fire'];
98+
if (!(declaredAngularFireVersion.startsWith('^') || declaredAngularFireVersion.startsWith('~'))) { return; }
99+
100+
if (!semverValid(installedVersion)) {
101+
context.logger.warn(
102+
'Could not determine the installed @angular/fire version; leaving the declared version range as-is.'
103+
);
104+
return;
105+
}
106+
107+
if (
108+
semverPrerelease(installedVersion) &&
109+
semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true })
110+
) {
111+
packageJson[dependencySection]['@angular/fire'] = installedVersion;
112+
overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson));
113+
context.logger.info(
114+
`Pinned @angular/fire to the exact version ${installedVersion} — a prerelease range like ` +
115+
`${declaredAngularFireVersion} also matches unreviewed canary builds, so a later install ` +
116+
'could silently change versions.'
117+
);
118+
}
119+
};

tools/build.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { spawn } from 'cross-spawn';
22
import { copy, writeFile } from 'fs-extra';
3-
import { join } from 'path';
3+
import { join, sep } from 'path';
44
import { keys as tsKeys } from 'ts-transformer-keys';
55
import * as esbuild from "esbuild";
66

@@ -273,6 +273,21 @@ async function replacePackageCoreVersion() {
273273
});
274274
}
275275

276+
async function writeVersionOverPlaceholder(root: { version: string }) {
277+
const replace = require('replace-in-file');
278+
const replacements = await replace({
279+
// Glob patterns need forward slashes even on Windows.
280+
files: `${dest('schematics').split(sep).join('/')}/**/*.js`,
281+
from: /ANGULARFIRE2_VERSION/g,
282+
to: root.version,
283+
// Zero matches should reach the guard below, not replace-in-file's generic rejection.
284+
allowEmptyPaths: true,
285+
});
286+
if (!replacements.some(replacement => replacement.hasChanged)) {
287+
throw new Error('No ANGULARFIRE2_VERSION placeholder found in the compiled schematics — the version pin would ship disabled.');
288+
}
289+
}
290+
276291
async function replaceSchematicVersions() {
277292
const root = await rootPackage;
278293
const packagesPath = dest('schematics', 'versions.json');
@@ -283,6 +298,7 @@ async function replaceSchematicVersions() {
283298
Object.keys(dependencies.firebaseFunctionsDependencies).forEach(name => {
284299
dependencies.firebaseFunctionsDependencies[name].version = root.dependencies[name] || root.devDependencies[name];
285300
});
301+
await writeVersionOverPlaceholder(root);
286302
return writeFile(packagesPath, JSON.stringify(dependencies, null, 2));
287303
}
288304

0 commit comments

Comments
 (0)