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