Skip to content

Commit 702a0cb

Browse files
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.
1 parent 9a2a541 commit 702a0cb

4 files changed

Lines changed: 307 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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
export const createFirestoreStarterFiles = (
67+
host: Tree,
68+
context: SchematicContext,
69+
features: FEATURES[],
70+
firebaseJsonConfig?: FirebaseJSON,
71+
) => {
72+
if (!features.includes(FEATURES.Firestore)) { return; }
73+
if (firebaseJsonConfig?.firestore) {
74+
context.logger.info('firebase.json already has a firestore section — leaving its rules and indexes untouched.');
75+
return;
76+
}
77+
if (host.exists('/firestore.rules')) {
78+
context.logger.info('firestore.rules already exists — leaving it untouched.');
79+
} else {
80+
host.create('/firestore.rules', testModeFirestoreRules());
81+
context.logger.warn(
82+
'Generated firestore.rules in test mode: anyone can read and write your database until the rules expire in 30 days. ' +
83+
'Write real security rules before then — https://firebase.google.com/docs/rules'
84+
);
85+
}
86+
if (!host.exists('/firestore.indexes.json')) {
87+
// Must exist once firebase.json references it — `firebase deploy` errors on a missing indexes file.
88+
host.create('/firestore.indexes.json', stringifyFormatted({ indexes: [], fieldOverrides: [] }));
89+
}
90+
};
91+
92+
/**
93+
* When Firestore is selected, points the `firestore` section of `firebase.json` at the starter
94+
* files so `firebase deploy` includes rules and indexes. Written to the real filesystem (not the
95+
* schematic Tree) because firebase-tools reads and rewrites `firebase.json` during the same
96+
* `ng add` run; call this after the last `firebaseTools.init` call for that reason.
97+
*/
98+
export const addFirestoreToFirebaseJson = (
99+
projectRoot: string,
100+
context: SchematicContext,
101+
features: FEATURES[],
102+
) => {
103+
if (!features.includes(FEATURES.Firestore)) { return; }
104+
// Re-read first: firebaseTools.init calls may have rewritten firebase.json on disk.
105+
let firebaseJson: FirebaseJSON;
106+
try {
107+
firebaseJson = JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString());
108+
} catch (e) {
109+
context.logger.warn(
110+
`Could not read firebase.json to add the firestore section (${e.message}). Add ` +
111+
'{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" } } to it manually.'
112+
);
113+
return;
114+
}
115+
if (!firebaseJson.firestore) {
116+
firebaseJson.firestore = { rules: 'firestore.rules', indexes: 'firestore.indexes.json' };
117+
writeFileSync(join(projectRoot, 'firebase.json'), stringifyFormatted(firebaseJson));
118+
}
119+
};

src/schematics/setup/index.ts

Lines changed: 14 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,9 @@ export const ngAddSetupProject = (
8489
const [ defaultProjectName ] = getFirebaseProjectNameFromHost(host, ngProjectName);
8590

8691
const firebaseProject = await projectPrompt(defaultProjectName, { projectRoot, account: user.email });
87-
92+
93+
createFirestoreStarterFiles(host, context, features, firebaseJson);
94+
8895
let firebaseApp: FirebaseApp|undefined;
8996
let sdkConfig: Record<string, string>|undefined;
9097

@@ -139,9 +146,14 @@ export const ngAddSetupProject = (
139146
setupTanstackDependencies(host, context);
140147
setupConfig.dataConnectConfig = dataConnectConfig;
141148
}
142-
149+
143150
}
144151

152+
// Both write the real filesystem, after the last firebaseTools.init call — init writes
153+
// .firebaserc and rewrites firebase.json on disk during the run.
154+
setDefaultProjectInFirebaseRc(projectRoot, firebaseProject.projectId);
155+
addFirestoreToFirebaseJson(projectRoot, context, features);
156+
145157
return setupProject(host, context, features, setupConfig);
146158
}
147159
};

0 commit comments

Comments
 (0)