Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 72d3c2e

Browse files
jamesdanielssulco
andauthoredSep 14, 2021
feat(schematics): Super charge the schematics (#2836)
Co-authored-by: Tomek Sułkowski <[email protected]>
1 parent d6cfe16 commit 72d3c2e

38 files changed

+2205
-1419
lines changed
 

‎.github/workflows/test.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,14 @@ jobs:
155155
yarn config set yarn-offline-mirror ~/.npm-packages-offline-cache
156156
yarn install --frozen-lockfile --prefer-offline --ignore-engines
157157
- name: Build
158-
id: yarn-pack-dir
159158
run: yarn build
159+
# Seeing some flakes on windows, skip for now
160+
# https://github.com/angular/angularfire/runs/3593478229
161+
# not just windows
162+
# https://github.com/angular/angularfire/runs/3593535123
163+
# - name: Test
164+
# if: runner.os != 'windows'
165+
# run: yarn test
160166

161167
headless:
162168
runs-on: ubuntu-latest

‎package.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,22 +52,25 @@
5252
"@angular/platform-browser": "^12.0.0",
5353
"@angular/platform-browser-dynamic": "^12.0.0",
5454
"@angular/router": "^12.0.0",
55+
"@schematics/angular": "^12.0.0",
5556
"firebase": "^9.0.0",
56-
"firebase-admin": "^8.10.0",
57+
"firebase-admin": "^9.11.1",
5758
"firebase-functions": "^3.6.0",
5859
"firebase-tools": "^9.0.0",
5960
"fs-extra": "^8.0.1",
6061
"fuzzy": "^0.1.3",
6162
"husky": "^4.2.5",
62-
"inquirer": "^6.2.2",
6363
"inquirer-autocomplete-prompt": "^1.0.1",
6464
"jsonc-parser": "^3.0.0",
65-
"open": "^7.0.3",
65+
"open": "^7.0.3 || ^8.0.0",
66+
"ora": "^5.3.0",
6667
"rxfire": "^6.0.0",
6768
"rxjs": "~6.6.0",
6869
"semver": "^7.1.3",
70+
"triple-beam": "^1.3.0",
6971
"tslib": "^2.1.0",
7072
"webpack": "^5.35.0",
73+
"winston": "^3.0.0",
7174
"zone.js": "~0.11.4"
7275
},
7376
"devDependencies": {
@@ -84,6 +87,8 @@
8487
"@types/node": "^12.6.2 < 12.12.42",
8588
"@types/request": "0.0.30",
8689
"@types/semver": "^7.1.0",
90+
"@types/triple-beam": "^1.3.0",
91+
"@types/winston": "^2.4.4",
8792
"codelyzer": "^6.0.0",
8893
"concurrently": "^2.2.0",
8994
"conventional-changelog-cli": "^1.2.0",

‎samples/advanced/.firebaserc

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎samples/advanced/angular.json

Lines changed: 12 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎samples/advanced/firebase.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎samples/advanced/package.json

Lines changed: 1 addition & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎samples/advanced/yarn.lock

Lines changed: 9 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎samples/modular/yarn.lock

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@
3232
"rxjs": "~6.6.0"
3333
},
3434
"dependencies": {
35-
"tslib": "^2.0.0"
35+
"tslib": "^2.0.0",
36+
"fuzzy": "^0.1.3",
37+
"inquirer-autocomplete-prompt": "^1.0.1",
38+
"open": "^8.0.0",
39+
"jsonc-parser": "^3.0.0",
40+
"ora": "^5.3.0",
41+
"winston": "^3.0.0",
42+
"triple-beam": "^1.3.0"
3643
},
3744
"ngPackage": {
3845
"lib": {
@@ -45,7 +52,12 @@
4552
},
4653
"entryFile": "public_api.ts"
4754
},
48-
"dest": "../dist/packages-dist"
55+
"dest": "../dist/packages-dist",
56+
"allowedNonPeerDependencies": [
57+
"fuzzy", "inquirer-autocomplete-prompt",
58+
"open", "jsonc-parser", "ora", "winston",
59+
"triple-beam"
60+
]
4961
},
5062
"ng-update": {
5163
"migrations": "./schematics/migration.json"

‎src/schematics/add/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { chain, Rule, SchematicContext, TaskId, Tree } from '@angular-devkit/schematics';
2+
import { DeployOptions } from '../interfaces';
3+
import { addDependencies } from '../common';
4+
import { peerDependencies } from '../versions.json';
5+
import { NodePackageInstallTask, RunSchematicTask } from '@angular-devkit/schematics/tasks';
6+
7+
const addFirebaseHostingDependencies = () => (tree: Tree, context: SchematicContext) => {
8+
addDependencies(
9+
tree,
10+
peerDependencies,
11+
context
12+
);
13+
return tree;
14+
};
15+
16+
let npmInstallTaskId: TaskId;
17+
18+
const npmInstall = () => (tree: Tree, context: SchematicContext) => {
19+
npmInstallTaskId = context.addTask(new NodePackageInstallTask());
20+
return tree;
21+
};
22+
23+
const runSetup = (options: DeployOptions) => (tree: Tree, context: SchematicContext) => {
24+
context.addTask(new RunSchematicTask('ng-add-setup-project', options), [npmInstallTaskId]);
25+
return tree;
26+
};
27+
28+
export const ngAdd = (options: DeployOptions): Rule => {
29+
return chain([
30+
addFirebaseHostingDependencies(),
31+
npmInstall(),
32+
runSetup(options),
33+
]);
34+
};

‎src/schematics/add/schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "angular-fire-ng-add",
4+
"title": "AngularFire ng-add",
5+
"type": "object",
6+
"properties": {
7+
"project": {
8+
"type": "string",
9+
"description": "The name of the project.",
10+
"$default": {
11+
"$source": "projectName"
12+
}
13+
}
14+
},
15+
"required": []
16+
}

‎src/schematics/collection.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
"schematics": {
44
"ng-add": {
55
"description": "Add firebase deploy schematic",
6-
"factory": "./public_api#ngAdd"
6+
"factory": "./add#ngAdd",
7+
"schema": "./add/schema.json"
78
},
89
"ng-add-setup-project": {
910
"description": "Setup ng deploy",
10-
"factory": "./public_api#ngAddSetupProject"
11+
"factory": "./setup#ngAddSetupProject",
12+
"schema": "./setup/schema.json"
1113
}
1214
}
1315
}

‎src/schematics/ng-add-common.ts renamed to ‎src/schematics/common.ts

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,7 @@
11
import { SchematicsException, Tree, SchematicContext } from '@angular-devkit/schematics';
2-
import { FirebaseRc } from './interfaces';
2+
import { FirebaseHostingSite, FirebaseRc } from './interfaces';
33
import * as semver from 'semver';
4-
5-
export interface NgAddOptions {
6-
firebaseProject: string;
7-
project?: string;
8-
}
9-
10-
export interface NgAddNormalizedOptions {
11-
firebaseProject: string;
12-
project: string;
13-
}
14-
15-
export interface DeployOptions {
16-
project: string;
17-
}
4+
import { shortSiteName } from './utils';
185

196
export const stringifyFormatted = (obj: any) => JSON.stringify(obj, null, 2);
207

@@ -36,12 +23,11 @@ function emptyFirebaseRc() {
3623
};
3724
}
3825

