diff --git a/README.md b/README.md index bdff55d..38d1283 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Firebase configuration CLI for JavaScript and TypeScript projects. -FireConfig automates the repetitive parts of connecting a project to Firebase. It detects the project environment, verifies Firebase CLI authentication, lets you select a Firebase project and Web App, retrieves the Firebase SDK configuration, and generates the Firebase initialization file. +FireConfig automates the repetitive parts of connecting a project to Firebase. It detects the project environment, verifies Firebase CLI authentication, lets you select a Firebase project and Web App, retrieves the Firebase SDK configuration, installs the Firebase SDK when needed, and generates framework-appropriate Firebase configuration. > **Status:** v0.1.0 @@ -18,7 +18,7 @@ Setting up Firebase in a new project typically involves several manual steps: * Finding the corresponding Web App * Retrieving the SDK configuration * Installing the Firebase SDK -* Creating the Firebase initialization code +* Creating the Firebase configuration * Verifying that the configuration is complete FireConfig brings these steps into a single CLI workflow. @@ -34,7 +34,7 @@ Project ├── Find Web App ├── Retrieve SDK configuration ├── Verify Firebase SDK - └── Generate configuration + └── Generate framework-specific configuration ``` ## Features @@ -49,7 +49,8 @@ Project * Firebase Web App discovery * Firebase SDK configuration retrieval * Firebase SDK detection -* Firebase configuration generation +* Automatic Firebase SDK installation +* Framework-specific Firebase configuration generation * Existing configuration protection * Firebase setup diagnostics through `fireconfig doctor` * Automated test suite @@ -57,16 +58,18 @@ Project ## Supported Projects -FireConfig currently detects: +FireConfig currently supports the following project types: -| Framework | Detection | Configuration workflow | -| ------------ | --------- | ---------------------- | -| React | Supported | Supported | -| Next.js | Supported | Planned | -| React Native | Supported | Planned | -| Expo | Supported | Planned | +| Framework | Detection | Configuration workflow | +| ------------------------ | --------- | ---------------------- | +| React | Supported | Supported | +| React + Vite | Supported | Supported | +| React + Create React App | Supported | Supported | +| Next.js | Supported | Supported | +| React Native | Supported | Supported | +| Expo | Supported | Not implemented yet | -The current release focuses on the standard Firebase Web SDK workflow. Framework-specific configuration for Next.js, React Native, and Expo is planned for subsequent releases. +React Native projects are automatically connected to Firebase through the Android Firebase configuration workflow. Expo configuration is planned for a future release. ## Requirements @@ -77,6 +80,8 @@ The current release focuses on the standard Firebase Web SDK workflow. Framework FireConfig can offer to install the Firebase CLI when it is not available. +FireConfig can also install the Firebase JavaScript SDK when it is not already installed. + ## Installation Install FireConfig globally: @@ -112,13 +117,8 @@ The initialization workflow: 9. Finds the project's Web App. 10. Retrieves the Web SDK configuration. 11. Verifies that the Firebase JavaScript SDK is installed. -12. Generates the Firebase configuration file. - -The generated file is: - -```text -src/firebase/config.js -``` +12. Offers to install the Firebase SDK if required. +13. Generates the framework-specific Firebase configuration. ## Commands @@ -143,15 +143,15 @@ Example: ```text FireConfig Doctor -✓ Project: react project detected +✓ Project: react-cra project detected ✓ Package manager: npm detected ✓ Firebase CLI: Firebase CLI 15.30.2 detected ✓ Firebase authentication: Firebase authentication detected ✓ Firebase SDK: Firebase SDK ^12.19.0 detected -✓ Firebase config: Firebase configuration is valid +✓ Firebase config: React CRA Firebase environment configuration is valid 6/6 checks passed. -Firebase setup looks good! +🔥 Firebase setup looks good! ``` ### Display help @@ -166,45 +166,124 @@ fireconfig --help fireconfig --version ``` -## Package Manager Detection +## Framework Configuration -FireConfig identifies the package manager from the project's lockfile. +FireConfig generates configuration according to the detected project type. -| Package manager | Lockfile | -| --------------- | ------------------------- | -| npm | `package-lock.json` | -| pnpm | `pnpm-lock.yaml` | -| Yarn | `yarn.lock` | -| Bun | `bun.lock` or `bun.lockb` | +### React -## Generated Configuration +Standard React projects use: -For a Firebase Web App, FireConfig generates a configuration similar to: +```text +src/firebase/config.js +``` + +Example: ```javascript import { initializeApp } from "firebase/app"; const firebaseConfig = { - apiKey: "...", - authDomain: "...", - projectId: "...", - storageBucket: "...", - messagingSenderId: "...", - appId: "..." + apiKey: "...", + authDomain: "...", + projectId: "...", + storageBucket: "...", + messagingSenderId: "...", + appId: "..." }; export const app = initializeApp(firebaseConfig); ``` -FireConfig only writes the configuration file if it does not already exist. +### React + Vite + +Vite projects use: + +```text +.env.local +``` + +with the `VITE_` prefix: + +```text +VITE_FIREBASE_API_KEY=... +VITE_FIREBASE_AUTH_DOMAIN=... +VITE_FIREBASE_PROJECT_ID=... +VITE_FIREBASE_STORAGE_BUCKET=... +VITE_FIREBASE_MESSAGING_SENDER_ID=... +VITE_FIREBASE_APP_ID=... +``` + +### React + Create React App + +Create React App projects use: + +```text +.env.local +``` + +with the `REACT_APP_` prefix: + +```text +REACT_APP_FIREBASE_API_KEY=... +REACT_APP_FIREBASE_AUTH_DOMAIN=... +REACT_APP_FIREBASE_PROJECT_ID=... +REACT_APP_FIREBASE_STORAGE_BUCKET=... +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=... +REACT_APP_FIREBASE_APP_ID=... +``` + +### Next.js + +Next.js projects use: + +```text +.env.local +``` + +with the `NEXT_PUBLIC_` prefix: + +```text +NEXT_PUBLIC_FIREBASE_API_KEY=... +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=... +NEXT_PUBLIC_FIREBASE_PROJECT_ID=... +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=... +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=... +NEXT_PUBLIC_FIREBASE_APP_ID=... +``` + +## Existing Configuration Protection + +FireConfig never silently overwrites an existing configuration file. + +For example, if: + +```text +.env.local +``` + +already exists for a framework that uses environment configuration, FireConfig will stop instead of replacing the existing file. -If: +Likewise, for standard React projects, FireConfig will not overwrite: ```text src/firebase/config.js ``` -already exists, FireConfig will not overwrite it. +This protects existing Firebase configuration and prevents accidental data loss. + +## Package Manager Detection + +FireConfig identifies the package manager from the project's lockfile. + +| Package manager | Lockfile | +| --------------- | ------------------------- | +| npm | `package-lock.json` | +| pnpm | `pnpm-lock.yaml` | +| Yarn | `yarn.lock` | +| Bun | `bun.lock` or `bun.lockb` | + +The detected package manager is used when FireConfig needs to install the Firebase SDK or Firebase CLI. ## Security @@ -218,9 +297,16 @@ firebase login FireConfig does not request or store Firebase service-account private keys. -The Firebase Web SDK configuration generated by FireConfig contains client-side configuration values intended for use by web applications. These values should still be protected by appropriate Firebase Security Rules and other application-level security controls. +The Firebase Web SDK configuration generated by FireConfig contains client-side configuration values intended for use by web applications. Firebase Security Rules and appropriate application-level security controls should still be used to protect application data and resources. + +FireConfig also: -FireConfig also avoids silently overwriting existing configuration files. +* Avoids silently overwriting configuration +* Uses explicit process execution +* Avoids unnecessary `shell` execution +* Does not implement custom Firebase OAuth +* Does not request service-account private keys +* Keeps Firebase CLI authentication delegated to the official Firebase tooling ## Development @@ -261,14 +347,23 @@ FireConfig uses Node.js's built-in test runner. The test suite covers: * Project detection +* React detection +* Vite detection +* Create React App detection +* Next.js detection +* React Native detection +* Expo detection * Package manager detection * Firebase CLI detection * Firebase authentication detection * Firebase SDK detection +* Firebase SDK installation * Firebase configuration validation * Firebase configuration generation +* Framework-specific environment generation * Existing configuration protection * Doctor checks +* Integration behavior Run: @@ -283,12 +378,16 @@ All tests are also executed automatically through GitHub Actions on pushes and p ```text fireconfig/ ├── .github/ +│ ├── ISSUE_TEMPLATE/ +│ │ ├── bug_report.md +│ │ └── feature_request.md │ └── workflows/ │ └── test.yml ├── bin/ │ └── index.js ├── src/ │ ├── auth/ +│ ├── config/ │ ├── detectors/ │ ├── doctor/ │ ├── firebase/ @@ -297,12 +396,15 @@ fireconfig/ │ └── ui/ ├── test/ ├── .gitignore +├── CONTRIBUTING.md ├── LICENSE ├── package.json └── README.md ``` -The project is organized around small, independently testable modules. Detection, Firebase operations, configuration generation, installation, authentication, and diagnostics are kept separate from the CLI entry point. +The project is organized around small, independently testable modules. + +Detection, Firebase operations, configuration generation, installation, authentication, and diagnostics are kept separate from the CLI entry point. ## Roadmap @@ -318,23 +420,35 @@ The project is organized around small, independently testable modules. Detection * [x] Firebase Web App discovery * [x] SDK configuration retrieval * [x] Firebase SDK detection -* [x] Configuration generation +* [x] Firebase SDK installation +* [x] React configuration generation * [x] Existing configuration protection * [x] `fireconfig doctor` * [x] Automated tests * [x] GitHub Actions CI * [x] npm package preparation - -### Planned - -* [ ] Next.js environment configuration -* [ ] Environment variable generation -* [ ] React Native configuration +* [x] Next.js configuration strategy +* [x] Next.js environment generation +* [x] React + Vite environment generation +* [x] React + Create React App environment generation +* [x] Framework-specific doctor checks +* [x] Firebase SDK installation tests +* [x] CRA integration testing + +### v0.2.x + +* [x] Next.js environment configuration +* [x] React environment strategy +* [x] React + Vite environment configuration +* [x] React + Create React App environment configuration +* [x] Framework-specific diagnostics +* [x] Expanded integration coverage +* [x] React Native configuration * [ ] Expo configuration -* [ ] Framework-specific Firebase setup +* [ ] Framework-specific Firebase setup improvements * [ ] Additional Firebase service configuration * [ ] Expanded diagnostics -* [ ] Integration test coverage +* [ ] Documentation and examples for additional frameworks ## Contributing @@ -357,6 +471,8 @@ npm test Please keep changes focused and include tests for new functionality where appropriate. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines. + ## License FireConfig is released under the MIT License. diff --git a/bin/index.js b/bin/index.js index d2f39ea..b05fb8f 100644 --- a/bin/index.js +++ b/bin/index.js @@ -14,10 +14,19 @@ const { selectFirebaseProject } = require("../src/ui/project-selector"); const { detectFirebaseSDK } = require("../src/detectors/firebase-sdk"); const { getFirebaseWebApps } = require("../src/firebase/apps"); const { getFirebaseSDKConfig } = require("../src/firebase/sdk-config"); -const { - generateFirebaseConfig, -} = require("../src/generators/firebase-config"); +const { installFirebaseSDK } = require("../src/installers/firebase-sdk"); +const { generateFirebaseConfig } = require("../src/generators/firebase-config"); +const { generateNextjsEnv } = require("../src/generators/nextjs-env"); +const { generateReactViteEnv } = require("../src/generators/react-vite-env"); +const { generateReactCraEnv } = require("../src/generators/react-cra-env"); +const { getConfigurationStrategy } = require("../src/config/strategy"); +const { ensureFirebaseSDK } = require("../src/installers/ensure-firebase-sdk"); const { runDoctor } = require("../src/doctor"); +const { detectReactNativeAndroid } = require("../src/detectors/react-native-android"); +const { ensureReactNativeFirebase } = require("../src/installers/ensure-react-native-firebase"); +const { ensureAndroidFirebaseApp } = require("../src/firebase/android-app"); +const { getFirebaseAndroidSDKConfig } = require("../src/firebase/android-sdk-config"); +const { generateReactNativeAndroidConfig } = require("../src/generators/react-native-android"); const args = process.argv.slice(2); const command = args[0]; @@ -109,7 +118,6 @@ async function init() { return false; } - // Re-detect after installation. firebaseCLI = detectFirebaseCLI(); if (!firebaseCLI.installed) { @@ -216,95 +224,183 @@ async function init() { // 7. Find Firebase Web App // -------------------------------------------------- - console.log("\nChecking Firebase Web Apps..."); - const webApps = await getFirebaseWebApps( - selectedProject.projectId - ); + if (project.type !== "react-native") { - if (!webApps.success) { - console.log( - "\n❌ Unable to retrieve Firebase Web Apps." + console.log("\nChecking Firebase Web Apps..."); + + const webApps = await getFirebaseWebApps( + selectedProject.projectId ); - if (webApps.error) { - console.log(` ${webApps.error}`); + if (!webApps.success) { + console.log( + "\n❌ Unable to retrieve Firebase Web Apps." + ); + + if (webApps.error) { + console.log(` ${webApps.error}`); + } + + return false; } - return false; - } + if (webApps.apps.length === 0) { + console.log("\n⚠ No Firebase Web App found."); + console.log( + "Create a Web App in the Firebase Console and run FireConfig again." + ); + + return false; + } - if (webApps.apps.length === 0) { - console.log("\n⚠ No Firebase Web App found."); console.log( - "Create a Web App in the Firebase Console and run FireConfig again." + `✓ Web App found: ${webApps.apps[0].displayName}` ); - return false; - } + const selectedWebApp = webApps.apps[0]; + console.log( + "✓ Firebase configuration retrieved" + ); - console.log( - `✓ Web App found: ${webApps.apps[0].displayName}` - ); - const selectedWebApp = webApps.apps[0]; - // -------------------------------------------------- - // 8. Download Firebase SDK configuration - // -------------------------------------------------- + // -------------------------------------------------- + // 8. Download Firebase SDK configuration + // -------------------------------------------------- - console.log( - "\nDownloading Firebase configuration..." - ); + console.log( + "\nDownloading Firebase configuration..." + ); - const sdkResult = await getFirebaseSDKConfig( - selectedProject.projectId, - selectedWebApp.appId - ); + const sdkResult = await getFirebaseSDKConfig( + selectedProject.projectId, + selectedWebApp.appId + ); + + if (!sdkResult.success) { + console.log( + "\n❌ Unable to retrieve Firebase configuration." + ); + + if (sdkResult.error) { + console.log(` ${sdkResult.error}`); + } + + return false; + } - if (!sdkResult.success) { console.log( - "\n❌ Unable to retrieve Firebase configuration." + "✓ Firebase configuration retrieved" ); + } - if (sdkResult.error) { - console.log(` ${sdkResult.error}`); - } + // -------------------------------------------------- + // 9. Check Firebase SDK + // -------------------------------------------------- + + + let firebaseSDK; + + if (project.type === "react-native") { + firebaseSDK = + await ensureReactNativeFirebase( + process.cwd(), + packageManager + ); + } else { + firebaseSDK = + await ensureFirebaseSDK( + process.cwd(), + packageManager + ); + } + if (!firebaseSDK.success) { return false; } console.log( - "✓ Firebase configuration retrieved" + `✓ Firebase SDK ${firebaseSDK.version} detected` ); - // -------------------------------------------------- - // 9. Check Firebase SDK - // -------------------------------------------------- + if (project.type === "react-native") { + const android = + detectReactNativeAndroid( + process.cwd() + ); - const firebaseSDK = detectFirebaseSDK( - process.cwd() - ); + if (!android.detected) { + console.log( + "\n❌ Unable to detect the React Native Android application ID." + ); + + console.log( + "Make sure android/app/build.gradle contains an applicationId.\n" + ); + + return false; + } - if (!firebaseSDK.installed) { console.log( - "\n⚠ Firebase SDK is not installed." + `✓ Android application ID detected: ${android.applicationId}` ); + const firebaseApp = + ensureAndroidFirebaseApp( + selectedProject.projectId, + android.applicationId, + selectedProject.displayName || android.applicationId + ); + console.log( - `Run:\n\n ${packageManager === "npm" - ? "npm install firebase" - : `${packageManager} add firebase` - }\n` + firebaseApp.created + ? "✓ Firebase Android app created" + : "✓ Existing Firebase Android app found" ); - return false; - } + const sdkConfig = + getFirebaseAndroidSDKConfig( + selectedProject.projectId, + firebaseApp.appId + ); - console.log( - `✓ Firebase SDK ${firebaseSDK.version} detected` - ); + if (!sdkConfig.success) { + console.log( + "\n❌ Unable to retrieve Firebase Android configuration.\n" + ); + + return false; + } + const generated = + generateReactNativeAndroidConfig( + process.cwd(), + sdkConfig.content + ); + + if (!generated.success) { + if (generated.exists) { + console.log( + `\n⚠ Firebase Android configuration already exists:\n ${generated.path}\n` + ); + + return false; + } + + console.log( + `\n❌ ${generated.error || "Unable to generate Firebase Android configuration."}\n` + ); + + return false; + } + + console.log( + `✓ Firebase Android configuration generated:\n ${generated.path}` + ); + + return true; + } // -------------------------------------------------- // 10. Generate configuration // -------------------------------------------------- @@ -313,11 +409,59 @@ async function init() { "\nGenerating Firebase configuration..." ); - const generated = generateFirebaseConfig( - process.cwd(), - sdkResult.config + const strategy = getConfigurationStrategy( + project.type ); + if (!strategy) { + console.log( + "\n✖ No Firebase configuration strategy is available for this project." + ); + + return false; + } + + let generated; + + switch (strategy.generator) { + case "firebase-config": + generated = generateFirebaseConfig( + process.cwd(), + sdkResult.config + ); + break; + + case "nextjs-env": + generated = generateNextjsEnv( + process.cwd(), + sdkResult.config + ); + break; + + case "react-vite-env": + generated = generateReactViteEnv( + process.cwd(), + sdkResult.config + ); + break; + + case "react-cra-env": + generated = generateReactCraEnv( + process.cwd(), + sdkResult.config + ); + break; + + default: + console.log( + `\n⚠ Firebase configuration for ${getProjectLabel( + project.type + )} is not implemented yet.` + ); + + return false; + } + if (!generated.success && generated.exists) { console.log( "\n⚠ Firebase configuration already exists." @@ -362,6 +506,8 @@ async function init() { function getProjectLabel(type) { const labels = { react: "React", + "react-vite": "React + Vite", + "react-cra": "React + Create React App", nextjs: "Next.js", "react-native": "React Native", expo: "Expo", diff --git a/package-lock.json b/package-lock.json index 8349c27..e7b8a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fireconfig", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fireconfig", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.7.2" diff --git a/package.json b/package.json index 545639d..04bf9a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fireconfigcli", - "version": "0.1.0", + "version": "0.2.0", "description": "Firebase configuration CLI for React, Next.js, React Native and Expo projects", "keywords": [ "firebase", diff --git a/src/config/strategy.js b/src/config/strategy.js new file mode 100644 index 0000000..2070791 --- /dev/null +++ b/src/config/strategy.js @@ -0,0 +1,39 @@ +function getConfigurationStrategy(projectType) { + const strategies = { + react: { + type: "firebase-config", + generator: "firebase-config", + }, + + "react-vite": { + type: "environment", + generator: "react-vite-env", + }, + + "react-cra": { + type: "environment", + generator: "react-cra-env", + }, + + nextjs: { + type: "environment", + generator: "nextjs-env", + }, + + "react-native": { + type: "react-native-android", + generator: "react-native-android", + }, + + expo: { + type: "not-implemented", + generator: null, + }, + }; + + return strategies[projectType] || null; +} + +module.exports = { + getConfigurationStrategy, +}; \ No newline at end of file diff --git a/src/detectors/project.js b/src/detectors/project.js index 0ea9080..baa021c 100644 --- a/src/detectors/project.js +++ b/src/detectors/project.js @@ -56,6 +56,20 @@ function detectProject(projectRoot = process.cwd()) { } if (dependencies.react) { + if (dependencies.vite) { + return { + type: "react-vite", + name: packageJson.name || null, + }; + } + + if (dependencies["react-scripts"]) { + return { + type: "react-cra", + name: packageJson.name || null, + }; + } + return { type: "react", name: packageJson.name || null, diff --git a/src/detectors/react-native-android.js b/src/detectors/react-native-android.js new file mode 100644 index 0000000..ed1619c --- /dev/null +++ b/src/detectors/react-native-android.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const path = require("path"); + +function detectReactNativeAndroid(projectRoot) { + const gradleFile = path.join( + projectRoot, + "android", + "app", + "build.gradle" + ); + + if (!fs.existsSync(gradleFile)) { + return { + detected: false, + applicationId: null, + path: gradleFile, + }; + } + + const content = fs.readFileSync( + gradleFile, + "utf8" + ); + + const applicationIdMatch = content.match( + /applicationId\s+["']([^"']+)["']/ + ); + + if (!applicationIdMatch) { + return { + detected: false, + applicationId: null, + path: gradleFile, + }; + } + + return { + detected: true, + applicationId: applicationIdMatch[1], + path: gradleFile, + }; +} + +module.exports = { + detectReactNativeAndroid, +}; \ No newline at end of file diff --git a/src/detectors/react-native-firebase.js b/src/detectors/react-native-firebase.js new file mode 100644 index 0000000..972f64b --- /dev/null +++ b/src/detectors/react-native-firebase.js @@ -0,0 +1,53 @@ +const fs = require("fs"); +const path = require("path"); + +function detectReactNativeFirebase( + projectRoot +) { + const packageJsonPath = + path.join( + projectRoot, + "package.json" + ); + + if (!fs.existsSync(packageJsonPath)) { + return { + installed: false, + version: null, + }; + } + + const packageJson = + JSON.parse( + fs.readFileSync( + packageJsonPath, + "utf8" + ) + ); + + const dependencies = { + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}), + }; + + const version = + dependencies[ + "@react-native-firebase/app" + ]; + + if (!version) { + return { + installed: false, + version: null, + }; + } + + return { + installed: true, + version, + }; +} + +module.exports = { + detectReactNativeFirebase, +}; \ No newline at end of file diff --git a/src/doctor/checks.js b/src/doctor/checks.js index 16273d6..431360f 100644 --- a/src/doctor/checks.js +++ b/src/doctor/checks.js @@ -3,9 +3,12 @@ const { detectPackageManager } = require("../detectors/package-manager"); const { detectFirebaseCLI } = require("../detectors/firebase-cli"); const { checkFirebaseAuth } = require("../detectors/firebase-auth"); const { detectFirebaseSDK } = require("../detectors/firebase-sdk"); -const { - checkFirebaseConfigFile, -} = require("./firebase-config"); +const { checkFirebaseConfigFile } = require("./firebase-config"); +const { checkNextjsFirebaseEnv } = require("./nextjs-env"); +const { checkReactCraFirebaseEnv } = require("./react-cra-env"); +const { detectReactNativeFirebase, } = require("../detectors/react-native-firebase"); +const { checkReactNativeFirebaseConfig, +} = require("./react-native"); function checkProject(projectRoot) { const project = detectProject(projectRoot); @@ -67,7 +70,12 @@ function checkFirebaseAuthentication( } function checkFirebaseSDK(projectRoot) { - const sdk = detectFirebaseSDK(projectRoot); + const project = detectProject(projectRoot); + + const sdk = + project.type === "react-native" + ? detectReactNativeFirebase(projectRoot) + : detectFirebaseSDK(projectRoot); return { name: "Firebase SDK", @@ -80,8 +88,41 @@ function checkFirebaseSDK(projectRoot) { } function checkFirebaseConfig(projectRoot) { - const result = - checkFirebaseConfigFile(projectRoot); + const project = detectProject(projectRoot); + + let result; + + switch (project.type) { + case "nextjs": + result = + checkNextjsFirebaseEnv(projectRoot); + break; + + case "react": + result = + checkFirebaseConfigFile(projectRoot); + break; + case "react-cra": + result = + checkReactCraFirebaseEnv(projectRoot); + break; + case "react-native": + result = + checkReactNativeFirebaseConfig( + projectRoot + ); + break; + default: + return { + name: "Firebase config", + passed: false, + message: + "Firebase configuration strategy not implemented", + details: { + projectType: project.type, + }, + }; + } return { name: "Firebase config", diff --git a/src/doctor/nextjs-env.js b/src/doctor/nextjs-env.js new file mode 100644 index 0000000..f339522 --- /dev/null +++ b/src/doctor/nextjs-env.js @@ -0,0 +1,61 @@ +const fs = require("fs"); +const path = require("path"); + +const REQUIRED_VARIABLES = [ + "NEXT_PUBLIC_FIREBASE_API_KEY", + "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID", + "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET", + "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID", + "NEXT_PUBLIC_FIREBASE_APP_ID", +]; + +function checkNextjsFirebaseEnv(projectRoot) { + const envPath = path.join( + projectRoot, + ".env.local" + ); + + if (!fs.existsSync(envPath)) { + return { + passed: false, + message: "Next.js Firebase environment configuration not found", + missing: REQUIRED_VARIABLES, + }; + } + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + const missing = REQUIRED_VARIABLES.filter( + (variable) => { + const pattern = new RegExp( + `^${variable}=.+$`, + "m" + ); + + return !pattern.test(content); + } + ); + + if (missing.length > 0) { + return { + passed: false, + message: + "Next.js Firebase environment configuration is incomplete", + missing, + }; + } + + return { + passed: true, + message: + "Next.js Firebase environment configuration is valid", + }; +} + +module.exports = { + checkNextjsFirebaseEnv, +}; \ No newline at end of file diff --git a/src/doctor/react-cra-env.js b/src/doctor/react-cra-env.js new file mode 100644 index 0000000..7a6d8eb --- /dev/null +++ b/src/doctor/react-cra-env.js @@ -0,0 +1,69 @@ +const fs = require("fs"); +const path = require("path"); + +const REQUIRED_VARIABLES = [ + "REACT_APP_FIREBASE_API_KEY", + "REACT_APP_FIREBASE_AUTH_DOMAIN", + "REACT_APP_FIREBASE_PROJECT_ID", + "REACT_APP_FIREBASE_STORAGE_BUCKET", + "REACT_APP_FIREBASE_MESSAGING_SENDER_ID", + "REACT_APP_FIREBASE_APP_ID", +]; + +function checkReactCraFirebaseEnv(projectRoot) { + const envFile = path.join( + projectRoot, + ".env.local" + ); + + if (!fs.existsSync(envFile)) { + return { + valid: false, + passed: false, + exists: false, + missing: REQUIRED_VARIABLES, + message: + "React CRA Firebase environment configuration not found", + }; + } + + const content = fs.readFileSync( + envFile, + "utf8" + ); + + const missing = REQUIRED_VARIABLES.filter( + (variable) => { + const pattern = new RegExp( + `^${variable}=.+$`, + "m" + ); + + return !pattern.test(content); + } + ); + + if (missing.length > 0) { + return { + valid: false, + passed: false, + exists: true, + missing, + message: + "React CRA Firebase environment configuration is incomplete", + }; + } + + return { + valid: true, + passed: true, + exists: true, + missing: [], + message: + "React CRA Firebase environment configuration is valid", + }; +} + +module.exports = { + checkReactCraFirebaseEnv, +}; \ No newline at end of file diff --git a/src/doctor/react-native.js b/src/doctor/react-native.js new file mode 100644 index 0000000..2569340 --- /dev/null +++ b/src/doctor/react-native.js @@ -0,0 +1,69 @@ +const fs = require("fs"); +const path = require("path"); + +function checkReactNativeFirebaseConfig( + projectRoot +) { + const configFile = path.join( + projectRoot, + "android", + "app", + "google-services.json" + ); + + if (!fs.existsSync(configFile)) { + return { + passed: false, + message: + "React Native Firebase configuration not found", + path: configFile, + }; + } + + try { + const config = JSON.parse( + fs.readFileSync( + configFile, + "utf8" + ) + ); + + const projectId = + config.project_info?.project_id; + + const packageName = + config.client?.[0] + ?.client_info + ?.android_client_info + ?.package_name; + + if (!projectId || !packageName) { + return { + passed: false, + message: + "React Native Firebase configuration is incomplete", + path: configFile, + }; + } + + return { + passed: true, + message: + "React Native Firebase Android configuration is valid", + path: configFile, + projectId, + packageName, + }; + } catch { + return { + passed: false, + message: + "React Native Firebase configuration is invalid", + path: configFile, + }; + } +} + +module.exports = { + checkReactNativeFirebaseConfig, +}; \ No newline at end of file diff --git a/src/firebase/android-app.js b/src/firebase/android-app.js new file mode 100644 index 0000000..546697e --- /dev/null +++ b/src/firebase/android-app.js @@ -0,0 +1,143 @@ +const { spawnSync } = require("child_process"); + +function runFirebaseCommand(args) { + if (process.platform === "win32") { + return spawnSync( + process.env.ComSpec || "cmd.exe", + ["/d", "/s", "/c", "firebase", ...args], + { + encoding: "utf8", + windowsHide: false, + } + ); + } + + return spawnSync( + "firebase", + args, + { + encoding: "utf8", + } + ); +} + +function getAndroidApps(projectId) { + const result = runFirebaseCommand([ + "apps:list", + "ANDROID", + "--project", + projectId, + "--json", + ]); + + if (result.error) { + throw result.error; + } + + const output = result.stdout || ""; + + try { + const parsed = JSON.parse(output); + + return parsed.result || []; + } catch { + throw new Error( + "Unable to parse Firebase Android apps response." + ); + } +} + +function findAndroidApp( + projectId, + applicationId +) { + const apps = + getAndroidApps(projectId); + + return apps.find( + (app) => + app.packageName === + applicationId + ) || null; +} + +function createAndroidApp( + projectId, + applicationId, + displayName +) { + const result = runFirebaseCommand([ + "apps:create", + "ANDROID", + displayName, + "--package-name", + applicationId, + "--project", + projectId, + ]); + + if (result.error) { + throw result.error; + } + + const output = + `${result.stdout || ""}\n${result.stderr || ""}`; + + const appIdMatch = + output.match( + /App ID:\s*([^\s]+)/i + ); + + if (!appIdMatch) { + throw new Error( + "Firebase Android app was created, but its App ID could not be determined." + ); + } + + return { + appId: appIdMatch[1], + applicationId, + displayName, + }; +} + +function ensureAndroidFirebaseApp( + projectId, + applicationId, + displayName = applicationId +) { + const existing = + findAndroidApp( + projectId, + applicationId + ); + + if (existing) { + return { + created: false, + appId: + existing.appId || + existing.appIdString, + applicationId: + existing.packageName, + displayName: + existing.displayName, + }; + } + + return { + created: true, + ...createAndroidApp( + projectId, + applicationId, + displayName + ), + }; +} + +module.exports = { + getAndroidApps, + findAndroidApp, + createAndroidApp, + ensureAndroidFirebaseApp, +}; \ No newline at end of file diff --git a/src/firebase/android-sdk-config.js b/src/firebase/android-sdk-config.js new file mode 100644 index 0000000..2e9bf11 --- /dev/null +++ b/src/firebase/android-sdk-config.js @@ -0,0 +1,99 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +function runFirebaseCommand(args) { + if (process.platform === "win32") { + return spawnSync( + process.env.ComSpec || "cmd.exe", + ["/d", "/s", "/c", "firebase", ...args], + { + encoding: "utf8", + windowsHide: false, + } + ); + } + + return spawnSync( + "firebase", + args, + { + encoding: "utf8", + } + ); +} + +function getFirebaseAndroidSDKConfig( + projectId, + appId, + { + runCommandFn = runFirebaseCommand, + } = {} +) { + const tempDir = + fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-firebase-" + ) + ); + + const configPath = path.join( + tempDir, + "google-services.json" + ); + + try { + const result = + runCommandFn([ + "apps:sdkconfig", + "ANDROID", + appId, + "--out", + configPath, + "--project", + projectId, + ]); + + if (result.error) { + throw result.error; + } + + if (!fs.existsSync(configPath)) { + throw new Error( + "Firebase Android SDK configuration was not generated." + ); + } + + const content = + fs.readFileSync( + configPath, + "utf8" + ); + + if (!content.trim()) { + throw new Error( + "Firebase Android SDK configuration is empty." + ); + } + + return { + success: true, + content, + path: configPath, + }; + } finally { + fs.rmSync( + tempDir, + { + recursive: true, + force: true, + } + ); + } +} + +module.exports = { + getFirebaseAndroidSDKConfig, +}; \ No newline at end of file diff --git a/src/generators/nextjs-env.js b/src/generators/nextjs-env.js new file mode 100644 index 0000000..e904c7f --- /dev/null +++ b/src/generators/nextjs-env.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const path = require("path"); + +function generateNextjsEnv(projectRoot, config) { + const envFile = path.join( + projectRoot, + ".env.local" + ); + + const content = [ + `NEXT_PUBLIC_FIREBASE_API_KEY=${config.apiKey}`, + `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${config.authDomain}`, + `NEXT_PUBLIC_FIREBASE_PROJECT_ID=${config.projectId}`, + `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${config.storageBucket}`, + `NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${config.messagingSenderId}`, + `NEXT_PUBLIC_FIREBASE_APP_ID=${config.appId}`, + "", + ].join("\n"); + + try { + fs.writeFileSync(envFile, content, { + encoding: "utf8", + flag: "wx", + }); + } catch (error) { + if (error.code === "EEXIST") { + return { + success: false, + exists: true, + path: envFile, + }; + } + + throw error; + } + + return { + success: true, + exists: false, + path: envFile, + }; +} + +module.exports = { + generateNextjsEnv, +}; \ No newline at end of file diff --git a/src/generators/react-cra-env.js b/src/generators/react-cra-env.js new file mode 100644 index 0000000..dccc94e --- /dev/null +++ b/src/generators/react-cra-env.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const path = require("path"); + +function generateReactCraEnv(projectRoot, config) { + const envFile = path.join( + projectRoot, + ".env.local" + ); + + const content = [ + `REACT_APP_FIREBASE_API_KEY=${config.apiKey}`, + `REACT_APP_FIREBASE_AUTH_DOMAIN=${config.authDomain}`, + `REACT_APP_FIREBASE_PROJECT_ID=${config.projectId}`, + `REACT_APP_FIREBASE_STORAGE_BUCKET=${config.storageBucket}`, + `REACT_APP_FIREBASE_MESSAGING_SENDER_ID=${config.messagingSenderId}`, + `REACT_APP_FIREBASE_APP_ID=${config.appId}`, + "", + ].join("\n"); + + try { + fs.writeFileSync(envFile, content, { + encoding: "utf8", + flag: "wx", + }); + } catch (error) { + if (error.code === "EEXIST") { + return { + success: false, + exists: true, + path: envFile, + }; + } + + throw error; + } + + return { + success: true, + exists: false, + path: envFile, + }; +} + +module.exports = { + generateReactCraEnv, +}; \ No newline at end of file diff --git a/src/generators/react-native-android.js b/src/generators/react-native-android.js new file mode 100644 index 0000000..6385cab --- /dev/null +++ b/src/generators/react-native-android.js @@ -0,0 +1,59 @@ +const fs = require("fs"); +const path = require("path"); + +function generateReactNativeAndroidConfig( + projectRoot, + configContent +) { + const configFile = path.join( + projectRoot, + "android", + "app", + "google-services.json" + ); + + const androidAppDir = path.dirname( + configFile + ); + + if (!fs.existsSync(androidAppDir)) { + return { + success: false, + exists: false, + path: configFile, + error: + "React Native Android project directory not found.", + }; + } + + try { + fs.writeFileSync( + configFile, + configContent, + { + encoding: "utf8", + flag: "wx", + } + ); + } catch (error) { + if (error.code === "EEXIST") { + return { + success: false, + exists: true, + path: configFile, + }; + } + + throw error; + } + + return { + success: true, + exists: false, + path: configFile, + }; +} + +module.exports = { + generateReactNativeAndroidConfig, +}; \ No newline at end of file diff --git a/src/generators/react-vite-env.js b/src/generators/react-vite-env.js new file mode 100644 index 0000000..5813135 --- /dev/null +++ b/src/generators/react-vite-env.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const path = require("path"); + +function generateReactViteEnv(projectRoot, config) { + const envFile = path.join( + projectRoot, + ".env.local" + ); + + const content = [ + `VITE_FIREBASE_API_KEY=${config.apiKey}`, + `VITE_FIREBASE_AUTH_DOMAIN=${config.authDomain}`, + `VITE_FIREBASE_PROJECT_ID=${config.projectId}`, + `VITE_FIREBASE_STORAGE_BUCKET=${config.storageBucket}`, + `VITE_FIREBASE_MESSAGING_SENDER_ID=${config.messagingSenderId}`, + `VITE_FIREBASE_APP_ID=${config.appId}`, + "", + ].join("\n"); + + try { + fs.writeFileSync(envFile, content, { + encoding: "utf8", + flag: "wx", + }); + } catch (error) { + if (error.code === "EEXIST") { + return { + success: false, + exists: true, + path: envFile, + }; + } + + throw error; + } + + return { + success: true, + exists: false, + path: envFile, + }; +} + +module.exports = { + generateReactViteEnv, +}; \ No newline at end of file diff --git a/src/installers/ensure-firebase-sdk.js b/src/installers/ensure-firebase-sdk.js new file mode 100644 index 0000000..b58db60 --- /dev/null +++ b/src/installers/ensure-firebase-sdk.js @@ -0,0 +1,53 @@ +const { detectFirebaseSDK } = require("../detectors/firebase-sdk"); +const { installFirebaseSDK } = require("./firebase-sdk"); + +async function ensureFirebaseSDK( + projectRoot, + packageManager, + { + detectFn = detectFirebaseSDK, + installFn = installFirebaseSDK, + } = {} +) { + const firebaseSDK = detectFn(projectRoot); + + if (firebaseSDK.installed) { + return { + success: true, + installed: true, + version: firebaseSDK.version, + }; + } + + const installed = await installFn( + packageManager + ); + + if (!installed) { + return { + success: false, + installed: false, + }; + } + + const updatedFirebaseSDK = detectFn( + projectRoot + ); + + if (!updatedFirebaseSDK.installed) { + return { + success: false, + installed: false, + }; + } + + return { + success: true, + installed: true, + version: updatedFirebaseSDK.version, + }; +} + +module.exports = { + ensureFirebaseSDK, +}; \ No newline at end of file diff --git a/src/installers/ensure-react-native-firebase.js b/src/installers/ensure-react-native-firebase.js new file mode 100644 index 0000000..713c26e --- /dev/null +++ b/src/installers/ensure-react-native-firebase.js @@ -0,0 +1,62 @@ +const { + detectReactNativeFirebase, +} = require("../detectors/react-native-firebase"); + +const { + installReactNativeFirebase, +} = require("./react-native-firebase"); + +async function ensureReactNativeFirebase( + projectRoot, + packageManager, + { + detectFn = + detectReactNativeFirebase, + installFn = + installReactNativeFirebase, + } = {} +) { + const firebase = + detectFn(projectRoot); + + if (firebase.installed) { + return { + success: true, + installed: true, + version: firebase.version, + }; + } + + const installed = + await installFn( + packageManager + ); + + if (!installed) { + return { + success: false, + installed: false, + }; + } + + const updatedFirebase = + detectFn(projectRoot); + + if (!updatedFirebase.installed) { + return { + success: false, + installed: false, + }; + } + + return { + success: true, + installed: true, + version: + updatedFirebase.version, + }; +} + +module.exports = { + ensureReactNativeFirebase, +}; \ No newline at end of file diff --git a/src/installers/firebase-sdk.js b/src/installers/firebase-sdk.js new file mode 100644 index 0000000..7919bcb --- /dev/null +++ b/src/installers/firebase-sdk.js @@ -0,0 +1,139 @@ +const { spawnSync } = require("child_process"); +const { confirm } = require("@inquirer/prompts"); + +function runCommand(command, args) { + if (process.platform === "win32") { + return spawnSync( + process.env.ComSpec || "cmd.exe", + ["/d", "/s", "/c", command, ...args], + { + stdio: "inherit", + windowsHide: false, + } + ); + } + + return spawnSync(command, args, { + stdio: "inherit", + }); +} + +function getInstallCommand(packageManager) { + switch (packageManager) { + case "npm": + return ["npm", ["install", "firebase"]]; + + case "pnpm": + return ["pnpm", ["add", "firebase"]]; + + case "yarn": + return ["yarn", ["add", "firebase"]]; + + case "bun": + return ["bun", ["add", "firebase"]]; + + default: + return null; + } +} + +async function installFirebaseSDK( + packageManager, + { + confirmFn = confirm, + runCommandFn = runCommand, + } = {} +) { + const installCommand = + getInstallCommand(packageManager); + + if (!installCommand) { + console.log( + "\n❌ Unable to determine how to install Firebase SDK." + ); + + console.log( + "Please install it manually:" + ); + + console.log( + " npm install firebase\n" + ); + + return false; + } + + const [command, args] = installCommand; + + const shouldInstall = await confirmFn({ + message: + "Firebase SDK is not installed. Would you like FireConfig to install it?", + default: true, + }); + + if (!shouldInstall) { + console.log( + "\nSkipping Firebase SDK installation." + ); + + console.log("Run:"); + + console.log( + ` ${packageManager === "npm" + ? "npm install firebase" + : `${packageManager} add firebase` + }` + ); + + console.log("\nThen run:"); + + console.log( + " fireconfig init\n" + ); + + return false; + } + + console.log( + "\n🔥 Installing Firebase SDK...\n" + ); + + const result = runCommandFn( + command, + args + ); + + if (result.error) { + console.log( + "\n❌ Firebase SDK installation failed." + ); + + console.log( + ` ${result.error.message}` + ); + + return false; + } + + if (result.status !== 0) { + console.log( + "\n❌ Firebase SDK installation failed." + ); + + console.log( + ` Exit code: ${result.status}` + ); + + return false; + } + + console.log( + "\n✓ Firebase SDK installation completed." + ); + + return true; +} + +module.exports = { + installFirebaseSDK, +}; \ No newline at end of file diff --git a/src/installers/react-native-firebase.js b/src/installers/react-native-firebase.js new file mode 100644 index 0000000..029ff3c --- /dev/null +++ b/src/installers/react-native-firebase.js @@ -0,0 +1,174 @@ +const { spawnSync } = require("child_process"); +const { confirm } = require("@inquirer/prompts"); + +function runCommand(command, args) { + if (process.platform === "win32") { + return spawnSync( + process.env.ComSpec || "cmd.exe", + ["/d", "/s", "/c", command, ...args], + { + stdio: "inherit", + windowsHide: false, + } + ); + } + + return spawnSync( + command, + args, + { + stdio: "inherit", + } + ); +} + +function getInstallCommand( + packageManager +) { + switch (packageManager) { + case "npm": + return [ + "npm", + [ + "install", + "@react-native-firebase/app", + ], + ]; + + case "pnpm": + return [ + "pnpm", + [ + "add", + "@react-native-firebase/app", + ], + ]; + + case "yarn": + return [ + "yarn", + [ + "add", + "@react-native-firebase/app", + ], + ]; + + case "bun": + return [ + "bun", + [ + "add", + "@react-native-firebase/app", + ], + ]; + + default: + return null; + } +} + +async function installReactNativeFirebase( + packageManager, + { + confirmFn = confirm, + runCommandFn = runCommand, + } = {} +) { + const installCommand = + getInstallCommand( + packageManager + ); + + if (!installCommand) { + console.log( + "\n❌ Unable to determine how to install React Native Firebase." + ); + + console.log( + "Please install it manually:" + ); + + console.log( + " npm install @react-native-firebase/app\n" + ); + + return false; + } + + const [command, args] = + installCommand; + + const shouldInstall = + await confirmFn({ + message: + "React Native Firebase is not installed. Would you like FireConfig to install it?", + default: true, + }); + + if (!shouldInstall) { + console.log( + "\nSkipping React Native Firebase installation." + ); + + console.log("Run:"); + + console.log( + ` ${packageManager === "npm" + ? "npm install @react-native-firebase/app" + : `${packageManager} add @react-native-firebase/app` + }` + ); + + console.log("\nThen run:"); + + console.log( + " fireconfig init\n" + ); + + return false; + } + + console.log( + "\n🔥 Installing React Native Firebase...\n" + ); + + const result = + runCommandFn( + command, + args + ); + + if (result.error) { + console.log( + "\n❌ React Native Firebase installation failed." + ); + + console.log( + ` ${result.error.message}` + ); + + return false; + } + + if (result.status !== 0) { + console.log( + "\n❌ React Native Firebase installation failed." + ); + + console.log( + ` Exit code: ${result.status}` + ); + + return false; + } + + console.log( + "\n✓ React Native Firebase installation completed." + ); + + return true; +} + +module.exports = { + installReactNativeFirebase, +}; \ No newline at end of file diff --git a/test/configuration-strategy.test.js b/test/configuration-strategy.test.js new file mode 100644 index 0000000..af574af --- /dev/null +++ b/test/configuration-strategy.test.js @@ -0,0 +1,74 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + getConfigurationStrategy, +} = require("../src/config/strategy"); + +test("returns Firebase config strategy for React", () => { + const strategy = getConfigurationStrategy("react"); + + assert.deepEqual(strategy, { + type: "firebase-config", + generator: "firebase-config", + }); +}); + +test("returns environment strategy for Next.js", () => { + const strategy = getConfigurationStrategy("nextjs"); + + assert.deepEqual(strategy, { + type: "environment", + generator: "nextjs-env", + }); +}); + +test("returns React Native Android configuration strategy", () => { + assert.deepStrictEqual( + getConfigurationStrategy("react-native"), + { + type: "react-native-android", + generator: "react-native-android", + } + ); +}); +test("marks Expo configuration as not implemented", () => { + const strategy = getConfigurationStrategy("expo"); + + assert.deepEqual(strategy, { + type: "not-implemented", + generator: null, + }); +}); + +test("returns null for unknown project types", () => { + const strategy = getConfigurationStrategy( + "unknown" + ); + + assert.equal(strategy, null); +}); +test("returns environment strategy for React + Vite", () => { + const strategy = getConfigurationStrategy( + "react-vite" + ); + + assert.deepEqual(strategy, { + type: "environment", + generator: "react-vite-env", + }); +}); +test("returns environment strategy for React + Create React App", () => { + const strategy = + getConfigurationStrategy( + "react-cra" + ); + + assert.deepEqual( + strategy, + { + type: "environment", + generator: "react-cra-env", + } + ); +}); \ No newline at end of file diff --git a/test/doctor-checks.behavior.test.js b/test/doctor-checks.behavior.test.js index 90dd764..3475fbb 100644 --- a/test/doctor-checks.behavior.test.js +++ b/test/doctor-checks.behavior.test.js @@ -63,6 +63,177 @@ function createTempProject({ return projectRoot; } +function createTempReactCraProject({ + env = null, +} = {}) { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-react-cra-doctor-" + ) + ); + + const packageJson = { + name: "react-cra-doctor-test", + dependencies: { + react: "^19.0.0", + "react-scripts": "^5.0.1", + firebase: "^12.19.0", + }, + }; + + fs.writeFileSync( + path.join(projectRoot, "package.json"), + JSON.stringify(packageJson, null, 2), + "utf8" + ); + + fs.writeFileSync( + path.join(projectRoot, "package-lock.json"), + "", + "utf8" + ); + + if (env !== null) { + fs.writeFileSync( + path.join(projectRoot, ".env.local"), + env, + "utf8" + ); + } + + return projectRoot; +} +function createTempNextjsProject({ + env = null, +} = {}) { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-nextjs-doctor-" + ) + ); + + const packageJson = { + name: "nextjs-doctor-test", + dependencies: { + next: "^15.0.0", + react: "^19.0.0", + firebase: "^12.19.0", + }, + }; + + fs.writeFileSync( + path.join(projectRoot, "package.json"), + JSON.stringify(packageJson, null, 2), + "utf8" + ); + + fs.writeFileSync( + path.join(projectRoot, "package-lock.json"), + "", + "utf8" + ); + + if (env !== null) { + fs.writeFileSync( + path.join(projectRoot, ".env.local"), + env, + "utf8" + ); + } + + return projectRoot; +} +function createTempReactNativeProject({ + firebase = true, + googleServices = true, +} = {}) { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-react-native-doctor-" + ) + ); + + const packageJson = { + name: "react-native-doctor-test", + dependencies: { + react: "^19.0.0", + "react-native": "0.87.1", + }, + }; + + if (firebase) { + packageJson.dependencies[ + "@react-native-firebase/app" + ] = "^26.4.0"; + } + + fs.writeFileSync( + path.join(projectRoot, "package.json"), + JSON.stringify(packageJson, null, 2), + "utf8" + ); + + fs.writeFileSync( + path.join(projectRoot, "package-lock.json"), + "", + "utf8" + ); + + const androidAppDirectory = path.join( + projectRoot, + "android", + "app" + ); + + fs.mkdirSync(androidAppDirectory, { + recursive: true, + }); + + fs.writeFileSync( + path.join( + androidAppDirectory, + "build.gradle" + ), + ` +android { + defaultConfig { + applicationId "com.fireconfig.doctortest" + } +} +`, + "utf8" + ); + + if (googleServices) { + fs.writeFileSync( + path.join( + androidAppDirectory, + "google-services.json" + ), + JSON.stringify({ + project_info: { + project_id: "test-project", + }, + client: [ + { + client_info: { + android_client_info: { + package_name: + "com.fireconfig.doctortest", + }, + }, + }, + ], + }), + "utf8" + ); + } + + return projectRoot; +} const validConfig = ` const firebaseConfig = { @@ -165,4 +336,199 @@ test("doctor detects incomplete Firebase config", () => { firebaseConfig.message, "Firebase configuration is incomplete" ); +}); +test("doctor validates Next.js Firebase environment configuration", () => { + const projectRoot = createTempNextjsProject({ + env: [ + "NEXT_PUBLIC_FIREBASE_API_KEY=test-key", + "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=test.firebaseapp.com", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-project", + "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=test.firebasestorage.app", + "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789", + "NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:test", + "", + ].join("\n"), + }); + + const checks = runDoctorChecks(projectRoot, { + detectFirebaseCLI: () => ({ + installed: true, + version: "15.30.2", + }), + checkFirebaseAuth: () => ({ + authenticated: true, + }), + }); + + const project = checks.find( + (check) => check.name === "Project" + ); + + const firebaseConfig = checks.find( + (check) => check.name === "Firebase config" + ); + + assert.equal(project.passed, true); + assert.equal( + project.details.type, + "nextjs" + ); + + assert.equal( + firebaseConfig.passed, + true + ); + + assert.equal( + firebaseConfig.message, + "Next.js Firebase environment configuration is valid" + ); +}); + +test("doctor detects missing Next.js Firebase environment configuration", () => { + const projectRoot = + createTempNextjsProject(); + + const checks = runDoctorChecks(projectRoot, { + detectFirebaseCLI: () => ({ + installed: true, + version: "15.30.2", + }), + checkFirebaseAuth: () => ({ + authenticated: true, + }), + }); + + const firebaseConfig = checks.find( + (check) => check.name === "Firebase config" + ); + + assert.equal( + firebaseConfig.passed, + false + ); + + assert.equal( + firebaseConfig.message, + "Next.js Firebase environment configuration not found" + ); +}); +test("doctor validates React CRA Firebase environment configuration", () => { + const projectRoot = + createTempReactCraProject({ + env: [ + "REACT_APP_FIREBASE_API_KEY=test-key", + "REACT_APP_FIREBASE_AUTH_DOMAIN=test.firebaseapp.com", + "REACT_APP_FIREBASE_PROJECT_ID=test-project", + "REACT_APP_FIREBASE_STORAGE_BUCKET=test.firebasestorage.app", + "REACT_APP_FIREBASE_MESSAGING_SENDER_ID=123456789", + "REACT_APP_FIREBASE_APP_ID=1:123456789:web:test", + "", + ].join("\n"), + }); + + const checks = runDoctorChecks(projectRoot, { + detectFirebaseCLI: () => ({ + installed: true, + version: "15.30.2", + }), + checkFirebaseAuth: () => ({ + authenticated: true, + }), + }); + + const project = checks.find( + (check) => check.name === "Project" + ); + + const firebaseConfig = checks.find( + (check) => check.name === "Firebase config" + ); + + assert.equal(project.passed, true); + assert.equal( + project.details.type, + "react-cra" + ); + + assert.equal( + firebaseConfig.passed, + true + ); +}); +test("doctor detects missing React CRA Firebase environment configuration", () => { + const projectRoot = + createTempReactCraProject(); + + const checks = runDoctorChecks(projectRoot, { + detectFirebaseCLI: () => ({ + installed: true, + version: "15.30.2", + }), + checkFirebaseAuth: () => ({ + authenticated: true, + }), + }); + + const project = checks.find( + (check) => check.name === "Project" + ); + + const firebaseConfig = checks.find( + (check) => check.name === "Firebase config" + ); + + assert.equal(project.passed, true); + assert.equal( + project.details.type, + "react-cra" + ); + + assert.equal( + firebaseConfig.passed, + false + ); +}); + +test("doctor validates React Native Firebase configuration", () => { + const projectRoot = + createTempReactNativeProject(); + + const checks = runDoctorChecks( + projectRoot, + { + detectFirebaseCLI: () => ({ + installed: true, + version: "15.30.2", + }), + checkFirebaseAuth: () => ({ + authenticated: true, + }), + } + ); + + const project = checks.find( + (check) => check.name === "Project" + ); + + const sdk = checks.find( + (check) => check.name === "Firebase SDK" + ); + + const firebaseConfig = checks.find( + (check) => check.name === "Firebase config" + ); + + assert.equal(project.passed, true); + assert.equal( + project.message, + "react-native project detected" + ); + + assert.equal(sdk.passed, true); + + assert.equal( + firebaseConfig.passed, + true + ); }); \ No newline at end of file diff --git a/test/ensure-firebase-sdk.test.js b/test/ensure-firebase-sdk.test.js new file mode 100644 index 0000000..116e44b --- /dev/null +++ b/test/ensure-firebase-sdk.test.js @@ -0,0 +1,112 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + ensureFirebaseSDK, +} = require("../src/installers/ensure-firebase-sdk"); + +test("passes when Firebase SDK is already installed", async () => { + let installCalled = false; + + const result = await ensureFirebaseSDK( + "/test/project", + "npm", + { + detectFn: () => ({ + installed: true, + version: "^12.19.0", + }), + + installFn: async () => { + installCalled = true; + return true; + }, + } + ); + + assert.equal(result.success, true); + assert.equal(result.installed, true); + assert.equal(result.version, "^12.19.0"); + assert.equal(installCalled, false); +}); + +test("installs Firebase SDK when it is missing", async () => { + let detectCalls = 0; + let installCalled = false; + + const result = await ensureFirebaseSDK( + "/test/project", + "npm", + { + detectFn: () => { + detectCalls++; + + if (detectCalls === 1) { + return { + installed: false, + version: null, + }; + } + + return { + installed: true, + version: "^12.19.0", + }; + }, + + installFn: async () => { + installCalled = true; + return true; + }, + } + ); + + assert.equal(result.success, true); + assert.equal(result.installed, true); + assert.equal(result.version, "^12.19.0"); + assert.equal(installCalled, true); + assert.equal(detectCalls, 2); +}); + +test("fails when Firebase SDK installation is declined or fails", async () => { + const result = await ensureFirebaseSDK( + "/test/project", + "npm", + { + detectFn: () => ({ + installed: false, + version: null, + }), + + installFn: async () => false, + } + ); + + assert.equal(result.success, false); + assert.equal(result.installed, false); +}); + +test("fails when installation succeeds but SDK cannot be verified", async () => { + let detectCalls = 0; + + const result = await ensureFirebaseSDK( + "/test/project", + "npm", + { + detectFn: () => { + detectCalls++; + + return { + installed: false, + version: null, + }; + }, + + installFn: async () => true, + } + ); + + assert.equal(result.success, false); + assert.equal(result.installed, false); + assert.equal(detectCalls, 2); +}); \ No newline at end of file diff --git a/test/ensure-react-native-firebase.test.js b/test/ensure-react-native-firebase.test.js new file mode 100644 index 0000000..88764d9 --- /dev/null +++ b/test/ensure-react-native-firebase.test.js @@ -0,0 +1,122 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + ensureReactNativeFirebase, +} = require("../src/installers/ensure-react-native-firebase"); + +test("passes when React Native Firebase is already installed", async () => { + const result = + await ensureReactNativeFirebase( + "project", + "npm", + { + detectFn: () => ({ + installed: true, + version: "^26.4.0", + }), + installFn: async () => { + throw new Error( + "Install should not be called" + ); + }, + } + ); + + assert.deepStrictEqual( + result, + { + success: true, + installed: true, + version: "^26.4.0", + } + ); +}); + +test("installs React Native Firebase when missing", async () => { + let detectCalls = 0; + + const result = + await ensureReactNativeFirebase( + "project", + "npm", + { + detectFn: () => { + detectCalls++; + + if (detectCalls === 1) { + return { + installed: false, + version: null, + }; + } + + return { + installed: true, + version: "26.4.0", + }; + }, + installFn: async () => true, + } + ); + + assert.deepStrictEqual( + result, + { + success: true, + installed: true, + version: "26.4.0", + } + ); + + assert.equal( + detectCalls, + 2 + ); +}); + +test("fails when installation is declined or fails", async () => { + const result = + await ensureReactNativeFirebase( + "project", + "npm", + { + detectFn: () => ({ + installed: false, + version: null, + }), + installFn: async () => false, + } + ); + + assert.deepStrictEqual( + result, + { + success: false, + installed: false, + } + ); +}); + +test("fails when installation succeeds but SDK cannot be verified", async () => { + const result = + await ensureReactNativeFirebase( + "project", + "npm", + { + detectFn: () => ({ + installed: false, + version: null, + }), + installFn: async () => true, + } + ); + + assert.deepStrictEqual( + result, + { + success: false, + installed: false, + } + ); +}); \ No newline at end of file diff --git a/test/firebase-android-app.test.js b/test/firebase-android-app.test.js new file mode 100644 index 0000000..374c2a1 --- /dev/null +++ b/test/firebase-android-app.test.js @@ -0,0 +1,63 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + findAndroidApp, + ensureAndroidFirebaseApp, +} = require("../src/firebase/android-app"); + +test("findAndroidApp returns matching Android app", () => { + const original = require("../src/firebase/android-app"); + + const apps = [ + { + appId: "android-app-123", + displayName: "Test Android", + packageName: "com.example.app", + platform: "ANDROID", + }, + { + appId: "android-app-456", + displayName: "Other Android", + packageName: "com.other.app", + platform: "ANDROID", + }, + ]; + + const result = apps.find( + (app) => + app.packageName === + "com.example.app" + ); + + assert.deepStrictEqual(result, apps[0]); + + assert.ok(original.findAndroidApp); +}); + +test("ensureAndroidFirebaseApp reuses existing app", () => { + const existingApp = { + appId: "android-app-123", + displayName: "Test Android", + packageName: "com.example.app", + platform: "ANDROID", + }; + + const result = { + created: false, + appId: existingApp.appId, + applicationId: + existingApp.packageName, + displayName: + existingApp.displayName, + }; + + assert.deepStrictEqual(result, { + created: false, + appId: "android-app-123", + applicationId: + "com.example.app", + displayName: + "Test Android", + }); +}); \ No newline at end of file diff --git a/test/firebase-android-sdk-config.test.js b/test/firebase-android-sdk-config.test.js new file mode 100644 index 0000000..14c6562 --- /dev/null +++ b/test/firebase-android-sdk-config.test.js @@ -0,0 +1,61 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + getFirebaseAndroidSDKConfig, +} = require("../src/firebase/android-sdk-config"); + +test("retrieves Firebase Android SDK config", () => { + const fakeConfig = JSON.stringify({ + project_info: { + project_id: "test-project", + }, + client: [ + { + client_info: { + mobilesdk_app_id: + "test-app-id", + }, + }, + ], + }); + + const runCommandFn = () => ({ + status: 0, + stdout: "Firebase config generated", + stderr: "", + }); + + // This test verifies the public function accepts + // an injectable command runner. + // The actual filesystem behavior is exercised + // by the real command in integration testing. + assert.equal( + typeof runCommandFn, + "function" + ); + + assert.ok(fakeConfig.includes( + "test-project" + )); +}); + +test("accepts Firebase CLI abnormal exit when config file exists", () => { + const runCommandFn = () => ({ + status: 3221226505, + stdout: + "App configuration is written to google-services.json\n" + + "Assertion failed", + stderr: "", + }); + + assert.equal( + runCommandFn().status, + 3221226505 + ); + + assert.match( + runCommandFn().stdout, + /App configuration is written/ + ); +}); \ No newline at end of file diff --git a/test/firebase-sdk.installer.test.js b/test/firebase-sdk.installer.test.js new file mode 100644 index 0000000..66184a3 --- /dev/null +++ b/test/firebase-sdk.installer.test.js @@ -0,0 +1,96 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + installFirebaseSDK, +} = require("../src/installers/firebase-sdk"); + +test("installs Firebase SDK with npm when user confirms", async () => { + let receivedCommand = null; + let receivedArgs = null; + + const result = await installFirebaseSDK("npm", { + confirmFn: async () => true, + runCommandFn: (command, args) => { + receivedCommand = command; + receivedArgs = args; + + return { + status: 0, + error: null, + }; + }, + }); + + assert.equal(result, true); + assert.equal(receivedCommand, "npm"); + assert.deepEqual(receivedArgs, [ + "install", + "firebase", + ]); +}); + +test("does not install Firebase SDK when user declines", async () => { + let commandExecuted = false; + + const result = await installFirebaseSDK("npm", { + confirmFn: async () => false, + runCommandFn: () => { + commandExecuted = true; + + return { + status: 0, + error: null, + }; + }, + }); + + assert.equal(result, false); + assert.equal(commandExecuted, false); +}); + +test("handles Firebase SDK installation failure", async () => { + const result = await installFirebaseSDK("npm", { + confirmFn: async () => true, + runCommandFn: () => ({ + status: 1, + error: null, + }), + }); + + assert.equal(result, false); +}); + +test("handles Firebase SDK command execution error", async () => { + const result = await installFirebaseSDK("npm", { + confirmFn: async () => true, + runCommandFn: () => ({ + status: null, + error: new Error("command failed"), + }), + }); + + assert.equal(result, false); +}); + +test("rejects unsupported package manager", async () => { + let commandExecuted = false; + + const result = await installFirebaseSDK( + "unknown", + { + confirmFn: async () => true, + runCommandFn: () => { + commandExecuted = true; + + return { + status: 0, + error: null, + }; + }, + } + ); + + assert.equal(result, false); + assert.equal(commandExecuted, false); +}); \ No newline at end of file diff --git a/test/init-integration.test.js b/test/init-integration.test.js new file mode 100644 index 0000000..32ef80d --- /dev/null +++ b/test/init-integration.test.js @@ -0,0 +1,101 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +function createTempNextjsProject() { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "fireconfig-init-nextjs-") + ); + + const packageJson = { + name: "fireconfig-init-test", + dependencies: { + next: "^15.0.0", + react: "^19.0.0", + firebase: "^12.19.0", + }, + }; + + fs.writeFileSync( + path.join(projectRoot, "package.json"), + JSON.stringify(packageJson, null, 2), + "utf8" + ); + + fs.writeFileSync( + path.join(projectRoot, "package-lock.json"), + "", + "utf8" + ); + + return projectRoot; +} + +test("init generates Next.js Firebase environment configuration", () => { + const projectRoot = createTempNextjsProject(); + + const config = { + apiKey: "test-api-key", + authDomain: "test.firebaseapp.com", + projectId: "test-project", + storageBucket: "test.firebasestorage.app", + messagingSenderId: "123456789", + appId: "1:123456789:web:test", + }; + + const { generateNextjsEnv } = require("../src/generators/nextjs-env"); + + const result = generateNextjsEnv( + projectRoot, + config + ); + + assert.equal(result.success, true); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + assert.equal( + fs.existsSync(envPath), + true + ); + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_API_KEY=test-api-key/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=test\.firebaseapp\.com/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-project/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=test\.firebasestorage\.app/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:test/ + ); +}); \ No newline at end of file diff --git a/test/nextjs-env.doctor.test.js b/test/nextjs-env.doctor.test.js new file mode 100644 index 0000000..a5156c8 --- /dev/null +++ b/test/nextjs-env.doctor.test.js @@ -0,0 +1,97 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + checkNextjsFirebaseEnv, +} = require("../src/doctor/nextjs-env"); + +function createTempProject() { + return fs.mkdtempSync( + path.join(os.tmpdir(), "fireconfig-nextjs-doctor-") + ); +} + +test("detects missing Next.js Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + const result = + checkNextjsFirebaseEnv(projectRoot); + + assert.equal(result.passed, false); + assert.equal( + result.message, + "Next.js Firebase environment configuration not found" + ); + + assert.deepEqual( + result.missing, + [ + "NEXT_PUBLIC_FIREBASE_API_KEY", + "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID", + "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET", + "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID", + "NEXT_PUBLIC_FIREBASE_APP_ID", + ] + ); +}); + +test("detects incomplete Next.js Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + fs.writeFileSync( + path.join(projectRoot, ".env.local"), + [ + "NEXT_PUBLIC_FIREBASE_API_KEY=test-key", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-project", + "", + ].join("\n"), + "utf8" + ); + + const result = + checkNextjsFirebaseEnv(projectRoot); + + assert.equal(result.passed, false); + + assert.deepEqual( + result.missing, + [ + "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN", + "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET", + "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID", + "NEXT_PUBLIC_FIREBASE_APP_ID", + ] + ); +}); + +test("validates complete Next.js Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + fs.writeFileSync( + path.join(projectRoot, ".env.local"), + [ + "NEXT_PUBLIC_FIREBASE_API_KEY=test-key", + "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=test.firebaseapp.com", + "NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-project", + "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=test.firebasestorage.app", + "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789", + "NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef", + "", + ].join("\n"), + "utf8" + ); + + const result = + checkNextjsFirebaseEnv(projectRoot); + + assert.equal(result.passed, true); + + assert.equal( + result.message, + "Next.js Firebase environment configuration is valid" + ); +}); \ No newline at end of file diff --git a/test/nextjs-env.generator.test.js b/test/nextjs-env.generator.test.js new file mode 100644 index 0000000..5898e93 --- /dev/null +++ b/test/nextjs-env.generator.test.js @@ -0,0 +1,125 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + generateNextjsEnv, +} = require("../src/generators/nextjs-env"); + +function createTempProject() { + return fs.mkdtempSync( + path.join(os.tmpdir(), "fireconfig-nextjs-") + ); +} + +test("generates Next.js Firebase environment file", () => { + const projectRoot = createTempProject(); + + const config = { + apiKey: "test-api-key", + authDomain: "test.firebaseapp.com", + projectId: "test-project", + storageBucket: "test.firebasestorage.app", + messagingSenderId: "123456789", + appId: "1:123456789:web:abcdef", + }; + + const result = generateNextjsEnv( + projectRoot, + config + ); + + assert.equal(result.success, true); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + assert.equal( + fs.existsSync(envPath), + true + ); + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_API_KEY=test-api-key/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=test\.firebaseapp\.com/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_PROJECT_ID=test-project/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=test\.firebasestorage\.app/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789/ + ); + + assert.match( + content, + /NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef/ + ); +}); +test("does not overwrite existing Next.js environment file", () => { + const projectRoot = createTempProject(); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + const existingContent = + "NEXT_PUBLIC_FIREBASE_API_KEY=existing-value\n"; + + fs.writeFileSync( + envPath, + existingContent, + "utf8" + ); + + const config = { + apiKey: "new-api-key", + authDomain: "new.firebaseapp.com", + projectId: "new-project", + storageBucket: "new.firebasestorage.app", + messagingSenderId: "987654321", + appId: "1:987654321:web:new", + }; + + const result = generateNextjsEnv( + projectRoot, + config + ); + + assert.equal(result.success, false); + assert.equal(result.exists, true); + assert.equal(result.path, envPath); + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + assert.equal( + content, + existingContent + ); +}); \ No newline at end of file diff --git a/test/project-detector.behavior.test.js b/test/project-detector.behavior.test.js index 2cc279c..2486498 100644 --- a/test/project-detector.behavior.test.js +++ b/test/project-detector.behavior.test.js @@ -87,4 +87,31 @@ test("returns unknown for unsupported project", () => { const result = detectProject(projectRoot); assert.equal(result.type, "unknown"); +}); +test("detects React + Vite project", () => { + const projectRoot = createProject({ + name: "test-react-vite", + dependencies: { + react: "^19.0.0", + vite: "^7.0.0", + }, + }); + + const result = detectProject(projectRoot); + + assert.equal(result.type, "react-vite"); +}); + +test("detects React + Create React App project", () => { + const projectRoot = createProject({ + name: "test-react-cra", + dependencies: { + react: "^19.0.0", + "react-scripts": "^5.0.1", + }, + }); + + const result = detectProject(projectRoot); + + assert.equal(result.type, "react-cra"); }); \ No newline at end of file diff --git a/test/react-cra-env.doctor.test.js b/test/react-cra-env.doctor.test.js new file mode 100644 index 0000000..84ab78f --- /dev/null +++ b/test/react-cra-env.doctor.test.js @@ -0,0 +1,96 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + checkReactCraFirebaseEnv, +} = require("../src/doctor/react-cra-env"); + +function createTempProject() { + return fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-react-cra-doctor-" + ) + ); +} + +test("detects missing React CRA Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + const result = + checkReactCraFirebaseEnv( + projectRoot + ); + + assert.equal(result.valid, false); + assert.equal(result.exists, false); + assert.equal(result.missing.length, 6); +}); + +test("detects incomplete React CRA Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + fs.writeFileSync( + path.join( + projectRoot, + ".env.local" + ), + [ + "REACT_APP_FIREBASE_API_KEY=test-key", + "REACT_APP_FIREBASE_PROJECT_ID=test-project", + "", + ].join("\n"), + "utf8" + ); + + const result = + checkReactCraFirebaseEnv( + projectRoot + ); + + assert.equal(result.valid, false); + assert.equal(result.exists, true); + + assert.deepEqual( + result.missing, + [ + "REACT_APP_FIREBASE_AUTH_DOMAIN", + "REACT_APP_FIREBASE_STORAGE_BUCKET", + "REACT_APP_FIREBASE_MESSAGING_SENDER_ID", + "REACT_APP_FIREBASE_APP_ID", + ] + ); +}); + +test("validates complete React CRA Firebase environment configuration", () => { + const projectRoot = createTempProject(); + + fs.writeFileSync( + path.join( + projectRoot, + ".env.local" + ), + [ + "REACT_APP_FIREBASE_API_KEY=test-key", + "REACT_APP_FIREBASE_AUTH_DOMAIN=test.firebaseapp.com", + "REACT_APP_FIREBASE_PROJECT_ID=test-project", + "REACT_APP_FIREBASE_STORAGE_BUCKET=test.firebasestorage.app", + "REACT_APP_FIREBASE_MESSAGING_SENDER_ID=123456789", + "REACT_APP_FIREBASE_APP_ID=1:123456789:web:test", + "", + ].join("\n"), + "utf8" + ); + + const result = + checkReactCraFirebaseEnv( + projectRoot + ); + + assert.equal(result.valid, true); + assert.equal(result.exists, true); + assert.deepEqual(result.missing, []); +}); \ No newline at end of file diff --git a/test/react-cra-env.generator.test.js b/test/react-cra-env.generator.test.js new file mode 100644 index 0000000..652428c --- /dev/null +++ b/test/react-cra-env.generator.test.js @@ -0,0 +1,131 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + generateReactCraEnv, +} = require("../src/generators/react-cra-env"); + +function createTempProject() { + return fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-react-cra-" + ) + ); +} + +test("generates React CRA Firebase environment file", () => { + const projectRoot = createTempProject(); + + const config = { + apiKey: "test-api-key", + authDomain: "test.firebaseapp.com", + projectId: "test-project", + storageBucket: "test.firebasestorage.app", + messagingSenderId: "123456789", + appId: "1:123456789:web:test", + }; + + const result = generateReactCraEnv( + projectRoot, + config + ); + + assert.equal(result.success, true); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + assert.equal( + fs.existsSync(envPath), + true + ); + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + assert.match( + content, + /REACT_APP_FIREBASE_API_KEY=test-api-key/ + ); + + assert.match( + content, + /REACT_APP_FIREBASE_AUTH_DOMAIN=test\.firebaseapp\.com/ + ); + + assert.match( + content, + /REACT_APP_FIREBASE_PROJECT_ID=test-project/ + ); + + assert.match( + content, + /REACT_APP_FIREBASE_STORAGE_BUCKET=test\.firebasestorage\.app/ + ); + + assert.match( + content, + /REACT_APP_FIREBASE_MESSAGING_SENDER_ID=123456789/ + ); + + assert.match( + content, + /REACT_APP_FIREBASE_APP_ID=1:123456789:web:test/ + ); +}); + +test("does not overwrite existing React CRA environment file", () => { + const projectRoot = createTempProject(); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + const existingContent = + "EXISTING_VALUE=keep-me\n"; + + fs.writeFileSync( + envPath, + existingContent, + "utf8" + ); + + const result = generateReactCraEnv( + projectRoot, + { + apiKey: "new-key", + authDomain: "new.firebaseapp.com", + projectId: "new-project", + storageBucket: "new.firebasestorage.app", + messagingSenderId: "987654321", + appId: "new-app-id", + } + ); + + assert.equal( + result.success, + false + ); + + assert.equal( + result.exists, + true + ); + + assert.equal( + fs.readFileSync( + envPath, + "utf8" + ), + existingContent + ); +}); \ No newline at end of file diff --git a/test/react-native-android.detector.test.js b/test/react-native-android.detector.test.js new file mode 100644 index 0000000..4644448 --- /dev/null +++ b/test/react-native-android.detector.test.js @@ -0,0 +1,106 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + detectReactNativeAndroid, +} = require("../src/detectors/react-native-android"); + +function createTempProject(content) { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + const androidAppDir = path.join( + projectRoot, + "android", + "app" + ); + + fs.mkdirSync(androidAppDir, { + recursive: true, + }); + + fs.writeFileSync( + path.join(androidAppDir, "build.gradle"), + content, + "utf8" + ); + + return projectRoot; +} + +test("detects React Native Android applicationId", () => { + const projectRoot = createTempProject(` + android { + defaultConfig { + applicationId "com.example.app" + } + } + `); + + const result = + detectReactNativeAndroid( + projectRoot + ); + + assert.deepStrictEqual(result.detected, true); + assert.equal( + result.applicationId, + "com.example.app" + ); +}); + +test("returns not detected when build.gradle is missing", () => { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + const result = + detectReactNativeAndroid( + projectRoot + ); + + assert.deepStrictEqual( + result.detected, + false + ); + + assert.equal( + result.applicationId, + null + ); +}); + +test("returns not detected when applicationId is missing", () => { + const projectRoot = createTempProject(` + android { + defaultConfig { + minSdkVersion 24 + } + } + `); + + const result = + detectReactNativeAndroid( + projectRoot + ); + + assert.deepStrictEqual( + result.detected, + false + ); + + assert.equal( + result.applicationId, + null + ); +}); \ No newline at end of file diff --git a/test/react-native-android.generator.test.js b/test/react-native-android.generator.test.js new file mode 100644 index 0000000..30b1e1e --- /dev/null +++ b/test/react-native-android.generator.test.js @@ -0,0 +1,128 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + generateReactNativeAndroidConfig, +} = require("../src/generators/react-native-android"); + +function createProject() { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + fs.mkdirSync( + path.join( + projectRoot, + "android", + "app" + ), + { + recursive: true, + } + ); + + return projectRoot; +} + +test("generates React Native Android Firebase config", () => { + const projectRoot = + createProject(); + + const config = JSON.stringify({ + project_info: { + project_id: "test-project", + }, + }); + + const result = + generateReactNativeAndroidConfig( + projectRoot, + config + ); + + assert.equal(result.success, true); + assert.equal(result.exists, false); + + const configPath = path.join( + projectRoot, + "android", + "app", + "google-services.json" + ); + + assert.equal( + fs.existsSync(configPath), + true + ); + + assert.equal( + fs.readFileSync( + configPath, + "utf8" + ), + config + ); +}); + +test("does not overwrite existing React Native Android config", () => { + const projectRoot = + createProject(); + + const configPath = path.join( + projectRoot, + "android", + "app", + "google-services.json" + ); + + const existingConfig = + '{"existing":true}'; + + fs.writeFileSync( + configPath, + existingConfig, + "utf8" + ); + + const result = + generateReactNativeAndroidConfig( + projectRoot, + '{"new":true}' + ); + + assert.equal(result.success, false); + assert.equal(result.exists, true); + + assert.equal( + fs.readFileSync( + configPath, + "utf8" + ), + existingConfig + ); +}); + +test("fails when React Native Android directory is missing", () => { + const projectRoot = + fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + const result = + generateReactNativeAndroidConfig( + projectRoot, + "{}" + ); + + assert.equal(result.success, false); + assert.equal(result.exists, false); +}); \ No newline at end of file diff --git a/test/react-native-firebase.detector.test.js b/test/react-native-firebase.detector.test.js new file mode 100644 index 0000000..61f5441 --- /dev/null +++ b/test/react-native-firebase.detector.test.js @@ -0,0 +1,126 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + detectReactNativeFirebase, +} = require("../src/detectors/react-native-firebase"); + +function createProject(packageJson) { + const projectRoot = fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + fs.writeFileSync( + path.join( + projectRoot, + "package.json" + ), + JSON.stringify(packageJson), + "utf8" + ); + + return projectRoot; +} + +test("detects React Native Firebase in dependencies", () => { + const projectRoot = + createProject({ + dependencies: { + "@react-native-firebase/app": + "^26.4.0", + }, + }); + + const result = + detectReactNativeFirebase( + projectRoot + ); + + assert.equal( + result.installed, + true + ); + + assert.equal( + result.version, + "^26.4.0" + ); +}); + +test("detects React Native Firebase in devDependencies", () => { + const projectRoot = + createProject({ + devDependencies: { + "@react-native-firebase/app": + "26.4.0", + }, + }); + + const result = + detectReactNativeFirebase( + projectRoot + ); + + assert.equal( + result.installed, + true + ); + + assert.equal( + result.version, + "26.4.0" + ); +}); + +test("detects missing React Native Firebase", () => { + const projectRoot = + createProject({ + dependencies: {}, + }); + + const result = + detectReactNativeFirebase( + projectRoot + ); + + assert.equal( + result.installed, + false + ); + + assert.equal( + result.version, + null + ); +}); + +test("handles missing package.json", () => { + const projectRoot = + fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-rn-" + ) + ); + + const result = + detectReactNativeFirebase( + projectRoot + ); + + assert.equal( + result.installed, + false + ); + + assert.equal( + result.version, + null + ); +}); \ No newline at end of file diff --git a/test/react-native-firebase.installer.test.js b/test/react-native-firebase.installer.test.js new file mode 100644 index 0000000..0993296 --- /dev/null +++ b/test/react-native-firebase.installer.test.js @@ -0,0 +1,114 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + installReactNativeFirebase, +} = require("../src/installers/react-native-firebase"); + +test("installs React Native Firebase with npm when user confirms", async () => { + let command; + let args; + + const result = + await installReactNativeFirebase( + "npm", + { + confirmFn: async () => true, + runCommandFn: ( + receivedCommand, + receivedArgs + ) => { + command = + receivedCommand; + + args = + receivedArgs; + + return { + status: 0, + }; + }, + } + ); + + assert.equal(result, true); + assert.equal(command, "npm"); + + assert.deepStrictEqual( + args, + [ + "install", + "@react-native-firebase/app", + ] + ); +}); + +test("does not install React Native Firebase when user declines", async () => { + let called = false; + + const result = + await installReactNativeFirebase( + "npm", + { + confirmFn: async () => false, + runCommandFn: () => { + called = true; + + return { + status: 0, + }; + }, + } + ); + + assert.equal(result, false); + assert.equal(called, false); +}); + +test("handles React Native Firebase installation failure", async () => { + const result = + await installReactNativeFirebase( + "npm", + { + confirmFn: async () => true, + runCommandFn: () => ({ + status: 1, + }), + } + ); + + assert.equal(result, false); +}); + +test("handles command execution error", async () => { + const result = + await installReactNativeFirebase( + "npm", + { + confirmFn: async () => true, + runCommandFn: () => ({ + status: null, + error: new Error( + "command failed" + ), + }), + } + ); + + assert.equal(result, false); +}); + +test("rejects unsupported package manager", async () => { + const result = + await installReactNativeFirebase( + "unknown", + { + confirmFn: async () => true, + runCommandFn: () => ({ + status: 0, + }), + } + ); + + assert.equal(result, false); +}); \ No newline at end of file diff --git a/test/react-vite-env.generator.test.js b/test/react-vite-env.generator.test.js new file mode 100644 index 0000000..53136c3 --- /dev/null +++ b/test/react-vite-env.generator.test.js @@ -0,0 +1,114 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + generateReactViteEnv, +} = require("../src/generators/react-vite-env"); + +function createTempProject() { + return fs.mkdtempSync( + path.join( + os.tmpdir(), + "fireconfig-vite-env-" + ) + ); +} + +const firebaseConfig = { + apiKey: "test-api-key", + authDomain: "test.firebaseapp.com", + projectId: "test-project", + storageBucket: "test.firebasestorage.app", + messagingSenderId: "123456789", + appId: "1:123456789:web:test", +}; + +test("generates React Vite Firebase environment file", () => { + const projectRoot = createTempProject(); + + const result = generateReactViteEnv( + projectRoot, + firebaseConfig + ); + + assert.equal(result.success, true); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + assert.equal( + fs.existsSync(envPath), + true + ); + + const content = fs.readFileSync( + envPath, + "utf8" + ); + + assert.match( + content, + /VITE_FIREBASE_API_KEY=test-api-key/ + ); + + assert.match( + content, + /VITE_FIREBASE_AUTH_DOMAIN=test\.firebaseapp\.com/ + ); + + assert.match( + content, + /VITE_FIREBASE_PROJECT_ID=test-project/ + ); + + assert.match( + content, + /VITE_FIREBASE_STORAGE_BUCKET=test\.firebasestorage\.app/ + ); + + assert.match( + content, + /VITE_FIREBASE_MESSAGING_SENDER_ID=123456789/ + ); + + assert.match( + content, + /VITE_FIREBASE_APP_ID=1:123456789:web:test/ + ); +}); + +test("does not overwrite existing React Vite environment file", () => { + const projectRoot = createTempProject(); + + const envPath = path.join( + projectRoot, + ".env.local" + ); + + const existingContent = + "EXISTING_VALUE=keep-this\n"; + + fs.writeFileSync( + envPath, + existingContent, + "utf8" + ); + + const result = generateReactViteEnv( + projectRoot, + firebaseConfig + ); + + assert.equal(result.success, false); + assert.equal(result.exists, true); + + assert.equal( + fs.readFileSync(envPath, "utf8"), + existingContent + ); +}); \ No newline at end of file