-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat(schematics): generate .firebaserc and Firestore starter files during ng add #3714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
armando-navarro
merged 4 commits into
angular:main
from
armando-navarro:feat/ng-add-firestore-scaffolding
Jul 16, 2026
+337
−2
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
702a0cb
feat(schematics): generate .firebaserc and Firestore starter files du…
armando-navarro 3a5b2bb
fix(schematics): move firestore starter files after init, widen error…
armando-navarro e9c6ee4
Merge branch 'main' into feat/ng-add-firestore-scaffolding
armando-navarro 28de572
fix(schematics): warn instead of crashing when the post-init firebase…
armando-navarro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| import { mkdtempSync, readFileSync, writeFileSync } from 'fs'; | ||
| import { tmpdir } from 'os'; | ||
| import { join } from 'path'; | ||
| import { logging } from '@angular-devkit/core'; | ||
| import { HostTree, SchematicContext } from '@angular-devkit/schematics'; | ||
| import fsExtra from 'fs-extra'; | ||
| import { FEATURES } from '../interfaces.js'; | ||
| import { | ||
| addFirestoreToFirebaseJson, | ||
| createFirestoreStarterFiles, | ||
| setDefaultProjectInFirebaseRc, | ||
| } from './firebaseConfigs.js'; | ||
| import 'jasmine'; | ||
|
|
||
| const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; | ||
|
|
||
| describe('setDefaultProjectInFirebaseRc', () => { | ||
| let projectRoot: string; | ||
|
|
||
| beforeEach(() => { | ||
| projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fsExtra.removeSync(projectRoot); | ||
| }); | ||
|
|
||
| const firebaseRcOnDisk = () => | ||
| JSON.parse(readFileSync(join(projectRoot, '.firebaserc')).toString()); | ||
|
|
||
| it('creates .firebaserc recording the selected project as default', () => { | ||
| setDefaultProjectInFirebaseRc(projectRoot, 'my-project'); | ||
| expect(firebaseRcOnDisk()).toEqual({ | ||
| projects: { default: 'my-project' }, | ||
| }); | ||
| }); | ||
|
|
||
| it('merges into an existing .firebaserc, preserving targets and other aliases', () => { | ||
| writeFileSync(join(projectRoot, '.firebaserc'), JSON.stringify({ | ||
| projects: { default: 'old-project', staging: 'staging-project' }, | ||
| targets: { 'old-project': { hosting: { app: ['app-site'] } } }, | ||
| })); | ||
| setDefaultProjectInFirebaseRc(projectRoot, 'new-project'); | ||
| expect(firebaseRcOnDisk()).toEqual({ | ||
| projects: { default: 'new-project', staging: 'staging-project' }, | ||
| targets: { 'old-project': { hosting: { app: ['app-site'] } } }, | ||
| }); | ||
| }); | ||
|
|
||
| it('names the file when an existing .firebaserc cannot be parsed', () => { | ||
| writeFileSync(join(projectRoot, '.firebaserc'), '{ not json'); | ||
| expect(() => setDefaultProjectInFirebaseRc(projectRoot, 'my-project')) | ||
| .toThrowError(/\.firebaserc/); | ||
| }); | ||
|
|
||
| }); | ||
|
|
||
| describe('createFirestoreStarterFiles', () => { | ||
|
|
||
| it('creates test-mode rules and an empty index manifest when Firestore is selected', () => { | ||
| const tree = new HostTree(); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]); | ||
| const rules = tree.readText('/firestore.rules'); | ||
| expect(rules).toContain("rules_version='2'"); | ||
| expect(rules).toContain('allow read, write: if request.time < timestamp.date('); | ||
| expect(JSON.parse(tree.readText('/firestore.indexes.json'))).toEqual({ | ||
| indexes: [], | ||
| fieldOverrides: [], | ||
| }); | ||
| }); | ||
|
|
||
| it('sets the rules to expire roughly 30 days out', () => { | ||
| const tree = new HostTree(); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]); | ||
| const match = /timestamp\.date\((\d+), (\d+), (\d+)\)/.exec(tree.readText('/firestore.rules')); | ||
| expect(match).not.toBeNull(); | ||
| const [, year, month, day] = match.map(Number); | ||
| const expiry = new Date(year, month - 1, day); | ||
| const daysOut = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24); | ||
| expect(daysOut).toBeGreaterThan(28); | ||
| expect(daysOut).toBeLessThan(31); | ||
| }); | ||
|
|
||
| it('never overwrites an existing rules file', () => { | ||
| const tree = new HostTree(); | ||
| tree.create('/firestore.rules', 'my existing rules'); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]); | ||
| expect(tree.readText('/firestore.rules')).toBe('my existing rules'); | ||
| }); | ||
|
|
||
| it('still creates the index manifest when only the rules file exists', () => { | ||
| const tree = new HostTree(); | ||
| tree.create('/firestore.rules', 'my existing rules'); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]); | ||
| expect(tree.exists('/firestore.indexes.json')).toBeTrue(); | ||
| }); | ||
|
|
||
| it('does nothing when Firestore is not selected', () => { | ||
| const tree = new HostTree(); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Authentication]); | ||
| expect(tree.exists('/firestore.rules')).toBeFalse(); | ||
| expect(tree.exists('/firestore.indexes.json')).toBeFalse(); | ||
| }); | ||
|
|
||
| it('creates nothing when firebase.json already has a firestore section', () => { | ||
| const tree = new HostTree(); | ||
| createFirestoreStarterFiles(tree, context, [FEATURES.Firestore], { | ||
| firestore: { rules: 'custom.rules' }, | ||
| }); | ||
| expect(tree.exists('/firestore.rules')).toBeFalse(); | ||
| expect(tree.exists('/firestore.indexes.json')).toBeFalse(); | ||
| }); | ||
|
|
||
| }); | ||
|
|
||
| describe('addFirestoreToFirebaseJson', () => { | ||
| let projectRoot: string; | ||
|
|
||
| beforeEach(() => { | ||
| projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-')); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fsExtra.removeSync(projectRoot); | ||
| }); | ||
|
|
||
| const firebaseJsonOnDisk = () => | ||
| JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString()); | ||
|
|
||
| it('adds the firestore section pointing at the starter files', () => { | ||
| writeFileSync(join(projectRoot, 'firebase.json'), '{}'); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]); | ||
| expect(firebaseJsonOnDisk().firestore).toEqual({ | ||
| rules: 'firestore.rules', | ||
| indexes: 'firestore.indexes.json', | ||
| }); | ||
| }); | ||
|
|
||
| it('preserves sections other tools wrote to firebase.json', () => { | ||
| writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({ | ||
| dataconnect: { source: 'dataconnect' }, | ||
| })); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]); | ||
| expect(firebaseJsonOnDisk().dataconnect).toEqual({ source: 'dataconnect' }); | ||
| expect(firebaseJsonOnDisk().firestore).toBeDefined(); | ||
| }); | ||
|
|
||
| it('leaves an existing firestore section untouched', () => { | ||
| writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({ | ||
| firestore: { rules: 'custom.rules' }, | ||
| })); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]); | ||
| expect(firebaseJsonOnDisk().firestore).toEqual({ rules: 'custom.rules' }); | ||
| }); | ||
|
|
||
| it('does nothing when Firestore is not selected', () => { | ||
| writeFileSync(join(projectRoot, 'firebase.json'), '{}'); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Authentication]); | ||
| expect(firebaseJsonOnDisk().firestore).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('warns and skips when firebase.json cannot be read', () => { | ||
| const warnSpy = spyOn(context.logger, 'warn'); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]); | ||
| expect(warnSpy).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('warns and skips when firebase.json parses to a non-object', () => { | ||
| writeFileSync(join(projectRoot, 'firebase.json'), 'null'); | ||
| const warnSpy = spyOn(context.logger, 'warn'); | ||
| addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]); | ||
| expect(warnSpy).toHaveBeenCalled(); | ||
| expect(readFileSync(join(projectRoot, 'firebase.json')).toString()).toBe('null'); | ||
| }); | ||
|
|
||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import { existsSync, readFileSync, writeFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; | ||
| import { stringifyFormatted } from '../common.js'; | ||
| import { FEATURES, FirebaseJSON, FirebaseRc } from '../interfaces.js'; | ||
|
|
||
| /** | ||
| * Builds the same test-mode starter rules `firebase init firestore` generates: anyone can read | ||
| * and write until a date 30 days from generation, after which all client requests are denied. | ||
| */ | ||
| const testModeFirestoreRules = () => { | ||
| const expiry = new Date(); | ||
| expiry.setDate(expiry.getDate() + 30); | ||
| return `rules_version='2' | ||
|
|
||
| service cloud.firestore { | ||
| match /databases/{database}/documents { | ||
| match /{document=**} { | ||
| // This rule allows anyone with your database reference to view, edit, | ||
| // and delete all data in your database. It is useful for getting | ||
| // started, but it is configured to expire after 30 days because it | ||
| // leaves your app open to attackers. At that time, all client | ||
| // requests to your database will be denied. | ||
| // | ||
| // Make sure to write security rules for your app before that time, or | ||
| // else all client requests to your database will be denied until you | ||
| // update your rules. | ||
| allow read, write: if request.time < timestamp.date(${expiry.getFullYear()}, ${expiry.getMonth() + 1}, ${expiry.getDate()}); | ||
| } | ||
| } | ||
| } | ||
| `; | ||
| }; | ||
|
|
||
| /** | ||
| * Records the selected Firebase project as the workspace's default in `.firebaserc`, so | ||
| * firebase-tools commands the user runs after setup completes don't prompt for (or fail without) | ||
| * a project. Merges into an existing file — only `projects.default` is set; targets and other | ||
| * aliases are preserved. Written to the real filesystem (not the schematic Tree) because | ||
| * `firebaseTools.init` writes `.firebaserc` on disk during the same `ng add` run — a Tree-staged | ||
| * copy would collide with it when the schematic commits; call this after the last | ||
| * `firebaseTools.init` call for that reason. | ||
| */ | ||
| export const setDefaultProjectInFirebaseRc = (projectRoot: string, projectId: string) => { | ||
| const path = join(projectRoot, '.firebaserc'); | ||
| let rc: FirebaseRc = {}; | ||
| if (existsSync(path)) { | ||
| try { | ||
| rc = JSON.parse(readFileSync(path).toString()); | ||
| } catch (e) { | ||
| throw new SchematicsException(`Error when parsing ${path}: ${e.message}`); | ||
| } | ||
| } | ||
| rc.projects = { ...rc.projects, default: projectId }; | ||
| writeFileSync(path, stringifyFormatted(rc)); | ||
| }; | ||
|
|
||
| /** | ||
| * When Firestore is selected, generates `firestore.rules` (test mode, with a logged warning | ||
| * about the 30-day expiry) and `firestore.indexes.json`. Existing files are never overwritten — | ||
| * a later `firebase deploy` would replace rules already deployed from elsewhere. A workspace | ||
| * whose firebase.json already has a `firestore` section is left entirely untouched: its section | ||
| * may point at differently-named files, and creating the default-named ones would only add | ||
| * orphans nothing references. | ||
| * | ||
| * `firebaseJsonConfig`, when passed, should be the post-init snapshot of firebase.json (read | ||
| * after the last `firebaseTools.init` call and before `addFirestoreToFirebaseJson` runs) — see | ||
| * the call site in `ngAddSetupProject` for why that order matters. Passing a stale pre-init | ||
| * snapshot risks staging the default-named starter files on top of files firebase-tools already | ||
| * wrote to disk, colliding when the Tree commits. | ||
| */ | ||
| export const createFirestoreStarterFiles = ( | ||
| host: Tree, | ||
| context: SchematicContext, | ||
| features: FEATURES[], | ||
| firebaseJsonConfig?: FirebaseJSON, | ||
| ) => { | ||
| if (!features.includes(FEATURES.Firestore)) { return; } | ||
| if (firebaseJsonConfig?.firestore) { | ||
| context.logger.info('firebase.json already has a firestore section — leaving its rules and indexes untouched.'); | ||
| return; | ||
| } | ||
| if (host.exists('/firestore.rules')) { | ||
| context.logger.info('firestore.rules already exists — leaving it untouched.'); | ||
| } else { | ||
| host.create('/firestore.rules', testModeFirestoreRules()); | ||
| context.logger.warn( | ||
| 'Generated firestore.rules in test mode: anyone can read and write your database until the rules expire in 30 days. ' + | ||
| 'Write real security rules before then — https://firebase.google.com/docs/rules' | ||
| ); | ||
| } | ||
| if (!host.exists('/firestore.indexes.json')) { | ||
| // Must exist once firebase.json references it — `firebase deploy` errors on a missing indexes file. | ||
| host.create('/firestore.indexes.json', stringifyFormatted({ indexes: [], fieldOverrides: [] })); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * When Firestore is selected, points the `firestore` section of `firebase.json` at the starter | ||
| * files so `firebase deploy` includes rules and indexes. Written to the real filesystem (not the | ||
| * schematic Tree) because firebase-tools reads and rewrites `firebase.json` during the same | ||
| * `ng add` run; call this after the last `firebaseTools.init` call for that reason. | ||
| */ | ||
| export const addFirestoreToFirebaseJson = ( | ||
| projectRoot: string, | ||
| context: SchematicContext, | ||
| features: FEATURES[], | ||
| ) => { | ||
| if (!features.includes(FEATURES.Firestore)) { return; } | ||
| // firebaseTools.init calls may have rewritten firebase.json on disk, so re-read it, add the | ||
| // firestore section if absent, and write it back. The whole read-check-write is guarded: a | ||
| // failed read (missing or unparseable file), a parse that isn't a usable object (reading | ||
| // `.firestore` off `null`/`undefined` throws; assigning it on a non-object primitive throws | ||
| // too), or a failed write (disk full, permissions) all warn and leave the file for the user to | ||
| // finish, rather than crashing the schematic with a raw Node error. | ||
| try { | ||
| const firebaseJson: FirebaseJSON = JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString()); | ||
| if (!firebaseJson.firestore) { | ||
| firebaseJson.firestore = { rules: 'firestore.rules', indexes: 'firestore.indexes.json' }; | ||
| writeFileSync(join(projectRoot, 'firebase.json'), stringifyFormatted(firebaseJson)); | ||
| } | ||
| } catch (e) { | ||
| context.logger.warn( | ||
| `Could not update firebase.json with the firestore section (${e.message}). Check firebase.json ` + | ||
| 'and add { "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" } } manually.' | ||
| ); | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worth adding one sentence here noting that this must be called with the pre-init
firebaseJsonsnapshot (not a post-init re-read). If a futurefirebaseTools.initcall adds afirestoresection to firebase.json on disk,addFirestoreToFirebaseJsonwill correctly skip, but the Tree-staged starter files will have already been created and will be orphaned. The ordering constraint is non-obvious without it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch - traced it further and it's a bit worse than an orphan: if a future init call adds a
firestoresection while this is still reading the pre-init snapshot, it'd stage the default-named starter files right on top of the ones that init call would have written to disk - the same collision.firebaserchit earlier in this PR, which aborts the wholeng add. So:addFirestoreToFirebaseJson, since that's what actually adds thefirestoresection on a normal run — added a comment on that ordering too.