39-
function generateFirebaseRcTarget(firebaseProject: string, project: string) {
26+
function generateFirebaseRcTarget(firebaseProject: string, firebaseHostingSite: FirebaseHostingSite|undefined, project: string) {
4027
return {
4128
hosting: {
4229
[project]: [
43-
// TODO(kirjs): Generally site name is consistent with the project name, but there are edge cases.
44-
firebaseProject
30+
shortSiteName(firebaseHostingSite) ?? firebaseProject
4531
]
4632
}
4733
};
@@ -51,25 +37,20 @@ export function generateFirebaseRc(
5137
tree: Tree,
5238
path: string,
5339
firebaseProject: string,
40+
firebaseHostingSite: FirebaseHostingSite|undefined,
5441
project: string
5542
) {
5643
const firebaseRc: FirebaseRc = tree.exists(path)
5744
? safeReadJSON(path, tree)
5845
: emptyFirebaseRc();
5946

6047
firebaseRc.targets = firebaseRc.targets || {};
61-
62-
/* TODO do we want to prompt?
63-
if (firebaseProject in firebaseRc.targets) {
64-
throw new SchematicsException(
65-
`Firebase project ${firebaseProject} already defined in .firebaserc`
66-
);
67-
}*/
68-
6948
firebaseRc.targets[firebaseProject] = generateFirebaseRcTarget(
7049
firebaseProject,
50+
firebaseHostingSite,
7151
project
7252
);
53+
firebaseRc.projects = { default: firebaseProject };
7354

7455
overwriteIfExists(tree, path, stringifyFormatted(firebaseRc));
7556
}
@@ -95,28 +76,27 @@ export const addDependencies = (
9576
throw new SchematicsException('Could not locate package.json');
9677
}
9778

79+
packageJson.devDependencies ??= {};
80+
packageJson.dependencies ??= {};
81+
9882
Object.keys(deps).forEach(depName => {
9983
const dep = deps[depName];
100-
if (dep.dev) {
101-
const existingVersion = packageJson.devDependencies[depName];
102-
if (existingVersion) {
84+
const existingDeps = dep.dev ? packageJson.devDependencies : packageJson.dependencies;
85+
const existingVersion = existingDeps[depName];
86+
if (existingVersion) {
87+
try {
10388
if (!semver.intersects(existingVersion, dep.version)) {
10489
context.logger.warn(`⚠️ The ${depName} devDependency specified in your package.json (${existingVersion}) does not fulfill AngularFire's dependency (${dep.version})`);
10590
// TODO offer to fix
10691
}
107-
} else {
108-
packageJson.devDependencies[depName] = dep.version;
109-
}
110-
} else {
111-
const existingVersion = packageJson.dependencies[depName];
112-
if (existingVersion) {
113-
if (!semver.intersects(existingVersion, dep.version)) {
114-
context.logger.warn(`⚠️ The ${depName} dependency specified in your package.json (${existingVersion}) does not fulfill AngularFire's dependency (${dep.version})`);
92+
} catch (e) {
93+
if (existingVersion !== dep.version) {
94+
context.logger.warn(`⚠️ The ${depName} devDependency specified in your package.json (${existingVersion}) does not fulfill AngularFire's dependency (${dep.version})`);
11595
// TODO offer to fix
11696
}
117-
} else {
118-
packageJson.dependencies[depName] = dep.version;
11997
}
98+
} else {
99+
existingDeps[depName] = dep.version;
120100
}
121101
});
122102

‎src/schematics/deploy/actions.jasmine.ts

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { JsonObject, logging } from '@angular-devkit/core';
22
import { BuilderContext, BuilderRun, ScheduleOptions, Target } from '@angular-devkit/architect';
33
import { BuildTarget, FirebaseDeployConfig, FirebaseTools, FSHost } from '../interfaces';
4-
import deploy, { deployToFunction } from './actions';
4+
import deploy, { deployToFunction } from '@angular/fire/schematics/deploy/actions';
55
import { join } from 'path';
66
import 'jasmine';
77

@@ -28,13 +28,29 @@ const initMocks = () => {
2828
renameSync(_: string, __: string) {
2929
},
3030
writeFileSync(_: string, __: string) {
31+
},
32+
copySync(_: string, __: string) {
33+
},
34+
removeSync(_: string) {
3135
}
3236
};
3337

3438
firebaseMock = {
3539
login: () => Promise.resolve(),
3640
projects: {
37-
list: () => Promise.resolve([])
41+
list: () => Promise.resolve([]),
42+
create: () => Promise.reject(),
43+
},
44+
apps: {
45+
list: () => Promise.resolve([]),
46+
create: () => Promise.reject(),
47+
sdkconfig: () => Promise.resolve({ fileName: '_', fileContents: '', sdkConfig: {}, }),
48+
},
49+
hosting: {
50+
sites: {
51+
list: () => Promise.resolve({sites: []}),
52+
create: () => Promise.reject(),
53+
}
3854
},
3955
deploy: (_: FirebaseDeployConfig) => Promise.resolve(),
4056
use: () => Promise.resolve(),
@@ -86,19 +102,22 @@ describe('Deploy Angular apps', () => {
86102

87103
it('should call login', async () => {
88104
const spy = spyOn(firebaseMock, 'login');
89-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false });
105+
await deploy(
106+
firebaseMock, context, STATIC_BUILD_TARGET, undefined,
107+
undefined, undefined, { projectId: FIREBASE_PROJECT, preview: false }
108+
);
90109
expect(spy).toHaveBeenCalled();
91110
});
92111

93112
it('should not call login', async () => {
94113
const spy = spyOn(firebaseMock, 'login');
95-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }, FIREBASE_TOKEN);
114+
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false }, FIREBASE_TOKEN);
96115
expect(spy).not.toHaveBeenCalled();
97116
});
98117

99118
it('should invoke the builder', async () => {
100119
const spy = spyOn(context, 'scheduleTarget').and.callThrough();
101-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false });
120+
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false });
102121
expect(spy).toHaveBeenCalled();
103122
expect(spy).toHaveBeenCalledWith({
104123
target: 'build',
@@ -113,26 +132,27 @@ describe('Deploy Angular apps', () => {
113132
options: {}
114133
};
115134
const spy = spyOn(context, 'scheduleTarget').and.callThrough();
116-
await deploy(firebaseMock, context, buildTarget, undefined, FIREBASE_PROJECT, { preview: false });
135+
await deploy(firebaseMock, context, buildTarget, undefined, undefined, undefined, { preview: false });
117136
expect(spy).toHaveBeenCalled();
118137
expect(spy).toHaveBeenCalledWith({ target: 'prerender', project: PROJECT }, {});
119138
});
120139

121140
it('should invoke firebase.deploy', async () => {
122141
const spy = spyOn(firebaseMock, 'deploy').and.callThrough();
123-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false }, FIREBASE_TOKEN);
142+
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false }, FIREBASE_TOKEN);
124143
expect(spy).toHaveBeenCalled();
125144
expect(spy).toHaveBeenCalledWith({
126145
cwd: 'cwd',
127146
only: 'hosting:' + PROJECT,
128-
token: FIREBASE_TOKEN
147+
token: FIREBASE_TOKEN,
148+
nonInteractive: true,
129149
});
130150
});
131151

