Skip to content
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

First pass at auto generating sdk configs #7833

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions src/commands/apps-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
});
spinner.succeed();
return appData;
} catch (err: any) {

Check warning on line 106 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
spinner.fail();
throw err;
}
Expand Down Expand Up @@ -135,7 +135,7 @@
});
spinner.succeed();
return appData;
} catch (err: any) {

Check warning on line 138 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
spinner.fail();
throw err;
}
Expand All @@ -153,7 +153,7 @@
const appData = await createWebApp(options.project, { displayName: options.displayName });
spinner.succeed();
return appData;
} catch (err: any) {

Check warning on line 156 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
spinner.fail();
throw err;
}
Expand All @@ -169,14 +169,14 @@
.before(requireAuth)
.action(
async (
platform: string = "",

Check warning on line 172 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Type string trivially inferred from a string literal, remove type annotation
displayName: string | undefined,
options: any,

Check warning on line 174 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
): Promise<AppMetadata> => {
const projectId = needProjectId(options);

Check warning on line 176 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `{ projectId?: string | undefined; project?: string | undefined; rc?: RC | undefined; }`

if (!options.nonInteractive && !platform) {

Check warning on line 178 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .nonInteractive on an `any` value
platform = await promptOnce({

Check warning on line 179 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
type: "list",
message: "Please choose the platform of the app:",
choices: [
Expand All @@ -193,23 +193,26 @@
}

logger.info(`Create your ${appPlatform} app in project ${clc.bold(projectId)}:`);
options.displayName = displayName; // add displayName into options to pass into prompt function

Check warning on line 196 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .displayName on an `any` value
let appData;
switch (appPlatform) {
case AppPlatform.IOS:
appData = await initiateIosAppCreation(options);
break;
case AppPlatform.ANDROID:
appData = await initiateAndroidAppCreation(options);
break;
case AppPlatform.WEB:
appData = await initiateWebAppCreation(options);
break;
default:
throw new FirebaseError("Unexpected error. This should not happen");
}

const appData = await sdkInit(appPlatform, options);
logPostAppCreationInformation(appData, appPlatform);
return appData;
},
);
export async function sdkInit(appPlatform: AppPlatform, options: any) {

Check warning on line 202 in src/commands/apps-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer to move this to a separate file - I like to keep these command files as lean as possible and only export the command itself.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, please use the Options type instead of any here too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move everything into a new file? Also, we should put that info somewhere, I think other commands have other functions besides just the Command

let appData;
switch (appPlatform) {
case AppPlatform.IOS:
appData = await initiateIosAppCreation(options);
break;
case AppPlatform.ANDROID:
appData = await initiateAndroidAppCreation(options);
break;
case AppPlatform.WEB:
appData = await initiateWebAppCreation(options);
break;
default:
throw new FirebaseError("Unexpected error. This should not happen");
}
return appData;
}
236 changes: 175 additions & 61 deletions src/commands/apps-sdkconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,40 @@ import { getOrPromptProject } from "../management/projects";
import { FirebaseError } from "../error";
import { requireAuth } from "../requireAuth";
import { logger } from "../logger";
import { promptOnce } from "../prompt";
import { promptForDirectory, promptOnce } from "../prompt";
import { Options } from "../options";

function checkForApps(apps: AppMetadata[], appPlatform: AppPlatform): void {
import { getPlatformFromFolder } from "../dataconnect/fileUtils";
import * as path from "path";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: prefer grouping this with the other external imports at the top of the file.

import { Platform } from "../dataconnect/types";
import { logBullet, logSuccess } from "../utils";
import { sdkInit } from "./apps-create";
import { logError } from "../logError";
import { PLATFORMS } from "../init/features/dataconnect/sdk";
export function getSdkOutputPath(appDir: string, platform: Platform): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above - prefer to put this in a separate file than the command

switch (platform) {
case Platform.ANDROID:
// build.gradle can be in either / or /android/app. We always want to place the google-services.json in /android/app.
// So we check the current directory if it's app, and if so, we'll place it in the current directory, if not, we'll put it in the android/app dir.
// Fallback is just to the current app dir.
if (path.dirname(appDir) !== "app") {
try {
const fileNames = fs.readdirSync(path.join(appDir, "app"));
if (fileNames.includes("build.gradle")) {
appDir = path.join(appDir, "app");
}
} catch {
// Wasn't able to find app dir. Default to outputting to current directory.
}
}
return path.join(appDir, "google-services.json");
case Platform.WEB:
return path.join(appDir, "firebase-js-config.json");
case Platform.IOS:
return path.join(appDir, "GoogleService-Info.plist");
}
throw new Error("Platform " + platform.toString() + " is not supported yet.");
}
export function checkForApps(apps: AppMetadata[], appPlatform: AppPlatform): void {
if (!apps.length) {
throw new FirebaseError(
`There are no ${appPlatform === AppPlatform.ANY ? "" : appPlatform + " "}apps ` +
Expand Down Expand Up @@ -53,6 +83,75 @@ async function selectAppInteractively(
});
}

export async function getSdkConfig(
options: Options,
appPlatform: AppPlatform,
appId?: string,
): Promise<any> {
if (!appId) {
let projectId = needProjectId(options);
if (options.nonInteractive && !projectId) {
throw new FirebaseError("Must supply app and project ids in non-interactive mode.");
} else if (!projectId) {
const result = await getOrPromptProject(options);
projectId = result.projectId;
}

const apps = await listFirebaseApps(projectId, appPlatform);
// Fail out early if there's no apps.
checkForApps(apps, appPlatform);
// if there's only one app, we don't need to prompt interactively
if (apps.length === 1) {
// If there's only one, use it.
appId = apps[0].appId;
appPlatform = apps[0].platform;
} else if (options.nonInteractive) {
// If there's > 1 and we're non-interactive, fail.
throw new FirebaseError(`Project ${projectId} has multiple apps, must specify an app id.`);
} else {
// > 1, ask what the user wants.
const appMetadata: AppMetadata = await selectAppInteractively(apps, appPlatform);
appId = appMetadata.appId;
appPlatform = appMetadata.platform;
}
}

let configData;
const spinner = ora(`Downloading configuration data of your Firebase ${appPlatform} app`).start();
try {
configData = await getAppConfig(appId, appPlatform);
} catch (err: any) {
spinner.fail();
throw err;
}
spinner.succeed();

return configData;
}

export async function writeConfigToFile(
filename: string,
nonInteractive: boolean,
fileContents: string,
) {
if (fs.existsSync(filename)) {
if (nonInteractive) {
throw new FirebaseError(`${filename} already exists`);
}
const overwrite = await promptOnce({
type: "confirm",
default: false,
message: `${filename} already exists. Do you want to overwrite?`,
});

if (!overwrite) {
return;
}
}
// TODO(mtewani): Make the call to get the fileContents a part of one of these util fns.
fs.writeFileSync(filename, fileContents);
}

export const command = new Command("apps:sdkconfig [platform] [appId]")
.description(
"print the Google Services config of a Firebase app. " +
Expand All @@ -61,51 +160,81 @@ export const command = new Command("apps:sdkconfig [platform] [appId]")
.option("-o, --out [file]", "(optional) write config output to a file")
.before(requireAuth)
.action(async (platform = "", appId = "", options: Options): Promise<AppConfigurationData> => {
let appPlatform = getAppPlatform(platform);

if (!appId) {
let projectId = needProjectId(options);
if (options.nonInteractive && !projectId) {
throw new FirebaseError("Must supply app and project ids in non-interactive mode.");
} else if (!projectId) {
const result = await getOrPromptProject(options);
projectId = result.projectId;
let outputPath: string | undefined = undefined;
if (options.out === undefined) {
// do auto-download
let appDir = process.cwd();
const config = options.config;
if (!platform) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this ever hit? I don't think AppPlatform.PLATFORM_UNSPECIFIED is falsey (since its a string enum). Feels like this should maybe be if (platform === AppPlatform.PLATFORM_UNSPECIFIED)

// Detect what platform based on current user
let targetPlatform = await getPlatformFromFolder(appDir);
if (targetPlatform === Platform.NONE) {
// If we aren't in an app directory, ask the user where their app is, and try to autodetect from there.
appDir = await promptForDirectory({
config,
message: "Where is your app directory?",
});
targetPlatform = await getPlatformFromFolder(appDir);
}
if (targetPlatform === Platform.NONE || targetPlatform === Platform.MULTIPLE) {
if (targetPlatform === Platform.NONE) {
logBullet(`Couldn't automatically detect app your in directory ${appDir}.`);
} else {
logSuccess(`Detected multiple app platforms in directory ${appDir}`);
// Can only setup one platform at a time, just ask the user
}
targetPlatform = await promptOnce({
message: "Which platform do you want to set up an SDK for?",
type: "list",
choices: PLATFORMS,
});
} else if (targetPlatform === Platform.FLUTTER) {
logError(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double checking this behavior - what happens after this logging? Should we error out/return instead?

`Flutter is not supported by apps:sdkconfig. Please install the flutterfire CLI and run "flutterfire configure" to set up firebase for your flutter app.`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Include a link to the flutterfire getting started docs?

);
} else {
logSuccess(`Detected ${targetPlatform} app in directory ${appDir}`);
}
platform =
targetPlatform === Platform.MULTIPLE
? AppPlatform.PLATFORM_UNSPECIFIED
: (targetPlatform as Platform);
outputPath = getSdkOutputPath(appDir, platform);
}

const apps = await listFirebaseApps(projectId, appPlatform);
// Fail out early if there's no apps.
checkForApps(apps, appPlatform);
// if there's only one app, we don't need to prompt interactively
if (apps.length === 1) {
// If there's only one, use it.
appId = apps[0].appId;
appPlatform = apps[0].platform;
} else if (options.nonInteractive) {
// If there's > 1 and we're non-interactive, fail.
throw new FirebaseError(`Project ${projectId} has multiple apps, must specify an app id.`);
} else {
// > 1, ask what the user wants.
const appMetadata: AppMetadata = await selectAppInteractively(apps, appPlatform);
appId = appMetadata.appId;
appPlatform = appMetadata.platform;
}
const outputDir = path.dirname(outputPath!);
fs.mkdirpSync(outputDir);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL about mkdirp - we probably ought to use it in quite a few other places

let sdkConfig: any;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, use a stronger type than any here.

while (sdkConfig === undefined) {
try {
sdkConfig = await getSdkConfig(options, getAppPlatform(platform), appId);
} catch (e) {
if ((e as Error).message.includes("associated with this Firebase project")) {
await sdkInit(platform as unknown as AppPlatform, options);
} else {
throw e;
}
}
}

let configData;
const spinner = ora(
`Downloading configuration data of your Firebase ${appPlatform} app`,
).start();
try {
configData = await getAppConfig(appId, appPlatform);
} catch (err: any) {
spinner.fail();
throw err;
}
spinner.succeed();
const fileInfo = getAppConfigFile(sdkConfig, platform as unknown as AppPlatform);
await writeConfigToFile(outputPath!, options.nonInteractive, fileInfo.fileContents);

const fileInfo = getAppConfigFile(configData, appPlatform);
if (appPlatform === AppPlatform.WEB) {
fileInfo.sdkConfig = configData;
if (platform === AppPlatform.WEB) {
console.log(`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use logger.log() or logger.info() instead - console.log circumvents some verbosity/VSCode specific log handling we have in place.

How to use your JS SDK Config:
ES Module:
import { initializeApp } from 'firebase/app';
import json from './firebase-js-config.json';
initializeApp(json);
// CommonJS Module:
const { initializeApp } = require('firebase/app');
const json = require('./firebase-js-config.json');
initializeApp(json);// instead of initializeApp(config);
`);
if (platform === AppPlatform.WEB) {
fileInfo.sdkConfig = sdkConfig;
}
}

if (options.out === undefined) {
Expand All @@ -114,24 +243,9 @@ export const command = new Command("apps:sdkconfig [platform] [appId]")
}

const shouldUseDefaultFilename = options.out === true || options.out === "";
const filename = shouldUseDefaultFilename ? configData.fileName : options.out;
if (fs.existsSync(filename)) {
if (options.nonInteractive) {
throw new FirebaseError(`${filename} already exists`);
}
const overwrite = await promptOnce({
type: "confirm",
default: false,
message: `${filename} already exists. Do you want to overwrite?`,
});

if (!overwrite) {
return configData;
}
}

fs.writeFileSync(filename, fileInfo.fileContents);
const filename = shouldUseDefaultFilename ? sdkConfig.fileName : options.out;
await writeConfigToFile(filename, options.nonInteractive, fileInfo.fileContents);
logger.info(`App configuration is written in ${filename}`);

return configData;
return sdkConfig;
});
1 change: 1 addition & 0 deletions src/dataconnect/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export async function pickService(
// case insensitive exact match indicators for supported app platforms
const WEB_INDICATORS = ["package.json", "package-lock.json", "node_modules"];
const IOS_INDICATORS = ["info.plist", "podfile", "package.swift"];
// Note: build.gradle can be nested inside android/ and android/app.
const ANDROID_INDICATORS = ["androidmanifest.xml", "build.gradle", "build.gradle.kts"];
const DART_INDICATORS = ["pubspec.yaml", "pubspec.lock"];

Expand Down
16 changes: 9 additions & 7 deletions src/init/features/dataconnect/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export async function doSetup(setup: Setup, config: Config): Promise<void> {
);
}

export const PLATFORMS = [
{ name: "iOS (Swift)", value: Platform.IOS },
{ name: "Web (JavaScript)", value: Platform.WEB },
{ name: "Android (Kotlin)", value: Platform.ANDROID },
{ name: "Flutter (Dart)", value: Platform.FLUTTER },
];

async function askQuestions(setup: Setup, config: Config): Promise<SDKInfo> {
const serviceCfgs = readFirebaseJson(config);
// TODO: This current approach removes comments from YAML files. Consider a different approach that won't.
Expand Down Expand Up @@ -79,16 +86,11 @@ async function askQuestions(setup: Setup, config: Config): Promise<SDKInfo> {
logSuccess(`Detected multiple app platforms in directory ${appDir}`);
// Can only setup one platform at a time, just ask the user
}
const platforms = [
{ name: "iOS (Swift)", value: Platform.IOS },
{ name: "Web (JavaScript)", value: Platform.WEB },
{ name: "Android (Kotlin)", value: Platform.ANDROID },
{ name: "Flutter (Dart)", value: Platform.FLUTTER },
];

targetPlatform = await promptOnce({
message: "Which platform do you want to set up a generated SDK for?",
type: "list",
choices: platforms,
choices: PLATFORMS,
});
} else {
logSuccess(`Detected ${targetPlatform} app in directory ${appDir}`);
Expand Down
5 changes: 1 addition & 4 deletions src/management/apps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,11 @@ function generateWebAppList(counts: number): WebAppMetadata[] {
describe("App management", () => {
let sandbox: sinon.SinonSandbox;
let pollOperationStub: sinon.SinonStub;
let readFileSyncStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
pollOperationStub = sandbox.stub(pollUtils, "pollOperation").throws("Unexpected poll call");
readFileSyncStub = sandbox.stub(fs, "readFileSync").throws("Unxpected readFileSync call");
sandbox.stub(fs, "readFileSync").throws("Unxpected readFileSync call");
nock.disableNetConnect();
});

Expand Down Expand Up @@ -583,7 +582,6 @@ describe("App management", () => {
nock(firebaseApiOrigin())
.get(`/v1beta1/projects/-/webApps/${APP_ID}/config`)
.reply(200, mockWebConfig);
readFileSyncStub.onFirstCall().returns("{/*--CONFIG--*/}");

const configData = await getAppConfig(APP_ID, AppPlatform.WEB);
const fileData = getAppConfigFile(configData, AppPlatform.WEB);
Expand All @@ -593,7 +591,6 @@ describe("App management", () => {
fileContents: JSON.stringify(mockWebConfig, null, 2),
});
expect(nock.isDone()).to.be.true;
expect(readFileSyncStub).to.be.calledOnce;
});
});

Expand Down
Loading
Loading