From f103e236ce7338e2b14f38e6b17683024aad8bfd Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Wed, 12 Aug 2026 16:39:32 -0700 Subject: [PATCH 1/5] ext:export understands kits in firebase.json and ejects secrets --- src/commands/ext-export.ts | 125 ++++++++++++++++++++++++++----- src/deploy/extensions/secrets.ts | 30 ++++++++ src/extensions/export.ts | 34 ++++++++- src/gcp/secretManager.ts | 2 +- 4 files changed, 171 insertions(+), 20 deletions(-) diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index f00ac117c0d..b15a215bd55 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -7,6 +7,7 @@ import { parameterizeProject, setSecretParamsToLatest, functionsEnvFromInstance, + ejectSecretsFromInstance, } from "../extensions/export"; import { ensureExtensionsApiEnabled } from "../extensions/extensionsHelper"; import * as manifest from "../extensions/manifest"; @@ -24,6 +25,8 @@ import { writeUserEnvs, UserEnvsOpts, hasUserEnvs } from "../functions/env"; import { mkdirSync } from "fs"; import { join } from "path"; import { logBullet } from "../utils"; +import { Config } from "../config"; +import { FirebaseError } from "../error"; import * as clc from "colorette"; import * as experiments from "../experiments"; @@ -31,18 +34,29 @@ export const command = new Command("ext:export") .description("export Extension instances installed on a project to a local Firebase directory") .option( `--mode `, - `experimental: controls the target system of the export (supports "extensions", "functions")`, + `experimental: controls the target system of the export (supports "extensions", "functions", "kits" as an alias for functions)`, ) .option( `--instance `, `scope the export to the single instance with the specified instance id`, ) + .option( + "--kit ", + "write the .env export from --mode functions to the config path for a kit currently defined in firebase.json", + ) + .option( + "--outputDir ", + "override the .env export from --mode functions to a specific arbitrary path", + ) .before(requirePermissions, ["firebaseextensions.instances.list"]) .before(ensureExtensionsApiEnabled) .before(checkMinRequiredVersion, "extMinVersion") .withForce() .action(async (options: Options) => { - if (experiments.isEnabled("extMigrationFeatures") && options.mode === "functions") { + if ( + experiments.isEnabled("extMigrationFeatures") && + (options.mode === "functions" || options.mode === "kits") + ) { // Functions handler: // - writes to /.env- // - does not parametrize project number and ID (e.g "12345678" instead of "{param:PROJECT_NUMBER}") @@ -145,21 +159,108 @@ async function fnHandler(options: Options): Promise { logger.info(`No extension matching instance ID ${options.instance} found`); return; } + if (instance.state !== "ACTIVE" && !options.force) { + logger.error( + `Extension ${options.instance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, + ); + return; + } const instanceId = last(instance.name.split("/")) ?? ""; if (instanceId !== options.instance) { return; } + let secretCount = 0; const convertedEnv = functionsEnvFromInstance(instance); for (const key of Object.keys(convertedEnv)) { + if (key.startsWith("FIREBASE_SECRET_REF_")) { + secretCount += 1; + } console.log(`${key}=${convertedEnv[key]}`); } - // Write to firebase root if inside a firebase project dir, otherwise / - let writeLocationOpts: UserEnvsOpts; + const writeLocation: UserEnvsOpts = kitExportTarget(instanceId, projectId, options); + if (hasUserEnvs(writeLocation)) { + logger.info( + `Exported extensions config appears to already exist in /${instanceId}, aborting write.`, + ); + } else { + logBullet( + clc.cyan(clc.bold("functions: ")) + + `Saving exported extensions config as a Function Kits .env file`, + ); + mkdirSync(join(writeLocation.projectDir, writeLocation.configDir ?? instanceId), { + recursive: true, + }); + writeUserEnvs(convertedEnv, writeLocation); + } + + if (secretCount > 0) { + if ( + !(await confirm({ + message: `${secretCount} Cloud Secret Manager resources found in export. Transfer control of secrets from Extensions to Kits?`, + nonInteractive: options.nonInteractive, + force: options.force, + default: true, + })) + ) { + return; + } + const secretsChanged = await ejectSecretsFromInstance(instance); + logBullet( + clc.cyan(clc.bold("functions: ")) + + `Added functions-managed label to secrets: ${secretsChanged}`, + ); + } +} + +/** + * @return A forged UserEnvsOpts that will cause writeUserEnvs to write an + * exported Kits environment to a sensible location: + * 1) --outputDir command line argument if provided + * 2) the corresponding kit config directory from firebase.json if --kit is provided + * 3) // if inside a Firebase directory + * 4) // as a fallback + */ +function kitExportTarget(instanceId: string, projectId: string, options: Options): UserEnvsOpts { + const firebaseConfig = Config.load(options, true); + if (typeof options.outputDir !== "undefined") { + return { + functionsSource: instanceId, + configDir: String(options.outputDir), + projectId: projectId, + isEmulator: false, + projectDir: options.cwd ?? process.cwd(), + }; + } + if (typeof options.kit !== "undefined") { + if (!firebaseConfig) { + throw new FirebaseError( + "--kit option was provided but no firebase.json available to look in for kit definitions.", + ); + } + const functionsConfig = firebaseConfig.get("functions"); + const functions = Array.isArray(functionsConfig) ? functionsConfig : [functionsConfig]; + for (const fn of functions) { + if (typeof fn.kit === "undefined" || typeof fn.instances === "undefined") { + continue; + } + for (const [kitId, kitPath] of Object.entries(fn.instances)) { + if (kitId === options.kit) { + return { + functionsSource: instanceId, + configDir: String(kitPath), + projectId: projectId, + isEmulator: false, + projectDir: options.projectRoot ?? process.cwd(), + }; + } + } + } + } if (typeof options.projectRoot !== "undefined") { - writeLocationOpts = { + return { functionsSource: instanceId, configDir: join(options.projectRoot, instanceId), projectId: projectId, @@ -167,7 +268,7 @@ async function fnHandler(options: Options): Promise { projectDir: options.projectRoot, }; } else { - writeLocationOpts = { + return { functionsSource: instanceId, configDir: instanceId, projectId: projectId, @@ -175,16 +276,4 @@ async function fnHandler(options: Options): Promise { projectDir: options.cwd ?? process.cwd(), }; } - if (hasUserEnvs(writeLocationOpts)) { - logger.info( - `Exported extensions config appears to already exist in /${instanceId}, aborting write.`, - ); - return; - } - logBullet( - clc.cyan(clc.bold("functions: ")) + - `Saving exported extensions config as a Function Kits .env file`, - ); - mkdirSync(instanceId, { recursive: true }); - writeUserEnvs(convertedEnv, writeLocationOpts); } diff --git a/src/deploy/extensions/secrets.ts b/src/deploy/extensions/secrets.ts index 00a41f57959..393f89ca832 100644 --- a/src/deploy/extensions/secrets.ts +++ b/src/deploy/extensions/secrets.ts @@ -15,6 +15,7 @@ import { ExtensionSpec, Param, ParamType } from "../../extensions/types"; import { FirebaseError } from "../../error"; import { logger } from "../../logger"; import { logLabeledBullet } from "../../utils"; +import { FIREBASE_MANAGED } from "../../gcp/secretManager"; /** * handleSecretParams checks each spec for secret params, and validates that the secrets in the configuration exist. @@ -45,6 +46,9 @@ export async function handleSecretParams( } } +/** + * + */ export async function checkSpecForSecrets(i: InstanceSpec): Promise { const extensionSpec = await getExtensionSpec(i); return secretUtils.usesSecrets(extensionSpec); @@ -225,6 +229,32 @@ async function getSecretInfo( return secretInfo; } +/** + * PATCHes a Secret resource by removing any "firebase-extensions-managed" labels + * and replacing them with "firebase-managed": "functions" + */ +export async function transferSecretToKits( + projectId: string, + secretName: string, +): Promise { + const newLabels: Record = {}; + try { + const have = await secretManager.getSecret(projectId, secretName); + for (const [labelKey, labelValue] of Object.entries(have.labels)) { + if (labelKey !== secretUtils.SECRET_LABEL) { + newLabels[labelKey] = labelValue; + } + } + } catch (err: any) { + throw new FirebaseError( + `Error when retrieving current state of migrating secret ${projectId}/${secretName}: ${err instanceof Error ? err.message : String(err)}`, + { original: err instanceof Error ? err : undefined }, + ); + } + newLabels[FIREBASE_MANAGED] = "functions"; + return secretManager.patchSecret(projectId, secretName, newLabels); +} + async function promptForCreateSecret(args: { projectId: string; secretName: string; diff --git a/src/extensions/export.ts b/src/extensions/export.ts index af07dcd9aca..51a34292714 100644 --- a/src/extensions/export.ts +++ b/src/extensions/export.ts @@ -1,9 +1,15 @@ import { getExtensionVersion, DeploymentInstanceSpec } from "../deploy/extensions/planner"; import { humanReadable } from "../deploy/extensions/deploymentSummary"; import { logger } from "../logger"; -import { parseSecretVersionResourceName, toSecretVersionResourceName } from "../gcp/secretManager"; +import { + parseSecretVersionResourceName, + toSecretVersionResourceName, + SECRET_VERSION_NAME_REGEX, +} from "../gcp/secretManager"; import { getActiveSecrets } from "./secretsUtils"; import { ExtensionInstance } from "./types"; +import { transferSecretToKits } from "../deploy/extensions/secrets"; +import { FirebaseError } from "../error"; /** * parameterizeProject searchs spec.params for any param that include projectId or projectNumber, @@ -148,3 +154,29 @@ export function functionsEnvFromInstance(instance: ExtensionInstance): Record { + const secretsChanged: string[] = []; + const liveParams = instance.config?.params || {}; + for (const specParam of instance.config?.source?.spec?.params ?? []) { + if (specParam.type !== "SECRET") { + continue; + } + const secretName = specParam.param; + const resourceName = liveParams[secretName]; + const match = resourceName.match(SECRET_VERSION_NAME_REGEX); + if (!match?.groups) { + throw new FirebaseError(`Invalid secret version resource name [${resourceName}].`); + } + const projectId = match.groups.project; + const secretId = match.groups.secret; + await transferSecretToKits(projectId, secretId); + secretsChanged.push(`${projectId}/${secretId}`); + } + return secretsChanged; +} diff --git a/src/gcp/secretManager.ts b/src/gcp/secretManager.ts index ac9c13fefd8..23290f1d18a 100644 --- a/src/gcp/secretManager.ts +++ b/src/gcp/secretManager.ts @@ -16,7 +16,7 @@ const SECRET_NAME_REGEX = new RegExp( ); // Matches projects/{PROJECT}/secrets/{SECRET}/versions/{latest|VERSION} -const SECRET_VERSION_NAME_REGEX = new RegExp( +export const SECRET_VERSION_NAME_REGEX = new RegExp( SECRET_NAME_REGEX.source + "\\/versions\\/" + "(?latest|[0-9]+)", ); From 4b141501235c752c67d8f0c6be6436a0317527f7 Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Wed, 12 Aug 2026 16:51:28 -0700 Subject: [PATCH 2/5] slop --- src/commands/ext-export.ts | 3 +-- src/extensions/export.ts | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index b15a215bd55..4874df930bc 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -160,10 +160,9 @@ async function fnHandler(options: Options): Promise { return; } if (instance.state !== "ACTIVE" && !options.force) { - logger.error( + throw new FirebaseError( `Extension ${options.instance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, ); - return; } const instanceId = last(instance.name.split("/")) ?? ""; diff --git a/src/extensions/export.ts b/src/extensions/export.ts index 51a34292714..4466e5f3619 100644 --- a/src/extensions/export.ts +++ b/src/extensions/export.ts @@ -169,6 +169,9 @@ export async function ejectSecretsFromInstance(instance: ExtensionInstance): Pro } const secretName = specParam.param; const resourceName = liveParams[secretName]; + if (!resourceName) { + throw new FirebaseError(`Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`); + } const match = resourceName.match(SECRET_VERSION_NAME_REGEX); if (!match?.groups) { throw new FirebaseError(`Invalid secret version resource name [${resourceName}].`); From 9aabc108e9288da41af151330c111a04479f3746 Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Thu, 13 Aug 2026 14:57:30 -0700 Subject: [PATCH 3/5] first review pass --- src/commands/ext-export.ts | 38 ++++++++++++++++---------------- src/deploy/extensions/secrets.ts | 2 +- src/extensions/export.ts | 6 +++-- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index 4874df930bc..b9f14feb546 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -34,19 +34,19 @@ export const command = new Command("ext:export") .description("export Extension instances installed on a project to a local Firebase directory") .option( `--mode `, - `experimental: controls the target system of the export (supports "extensions", "functions", "kits" as an alias for functions)`, + `experimental: controls the target system of the export (supports "extensions", "functions")`, ) .option( - `--instance `, + `-e`, `--extension-instance `, `scope the export to the single instance with the specified instance id`, ) .option( - "--kit ", - "write the .env export from --mode functions to the config path for a kit currently defined in firebase.json", + `-k`, "---kit-instance ", + `write the .env export from --mode functions to the config path for a kit instance currently defined in firebase.json`, ) .option( - "--outputDir ", - "override the .env export from --mode functions to a specific arbitrary path", + `--outputDir `, + `override the .env export from --mode functions to a specific arbitrary path`, ) .before(requirePermissions, ["firebaseextensions.instances.list"]) .before(ensureExtensionsApiEnabled) @@ -81,11 +81,11 @@ async function extHandler(options: Options): Promise { logger.info(`No extension instances installed on ${projectId}, so there is nothing to export.`); return; } - if (options.instance) { - have = have.filter((s) => s.instanceId === options.instance); + if (options.extensionInstance) { + have = have.filter((s) => s.instanceId === options.extensionInstance); if (have.length === 0) { logger.info( - `No extension instances installed on ${projectId} match specified instance ID ${options.instance}.`, + `No extension instances installed on ${projectId} match specified instance ID ${options.extensionInstance}.`, ); return; } @@ -147,26 +147,26 @@ async function extHandler(options: Options): Promise { } async function fnHandler(options: Options): Promise { - if (!options.instance) { + if (!options.extensionInstance) { logger.info( `ext:export must specify an --instance option when exporting to Functions. Use ext:list to find your instance IDs.`, ); return; } const projectId = needProjectId(options); - const instance = await getInstance(projectId, options.instance as string); + const instance = await getInstance(projectId, options.extensionInstance as string); if (typeof instance === "undefined") { - logger.info(`No extension matching instance ID ${options.instance} found`); + logger.info(`No extension matching instance ID ${options.extensionInstance} found`); return; } if (instance.state !== "ACTIVE" && !options.force) { throw new FirebaseError( - `Extension ${options.instance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, + `Extension ${options.extensionInstance} is in state ${instance.state}. To export a non-ACTIVE extension, use the --force option.`, ); } const instanceId = last(instance.name.split("/")) ?? ""; - if (instanceId !== options.instance) { + if (instanceId !== options.extensionInstance) { return; } @@ -198,7 +198,7 @@ async function fnHandler(options: Options): Promise { if (secretCount > 0) { if ( !(await confirm({ - message: `${secretCount} Cloud Secret Manager resources found in export. Transfer control of secrets from Extensions to Kits?`, + message: `${secretCount} Cloud Secret Manager resources found in export. Remove from Extensions lifecycle management?\nThis is necessary to prevent extension uninstall from deleting potentially migrated secrets.`, nonInteractive: options.nonInteractive, force: options.force, default: true, @@ -218,7 +218,7 @@ async function fnHandler(options: Options): Promise { * @return A forged UserEnvsOpts that will cause writeUserEnvs to write an * exported Kits environment to a sensible location: * 1) --outputDir command line argument if provided - * 2) the corresponding kit config directory from firebase.json if --kit is provided + * 2) the corresponding kit instance directory from firebase.json if --kit-instance is provided * 3) // if inside a Firebase directory * 4) // as a fallback */ @@ -233,10 +233,10 @@ function kitExportTarget(instanceId: string, projectId: string, options: Options projectDir: options.cwd ?? process.cwd(), }; } - if (typeof options.kit !== "undefined") { + if (typeof options.kitInstance !== "undefined") { if (!firebaseConfig) { throw new FirebaseError( - "--kit option was provided but no firebase.json available to look in for kit definitions.", + "--kit-instance option was provided but no firebase.json available to look in for kit definitions.", ); } const functionsConfig = firebaseConfig.get("functions"); @@ -246,7 +246,7 @@ function kitExportTarget(instanceId: string, projectId: string, options: Options continue; } for (const [kitId, kitPath] of Object.entries(fn.instances)) { - if (kitId === options.kit) { + if (kitId === options.kitInstance) { return { functionsSource: instanceId, configDir: String(kitPath), diff --git a/src/deploy/extensions/secrets.ts b/src/deploy/extensions/secrets.ts index 393f89ca832..e2ad9d70482 100644 --- a/src/deploy/extensions/secrets.ts +++ b/src/deploy/extensions/secrets.ts @@ -47,7 +47,7 @@ export async function handleSecretParams( } /** - * + * @returns true if the InstanceSpec defines any Secret params */ export async function checkSpecForSecrets(i: InstanceSpec): Promise { const extensionSpec = await getExtensionSpec(i); diff --git a/src/extensions/export.ts b/src/extensions/export.ts index 4466e5f3619..f9dc7c6e5b6 100644 --- a/src/extensions/export.ts +++ b/src/extensions/export.ts @@ -158,7 +158,7 @@ export function functionsEnvFromInstance(instance: ExtensionInstance): Record { const secretsChanged: string[] = []; @@ -170,7 +170,9 @@ export async function ejectSecretsFromInstance(instance: ExtensionInstance): Pro const secretName = specParam.param; const resourceName = liveParams[secretName]; if (!resourceName) { - throw new FirebaseError(`Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`); + throw new FirebaseError( + `Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`, + ); } const match = resourceName.match(SECRET_VERSION_NAME_REGEX); if (!match?.groups) { From 552ae2fd2be88043cae5eb0779f9b84130a0161d Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Thu, 13 Aug 2026 15:47:58 -0700 Subject: [PATCH 4/5] lint --- src/commands/ext-export.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index b9f14feb546..cff2d7d7bc9 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -37,11 +37,13 @@ export const command = new Command("ext:export") `experimental: controls the target system of the export (supports "extensions", "functions")`, ) .option( - `-e`, `--extension-instance `, + `-e`, + `--extension-instance `, `scope the export to the single instance with the specified instance id`, ) .option( - `-k`, "---kit-instance ", + `-k`, + "---kit-instance ", `write the .env export from --mode functions to the config path for a kit instance currently defined in firebase.json`, ) .option( From e164b4750d78eefc366c35f34547455109452147 Mon Sep 17 00:00:00 2001 From: Victor Fan Date: Mon, 17 Aug 2026 16:30:17 -0700 Subject: [PATCH 5/5] review --- src/commands/ext-export.ts | 43 ++++++++++++++++++++------------------ src/extensions/export.ts | 1 + 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/commands/ext-export.ts b/src/commands/ext-export.ts index cff2d7d7bc9..e0f4059bc7b 100644 --- a/src/commands/ext-export.ts +++ b/src/commands/ext-export.ts @@ -26,6 +26,7 @@ import { mkdirSync } from "fs"; import { join } from "path"; import { logBullet } from "../utils"; import { Config } from "../config"; +import { normalizeAndValidate, isKitConfig } from "../functions/projectConfig"; import { FirebaseError } from "../error"; import * as clc from "colorette"; import * as experiments from "../experiments"; @@ -197,23 +198,24 @@ async function fnHandler(options: Options): Promise { writeUserEnvs(convertedEnv, writeLocation); } - if (secretCount > 0) { - if ( - !(await confirm({ - message: `${secretCount} Cloud Secret Manager resources found in export. Remove from Extensions lifecycle management?\nThis is necessary to prevent extension uninstall from deleting potentially migrated secrets.`, - nonInteractive: options.nonInteractive, - force: options.force, - default: true, - })) - ) { - return; - } - const secretsChanged = await ejectSecretsFromInstance(instance); - logBullet( - clc.cyan(clc.bold("functions: ")) + - `Added functions-managed label to secrets: ${secretsChanged}`, - ); + if (secretCount === 0) { + return; } + if ( + !(await confirm({ + message: `${secretCount} Cloud Secret Manager resources found in export. Remove from Extensions lifecycle management?\nThis is necessary to prevent extension uninstall from deleting potentially migrated secrets.`, + nonInteractive: options.nonInteractive, + force: options.force, + default: true, + })) + ) { + return; + } + const secretsChanged = await ejectSecretsFromInstance(instance); + logBullet( + clc.cyan(clc.bold("functions: ")) + + `Added functions-managed label to secrets: ${secretsChanged}`, + ); } /** @@ -241,10 +243,11 @@ function kitExportTarget(instanceId: string, projectId: string, options: Options "--kit-instance option was provided but no firebase.json available to look in for kit definitions.", ); } - const functionsConfig = firebaseConfig.get("functions"); - const functions = Array.isArray(functionsConfig) ? functionsConfig : [functionsConfig]; - for (const fn of functions) { - if (typeof fn.kit === "undefined" || typeof fn.instances === "undefined") { + const functionsConfig = firebaseConfig.src.functions ?? []; + const validatedFunctions = normalizeAndValidate(functionsConfig); + + for (const fn of validatedFunctions) { + if (!isKitConfig(fn)) { continue; } for (const [kitId, kitPath] of Object.entries(fn.instances)) { diff --git a/src/extensions/export.ts b/src/extensions/export.ts index f9dc7c6e5b6..6c72c602669 100644 --- a/src/extensions/export.ts +++ b/src/extensions/export.ts @@ -172,6 +172,7 @@ export async function ejectSecretsFromInstance(instance: ExtensionInstance): Pro if (!resourceName) { throw new FirebaseError( `Secret ${secretName} was defined in the extension spec, but is missing in live deployed secrets.`, + { exit: 1 }, ); } const match = resourceName.match(SECRET_VERSION_NAME_REGEX);