132152
describe('error handling', () => {
133153
it('throws if there is no firebase project', async () => {
134154
try {
135-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, { preview: false });
155+
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false });
136156
} catch (e) {
137157
expect(e.message).toMatch(/Cannot find firebase project/);
138158
}
@@ -141,7 +161,7 @@ describe('Deploy Angular apps', () => {
141161
it('throws if there is no target project', async () => {
142162
context.target = undefined;
143163
try {
144-
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, FIREBASE_PROJECT, { preview: false });
164+
await deploy(firebaseMock, context, STATIC_BUILD_TARGET, undefined, undefined, undefined, { preview: false });
145165
} catch (e) {
146166
expect(e.message).toMatch(/Cannot execute the build target/);
147167
}
@@ -174,6 +194,28 @@ describe('universal deployment', () => {
174194
expect(functionArgs[0]).toBe(join('dist', 'index.js'));
175195
});
176196

197+
it('should create a firebase function (new)', async () => {
198+
const spy = spyOn(fsHost, 'writeFileSync');
199+
await deployToFunction(
200+
firebaseMock,
201+
context,
202+
'/home/user',
203+
STATIC_BUILD_TARGET,
204+
SERVER_BUILD_TARGET,
205+
{ preview: false, outputPath: join('dist', 'functions') },
206+
undefined,
207+
fsHost
208+
);
209+
210+
expect(spy).toHaveBeenCalledTimes(2);
211+
212+
const packageArgs = spy.calls.argsFor(0);
213+
const functionArgs = spy.calls.argsFor(1);
214+
215+
expect(packageArgs[0]).toBe(join('dist', 'functions', 'package.json'));
216+
expect(functionArgs[0]).toBe(join('dist', 'functions', 'index.js'));
217+
});
218+
177219
it('should rename the index.html file in the nested dist', async () => {
178220
const spy = spyOn(fsHost, 'renameSync');
179221
await deployToFunction(
@@ -197,6 +239,29 @@ describe('universal deployment', () => {
197239
]);
198240
});
199241

242+
it('should rename the index.html file in the nested dist (new)', async () => {
243+
const spy = spyOn(fsHost, 'renameSync');
244+
await deployToFunction(
245+
firebaseMock,
246+
context,
247+
'/home/user',
248+
STATIC_BUILD_TARGET,
249+
SERVER_BUILD_TARGET,
250+
{ preview: false, outputPath: join('dist', 'functions') },
251+
undefined,
252+
fsHost
253+
);
254+
255+
expect(spy).toHaveBeenCalledTimes(1);
256+
257+
const packageArgs = spy.calls.argsFor(0);
258+
259+
expect(packageArgs).toEqual([
260+
join('dist', 'functions', 'dist', 'browser', 'index.html'),
261+
join('dist', 'functions', 'dist', 'browser', 'index.original.html')
262+
]);
263+
});
264+
200265
it('should invoke firebase.deploy', async () => {
201266
const spy = spyOn(firebaseMock, 'deploy');
202267
await deployToFunction(

‎src/schematics/deploy/actions.ts

Lines changed: 316 additions & 144 deletions
Large diffs are not rendered by default.

‎src/schematics/deploy/builder.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
22
import deploy, { DeployBuilderOptions } from './actions';
33
import { BuildTarget } from '../interfaces';
4-
import { getFirebaseProjectName } from '../utils';
4+
import { getFirebaseProjectNameFromFs } from '../utils';
5+
import { getFirebaseTools } from '../firebaseTools';
56

67
// Call the createBuilder() function to create a builder. This mirrors
78
// createJobHandler() but add typings specific to Architect Builders.
@@ -11,31 +12,51 @@ export default createBuilder(
1112
throw new Error('Cannot deploy the application without a target');
1213
}
1314

14-
const firebaseProject = options.firebaseProject || getFirebaseProjectName(
15+
const [defaultFirebaseProject, defulatFirebaseHostingSite] = getFirebaseProjectNameFromFs(
1516
context.workspaceRoot,
1617
context.target.project
1718
);
1819

20+
const firebaseProject = options.firebaseProject || defaultFirebaseProject;
1921
if (!firebaseProject) {
20-
throw new Error('Cannot find firebase project for your app in .firebaserc');
22+
throw new Error('Cannot detirmine the Firebase Project from your angular.json or .firebaserc');
23+
}
24+
if (firebaseProject !== defaultFirebaseProject) {
25+
throw new Error('The Firebase Project specified by your angular.json or .firebaserc is in conflict');
26+
}
27+
28+
const firebaseHostingSite = options.firebaseHostingSite || defulatFirebaseHostingSite;
29+
if (!firebaseHostingSite) {
30+
throw new Error(`Cannot detirmine the Firebase Hosting Site from your angular.json or .firebaserc`);
31+
}
32+
if (firebaseHostingSite !== defulatFirebaseHostingSite) {
33+
throw new Error('The Firebase Hosting Site specified by your angular.json or .firebaserc is in conflict');
2134
}
2235

23-
const staticBuildTarget = { name: options.buildTarget || `${context.target.project}:build:production` };
36+
const staticBuildTarget = { name: options.browserTarget || options.buildTarget || `${context.target.project}:build:production` };
37+
38+
let prerenderBuildTarget: BuildTarget | undefined;
39+
if (options.prerender) {
40+
prerenderBuildTarget = {
41+
name: options.prerenderTarget || `${context.target.project}:prerender:production`
42+
};
43+
}
2444

2545
let serverBuildTarget: BuildTarget | undefined;
2646
if (options.ssr) {
2747
serverBuildTarget = {
28-
name: options.universalBuildTarget || `${context.target.project}:server:production`
48+
name: options.serverTarget || options.universalBuildTarget || `${context.target.project}:server:production`
2949
};
3050
}
3151

3252
try {
3353
process.env.FIREBASE_DEPLOY_AGENT = 'angularfire';
3454
await deploy(
35-
require('firebase-tools'),
55+
(await getFirebaseTools()),
3656
context,
3757
staticBuildTarget,
3858
serverBuildTarget,
59+
prerenderBuildTarget,
3960
firebaseProject,
4061
options,
4162
process.env.FIREBASE_TOKEN,
Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { DeployBuilderOptions } from './actions';
22

3-
// TODO allow these to be configured
4-
const DEFAULT_NODE_VERSION = 10;
5-
const DEFAULT_FUNCTION_NAME = 'ssr';
3+
export const DEFAULT_NODE_VERSION = 14;
4+
export const DEFAULT_FUNCTION_NAME = 'ssr';
5+
66
const DEFAULT_FUNCTION_REGION = 'us-central1';
7+
78
const DEFAULT_RUNTIME_OPTIONS = {
89
timeoutSeconds: 60,
910
memory: '1GB'
@@ -13,39 +14,46 @@ export const defaultPackage = (
1314
dependencies: {[key: string]: string},
1415
devDependencies: {[key: string]: string},
1516
options: DeployBuilderOptions,
16-
) => `{
17-
"name": "functions",
18-
"description": "Angular Universal Application",
19-
"scripts": {
20-
"lint": "",
21-
"serve": "firebase serve --only functions",
22-
"shell": "firebase functions:shell",
23-
"start": "npm run shell",
24-
"deploy": "firebase deploy --only functions",
25-
"logs": "firebase functions:log"
17+
main?: string,
18+
) => ({
19+
name: 'functions',
20+
description: 'Angular Universal Application',
21+
main: main ?? 'index.js',
22+
scripts: {
23+
start: main ? `node ${main}` : 'firebase functions:shell',
2624
},
27-
"engines": {
28-
"node": "${options.functionsNodeVersion || DEFAULT_NODE_VERSION}"
25+
engines: {
26+
node: (options.functionsNodeVersion || DEFAULT_NODE_VERSION).toString()
2927
},
30-
"dependencies": ${JSON.stringify(dependencies, null, 4)},
31-
"devDependencies": ${JSON.stringify(devDependencies, null, 4)},
32-
"private": true
33-
}
34-
`;
28+
dependencies,
29+
devDependencies,
30+
private: true
31+
});
3532

3633
export const defaultFunction = (
3734
path: string,
3835
options: DeployBuilderOptions,
36+
functionName: string|undefined,
3937
) => `const functions = require('firebase-functions');
4038
4139
// Increase readability in Cloud Logging
4240
require("firebase-functions/lib/logger/compat");
4341
4442
const expressApp = require('./${path}/main').app();
4543
46-
exports.${DEFAULT_FUNCTION_NAME} = functions
47-
.region('${DEFAULT_FUNCTION_REGION}')
44+
exports.${functionName || DEFAULT_FUNCTION_NAME} = functions
45+
.region('${options.region || DEFAULT_FUNCTION_REGION}')
4846
.runWith(${JSON.stringify(options.functionsRuntimeOptions || DEFAULT_RUNTIME_OPTIONS)})
4947
.https
5048
.onRequest(expressApp);
5149
`;
50+
51+
export const dockerfile = (
52+
options: DeployBuilderOptions,
53+
) => `FROM node:${options.functionsNodeVersion || DEFAULT_NODE_VERSION}-slim
54+
WORKDIR /usr/src/app
55+
COPY package*.json ./
56+
RUN npm install --only=production
57+
COPY . ./
58+
CMD [ "npm", "start" ]
59+
`;

‎src/schematics/deploy/schema.json

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,103 @@
99
"description": "Target to build.",
1010
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
1111
},
12+
"browserTarget": {
13+
"type": "string",
14+
"description": "Target to build.",
15+
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
16+
},
17+
"prerenderTarget": {
18+
"type": "string",
19+
"description": "Target to build.",
20+
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
21+
},
22+
"serverTarget": {
23+
"type": "string",
24+
"description": "Target to build.",
25+
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
26+
},
27+
"universalBuildTarget": {
28+
"type": "string",
29+
"description": "Target to build.",
30+
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
31+
},
32+
"ssr": {
33+
"type": ["boolean", "string"],
34+
"description": "Should we attempt to deploy the function to Cloud Functions (true or 'cloud-functions') / Cloud Run ('cloud-run') or just Hosting (false)"
35+
},
36+
"prerender": {
37+
"type": "boolean",
38+
"description": "Prerender before deploy?"
39+
},
1240
"firebaseProject": {
1341
"type": "string",
1442
"description": "The Firebase project name or project alias to use when deploying"
1543
},
44+
"firebaseHostingSite": {
45+
"type": "string",
46+
"description": "The Firebase Hosting site to deploy to"
47+
},
48+
"functionName": {
49+
"type": "string",
50+
"description": "The name of the Cloud Function or Cloud Run serviceId to deploy SSR to"
51+
},
1652
"functionsNodeVersion": {
17-
"type": "number",
18-
"description": "Version of Node.js to run Cloud Functions on"
53+
"type": ["number", "string"],
54+
"description": "Version of Node.js to run Cloud Functions / Run on"
55+
},
56+
"region": {
57+
"type": "string",
58+
"description": "The region to deploy Cloud Functions or Cloud Run to"
59+
},
60+
"outputPath": {
61+
"type": "string",
62+
"description": "Where to output the deploy artifacts"
1963
},
2064
"functionsRuntimeOptions": {
2165
"type": "object",
22-
"description": "Runtime options for Cloud Functions"
66+
"description": "Runtime options for Cloud Functions, if deploying to Cloud Functions"
2367
},
2468
"preview": {
2569
"type": "boolean",
2670
"description": "Do not deploy the application, just set up the Firebase Function in the project output directory. Can be used for testing the Firebase Function with `firebase serve`."
71+
},
72+
"cloudRunOptions": {
73+
"type": "object",
74+
"description": "Options passed to Cloud Run, if deploying to Cloud Run.",
75+
"properties": {
76+
"cpus": {
77+
"type": "number",
78+
"description": "Set a CPU limit in Kubernetes cpu units."
79+
},
80+
"maxConcurrency": {
81+
"type": ["number", "string"],
82+
"pattern": "^(\\d+|default)$",
83+
"description": "Set the maximum number of concurrent requests allowed per container instance. If concurrency is unspecified, any number of concurrent requests are allowed. To unset this field, provide the special value default."
84+
},
85+
"maxInstances": {
86+
"type": ["number", "string"],
87+
"pattern": "^(\\d+|default)$",
88+
"description": "The maximum number of container instances of the Service to run. Use 'default' to unset the limit and use the platform default."
89+
},
90+
"memory": {
91+
"type": "string",
92+
"pattern": "^\\d+(G|M)i$",
93+
"description": "Set a memory limit. Ex: 1Gi, 512Mi."
94+
},
95+
"minInstances": {
96+
"type": ["number", "string"],
97+
"pattern": "^(\\d+|default)$",
98+
"description": "The minimum number of container instances of the Service to run or 'default' to remove any minimum."
99+
},
100+
"timeout": {
101+
"type": "number",
102+
"description": "Set the maximum request execution time (timeout) in seconds."
103+
},
104+
"vpcConnector": {
105+
"type": "string",
106+
"description": "Set a VPC connector for this resource."
107+
}
108+
}
27109
}
28110
}
29111
}

‎src/schematics/firebaseTools.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { FirebaseTools } from './interfaces';
2+
import { spawn, execSync } from 'child_process';
3+
import ora from 'ora';
4+
5+
declare global {
6+
var firebaseTools: FirebaseTools|undefined;
7+
}
8+
9+
export const getFirebaseTools = () => globalThis.firebaseTools ?
10+
Promise.resolve(globalThis.firebaseTools) :
11+
new Promise<FirebaseTools>((resolve, reject) => {
12+
try {
13+
resolve(require('firebase-tools'));
14+
} catch (e) {
15+
try {
16+
const root = execSync('npm root -g').toString().trim();
17+
resolve(require(`${root}/firebase-tools`));
18+
} catch (e) {
19+
const spinner = ora({
20+
text: `Installing firebase-tools...`,
21+
// Workaround for https://github.com/sindresorhus/ora/issues/136.
22+
discardStdin: process.platform !== 'win32',
23+
}).start();
24+
spawn('npm', ['i', '-g', 'firebase-tools'], {
25+
stdio: 'pipe',
26+
shell: true,
27+
}).on('close', (code) => {
28+
if (code === 0) {
29+
spinner.succeed('firebase-tools installed globally.');
30+
spinner.stop();
31+
const root = execSync('npm root -g').toString().trim();
32+
resolve(require(`${root}/firebase-tools`));
33+
} else {
34+
spinner.fail('Package install failed.');
35+
reject();
36+
}
37+
});
38+
}
39+
}
40+
}).then(firebaseTools => {
41+
globalThis.firebaseTools = firebaseTools;
42+
const version = firebaseTools.cli.version();
43+
console.log(`Using firebase-tools version ${version}`);
44+
if (parseInt(version, 10) !== 9) {
45+
console.error('firebase-tools version 9 is required');
46+
return Promise.reject();
47+
}
48+
return firebaseTools;
49+
});

‎src/schematics/interfaces.ts

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,108 @@
11
import { RuntimeOptions } from 'firebase-functions';
22

3-
export interface Project {
3+
export const enum FEATURES {
4+
Hosting,
5+
Authentication,
6+
Analytics,
7+
Database,
8+
Functions,
9+
Messaging,
10+
Performance,
11+
Firestore,
12+
Storage,
13+
RemoteConfig,
14+
}
15+
16+
export const featureOptions = [
17+
{ name: 'ng deploy -- hosting', value: FEATURES.Hosting },
18+
{ name: 'Authentication', value: FEATURES.Authentication },
19+
{ name: 'Firestore', value: FEATURES.Firestore },
20+
{ name: 'Realtime Database', value: FEATURES.Database },
21+
{ name: 'Analytics', value: FEATURES.Analytics },
22+
{ name: 'Cloud Functions (callable)', value: FEATURES.Functions },
23+
{ name: 'Cloud Messaging', value: FEATURES.Messaging },
24+
{ name: 'Performance Monitoring', value: FEATURES.Performance },
25+
{ name: 'Cloud Storage', value: FEATURES.Storage },
26+
{ name: 'Remote Config', value: FEATURES.RemoteConfig },
27+
];
28+
29+
export const enum PROJECT_TYPE { Static, CloudFunctions, CloudRun }
30+
31+
export interface NgAddOptions {
32+
firebaseProject: string;
33+
project?: string;
34+
}
35+
36+
export interface NgAddNormalizedOptions {
37+
project: string;
38+
firebaseProject: FirebaseProject;
39+
firebaseApp: FirebaseApp|undefined;
40+
firebaseHostingSite: FirebaseHostingSite|undefined;
41+
sdkConfig: Record<string, string>|undefined;
42+
prerender: boolean;
43+
browserTarget: string|undefined;
44+
serverTarget: string|undefined;
45+
prerenderTarget: string|undefined;
46+
}
47+
48+
export interface DeployOptions {
49+
project: string;
50+
}
51+
52+
export interface FirebaseProject {
453
projectId: string;
554
projectNumber: string;
655
displayName: string;
756
name: string;
857
resources: { [key: string]: string };
58+
state: string;
959
}
1060

1161
export interface FirebaseDeployConfig {
1262
cwd: string;
1363
only?: string;
1464
token?: string;
65+
[key: string]: any;
66+
}
67+
68+
export interface FirebaseApp {
69+
name: string;
70+
displayName: string;
71+
platform: string;
72+
appId: string;
73+
namespace: string;
74+
}
75+
76+
export interface FirebaseHostingSite {
77+
name: string;
78+
defaultUrl: string;
79+
type: string;
80+
appId: string|undefined;
81+
}
82+
83+
export interface FirebaseSDKConfig {
84+
fileName: string;
85+
fileContents: string;
86+
sdkConfig: { [key: string]: string };
1587
}
1688

1789
export interface FirebaseTools {
1890
projects: {
19-
list(): Promise<Project[]>;
91+
list(options: any): Promise<FirebaseProject[]>;
92+
create(projectId: string|undefined, options: any): Promise<FirebaseProject>;
93+
};
94+
95+
apps: {
96+
list(platform: string|undefined, options: any): Promise<FirebaseApp[]>;
97+
create(platform: string, displayName: string|undefined, options: any): Promise<FirebaseApp>;
98+
sdkconfig(type: string, projectId: string, options: any): Promise<FirebaseSDKConfig>;
99+
};
100+
101+
hosting: {
102+
sites: {
103+
list(options: any): Promise<{ sites: FirebaseHostingSite[]}>;
104+
create(siteId: string, options: any): Promise<FirebaseHostingSite>;
105+
}
20106
};
21107

22108
logger: {
@@ -67,16 +153,36 @@ export interface FirebaseRcTarget {
67153

68154
export interface FirebaseRc {
69155
targets?: Record<string, FirebaseRcTarget>;
156+
projects?: Record<string, string>;
70157
}
71158

72159
export interface DeployBuilderSchema {
73160
buildTarget?: string;
161+
browserTarget?: string;
74162
firebaseProject?: string;
163+
firebaseHostingSite?: string;
75164
preview?: boolean;
76165
universalBuildTarget?: string;
77-
ssr?: boolean;
78-
functionsNodeVersion?: number;
166+
serverTarget?: string;
167+
prerenderTarget?: string;
168+
ssr?: boolean | string;
169+
region?: string;
170+
prerender?: boolean;
171+
functionName?: string;
172+
functionsNodeVersion?: number|string;
79173
functionsRuntimeOptions?: RuntimeOptions;
174+
cloudRunOptions?: Partial<CloudRunOptions>;
175+
outputPath?: string;
176+
}
177+
178+
export interface CloudRunOptions {
179+
cpus: number;
180+
maxConcurrency: number | 'default';
181+
maxInstances: number | 'default';
182+
memory: string;
183+
minInstances: number | 'default';
184+
timeout: number;
185+
vpcConnector: string;
80186
}
81187

82188
export interface BuildTarget {
@@ -88,11 +194,20 @@ export interface FSHost {
88194
moveSync(src: string, dest: string): void;
89195
writeFileSync(src: string, data: string): void;
90196
renameSync(src: string, dest: string): void;
197+
copySync(src: string, dest: string): void;
198+
removeSync(src: string): void;
91199
}
92200

93201
export interface WorkspaceProject {
202+
root: string;
203+
sourceRoot?: string;
94204
projectType?: string;
95-
architect?: Record<string, { builder: string; options?: Record<string, any> }>;
205+
architect?: Record<string, {
206+
builder: string;
207+
options?: Record<string, any>,
208+
configurations?: Record<string, Record<string, any>>,
209+
defaultConfiguration?: string,
210+
}>;
96211
}
97212

98213
export interface Workspace {

‎src/schematics/migration.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
"migration-v7": {
55
"version": "7.0.0",
66
"description": "Update @angular/fire to v7",
7-
"factory": "./public_api#ngUpdateV7"
7+
"factory": "./update/v7#ngUpdate"
88
},
99
"ng-post-upgate": {
1010
"description": "Print out results after ng-update",
11-
"factory": "./public_api#ngPostUpdate",
11+
"factory": "./update#ngPostUpdate",
1212
"private": true
1313
}
1414
}

‎src/schematics/ng-add-ssr.ts

Lines changed: 0 additions & 188 deletions
This file was deleted.

‎src/schematics/ng-add.jasmine.ts

Lines changed: 143 additions & 92 deletions
Large diffs are not rendered by default.

‎src/schematics/ng-add.ts

Lines changed: 0 additions & 57 deletions
This file was deleted.

‎src/schematics/ngcc-config.ts

Lines changed: 0 additions & 70 deletions
This file was deleted.

‎src/schematics/public_api.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

‎src/schematics/setup/index.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
2+
import { getWorkspace, getProject, getFirebaseProjectNameFromHost, addEnvironmentEntry, addToNgModule, addIgnoreFiles } from '../utils';
3+
import { projectTypePrompt, appPrompt, sitePrompt, projectPrompt, featuresPrompt } from './prompts';
4+
import { setupUniversalDeployment } from './ssr';
5+
import { setupStaticDeployment } from './static';
6+
import {
7+
FirebaseApp, FirebaseHostingSite, FirebaseProject, DeployOptions, NgAddNormalizedOptions,
8+
FEATURES, PROJECT_TYPE
9+
} from '../interfaces';
10+
import { getFirebaseTools } from '../firebaseTools';
11+
12+
export const setupProject =
13+
async (tree: Tree, context: SchematicContext, features: FEATURES[], config: DeployOptions & {
14+
firebaseProject: FirebaseProject,
15+
firebaseApp?: FirebaseApp,
16+
firebaseHostingSite?: FirebaseHostingSite,
17+
sdkConfig?: Record<string, string>,
18+
projectType: PROJECT_TYPE,
19+
prerender: boolean,
20+
nodeVersion?: string,
21+
browserTarget?: string,
22+
serverTarget?: string,
23+
prerenderTarget?: string,
24+
project: string,
25+
}) => {
26+
const { path: workspacePath, workspace } = getWorkspace(tree);
27+
28+
const { project, projectName } = getProject(config, tree);
29+
30+
const sourcePath = [project.root, project.sourceRoot].filter(it => !!it).join('/');
31+
32+
addIgnoreFiles(tree);
33+
34+
const featuresToImport = features.filter(it => it !== FEATURES.Hosting);
35+
if (featuresToImport.length > 0) {
36+
addToNgModule(tree, { features: featuresToImport, sourcePath });
37+
}
38+
39+
if (config.sdkConfig) {
40+
const source = `
41+
firebase: {
42+
${Object.entries(config.sdkConfig).reduce(
43+
(c, [k, v]) => c.concat(` ${k}: '${v}'`),
44+
[] as string[]
45+
).join(',\n')},
46+
}`;
47+
48+
const environmentPath = `${sourcePath}/environments/environment.ts`;
49+
addEnvironmentEntry(tree, `/${environmentPath}`, source);
50+
51+
// Iterate over the replacements for the environment file and add the config
52+
Object.values(project.architect || {}).forEach(builder => {
53+
Object.values(builder.configurations || {}).forEach(configuration => {
54+
(configuration.fileReplacements || []).forEach((replacement: any) => {
55+
if (replacement.replace === environmentPath) {
56+
addEnvironmentEntry(tree, `/${replacement.with}`, source);
57+
}
58+
});
59+
});
60+
});
61+
}
62+
63+
const options: NgAddNormalizedOptions = {
64+
project: projectName,
65+
firebaseProject: config.firebaseProject,
66+
firebaseApp: config.firebaseApp,
67+
firebaseHostingSite: config.firebaseHostingSite,
68+
sdkConfig: config.sdkConfig,
69+
prerender: config.prerender,
70+
browserTarget: config.browserTarget,
71+
serverTarget: config.serverTarget,
72+
prerenderTarget: config.prerenderTarget,
73+
};
74+
75+
if (features.includes(FEATURES.Hosting)) {
76+
// TODO dry up by always doing the static work
77+
switch (config.projectType) {
78+
case PROJECT_TYPE.CloudFunctions:
79+
case PROJECT_TYPE.CloudRun:
80+
return setupUniversalDeployment({
81+
workspace,
82+
workspacePath,
83+
options,
84+
tree,
85+
context,
86+
project,
87+
projectType: config.projectType,
88+
// tslint:disable-next-line:no-non-null-assertion
89+
nodeVersion: config.nodeVersion!,
90+
});
91+
case PROJECT_TYPE.Static:
92+
return setupStaticDeployment({
93+
workspace,
94+
workspacePath,
95+
options,
96+
tree,
97+
context,
98+
project
99+
});
100+
default: throw(new SchematicsException(`Unimplemented PROJECT_TYPE ${config.projectType}`));
101+
}
102+
}
103+
};
104+
105+
export const ngAddSetupProject = (
106+
options: DeployOptions
107+
) => async (host: Tree, context: SchematicContext) => {
108+
const features = await featuresPrompt();
109+
110+
if (features.length > 0) {
111+
112+
const firebaseTools = await getFirebaseTools();
113+
114+
await firebaseTools.login();
115+
116+
const { project: ngProject, projectName: ngProjectName } = getProject(options, host);
117+
118+
const [ defaultProjectName ] = getFirebaseProjectNameFromHost(host, ngProjectName);
119+
120+
const firebaseProject = await projectPrompt(defaultProjectName);
121+
122+
let hosting = { projectType: PROJECT_TYPE.Static, prerender: false };
123+
let firebaseHostingSite: FirebaseHostingSite|undefined;
124+
125+
if (features.includes(FEATURES.Hosting)) {
126+
// TODO read existing settings from angular.json, if available
127+
const results = await projectTypePrompt(ngProject, ngProjectName);
128+
hosting = { ...hosting, ...results };
129+
firebaseHostingSite = await sitePrompt(firebaseProject);
130+
}
131+
132+
let firebaseApp: FirebaseApp|undefined;
133+
let sdkConfig: Record<string, string>|undefined;
134+
135+
if (features.find(it => it !== FEATURES.Hosting)) {
136+
137+
const defaultAppId = firebaseHostingSite?.appId;
138+
firebaseApp = await appPrompt(firebaseProject, defaultAppId);
139+
140+
const result = await firebaseTools.apps.sdkconfig('web', firebaseApp.appId, { nonInteractive: true });
141+
sdkConfig = result.sdkConfig;
142+
143+
}
144+
145+
await setupProject(host, context, features, {
146+
...options, ...hosting, firebaseProject, firebaseApp, firebaseHostingSite, sdkConfig,
147+
});
148+
149+
}
150+
};

‎src/schematics/setup/prompts.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import * as fuzzy from 'fuzzy';
2+
import * as inquirer from 'inquirer';
3+
import { featureOptions, FEATURES, FirebaseApp, FirebaseHostingSite, FirebaseProject, PROJECT_TYPE, WorkspaceProject } from '../interfaces';
4+
import { hasPrerenderOption, isUniversalApp, shortAppId, shortSiteName } from '../utils';
5+
import { getFirebaseTools } from '../firebaseTools';
6+
7+
inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt'));
8+
9+
const NEW_OPTION = '~~angularfire-new~~';
10+
const DEFAULT_SITE_TYPE = 'DEFAULT_SITE';
11+
12+
// `fuzzy` passes either the original list of projects or an internal object
13+
// which contains the project as a property.
14+
const isProject = (elem: FirebaseProject | fuzzy.FilterResult<FirebaseProject>): elem is FirebaseProject => {
15+
return (elem as { original: FirebaseProject }).original === undefined;
16+
};
17+
18+
const isApp = (elem: FirebaseApp | fuzzy.FilterResult<FirebaseApp>): elem is FirebaseApp => {
19+
return (elem as { original: FirebaseApp }).original === undefined;
20+
};
21+
22+
const isSite = (elem: FirebaseHostingSite | fuzzy.FilterResult<FirebaseHostingSite>): elem is FirebaseHostingSite => {
23+
return (elem as { original: FirebaseHostingSite }).original === undefined;
24+
};
25+
26+
export const searchProjects = (promise: Promise<FirebaseProject[]>) =>
27+
(_: any, input: string) => promise.then(projects => {
28+
projects.unshift({
29+
projectId: NEW_OPTION,
30+
displayName: '[CREATE NEW PROJECT]'
31+
} as any);
32+
return fuzzy.filter(input, projects, {
33+
extract(el) {
34+
return `${el.projectId} ${el.displayName}`;
35+
}
36+
}).map((result) => {
37+
let original: FirebaseProject;
38+
if (isProject(result)) {
39+
original = result;
40+
} else {
41+
original = result.original;
42+
}
43+
return {
44+
name: original.displayName,
45+
title: original.displayName,
46+
value: original.projectId
47+
};
48+
});
49+
});
50+
51+
export const searchApps = (promise: Promise<FirebaseApp[]>) =>
52+
(_: any, input: string) => promise.then(apps => {
53+
apps.unshift({
54+
appId: NEW_OPTION,
55+
displayName: '[CREATE NEW APP]',
56+
} as any);
57+
return fuzzy.filter(input, apps, {
58+
extract(el: FirebaseApp) {
59+
return el.displayName;
60+
}
61+
}).map((result) => {
62+
let original: FirebaseApp;
63+
if (isApp(result)) {
64+
original = result;
65+
} else {
66+
original = result.original;
67+
}
68+
return {
69+
name: original.displayName,
70+
title: original.displayName,
71+
value: shortAppId(original),
72+
};
73+
});
74+
});
75+
76+
export const searchSites = (promise: Promise<FirebaseHostingSite[]>) =>
77+
(_: any, input: string) => promise.then(sites => {
78+
sites.unshift({
79+
name: NEW_OPTION,
80+
defaultUrl: '[CREATE NEW SITE]',
81+
} as any);
82+
return fuzzy.filter(input, sites, {
83+
extract(el) {
84+
return el.defaultUrl;
85+
}
86+
}).map((result) => {
87+
let original: FirebaseHostingSite;
88+
if (isSite(result)) {
89+
original = result;
90+
} else {
91+
original = result.original;
92+
}
93+
return {
94+
name: original.defaultUrl,
95+
title: original.defaultUrl,
96+
value: shortSiteName(original),
97+
};
98+
});
99+
});
100+
101+
102+
type Prompt = <K extends string, U= unknown>(questions: { name: K, source: (...args) =>
103+
Promise<{ value: U }[]>, default?: U | ((o: U[]) => U | Promise<U>), [key: string]: any }) =>
104+
Promise<{[T in K]: U }>;
105+
106+
const autocomplete: Prompt = (questions) => inquirer.prompt(questions);
107+
108+
109+
export const featuresPrompt = async (): Promise<FEATURES[]> => {
110+
const { features } = await inquirer.prompt({
111+
type: 'checkbox',
112+
name: 'features',
113+
choices: featureOptions,
114+
message: 'What features would you like to setup?',
115+
default: [FEATURES.Hosting],
116+
});
117+
return features;
118+
};
119+
120+
export const projectPrompt = async (defaultProject?: string) => {
121+
const firebaseTools = await getFirebaseTools();
122+
const projects = firebaseTools.projects.list({});
123+
const { projectId } = await autocomplete({
124+
type: 'autocomplete',
125+
name: 'projectId',
126+
source: searchProjects(projects),
127+
message: 'Please select a project:',
128+
default: defaultProject,
129+
});
130+
if (projectId === NEW_OPTION) {
131+
const { projectId } = await inquirer.prompt({
132+
type: 'input',
133+
name: 'projectId',
134+
message: `Please specify a unique project id (cannot be modified afterward) [6-30 characters]:`,
135+
});
136+
const { displayName } = await inquirer.prompt({
137+
type: 'input',
138+
name: 'displayName',
139+
message: 'What would you like to call your project?',
140+
default: projectId,
141+
});
142+
return await firebaseTools.projects.create(projectId, { displayName, nonInteractive: true });
143+
}
144+
// tslint:disable-next-line:no-non-null-assertion
145+
return (await projects).find(it => it.projectId === projectId)!;
146+
};
147+
148+
export const appPrompt = async ({ projectId: project }: FirebaseProject, defaultAppId: string|undefined) => {
149+
const firebaseTools = await getFirebaseTools();
150+
const apps = firebaseTools.apps.list('web', { project });
151+
const { appId } = await autocomplete({
152+
type: 'autocomplete',
153+
name: 'appId',
154+
source: searchApps(apps),
155+
message: 'Please select an app:',
156+
default: defaultAppId,
157+
});
158+
if (appId === NEW_OPTION) {
159+
const { displayName } = await inquirer.prompt({
160+
type: 'input',
161+
name: 'displayName',
162+
message: 'What would you like to call your app?',
163+
});
164+
return await firebaseTools.apps.create('web', displayName, { nonInteractive: true, project });
165+
}
166+
// tslint:disable-next-line:no-non-null-assertion
167+
return (await apps).find(it => shortAppId(it) === appId)!;
168+
};
169+
170+
export const sitePrompt = async ({ projectId: project }: FirebaseProject) => {
171+
const firebaseTools = await getFirebaseTools();
172+
if (!firebaseTools.hosting.sites) {
173+
return undefined;
174+
}
175+
const sites = firebaseTools.hosting.sites.list({ project }).then(it => {
176+
if (it.sites.length === 0) {
177+
// newly created projects don't return their default site, stub one
178+
return [{
179+
name: project,
180+
defaultUrl: `https://${project}.web.app`,
181+
type: DEFAULT_SITE_TYPE,
182+
appId: undefined,
183+
} as FirebaseHostingSite];
184+
} else {
185+
return it.sites;
186+
}
187+
});
188+
const { siteName } = await autocomplete({
189+
type: 'autocomplete',
190+
name: 'siteName',
191+
source: searchSites(sites),
192+
message: 'Please select a hosting site:',
193+
default: _ => sites.then(it => shortSiteName(it.find(it => it.type === DEFAULT_SITE_TYPE))),
194+
});
195+
if (siteName === NEW_OPTION) {
196+
const { subdomain } = await inquirer.prompt({
197+
type: 'input',
198+
name: 'subdomain',
199+
message: 'Please provide an unique, URL-friendly id for the site (<id>.web.app):',
200+
});
201+
return await firebaseTools.hosting.sites.create(subdomain, { nonInteractive: true, project });
202+
}
203+
// tslint:disable-next-line:no-non-null-assertion
204+
return (await sites).find(it => shortSiteName(it) === siteName)!;
205+
};
206+
207+
export const prerenderPrompt = (project: WorkspaceProject, prerender: boolean): Promise<{ projectType: PROJECT_TYPE }> => {
208+
if (isUniversalApp(project)) {
209+
return inquirer.prompt({
210+
type: 'prompt',
211+
name: 'prerender',
212+
message: 'We detected an Angular Universal project. How would you like to render server-side content?',
213+
default: true
214+
});
215+
}
216+
return Promise.resolve({ projectType: PROJECT_TYPE.Static });
217+
};
218+
219+
export const projectTypePrompt = async (project: WorkspaceProject, name: string) => {
220+
let prerender = false;
221+
let nodeVersion: string|undefined;
222+
let serverTarget: string|undefined;
223+
let browserTarget = `${name}:build:${project.architect?.build?.defaultConfiguration || 'production'}`;
224+
let prerenderTarget: string|undefined;
225+
if (isUniversalApp(project)) {
226+
serverTarget = `${name}:server:${project.architect?.server?.defaultConfiguration || 'production'}`;
227+
browserTarget = `${name}:build:${project.architect?.build?.defaultConfiguration || 'production'}`;
228+
if (hasPrerenderOption(project)) {
229+
prerenderTarget = `${name}:prerender:${project.architect?.prerender?.defaultConfiguration || 'production'}`;
230+
const { shouldPrerender } = await inquirer.prompt({
231+
type: 'confirm',
232+
name: 'shouldPrerender',
233+
message: 'Should we prerender before deployment?',
234+
default: true
235+
});
236+
prerender = shouldPrerender;
237+
}
238+
const choices = [
239+
{ name: prerender ? 'Pre-render only' : 'Don\'t render universal content', value: PROJECT_TYPE.Static },
240+
{ name: 'Cloud Functions', value: PROJECT_TYPE.CloudFunctions },
241+
{ name: 'Cloud Run', value: PROJECT_TYPE.CloudRun },
242+
];
243+
const { projectType } = await inquirer.prompt({
244+
type: 'list',
245+
name: 'projectType',
246+
choices,
247+
message: 'How would you like to render server-side content?',
248+
default: PROJECT_TYPE.CloudFunctions,
249+
});
250+
if (projectType === PROJECT_TYPE.CloudFunctions) {
251+
const { newNodeVersion } = await inquirer.prompt({
252+
type: 'list',
253+
name: 'newNodeVersion',
254+
choices: ['12', '14', '16'],
255+
message: 'What version of Node.js would you like to use?',
256+
default: parseInt(process.versions.node, 10).toString(),
257+
});
258+
nodeVersion = newNodeVersion;
259+
} else if (projectType === PROJECT_TYPE.CloudRun) {
260+
const fetch = require('node-fetch');
261+
const { newNodeVersion } = await inquirer.prompt({
262+
type: 'input',
263+
name: 'newNodeVersion',
264+
message: 'What version of Node.js would you like to use?',
265+
validate: it => fetch(`https://hub.docker.com/v2/repositories/library/node/tags/${it}-slim`).then(it => it.status === 200 || `Can't find node:${it}-slim docker image.`),
266+
default: parseFloat(process.versions.node).toString(),
267+
});
268+
nodeVersion = newNodeVersion;
269+
}
270+
return { prerender, projectType, nodeVersion, browserTarget, serverTarget, prerenderTarget };
271+
}
272+
return { projectType: PROJECT_TYPE.Static, prerender, nodeVersion, browserTarget, serverTarget, prerenderTarget };
273+
};

‎src/schematics/setup/schema.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "angular-fire-ng-add",
4+
"title": "AngularFire ng-add",
5+
"type": "object",
6+
"properties": {
7+
"project": {
8+
"type": "string",
9+
"description": "The name of the project.",
10+
"$default": {
11+
"$source": "projectName"
12+
}
13+
}
14+
},
15+
"required": []
16+
}

‎src/schematics/setup/ssr.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { SchematicsException, Tree, SchematicContext } from '@angular-devkit/schematics';
2+
import {
3+
addDependencies,
4+
generateFirebaseRc,
5+
overwriteIfExists,
6+
safeReadJSON,
7+
stringifyFormatted
8+
} from '../common';
9+
import { FirebaseJSON, Workspace, WorkspaceProject, NgAddNormalizedOptions, PROJECT_TYPE } from '../interfaces';
10+
import { firebaseFunctionsDependencies } from '../versions.json';
11+
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
12+
import { shortSiteName } from '../utils';
13+
14+
function generateHostingConfig(project: string, dist: string, functionName: string, projectType: PROJECT_TYPE) {
15+
return {
16+
target: project,
17+
public: dist,
18+
ignore: ['**/.*'],
19+
headers: [{
20+
// TODO check the hash style in the angular.json
21+
source: '*.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].+(css|js)',
22+
headers: [{
23+
key: 'Cache-Control',
24+
value: 'public,max-age=31536000,immutable'
25+
}]
26+
}],
27+
rewrites: [
28+
projectType === PROJECT_TYPE.CloudFunctions ? {
29+
source: '**',
30+
function: functionName
31+
} : {
32+
source: '**',
33+
run: { serviceId: functionName }
34+
}
35+
]
36+
};
37+
}
38+
39+
function generateFunctionsConfig(source: string) {
40+
return {
41+
source
42+
};
43+
}
44+
45+
export function generateFirebaseJson(
46+
tree: Tree,
47+
path: string,
48+
project: string,
49+
dist: string,
50+
functionsOutput: string,
51+
functionName: string,
52+
projectType: PROJECT_TYPE,
53+
) {
54+
const firebaseJson: FirebaseJSON = tree.exists(path)
55+
? safeReadJSON(path, tree)
56+
: {};
57+
58+
const newConfig = generateHostingConfig(project, dist, functionName, projectType);
59+
if (firebaseJson.hosting === undefined) {
60+
firebaseJson.hosting = [newConfig];
61+
} else if (Array.isArray(firebaseJson.hosting)) {
62+
const existingConfigIndex = firebaseJson.hosting.findIndex(config => config.target === newConfig.target);
63+
if (existingConfigIndex > -1) {
64+
firebaseJson.hosting.splice(existingConfigIndex, 1, newConfig);
65+
} else {
66+
firebaseJson.hosting.push(newConfig);
67+
}
68+
} else {
69+
firebaseJson.hosting = [firebaseJson.hosting, newConfig];
70+
}
71+
72+
if (projectType === PROJECT_TYPE.CloudFunctions) {
73+
firebaseJson.functions = generateFunctionsConfig(functionsOutput);
74+
}
75+
76+
overwriteIfExists(tree, path, stringifyFormatted(firebaseJson));
77+
}
78+
79+
export const setupUniversalDeployment = (config: {
80+
project: WorkspaceProject;
81+
options: NgAddNormalizedOptions;
82+
workspacePath: string;
83+
workspace: Workspace;
84+
tree: Tree;
85+
context: SchematicContext;
86+
projectType: PROJECT_TYPE;
87+
nodeVersion: string;
88+
}) => {
89+
const { tree, workspacePath, workspace, options } = config;
90+
const project = workspace.projects[options.project];
91+
92+
if (!project.architect?.build?.options?.outputPath) {
93+
throw new SchematicsException(
94+
`Cannot read the output path (architect.build.options.outputPath) of the Angular project "${options.project}" in angular.json`
95+
);
96+
}
97+
98+
if (!project.architect?.server?.options?.outputPath) {
99+
throw new SchematicsException(
100+
`Cannot read the output path (architect.server.options.outputPath) of the Angular project "${options.project}" in angular.json`
101+
);
102+
}
103+
104+
const ssrDirectory = config.projectType === PROJECT_TYPE.CloudFunctions ? 'functions' : 'run';
105+
const staticOutput = project.architect.build.options.outputPath;
106+
const functionsOutput = `dist/${options.project}/${ssrDirectory}`;
107+
108+
// TODO clean this up a bit
109+
const functionName = config.projectType === PROJECT_TYPE.CloudRun ?
110+
`ssr-${options.project.replace('_', '-')}` :
111+
`ssr_${options.project}`;
112+
113+
project.architect.deploy = {
114+
builder: '@angular/fire:deploy',
115+
options: {
116+
ssr: config.projectType === PROJECT_TYPE.CloudRun ? 'cloud-run' : 'cloud-functions',
117+
prerender: options.prerender,
118+
firebaseProject: options.firebaseProject.projectId,
119+
firebaseHostingSite: shortSiteName(options.firebaseHostingSite),
120+
functionName,
121+
functionsNodeVersion: config.nodeVersion,
122+
region: 'us-central1',
123+
browserTarget: options.browserTarget,
124+
...(options.serverTarget ? {serverTarget: options.serverTarget} : {}),
125+
...(options.prerenderTarget ? {prerenderTarget: options.prerenderTarget} : {}),
126+
outputPath: functionsOutput,
127+
}
128+
};
129+
130+
tree.overwrite(workspacePath, JSON.stringify(workspace, null, 2));
131+
132+
addDependencies(
133+
tree,
134+
firebaseFunctionsDependencies,
135+
config.context
136+
);
137+
138+
config.context.addTask(new NodePackageInstallTask());
139+
140+
generateFirebaseJson(tree, 'firebase.json', options.project, staticOutput, functionsOutput, functionName, config.projectType);
141+
generateFirebaseRc(
142+
tree,
143+
'.firebaserc',
144+
options.firebaseProject.projectId,
145+
options.firebaseHostingSite,
146+
options.project
147+
);
148+
149+
return tree;
150+
};

‎src/schematics/ng-add-static.ts renamed to ‎src/schematics/setup/static.ts

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { SchematicsException, Tree, SchematicContext } from '@angular-devkit/schematics';
22
import {
3-
addDependencies,
4-
DeployOptions,
53
generateFirebaseRc,
6-
NgAddNormalizedOptions,
74
overwriteIfExists,
85
safeReadJSON,
96
stringifyFormatted
10-
} from './ng-add-common';
11-
import { FirebaseJSON, Workspace, WorkspaceProject } from './interfaces';
7+
} from '../common';
8+
import { NgAddNormalizedOptions, FirebaseJSON, Workspace, WorkspaceProject } from '../interfaces';
129

13-
import { default as defaultDependencies } from './versions.json';
14-
import { NodePackageInstallTask, RunSchematicTask } from '@angular-devkit/schematics/tasks';
10+
import { shortSiteName } from '../utils';
1511

1612
function emptyFirebaseJson() {
1713
return {
@@ -50,58 +46,35 @@ export function generateFirebaseJson(
5046
? safeReadJSON(path, tree)
5147
: emptyFirebaseJson();
5248

53-
/* TODO do we want to prompt for override?
54-
if (
55-
firebaseJson.hosting &&
56-
((Array.isArray(firebaseJson.hosting) &&
57-
firebaseJson.hosting.find(config => config.target === project)) ||
58-
(firebaseJson.hosting as FirebaseHostingConfig).target === project)
59-
) {
60-
throw new SchematicsException(
61-
`Target ${project} already exists in firebase.json`
62-
);
63-
}*/
64-
6549
const newConfig = generateHostingConfig(project, dist);
6650
if (firebaseJson.hosting === undefined) {
6751
firebaseJson.hosting = newConfig;
6852
} else if (Array.isArray(firebaseJson.hosting)) {
69-
firebaseJson.hosting.push(newConfig);
53+
const targetIndex = firebaseJson.hosting.findIndex(it => it.target === newConfig.target);
54+
if (targetIndex > -1) {
55+
firebaseJson.hosting[targetIndex] = newConfig;
56+
} else {
57+
firebaseJson.hosting.push(newConfig);
58+
}
7059
} else {
7160
firebaseJson.hosting = [firebaseJson.hosting, newConfig];
7261
}
7362

7463
overwriteIfExists(tree, path, stringifyFormatted(firebaseJson));
7564
}
7665

77-
export const addFirebaseHostingDependencies = (options: DeployOptions) => (tree: Tree, context: SchematicContext) => {
78-
addDependencies(
79-
tree,
80-
defaultDependencies,
81-
context
82-
);
83-
context.addTask(new RunSchematicTask('ng-add-setup-project', options), [
84-
context.addTask(new NodePackageInstallTask())
85-
]);
86-
return tree;
87-
};
88-
8966
export const setupStaticDeployment = (config: {
9067
project: WorkspaceProject;
9168
options: NgAddNormalizedOptions;
9269
workspacePath: string;
9370
workspace: Workspace;
9471
tree: Tree;
72+
context: SchematicContext;
9573
}) => {
9674
const { tree, workspacePath, workspace, options } = config;
9775
const project = workspace.projects[options.project];
9876

99-
if (
100-
!project.architect ||
101-
!project.architect.build ||
102-
!project.architect.build.options ||
103-
!project.architect.build.options.outputPath
104-
) {
77+
if (!project.architect?.build?.options?.outputPath) {
10578
throw new SchematicsException(
10679
`Cannot read the output path (architect.build.options.outputPath) of the Angular project "${options.project}" in angular.json`
10780
);
@@ -111,15 +84,24 @@ export const setupStaticDeployment = (config: {
11184

11285
project.architect.deploy = {
11386
builder: '@angular/fire:deploy',
114-
options: {}
87+
options: {
88+
prerender: options.prerender,
89+
ssr: false,
90+
browserTarget: options.browserTarget,
91+
firebaseProject: options.firebaseProject.projectId,
92+
firebaseHostingSite: shortSiteName(options.firebaseHostingSite),
93+
...(options.serverTarget ? {serverTarget: options.serverTarget} : {}),
94+
...(options.prerenderTarget ? {prerenderTarget: options.prerenderTarget} : {}),
95+
}
11596
};
11697

11798
tree.overwrite(workspacePath, JSON.stringify(workspace, null, 2));
11899
generateFirebaseJson(tree, 'firebase.json', options.project, outputPath);
119100
generateFirebaseRc(
120101
tree,
121102
'.firebaserc',
122-
options.firebaseProject,
103+
options.firebaseProject.projectId,
104+
options.firebaseHostingSite,
123105
options.project
124106
);
125107

‎src/schematics/tsconfig.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"removeComments": true,
88
"strictNullChecks": true,
99
"resolveJsonModule": true,
10+
"esModuleInterop": true,
1011
"lib": [
1112
"es2015",
1213
"dom",
@@ -20,5 +21,12 @@
2021
"module": "commonjs",
2122
"outDir": "../../dist/packages-dist/schematics"
2223
},
23-
"files": ["public_api.ts"]
24+
"files": [
25+
"update/index.ts",
26+
"deploy/actions.ts",
27+
"deploy/builder.ts",
28+
"add/index.ts",
29+
"setup/index.ts",
30+
"update/v7/index.ts",
31+
]
2432
}
File renamed without changes.

‎src/schematics/update/v7/index.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
22
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
3-
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from '../../ng-add-common';
4-
import { default as defaultDependencies, firebaseFunctions } from '../../versions.json';
3+
import { overwriteIfExists, safeReadJSON, stringifyFormatted } from '../../common';
4+
import { peerDependencies, firebaseFunctionsDependencies } from '../../versions.json';
55
import { join } from 'path';
66

77
const IMPORT_REGEX = /(?<key>import|export)\s+(?:(?<alias>[\w,{}\s\*]+)\s+from)?\s*(?:(?<quote>["'])?(?<ref>[@\w\s\\\/.-]+)\3?)\s*(?<term>[;\n])/g;
@@ -23,18 +23,16 @@ export const ngUpdate = (): Rule => (
2323
throw new SchematicsException('Could not locate package.json');
2424
}
2525

26-
Object.keys(defaultDependencies).forEach(depName => {
27-
const dep = defaultDependencies[depName];
28-
if (dep.dev) {
29-
packageJson.devDependencies[depName] = dep.version;
30-
} else {
31-
packageJson.dependencies[depName] = dep.version;
26+
Object.keys(peerDependencies).forEach(depName => {
27+
const dep = peerDependencies[depName];
28+
if (dep) {
29+
packageJson[dep.dev ? 'devDependencies' : 'dependencies'][depName] = dep.version;
3230
}
3331
});
3432

3533
// TODO test if it's a SSR project in the JSON
36-
Object.keys(firebaseFunctions).forEach(depName => {
37-
const dep = firebaseFunctions[depName];
34+
Object.keys(firebaseFunctionsDependencies).forEach(depName => {
35+
const dep = firebaseFunctionsDependencies[depName];
3836
if (dep.dev && packageJson.devDependencies[depName]) {
3937
packageJson.devDependencies[depName] = dep.version;
4038
} else if (packageJson.dependencies[depName]) {

‎src/schematics/utils.ts

Lines changed: 260 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,38 @@
11
import { readFileSync } from 'fs';
2-
import { FirebaseRc, Project, Workspace, WorkspaceProject } from './interfaces';
2+
import { FirebaseRc, Workspace, WorkspaceProject, FirebaseApp, FirebaseHostingSite, DeployOptions, FEATURES } from './interfaces';
33
import { join } from 'path';
4-
import { isUniversalApp } from './ng-add-ssr';
54
import { SchematicsException, Tree } from '@angular-devkit/schematics';
6-
import { DeployOptions } from './ng-add-common';
5+
import ts from '@schematics/angular/third_party/github.com/Microsoft/TypeScript/lib/typescript';
6+
import { findNode, addImportToModule, insertImport } from '@schematics/angular/utility/ast-utils';
7+
import { InsertChange, ReplaceChange, applyToUpdateRecorder, Change } from '@schematics/angular/utility/change';
8+
import { findModuleFromOptions, buildRelativePath } from '@schematics/angular/utility/find-module';
9+
import { overwriteIfExists } from './common';
710

8-
export async function listProjects() {
9-
const firebase = require('firebase-tools');
10-
await firebase.login();
11-
return firebase.projects.list();
12-
}
11+
// We consider a project to be a universal project if it has a `server` architect
12+
// target. If it does, it knows how to build the application's server.
13+
export const isUniversalApp = (
14+
project: WorkspaceProject
15+
) => project.architect?.server;
1316

14-
// `fuzzy` passes either the original list of projects or an internal object
15-
// which contains the project as a property.
16-
const isProject = (elem: Project | { original: Project }): elem is Project => {
17-
return (elem as { original: Project }).original === undefined;
18-
};
17+
export const hasPrerenderOption = (
18+
project: WorkspaceProject
19+
) => project.architect?.prerender;
1920

20-
const searchProjects = (projects: Project[]) => {
21-
return (_: any, input: string) => {
22-
return Promise.resolve(
23-
require('fuzzy')
24-
.filter(input, projects, {
25-
extract(el: Project) {
26-
return `${el.projectId} ${el.displayName}`;
27-
}
28-
})
29-
.map((result: Project | { original: Project }) => {
30-
let original: Project;
31-
if (isProject(result)) {
32-
original = result;
33-
} else {
34-
original = result.original;
35-
}
36-
return {
37-
name: `${original.displayName} (${original.projectId})`,
38-
title: original.displayName,
39-
value: original.projectId
40-
};
41-
})
42-
);
43-
};
44-
};
21+
export const shortAppId = (app?: FirebaseApp) => app?.appId && app.appId.split('/').pop();
4522

23+
export const shortSiteName = (site?: FirebaseHostingSite) => site?.name && site.name.split('/').pop();
4624

4725
export function getWorkspace(
4826
host: Tree
4927
): { path: string; workspace: Workspace } {
50-
const possibleFiles = ['/angular.json', '/.angular.json'];
51-
const path = possibleFiles.filter(p => host.exists(p))[0];
28+
const path = '/angular.json';
5229

53-
const configBuffer = host.read(path);
54-
if (configBuffer === null) {
30+
const configBuffer = path && host.read(path);
31+
if (!configBuffer) {
5532
throw new SchematicsException(`Could not find angular.json`);
5633
}
5734

58-
// We can not depend on this library to have be included in older (or newer) Angular versions.
59-
// Require here, since the schematic will add it to the package.json and install it before
60-
// continuing.
61-
const { parse }: typeof import('jsonc-parser') = require('jsonc-parser');
35+
const { parse } = require('jsonc-parser');
6236

6337
const workspace = parse(configBuffer.toString()) as Workspace|undefined;
6438
if (!workspace) {
@@ -97,41 +71,251 @@ export const getProject = (options: DeployOptions, host: Tree) => {
9771
return {project, projectName};
9872
};
9973

100-
export const projectPrompt = (projects: Project[]) => {
101-
const inquirer = require('inquirer');
102-
inquirer.registerPrompt(
103-
'autocomplete',
104-
require('inquirer-autocomplete-prompt')
74+
export function getFirebaseProjectNameFromHost(
75+
host: Tree,
76+
target: string
77+
): [string|undefined, string|undefined] {
78+
const buffer = host.read('/.firebaserc');
79+
if (!buffer) {
80+
return [undefined, undefined];
81+
}
82+
const rc: FirebaseRc = JSON.parse(buffer.toString());
83+
return projectFromRc(rc, target);
84+
}
85+
86+
export function getFirebaseProjectNameFromFs(
87+
root: string,
88+
target: string
89+
): [string|undefined, string|undefined] {
90+
const path = join(root, '.firebaserc');
91+
try {
92+
const buffer = readFileSync(path);
93+
const rc: FirebaseRc = JSON.parse(buffer.toString());
94+
return projectFromRc(rc, target);
95+
} catch (e) {
96+
return [undefined, undefined];
97+
}
98+
}
99+
100+
const projectFromRc = (rc: FirebaseRc, target: string): [string|undefined, string|undefined] => {
101+
const defaultProject = rc.projects?.default;
102+
const project = Object.keys(rc.targets || {}).find(
103+
project => !!rc.targets?.[project]?.hosting?.[target]
105104
);
106-
return inquirer.prompt({
107-
type: 'autocomplete',
108-
name: 'firebaseProject',
109-
source: searchProjects(projects),
110-
message: 'Please select a project:'
111-
});
105+
const site = project && rc.targets?.[project]?.hosting?.[target]?.[0];
106+
return [project || defaultProject, site];
112107
};
113108

114-
export const projectTypePrompt = (project: WorkspaceProject): Promise<{ universalProject: boolean }> => {
115-
if (isUniversalApp(project)) {
116-
return require('inquirer').prompt({
117-
type: 'confirm',
118-
name: 'universalProject',
119-
message: 'We detected an Angular Universal project. Do you want to deploy as a Firebase Function?'
120-
});
109+
/**
110+
* Adds a package to the package.json
111+
*/
112+
export function addEnvironmentEntry(
113+
host: Tree,
114+
filePath: string,
115+
data: string,
116+
): Tree {
117+
if (!host.exists(filePath)) {
118+
throw new Error(`File ${filePath} does not exist`);
121119
}
122-
return Promise.resolve({ universalProject: false });
123-
};
124120

125-
export function getFirebaseProjectName(
126-
workspaceRoot: string,
127-
target: string
128-
): string | undefined {
129-
const rc: FirebaseRc = JSON.parse(
130-
readFileSync(join(workspaceRoot, '.firebaserc'), 'UTF-8')
121+
const buffer = host.read(filePath);
122+
if (!buffer) {
123+
throw new SchematicsException(`Cannot read ${filePath}`);
124+
}
125+
const sourceFile = ts.createSourceFile(filePath, buffer.toString('utf-8'), ts.ScriptTarget.Latest, true);
126+
127+
const envIdentifier = findNode(sourceFile as any, ts.SyntaxKind.Identifier, 'environment');
128+
if (!envIdentifier || !envIdentifier.parent) {
129+
throw new SchematicsException(`Cannot find 'environment' identifier in ${filePath}`);
130+
}
131+
132+
const envObjectLiteral = envIdentifier.parent.getChildren().find(({ kind }) => kind === ts.SyntaxKind.ObjectLiteralExpression);
133+
if (!envObjectLiteral) {
134+
throw new SchematicsException(`${filePath} is not in the expected format`);
135+
}
136+
const firebaseIdentifier = findNode(envObjectLiteral, ts.SyntaxKind.Identifier, 'firebase');
137+
138+
const recorder = host.beginUpdate(filePath);
139+
if (firebaseIdentifier && firebaseIdentifier.parent) {
140+
const change = new ReplaceChange(filePath, firebaseIdentifier.parent.pos, firebaseIdentifier.parent.getFullText(), data);
141+
applyToUpdateRecorder(recorder, [change]);
142+
} else {
143+
const openBracketToken = envObjectLiteral.getChildren().find(({ kind }) => kind === ts.SyntaxKind.OpenBraceToken);
144+
if (openBracketToken) {
145+
const change = new InsertChange(filePath, openBracketToken.end, `${data},`);
146+
applyToUpdateRecorder(recorder, [change]);
147+
} else {
148+
throw new SchematicsException(`${filePath} is not in the expected format`);
149+
}
150+
}
151+
host.commitUpdate(recorder);
152+
153+
return host;
154+
}
155+
156+
export function addToNgModule(host: Tree, options: { sourcePath: string, features: FEATURES[]}) {
157+
158+
const modulePath = findModuleFromOptions(host, {
159+
name: 'app',
160+
path: options.sourcePath,
161+
});
162+
163+
if (!modulePath) {
164+
return host;
165+
}
166+
167+
if (!host.exists(modulePath)) {
168+
throw new Error(`Specified module path ${modulePath} does not exist`);
169+
}
170+
171+
const text = host.read(modulePath);
172+
if (text === null) {
173+
throw new SchematicsException(`File ${modulePath} does not exist.`);
174+
}
175+
const sourceText = text.toString('utf-8');
176+
177+
const source = ts.createSourceFile(
178+
modulePath,
179+
sourceText,
180+
ts.ScriptTarget.Latest,
181+
true
131182
);
132-
const targets = rc.targets || {};
133-
const projects = Object.keys(targets || {});
134-
return projects.find(
135-
project => !!Object.keys(targets[project].hosting).find(t => t === target)
183+
184+
const environmentsPath = buildRelativePath(
185+
modulePath,
186+
`/${options.sourcePath}/environments/environment`
136187
);
188+
189+
const changes: Array<Change> = [];
190+
191+
if (!findNode(source, ts.SyntaxKind.Identifier, 'provideFirebaseApp')) {
192+
changes.push(
193+
insertImport(source, modulePath, ['initializeApp', 'provideFirebaseApp'] as any, '@angular/fire/app'),
194+
insertImport(source, modulePath, 'environment', environmentsPath),
195+
...addImportToModule(source, modulePath, `provideFirebaseApp(() => initializeApp(environment.firebase))`, null as any),
196+
);
197+
}
198+
199+
if (
200+
options.features.includes(FEATURES.Analytics) &&
201+
!findNode(source, ts.SyntaxKind.Identifier, 'provideAnalytics')
202+
) {
203+
// TODO add user and screen tracking service
204+
changes.push(
205+
insertImport(source, modulePath, ['provideAnalytics', 'getAnalytics'] as any, '@angular/fire/analytics'),
206+
...addImportToModule(source, modulePath, `provideAnalytics(() => getAnalytics())`, null as any),
207+
);
208+
}
209+
210+
if (
211+
options.features.includes(FEATURES.Authentication) &&
212+
!findNode(source, ts.SyntaxKind.Identifier, 'provideAuth')
213+
) {
214+
changes.push(
215+
insertImport(source, modulePath, ['provideAuth', 'getAuth'] as any, '@angular/fire/auth'),
216+
...addImportToModule(source, modulePath, `provideAuth(() => getAuth())`, null as any),
217+
);
218+
}
219+
220+
if (
221+
options.features.includes(FEATURES.Database) &&
222+
!findNode(source, ts.SyntaxKind.Identifier, 'provideDatabase')
223+
) {
224+
changes.push(
225+
insertImport(source, modulePath, ['provideDatabase', 'getDatabase'] as any, '@angular/fire/database'),
226+
...addImportToModule(source, modulePath, `provideDatabase(() => getDatabase())`, null as any),
227+
);
228+
}
229+
230+
if (
231+
options.features.includes(FEATURES.Firestore) &&
232+
!findNode(source, ts.SyntaxKind.Identifier, 'provideFirestore')
233+
) {
234+
changes.push(
235+
insertImport(source, modulePath, ['provideFirestore', 'getFirestore'] as any, '@angular/fire/firestore'),
236+
...addImportToModule(source, modulePath, `provideFirestore(() => getFirestore())`, null as any),
237+
);
238+
}
239+
240+
if (
241+
options.features.includes(FEATURES.Functions) &&
242+
!findNode(source, ts.SyntaxKind.Identifier, 'provideFunctions')
243+
) {
244+
changes.push(
245+
insertImport(source, modulePath, ['provideFunctions', 'getFunctions'] as any, '@angular/fire/functions'),
246+
...addImportToModule(source, modulePath, `provideFunctions(() => getFunctions())`, null as any),
247+
);
248+
}
249+
250+
if (
251+
options.features.includes(FEATURES.Messaging) &&
252+
!findNode(source, ts.SyntaxKind.Identifier, 'provideMessaging')
253+
) {
254+
// TODO add the service worker
255+
changes.push(
256+
insertImport(source, modulePath, ['provideMessaging', 'getMessaging'] as any, '@angular/fire/messaging'),
257+
...addImportToModule(source, modulePath, `provideMessaging(() => getMessaging())`, null as any),
258+
);
259+
}
260+
261+
if (
262+
options.features.includes(FEATURES.Performance) &&
263+
!findNode(source, ts.SyntaxKind.Identifier, 'providePerformance')
264+
) {
265+
// TODO performance monitor service
266+
changes.push(
267+
insertImport(source, modulePath, ['providePerformance', 'getPerformance'] as any, '@angular/fire/performance'),
268+
...addImportToModule(source, modulePath, `providePerformance(() => getPerformance())`, null as any),
269+
);
270+
}
271+
272+
if (
273+
options.features.includes(FEATURES.RemoteConfig) &&
274+
!findNode(source, ts.SyntaxKind.Identifier, 'provideRemoteConfig')
275+
) {
276+
changes.push(
277+
insertImport(source, modulePath, ['provideRemoteConfig', 'getRemoteConfig'] as any, '@angular/fire/remote-config'),
278+
...addImportToModule(source, modulePath, `provideRemoteConfig(() => getRemoteConfig())`, null as any),
279+
);
280+
}
281+
282+
if (
283+
options.features.includes(FEATURES.Storage) &&
284+
!findNode(source, ts.SyntaxKind.Identifier, 'provideStorage')
285+
) {
286+
changes.push(
287+
insertImport(source, modulePath, ['provideStorage', 'getStorage'] as any, '@angular/fire/storage'),
288+
...addImportToModule(source, modulePath, `provideStorage(() => getStorage())`, null as any),
289+
);
290+
}
291+
292+
const recorder = host.beginUpdate(modulePath);
293+
applyToUpdateRecorder(recorder, changes);
294+
host.commitUpdate(recorder);
295+
296+
return host;
137297
}
298+
299+
export const addIgnoreFiles = (host: Tree) => {
300+
const path = '/.gitignore';
301+
if (!host.exists(path)) {
302+
return host;
303+
}
304+
305+
const buffer = host.read(path);
306+
if (!buffer) {
307+
return host;
308+
}
309+
310+
const content = buffer.toString();
311+
if (!content.includes('# Firebase')) {
312+
overwriteIfExists(host, path, content.concat(`
313+
# Firebase
314+
.firebase
315+
*-debug.log
316+
.runtimeconfig.json
317+
`));
318+
}
319+
320+
return host;
321+
};

‎src/schematics/versions.json

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
{
2-
"default": {
3-
"firebase": { "version": "0.0.0" },
4-
"rxfire": { "version": "0.0.0" },
5-
"@angular-devkit/architect": { "dev": true, "version": "0.0.0" },
6-
"firebase-tools": { "dev": true, "version": "0.0.0" },
7-
"fuzzy": { "dev": true, "version": "0.0.0"},
8-
"inquirer": { "dev": true, "version": "0.0.0"},
9-
"inquirer-autocomplete-prompt": { "dev": true, "version": "0.0.0"},
10-
"open": { "dev": true, "version": "0.0.0"},
11-
"jsonc-parser": { "dev": true, "version": "0.0.0" }
2+
"peerDependencies": {
3+
"firebase": { "dev": false, "version": "0.0.0" },
4+
"rxfire": { "dev": false, "version": "0.0.0" }
125
},
13-
"firebaseFunctions": {
6+
"firebaseFunctionsDependencies": {
147
"firebase-admin": { "dev": true, "version": "0.0.0" },
15-
"firebase-functions": { "dev": true, "version": "0.0.0" },
16-
"firebase-functions-test": { "dev": true, "version": "0.0.0" }
8+
"firebase-functions": { "dev": true, "version": "0.0.0" }
179
}
1810
}

‎tools/build.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ async function replaceSchematicVersions() {
161161
const root = await rootPackage;
162162
const path = dest('schematics', 'versions.json');
163163
const dependencies = await import(path);
164-
Object.keys(dependencies.default).forEach(name => {
165-
dependencies.default[name].version = root.dependencies[name] || root.devDependencies[name];
164+
Object.keys(dependencies.peerDependencies).forEach(name => {
165+
dependencies.peerDependencies[name].version = root.dependencies[name] || root.devDependencies[name];
166166
});
167-
Object.keys(dependencies.firebaseFunctions).forEach(name => {
168-
dependencies.firebaseFunctions[name].version = root.dependencies[name] || root.devDependencies[name];
167+
Object.keys(dependencies.firebaseFunctionsDependencies).forEach(name => {
168+
dependencies.firebaseFunctionsDependencies[name].version = root.dependencies[name] || root.devDependencies[name];
169169
});
170170
return writeFile(path, JSON.stringify(dependencies, null, 2));
171171
}
@@ -181,6 +181,8 @@ async function compileSchematics() {
181181
copy(src('schematics', 'collection.json'), dest('schematics', 'collection.json')),
182182
copy(src('schematics', 'migration.json'), dest('schematics', 'migration.json')),
183183
copy(src('schematics', 'deploy', 'schema.json'), dest('schematics', 'deploy', 'schema.json')),
184+
copy(src('schematics', 'add', 'schema.json'), dest('schematics', 'add', 'schema.json')),
185+
copy(src('schematics', 'setup', 'schema.json'), dest('schematics', 'setup', 'schema.json')),
184186
replaceSchematicVersions()
185187
]);
186188
}

‎yarn.lock

Lines changed: 320 additions & 577 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.