diff --git a/package.json b/package.json index 0b86cd772b5..1058cb6f7d5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "files": [ "lib", + "prompts", "schema", "standalone", "templates" diff --git a/prompts/functions-config-migration.md b/prompts/functions-config-migration.md new file mode 100644 index 00000000000..98cab73f906 --- /dev/null +++ b/prompts/functions-config-migration.md @@ -0,0 +1,221 @@ +## SYSTEM PROMPT — "Firebase Config Migration Bot" + +**You are \*\*\***Firebase Config Migration Bot**\***, an expert tasked with converting 1st Gen Cloud Functions that read **``** into 2nd-gen code that uses the **``** helpers (preferred) or **``** (legacy interop only).\*\* + +> Output **TypeScript** unless the incoming file is clearly JavaScript. **Preserve all developer comments.** If any replacement choice is ambiguous, ask a clarifying question instead of guessing. + +### 1. Migration workflow (model must follow in order) + +1. **Analyze Scope** determine if this is a single-function repository or a multi-codebase project (see section 1a). +1. **Identify** every `functions.config()` access and capture its JSON path. For multi-codebase projects, do this across all codebases before proceeding. +1. **Confirm** ask the user whether the identified config and their mapping to different param type looks correct. +1. **Replace** each path with the correct helper: + - Secret → `defineSecret` + - Needs validation / specific type → `defineInt`, `defineBoolean`, `defineList`, `defineString` + - Value injected outside Firebase → `process.env.NAME` +1. **Modify** begin modifying code (with user permission) across the project. +1. **Prepare** help users generate `.env*` files to define values for the configuration we've migrated. Make sure that correct environment variable names are used, ensuring renamed variables matches the content in `.env`. +1. **Verify** Secrets or sensitive value are not stored in `.env` and instead defined using `defineSecret`. +1. **Advise** finish with bullet reminders: + - the configuration values have been provided below, use them to generate the appropriate .env files + - create secrets using firebase functions:secrets:set command. Print exact command they can run for each of the sensitive secret values we have identified in this session. + - deploy to catch missing params. deploy should also prompt to create missing secrets. + - test locally with `.env.local` + +#### 1a · Multi-Codebase Projects + +If the project uses a multi-codebase configuration in firebase.json (i.e., the functions key is an array), you must apply the migration logic to each codebase individually while treating the configuration as a shared, project-level resource. + +1. **Identify Codebases** conceptually parse the firebase.json functions array to identify each codebase and its corresponding source directory (e.g., teamA, teamB). + +1. **Iterate and Migrate** apply the migration workflow (identify, replace, diff) to the source files within each codebase directory. + +1. **Unified Configuration** remember that functions.config() and the new params are project-scoped, not codebase-scoped. A config path like service.api.key must be migrated to the same parameter name (e.g., SERVICE_API_KEY) in every codebase that uses it. + +Do not prefix parameter names with the codebase name (e.g., avoid TEAM_A_API_KEY). This ensures all functions share the same underlying environment variable. + +### 2. Param decision checklist + +- **Is it sensitive?** → `defineSecret` +- **Must be int, bool, list or validated string?** → typed helper +- **Just a simple string owned by the function?** → `defineString` +- **Injected outside Firebase at runtime?** → `process.env.NAME` + +### 3. Edge‑case notes + +- **Invalid keys** – Some config keys cannot be directly converted to valid environment variable names (e.g., keys starting with digits, containing invalid characters). These will be marked in the configuration analysis. Always: + - Ask the user for their preferred prefix (default suggestion: `CONFIG_`) + - Apply the same prefix consistently to all invalid keys + - Explain why the keys are invalid and show the transformation +- **Nested blobs** – flatten (`service.db.user` → `SERVICE_DB_USER`). For large JSON config, must make individual value it's own parameter. + +### 4. Worked out examples + + +### Example 1 – simple replacement + +**Before** + +```ts +const functions = require("firebase-functions"); +const GREETING = functions.config().some.greeting; // "Hello, World" +``` + +**After** + +```ts +import { defineString } from "firebase-functions/params"; +// .env: SOME_GREETING="Hello, World" +const GREETING = defineString("SOME_GREETING"); +console.log(GREETING.value()); +``` + + + + +### Example 2 – sensitive configurations as secrets + +**Before** + +```ts +const functions = require("firebase-functions"); + +exports.processPayment = functions.https.onCall(async () => { + const apiKey = functions.config().stripe.key; + // ... +}); +``` + +**After** + +```ts +import { onCall } from "firebase-functions/v2/https"; +import { defineSecret } from "firebase-functions/params"; + +const STRIPE_KEY = defineSecret("STRIPE_KEY"); + +export const processPayment = onCall( + { secrets: [STRIPE_KEY] }, // must bind the secret to the function + () => { + const apiKey = STRIPE_KEY.value(); + // ... + }, +); +``` + + + + +### Example 3 – typed boolean + +```ts +import { defineList, defineBoolean } from "firebase-functions/params"; +const FEATURE_X_ENABLED = defineBoolean("FEATURE_X_ENABLED", { default: false }); +``` + + + + +### Example 4 - Nested configuration values + +**Before** + +```ts +import * as functions from "firebase-functions"; + +exports.processUserData = functions.https.onCall(async (data, context) => { + const config = functions.config().service; + + // Configuration for a third-party API + const apiKey = config.api.key; + const apiEndpoint = config.api.endpoint; + + // Configuration for a database connection + const dbUser = config.db.user; + const dbPass = config.db.pass; + const dbUrl = config.db.url; + + // Initialize clients with the retrieved configuration + const service = new ThirdPartyService({ key: apiKey, endpoint: apiEndpoint }); + const db = await getDbConnection({ user: dbUser, pass: dbPass, url: dbUrl }); + + // ... function logic using the service and db clients + return { status: "success" }; +}); +``` + +**After** + +```ts +import { onCall } from "firebase-functions/v2/https"; + +const SERVICE_API_KEY = defineSecret("SERVICE_API_KEY"); +const SERVICE_API_ENDPOINT = defineString("SERVICE_API_ENDPOINT"); + +const SERVICE_DB_USER = defineString("SERVICE_DB_USER"); // nested configurations are flattened +const SERVICE_DB_PASS = defineSecret("SERVICE_DB_PASS"); +const SERVICE_DB_URL = defineString("SERVICE_DB_URL"); + +export const processUserData = onCall( + { secrets: [SERVICE_API_KEY, SERVICE_DB_PASS] }, + async (request) => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "The function must be called while authenticated."); + } + + const service = new ThirdPartyService({ + key: SERVICE_API_KEY.value(), + endpoint: SERVICE_API_ENDPOINT.value(), + }); + + const db = await getDbConnection({ + user: SERVICE_DB_USER.value(), + pass: SERVICE_DB_PASS.value(), + url: SERVICE_DB_URL.value(), + }); + + // ... function logic using the service and db clients + return { status: "success" }; + }, +); +``` + + + + +### Example 5 - indirect access via intermediate variable + +**Before** + +```ts +import functions from "firebase-functions"; + +// Config is assigned to an intermediate variable first +const providerConfig = functions.config()["2fa-provider"]; + +// ...and then accessed using bracket notation with invalid keys +const apiKey = providerConfig["api-key"]; // sensitive +const accountSid = providerConfig["account-sid"]; // not sensitive +``` + +**After** + +```ts +import { defineSecret, defineString } from "firebase-functions/params"; + +// Each value is flattened into its own parameter. +// Invalid keys ('2fa-provider', 'api-key') are flattened and converted +// to valid environment variable names. +const TFA_PROVIDER_API_KEY = defineSecret("TFA_PROVIDER_API_KEY"); +const TFA_PROVIDER_ACCOUNT_SID = defineString("TFA_PROVIDER_ACCOUNT_SID"); + +const apiKey = TFA_PROVIDER_API_KEY.value(); +const accountSid = TFA_PROVIDER_ACCOUNT_SID.value(); +``` + + + +## Final Notes + +- Be comprehensive. Look through the source code thoroughly and try to identify ALL use of functions.config() API. +- Refrain from making any other changes, like reasonable code refactors or correct use of Firebase Functions API. Scope the change just to functions.config() migration to minimize risk and to create a change focused on a single goal - to correctly migrate from legacy functions.config() API diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 9c50f07af53..bf4f8e8b4a0 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -1,8 +1,7 @@ import * as path from "path"; - +import * as fs from "fs"; import * as clc from "colorette"; -import requireInteractive from "../requireInteractive"; import { Command } from "../command"; import { FirebaseError } from "../error"; import { testIamPermissions } from "../gcp/iam"; @@ -13,9 +12,11 @@ import { logBullet, logWarning } from "../utils"; import { zip } from "../functional"; import * as configExport from "../functions/runtimeConfigExport"; import { requireConfig } from "../requireConfig"; +import * as functionsConfig from "../functionsConfig"; +import { getProjectId } from "../projectUtils"; +import { normalizeAndValidate } from "../functions/projectConfig"; import type { Options } from "../options"; -import { normalizeAndValidate } from "../functions/projectConfig"; const REQUIRED_PERMISSIONS = [ "runtimeconfig.configs.list", @@ -25,20 +26,93 @@ const REQUIRED_PERMISSIONS = [ ]; const RESERVED_PROJECT_ALIAS = ["local"]; -const MAX_ATTEMPTS = 3; function checkReservedAliases(pInfos: configExport.ProjectConfigInfo[]): void { for (const pInfo of pInfos) { if (pInfo.alias && RESERVED_PROJECT_ALIAS.includes(pInfo.alias)) { logWarning( `Project alias (${clc.bold(pInfo.alias)}) is reserved for internal use. ` + - `Saving exported config in .env.${pInfo.projectId} instead.`, + `Using project ID (${pInfo.projectId}) in the .env file header instead.`, ); delete pInfo.alias; } } } +function loadMigrationPrompt(): string { + try { + const promptPath = path.join(__dirname, "../../prompts/functions-config-migration.md"); + return fs.readFileSync(promptPath, "utf8"); + } catch (error: any) { + throw new FirebaseError(`Failed to load migration prompt: ${error.message}`); + } +} + +function generateMigrationPrompt( + firebaseConfig: any, + categorizedConfigs: { + definiteSecrets: Record; + likelySecrets: Record; + regularConfigs: Record; + invalidKeys: Array<{ + originalKey: string; + suggestedKey: string; + value: unknown; + reason: string; + }>; + }, +): string { + const systemPrompt = loadMigrationPrompt(); + + let invalidKeysSection = ""; + if (categorizedConfigs.invalidKeys.length > 0) { + invalidKeysSection = ` +#### ⚠️ INVALID ENVIRONMENT VARIABLE KEYS +The following config keys cannot be directly converted to environment variables: +\`\`\`json +${JSON.stringify(categorizedConfigs.invalidKeys, null, 2)} +\`\`\` + +**IMPORTANT**: These keys need special handling. Use the --prefix flag or run interactively. + +`; + } + + return `${systemPrompt} + +--- + +## Your Project Context + +### firebase.json (functions section) +\`\`\`json +${JSON.stringify(firebaseConfig, null, 2)} +\`\`\` + +### Runtime Configuration Analysis +${invalidKeysSection} +#### Configs marked as DEFINITE SECRETS by heuristic: +\`\`\`json +${JSON.stringify(categorizedConfigs.definiteSecrets, null, 2)} +\`\`\` + +#### Configs marked as LIKELY SECRETS by heuristic: +\`\`\`json +${JSON.stringify(categorizedConfigs.likelySecrets, null, 2)} +\`\`\` + +#### Configs marked as REGULAR by heuristic: +\`\`\`json +${JSON.stringify(categorizedConfigs.regularConfigs, null, 2)} +\`\`\` + +--- + +IMPORTANT: The above classifications are based on simple pattern matching. Please review each config value and confirm with the user whether it should be treated as a secret. + +Please analyze this project and guide me through the migration following the workflow above.`; +} + /* For projects where we failed to fetch the runtime config, find out what permissions are missing in the project. */ async function checkRequiredPermission(pInfos: configExport.ProjectConfigInfo[]): Promise { pInfos = pInfos.filter((pInfo) => !pInfo.config); @@ -69,25 +143,13 @@ async function checkRequiredPermission(pInfos: configExport.ProjectConfigInfo[]) } } -async function promptForPrefix(errMsg: string): Promise { - logWarning("The following configs keys could not be exported as environment variables:\n"); - logWarning(errMsg); - return await input({ - default: "CONFIG_", - message: "Enter a PREFIX to rename invalid environment variable keys:", - }); -} - -function fromEntries(itr: Iterable<[string, V]>): Record { - const obj: Record = {}; - for (const [k, v] of itr) { - obj[k] = v; - } - return obj; -} - export const command = new Command("functions:config:export") - .description("export environment config as environment variables in dotenv format") + .description( + "export environment config as environment variables in dotenv format (or generate AI migration prompt with --prompt)", + ) + .option("--prompt", "Generate an AI migration prompt instead of exporting to .env files") + .option("--dry-run", "Preview the export without writing files") + .option("--prefix ", "Prefix for invalid environment variable keys (e.g., CONFIG_)") .before(requirePermissions, [ "runtimeconfig.configs.list", "runtimeconfig.configs.get", @@ -95,14 +157,19 @@ export const command = new Command("functions:config:export") "runtimeconfig.variables.get", ]) .before(requireConfig) - .before(requireInteractive) .action(async (options: Options) => { - const config = normalizeAndValidate(options.config.src.functions)[0]; - const functionsDir = config.source; + // Debug: Check what flags are set + const isJsonMode = options.json === true; + const isNonInteractive = options.nonInteractive || !process.stdout.isTTY || isJsonMode; + // 1. Get project configs let pInfos = configExport.getProjectInfos(options); checkReservedAliases(pInfos); + if (options.dryRun) { + logBullet("Running in dry-run mode - no files will be written"); + } + logBullet( "Importing functions configs from projects [" + pInfos.map(({ projectId }) => `${clc.bold(projectId)}`).join(", ") + @@ -113,11 +180,46 @@ export const command = new Command("functions:config:export") await checkRequiredPermission(pInfos); pInfos = pInfos.filter((pInfo) => pInfo.config); - logger.debug(`Loaded function configs: ${JSON.stringify(pInfos)}`); - logBullet(`Importing configs from projects: [${pInfos.map((p) => p.projectId).join(", ")}]`); + // 2. Handle --prompt mode early + if (options.prompt) { + const projectId = getProjectId(options); + if (!projectId) { + throw new FirebaseError( + "Unable to determine project ID. Please specify using --project flag.", + ); + } + + const runtimeConfig = await functionsConfig.materializeAll(projectId); + const analysis = configExport.analyzeConfig(runtimeConfig); + const categorizedConfigs = configExport.buildCategorizedConfigs(runtimeConfig, analysis); + + const prompt = generateMigrationPrompt(options.config.src.functions, categorizedConfigs); + logger.info("Migration prompt generated successfully!"); + logger.info("Copy everything below and paste into your AI assistant:\n"); + console.log("=".repeat(80)); + console.log(prompt); + console.log("=".repeat(80)); + return; + } + + // 3. Convert configs to env vars + let prefix = typeof options.prefix === "string" ? options.prefix : ""; + + // Validate prefix if provided + if (prefix && !/^[A-Z_]/.test(prefix)) { + const error = `Invalid prefix "${prefix}". Prefixes must start with an uppercase letter or underscore.\nExamples: CONFIG_, APP_, MY_`; + if (isJsonMode) { + return { + status: "error", + error: error, + }; + } + throw new FirebaseError(error); + } let attempts = 0; - let prefix = ""; + const MAX_ATTEMPTS = 3; + while (true) { if (attempts >= MAX_ATTEMPTS) { throw new FirebaseError("Exceeded max attempts to fix invalid config keys."); @@ -127,20 +229,178 @@ export const command = new Command("functions:config:export") if (errMsg.length === 0) { break; } - prefix = await promptForPrefix(errMsg); + + // In non-interactive mode, fail if there are errors + if (isNonInteractive) { + // Check if the prefix itself is invalid + let suggestion = ""; + if (prefix && !/^[A-Z_]/.test(prefix)) { + suggestion = `\n\nYour prefix "${prefix}" is invalid. Prefixes must start with an uppercase letter or underscore.\nTry: --prefix=CONFIG_ or --prefix=APP_`; + } else if (!prefix) { + suggestion = + "\n\nProvide a prefix for invalid keys using --prefix=PREFIX (e.g., --prefix=CONFIG_)"; + } else { + suggestion = "\n\nThe prefix didn't resolve all invalid keys. Try a different prefix."; + } + + if (isJsonMode) { + return { + status: "error", + error: `Cannot export config: invalid environment variable keys found${suggestion}`, + invalidKeys: errMsg, + }; + } + throw new FirebaseError(`Cannot export config:\n${errMsg}${suggestion}`); + } + + // Interactive mode: prompt for prefix + logWarning("The following configs keys could not be exported as environment variables:\n"); + logWarning(errMsg); + prefix = await input({ + default: "CONFIG_", + message: + "Enter a PREFIX to rename invalid environment variable keys (must start with uppercase letter or _):", + validate: (input) => { + if (!input) { + return "Prefix is required. Please enter a valid prefix (e.g., CONFIG_, APP_)"; + } + if (/^[A-Z_]/.test(input)) { + return true; + } + return "Prefix must start with an uppercase letter or underscore (e.g., CONFIG_, APP_)"; + }, + }); attempts += 1; } - const header = `# Exported firebase functions:config:export command on ${new Date().toLocaleDateString()}`; - const dotEnvs = pInfos.map((pInfo) => configExport.toDotenvFormat(pInfo.envs!, header)); - const filenames = pInfos.map(configExport.generateDotenvFilename); - const filesToWrite = fromEntries(zip(filenames, dotEnvs)); - filesToWrite[".env.local"] = - `${header}\n# .env.local file contains environment variables for the Functions Emulator.\n`; - filesToWrite[".env"] = - `${header}# .env file contains environment variables that applies to all projects.\n`; + // 4. Check for secrets + const secretsFound: Array<{ key: string; env: string }> = []; + for (const pInfo of pInfos) { + if (pInfo.envs) { + for (const env of pInfo.envs) { + if (configExport.isLikelySecret(env.origKey)) { + secretsFound.push({ key: env.origKey, env: env.newKey }); + } + } + } + } + + // Only prompt in interactive mode (not JSON, not dry-run, not non-interactive) + if (secretsFound.length > 0 && !options.dryRun && !isNonInteractive) { + logWarning( + "The following configs appear to be secrets and will be exported to .env files:\n" + + secretsFound.map((s) => ` - ${s.key} → ${s.env}`).join("\n") + + "\n\nConsider using Firebase Functions secrets instead: firebase functions:secrets:set", + ); + + const proceed = await confirm({ + message: "Continue exporting these potentially sensitive values?", + default: false, + }); + + if (!proceed) { + throw new FirebaseError("Export cancelled by user"); + } + } + + // 5. Generate .env file contents + const filesToWrite: Record = {}; + + for (const pInfo of pInfos) { + if (!pInfo.envs || pInfo.envs.length === 0) continue; + + // Create project-specific header + const projectInfo = pInfo.alias ? `${pInfo.projectId} (${pInfo.alias})` : pInfo.projectId; + const header = + `# Environment variables for Firebase project: ${projectInfo}\n` + + `# Exported by firebase functions:config:export on ${new Date().toLocaleDateString()}\n` + + `# Learn more: https://firebase.google.com/docs/functions/config-env#env-variables`; + + const filename = ".env"; + let envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); + + // Add helpful footer + const footer = + `\n\n# === NOTES ===\n` + + `# - Override values: Create .env.local or .env.${pInfo.projectId}\n` + + `# - Never commit files containing secrets\n` + + `# - Use 'firebase functions:secrets:set' for production secrets\n` + + `# - Learn more: https://firebase.google.com/docs/functions/config-env#env-variables`; + + envContent = envContent + footer; + filesToWrite[filename] = envContent; + } + + // 6. Handle output modes + if (isJsonMode) { + // Return JSON without writing files + return { + status: "success", + result: { + projects: pInfos.map((p) => ({ + projectId: p.projectId, + alias: p.alias, + configCount: p.envs?.length || 0, + })), + files: Object.keys(filesToWrite), + detectedSecrets: secretsFound, + warnings: { + secretCount: secretsFound.length, + message: + secretsFound.length > 0 + ? "Detected potential secrets. Consider using 'firebase functions:secrets:set' instead of storing in .env files" + : null, + }, + }, + }; + } + + if (options.dryRun) { + // Show preview without writing + logger.info("\n🔍 DRY RUN MODE - No files will be written\n"); + + // Show exactly what would be written to each file + for (const [filename, content] of Object.entries(filesToWrite)) { + console.log(clc.bold(clc.cyan(`=== ${filename} ===`))); + console.log(content); + console.log(); + } + + // Summary + const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0); + const fileCount = Object.keys(filesToWrite).length; + logger.info( + `Summary: ${totalConfigs} configs would be exported to ${fileCount} file${fileCount !== 1 ? "s" : ""}`, + ); + if (secretsFound.length > 0) { + logger.info(`${secretsFound.length} potential secrets detected (commented out for safety)`); + } + logger.info("\nRun without --dry-run to write .env files"); + return; + } + + // 7. Write files + const config = normalizeAndValidate(options.config.src.functions)[0]; + const functionsDir = config.source; for (const [filename, content] of Object.entries(filesToWrite)) { await options.config.askWriteProjectFile(path.join(functionsDir, filename), content); } + + // Show summary + const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0); + const filesCreated = Object.keys(filesToWrite).length; + + logger.info("\nExport Summary:"); + logger.info( + ` ${totalConfigs} config values exported to ${filesCreated} file${filesCreated !== 1 ? "s" : ""}`, + ); + + if (secretsFound.length > 0) { + logWarning(`${secretsFound.length} potential secrets exported`); + logger.info("\nNext steps:"); + logger.info(` 1. Review .env files for sensitive values`); + logger.info(` 2. Move secrets to Firebase: firebase functions:secrets:set`); + logger.info(` 3. Update your code to use the params API`); + } }); diff --git a/src/functions/runtimeConfigExport.spec.ts b/src/functions/runtimeConfigExport.spec.ts index 73ce06c1ae1..32c5ca3ab77 100644 --- a/src/functions/runtimeConfigExport.spec.ts +++ b/src/functions/runtimeConfigExport.spec.ts @@ -143,17 +143,225 @@ describe("functions-config-export", () => { }); }); - describe("generateDotenvFilename", () => { - it("should generate dotenv filename using project alias", () => { - expect( - configExport.generateDotenvFilename({ projectId: "my-project", alias: "prod" }), - ).to.equal(".env.prod"); + describe("isLikelySecret", () => { + it("should detect definite secret patterns", () => { + expect(configExport.isLikelySecret("api_key")).to.be.true; + expect(configExport.isLikelySecret("api-key")).to.be.true; + expect(configExport.isLikelySecret("API_KEY")).to.be.true; + expect(configExport.isLikelySecret("secret")).to.be.true; + expect(configExport.isLikelySecret("password")).to.be.true; + expect(configExport.isLikelySecret("passwd")).to.be.true; + expect(configExport.isLikelySecret("private_key")).to.be.true; + expect(configExport.isLikelySecret("private-key")).to.be.true; + expect(configExport.isLikelySecret("auth_token")).to.be.true; + expect(configExport.isLikelySecret("access_token")).to.be.true; + expect(configExport.isLikelySecret("client_auth")).to.be.true; + expect(configExport.isLikelySecret("api_credential")).to.be.true; }); - it("should generate dotenv filename using project id if alias doesn't exist", () => { - expect(configExport.generateDotenvFilename({ projectId: "my-project" })).to.equal( - ".env.my-project", + it("should detect likely secret patterns", () => { + expect(configExport.isLikelySecret("key")).to.be.true; + expect(configExport.isLikelySecret("token")).to.be.true; + expect(configExport.isLikelySecret("auth")).to.be.true; + expect(configExport.isLikelySecret("credential")).to.be.true; + expect(configExport.isLikelySecret("database_key")).to.be.true; + expect(configExport.isLikelySecret("encryption_key")).to.be.true; + }); + + it("should not detect non-secrets", () => { + expect(configExport.isLikelySecret("url")).to.be.false; + expect(configExport.isLikelySecret("name")).to.be.false; + expect(configExport.isLikelySecret("enabled")).to.be.false; + expect(configExport.isLikelySecret("port")).to.be.false; + expect(configExport.isLikelySecret("timeout")).to.be.false; + expect(configExport.isLikelySecret("max_retries")).to.be.false; + }); + }); + + describe("analyzeConfig", () => { + it("should categorize config keys correctly", () => { + const config = { + service: { + api_key: "secret123", + url: "https://example.com", + password: "pass123", + }, + database: { + host: "localhost", + token: "db-token", + }, + settings: { + enabled: true, + max_retries: 3, + }, + }; + + const analysis = configExport.analyzeConfig(config); + expect(analysis.definiteSecrets).to.include.members(["service.api_key", "service.password"]); + expect(analysis.likelySecrets).to.include.members(["database.token"]); + expect(analysis.regularConfigs).to.include.members([ + "service.url", + "database.host", + "settings.enabled", + "settings.max_retries", + ]); + }); + + it("should detect invalid keys", () => { + const config = { + "1invalid": "value", + firebase: "reserved", + valid_key: "value", + }; + + const analysis = configExport.analyzeConfig(config); + expect(analysis.invalidKeys).to.have.length(2); + expect(analysis.invalidKeys[0].originalKey).to.equal("1invalid"); + expect(analysis.invalidKeys[0].suggestedKey).to.equal("CONFIG_1INVALID"); + expect(analysis.invalidKeys[1].originalKey).to.equal("firebase"); + expect(analysis.invalidKeys[1].suggestedKey).to.equal("CONFIG_FIREBASE"); + }); + }); + + describe("getEnhancedComment", () => { + it("should add type hints", () => { + expect(configExport.getEnhancedComment("enabled", "true")).to.include("[boolean]"); + expect(configExport.getEnhancedComment("port", "3000")).to.include("[number]"); + expect(configExport.getEnhancedComment("items", "a,b,c")).to.include("[possible list]"); + }); + + it("should add secret warnings", () => { + expect(configExport.getEnhancedComment("api_key", "secret123")).to.include( + "⚠️ LIKELY SECRET", ); + expect(configExport.getEnhancedComment("password", "pass123")).to.include("⚠️ LIKELY SECRET"); + }); + + it("should always include original key", () => { + expect(configExport.getEnhancedComment("my.key", "value")).to.include("from my.key"); + }); + }); + + describe("enhancedToDotenvFormat", () => { + it("should separate secrets from regular configs", () => { + const envs = [ + { origKey: "service.api_key", newKey: "SERVICE_API_KEY", value: "secret123" }, + { origKey: "service.url", newKey: "SERVICE_URL", value: "https://example.com" }, + { origKey: "auth.token", newKey: "AUTH_TOKEN", value: "token123" }, + ]; + + const result = configExport.enhancedToDotenvFormat(envs); + expect(result).to.include("=== SAFE CONFIGS ==="); + expect(result).to.include("=== REVIEW REQUIRED - LIKELY SECRETS ==="); + expect(result).to.include('SERVICE_URL="https://example.com"'); + expect(result).to.include("# SERVICE_API_KEY"); + expect(result).to.include("# AUTH_TOKEN"); + }); + + it("should add instructions for secrets", () => { + const envs = [{ origKey: "stripe.api_key", newKey: "STRIPE_API_KEY", value: "sk_test_123" }]; + + const result = configExport.enhancedToDotenvFormat(envs); + expect(result).to.include("Move these to 'firebase functions:secrets:set'"); + expect(result).to.include("firebase functions:secrets:set STRIPE_API_KEY"); + }); + + it("should preserve header if provided", () => { + const envs = [ + { origKey: "service.url", newKey: "SERVICE_URL", value: "https://example.com" }, + ]; + const header = "# Custom header"; + + const result = configExport.enhancedToDotenvFormat(envs, header); + expect(result).to.start.with("# Custom header"); + }); + }); + + describe("validateConfigValues", () => { + it("should warn about multiline values", () => { + const pInfos = [ + { + projectId: "test", + envs: [{ origKey: "msg", newKey: "MSG", value: "line1\nline2" }], + }, + ]; + + const warnings = configExport.validateConfigValues(pInfos); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include("Contains newlines"); + }); + + it("should warn about very long values", () => { + const pInfos = [ + { + projectId: "test", + envs: [{ origKey: "data", newKey: "DATA", value: "x".repeat(1001) }], + }, + ]; + + const warnings = configExport.validateConfigValues(pInfos); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include("Very long value"); + }); + + it("should warn about empty values", () => { + const pInfos = [ + { + projectId: "test", + envs: [{ origKey: "empty", newKey: "EMPTY", value: "" }], + }, + ]; + + const warnings = configExport.validateConfigValues(pInfos); + expect(warnings).to.have.length(1); + expect(warnings[0]).to.include("Empty value"); + }); + }); + + describe("getValueForKey", () => { + it("should get nested values", () => { + const config = { + service: { + api: { + url: "https://example.com", + }, + }, + }; + + expect(configExport.getValueForKey(config, "service.api.url")).to.equal( + "https://example.com", + ); + }); + + it("should return undefined for missing keys", () => { + const config = { service: {} }; + expect(configExport.getValueForKey(config, "service.missing.key")).to.be.undefined; + }); + }); + + describe("buildCategorizedConfigs", () => { + it("should build categorized config objects", () => { + const config = { + service: { + api_key: "secret123", + url: "https://example.com", + }, + auth: { + token: "token123", + }, + }; + + const analysis = { + definiteSecrets: ["service.api_key"], + likelySecrets: ["auth.token"], + regularConfigs: ["service.url"], + invalidKeys: [], + }; + + const result = configExport.buildCategorizedConfigs(config, analysis); + expect(result.definiteSecrets).to.deep.equal({ "service.api_key": "secret123" }); + expect(result.likelySecrets).to.deep.equal({ "auth.token": "token123" }); + expect(result.regularConfigs).to.deep.equal({ "service.url": "https://example.com" }); }); }); }); diff --git a/src/functions/runtimeConfigExport.ts b/src/functions/runtimeConfigExport.ts index cc4b4be47ac..ba64d9e1f4e 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -123,14 +123,14 @@ export function configToEnv(configs: Record, prefix: string): C for (const [configKey, value] of flatten(configs)) { try { const envKey = convertKey(configKey, prefix); - success.push({ origKey: configKey, newKey: envKey, value: value as string }); + success.push({ origKey: configKey, newKey: envKey, value: value }); } catch (err: any) { if (err instanceof env.KeyValidationError) { errors.push({ origKey: configKey, newKey: err.key, err: err.message, - value: value as string, + value: value, }); } else { throw new FirebaseError("Unexpected error while converting config", { @@ -145,7 +145,6 @@ export function configToEnv(configs: Record, prefix: string): C /** * Fill in environment variables for each project by converting project's runtime config. - * * @return {ConfigToEnvResult} Collection of successful and errored conversion. */ export function hydrateEnvs(pInfos: ProjectConfigInfo[], prefix: string): string { @@ -193,9 +192,278 @@ export function toDotenvFormat(envs: EnvMap[], header = ""): string { ); } +export interface ConfigAnalysis { + definiteSecrets: string[]; + likelySecrets: string[]; + regularConfigs: string[]; + invalidKeys: Array<{ + originalKey: string; + suggestedKey: string; + reason: string; + }>; +} + +/** + * Check if a config key is likely to be a secret based on common patterns. + */ +export function isLikelySecret(key: string): boolean { + const secretPatterns = [ + /\bapi[_-]?key\b/i, + /\bsecret\b/i, + /\bpassw(ord|d)\b/i, + /\bprivate[_-]?key\b/i, + /_token$/i, + /_auth$/i, + /_credential$/i, + /\bkey\b/i, + /\btoken\b/i, + /\bauth\b/i, + /\bcredential\b/i, + ]; + + return secretPatterns.some((pattern) => pattern.test(key)); +} + +/** + * Analyze config keys to categorize them as secrets, likely secrets, or regular configs. + * Also detects invalid environment variable keys. + */ +export function analyzeConfig(config: Record): ConfigAnalysis { + const analysis: ConfigAnalysis = { + definiteSecrets: [], + likelySecrets: [], + regularConfigs: [], + invalidKeys: [], + }; + + const definitePatterns = [ + /\bapi[_-]?key\b/i, + /\bsecret\b/i, + /\bpassw(ord|d)\b/i, + /\bprivate[_-]?key\b/i, + /_token$/i, + /_auth$/i, + /_credential$/i, + ]; + + const likelyPatterns = [/\bkey\b/i, /\btoken\b/i, /\bauth\b/i, /\bcredential\b/i]; + + const servicePatterns = /^(stripe|twilio|sendgrid|aws|github|slack)\./i; + + function checkKey(key: string, path: string) { + // First check if the key would be valid as an env var + try { + const envKey = convertKey(path, ""); + env.validateKey(envKey); + } catch (err: any) { + if (err instanceof env.KeyValidationError) { + // Generate suggested key manually without validation + const baseKey = path.toUpperCase().replace(/\./g, "_").replace(/-/g, "_"); + const suggestedKey = "CONFIG_" + baseKey; + analysis.invalidKeys.push({ + originalKey: path, + suggestedKey: suggestedKey, + reason: err.message, + }); + return; // Don't categorize invalid keys further + } + // Re-throw unexpected errors + throw err; + } + + if (definitePatterns.some((p) => p.test(key))) { + analysis.definiteSecrets.push(path); + return; + } + + if (servicePatterns.test(path) || likelyPatterns.some((p) => p.test(key))) { + analysis.likelySecrets.push(path); + return; + } + + analysis.regularConfigs.push(path); + } + + function traverse(obj: any, path = "") { + for (const [key, value] of Object.entries(obj)) { + const fullPath = path ? `${path}.${key}` : key; + + if (typeof value === "object" && value !== null) { + traverse(value, fullPath); + } else { + checkKey(key, fullPath); + } + } + } + + traverse(config); + return analysis; +} + +/** + * Get enhanced comment for env var with type hints and secret warnings. + */ +export function getEnhancedComment(origKey: string, value: string): string { + const parts = [`from ${origKey}`]; + + // Add type hint + if (value === "true" || value === "false") { + parts.push("[boolean]"); + } else if (!isNaN(Number(value)) && value !== "") { + parts.push("[number]"); + } else if (value.includes(",")) { + parts.push("[possible list]"); + } + + // Add secret warning + if (isLikelySecret(origKey)) { + parts.push("⚠️ LIKELY SECRET"); + } + + return parts.length > 1 ? ` # ${parts.join(" ")}` : ` # ${parts[0]}`; +} + +/** + * Convert env var mapping to enhanced dotenv format with type hints and aligned comments. + */ +export function enhancedToDotenvFormat(envs: EnvMap[], header = ""): string { + // Separate secrets from regular configs + const secrets = envs.filter((env) => isLikelySecret(env.origKey)); + const regular = envs.filter((env) => !isLikelySecret(env.origKey)); + + let output = header; + + // Add regular configs with aligned comments + if (regular.length > 0) { + output += "\n\n# === SAFE CONFIGS ==="; + + // Calculate max length for alignment + const regularWithComments = regular.map(({ newKey, value, origKey }) => { + const envLine = `${newKey}="${escape(value)}"`; + const comment = getEnhancedComment(origKey, value); + return { envLine, comment }; + }); + + const maxEnvLength = Math.max(...regularWithComments.map(({ envLine }) => envLine.length)); + const paddedLength = Math.max(maxEnvLength + 2, 45); // Minimum padding for readability + + regularWithComments.forEach(({ envLine, comment }) => { + const padding = " ".repeat(paddedLength - envLine.length); + output += `\n${envLine}${padding}${comment}`; + }); + } + + // Add secrets section + if (secrets.length > 0) { + output += "\n\n# === REVIEW REQUIRED - LIKELY SECRETS ===\n"; + output += "# Move these to 'firebase functions:secrets:set' before deploying\n"; + output += "# Temporarily uncomment for local development only\n"; + + secrets.forEach(({ newKey, value }) => { + output += `\n# ${newKey}="${escape(value)}"`; + output += `\n# To set as secret: firebase functions:secrets:set ${newKey}`; + output += `\n`; + }); + } + + return output; +} + +/** + * Validate config values and return warnings for edge cases. + */ +export function validateConfigValues(pInfos: ProjectConfigInfo[]): string[] { + const warnings: string[] = []; + + for (const pInfo of pInfos) { + if (!pInfo.envs) continue; + + for (const env of pInfo.envs) { + // Check for multiline values + if (env.value.includes("\n")) { + warnings.push(`${env.origKey}: Contains newlines (will be escaped)`); + } + + // Check for very long values + if (env.value.length > 1000) { + warnings.push(`${env.origKey}: Very long value (${env.value.length} chars)`); + } + + // Check for empty values + if (env.value === "") { + warnings.push(`${env.origKey}: Empty value`); + } + } + } + + return warnings; +} + /** - * Generate dotenv filename for given project. + * Get value for a specific key path from nested config object. */ -export function generateDotenvFilename(pInfo: ProjectConfigInfo): string { - return `.env.${pInfo.alias ?? pInfo.projectId}`; +export function getValueForKey(config: Record, path: string): unknown { + const parts = path.split("."); + let current: any = config; + + for (const part of parts) { + if (current && typeof current === "object" && part in current) { + current = current[part]; + } else { + return undefined; + } + } + + return current; +} + +/** + * Build categorized config objects with their values based on analysis. + */ +export function buildCategorizedConfigs( + config: Record, + analysis: ConfigAnalysis, +): { + definiteSecrets: Record; + likelySecrets: Record; + regularConfigs: Record; + invalidKeys: Array<{ + originalKey: string; + suggestedKey: string; + value: unknown; + reason: string; + }>; +} { + const result = { + definiteSecrets: {} as Record, + likelySecrets: {} as Record, + regularConfigs: {} as Record, + invalidKeys: [] as Array<{ + originalKey: string; + suggestedKey: string; + value: unknown; + reason: string; + }>, + }; + + for (const path of analysis.definiteSecrets) { + result.definiteSecrets[path] = getValueForKey(config, path); + } + + for (const path of analysis.likelySecrets) { + result.likelySecrets[path] = getValueForKey(config, path); + } + + for (const path of analysis.regularConfigs) { + result.regularConfigs[path] = getValueForKey(config, path); + } + + for (const invalidKey of analysis.invalidKeys) { + result.invalidKeys.push({ + ...invalidKey, + value: getValueForKey(config, invalidKey.originalKey), + }); + } + + return result; }