diff --git a/CHANGELOG.md b/CHANGELOG.md index 0923c21fe21..3b3ee34ff0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,3 @@ - Adds additional Crashlytics tools for debugging/analyzing crashes (#9020) +- Improve `firebase init dataconnect` to detect all apps and set up SDKs in platform-specific directories (#9026) +- `firebase init dataconnect` provide an option to create an Next.JS app template (#9026) diff --git a/src/commands/init.ts b/src/commands/init.ts index 223d012a80d..2f8a94adc52 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -252,6 +252,10 @@ export async function initAction(feature: string, options: Options): Promise f !== "hosting:github"); } + // "dataconnect:sdk" is a part of "dataconnect", so if both are selected, "dataconnect:sdk" is ignored. + if (setup.features.includes("dataconnect") && setup.features.includes("dataconnect:sdk")) { + setup.features = setup.features.filter((f) => f !== "dataconnect:sdk"); + } await init(setup, config, options); diff --git a/src/dataconnect/appFinder.spec.ts b/src/dataconnect/appFinder.spec.ts new file mode 100644 index 00000000000..b3a64c6695b --- /dev/null +++ b/src/dataconnect/appFinder.spec.ts @@ -0,0 +1,179 @@ +import { expect } from "chai"; +import * as fs from "fs-extra"; +import { getPlatformFromFolder, detectApps } from "./appFinder"; +import { Platform } from "./types"; + +describe("getPlatformFromFolder", () => { + const testDir = "test-dir"; + + afterEach(() => { + fs.removeSync(testDir); + }); + + it("should return WEB if package.json exists", async () => { + fs.outputFileSync(`${testDir}/package.json`, "{}"); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.WEB); + }); + + it("should return ANDROID if src/main exists", async () => { + fs.mkdirpSync(`${testDir}/src/main`); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.ANDROID); + }); + + it("should return IOS if .xcodeproj exists", async () => { + fs.mkdirpSync(`${testDir}/a.xcodeproj`); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.IOS); + }); + + it("should return FLUTTER if pubspec.yaml exists", async () => { + fs.outputFileSync(`${testDir}/pubspec.yaml`, "name: test"); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.FLUTTER); + }); + + it("should return MULTIPLE if multiple identifiers exist", async () => { + fs.outputFileSync(`${testDir}/package.json`, "{}"); + fs.outputFileSync(`${testDir}/pubspec.yaml`, "name: test"); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.MULTIPLE); + }); + + it("should return NONE if no identifiers exist", async () => { + fs.mkdirpSync(testDir); + const platform = await getPlatformFromFolder(testDir); + expect(platform).to.equal(Platform.NONE); + }); +}); + +describe("detectApps", () => { + const testDir = "test-dir"; + + afterEach(() => { + fs.removeSync(testDir); + }); + + it("should detect a web app", async () => { + fs.outputFileSync(`${testDir}/package.json`, "{}"); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.WEB, + directory: ".", + frameworks: [], + }, + ]); + }); + + it("should detect an android app", async () => { + fs.mkdirpSync(`${testDir}/src/main`); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.ANDROID, + directory: ".", + }, + ]); + }); + + it("should detect an ios app", async () => { + fs.mkdirpSync(`${testDir}/a.xcodeproj`); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.IOS, + directory: ".", + }, + ]); + }); + + it("should detect a flutter app", async () => { + fs.outputFileSync(`${testDir}/pubspec.yaml`, "name: test"); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.FLUTTER, + directory: ".", + }, + ]); + }); + + it("should detect multiple apps", async () => { + fs.mkdirpSync(`${testDir}/web`); + fs.outputFileSync(`${testDir}/web/package.json`, "{}"); + fs.mkdirpSync(`${testDir}/android/src/main`); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.WEB, + directory: `web`, + frameworks: [], + }, + { + platform: Platform.ANDROID, + directory: `android`, + }, + ]); + }); + + it("should detect web frameworks", async () => { + fs.outputFileSync( + `${testDir}/package.json`, + JSON.stringify({ + dependencies: { + react: "1.0.0", + }, + }), + ); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.WEB, + directory: ".", + frameworks: ["react"], + }, + ]); + }); + + it("should detect a nested web app", async () => { + fs.mkdirpSync(`${testDir}/frontend`); + fs.outputFileSync(`${testDir}/frontend/package.json`, "{}"); + const apps = await detectApps(testDir); + expect(apps).to.deep.equal([ + { + platform: Platform.WEB, + directory: "frontend", + frameworks: [], + }, + ]); + }); + + it("should detect multiple top-level and nested apps", async () => { + fs.mkdirpSync(`${testDir}/android/src/main`); + fs.mkdirpSync(`${testDir}/ios/a.xcodeproj`); + fs.mkdirpSync(`${testDir}/web/frontend`); + fs.outputFileSync(`${testDir}/web/frontend/package.json`, "{}"); + + const apps = await detectApps(testDir); + const expected = [ + { + platform: Platform.ANDROID, + directory: "android", + }, + { + platform: Platform.IOS, + directory: "ios", + }, + { + platform: Platform.WEB, + directory: "web/frontend", + frameworks: [], + }, + ]; + expect(apps.sort((a, b) => a.directory.localeCompare(b.directory))).to.deep.equal( + expected.sort((a, b) => a.directory.localeCompare(b.directory)), + ); + }); +}); diff --git a/src/dataconnect/appFinder.ts b/src/dataconnect/appFinder.ts new file mode 100644 index 00000000000..26f8bbc4559 --- /dev/null +++ b/src/dataconnect/appFinder.ts @@ -0,0 +1,112 @@ +import * as fs from "fs-extra"; +import * as path from "path"; +import { glob } from "glob"; +import { Framework, Platform } from "./types"; +import { PackageJSON } from "../frameworks/compose/discover/runtime/node"; + +export interface App { + platform: Platform; + directory: string; + frameworks?: Framework[]; +} + +/** Returns a string description of the app */ +export function appDescription(a: App): string { + return `${a.directory} (${a.platform.toLowerCase()})`; +} + +/** Given a directory, determine the platform type */ +export async function getPlatformFromFolder(dirPath: string): Promise { + const apps = await detectApps(dirPath); + const hasWeb = apps.some((app) => app.platform === Platform.WEB); + const hasAndroid = apps.some((app) => app.platform === Platform.ANDROID); + const hasIOS = apps.some((app) => app.platform === Platform.IOS); + const hasDart = apps.some((app) => app.platform === Platform.FLUTTER); + if (!hasWeb && !hasAndroid && !hasIOS && !hasDart) { + return Platform.NONE; + } else if (hasWeb && !hasAndroid && !hasIOS && !hasDart) { + return Platform.WEB; + } else if (hasAndroid && !hasWeb && !hasIOS && !hasDart) { + return Platform.ANDROID; + } else if (hasIOS && !hasWeb && !hasAndroid && !hasDart) { + return Platform.IOS; + } else if (hasDart && !hasWeb && !hasIOS && !hasAndroid) { + return Platform.FLUTTER; + } + // At this point, its not clear which platform the app directory is + // because we found indicators for multiple platforms. + return Platform.MULTIPLE; +} + +/** Detects the apps in a given directory */ +export async function detectApps(dirPath: string): Promise { + const packageJsonFiles = await detectFiles(dirPath, "package.json"); + const pubSpecYamlFiles = await detectFiles(dirPath, "pubspec.yaml"); + const srcMainFolders = await detectFiles(dirPath, "src/main/"); + const xCodeProjects = await detectFiles(dirPath, "*.xcodeproj/"); + const webApps = await Promise.all(packageJsonFiles.map((p) => packageJsonToWebApp(dirPath, p))); + const flutterApps = pubSpecYamlFiles.map((f) => ({ + platform: Platform.FLUTTER, + directory: path.dirname(f), + })); + const androidApps = srcMainFolders + .map((f) => ({ + platform: Platform.ANDROID, + directory: path.dirname(path.dirname(f)), + })) + .filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory))); + const iosApps = xCodeProjects + .map((f) => ({ + platform: Platform.IOS, + directory: path.dirname(f), + })) + .filter((a) => !flutterApps.some((f) => isPathInside(f.directory, a.directory))); + return [...webApps, ...flutterApps, ...androidApps, ...iosApps]; +} + +export function isPathInside(parent: string, child: string): boolean { + const relativePath = path.relative(parent, child); + return !relativePath.startsWith(`..`); +} + +async function packageJsonToWebApp(dirPath: string, packageJsonFile: string): Promise { + const fullPath = path.join(dirPath, packageJsonFile); + const packageJson = JSON.parse((await fs.readFile(fullPath)).toString()); + return { + platform: Platform.WEB, + directory: path.dirname(packageJsonFile), + frameworks: getFrameworksFromPackageJson(packageJson), + }; +} + +export const WEB_FRAMEWORKS: Framework[] = ["react", "angular"]; +export const WEB_FRAMEWORKS_SIGNALS: { [key in Framework]: string[] } = { + react: ["react", "next"], + angular: ["@angular/core"], +}; + +export function getFrameworksFromPackageJson(packageJson: PackageJSON): Framework[] { + const devDependencies = Object.keys(packageJson.devDependencies ?? {}); + const dependencies = Object.keys(packageJson.dependencies ?? {}); + const allDeps = Array.from(new Set([...devDependencies, ...dependencies])); + return WEB_FRAMEWORKS.filter((framework) => + WEB_FRAMEWORKS_SIGNALS[framework]!.find((dep) => allDeps.includes(dep)), + ); +} + +async function detectFiles(dirPath: string, filePattern: string): Promise { + const options = { + cwd: dirPath, + ignore: [ + "**/dataconnect*/**", + "**/node_modules/**", // Standard dependency directory + "**/dist/**", // Common build output + "**/build/**", // Common build output + "**/out/**", // Another common build output + "**/.next/**", // Next.js build directory + "**/coverage/**", // Test coverage reports + ], + absolute: false, + }; + return glob(`**/${filePattern}`, options); +} diff --git a/src/dataconnect/fileUtils.spec.ts b/src/dataconnect/fileUtils.spec.ts deleted file mode 100644 index 177a017984b..00000000000 --- a/src/dataconnect/fileUtils.spec.ts +++ /dev/null @@ -1,441 +0,0 @@ -import * as mockfs from "mock-fs"; - -import { expect } from "chai"; -import { frameworksMap, getPlatformFromFolder, SUPPORTED_FRAMEWORKS } from "./fileUtils"; -import { generateSdkYaml } from "../init/features/dataconnect/sdk"; -import { ConnectorYaml, Platform } from "./types"; -import FileSystem from "mock-fs/lib/filesystem"; - -describe("getPlatformFromFolder", () => { - const cases: { - desc: string; - folderName: string; - folderItems: FileSystem.DirectoryItems; - output: Platform; - }[] = [ - { - desc: "Empty folder", - folderName: "test/", - folderItems: {}, - output: Platform.NONE, - }, - { - desc: "Empty folder, long path name", - folderName: "root/abcd/test/", - folderItems: {}, - output: Platform.NONE, - }, - { - desc: "folder w/ no identifier", - folderName: "test/", - folderItems: { file1: "contents", randomfile2: "my android contents" }, - output: Platform.NONE, - }, - { - desc: "Web identifier 1", - folderName: "test/", - folderItems: { file1: "contents", "package.json": "node" }, - output: Platform.WEB, - }, - { - desc: "Web identifier 2", - folderName: "test/", - folderItems: { file1: "contents", node_modules: { dep1: "firebase", dep2: "dataconnect" } }, - output: Platform.WEB, - }, - { - desc: "Android identifier 1", - folderName: "/test", - folderItems: { file1: "contents", "androidmanifest.xml": "my android contents" }, - output: Platform.ANDROID, - }, - { - desc: "Android identifier 2", - folderName: "/test/", - folderItems: { - "build.gradle": "contents", - file2: "my android contents", - }, - output: Platform.ANDROID, - }, - { - desc: "Android identifier, long path name", - folderName: "is/this/an/android/test", - folderItems: { file1: "contents", "androidmanifest.xml": "my android contents" }, - output: Platform.ANDROID, - }, - { - desc: "iOS file identifier 1", - folderName: "test/", - folderItems: { file1: "contents", podfile: "cocoa pods yummy" }, - output: Platform.IOS, - }, - { - desc: "iOS file identifier 2", - folderName: "root/abcd", - folderItems: { - file1: "ios", - "myapp.xcodeproj": "folder in an xcode folder", - }, - output: Platform.IOS, - }, - { - desc: "iOS folder identifier 3", - folderName: "/users/googler/myprojects", - folderItems: { - "myapp.xcworkspace": { file1: "contents" }, - }, - output: Platform.IOS, - }, - { - desc: "Flutter identifier 1", - folderName: "is/this/a/dart/test", - folderItems: { - file1: "contents", - "pubspec.yaml": "my deps", - }, - output: Platform.FLUTTER, - }, - { - desc: "Flutter identifier 2", - folderName: "is/this/a/dart/test", - folderItems: { - "pubspec.lock": "my deps", - }, - output: Platform.FLUTTER, - }, - { - desc: "Flutter identifier with experiment disabled", - folderName: "is/this/a/dart/test", - folderItems: { - "pubspec.mispelled": "my deps", - }, - output: Platform.NONE, - }, - { - desc: "multiple identifiers, returns undetermined", - folderName: "test/", - folderItems: { - file1: "contents", - podfile: "cocoa pods yummy", - "androidmanifest.xml": "file found second :(", - }, - output: Platform.MULTIPLE, - }, - ]; - for (const c of cases) { - it(c.desc, async () => { - mockfs({ [c.folderName]: c.folderItems }); - const platform = await getPlatformFromFolder(c.folderName); - expect(platform).to.equal(c.output); - }); - } - afterEach(() => { - mockfs.restore(); - }); -}); - -describe("generateSdkYaml", () => { - // Test Data - function getSampleConnectorYaml(): ConnectorYaml { - return { - connectorId: "default", - generate: {}, - }; - } - const connectorYamlFolder = "/my/app/folder/connector"; - - const appFolderBase = "/my/app/folder"; - const appFolderDetectable = "/my/app/folder/detected"; - const appFolderBelowConnector = "/my/app/folder/connector/belowConnector"; - const appFolderOutside = "/my/app/outside"; - - describe("Web platform should add JavaScript SDK Generation", () => { - const cases: { - desc: string; - appDir: string; - output: any; - }[] = [ - { - desc: "basic", - appDir: appFolderBase, - output: { - outputDir: "../dataconnect-generated/js/default-connector", - package: "@dataconnect/generated", - packageJsonDir: "..", - }, - }, - { - desc: "has package.json", - appDir: appFolderDetectable, - output: { - outputDir: "../detected/dataconnect-generated/js/default-connector", - package: "@dataconnect/generated", - packageJsonDir: "../detected", - }, - }, - { - desc: "below connector", - appDir: appFolderBelowConnector, - output: { - outputDir: "belowConnector/dataconnect-generated/js/default-connector", - package: "@dataconnect/generated", - packageJsonDir: "belowConnector", - }, - }, - { - desc: "outside", - appDir: appFolderOutside, - output: { - outputDir: "../../outside/dataconnect-generated/js/default-connector", - package: "@dataconnect/generated", - packageJsonDir: "../../outside", - }, - }, - ]; - for (const c of cases) { - it(c.desc, async () => { - mockfs({ [appFolderDetectable]: { ["package.json"]: "{}" } }); - const modifiedYaml = await generateSdkYaml( - Platform.WEB, - getSampleConnectorYaml(), - connectorYamlFolder, - c.appDir, - ); - expect(modifiedYaml.generate?.javascriptSdk).to.deep.equal(c.output); - }); - } - }); - for (const f of SUPPORTED_FRAMEWORKS) { - describe(`Check support for ${f} framework`, () => { - const cases = [ - { - desc: `can detect a ${f}`, - deps: frameworksMap[f], - detect: true, - }, - { - desc: `can detect not ${f}`, - deps: `not-${f}`, - }, - ]; - async function testDependency(dep: string, shouldDetect: boolean | undefined) { - mockfs({ - [appFolderDetectable]: { - ["package.json"]: `{"dependencies": {"${dep}": "1"}}`, - }, - }); - const modifiedYaml = await generateSdkYaml( - Platform.WEB, - getSampleConnectorYaml(), - connectorYamlFolder, - appFolderDetectable, - ); - console.log(`{"dependencies": {"${dep}": "1"}}`); - expect(modifiedYaml.generate?.javascriptSdk?.[f]).to.equal(shouldDetect); - } - for (const c of cases) { - it(c.desc, async () => { - if (Array.isArray(c.deps)) { - for (const dep of c.deps) { - await testDependency(dep, c.detect); - } - } else { - await testDependency(c.deps as string, c.detect); - } - }); - } - }); - } - - describe("IOS platform should add Swift SDK Generation", () => { - const cases: { - desc: string; - appDir: string; - output: any; - }[] = [ - { - desc: "basic", - appDir: appFolderBase, - output: { - outputDir: "../dataconnect-generated/swift", - package: "DataConnectGenerated", - }, - }, - { - desc: "below connector", - appDir: appFolderBelowConnector, - output: { - outputDir: "belowConnector/dataconnect-generated/swift", - package: "DataConnectGenerated", - }, - }, - { - desc: "outside", - appDir: appFolderOutside, - output: { - outputDir: "../../outside/dataconnect-generated/swift", - package: "DataConnectGenerated", - }, - }, - ]; - for (const c of cases) { - it(c.desc, async () => { - const modifiedYaml = await generateSdkYaml( - Platform.IOS, - getSampleConnectorYaml(), - connectorYamlFolder, - c.appDir, - ); - expect(modifiedYaml.generate?.swiftSdk).to.deep.equal(c.output); - }); - } - }); - - describe("Android platform should add Kotlin SDK Generation", () => { - const appFolderHasJava = "/my/app/folder/has-java"; - const appFolderHasKotlin = "/my/app/folder/has-kotlin"; - const appFolderHasBoth = "/my/app/folder/has-both"; - const cases: { - desc: string; - appDir: string; - output: any; - }[] = [ - { - desc: "basic", - appDir: appFolderBase, - output: { - outputDir: "../dataconnect-generated/kotlin", - package: "com.google.firebase.dataconnect.generated", - }, - }, - { - desc: "has java folder", - appDir: appFolderHasJava, - output: { - outputDir: "../has-java/app/src/main/java", - package: "com.google.firebase.dataconnect.generated", - }, - }, - { - desc: "has kotlin folder", - appDir: appFolderHasKotlin, - output: { - outputDir: "../has-kotlin/app/src/main/kotlin", - package: "com.google.firebase.dataconnect.generated", - }, - }, - { - desc: "prefer kotlin folder over java folder", - appDir: appFolderHasBoth, - output: { - outputDir: "../has-both/app/src/main/kotlin", - package: "com.google.firebase.dataconnect.generated", - }, - }, - { - desc: "below connector", - appDir: appFolderBelowConnector, - output: { - outputDir: "belowConnector/dataconnect-generated/kotlin", - package: "com.google.firebase.dataconnect.generated", - }, - }, - { - desc: "outside", - appDir: appFolderOutside, - output: { - outputDir: "../../outside/dataconnect-generated/kotlin", - package: "com.google.firebase.dataconnect.generated", - }, - }, - ]; - for (const c of cases) { - it(c.desc, async () => { - mockfs({ - [appFolderHasJava + "/app/src/main/java"]: {}, - [appFolderHasKotlin + "/app/src/main/kotlin"]: {}, - [appFolderHasBoth + "/app/src/main/java"]: {}, - [appFolderHasBoth + "/app/src/main/kotlin"]: {}, - }); - const modifiedYaml = await generateSdkYaml( - Platform.ANDROID, - getSampleConnectorYaml(), - connectorYamlFolder, - c.appDir, - ); - expect(modifiedYaml.generate?.kotlinSdk).to.deep.equal(c.output); - }); - } - }); - - describe("Flutter platform should add Dart SDK Generation", () => { - const cases: { - desc: string; - appDir: string; - output: any; - }[] = [ - { - desc: "basic", - appDir: appFolderBase, - output: { - outputDir: "../dataconnect-generated/dart/default_connector", - package: "dataconnect_generated", - }, - }, - { - desc: "below connector", - appDir: appFolderBelowConnector, - output: { - outputDir: "belowConnector/dataconnect-generated/dart/default_connector", - package: "dataconnect_generated", - }, - }, - { - desc: "outside", - appDir: appFolderOutside, - output: { - outputDir: "../../outside/dataconnect-generated/dart/default_connector", - package: "dataconnect_generated", - }, - }, - ]; - for (const c of cases) { - it(c.desc, async () => { - const modifiedYaml = await generateSdkYaml( - Platform.FLUTTER, - getSampleConnectorYaml(), - connectorYamlFolder, - c.appDir, - ); - expect(modifiedYaml.generate?.dartSdk).to.deep.equal(c.output); - }); - } - }); - - it("should create generate object if it doesn't exist", async () => { - const yamlWithoutGenerate: ConnectorYaml = { connectorId: "default-connector" }; - const modifiedYaml = await generateSdkYaml( - Platform.WEB, - yamlWithoutGenerate, - connectorYamlFolder, - appFolderBase, - ); - expect(modifiedYaml.generate).to.exist; - }); - - it("should not modify yaml for unknown platforms", async () => { - const unknownPlatform = "unknown" as Platform; // Type assertion for test - const modifiedYaml = await generateSdkYaml( - unknownPlatform, - getSampleConnectorYaml(), - connectorYamlFolder, - appFolderBase, - ); - expect(modifiedYaml).to.deep.equal(getSampleConnectorYaml()); // No changes - }); - - afterEach(() => { - mockfs.restore(); - }); -}); diff --git a/src/dataconnect/fileUtils.ts b/src/dataconnect/fileUtils.ts deleted file mode 100644 index 14420af00da..00000000000 --- a/src/dataconnect/fileUtils.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as fs from "fs-extra"; -import * as path from "path"; - -import { Platform, SupportedFrameworks } from "./types"; -import { PackageJSON } from "../frameworks/compose/discover/runtime/node"; - -// 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", ".xcodeproj"]; -// 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"]; - -// endswith match -const IOS_POSTFIX_INDICATORS = [".xcworkspace", ".xcodeproj"]; - -// given a directory, determine the platform type -export async function getPlatformFromFolder(dirPath: string) { - // Check for file indicators - const fileNames = await fs.readdir(dirPath); - - let hasWeb = false; - let hasAndroid = false; - let hasIOS = false; - let hasDart = false; - for (const fileName of fileNames) { - const cleanedFileName = fileName.toLowerCase(); - hasWeb ||= WEB_INDICATORS.some((indicator) => indicator === cleanedFileName); - hasAndroid ||= ANDROID_INDICATORS.some((indicator) => indicator === cleanedFileName); - hasIOS ||= - IOS_INDICATORS.some((indicator) => indicator === cleanedFileName) || - IOS_POSTFIX_INDICATORS.some((indicator) => cleanedFileName.endsWith(indicator)); - hasDart ||= DART_INDICATORS.some((indicator) => indicator === cleanedFileName); - } - if (!hasWeb && !hasAndroid && !hasIOS && !hasDart) { - return Platform.NONE; - } else if (hasWeb && !hasAndroid && !hasIOS && !hasDart) { - return Platform.WEB; - } else if (hasAndroid && !hasWeb && !hasIOS && !hasDart) { - return Platform.ANDROID; - } else if (hasIOS && !hasWeb && !hasAndroid && !hasDart) { - return Platform.IOS; - } else if (hasDart && !hasWeb && !hasIOS && !hasAndroid) { - return Platform.FLUTTER; - } - // At this point, its not clear which platform the app directory is - // because we found indicators for multiple platforms. - return Platform.MULTIPLE; -} - -export async function resolvePackageJson( - packageJsonPath: string, -): Promise { - let validPackageJsonPath = packageJsonPath; - if (!packageJsonPath.endsWith("package.json")) { - validPackageJsonPath = path.join(packageJsonPath, "package.json"); - } - validPackageJsonPath = path.resolve(validPackageJsonPath); - try { - return JSON.parse((await fs.readFile(validPackageJsonPath)).toString()); - } catch { - return undefined; - } -} - -export const SUPPORTED_FRAMEWORKS: (keyof SupportedFrameworks)[] = ["react", "angular"]; -export const frameworksMap: { [key in keyof SupportedFrameworks]: string[] } = { - react: ["react", "next"], - angular: ["@angular/core"], -}; -export function getFrameworksFromPackageJson( - packageJson: PackageJSON, -): (keyof SupportedFrameworks)[] { - const devDependencies = Object.keys(packageJson.devDependencies ?? {}); - const dependencies = Object.keys(packageJson.dependencies ?? {}); - const allDeps = Array.from(new Set([...devDependencies, ...dependencies])); - return SUPPORTED_FRAMEWORKS.filter((framework) => - frameworksMap[framework]!.find((dep) => allDeps.includes(dep)), - ); -} diff --git a/src/dataconnect/types.ts b/src/dataconnect/types.ts index 58870f51cf1..15718cd3482 100644 --- a/src/dataconnect/types.ts +++ b/src/dataconnect/types.ts @@ -142,10 +142,10 @@ export interface ConnectorYaml { } export interface Generate { - javascriptSdk?: JavascriptSDK; - swiftSdk?: SwiftSDK; - kotlinSdk?: KotlinSDK; - dartSdk?: DartSDK; + javascriptSdk?: JavascriptSDK | JavascriptSDK[]; + swiftSdk?: SwiftSDK | SwiftSDK[]; + kotlinSdk?: KotlinSDK | KotlinSDK[]; + dartSdk?: DartSDK | DartSDK[]; } export interface SupportedFrameworks { @@ -153,6 +153,8 @@ export interface SupportedFrameworks { angular?: boolean; } +export type Framework = keyof SupportedFrameworks; + export interface JavascriptSDK extends SupportedFrameworks { outputDir: string; package: string; diff --git a/src/init/features/dataconnect/create_app.ts b/src/init/features/dataconnect/create_app.ts new file mode 100644 index 00000000000..d1a2f53bb14 --- /dev/null +++ b/src/init/features/dataconnect/create_app.ts @@ -0,0 +1,56 @@ +import { spawn } from "child_process"; +import * as clc from "colorette"; +import { logLabeledBullet } from "../../../utils"; + +/** Create a React app using vite react template. */ +export async function createReactApp(webAppId: string): Promise { + const args = ["create", "vite@latest", webAppId, "--", "--template", "react"]; + await executeCommand("npm", args); +} + +/** Create a Next.js app using create-next-app. */ +export async function createNextApp(webAppId: string): Promise { + const args = [ + "create-next-app@latest", + webAppId, + "--ts", + "--eslint", + "--tailwind", + "--src-dir", + "--app", + "--turbopack", + "--import-alias", + '"@/*"', + "--skip-install", + ]; + await executeCommand("npx", args); +} + +// Function to execute a command asynchronously and pipe I/O +async function executeCommand(command: string, args: string[]): Promise { + logLabeledBullet("dataconnect", `Running ${clc.bold(`${command} ${args.join(" ")}`)}`); + return new Promise((resolve, reject) => { + // spawn returns a ChildProcess object + const childProcess = spawn(command, args, { + // 'inherit' pipes stdin, stdout, and stderr to the parent process + stdio: "inherit", + // Runs the command in a shell, which allows for shell syntax like pipes, etc. + shell: true, + }); + + childProcess.on("close", (code) => { + if (code === 0) { + // Command executed successfully + resolve(); + } else { + // Command failed + reject(new Error(`Command failed with exit code ${code}`)); + } + }); + + childProcess.on("error", (err) => { + // Handle errors like command not found + reject(err); + }); + }); +} diff --git a/src/init/features/dataconnect/index.spec.ts b/src/init/features/dataconnect/index.spec.ts index 12b3ef5e2c6..77a8ab45015 100644 --- a/src/init/features/dataconnect/index.spec.ts +++ b/src/init/features/dataconnect/index.spec.ts @@ -3,6 +3,7 @@ import { expect } from "chai"; import * as fs from "fs-extra"; import * as init from "./index"; +import * as sdk from "./sdk"; import { Config } from "../../../config"; import { RCData } from "../../../rc"; import * as provison from "../../../dataconnect/provisionCloudSql"; @@ -20,10 +21,12 @@ describe("init dataconnect", () => { let provisionCSQLStub: sinon.SinonStub; let askWriteProjectFileStub: sinon.SinonStub; let ensureSyncStub: sinon.SinonStub; + let sdkActuateStub: sinon.SinonStub; beforeEach(() => { provisionCSQLStub = sandbox.stub(provison, "setupCloudSql"); ensureSyncStub = sandbox.stub(fs, "ensureFileSync"); + sdkActuateStub = sandbox.stub(sdk, "actuate").resolves(); sandbox.stub(cloudbilling, "isBillingEnabled").resolves(true); }); @@ -179,7 +182,7 @@ describe("init dataconnect", () => { projectId: "test-project", rcfile: MOCK_RC, config: c.config.src, - featureInfo: { dataconnect: c.requiredInfo }, + featureInfo: { dataconnect: c.requiredInfo, dataconnectSdk: { apps: [] } }, }, c.config, {}, @@ -190,6 +193,7 @@ describe("init dataconnect", () => { } expect(askWriteProjectFileStub.args.map((a) => a[0])).to.deep.equal(c.expectedFiles); expect(provisionCSQLStub.called).to.equal(c.expectCSQLProvisioning); + expect(sdkActuateStub.called).to.be.true; }); } }); @@ -231,7 +235,7 @@ describe("init dataconnect", () => { }); function mockConfig(data: Record = {}): Config { - return new Config(data, {}); + return new Config(data, { projectDir: "." }); } function mockRequiredInfo(info: Partial = {}): init.RequiredInfo { return { diff --git a/src/init/features/dataconnect/index.ts b/src/init/features/dataconnect/index.ts index 763a2f9213b..6aa19734f71 100644 --- a/src/init/features/dataconnect/index.ts +++ b/src/init/features/dataconnect/index.ts @@ -17,7 +17,7 @@ import { createService, upsertSchema, } from "../../../dataconnect/client"; -import { Schema, Service, File, Platform, SCHEMA_ID } from "../../../dataconnect/types"; +import { Schema, Service, File, SCHEMA_ID } from "../../../dataconnect/types"; import { parseCloudSQLInstanceName, parseServiceName } from "../../../dataconnect/names"; import { logger } from "../../../logger"; import { readTemplateSync } from "../../../templates"; @@ -27,10 +27,10 @@ import { envOverride, promiseWithSpinner, logLabeledError, + newUniqueId, } from "../../../utils"; import { isBillingEnabled } from "../../../gcp/cloudbilling"; import * as sdk from "./sdk"; -import { getPlatformFromFolder } from "../../../dataconnect/fileUtils"; import { generateOperation, generateSchema, @@ -38,7 +38,6 @@ import { PROMPT_GENERATE_SEED_DATA, } from "../../../gemini/fdcExperience"; import { configstore } from "../../../configstore"; -import { Options } from "../../../options"; import { trackGA4 } from "../../../track"; const DATACONNECT_YAML_TEMPLATE = readTemplateSync("init/dataconnect/dataconnect.yaml"); @@ -128,6 +127,8 @@ export async function askQuestions(setup: Setup): Promise { } setup.featureInfo = setup.featureInfo || {}; setup.featureInfo.dataconnect = info; + + await sdk.askQuestions(setup); } // actuate writes product specific files and makes product specifc API calls. @@ -150,6 +151,7 @@ export async function actuate(setup: Setup, config: Config, options: any): Promi try { await actuateWithInfo(setup, config, info, options); + await sdk.actuate(setup, config); } finally { void trackGA4("dataconnect_init", { project_status: setup.projectId ? (setup.isBillingEnabled ? "blaze" : "spark") : "missing", @@ -340,24 +342,13 @@ function schemasDeploySequence( ]; } -export async function postSetup(setup: Setup, config: Config, options: Options): Promise { +export async function postSetup(setup: Setup): Promise { const info = setup.featureInfo?.dataconnect; if (!info) { throw new Error("Data Connect feature RequiredInfo is not provided"); } const instructions: string[] = []; - const cwdPlatformGuess = await getPlatformFromFolder(process.cwd()); - // If a platform can be detected or a connector is chosen via env var, always - // setup SDK. FDC_CONNECTOR is used for scripts under https://firebase.tools/. - if (cwdPlatformGuess !== Platform.NONE || envOverride("FDC_CONNECTOR", "")) { - await sdk.doSetup(setup, config, options); - } else { - instructions.push( - `To add the generated SDK to your app, run ${clc.bold("firebase init dataconnect:sdk")}`, - ); - } - if (info.appDescription) { instructions.push( `You can visualize the Data Connect Schema in Firebase Console: @@ -366,13 +357,11 @@ export async function postSetup(setup: Setup, config: Config, options: Options): ); } - if (setup.projectId) { - if (!setup.isBillingEnabled) { - instructions.push(upgradeInstructions(setup.projectId)); - } + if (!setup.isBillingEnabled) { + instructions.push(upgradeInstructions(setup.projectId || "your-firebase-project")); } instructions.push( - `Install the Data Connect VS Code Extensions. You can explore Data Connect Query on local pgLite or Cloud SQL Postgres Instance.`, + `Install the Data Connect VS Code Extensions. You can explore Data Connect Query on local pgLite and Cloud SQL Postgres Instance.`, ); if (instructions.length) { @@ -440,6 +429,8 @@ async function writeConnectorFiles( join(dir, connectorInfo.path, "connector.yaml"), subbedConnectorYaml, !!options.force, + // Default to override connector.yaml + true, ); for (const f of connectorInfo.files) { await config.askWriteProjectFile( @@ -533,7 +524,7 @@ async function promptForExistingServices(setup: Setup, info: RequiredInfo): Prom const id = c.name.split("/").pop()!; return { id, - path: connectors.length === 1 ? "./connector" : `./${id}`, + path: connectors.length === 1 ? "./example" : `./${id}`, files: c.source.files || [], }; }); @@ -688,20 +679,6 @@ async function locationChoices(setup: Setup) { } } -/** - * Returns a unique ID that's either `recommended` or `recommended-{i}`. - * Avoid existing IDs. - */ -function newUniqueId(recommended: string, existingIDs: string[]): string { - let id = recommended; - let i = 1; - while (existingIDs.includes(id)) { - id = `${recommended}-${i}`; - i++; - } - return id; -} - function defaultServiceId(): string { return toDNSCompatibleId(basename(process.cwd())); } @@ -722,3 +699,4 @@ export function toDNSCompatibleId(id: string): string { } return id || "app"; } +export { newUniqueId }; diff --git a/src/init/features/dataconnect/sdk.spec.ts b/src/init/features/dataconnect/sdk.spec.ts index c1e1acdb7f6..34b8c44bc93 100644 --- a/src/init/features/dataconnect/sdk.spec.ts +++ b/src/init/features/dataconnect/sdk.spec.ts @@ -1,72 +1,97 @@ -import * as fs from "fs-extra"; -import * as sinon from "sinon"; -import { expect } from "chai"; +import * as chai from "chai"; +import { addSdkGenerateToConnectorYaml } from "./sdk"; +import { ConnectorInfo, ConnectorYaml, Platform } from "../../../dataconnect/types"; +import { App } from "../../../dataconnect/appFinder"; -import * as sdk from "./sdk"; -import { DataConnectEmulator } from "../../../emulator/dataconnectEmulator"; -import { Config } from "../../../config"; +const expect = chai.expect; -const CONNECTOR_YAML_CONTENTS = "connectorId: blah"; +describe("addSdkGenerateToConnectorYaml", () => { + let connectorInfo: ConnectorInfo; + let connectorYaml: ConnectorYaml; + let app: App; -describe("init dataconnect:sdk", () => { - describe.skip("askQuestions", () => { - // TODO: Add unit tests for askQuestions + beforeEach(() => { + connectorInfo = { + directory: "/users/test/project/dataconnect", + connectorYaml: { + connectorId: "test-connector", + }, + connector: {} as any, + }; + connectorYaml = { + connectorId: "test-connector", + }; + app = { + directory: "/users/test/project/app", + platform: Platform.WEB, + frameworks: [], + }; }); - describe("actuation", () => { - const sandbox = sinon.createSandbox(); - let generateStub: sinon.SinonStub; - let fsStub: sinon.SinonStub; - let emptyConfig: Config; + it("should add javascriptSdk for web platform", () => { + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); + expect(connectorYaml.generate?.javascriptSdk).to.deep.equal([ + { + outputDir: "../app/src/dataconnect-generated", + package: "@dataconnect/generated", + packageJsonDir: "../app", + react: false, + angular: false, + }, + ]); + }); - beforeEach(() => { - fsStub = sandbox.stub(fs, "writeFileSync"); - sandbox.stub(fs, "ensureFileSync").returns(); - generateStub = sandbox.stub(DataConnectEmulator, "generate"); - emptyConfig = new Config({}, { projectDir: process.cwd() }); - }); + it("should add javascriptSdk with react for web platform", () => { + app.frameworks = ["react"]; + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); + expect(connectorYaml.generate?.javascriptSdk).to.deep.equal([ + { + outputDir: "../app/src/dataconnect-generated", + package: "@dataconnect/generated", + packageJsonDir: "../app", + react: true, + angular: false, + }, + ]); + }); - afterEach(() => { - sandbox.restore(); - }); + it("should add dartSdk for flutter platform", () => { + app.platform = Platform.FLUTTER; + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); + expect(connectorYaml.generate?.dartSdk).to.deep.equal([ + { + outputDir: "../app/lib/dataconnect_generated", + package: "dataconnect_generated", + }, + ]); + }); - const cases: { - desc: string; - sdkInfo: sdk.SDKInfo; - shouldGenerate: boolean; - }[] = [ + it("should add kotlinSdk for android platform", () => { + app.platform = Platform.ANDROID; + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); + expect(connectorYaml.generate?.kotlinSdk).to.deep.equal([ { - desc: "should write files and generate code", - sdkInfo: mockSDKInfo(), - shouldGenerate: true, + outputDir: "../app/src/main/kotlin", + package: "com.google.firebase.dataconnect.generated", }, - ]; + ]); + }); - for (const c of cases) { - it(c.desc, async () => { - generateStub.resolves(); - fsStub.returns({}); + it("should add swiftSdk for ios platform", () => { + app.platform = Platform.IOS; + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); + expect(connectorYaml.generate?.swiftSdk).to.deep.equal([ + { + outputDir: "../FirebaseDataConnectGenerated", + package: "DataConnectGenerated", + }, + ]); + }); - await sdk.actuate(c.sdkInfo, emptyConfig); - expect(generateStub.called).to.equal(c.shouldGenerate); - }); - } + it("should throw error for unsupported platform", () => { + app.platform = Platform.NONE; + expect(() => addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app)).to.throw( + "Unsupported platform", + ); }); }); - -function mockSDKInfo(): sdk.SDKInfo { - return { - connectorYamlContents: CONNECTOR_YAML_CONTENTS, - connectorInfo: { - connector: { - name: "test", - source: {}, - }, - directory: `${process.cwd()}/dataconnect/connector`, - connectorYaml: { - connectorId: "app", - }, - }, - displayIOSWarning: false, - }; -} diff --git a/src/init/features/dataconnect/sdk.ts b/src/init/features/dataconnect/sdk.ts index 40375c102c4..680ac6926c0 100644 --- a/src/init/features/dataconnect/sdk.ts +++ b/src/init/features/dataconnect/sdk.ts @@ -2,14 +2,10 @@ import * as yaml from "yaml"; import * as clc from "colorette"; import * as path from "path"; -import { dirExistsSync } from "../../../fsutils"; +const cwd = process.cwd(); + import { checkbox, select } from "../../../prompt"; -import { - getPlatformFromFolder, - getFrameworksFromPackageJson, - resolvePackageJson, - SUPPORTED_FRAMEWORKS, -} from "../../../dataconnect/fileUtils"; +import { App, appDescription, detectApps } from "../../../dataconnect/appFinder"; import { Config } from "../../../config"; import { Setup } from "../.."; import { loadAll } from "../../../dataconnect/load"; @@ -17,143 +13,220 @@ import { ConnectorInfo, ConnectorYaml, DartSDK, + Framework, JavascriptSDK, KotlinSDK, Platform, - SupportedFrameworks, } from "../../../dataconnect/types"; -import { DataConnectEmulator } from "../../../emulator/dataconnectEmulator"; import { FirebaseError } from "../../../error"; -import { snakeCase } from "lodash"; -import { logSuccess, logBullet, promptForDirectory, envOverride, logWarning } from "../../../utils"; +import { isArray } from "lodash"; +import { + logBullet, + envOverride, + logWarning, + logLabeledSuccess, + logLabeledWarning, + logLabeledBullet, + newUniqueId, +} from "../../../utils"; +import { DataConnectEmulator } from "../../../emulator/dataconnectEmulator"; import { getGlobalDefaultAccount } from "../../../auth"; -import { Options } from "../../../options"; +import { createNextApp, createReactApp } from "./create_app"; +import { trackGA4 } from "../../../track"; +import { dirExistsSync, listFiles } from "../../../fsutils"; export const FDC_APP_FOLDER = "FDC_APP_FOLDER"; export const FDC_SDK_FRAMEWORKS_ENV = "FDC_SDK_FRAMEWORKS"; export const FDC_SDK_PLATFORM_ENV = "FDC_SDK_PLATFORM"; +export interface SdkRequiredInfo { + apps: App[]; +} + export type SDKInfo = { connectorYamlContents: string; connectorInfo: ConnectorInfo; displayIOSWarning: boolean; }; -export async function doSetup(setup: Setup, config: Config, options: Options): Promise { - const sdkInfo = await askQuestions(setup, config, options); - await actuate(sdkInfo, config); - logSuccess( - `If you'd like to add more generated SDKs to your app your later, run ${clc.bold("firebase init dataconnect:sdk")} again`, - ); -} -async function askQuestions(setup: Setup, config: Config, options: Options): Promise { - const serviceInfos = await loadAll(setup.projectId || "", config); - const connectorChoices: connectorChoice[] = serviceInfos - .map((si) => { - return si.connectorInfo.map((ci) => { - return { - name: `${si.dataConnectYaml.location}/${si.dataConnectYaml.serviceId}/${ci.connectorYaml.connectorId}`, - value: ci, - }; - }); - }) - .flat(); - if (!connectorChoices.length) { - throw new FirebaseError( - `Your config has no connectors to set up SDKs for. Run ${clc.bold( - "firebase init dataconnect", - )} to set up a service and connectors.`, - ); +export async function askQuestions(setup: Setup): Promise { + const info: SdkRequiredInfo = { + apps: [], + }; + + info.apps = await chooseApp(); + if (!info.apps.length) { + // By default, create an React web app. + const existingFilesAndDirs = listFiles(cwd); + const webAppId = newUniqueId("web-app", existingFilesAndDirs); + const choice = await select({ + message: `Do you want to create an app template?`, + choices: [ + // TODO: Create template tailored to FDC. + { name: "React", value: "react" }, + { name: "Next.JS", value: "next" }, + // TODO: Add flutter here. + { name: "no", value: "no" }, + ], + }); + switch (choice) { + case "react": + await createReactApp(webAppId); + break; + case "next": + await createNextApp(webAppId); + break; + case "no": + break; + } } - // First, lets check if we are in an app directory - let appDir = process.env[FDC_APP_FOLDER] || process.cwd(); - let targetPlatform = envOverride( - FDC_SDK_PLATFORM_ENV, - (await getPlatformFromFolder(appDir)) || Platform.NONE, - ) as Platform; + setup.featureInfo = setup.featureInfo || {}; + setup.featureInfo.dataconnectSdk = info; +} - if (options.nonInteractive && targetPlatform === Platform.NONE) { - throw new FirebaseError( - `In non-interactive mode, the target platform and app directory must be specified using environment variables if they cannot be automatically detected. -Please set the ${FDC_SDK_PLATFORM_ENV} and ${FDC_APP_FOLDER} environment variables. -For example: -${clc.bold( - `${FDC_SDK_PLATFORM_ENV}=WEB ${FDC_APP_FOLDER}=app-dir ${FDC_SDK_FRAMEWORKS_ENV}=react firebase init dataconnect:sdk --non-interactive`, -)}`, +async function chooseApp(): Promise { + let apps = await detectApps(cwd); + if (apps.length) { + logLabeledSuccess( + "dataconnect", + `Detected existing apps ${apps.map((a) => appDescription(a)).join(", ")}`, + ); + } else { + logLabeledWarning("dataconnect", "No app exists in the current directory."); + } + // Check for environment variables override. + const envAppFolder = envOverride(FDC_APP_FOLDER, ""); + const envPlatform = envOverride(FDC_SDK_PLATFORM_ENV, Platform.NONE) as Platform; + const envFrameworks: Framework[] = envOverride(FDC_SDK_FRAMEWORKS_ENV, "") + .split(",") + .map((f) => f as Framework); + if (envAppFolder && envPlatform !== Platform.NONE) { + // Resolve the relative path to the app directory + const envAppRelDir = path.relative(cwd, path.resolve(cwd, envAppFolder)); + const matchedApps = apps.filter( + (app) => app.directory === envAppRelDir && (!app.platform || app.platform === envPlatform), ); + if (matchedApps.length) { + for (const a of matchedApps) { + a.frameworks = [...(a.frameworks || []), ...envFrameworks]; + } + return matchedApps; + } + return [ + { + platform: envPlatform, + directory: envAppRelDir, + frameworks: envFrameworks, + }, + ]; } - if (targetPlatform === Platform.NONE && !process.env[FDC_APP_FOLDER]?.length) { - // 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? Leave blank to set up a generated SDK in your current directory.", + if (apps.length >= 2) { + const choices = apps.map((a) => { + return { + name: appDescription(a), + value: a, + checked: a.directory === ".", + }; + }); + const pickedApps = await checkbox({ + message: "Which apps do you want to set up Data Connect SDKs in?", + choices, }); - targetPlatform = await getPlatformFromFolder(appDir); + if (!pickedApps.length) { + throw new FirebaseError("Command Aborted. Please choose at least one app."); + } + apps = pickedApps; + } + return apps; +} + +export async function actuate(setup: Setup, config: Config) { + const fdcInfo = setup.featureInfo?.dataconnect; + const sdkInfo = setup.featureInfo?.dataconnectSdk; + if (!sdkInfo) { + throw new Error("Data Connect SDK feature RequiredInfo is not provided"); } - if (targetPlatform === Platform.NONE || targetPlatform === Platform.MULTIPLE) { - if (targetPlatform === Platform.NONE) { - logBullet(`Couldn't automatically detect app your in directory ${appDir}.`); + try { + await actuateWithInfo(setup, config, sdkInfo); + } finally { + let flow = "no_app"; + if (sdkInfo.apps.length) { + const platforms = sdkInfo.apps.map((a) => a.platform.toLowerCase()).sort(); + flow = `${platforms.join("_")}_app`; + } + if (fdcInfo) { + fdcInfo.analyticsFlow += `_${flow}`; } else { - logSuccess(`Detected multiple app platforms in directory ${appDir}`); - // Can only setup one platform at a time, just ask the user + void trackGA4("dataconnect_init", { + project_status: setup.projectId ? (setup.isBillingEnabled ? "blaze" : "spark") : "missing", + flow: `cli_sdk_${flow}`, + }); } - 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 select({ - message: "Which platform do you want to set up a generated SDK for?", - choices: platforms, - }); - } else { - logSuccess(`Detected ${targetPlatform} app in directory ${appDir}`); } +} - const connectorInfo = await chooseExistingConnector(connectorChoices); +async function actuateWithInfo(setup: Setup, config: Config, info: SdkRequiredInfo) { + if (!info.apps.length) { + // If no apps is specified, try to detect it again. + // In `firebase init dataconnect:sdk`, customer may create the app while the command is running. + // The `firebase_init` MCP tool always pass an empty `apps` list, it should setup all apps detected. + info.apps = await detectApps(cwd); + if (!info.apps.length) { + logLabeledBullet("dataconnect", "No apps to setup Data Connect Generated SDKs"); + return; + } + } + const apps = info.apps; + const connectorInfo = await chooseExistingConnector(setup, config); const connectorYaml = JSON.parse(JSON.stringify(connectorInfo.connectorYaml)) as ConnectorYaml; - const newConnectorYaml = await generateSdkYaml( - targetPlatform, - connectorYaml, - connectorInfo.directory, - appDir, - ); - if (targetPlatform === Platform.WEB) { - const unusedFrameworks = SUPPORTED_FRAMEWORKS.filter( - (framework) => !newConnectorYaml!.generate?.javascriptSdk![framework], - ); - if (unusedFrameworks.length > 0) { - let additionalFrameworks: (typeof SUPPORTED_FRAMEWORKS)[number][] = []; - if (options.nonInteractive) { - additionalFrameworks = envOverride(FDC_SDK_FRAMEWORKS_ENV, "") - .split(",") - .filter((f) => f) as (typeof SUPPORTED_FRAMEWORKS)[number][]; - } else { - additionalFrameworks = await checkbox<(typeof SUPPORTED_FRAMEWORKS)[number]>({ - message: - "Which frameworks would you like to generate SDKs for in addition to the TypeScript SDK? Press Enter to skip.\n", - choices: SUPPORTED_FRAMEWORKS.map((frameworkStr) => ({ - value: frameworkStr, - checked: newConnectorYaml?.generate?.javascriptSdk?.[frameworkStr], - })), - }); - } - - for (const framework of additionalFrameworks) { - newConnectorYaml!.generate!.javascriptSdk![framework] = true; - } + for (const app of apps) { + if (!dirExistsSync(app.directory)) { + logLabeledWarning("dataconnect", `App directory ${app.directory} does not exist`); } + addSdkGenerateToConnectorYaml(connectorInfo, connectorYaml, app); } // TODO: Prompt user about adding generated paths to .gitignore - const connectorYamlContents = yaml.stringify(newConnectorYaml); - connectorInfo.connectorYaml = newConnectorYaml; - const displayIOSWarning = targetPlatform === Platform.IOS; - return { connectorYamlContents, connectorInfo, displayIOSWarning }; + const connectorYamlContents = yaml.stringify(connectorYaml); + connectorInfo.connectorYaml = connectorYaml; + + const connectorYamlPath = `${connectorInfo.directory}/connector.yaml`; + config.writeProjectFile( + path.relative(config.projectDir, connectorYamlPath), + connectorYamlContents, + ); + + logLabeledBullet("dataconnect", `Installing the generated SDKs ...`); + const account = getGlobalDefaultAccount(); + await DataConnectEmulator.generate({ + configDir: connectorInfo.directory, + connectorId: connectorInfo.connectorYaml.connectorId, + account, + }); + + logLabeledSuccess( + "dataconnect", + `Installed generated SDKs for ${clc.bold(apps.map((a) => appDescription(a)).join(", "))}`, + ); + if (apps.some((a) => a.platform === Platform.IOS)) { + logBullet( + clc.bold( + "Please follow the instructions here to add your generated sdk to your XCode project:\n\thttps://firebase.google.com/docs/data-connect/ios-sdk#set-client", + ), + ); + } + if (apps.some((a) => a.frameworks?.includes("react"))) { + logBullet( + "Visit https://firebase.google.com/docs/data-connect/web-sdk#react for more information on how to set up React Generated SDKs for Firebase Data Connect", + ); + } + if (apps.some((a) => a.frameworks?.includes("angular"))) { + logBullet( + "Run `ng add @angular/fire` to install angular sdk dependencies.\nVisit https://github.com/invertase/tanstack-query-firebase/tree/main/packages/angular for more information on how to set up Angular Generated SDKs for Firebase Data Connect", + ); + } } interface connectorChoice { @@ -171,7 +244,23 @@ interface connectorChoice { * `FDC_CONNECTOR` should have the same `//`. * @param choices */ -async function chooseExistingConnector(choices: connectorChoice[]): Promise { +async function chooseExistingConnector(setup: Setup, config: Config): Promise { + const serviceInfos = await loadAll(setup.projectId || "", config); + const choices: connectorChoice[] = serviceInfos + .map((si) => { + return si.connectorInfo.map((ci) => { + return { + name: `${si.dataConnectYaml.location}/${si.dataConnectYaml.serviceId}/${ci.connectorYaml.connectorId}`, + value: ci, + }; + }); + }) + .flat(); + if (!choices.length) { + throw new FirebaseError( + `No Firebase Data Connect workspace found. Run ${clc.bold("firebase init dataconnect")} to set up a service and connector.`, + ); + } if (choices.length === 1) { // Only one connector available, use it. return choices[0].value; @@ -187,125 +276,94 @@ async function chooseExistingConnector(choices: connectorChoice[]): Promise({ - message: "Which connector do you want set up a generated SDK for?", - choices: choices, - }); + logWarning( + `Pick up the first connector ${clc.bold(connectorEnvVar)}. Use FDC_CONNECTOR to override it`, + ); + return choices[0].value; } -export async function generateSdkYaml( - targetPlatform: Platform, +/** add SDK generation configuration to connector.yaml in place */ +export function addSdkGenerateToConnectorYaml( + connectorInfo: ConnectorInfo, connectorYaml: ConnectorYaml, - connectorDir: string, - appDir: string, -): Promise { + app: App, +): void { + const connectorDir = connectorInfo.directory; + const appDir = app.directory; if (!connectorYaml.generate) { connectorYaml.generate = {}; } + const generate = connectorYaml.generate; - if (targetPlatform === Platform.IOS) { - const swiftSdk = { - outputDir: path.relative(connectorDir, path.join(appDir, `dataconnect-generated/swift`)), - package: "DataConnectGenerated", - }; - connectorYaml.generate.swiftSdk = swiftSdk; - } - - if (targetPlatform === Platform.WEB) { - const pkg = `${connectorYaml.connectorId}-connector`; - const packageJsonDir = path.relative(connectorDir, appDir); - const javascriptSdk: JavascriptSDK = { - outputDir: path.relative(connectorDir, path.join(appDir, `dataconnect-generated/js/${pkg}`)), - package: `@dataconnect/generated`, - // If appDir has package.json, Emulator would add Generated JS SDK to `package.json`. - // Otherwise, emulator would ignore it. Always add it here in case `package.json` is added later. - // TODO: Explore other platforms that can be automatically installed. Dart? Android? - packageJsonDir, - }; - const packageJson = await resolvePackageJson(appDir); - if (packageJson) { - const frameworksUsed = getFrameworksFromPackageJson(packageJson); - for (const framework of frameworksUsed) { - logBullet(`Detected ${framework} app. Enabling ${framework} generated SDKs.`); - javascriptSdk[framework] = true; + switch (app.platform) { + case Platform.WEB: { + const javascriptSdk: JavascriptSDK = { + outputDir: path.relative(connectorDir, path.join(appDir, `src/dataconnect-generated`)), + package: `@dataconnect/generated`, + packageJsonDir: path.relative(connectorDir, appDir), + react: false, + angular: false, + }; + for (const f of app.frameworks || []) { + javascriptSdk[f] = true; + } + if (!isArray(generate?.javascriptSdk)) { + generate.javascriptSdk = generate.javascriptSdk ? [generate.javascriptSdk] : []; } + if (!generate.javascriptSdk.some((s) => s.outputDir === javascriptSdk.outputDir)) { + generate.javascriptSdk.push(javascriptSdk); + } + break; } - - connectorYaml.generate.javascriptSdk = javascriptSdk; - } - - if (targetPlatform === Platform.FLUTTER) { - const pkg = `${snakeCase(connectorYaml.connectorId)}_connector`; - const dartSdk: DartSDK = { - outputDir: path.relative( - connectorDir, - path.join(appDir, `dataconnect-generated/dart/${pkg}`), - ), - package: "dataconnect_generated", - }; - connectorYaml.generate.dartSdk = dartSdk; - } - - if (targetPlatform === Platform.ANDROID) { - const kotlinSdk: KotlinSDK = { - outputDir: path.relative(connectorDir, path.join(appDir, `dataconnect-generated/kotlin`)), - package: `com.google.firebase.dataconnect.generated`, - }; - // app/src/main/kotlin and app/src/main/java are conventional for Android, - // but not required or enforced. If one of them is present (preferring the - // "kotlin" directory), use it. Otherwise, fall back to the dataconnect-generated dir. - for (const candidateSubdir of ["app/src/main/java", "app/src/main/kotlin"]) { - const candidateDir = path.join(appDir, candidateSubdir); - if (dirExistsSync(candidateDir)) { - kotlinSdk.outputDir = path.relative(connectorDir, candidateDir); + case Platform.FLUTTER: { + const dartSdk: DartSDK = { + outputDir: path.relative(connectorDir, path.join(appDir, `lib/dataconnect_generated`)), + package: "dataconnect_generated", + }; + if (!isArray(generate?.dartSdk)) { + generate.dartSdk = generate.dartSdk ? [generate.dartSdk] : []; + } + if (!generate.dartSdk.some((s) => s.outputDir === dartSdk.outputDir)) { + generate.dartSdk.push(dartSdk); } + break; } - connectorYaml.generate.kotlinSdk = kotlinSdk; - } - - return connectorYaml; -} - -export async function actuate(sdkInfo: SDKInfo, config: Config) { - const connectorYamlPath = `${sdkInfo.connectorInfo.directory}/connector.yaml`; - logBullet(`Writing your new SDK configuration to ${connectorYamlPath}`); - config.writeProjectFile( - path.relative(config.projectDir, connectorYamlPath), - sdkInfo.connectorYamlContents, - ); - - const account = getGlobalDefaultAccount(); - await DataConnectEmulator.generate({ - configDir: sdkInfo.connectorInfo.directory, - connectorId: sdkInfo.connectorInfo.connectorYaml.connectorId, - account, - }); - logBullet(`Generated SDK code for ${sdkInfo.connectorInfo.connectorYaml.connectorId}`); - if (sdkInfo.connectorInfo.connectorYaml.generate?.swiftSdk && sdkInfo.displayIOSWarning) { - logBullet( - clc.bold( - "Please follow the instructions here to add your generated sdk to your XCode project:\n\thttps://firebase.google.com/docs/data-connect/ios-sdk#set-client", - ), - ); - } - if (sdkInfo.connectorInfo.connectorYaml.generate?.javascriptSdk) { - for (const framework of SUPPORTED_FRAMEWORKS) { - if (sdkInfo.connectorInfo.connectorYaml!.generate!.javascriptSdk![framework]) { - logInfoForFramework(framework); + case Platform.ANDROID: { + const kotlinSdk: KotlinSDK = { + outputDir: path.relative(connectorDir, path.join(appDir, `src/main/kotlin`)), + package: `com.google.firebase.dataconnect.generated`, + }; + if (!isArray(generate?.kotlinSdk)) { + generate.kotlinSdk = generate.kotlinSdk ? [generate.kotlinSdk] : []; } + if (!generate.kotlinSdk.some((s) => s.outputDir === kotlinSdk.outputDir)) { + generate.kotlinSdk.push(kotlinSdk); + } + break; } - } -} - -function logInfoForFramework(framework: keyof SupportedFrameworks) { - if (framework === "react") { - logBullet( - "Visit https://firebase.google.com/docs/data-connect/web-sdk#react for more information on how to set up React Generated SDKs for Firebase Data Connect", - ); - } else if (framework === "angular") { - // TODO(mtewani): Replace this with `ng add @angular/fire` when ready. - logBullet( - "Run `npm i --save @angular/fire @tanstack-query-firebase/angular @tanstack/angular-query-experimental` to install angular sdk dependencies.\nVisit https://github.com/invertase/tanstack-query-firebase/tree/main/packages/angular for more information on how to set up Angular Generated SDKs for Firebase Data Connect", - ); + case Platform.IOS: { + const swiftSdk = { + outputDir: path.relative( + connectorDir, + path.join(app.directory, `../FirebaseDataConnectGenerated`), + ), + package: "DataConnectGenerated", + }; + if (!isArray(generate?.swiftSdk)) { + generate.swiftSdk = generate.swiftSdk ? [generate.swiftSdk] : []; + } + if (!generate.swiftSdk.some((s) => s.outputDir === swiftSdk.outputDir)) { + generate.swiftSdk.push(swiftSdk); + } + break; + } + default: + throw new FirebaseError( + `Unsupported platform ${app.platform} for Data Connect SDK generation. Supported platforms are: ${Object.values( + Platform, + ) + .filter((p) => p !== Platform.NONE && p !== Platform.MULTIPLE) + .join(", ")}\n${JSON.stringify(app)}`, + ); } } diff --git a/src/init/features/index.ts b/src/init/features/index.ts index ddcf206c07d..97acd81cd0b 100644 --- a/src/init/features/index.ts +++ b/src/init/features/index.ts @@ -28,7 +28,11 @@ export { actuate as dataconnectActuate, postSetup as dataconnectPostSetup, } from "./dataconnect"; -export { doSetup as dataconnectSdk } from "./dataconnect/sdk"; +export { + askQuestions as dataconnectSdkAskQuestions, + SdkRequiredInfo as DataconnectSdkInfo, + actuate as dataconnectSdkActuate, +} from "./dataconnect/sdk"; export { doSetup as apphosting } from "./apphosting"; export { doSetup as genkit } from "./genkit"; export { diff --git a/src/init/index.ts b/src/init/index.ts index 51e51b3378b..9948f4e96e5 100644 --- a/src/init/index.ts +++ b/src/init/index.ts @@ -30,6 +30,7 @@ export interface SetupInfo { database?: features.DatabaseInfo; firestore?: features.FirestoreInfo; dataconnect?: features.DataconnectInfo; + dataconnectSdk?: features.DataconnectSdkInfo; storage?: features.StorageInfo; apptesting?: features.ApptestingInfo; } @@ -67,7 +68,11 @@ const featuresList: Feature[] = [ actuate: features.dataconnectActuate, postSetup: features.dataconnectPostSetup, }, - { name: "dataconnect:sdk", doSetup: features.dataconnectSdk }, + { + name: "dataconnect:sdk", + askQuestions: features.dataconnectSdkAskQuestions, + actuate: features.dataconnectSdkActuate, + }, { name: "functions", doSetup: features.functions }, { name: "hosting", doSetup: features.hosting }, { diff --git a/src/management/apps.ts b/src/management/apps.ts index 8acd8455c7e..a053b477134 100644 --- a/src/management/apps.ts +++ b/src/management/apps.ts @@ -13,7 +13,7 @@ import * as prompt from "../prompt"; import { getOrPromptProject } from "./projects"; import { Options } from "../options"; import { Config } from "../config"; -import { getPlatformFromFolder } from "../dataconnect/fileUtils"; +import { getPlatformFromFolder } from "../dataconnect/appFinder"; import { logBullet, logSuccess, logWarning, promptForDirectory } from "../utils"; import { AppsInitOptions } from "../commands/apps-init"; diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index 6cf8b693b25..1d75b1983a4 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -89,7 +89,8 @@ export const init = tool( }) .optional() .describe( - "Provide this object to initialize Firebase Data Connect with Cloud SQL Postgres in this project directory.", + "Provide this object to initialize Firebase Data Connect with Cloud SQL Postgres in this project directory.\n" + + "It installs Data Connect Generated SDKs in all detected apps in the folder.", ), storage: z .object({ @@ -155,6 +156,10 @@ export const init = tool( cloudSqlInstanceId: features.dataconnect.cloudsql_instance_id || "", cloudSqlDatabase: features.dataconnect.cloudsql_database || "", }; + featureInfo.dataconnectSdk = { + // Add FDC generated SDKs to all apps detected. + apps: [], + }; } const setup: Setup = { config: config?.src, diff --git a/src/utils.ts b/src/utils.ts index e8e4aa9d661..fd81616311c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -992,3 +992,17 @@ export function deepEqual(a: any, b: any): boolean { return true; } + +/** + * Returns a unique ID that's either `recommended` or `recommended-{i}`. + * Avoid existing IDs. + */ +export function newUniqueId(recommended: string, existingIDs: string[]): string { + let id = recommended; + let i = 1; + while (existingIDs.includes(id)) { + id = `${recommended}-${i}`; + i++; + } + return id; +}