Skip to content

Commit 96cf855

Browse files
feat(schematics): generate .firebaserc and Firestore starter files during ng add (#3714)
* feat(schematics): generate .firebaserc and Firestore starter files during ng add ng add asks the user to pick a Firebase project but never records the choice, so every later firebase-tools command has no default project. It also leaves firebase.json empty and creates no security rules, so a Firestore-selected workspace cannot deploy rules at all. Now, after the project prompt, the schematic records the selection in .firebaserc (merging into an existing file rather than replacing it). When Firestore is selected it also generates firestore.rules and firestore.indexes.json — the same test-mode starter files that 'firebase init firestore' produces, with a warning that the rules expire in 30 days — and wires the firestore section into firebase.json. Existing rules files are left untouched. The new files go through the schematic Tree; firebase.json stays on the real filesystem because firebase-tools reads and rewrites it during the same run, and its firestore section is added only after those rewrites. * fix(schematics): move firestore starter files after init, widen error handling Tyler's review on #3714: createFirestoreStarterFiles staged the Tree before the DataConnect init calls, using a pre-init firebase.json snapshot. If a future init call ever added a firestore section mid-run, the stale snapshot would miss it and stage starter files on top of files firebase-tools already wrote to disk — the same Tree/disk collision the .firebaserc write hit earlier in this PR. Moved the call to run after init with a fresh re-read, and documented the more immediate invariant this enforces: it must run before addFirestoreToFirebaseJson, which is what adds the firestore section on a normal run. Also widened addFirestoreToFirebaseJson's try/catch to cover the firebase.json mutation and write, not just the read — a write failure (disk full, permissions) previously crashed with a raw Node error instead of the warn-and-continue the read path already gets. * fix(schematics): warn instead of crashing when the post-init firebase.json read fails The re-read added for the firestore starter files was unguarded, so a firebase.json that firebase-tools left unreadable would abort the whole ng add. Before the starter files moved after the init calls, this same failure degraded gracefully — the files were already staged, and addFirestoreToFirebaseJson warned and let setup finish. Restore that: warn and continue. createFirestoreStarterFiles independently checks the disk for each file it would create, so a missing snapshot costs the firestore-section check, not the collision safety it also relies on. Trimmed the surrounding comments to the two constraints a future edit could silently break — read after init, and run before addFirestoreToFirebaseJson — and moved the rationale here. The reason the read sits after the init calls at all: staging against a stale pre-init snapshot risks the Tree/disk collision the .firebaserc write hit earlier in this PR, where a Tree-staged file collides at commit time with one firebase-tools already wrote to disk and aborts the run. No init call adds a firestore section today, so that specific path is defensive against a future one.
1 parent 62d3929 commit 96cf855

4 files changed

Lines changed: 337 additions & 2 deletions

File tree

src/schematics/interfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,15 @@ export interface DataConnectConfig {
155155
source?: string;
156156
}
157157

158+
export interface FirebaseFirestoreConfig {
159+
rules?: string;
160+
indexes?: string;
161+
}
162+
158163
export interface FirebaseJSON {
159164
hosting?: FirebaseHostingConfig[] | FirebaseHostingConfig;
160165
functions?: FirebaseFunctionsConfig;
166+
firestore?: FirebaseFirestoreConfig;
161167
dataconnect?: DataConnectConfig;
162168
}
163169

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { mkdtempSync, readFileSync, writeFileSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
import { logging } from '@angular-devkit/core';
5+
import { HostTree, SchematicContext } from '@angular-devkit/schematics';
6+
import fsExtra from 'fs-extra';
7+
import { FEATURES } from '../interfaces.js';
8+
import {
9+
addFirestoreToFirebaseJson,
10+
createFirestoreStarterFiles,
11+
setDefaultProjectInFirebaseRc,
12+
} from './firebaseConfigs.js';
13+
import 'jasmine';
14+
15+
const context = { logger: new logging.Logger('test') } as unknown as SchematicContext;
16+
17+
describe('setDefaultProjectInFirebaseRc', () => {
18+
let projectRoot: string;
19+
20+
beforeEach(() => {
21+
projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-'));
22+
});
23+
24+
afterEach(() => {
25+
fsExtra.removeSync(projectRoot);
26+
});
27+
28+
const firebaseRcOnDisk = () =>
29+
JSON.parse(readFileSync(join(projectRoot, '.firebaserc')).toString());
30+
31+
it('creates .firebaserc recording the selected project as default', () => {
32+
setDefaultProjectInFirebaseRc(projectRoot, 'my-project');
33+
expect(firebaseRcOnDisk()).toEqual({
34+
projects: { default: 'my-project' },
35+
});
36+
});
37+
38+
it('merges into an existing .firebaserc, preserving targets and other aliases', () => {
39+
writeFileSync(join(projectRoot, '.firebaserc'), JSON.stringify({
40+
projects: { default: 'old-project', staging: 'staging-project' },
41+
targets: { 'old-project': { hosting: { app: ['app-site'] } } },
42+
}));
43+
setDefaultProjectInFirebaseRc(projectRoot, 'new-project');
44+
expect(firebaseRcOnDisk()).toEqual({
45+
projects: { default: 'new-project', staging: 'staging-project' },
46+
targets: { 'old-project': { hosting: { app: ['app-site'] } } },
47+
});
48+
});
49+
50+
it('names the file when an existing .firebaserc cannot be parsed', () => {
51+
writeFileSync(join(projectRoot, '.firebaserc'), '{ not json');
52+
expect(() => setDefaultProjectInFirebaseRc(projectRoot, 'my-project'))
53+
.toThrowError(/\.firebaserc/);
54+
});
55+
56+
});
57+
58+
describe('createFirestoreStarterFiles', () => {
59+
60+
it('creates test-mode rules and an empty index manifest when Firestore is selected', () => {
61+
const tree = new HostTree();
62+
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
63+
const rules = tree.readText('/firestore.rules');
64+
expect(rules).toContain("rules_version='2'");
65+
expect(rules).toContain('allow read, write: if request.time < timestamp.date(');
66+
expect(JSON.parse(tree.readText('/firestore.indexes.json'))).toEqual({
67+
indexes: [],
68+
fieldOverrides: [],
69+
});
70+
});
71+
72+
it('sets the rules to expire roughly 30 days out', () => {
73+
const tree = new HostTree();
74+
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
75+
const match = /timestamp\.date\((\d+), (\d+), (\d+)\)/.exec(tree.readText('/firestore.rules'));
76+
expect(match).not.toBeNull();
77+
const [, year, month, day] = match.map(Number);
78+
const expiry = new Date(year, month - 1, day);
79+
const daysOut = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
80+
expect(daysOut).toBeGreaterThan(28);
81+
expect(daysOut).toBeLessThan(31);
82+
});
83+
84+
it('never overwrites an existing rules file', () => {
85+
const tree = new HostTree();
86+
tree.create('/firestore.rules', 'my existing rules');
87+
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
88+
expect(tree.readText('/firestore.rules')).toBe('my existing rules');
89+
});
90+
91+
it('still creates the index manifest when only the rules file exists', () => {
92+
const tree = new HostTree();
93+
tree.create('/firestore.rules', 'my existing rules');
94+
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
95+
expect(tree.exists('/firestore.indexes.json')).toBeTrue();
96+
});
97+
98+
it('does nothing when Firestore is not selected', () => {
99+
const tree = new HostTree();
100+
createFirestoreStarterFiles(tree, context, [FEATURES.Authentication]);
101+
expect(tree.exists('/firestore.rules')).toBeFalse();
102+
expect(tree.exists('/firestore.indexes.json')).toBeFalse();
103+
});
104+
105+
it('creates nothing when firebase.json already has a firestore section', () => {
106+
const tree = new HostTree();
107+
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore], {
108+
firestore: { rules: 'custom.rules' },
109+
});
110+
expect(tree.exists('/firestore.rules')).toBeFalse();
111+
expect(tree.exists('/firestore.indexes.json')).toBeFalse();
112+
});
113+
114+
});
115+
116+
describe('addFirestoreToFirebaseJson', () => {
117+
let projectRoot: string;
118+
119+
beforeEach(() => {
120+
projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-'));
121+
});
122+
123+
afterEach(() => {
124+
fsExtra.removeSync(projectRoot);
125+
});
126+
127+
const firebaseJsonOnDisk = () =>
128+
JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString());
129+
130+
it('adds the firestore section pointing at the starter files', () => {
131+
writeFileSync(join(projectRoot, 'firebase.json'), '{}');
132+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
133+
expect(firebaseJsonOnDisk().firestore).toEqual({
134+
rules: 'firestore.rules',
135+
indexes: 'firestore.indexes.json',
136+
});
137+
});
138+
139+
it('preserves sections other tools wrote to firebase.json', () => {
140+
writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({
141+
dataconnect: { source: 'dataconnect' },
142+
}));
143+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
144+
expect(firebaseJsonOnDisk().dataconnect).toEqual({ source: 'dataconnect' });
145+
expect(firebaseJsonOnDisk().firestore).toBeDefined();
146+
});
147+
148+
it('leaves an existing firestore section untouched', () => {
149+
writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({
150+
firestore: { rules: 'custom.rules' },
151+
}));
152+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
153+
expect(firebaseJsonOnDisk().firestore).toEqual({ rules: 'custom.rules' });
154+
});
155+
156+
it('does nothing when Firestore is not selected', () => {
157+
writeFileSync(join(projectRoot, 'firebase.json'), '{}');
158+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Authentication]);
159+
expect(firebaseJsonOnDisk().firestore).toBeUndefined();
160+
});
161+
162+
it('warns and skips when firebase.json cannot be read', () => {
163+
const warnSpy = spyOn(context.logger, 'warn');
164+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
165+
expect(warnSpy).toHaveBeenCalled();
166+
});
167+
168+
it('warns and skips when firebase.json parses to a non-object', () => {
169+
writeFileSync(join(projectRoot, 'firebase.json'), 'null');
170+
const warnSpy = spyOn(context.logger, 'warn');
171+
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
172+
expect(warnSpy).toHaveBeenCalled();
173+
expect(readFileSync(join(projectRoot, 'firebase.json')).toString()).toBe('null');
174+
});
175+
176+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { existsSync, readFileSync, writeFileSync } from 'fs';
2+
import { join } from 'path';
3+
import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
4+
import { stringifyFormatted } from '../common.js';
5+
import { FEATURES, FirebaseJSON, FirebaseRc } from '../interfaces.js';
6+
7+
/**
8+
* Builds the same test-mode starter rules `firebase init firestore` generates: anyone can read
9+
* and write until a date 30 days from generation, after which all client requests are denied.
10+
*/
11+
const testModeFirestoreRules = () => {
12+
const expiry = new Date();
13+
expiry.setDate(expiry.getDate() + 30);
14+
return `rules_version='2'
15+
16+
service cloud.firestore {
17+
match /databases/{database}/documents {
18+
match /{document=**} {
19+
// This rule allows anyone with your database reference to view, edit,
20+
// and delete all data in your database. It is useful for getting
21+
// started, but it is configured to expire after 30 days because it
22+
// leaves your app open to attackers. At that time, all client
23+
// requests to your database will be denied.
24+
//
25+
// Make sure to write security rules for your app before that time, or
26+
// else all client requests to your database will be denied until you
27+
// update your rules.
28+
allow read, write: if request.time < timestamp.date(${expiry.getFullYear()}, ${expiry.getMonth() + 1}, ${expiry.getDate()});
29+
}
30+
}
31+
}
32+
`;
33+
};
34+
35+
/**
36+
* Records the selected Firebase project as the workspace's default in `.firebaserc`, so
37+
* firebase-tools commands the user runs after setup completes don't prompt for (or fail without)
38+
* a project. Merges into an existing file — only `projects.default` is set; targets and other
39+
* aliases are preserved. Written to the real filesystem (not the schematic Tree) because
40+
* `firebaseTools.init` writes `.firebaserc` on disk during the same `ng add` run — a Tree-staged
41+
* copy would collide with it when the schematic commits; call this after the last
42+
* `firebaseTools.init` call for that reason.
43+
*/
44+
export const setDefaultProjectInFirebaseRc = (projectRoot: string, projectId: string) => {
45+
const path = join(projectRoot, '.firebaserc');
46+
let rc: FirebaseRc = {};
47+
if (existsSync(path)) {
48+
try {
49+
rc = JSON.parse(readFileSync(path).toString());
50+
} catch (e) {
51+
throw new SchematicsException(`Error when parsing ${path}: ${e.message}`);
52+
}
53+
}
54+
rc.projects = { ...rc.projects, default: projectId };
55+
writeFileSync(path, stringifyFormatted(rc));
56+
};
57+
58+
/**
59+
* When Firestore is selected, generates `firestore.rules` (test mode, with a logged warning
60+
* about the 30-day expiry) and `firestore.indexes.json`. Existing files are never overwritten —
61+
* a later `firebase deploy` would replace rules already deployed from elsewhere. A workspace
62+
* whose firebase.json already has a `firestore` section is left entirely untouched: its section
63+
* may point at differently-named files, and creating the default-named ones would only add
64+
* orphans nothing references.
65+
*
66+
* `firebaseJsonConfig`, when passed, should be the post-init snapshot of firebase.json (read
67+
* after the last `firebaseTools.init` call and before `addFirestoreToFirebaseJson` runs) — see
68+
* the call site in `ngAddSetupProject` for why that order matters. Passing a stale pre-init
69+
* snapshot risks staging the default-named starter files on top of files firebase-tools already
70+
* wrote to disk, colliding when the Tree commits.
71+
*/
72+
export const createFirestoreStarterFiles = (
73+
host: Tree,
74+
context: SchematicContext,
75+
features: FEATURES[],
76+
firebaseJsonConfig?: FirebaseJSON,
77+
) => {
78+
if (!features.includes(FEATURES.Firestore)) { return; }
79+
if (firebaseJsonConfig?.firestore) {
80+
context.logger.info('firebase.json already has a firestore section — leaving its rules and indexes untouched.');
81+
return;
82+
}
83+
if (host.exists('/firestore.rules')) {
84+
context.logger.info('firestore.rules already exists — leaving it untouched.');
85+
} else {
86+
host.create('/firestore.rules', testModeFirestoreRules());
87+
context.logger.warn(
88+
'Generated firestore.rules in test mode: anyone can read and write your database until the rules expire in 30 days. ' +
89+
'Write real security rules before then — https://firebase.google.com/docs/rules'
90+
);
91+
}
92+
if (!host.exists('/firestore.indexes.json')) {
93+
// Must exist once firebase.json references it — `firebase deploy` errors on a missing indexes file.
94+
host.create('/firestore.indexes.json', stringifyFormatted({ indexes: [], fieldOverrides: [] }));
95+
}
96+
};
97+
98+
/**
99+
* When Firestore is selected, points the `firestore` section of `firebase.json` at the starter
100+
* files so `firebase deploy` includes rules and indexes. Written to the real filesystem (not the
101+
* schematic Tree) because firebase-tools reads and rewrites `firebase.json` during the same
102+
* `ng add` run; call this after the last `firebaseTools.init` call for that reason.
103+
*/
104+
export const addFirestoreToFirebaseJson = (
105+
projectRoot: string,
106+
context: SchematicContext,
107+
features: FEATURES[],
108+
) => {
109+
if (!features.includes(FEATURES.Firestore)) { return; }
110+
// firebaseTools.init calls may have rewritten firebase.json on disk, so re-read it, add the
111+
// firestore section if absent, and write it back. The whole read-check-write is guarded: a
112+
// failed read (missing or unparseable file), a parse that isn't a usable object (reading
113+
// `.firestore` off `null`/`undefined` throws; assigning it on a non-object primitive throws
114+
// too), or a failed write (disk full, permissions) all warn and leave the file for the user to
115+
// finish, rather than crashing the schematic with a raw Node error.
116+
try {
117+
const firebaseJson: FirebaseJSON = JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString());
118+
if (!firebaseJson.firestore) {
119+
firebaseJson.firestore = { rules: 'firestore.rules', indexes: 'firestore.indexes.json' };
120+
writeFileSync(join(projectRoot, 'firebase.json'), stringifyFormatted(firebaseJson));
121+
}
122+
} catch (e) {
123+
context.logger.warn(
124+
`Could not update firebase.json with the firestore section (${e.message}). Check firebase.json ` +
125+
'and add { "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" } } manually.'
126+
);
127+
}
128+
};

