From 6fed729acdd5946f0eecd2dc53be5259dce50cff Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 15 Jul 2025 08:47:48 -0700 Subject: [PATCH 1/7] feat(functions:config:export): add --prompt flag for AI migration assistance - Add --prompt flag to generate AI-optimized migration prompts - Implement secret detection with three-tier classification (definite/likely/regular) - Generate comprehensive prompt with system instructions and project context - Maintain backward compatibility with existing .env export functionality --- src/commands/functions-config-export.ts | 397 +++++++++++++++++++++++- 1 file changed, 396 insertions(+), 1 deletion(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 9c50f07af53..89258859443 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -13,6 +13,8 @@ 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 type { Options } from "../options"; import { normalizeAndValidate } from "../functions/projectConfig"; @@ -27,6 +29,364 @@ const REQUIRED_PERMISSIONS = [ const RESERVED_PROJECT_ALIAS = ["local"]; const MAX_ATTEMPTS = 3; +const MIGRATION_SYSTEM_PROMPT = `## 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: + - consider running \`firebase functions:config:export\` for automated export of functions configuration to .env format + - 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** – if \`functions:config:export\` prompts for a prefix (key starts with a digit), use the prefixed name (\`FF_CONFIG_\`). +- **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 – senitive 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 configrations 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`; + +interface ConfigAnalysis { + definiteSecrets: string[]; + likelySecrets: string[]; + regularConfigs: string[]; +} + +function analyzeConfig(config: Record): ConfigAnalysis { + const analysis: ConfigAnalysis = { + definiteSecrets: [], + likelySecrets: [], + regularConfigs: [] + }; + + 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) { + 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: string = '') { + 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; +} + +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; +} + +function buildCategorizedConfigs( + config: Record, + analysis: ConfigAnalysis +): { + definiteSecrets: Record; + likelySecrets: Record; + regularConfigs: Record; +} { + const result = { + definiteSecrets: {} as Record, + likelySecrets: {} as Record, + regularConfigs: {} as Record + }; + + 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); + } + + return result; +} + +function generateMigrationPrompt( + firebaseConfig: any, + categorizedConfigs: { + definiteSecrets: Record; + likelySecrets: Record; + regularConfigs: Record; + } +): string { + return `${MIGRATION_SYSTEM_PROMPT} + +--- + +## Your Project Context + +### firebase.json (functions section) +\`\`\`json +${JSON.stringify(firebaseConfig, null, 2)} +\`\`\` + +### Runtime Configuration Analysis + +#### Configs marked as LIKELY SECRETS by heuristic: +\`\`\`json +${JSON.stringify(categorizedConfigs.definiteSecrets, null, 2)} +\`\`\` + +#### Configs marked as POSSIBLE 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, especially for values marked as "POSSIBLE SECRETS". + +Please analyze this project and guide me through the migration following the workflow above.`; +} + function checkReservedAliases(pInfos: configExport.ProjectConfigInfo[]): void { for (const pInfo of pInfos) { if (pInfo.alias && RESERVED_PROJECT_ALIAS.includes(pInfo.alias)) { @@ -87,7 +447,8 @@ function fromEntries(itr: Iterable<[string, V]>): Record { } 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") .before(requirePermissions, [ "runtimeconfig.configs.list", "runtimeconfig.configs.get", @@ -100,6 +461,40 @@ export const command = new Command("functions:config:export") const config = normalizeAndValidate(options.config.src.functions)[0]; const functionsDir = config.source; + // If --prompt flag is set, generate migration prompt instead + if (options.prompt) { + logBullet("Generating AI migration prompt..."); + + // Get the current project + const projectId = getProjectId(options); + if (!projectId) { + throw new FirebaseError("Unable to determine project ID. Please specify using --project flag."); + } + + // Fetch runtime config + const runtimeConfig = await functionsConfig.materializeAll(projectId); + + // Analyze config for secrets + const analysis = analyzeConfig(runtimeConfig); + const categorizedConfigs = buildCategorizedConfigs(runtimeConfig, analysis); + + // Get firebase.json functions config + const firebaseJsonFunctions = options.config.src.functions; + + // Generate prompt + const prompt = generateMigrationPrompt(firebaseJsonFunctions, categorizedConfigs); + + // Output + 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; + } + + // Otherwise, continue with existing .env export logic let pInfos = configExport.getProjectInfos(options); checkReservedAliases(pInfos); From af0b44d56962b2bd1d972f810a219807c0d35d8f Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 15 Jul 2025 09:03:03 -0700 Subject: [PATCH 2/7] feat(functions:config:export): enhance export with security and migration features - Add secret detection warnings with user confirmation - Enhance .env comments with type hints and secret indicators - Add --dry-run flag to preview exports without writing files - Add migration hints for secrets, booleans, and numbers - Add value validation warnings for edge cases - Add comprehensive export summary with next steps - Improve .env file formatting with aligned comments --- src/commands/functions-config-export.ts | 225 +++++++++++++++++++++++- 1 file changed, 219 insertions(+), 6 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 89258859443..68e801dc84e 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -446,9 +446,160 @@ function fromEntries(itr: Iterable<[string, V]>): Record { return obj; } +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)); +} + +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]}`; +} + +function escape(s: string): string { + // Escape newlines, tabs, backslashes and quotes + return s.replace(/[\n\r\t\v\\"']/g, (ch) => { + const escapeMap: Record = { + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\v": "\\v", + "\\": "\\\\", + '"': '\\"', + "'": "\\'", + }; + return escapeMap[ch]; + }); +} + +function enhancedToDotenvFormat(envs: configExport.EnvMap[], header = ""): string { + const lines = envs.map(({ newKey, value, origKey }) => { + const comment = getEnhancedComment(origKey, value); + return `${newKey}="${escape(value)}"${comment}`; + }); + + // Calculate max line length for alignment + const maxLineLen = Math.max(...lines.map(l => l.indexOf(" #"))); + const alignedLines = lines.map(line => { + const commentIndex = line.indexOf(" #"); + const padding = " ".repeat(Math.max(0, maxLineLen - commentIndex)); + return line.replace(" #", padding + " #"); + }); + + return `${header}\n${alignedLines.join('\n')}`; +} + +function addMigrationHints(envs: configExport.EnvMap[]): string { + const hints: string[] = []; + + const secrets = envs.filter(e => isLikelySecret(e.origKey)); + const booleans = envs.filter(e => e.value === "true" || e.value === "false"); + const numbers = envs.filter(e => !isNaN(Number(e.value)) && e.value !== ""); + + if (secrets.length > 0) { + hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected. +# Consider using defineSecret() for: ${secrets.map(s => s.newKey).join(", ")} +# Run: firebase functions:secrets:set ${secrets[0].newKey}\n`); + } + + if (booleans.length > 0) { + hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected. +# Consider using defineBoolean() for: ${booleans.map(b => b.newKey).join(", ")}\n`); + } + + if (numbers.length > 0) { + hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected. +# Consider using defineInt() for: ${numbers.map(n => n.newKey).join(", ")}\n`); + } + + if (hints.length > 0) { + hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`); + } + + return hints.join('\n'); +} + +function validateConfigValues(pInfos: configExport.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; +} + +function showExportSummary(pInfos: configExport.ProjectConfigInfo[], filesToWrite: Record): void { + const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0); + const filesCreated = Object.keys(filesToWrite).length; + + logger.info("\n📊 Export Summary:"); + logger.info(` ✓ ${totalConfigs} config values exported`); + logger.info(` ✓ ${filesCreated} files created`); + + const secrets = pInfos.flatMap(p => + (p.envs || []).filter(e => isLikelySecret(e.origKey)) + ); + + if (secrets.length > 0) { + logger.info(` ⚠️ ${secrets.length} potential secrets exported`); + logger.info(`\n💡 Next 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`); + logger.info(` 4. Run 'firebase functions:config:export --prompt' for migration help`); + } +} + export const command = new Command("functions:config:export") .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") .before(requirePermissions, [ "runtimeconfig.configs.list", "runtimeconfig.configs.get", @@ -526,16 +677,78 @@ export const command = new Command("functions:config:export") attempts += 1; } + // Check for secrets and warn user + const secretsFound: string[] = []; + for (const pInfo of pInfos) { + if (pInfo.envs) { + for (const env of pInfo.envs) { + if (isLikelySecret(env.origKey)) { + secretsFound.push(`${env.origKey} → ${env.newKey}`); + } + } + } + } + + if (secretsFound.length > 0 && !options.dryRun) { + logWarning( + "⚠️ The following configs appear to be secrets and will be exported to .env files:\n" + + secretsFound.map(s => ` - ${s}`).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"); + } + } + + // Validate config values and show warnings + const valueWarnings = validateConfigValues(pInfos); + if (valueWarnings.length > 0) { + logWarning("⚠️ Value warnings:\n" + valueWarnings.map(w => ` - ${w}`).join('\n')); + } + 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)); + + // Generate enhanced .env files with migration hints + const filesToWrite: Record = {}; + + for (const pInfo of pInfos) { + if (!pInfo.envs || pInfo.envs.length === 0) continue; + + const filename = configExport.generateDotenvFilename(pInfo); + const migrationHints = addMigrationHints(pInfo.envs); + const envContent = enhancedToDotenvFormat(pInfo.envs, header); + + filesToWrite[filename] = migrationHints ? `${header}\n${migrationHints}\n${envContent}` : envContent; + } + + // Add default files 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`; + `${header}\n# .env file contains environment variables that applies to all projects.\n`; - for (const [filename, content] of Object.entries(filesToWrite)) { - await options.config.askWriteProjectFile(path.join(functionsDir, filename), content); + if (options.dryRun) { + logger.info("🔍 DRY RUN MODE - No files will be written\n"); + + for (const [filename, content] of Object.entries(filesToWrite)) { + console.log(clc.bold(clc.cyan(`=== ${filename} ===`))); + console.log(content); + console.log(); + } + + logger.info("✅ Dry run complete. Use without --dry-run to write files."); + } else { + for (const [filename, content] of Object.entries(filesToWrite)) { + await options.config.askWriteProjectFile(path.join(functionsDir, filename), content); + } + + // Show export summary + showExportSummary(pInfos, filesToWrite); } }); From c8290bee265cb283219e43a8bd04ba037c1e66ae Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 15 Jul 2025 09:35:56 -0700 Subject: [PATCH 3/7] refactor: extract config migration utilities to runtimeConfigExport module - Move secret detection, config analysis, and formatting functions to runtimeConfigExport.ts - Extract migration system prompt to prompts/functions-config-migration.md - Add prompts folder to package.json files array for distribution - Update functions:config:export command to use refactored utilities - Improve testability by moving logic to dedicated module --- package.json | 1 + prompts/functions-config-migration.md | 208 +++++++++++ src/commands/functions-config-export.ts | 464 +----------------------- src/functions/runtimeConfigExport.ts | 242 ++++++++++++ 4 files changed, 468 insertions(+), 447 deletions(-) create mode 100644 prompts/functions-config-migration.md 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..390bde1066c --- /dev/null +++ b/prompts/functions-config-migration.md @@ -0,0 +1,208 @@ +## 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** – if `functions:config:export` prompts for a prefix (key starts with a digit), use the prefixed name (`FF_CONFIG_`). +- **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 – senitive 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 configrations 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 \ No newline at end of file diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 68e801dc84e..03d527e4b6c 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as fs from "fs"; import * as clc from "colorette"; @@ -29,320 +30,15 @@ const REQUIRED_PERMISSIONS = [ const RESERVED_PROJECT_ALIAS = ["local"]; const MAX_ATTEMPTS = 3; -const MIGRATION_SYSTEM_PROMPT = `## 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: - - consider running \`firebase functions:config:export\` for automated export of functions configuration to .env format - - 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** – if \`functions:config:export\` prompts for a prefix (key starts with a digit), use the prefixed name (\`FF_CONFIG_\`). -- **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 – senitive 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 configrations 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`; - -interface ConfigAnalysis { - definiteSecrets: string[]; - likelySecrets: string[]; - regularConfigs: string[]; -} - -function analyzeConfig(config: Record): ConfigAnalysis { - const analysis: ConfigAnalysis = { - definiteSecrets: [], - likelySecrets: [], - regularConfigs: [] - }; - - 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) { - 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: string = '') { - 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); - } - } +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}`); } - - traverse(config); - return analysis; -} - -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; } -function buildCategorizedConfigs( - config: Record, - analysis: ConfigAnalysis -): { - definiteSecrets: Record; - likelySecrets: Record; - regularConfigs: Record; -} { - const result = { - definiteSecrets: {} as Record, - likelySecrets: {} as Record, - regularConfigs: {} as Record - }; - - 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); - } - - return result; -} function generateMigrationPrompt( firebaseConfig: any, @@ -352,7 +48,9 @@ function generateMigrationPrompt( regularConfigs: Record; } ): string { - return `${MIGRATION_SYSTEM_PROMPT} + const systemPrompt = loadMigrationPrompt(); + + return `${systemPrompt} --- @@ -446,134 +144,6 @@ function fromEntries(itr: Iterable<[string, V]>): Record { return obj; } -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)); -} - -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]}`; -} - -function escape(s: string): string { - // Escape newlines, tabs, backslashes and quotes - return s.replace(/[\n\r\t\v\\"']/g, (ch) => { - const escapeMap: Record = { - "\n": "\\n", - "\r": "\\r", - "\t": "\\t", - "\v": "\\v", - "\\": "\\\\", - '"': '\\"', - "'": "\\'", - }; - return escapeMap[ch]; - }); -} - -function enhancedToDotenvFormat(envs: configExport.EnvMap[], header = ""): string { - const lines = envs.map(({ newKey, value, origKey }) => { - const comment = getEnhancedComment(origKey, value); - return `${newKey}="${escape(value)}"${comment}`; - }); - - // Calculate max line length for alignment - const maxLineLen = Math.max(...lines.map(l => l.indexOf(" #"))); - const alignedLines = lines.map(line => { - const commentIndex = line.indexOf(" #"); - const padding = " ".repeat(Math.max(0, maxLineLen - commentIndex)); - return line.replace(" #", padding + " #"); - }); - - return `${header}\n${alignedLines.join('\n')}`; -} - -function addMigrationHints(envs: configExport.EnvMap[]): string { - const hints: string[] = []; - - const secrets = envs.filter(e => isLikelySecret(e.origKey)); - const booleans = envs.filter(e => e.value === "true" || e.value === "false"); - const numbers = envs.filter(e => !isNaN(Number(e.value)) && e.value !== ""); - - if (secrets.length > 0) { - hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected. -# Consider using defineSecret() for: ${secrets.map(s => s.newKey).join(", ")} -# Run: firebase functions:secrets:set ${secrets[0].newKey}\n`); - } - - if (booleans.length > 0) { - hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected. -# Consider using defineBoolean() for: ${booleans.map(b => b.newKey).join(", ")}\n`); - } - - if (numbers.length > 0) { - hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected. -# Consider using defineInt() for: ${numbers.map(n => n.newKey).join(", ")}\n`); - } - - if (hints.length > 0) { - hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`); - } - - return hints.join('\n'); -} - -function validateConfigValues(pInfos: configExport.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; -} - function showExportSummary(pInfos: configExport.ProjectConfigInfo[], filesToWrite: Record): void { const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0); const filesCreated = Object.keys(filesToWrite).length; @@ -583,7 +153,7 @@ function showExportSummary(pInfos: configExport.ProjectConfigInfo[], filesToWrit logger.info(` ✓ ${filesCreated} files created`); const secrets = pInfos.flatMap(p => - (p.envs || []).filter(e => isLikelySecret(e.origKey)) + (p.envs || []).filter(e => configExport.isLikelySecret(e.origKey)) ); if (secrets.length > 0) { @@ -626,8 +196,8 @@ export const command = new Command("functions:config:export") const runtimeConfig = await functionsConfig.materializeAll(projectId); // Analyze config for secrets - const analysis = analyzeConfig(runtimeConfig); - const categorizedConfigs = buildCategorizedConfigs(runtimeConfig, analysis); + const analysis = configExport.analyzeConfig(runtimeConfig); + const categorizedConfigs = configExport.buildCategorizedConfigs(runtimeConfig, analysis); // Get firebase.json functions config const firebaseJsonFunctions = options.config.src.functions; @@ -682,7 +252,7 @@ export const command = new Command("functions:config:export") for (const pInfo of pInfos) { if (pInfo.envs) { for (const env of pInfo.envs) { - if (isLikelySecret(env.origKey)) { + if (configExport.isLikelySecret(env.origKey)) { secretsFound.push(`${env.origKey} → ${env.newKey}`); } } @@ -707,7 +277,7 @@ export const command = new Command("functions:config:export") } // Validate config values and show warnings - const valueWarnings = validateConfigValues(pInfos); + const valueWarnings = configExport.validateConfigValues(pInfos); if (valueWarnings.length > 0) { logWarning("⚠️ Value warnings:\n" + valueWarnings.map(w => ` - ${w}`).join('\n')); } @@ -721,8 +291,8 @@ export const command = new Command("functions:config:export") if (!pInfo.envs || pInfo.envs.length === 0) continue; const filename = configExport.generateDotenvFilename(pInfo); - const migrationHints = addMigrationHints(pInfo.envs); - const envContent = enhancedToDotenvFormat(pInfo.envs, header); + const migrationHints = configExport.addMigrationHints(pInfo.envs); + const envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); filesToWrite[filename] = migrationHints ? `${header}\n${migrationHints}\n${envContent}` : envContent; } diff --git a/src/functions/runtimeConfigExport.ts b/src/functions/runtimeConfigExport.ts index cc4b4be47ac..d1be86ce287 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -199,3 +199,245 @@ export function toDotenvFormat(envs: EnvMap[], header = ""): string { export function generateDotenvFilename(pInfo: ProjectConfigInfo): string { return `.env.${pInfo.alias ?? pInfo.projectId}`; } + +export interface ConfigAnalysis { + definiteSecrets: string[]; + likelySecrets: string[]; + regularConfigs: 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. + */ +export function analyzeConfig(config: Record): ConfigAnalysis { + const analysis: ConfigAnalysis = { + definiteSecrets: [], + likelySecrets: [], + regularConfigs: [] + }; + + 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) { + 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: string = '') { + 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 { + const lines = envs.map(({ newKey, value, origKey }) => { + const comment = getEnhancedComment(origKey, value); + return `${newKey}="${escape(value)}"${comment}`; + }); + + // Calculate max line length for alignment + const maxLineLen = Math.max(...lines.map(l => l.indexOf(" #"))); + const alignedLines = lines.map(line => { + const commentIndex = line.indexOf(" #"); + const padding = " ".repeat(Math.max(0, maxLineLen - commentIndex)); + return line.replace(" #", padding + " #"); + }); + + return `${header}\n${alignedLines.join('\n')}`; +} + +/** + * Generate migration hints for env files based on detected patterns. + */ +export function addMigrationHints(envs: EnvMap[]): string { + const hints: string[] = []; + + const secrets = envs.filter(e => isLikelySecret(e.origKey)); + const booleans = envs.filter(e => e.value === "true" || e.value === "false"); + const numbers = envs.filter(e => !isNaN(Number(e.value)) && e.value !== ""); + + if (secrets.length > 0) { + hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected. +# Consider using defineSecret() for: ${secrets.map(s => s.newKey).join(", ")} +# Run: firebase functions:secrets:set ${secrets[0].newKey}\n`); + } + + if (booleans.length > 0) { + hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected. +# Consider using defineBoolean() for: ${booleans.map(b => b.newKey).join(", ")}\n`); + } + + if (numbers.length > 0) { + hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected. +# Consider using defineInt() for: ${numbers.map(n => n.newKey).join(", ")}\n`); + } + + if (hints.length > 0) { + hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`); + } + + return hints.join('\n'); +} + +/** + * 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; +} + +/** + * Get value for a specific key path from nested config object. + */ +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; +} { + const result = { + definiteSecrets: {} as Record, + likelySecrets: {} as Record, + regularConfigs: {} as Record + }; + + 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); + } + + return result; +} From 86fbfa617c0db6d843b99b26e2e7cee55a42f284 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Tue, 15 Jul 2025 13:17:33 -0700 Subject: [PATCH 4/7] feat(functions:config:export): add AI migration prompt and enhance export functionality - Add --prompt flag to generate AI-optimized migration prompts - Add --dry-run flag to preview exports without writing files - Add --prefix flag to handle invalid environment variable keys - Implement smart secret detection with pattern matching - Enhance .env file formatting with aligned comments and categorized sections - Add secret categorization (definite secrets vs likely secrets vs safe configs) - Comment out detected secrets with migration instructions - Improve error messages for invalid prefixes - Fix JSON mode to be truly non-interactive - Add helpful migration hints and footer notes to .env files --- prompts/functions-config-migration.md | 5 +- src/commands/functions-config-export.ts | 356 ++++++++++++++---------- src/functions/runtimeConfigExport.ts | 235 +++++++++++----- 3 files changed, 380 insertions(+), 216 deletions(-) diff --git a/prompts/functions-config-migration.md b/prompts/functions-config-migration.md index 390bde1066c..f5f37fd821e 100644 --- a/prompts/functions-config-migration.md +++ b/prompts/functions-config-migration.md @@ -40,7 +40,10 @@ Do not prefix parameter names with the codebase name (e.g., avoid TEAM_A_API_KEY - **Injected outside Firebase at runtime?** → `process.env.NAME` ### 3. Edge‑case notes -- **Invalid keys** – if `functions:config:export` prompts for a prefix (key starts with a digit), use the prefixed name (`FF_CONFIG_`). +- **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 diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 03d527e4b6c..d68996766e5 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -1,9 +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"; @@ -16,9 +14,9 @@ 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", @@ -28,7 +26,18 @@ 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.`, + ); + delete pInfo.alias; + } + } +} function loadMigrationPrompt(): string { try { @@ -39,17 +48,36 @@ function loadMigrationPrompt(): string { } } - 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} --- @@ -62,7 +90,7 @@ ${JSON.stringify(firebaseConfig, null, 2)} \`\`\` ### Runtime Configuration Analysis - +${invalidKeysSection} #### Configs marked as LIKELY SECRETS by heuristic: \`\`\`json ${JSON.stringify(categorizedConfigs.definiteSecrets, null, 2)} @@ -80,25 +108,13 @@ ${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, especially for values marked as "POSSIBLE SECRETS". +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.`; } -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.`, - ); - delete pInfo.alias; - } - } -} - /* 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 { +async function checkRequiredPermission(pInfos: any[]): Promise { pInfos = pInfos.filter((pInfo) => !pInfo.config); const testPermissions = pInfos.map((pInfo) => testIamPermissions(pInfo.projectId, REQUIRED_PERMISSIONS), @@ -127,49 +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; -} - -function showExportSummary(pInfos: configExport.ProjectConfigInfo[], filesToWrite: Record): void { - const totalConfigs = pInfos.reduce((sum, p) => sum + (p.envs?.length || 0), 0); - const filesCreated = Object.keys(filesToWrite).length; - - logger.info("\n📊 Export Summary:"); - logger.info(` ✓ ${totalConfigs} config values exported`); - logger.info(` ✓ ${filesCreated} files created`); - - const secrets = pInfos.flatMap(p => - (p.envs || []).filter(e => configExport.isLikelySecret(e.origKey)) - ); - - if (secrets.length > 0) { - logger.info(` ⚠️ ${secrets.length} potential secrets exported`); - logger.info(`\n💡 Next 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`); - logger.info(` 4. Run 'firebase functions:config:export --prompt' for migration help`); - } -} - export const command = new Command("functions:config:export") - .description("export environment config as environment variables in dotenv format (or generate AI migration prompt with --prompt)") + .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", @@ -177,63 +157,69 @@ 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(", ") + + "]", + ); + + await configExport.hydrateConfigs(pInfos); + await checkRequiredPermission(pInfos); + pInfos = pInfos.filter((pInfo) => pInfo.config); - // If --prompt flag is set, generate migration prompt instead + // 2. Handle --prompt mode early if (options.prompt) { - logBullet("Generating AI migration prompt..."); - - // Get the current project const projectId = getProjectId(options); if (!projectId) { - throw new FirebaseError("Unable to determine project ID. Please specify using --project flag."); + throw new FirebaseError( + "Unable to determine project ID. Please specify using --project flag.", + ); } - - // Fetch runtime config + const runtimeConfig = await functionsConfig.materializeAll(projectId); - - // Analyze config for secrets const analysis = configExport.analyzeConfig(runtimeConfig); const categorizedConfigs = configExport.buildCategorizedConfigs(runtimeConfig, analysis); - - // Get firebase.json functions config - const firebaseJsonFunctions = options.config.src.functions; - - // Generate prompt - const prompt = generateMigrationPrompt(firebaseJsonFunctions, categorizedConfigs); - - // Output - logger.info("✅ Migration prompt generated successfully!"); + + 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; } - // Otherwise, continue with existing .env export logic - let pInfos = configExport.getProjectInfos(options); - checkReservedAliases(pInfos); - - logBullet( - "Importing functions configs from projects [" + - pInfos.map(({ projectId }) => `${clc.bold(projectId)}`).join(", ") + - "]", - ); - - await configExport.hydrateConfigs(pInfos); - 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(", ")}]`); + // 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."); @@ -243,82 +229,168 @@ 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 || /^[A-Z_]/.test(input)) { + return true; + } + return "Prefix must start with an uppercase letter or underscore (e.g., CONFIG_, APP_)"; + }, + }); attempts += 1; } - // Check for secrets and warn user - const secretsFound: string[] = []; + // 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(`${env.origKey} → ${env.newKey}`); + secretsFound.push({ key: env.origKey, env: env.newKey }); } } } } - if (secretsFound.length > 0 && !options.dryRun) { + // 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}`).join('\n') + - "\n\nConsider using Firebase Functions secrets instead: firebase functions:secrets:set" + "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 + default: false, }); - + if (!proceed) { throw new FirebaseError("Export cancelled by user"); } } - // Validate config values and show warnings - const valueWarnings = configExport.validateConfigValues(pInfos); - if (valueWarnings.length > 0) { - logWarning("⚠️ Value warnings:\n" + valueWarnings.map(w => ` - ${w}`).join('\n')); - } - + // 5. Generate .env file contents const header = `# Exported firebase functions:config:export command on ${new Date().toLocaleDateString()}`; - - // Generate enhanced .env files with migration hints const filesToWrite: Record = {}; - + for (const pInfo of pInfos) { if (!pInfo.envs || pInfo.envs.length === 0) continue; - + const filename = configExport.generateDotenvFilename(pInfo); - const migrationHints = configExport.addMigrationHints(pInfo.envs); - const envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); - - filesToWrite[filename] = migrationHints ? `${header}\n${migrationHints}\n${envContent}` : envContent; + let envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); + + // Add helpful footer + const footer = + `\n\n# === NOTES ===\n` + + `# - Override values: Create .env.local or .env\n` + + `# - Never commit files containing secrets\n` + + `# - Use 'firebase functions:secrets:set' for production secrets\n`; + + 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, + }, + }, + }; } - - // Add default files - filesToWrite[".env.local"] = - `${header}\n# .env.local file contains environment variables for the Functions Emulator.\n`; - filesToWrite[".env"] = - `${header}\n# .env file contains environment variables that applies to all projects.\n`; if (options.dryRun) { - logger.info("🔍 DRY RUN MODE - No files will be written\n"); - + // 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(); } - - logger.info("✅ Dry run complete. Use without --dry-run to write files."); - } else { - for (const [filename, content] of Object.entries(filesToWrite)) { - await options.config.askWriteProjectFile(path.join(functionsDir, filename), content); + + // 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)`); } - - // Show export summary - showExportSummary(pInfos, filesToWrite); + 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.ts b/src/functions/runtimeConfigExport.ts index d1be86ce287..e00c76be45e 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -8,6 +8,9 @@ import { getProjectId } from "../projectUtils"; import { loadRC } from "../rc"; import { logWarning } from "../utils"; import { flatten } from "../functional"; +import { normalizeAndValidate } from "./projectConfig"; + +export { normalizeAndValidate }; export interface ProjectConfigInfo { projectId: string; @@ -145,7 +148,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 { @@ -204,6 +206,11 @@ export interface ConfigAnalysis { definiteSecrets: string[]; likelySecrets: string[]; regularConfigs: string[]; + invalidKeys: Array<{ + originalKey: string; + suggestedKey: string; + reason: string; + }>; } /** @@ -221,22 +228,24 @@ export function isLikelySecret(key: string): boolean { /\bkey\b/i, /\btoken\b/i, /\bauth\b/i, - /\bcredential\b/i + /\bcredential\b/i, ]; - - return secretPatterns.some(pattern => pattern.test(key)); + + 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: [] + regularConfigs: [], + invalidKeys: [], }; - + const definitePatterns = [ /\bapi[_-]?key\b/i, /\bsecret\b/i, @@ -244,44 +253,59 @@ export function analyzeConfig(config: Record): ConfigAnalysis { /\bprivate[_-]?key\b/i, /_token$/i, /_auth$/i, - /_credential$/i - ]; - - const likelyPatterns = [ - /\bkey\b/i, - /\btoken\b/i, - /\bauth\b/i, - /\bcredential\b/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) { - if (definitePatterns.some(p => p.test(key))) { + // 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))) { + + if (servicePatterns.test(path) || likelyPatterns.some((p) => p.test(key))) { analysis.likelySecrets.push(path); return; } - + analysis.regularConfigs.push(path); } - - function traverse(obj: any, path: string = '') { + + 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) { + + if (typeof value === "object" && value !== null) { traverse(value, fullPath); } else { checkKey(key, fullPath); } } } - + traverse(config); return analysis; } @@ -291,7 +315,7 @@ export function analyzeConfig(config: Record): ConfigAnalysis { */ export function getEnhancedComment(origKey: string, value: string): string { const parts = [`from ${origKey}`]; - + // Add type hint if (value === "true" || value === "false") { parts.push("[boolean]"); @@ -300,12 +324,12 @@ export function getEnhancedComment(origKey: string, value: string): string { } 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]}`; } @@ -313,20 +337,66 @@ export function getEnhancedComment(origKey: string, value: string): string { * Convert env var mapping to enhanced dotenv format with type hints and aligned comments. */ export function enhancedToDotenvFormat(envs: EnvMap[], header = ""): string { - const lines = envs.map(({ newKey, value, origKey }) => { - const comment = getEnhancedComment(origKey, value); - return `${newKey}="${escape(value)}"${comment}`; - }); - - // Calculate max line length for alignment - const maxLineLen = Math.max(...lines.map(l => l.indexOf(" #"))); - const alignedLines = lines.map(line => { - const commentIndex = line.indexOf(" #"); - const padding = " ".repeat(Math.max(0, maxLineLen - commentIndex)); - return line.replace(" #", padding + " #"); - }); - - return `${header}\n${alignedLines.join('\n')}`; + // 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, origKey }) => { + const secretType = getSecretType(origKey); + output += `\n# ${newKey}="${escape(value)}"`; + output += `\n# To set as secret: firebase functions:secrets:set ${newKey}`; + output += `\n`; + }); + } + + return output; +} + +/** + * Get a description of why this is detected as a secret + */ +function getSecretType(key: string): string { + const lowerKey = key.toLowerCase(); + if (lowerKey.includes("api_key") || lowerKey.includes("api-key")) return "API key pattern"; + if (lowerKey.includes("secret")) return "Contains 'secret'"; + if (lowerKey.includes("password") || lowerKey.includes("passwd")) return "Password pattern"; + if (lowerKey.includes("private_key") || lowerKey.includes("private-key")) + return "Private key pattern"; + if (lowerKey.endsWith("_token") || lowerKey.endsWith("-token")) return "Token pattern"; + if (lowerKey.endsWith("_auth") || lowerKey.endsWith("-auth")) return "Auth pattern"; + if (/^(stripe|twilio|sendgrid|aws|github|slack)\./i.test(key)) return "Service-specific pattern"; + if (lowerKey.includes("key")) return "Contains 'key'"; + if (lowerKey.includes("token")) return "Contains 'token'"; + if (lowerKey.includes("credential")) return "Contains 'credential'"; + return "Matches secret pattern"; } /** @@ -334,32 +404,32 @@ export function enhancedToDotenvFormat(envs: EnvMap[], header = ""): string { */ export function addMigrationHints(envs: EnvMap[]): string { const hints: string[] = []; - - const secrets = envs.filter(e => isLikelySecret(e.origKey)); - const booleans = envs.filter(e => e.value === "true" || e.value === "false"); - const numbers = envs.filter(e => !isNaN(Number(e.value)) && e.value !== ""); - + + const secrets = envs.filter((e) => isLikelySecret(e.origKey)); + const booleans = envs.filter((e) => e.value === "true" || e.value === "false"); + const numbers = envs.filter((e) => !isNaN(Number(e.value)) && e.value !== ""); + if (secrets.length > 0) { hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected. -# Consider using defineSecret() for: ${secrets.map(s => s.newKey).join(", ")} +# Consider using defineSecret() for: ${secrets.map((s) => s.newKey).join(", ")} # Run: firebase functions:secrets:set ${secrets[0].newKey}\n`); } - + if (booleans.length > 0) { hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected. -# Consider using defineBoolean() for: ${booleans.map(b => b.newKey).join(", ")}\n`); +# Consider using defineBoolean() for: ${booleans.map((b) => b.newKey).join(", ")}\n`); } - + if (numbers.length > 0) { hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected. -# Consider using defineInt() for: ${numbers.map(n => n.newKey).join(", ")}\n`); +# Consider using defineInt() for: ${numbers.map((n) => n.newKey).join(", ")}\n`); } - + if (hints.length > 0) { hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`); } - - return hints.join('\n'); + + return hints.join("\n"); } /** @@ -367,28 +437,28 @@ export function addMigrationHints(envs: EnvMap[]): string { */ 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')) { + 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 === '') { + if (env.value === "") { warnings.push(`${env.origKey}: Empty value`); } } } - + return warnings; } @@ -396,17 +466,17 @@ export function validateConfigValues(pInfos: ProjectConfigInfo[]): string[] { * Get value for a specific key path from nested config object. */ export function getValueForKey(config: Record, path: string): unknown { - const parts = path.split('.'); + const parts = path.split("."); let current: any = config; - + for (const part of parts) { - if (current && typeof current === 'object' && part in current) { + if (current && typeof current === "object" && part in current) { current = current[part]; } else { return undefined; } } - + return current; } @@ -414,30 +484,49 @@ export function getValueForKey(config: Record, path: string): u * Build categorized config objects with their values based on analysis. */ export function buildCategorizedConfigs( - config: Record, - analysis: ConfigAnalysis + 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 + 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; } From 6d573bdb8669876349a6155efc563d300aca2b3c Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 16 Jul 2025 13:09:03 -0700 Subject: [PATCH 5/7] refactor: simplify .env file naming and add Firebase docs references - Change export filename from .env.{project} to just .env - Add project information to file header instead of filename - Include Firebase documentation link in header and footer - Update override instructions to suggest .env.local or .env.{projectId} - Update reserved alias warning message to reflect new behavior --- src/commands/functions-config-export.ts | 15 +++++++++++---- src/functions/runtimeConfigExport.ts | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index d68996766e5..975a675ffc0 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -32,7 +32,7 @@ function checkReservedAliases(pInfos: configExport.ProjectConfigInfo[]): void { 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; } @@ -301,21 +301,28 @@ export const command = new Command("functions:config:export") } // 5. Generate .env file contents - const header = `# Exported firebase functions:config:export command on ${new Date().toLocaleDateString()}`; 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 = configExport.generateDotenvFilename(pInfo); let envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); // Add helpful footer const footer = `\n\n# === NOTES ===\n` + - `# - Override values: Create .env.local or .env\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`; + `# - 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; diff --git a/src/functions/runtimeConfigExport.ts b/src/functions/runtimeConfigExport.ts index e00c76be45e..6c678142f71 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -199,7 +199,7 @@ export function toDotenvFormat(envs: EnvMap[], header = ""): string { * Generate dotenv filename for given project. */ export function generateDotenvFilename(pInfo: ProjectConfigInfo): string { - return `.env.${pInfo.alias ?? pInfo.projectId}`; + return `.env`; } export interface ConfigAnalysis { From b2dc0823842e9246583e5a9a4ba5141e2514dc0e Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 16 Jul 2025 14:31:51 -0700 Subject: [PATCH 6/7] fix: address PR review feedback - Fix typos in migration prompt documentation - Fix confusing section headers (DEFINITE vs LIKELY secrets) - Improve type safety by removing 'any' type from checkRequiredPermission - Fix prefix validation to reject empty strings - Remove unused code (secretType variable, getSecretType function) - Remove useless generateDotenvFilename function that just returned '.env' - Keep validateConfigValues for potential future use (has tests) --- prompts/functions-config-migration.md | 34 ++-- src/commands/functions-config-export.ts | 15 +- src/functions/runtimeConfigExport.spec.ts | 224 +++++++++++++++++++++- src/functions/runtimeConfigExport.ts | 66 +------ 4 files changed, 250 insertions(+), 89 deletions(-) diff --git a/prompts/functions-config-migration.md b/prompts/functions-config-migration.md index f5f37fd821e..98cab73f906 100644 --- a/prompts/functions-config-migration.md +++ b/prompts/functions-config-migration.md @@ -1,13 +1,14 @@ ## 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).** +**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. **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` @@ -22,6 +23,7 @@ - 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). @@ -40,6 +42,7 @@ Do not prefix parameter names with the codebase name (e.g., avoid TEAM_A_API_KEY - **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 @@ -66,10 +69,11 @@ import { defineString } from "firebase-functions/params"; const GREETING = defineString("SOME_GREETING"); console.log(GREETING.value()); ``` + -### Example 2 – senitive configurations as secrets +### Example 2 – sensitive configurations as secrets **Before** @@ -95,8 +99,10 @@ export const processPayment = onCall( () => { const apiKey = STRIPE_KEY.value(); // ... -}); + }, +); ``` + @@ -106,12 +112,14 @@ export const processPayment = onCall( 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"; @@ -144,7 +152,7 @@ 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 configrations are flattened +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"); @@ -152,10 +160,7 @@ 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." - ); + throw new HttpsError("unauthenticated", "The function must be called while authenticated."); } const service = new ThirdPartyService({ @@ -171,15 +176,17 @@ export const processUserData = onCall( // ... 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"; @@ -192,6 +199,7 @@ const accountSid = providerConfig["account-sid"]; // not sensitive ``` **After** + ```ts import { defineSecret, defineString } from "firebase-functions/params"; @@ -204,8 +212,10 @@ 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 \ No newline at end of file +- 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 975a675ffc0..bf4f8e8b4a0 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -91,12 +91,12 @@ ${JSON.stringify(firebaseConfig, null, 2)} ### Runtime Configuration Analysis ${invalidKeysSection} -#### Configs marked as LIKELY SECRETS by heuristic: +#### Configs marked as DEFINITE SECRETS by heuristic: \`\`\`json ${JSON.stringify(categorizedConfigs.definiteSecrets, null, 2)} \`\`\` -#### Configs marked as POSSIBLE SECRETS by heuristic: +#### Configs marked as LIKELY SECRETS by heuristic: \`\`\`json ${JSON.stringify(categorizedConfigs.likelySecrets, null, 2)} \`\`\` @@ -114,7 +114,7 @@ Please analyze this project and guide me through the migration following the wor } /* For projects where we failed to fetch the runtime config, find out what permissions are missing in the project. */ -async function checkRequiredPermission(pInfos: any[]): Promise { +async function checkRequiredPermission(pInfos: configExport.ProjectConfigInfo[]): Promise { pInfos = pInfos.filter((pInfo) => !pInfo.config); const testPermissions = pInfos.map((pInfo) => testIamPermissions(pInfo.projectId, REQUIRED_PERMISSIONS), @@ -261,7 +261,10 @@ export const command = new Command("functions:config:export") message: "Enter a PREFIX to rename invalid environment variable keys (must start with uppercase letter or _):", validate: (input) => { - if (!input || /^[A-Z_]/.test(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_)"; @@ -308,12 +311,12 @@ export const command = new Command("functions:config:export") // Create project-specific header const projectInfo = pInfo.alias ? `${pInfo.projectId} (${pInfo.alias})` : pInfo.projectId; - const header = + 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 = configExport.generateDotenvFilename(pInfo); + const filename = ".env"; let envContent = configExport.enhancedToDotenvFormat(pInfo.envs, header); // Add helpful footer 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 6c678142f71..893a97c88c0 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -126,14 +126,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", { @@ -195,13 +195,6 @@ export function toDotenvFormat(envs: EnvMap[], header = ""): string { ); } -/** - * Generate dotenv filename for given project. - */ -export function generateDotenvFilename(pInfo: ProjectConfigInfo): string { - return `.env`; -} - export interface ConfigAnalysis { definiteSecrets: string[]; likelySecrets: string[]; @@ -369,8 +362,7 @@ export function enhancedToDotenvFormat(envs: EnvMap[], header = ""): string { output += "# Move these to 'firebase functions:secrets:set' before deploying\n"; output += "# Temporarily uncomment for local development only\n"; - secrets.forEach(({ newKey, value, origKey }) => { - const secretType = getSecretType(origKey); + secrets.forEach(({ newKey, value }) => { output += `\n# ${newKey}="${escape(value)}"`; output += `\n# To set as secret: firebase functions:secrets:set ${newKey}`; output += `\n`; @@ -380,58 +372,6 @@ export function enhancedToDotenvFormat(envs: EnvMap[], header = ""): string { return output; } -/** - * Get a description of why this is detected as a secret - */ -function getSecretType(key: string): string { - const lowerKey = key.toLowerCase(); - if (lowerKey.includes("api_key") || lowerKey.includes("api-key")) return "API key pattern"; - if (lowerKey.includes("secret")) return "Contains 'secret'"; - if (lowerKey.includes("password") || lowerKey.includes("passwd")) return "Password pattern"; - if (lowerKey.includes("private_key") || lowerKey.includes("private-key")) - return "Private key pattern"; - if (lowerKey.endsWith("_token") || lowerKey.endsWith("-token")) return "Token pattern"; - if (lowerKey.endsWith("_auth") || lowerKey.endsWith("-auth")) return "Auth pattern"; - if (/^(stripe|twilio|sendgrid|aws|github|slack)\./i.test(key)) return "Service-specific pattern"; - if (lowerKey.includes("key")) return "Contains 'key'"; - if (lowerKey.includes("token")) return "Contains 'token'"; - if (lowerKey.includes("credential")) return "Contains 'credential'"; - return "Matches secret pattern"; -} - -/** - * Generate migration hints for env files based on detected patterns. - */ -export function addMigrationHints(envs: EnvMap[]): string { - const hints: string[] = []; - - const secrets = envs.filter((e) => isLikelySecret(e.origKey)); - const booleans = envs.filter((e) => e.value === "true" || e.value === "false"); - const numbers = envs.filter((e) => !isNaN(Number(e.value)) && e.value !== ""); - - if (secrets.length > 0) { - hints.push(`# 🔐 Migration hint: ${secrets.length} potential secrets detected. -# Consider using defineSecret() for: ${secrets.map((s) => s.newKey).join(", ")} -# Run: firebase functions:secrets:set ${secrets[0].newKey}\n`); - } - - if (booleans.length > 0) { - hints.push(`# 📊 Migration hint: ${booleans.length} boolean values detected. -# Consider using defineBoolean() for: ${booleans.map((b) => b.newKey).join(", ")}\n`); - } - - if (numbers.length > 0) { - hints.push(`# 🔢 Migration hint: ${numbers.length} numeric values detected. -# Consider using defineInt() for: ${numbers.map((n) => n.newKey).join(", ")}\n`); - } - - if (hints.length > 0) { - hints.push(`# 💡 For AI-assisted migration, run: firebase functions:config:export --prompt\n`); - } - - return hints.join("\n"); -} - /** * Validate config values and return warnings for edge cases. */ From 7e0a27aae1fbf5a6a6526c1fd306362f073c83e1 Mon Sep 17 00:00:00 2001 From: Daniel Young Lee Date: Wed, 16 Jul 2025 14:38:23 -0700 Subject: [PATCH 7/7] refactor: remove unnecessary re-export of normalizeAndValidate --- src/functions/runtimeConfigExport.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/functions/runtimeConfigExport.ts b/src/functions/runtimeConfigExport.ts index 893a97c88c0..ba64d9e1f4e 100644 --- a/src/functions/runtimeConfigExport.ts +++ b/src/functions/runtimeConfigExport.ts @@ -8,9 +8,6 @@ import { getProjectId } from "../projectUtils"; import { loadRC } from "../rc"; import { logWarning } from "../utils"; import { flatten } from "../functional"; -import { normalizeAndValidate } from "./projectConfig"; - -export { normalizeAndValidate }; export interface ProjectConfigInfo { projectId: string;