From c68a82a2894e9c2013de39fc338cfde4c8acbd3e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Fri, 17 Oct 2025 21:30:48 -0700 Subject: [PATCH 1/7] BREAKING: Rewrite functions:config:export command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target the new defineJsonSecret API as migration target for functions.config() usage. The new API is a simpler migration target for existing functions.config() use cases. Example flow: $ firebase functions:config:export i This command retrieves your Runtime Config values (accessed via functions.config()) and exports them as a Secret Manager secret. i Fetching your existing functions.config() from danielylee-90... ✔ Fetched your existing functions.config(). i Configuration to be exported: ⚠ This may contain sensitive data. Do not share this output. { } ✔ What would you like to name the new secret for your configuration? RUNTIME_CONFIG ✔ Created new secret version projects/XXX/secrets/RUNTIME_CONFIG/versions/1 i To complete the migration, update your code: // Before: const functions = require('firebase-functions'); exports.myFunction = functions.https.onRequest((req, res) => { const apiKey = functions.config().service.key; // ... }); // After: const functions = require('firebase-functions'); const { defineJsonSecret } = require('firebase-functions/params'); const config = defineJsonSecret("RUNTIME_CONFIG"); exports.myFunction = functions .runWith({ secrets: [config] }) // Bind secret here .https.onRequest((req, res) => { const apiKey = config.value().service.key; // ... }); i Note: defineJsonSecret requires firebase-functions v6.6.0 or later. Update your package.json if needed. i Then deploy your functions: firebase deploy --only functions --- src/commands/functions-config-export.ts | 231 +++++++++++----------- src/functions/runtimeConfigExport.spec.ts | 159 --------------- src/functions/runtimeConfigExport.ts | 201 ------------------- 3 files changed, 111 insertions(+), 480 deletions(-) delete mode 100644 src/functions/runtimeConfigExport.spec.ts delete mode 100644 src/functions/runtimeConfigExport.ts diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index fc50bbbd764..82073b97db2 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -1,151 +1,142 @@ -import * as path from "path"; - import * as clc from "colorette"; -import requireInteractive from "../requireInteractive"; +import * as functionsConfig from "../functionsConfig"; import { Command } from "../command"; import { FirebaseError } from "../error"; -import { testIamPermissions } from "../gcp/iam"; -import { logger } from "../logger"; -import { input, confirm } from "../prompt"; +import { input } from "../prompt"; import { requirePermissions } from "../requirePermissions"; -import { logBullet, logWarning } from "../utils"; -import { zip } from "../functional"; -import * as configExport from "../functions/runtimeConfigExport"; +import { logBullet, logWarning, logSuccess } from "../utils"; import { requireConfig } from "../requireConfig"; +import { ensureValidKey, ensureSecret } from "../functions/secrets"; +import { addVersion, toSecretVersionResourceName } from "../gcp/secretManager"; +import { needProjectId } from "../projectUtils"; +import { requireAuth } from "../requireAuth"; +import { ensureApi } from "../gcp/secretManager"; import type { Options } from "../options"; -import { normalizeAndValidate, resolveConfigDir } from "../functions/projectConfig"; -const REQUIRED_PERMISSIONS = [ +const RUNTIME_CONFIG_PERMISSIONS = [ "runtimeconfig.configs.list", "runtimeconfig.configs.get", "runtimeconfig.variables.list", "runtimeconfig.variables.get", ]; -const RESERVED_PROJECT_ALIAS = ["local"]; -const MAX_ATTEMPTS = 3; +const SECRET_MANAGER_PERMISSIONS = [ + "secretmanager.secrets.create", + "secretmanager.secrets.get", + "secretmanager.secrets.update", + "secretmanager.versions.add", +]; -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 { - pInfos = pInfos.filter((pInfo) => !pInfo.config); - const testPermissions = pInfos.map((pInfo) => - testIamPermissions(pInfo.projectId, REQUIRED_PERMISSIONS), - ); - const results = await Promise.all(testPermissions); - for (const [pInfo, result] of zip(pInfos, results)) { - if (result.passed) { - // We should've been able to fetch the config but couldn't. Ask the user to try export command again. +export const command = new Command("functions:config:export") + .description("export environment config as a JSON secret to store in Cloud Secret Manager") + .option("--secret ", "name of the secret to create (default: RUNTIME_CONFIG)") + .withForce("use default secret name without prompting") + .before(requireAuth) + .before(ensureApi) + .before(requirePermissions, [...RUNTIME_CONFIG_PERMISSIONS, ...SECRET_MANAGER_PERMISSIONS]) + .before(requireConfig) + .action(async (options: Options) => { + const projectId = needProjectId(options); + + logBullet( + "This command retrieves your Runtime Config values (accessed via " + + clc.bold("functions.config()") + + ") and exports them as a Secret Manager secret.", + ); + console.log(""); + + logBullet(`Fetching your existing functions.config() from ${clc.bold(projectId)}...`); + + let configJson: Record; + try { + configJson = await functionsConfig.materializeAll(projectId); + } catch (err: any) { throw new FirebaseError( - `Unexpectedly failed to fetch runtime config for project ${pInfo.projectId}`, + `Failed to fetch runtime config for project ${projectId}. ` + + "Ensure you have the required permissions:\n\t" + + RUNTIME_CONFIG_PERMISSIONS.join("\n\t"), + { original: err }, ); } - logWarning( - "You are missing the following permissions to read functions config on project " + - `${clc.bold(pInfo.projectId)}:\n\t${result.missing.join("\n\t")}`, - ); - - const confirmed = await confirm({ - message: `Continue without importing configs from project ${pInfo.projectId}?`, - default: true, - }); - if (!confirmed) { - throw new FirebaseError("Command aborted!"); + if (Object.keys(configJson).length === 0) { + logSuccess("Your functions.config() is empty. Nothing to do."); + return; } - } -} - -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; -} + logSuccess("Fetched your existing functions.config()."); + console.log(""); -export const command = new Command("functions:config:export") - .description("export environment config as environment variables in dotenv format") - .before(requirePermissions, [ - "runtimeconfig.configs.list", - "runtimeconfig.configs.get", - "runtimeconfig.variables.list", - "runtimeconfig.variables.get", - ]) - .before(requireConfig) - .before(requireInteractive) - .action(async (options: Options) => { - const config = normalizeAndValidate(options.config.src.functions)[0]; - const configDir = resolveConfigDir(config); - if (!configDir) { + // Display config in interactive mode + if (!options.nonInteractive) { + logBullet(clc.bold("Configuration to be exported:")); + logWarning("This may contain sensitive data. Do not share this output."); + console.log(""); + console.log(JSON.stringify(configJson, null, 2)); + console.log(""); + } + + const defaultSecretName = "RUNTIME_CONFIG"; + const secretName = + (options.secret as string) || + (await input({ + message: "What would you like to name the new secret for your configuration?", + default: defaultSecretName, + nonInteractive: options.nonInteractive, + force: options.force, + })); + + const key = await ensureValidKey(secretName, options); + await ensureSecret(projectId, key, options); + + const secretValue = JSON.stringify(configJson, null, 2); + + // Check size limit (64KB) + const sizeInBytes = Buffer.byteLength(secretValue, "utf8"); + const maxSize = 64 * 1024; // 64KB + if (sizeInBytes > maxSize) { throw new FirebaseError( - "functions:config:export requires a local env directory. Set functions[].configDir in firebase.json when using remoteSource.", + `Configuration size (${sizeInBytes} bytes) exceeds the 64KB limit for JSON secrets. ` + + "Please reduce the size of your configuration or split it into multiple secrets.", ); } - let pInfos = configExport.getProjectInfos(options); - checkReservedAliases(pInfos); - + const secretVersion = await addVersion(projectId, key, secretValue); + console.log(""); + + logSuccess(`Created new secret version ${toSecretVersionResourceName(secretVersion)}`); + console.log(""); + logBullet(clc.bold("To complete the migration, update your code:")); + console.log(""); + console.log(clc.gray(" // Before:")); + console.log(clc.gray(` const functions = require('firebase-functions');`)); + console.log(clc.gray(` `)); + console.log(clc.gray(` exports.myFunction = functions.https.onRequest((req, res) => {`)); + console.log(clc.gray(` const apiKey = functions.config().service.key;`)); + console.log(clc.gray(` // ...`)); + console.log(clc.gray(` });`)); + console.log(""); + console.log(clc.gray(" // After:")); + console.log(clc.gray(` const functions = require('firebase-functions');`)); + console.log(clc.gray(` const { defineJsonSecret } = require('firebase-functions/params');`)); + console.log(clc.gray(` `)); + console.log(clc.gray(` const config = defineJsonSecret("${key}");`)); + console.log(clc.gray(` `)); + console.log(clc.gray(` exports.myFunction = functions`)); + console.log(clc.gray(` .runWith({ secrets: [config] }) // Bind secret here`)); + console.log(clc.gray(` .https.onRequest((req, res) => {`)); + console.log(clc.gray(` const apiKey = config.value().service.key;`)); + console.log(clc.gray(` // ...`)); + console.log(clc.gray(` });`)); + console.log(""); logBullet( - "Importing functions configs from projects [" + - pInfos.map(({ projectId }) => `${clc.bold(projectId)}`).join(", ") + - "]", + clc.bold("Note: ") + + "defineJsonSecret requires firebase-functions v6.6.0 or later. " + + "Update your package.json if needed.", ); + logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions")); - 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(", ")}]`); - - let attempts = 0; - let prefix = ""; - while (true) { - if (attempts >= MAX_ATTEMPTS) { - throw new FirebaseError("Exceeded max attempts to fix invalid config keys."); - } - - const errMsg = configExport.hydrateEnvs(pInfos, prefix); - if (errMsg.length === 0) { - break; - } - prefix = await promptForPrefix(errMsg); - 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`; - - for (const [filename, content] of Object.entries(filesToWrite)) { - await options.config.askWriteProjectFile(path.join(configDir, filename), content); - } + return secretName; }); diff --git a/src/functions/runtimeConfigExport.spec.ts b/src/functions/runtimeConfigExport.spec.ts deleted file mode 100644 index 73ce06c1ae1..00000000000 --- a/src/functions/runtimeConfigExport.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { expect } from "chai"; - -import * as configExport from "./runtimeConfigExport"; -import * as env from "./env"; -import * as sinon from "sinon"; -import * as rc from "../rc"; - -describe("functions-config-export", () => { - describe("getAllProjects", () => { - let loadRCStub: sinon.SinonStub; - - beforeEach(() => { - loadRCStub = sinon.stub(rc, "loadRC").returns({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any - }); - - afterEach(() => { - loadRCStub.restore(); - }); - - it("should include projectId from the options", () => { - expect(configExport.getProjectInfos({ projectId: "project-0" })).to.have.deep.members([ - { - projectId: "project-0", - }, - ]); - }); - - it("should include project and its alias from firebaserc", () => { - loadRCStub.returns({ projects: { dev: "project-0", prod: "project-1" } }); - expect(configExport.getProjectInfos({ projectId: "project-0" })).to.have.deep.members([ - { - projectId: "project-0", - alias: "dev", - }, - { - projectId: "project-1", - alias: "prod", - }, - ]); - }); - }); - - describe("convertKey", () => { - it("should converts valid config key", () => { - expect(configExport.convertKey("service.api.url", "")).to.be.equal("SERVICE_API_URL"); - expect(configExport.convertKey("foo-bar.car", "")).to.be.equal("FOO_BAR_CAR"); - }); - - it("should throw error if conversion is invalid", () => { - expect(() => { - configExport.convertKey("1.api.url", ""); - }).to.throw(); - expect(() => { - configExport.convertKey("x.google.env", ""); - }).to.throw(); - expect(() => { - configExport.convertKey("k.service", ""); - }).to.throw(); - }); - - it("should use prefix to fix invalid config keys", () => { - expect(configExport.convertKey("1.api.url", "CONFIG_")).to.equal("CONFIG_1_API_URL"); - expect(configExport.convertKey("x.google.env", "CONFIG_")).to.equal("CONFIG_X_GOOGLE_ENV"); - expect(configExport.convertKey("k.service", "CONFIG_")).to.equal("CONFIG_K_SERVICE"); - }); - - it("should throw error if prefix is invalid", () => { - expect(() => { - configExport.convertKey("1.api.url", "X_GOOGLE_"); - }).to.throw(); - expect(() => { - configExport.convertKey("x.google.env", "FIREBASE_"); - }).to.throw(); - expect(() => { - configExport.convertKey("k.service", "123_"); - }).to.throw(); - }); - }); - - describe("configToEnv", () => { - it("should convert valid functions config ", () => { - const { success, errors } = configExport.configToEnv( - { foo: { bar: "foobar" }, service: { api: { url: "foobar", name: "a service" } } }, - "", - ); - expect(success).to.have.deep.members([ - { origKey: "service.api.url", newKey: "SERVICE_API_URL", value: "foobar" }, - { origKey: "service.api.name", newKey: "SERVICE_API_NAME", value: "a service" }, - { origKey: "foo.bar", newKey: "FOO_BAR", value: "foobar" }, - ]); - expect(errors).to.be.empty; - }); - - it("should collect errors for invalid conversions", () => { - const { success, errors } = configExport.configToEnv( - { firebase: { name: "foobar" }, service: { api: { url: "foobar", name: "a service" } } }, - "", - ); - expect(success).to.have.deep.members([ - { origKey: "service.api.url", newKey: "SERVICE_API_URL", value: "foobar" }, - { origKey: "service.api.name", newKey: "SERVICE_API_NAME", value: "a service" }, - ]); - expect(errors).to.not.be.empty; - }); - - it("should use prefix to fix invalid keys", () => { - const { success, errors } = configExport.configToEnv( - { firebase: { name: "foobar" }, service: { api: { url: "foobar", name: "a service" } } }, - "CONFIG_", - ); - expect(success).to.have.deep.members([ - { origKey: "service.api.url", newKey: "SERVICE_API_URL", value: "foobar" }, - { origKey: "service.api.name", newKey: "SERVICE_API_NAME", value: "a service" }, - { origKey: "firebase.name", newKey: "CONFIG_FIREBASE_NAME", value: "foobar" }, - ]); - expect(errors).to.be.empty; - }); - }); - - describe("toDotenvFormat", () => { - it("should produce valid dotenv file with keys", () => { - const dotenv = configExport.toDotenvFormat([ - { origKey: "service.api.url", newKey: "SERVICE_API_URL", value: "hello" }, - { origKey: "service.api.name", newKey: "SERVICE_API_NAME", value: "world" }, - ]); - const { envs, errors } = env.parse(dotenv); - expect(envs).to.be.deep.equal({ - SERVICE_API_URL: "hello", - SERVICE_API_NAME: "world", - }); - expect(errors).to.be.empty; - }); - - it("should preserve newline characters", () => { - const dotenv = configExport.toDotenvFormat([ - { origKey: "service.api.url", newKey: "SERVICE_API_URL", value: "hello\nthere\nworld" }, - ]); - const { envs, errors } = env.parse(dotenv); - expect(envs).to.be.deep.equal({ - SERVICE_API_URL: "hello\nthere\nworld", - }); - expect(errors).to.be.empty; - }); - }); - - describe("generateDotenvFilename", () => { - it("should generate dotenv filename using project alias", () => { - expect( - configExport.generateDotenvFilename({ projectId: "my-project", alias: "prod" }), - ).to.equal(".env.prod"); - }); - - it("should generate dotenv filename using project id if alias doesn't exist", () => { - expect(configExport.generateDotenvFilename({ projectId: "my-project" })).to.equal( - ".env.my-project", - ); - }); - }); -}); diff --git a/src/functions/runtimeConfigExport.ts b/src/functions/runtimeConfigExport.ts deleted file mode 100644 index cc4b4be47ac..00000000000 --- a/src/functions/runtimeConfigExport.ts +++ /dev/null @@ -1,201 +0,0 @@ -import * as clc from "colorette"; - -import * as env from "./env"; -import * as functionsConfig from "../functionsConfig"; -import { FirebaseError } from "../error"; -import { logger } from "../logger"; -import { getProjectId } from "../projectUtils"; -import { loadRC } from "../rc"; -import { logWarning } from "../utils"; -import { flatten } from "../functional"; - -export interface ProjectConfigInfo { - projectId: string; - alias?: string; - config?: Record; - envs?: EnvMap[]; -} - -export interface EnvMap { - origKey: string; - newKey: string; - value: string; - err?: string; -} - -export interface ConfigToEnvResult { - success: EnvMap[]; - errors: Required[]; -} - -/** - * Find all projects (and its alias) associated with the current directory. - */ -export function getProjectInfos(options: { - project?: string; - projectId?: string; - cwd?: string; -}): ProjectConfigInfo[] { - const result: Record = {}; - - const rc = loadRC(options); - if (rc.projects) { - for (const [alias, projectId] of Object.entries(rc.projects)) { - if (Object.keys(result).includes(projectId)) { - logWarning( - `Multiple aliases found for ${clc.bold(projectId)}. ` + - `Preferring alias (${clc.bold(result[projectId])}) over (${clc.bold(alias)}).`, - ); - continue; - } - result[projectId] = alias; - } - } - - // We export runtime config of a --project set via CLI flag, allowing export command to run on projects that's - // never been added to the .firebaserc file. - const projectId = getProjectId(options); - if (projectId && !Object.keys(result).includes(projectId)) { - result[projectId] = projectId; - } - - return Object.entries(result).map(([k, v]) => { - const result: ProjectConfigInfo = { projectId: k }; - if (k !== v) { - result.alias = v; - } - return result; - }); -} - -/** - * Fetch and fill in runtime config for each project. - */ -export async function hydrateConfigs(pInfos: ProjectConfigInfo[]): Promise { - const hydrate = pInfos.map((info) => { - return functionsConfig - .materializeAll(info.projectId) - .then((config) => { - info.config = config; - return; - }) - .catch((err) => { - logger.debug( - `Failed to fetch runtime config for project ${info.projectId}: ${err.message}`, - ); - }); - }); - await Promise.all(hydrate); -} - -/** - * Converts functions config key from runtime config to env var key. - * If the original config key fails to convert, try again with provided prefix. - * - * Throws KeyValidationError if the converted key is invalid. - */ -export function convertKey(configKey: string, prefix: string): string { - /* prettier-ignore */ - const baseKey = configKey - .toUpperCase() // 1. Uppercase all characters (e.g. SOME-SERVICE.KEY) - .replace(/\./g, "_") // 2. Dots to underscores (e.g. SOME-SERVICE_KEY) - .replace(/-/g, "_"); // 3. Dashses to underscores (e.g. SOME_SERVICE_KEY) - - let envKey = baseKey; - try { - env.validateKey(envKey); - } catch (err: any) { - if (err instanceof env.KeyValidationError) { - envKey = prefix + envKey; - env.validateKey(envKey); - } - } - return envKey; -} - -/** - * Convert runtime config into a map of env vars. - */ -export function configToEnv(configs: Record, prefix: string): ConfigToEnvResult { - const success = []; - const errors = []; - - for (const [configKey, value] of flatten(configs)) { - try { - const envKey = convertKey(configKey, prefix); - success.push({ origKey: configKey, newKey: envKey, value: value as string }); - } catch (err: any) { - if (err instanceof env.KeyValidationError) { - errors.push({ - origKey: configKey, - newKey: err.key, - err: err.message, - value: value as string, - }); - } else { - throw new FirebaseError("Unexpected error while converting config", { - exit: 2, - original: err, - }); - } - } - } - return { success, errors }; -} - -/** - * 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 { - let errMsg = ""; - for (const pInfo of pInfos) { - const { success, errors } = configToEnv(pInfo.config!, prefix); - if (errors.length > 0) { - const msg = - `${pInfo.projectId} ` + - `${pInfo.alias ? "(" + pInfo.alias + ")" : ""}:\n` + - errors.map((err) => `\t${err.origKey} => ${clc.bold(err.newKey)} (${err.err})`).join("\n") + - "\n"; - errMsg += msg; - } else { - pInfo.envs = success; - } - } - return errMsg; -} - -const CHARACTERS_TO_ESCAPE_SEQUENCES: Record = { - "\n": "\\n", - "\r": "\\r", - "\t": "\\t", - "\v": "\\v", - "\\": "\\\\", - '"': '\\"', - "'": "\\'", -}; - -function escape(s: string): string { - // Escape newlines, tabs, backslashes and quotes - return s.replace(/[\n\r\t\v\\"']/g, (ch) => CHARACTERS_TO_ESCAPE_SEQUENCES[ch]); -} - -/** - * Convert env var mapping to dotenv compatible string. - */ -export function toDotenvFormat(envs: EnvMap[], header = ""): string { - const lines = envs.map(({ newKey, value }) => `${newKey}="${escape(value)}"`); - const maxLineLen = Math.max(...lines.map((l) => l.length)); - return ( - `${header}\n` + - lines.map((line, idx) => `${line.padEnd(maxLineLen)} # from ${envs[idx].origKey}`).join("\n") - ); -} - -/** - * Generate dotenv filename for given project. - */ -export function generateDotenvFilename(pInfo: ProjectConfigInfo): string { - return `.env.${pInfo.alias ?? pInfo.projectId}`; -} From 54572c6c20a5d9b922a33ab00b5731dcf640cadb Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Sun, 19 Oct 2025 16:54:12 -0700 Subject: [PATCH 2/7] Update src/commands/functions-config-export.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/commands/functions-config-export.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 82073b97db2..3b791d543af 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -79,14 +79,15 @@ export const command = new Command("functions:config:export") } const defaultSecretName = "RUNTIME_CONFIG"; - const secretName = - (options.secret as string) || - (await input({ + let secretName = options.secret as string; + if (!secretName) { + secretName = await input({ message: "What would you like to name the new secret for your configuration?", default: defaultSecretName, nonInteractive: options.nonInteractive, force: options.force, - })); + }); + } const key = await ensureValidKey(secretName, options); await ensureSecret(projectId, key, options); From f4eef7af94c723b6c69f4820deb762af282e9069 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Sun, 19 Oct 2025 17:11:49 -0700 Subject: [PATCH 3/7] nit --- src/commands/functions-config-export.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 82073b97db2..f554fec2a42 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -52,12 +52,12 @@ export const command = new Command("functions:config:export") let configJson: Record; try { configJson = await functionsConfig.materializeAll(projectId); - } catch (err: any) { + } catch (err: unknown) { throw new FirebaseError( `Failed to fetch runtime config for project ${projectId}. ` + "Ensure you have the required permissions:\n\t" + RUNTIME_CONFIG_PERMISSIONS.join("\n\t"), - { original: err }, + { original: err as Error }, ); } From 8fdad4c25a1d357d5c5dbd1bbfee974c56bdc644 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 27 Oct 2025 20:43:57 -0700 Subject: [PATCH 4/7] response to comments --- src/commands/functions-config-export.ts | 130 +++++++++++++++++------- 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 14a69cebfaf..1489a00775a 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -1,14 +1,15 @@ import * as clc from "colorette"; +import * as semver from "semver"; import * as functionsConfig from "../functionsConfig"; import { Command } from "../command"; import { FirebaseError } from "../error"; -import { input } from "../prompt"; +import { input, confirm } from "../prompt"; import { requirePermissions } from "../requirePermissions"; -import { logBullet, logWarning, logSuccess } from "../utils"; +import { logBullet, logSuccess } from "../utils"; import { requireConfig } from "../requireConfig"; import { ensureValidKey, ensureSecret } from "../functions/secrets"; -import { addVersion, toSecretVersionResourceName } from "../gcp/secretManager"; +import { addVersion, listSecretVersions, toSecretVersionResourceName } from "../gcp/secretManager"; import { needProjectId } from "../projectUtils"; import { requireAuth } from "../requireAuth"; import { ensureApi } from "../gcp/secretManager"; @@ -29,6 +30,17 @@ const SECRET_MANAGER_PERMISSIONS = [ "secretmanager.versions.add", ]; +function maskConfigValues(obj: any): any { + if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) { + const masked: Record = {}; + for (const [key, value] of Object.entries(obj)) { + masked[key] = maskConfigValues(value); + } + return masked; + } + return "******"; +} + export const command = new Command("functions:config:export") .description("export environment config as a JSON secret to store in Cloud Secret Manager") .option("--secret ", "name of the secret to create (default: RUNTIME_CONFIG)") @@ -72,26 +84,48 @@ export const command = new Command("functions:config:export") // Display config in interactive mode if (!options.nonInteractive) { logBullet(clc.bold("Configuration to be exported:")); - logWarning("This may contain sensitive data. Do not share this output."); - console.log(""); - console.log(JSON.stringify(configJson, null, 2)); + console.log(JSON.stringify(maskConfigValues(configJson), null, 2)); console.log(""); } - const defaultSecretName = "RUNTIME_CONFIG"; + const defaultSecretName = "FUNCTIONS_CONFIG_EXPORT"; let secretName = options.secret as string; if (!secretName) { - secretName = await input({ - message: "What would you like to name the new secret for your configuration?", - default: defaultSecretName, - nonInteractive: options.nonInteractive, - force: options.force, - }); + if (options.force) { + secretName = defaultSecretName; + } else { + secretName = await input({ + message: "What would you like to name the new secret for your configuration?", + default: defaultSecretName, + nonInteractive: options.nonInteractive, + }); + } } const key = await ensureValidKey(secretName, options); await ensureSecret(projectId, key, options); + const versions = await listSecretVersions(projectId, key); + const enabledVersions = versions.filter((v) => v.state === "ENABLED"); + enabledVersions.sort((a, b) => (b.createTime || "").localeCompare(a.createTime || "")); + const latest = enabledVersions[0]; + + if (latest) { + logBullet( + `Secret ${clc.bold(key)} already exists (latest version: ${clc.bold(latest.versionId)}, created: ${latest.createTime}).`, + ); + const proceed = await confirm({ + message: "Do you want to add a new version to this secret?", + default: false, + nonInteractive: options.nonInteractive, + force: options.force, + }); + if (!proceed) { + return; + } + console.log(""); + } + const secretValue = JSON.stringify(configJson, null, 2); // Check size limit (64KB) @@ -111,32 +145,52 @@ export const command = new Command("functions:config:export") console.log(""); logBullet(clc.bold("To complete the migration, update your code:")); console.log(""); - console.log(clc.gray(" // Before:")); - console.log(clc.gray(` const functions = require('firebase-functions');`)); - console.log(clc.gray(` `)); - console.log(clc.gray(` exports.myFunction = functions.https.onRequest((req, res) => {`)); - console.log(clc.gray(` const apiKey = functions.config().service.key;`)); - console.log(clc.gray(` // ...`)); - console.log(clc.gray(` });`)); - console.log(""); - console.log(clc.gray(" // After:")); - console.log(clc.gray(` const functions = require('firebase-functions');`)); - console.log(clc.gray(` const { defineJsonSecret } = require('firebase-functions/params');`)); - console.log(clc.gray(` `)); - console.log(clc.gray(` const config = defineJsonSecret("${key}");`)); - console.log(clc.gray(` `)); - console.log(clc.gray(` exports.myFunction = functions`)); - console.log(clc.gray(` .runWith({ secrets: [config] }) // Bind secret here`)); - console.log(clc.gray(` .https.onRequest((req, res) => {`)); - console.log(clc.gray(` const apiKey = config.value().service.key;`)); - console.log(clc.gray(` // ...`)); - console.log(clc.gray(` });`)); - console.log(""); - logBullet( - clc.bold("Note: ") + - "defineJsonSecret requires firebase-functions v6.6.0 or later. " + - "Update your package.json if needed.", + console.log( + clc.gray(` // Before: + const functions = require('firebase-functions'); + + exports.myFunction = functions.https.onRequest((req, res) => { + const apiKey = functions.config().service.key; + // ... + }); + + // After: + const functions = require('firebase-functions'); + const { defineJsonSecret } = require('firebase-functions/params'); + + const config = defineJsonSecret("${key}"); + + exports.myFunction = functions + .runWith({ secrets: [config] }) // Bind secret here + .https.onRequest((req, res) => { + const apiKey = config.value().service.key; + // ... + });`), ); + console.log(""); + + // Try to detect the firebase-functions version to see if we need to warn about defineJsonSecret + let sdkVersion: string | undefined; + try { + const functionsConfig = options.config.get("functions"); + const source = Array.isArray(functionsConfig) + ? functionsConfig[0]?.source + : functionsConfig?.source; + if (source) { + const sourceDir = options.config.path(source); + sdkVersion = getFunctionsSDKVersion(sourceDir); + } + } catch (e) { + // ignore error, just show the warning if we can't detect the version + } + + if (!sdkVersion || semver.lt(sdkVersion, "6.6.0")) { + logBullet( + clc.bold("Note: ") + + "defineJsonSecret requires firebase-functions v6.6.0 or later. " + + "Update your package.json if needed.", + ); + } logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions")); return secretName; From a26f76b1486de58023a8edd308985d0c39f1fc2e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 28 Oct 2025 09:24:42 -0700 Subject: [PATCH 5/7] fix build --- src/commands/functions-config-export.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 1489a00775a..04a644cab81 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -13,6 +13,7 @@ import { addVersion, listSecretVersions, toSecretVersionResourceName } from "../ import { needProjectId } from "../projectUtils"; import { requireAuth } from "../requireAuth"; import { ensureApi } from "../gcp/secretManager"; +import { getFunctionsSDKVersion } from "../deploy/functions/runtimes/node/versioning"; import type { Options } from "../options"; From 9123e04df5a39a5c8cf6000427aa6bc53f44c99f Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 3 Nov 2025 10:57:33 -0800 Subject: [PATCH 6/7] Update src/commands/functions-config-export.ts Co-authored-by: Jeff <3759507+jhuleatt@users.noreply.github.com> --- src/commands/functions-config-export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 04a644cab81..766e84f2336 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -43,7 +43,7 @@ function maskConfigValues(obj: any): any { } export const command = new Command("functions:config:export") - .description("export environment config as a JSON secret to store in Cloud Secret Manager") + .description("export functions.config() values as JSON and store it as a single secret in Cloud Secret Manager") .option("--secret ", "name of the secret to create (default: RUNTIME_CONFIG)") .withForce("use default secret name without prompting") .before(requireAuth) From b49832236cd6d9e7fcd504f234b775ddd9b3485e Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Mon, 3 Nov 2025 11:05:55 -0800 Subject: [PATCH 7/7] respond to pr reviews --- src/commands/functions-config-export.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/functions-config-export.ts b/src/commands/functions-config-export.ts index 766e84f2336..784855be5bb 100644 --- a/src/commands/functions-config-export.ts +++ b/src/commands/functions-config-export.ts @@ -31,6 +31,8 @@ const SECRET_MANAGER_PERMISSIONS = [ "secretmanager.versions.add", ]; +const DEFAULT_SECRET_NAME = "FUNCTIONS_CONFIG_EXPORT"; + function maskConfigValues(obj: any): any { if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) { const masked: Record = {}; @@ -43,8 +45,8 @@ function maskConfigValues(obj: any): any { } export const command = new Command("functions:config:export") - .description("export functions.config() values as JSON and store it as a single secret in Cloud Secret Manager") - .option("--secret ", "name of the secret to create (default: RUNTIME_CONFIG)") + .description("export environment config as a JSON secret to store in Cloud Secret Manager") + .option("--secret ", `name of the secret to create (default: ${DEFAULT_SECRET_NAME})`) .withForce("use default secret name without prompting") .before(requireAuth) .before(ensureApi) @@ -89,15 +91,14 @@ export const command = new Command("functions:config:export") console.log(""); } - const defaultSecretName = "FUNCTIONS_CONFIG_EXPORT"; let secretName = options.secret as string; if (!secretName) { if (options.force) { - secretName = defaultSecretName; + secretName = DEFAULT_SECRET_NAME; } else { secretName = await input({ message: "What would you like to name the new secret for your configuration?", - default: defaultSecretName, + default: DEFAULT_SECRET_NAME, nonInteractive: options.nonInteractive, }); } @@ -189,7 +190,7 @@ export const command = new Command("functions:config:export") logBullet( clc.bold("Note: ") + "defineJsonSecret requires firebase-functions v6.6.0 or later. " + - "Update your package.json if needed.", + `Update to a newer version with ${clc.bold("npm i firebase-functions @latest")}${!sdkVersion ? " if needed" : ""}.`, ); } logBullet("Then deploy your functions:\n " + clc.bold("firebase deploy --only functions"));