src/schematics/setup/index.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
parseDataConnectConfig,
1717
setupTanstackDependencies,
1818
} from '../utils';
19+
import {
20+
addFirestoreToFirebaseJson,
21+
createFirestoreStarterFiles,
22+
setDefaultProjectInFirebaseRc,
23+
} from './firebaseConfigs';
1924
import { appPrompt, featuresPrompt, projectPrompt, userPrompt } from './prompts';
2025

2126
// FirebaseOptions keys — apps.sdkconfig responses include management-API extras that initializeApp() rejects.
@@ -84,7 +89,7 @@ export const ngAddSetupProject = (
8489
const [ defaultProjectName ] = getFirebaseProjectNameFromHost(host, ngProjectName);
8590

8691
const firebaseProject = await projectPrompt(defaultProjectName, { projectRoot, account: user.email });
87-
92+
8893
let firebaseApp: FirebaseApp|undefined;
8994
let sdkConfig: Record<string, string>|undefined;
9095

@@ -139,8 +144,28 @@ export const ngAddSetupProject = (
139144
setupTanstackDependencies(host, context);
140145
setupConfig.dataConnectConfig = dataConnectConfig;
141146
}
142-
147+
148+
}
149+
150+
// Read after the init calls, never before — firebase-tools rewrites firebase.json on disk
151+
// mid-run. A failed read isn't fatal: createFirestoreStarterFiles falls back to checking the
152+
// disk for each file it would create.
153+
let firebaseJsonAfterInit: FirebaseJSON | undefined;
154+
try {
155+
firebaseJsonAfterInit = JSON.parse(
156+
readFileSync(join(projectRoot, "firebase.json")).toString()
157+
);
158+
} catch (e) {
159+
context.logger.warn(`Could not re-read firebase.json after setup (${e.message}).`);
143160
}
161+
// Must run before addFirestoreToFirebaseJson: that call adds the firestore section, and if it
162+
// ran first this snapshot would see it and skip creating the files it points at.
163+
createFirestoreStarterFiles(host, context, features, firebaseJsonAfterInit);
164+
165+
// Both write the real filesystem, after the last firebaseTools.init call — init writes
166+
// .firebaserc and rewrites firebase.json on disk during the run.
167+
setDefaultProjectInFirebaseRc(projectRoot, firebaseProject.projectId);
168+
addFirestoreToFirebaseJson(projectRoot, context, features);
144169

145170
return setupProject(host, context, features, setupConfig);
146171
}

0 commit comments

Comments
 (0)