From 96aff4945f29d3981bc2bfef63a4824875b8eaa4 Mon Sep 17 00:00:00 2001 From: Andy Perelson Date: Wed, 9 Sep 2026 21:50:31 +0000 Subject: [PATCH 1/2] fix(extensions): gate experiments in before hooks, prompt for upgrades, and default uninstall to false in ext:migrate Improves safety and execution flow in `ext:migrate` and `functions:kits:install`: - **Early Experiment Gating**: Moves experiment checks into `.before()` hooks on `ext:migrate` (`extMigrationFeatures`, `kits`, `secretEnvParams`) and `functions:kits:install` (`kits`) so execution fails fast before configuration, IAM, or scaffolding steps run. - **Safe Extension Uninstallation**: Updates the post-deploy extension uninstallation confirmation in `ext:migrate` to default to `false`, preventing unintended teardown of live extensions during non-interactive runs. - **Extension Upgrade Prompts & Enforcement**: Ensures extension specifications and version fallbacks are loaded before update checks. Outdated extensions now prompt for confirmation before upgrading in-place, and declining halts migration with instructions to rerun with `--force` to proceed without upgrading. - Unit tests in `src/commands/functions-kits-install.spec.ts` verifying `.before()` hook experiment gating. - Unit tests in `src/extensions/migrate.spec.ts` verifying upgrade prompts, `--force` bypass, declined upgrade errors, version fallbacks, and uninstall confirmation defaults. - `npm run test` - Manual migration of an out of date storage-resize-images extension. firebase ext:migrate --package @firebase-function-kits/storage-resize-images@next firebase ext:migrate --package @firebase-function-kits/storage-resize-images@next --force --- src/commands/ext-migrate.ts | 25 ++++--- src/commands/functions-kits-install.spec.ts | 30 ++++----- src/commands/functions-kits-install.ts | 5 +- src/extensions/migrate.spec.ts | 75 +++++++++++++++++++-- src/extensions/migrate.ts | 29 +++++++- 5 files changed, 129 insertions(+), 35 deletions(-) diff --git a/src/commands/ext-migrate.ts b/src/commands/ext-migrate.ts index bb9577c7246..f740b633bae 100644 --- a/src/commands/ext-migrate.ts +++ b/src/commands/ext-migrate.ts @@ -2,6 +2,7 @@ import * as clc from "colorette"; import { checkMinRequiredVersion } from "../checkMinRequiredVersion"; import { Command } from "../command"; import { needProjectId } from "../projectUtils"; +import * as experiments from "../experiments"; import { ensureExtensionsApiEnabled, ensureInstanceSpec, @@ -37,6 +38,14 @@ export const command = new Command("ext:migrate") .option("--ext-instance ", "extension instance ID to migrate") .option("-e, --extension ", "extension reference or name to migrate") .option("-f, --force", "force update and migration without prompting") + .before(() => { + experiments.assertEnabled( + "extMigrationFeatures", + "migrate an extension instance to a function kit", + ); + experiments.assertEnabled("kits", "migrate an extension instance to a function kit"); + experiments.assertEnabled("secretEnvParams", "migrate an extension instance to a function kit"); + }) .before(requireConfig) .before(requirePermissions, [ "firebaseextensions.instances.list", @@ -67,6 +76,13 @@ export const command = new Command("ext:migrate") `Selected instance ${clc.bold(plan.instanceId)} (${plan.kitPackage}) for migration.`, ); + plan.instance = await ensureInstanceSpec(plan.instance); + if (!plan.instance.config?.source?.spec) { + throw new FirebaseError( + `Could not load extension specification for ${clc.bold(plan.instanceId)}. Unable to export configuration.`, + ); + } + plan.instance = await ensureInstanceUpToDate(projectId, plan.instance, options); if (plan.instance.state !== "ACTIVE") { @@ -76,13 +92,6 @@ export const command = new Command("ext:migrate") ); } - plan.instance = await ensureInstanceSpec(plan.instance); - if (!plan.instance.config?.source?.spec) { - throw new FirebaseError( - `Could not load extension specification for ${clc.bold(plan.instanceId)}. Unable to export configuration.`, - ); - } - const exportedEnvs = functionsEnvFromInstance(plan.instance); await migrateSecrets(plan.instance, { force: options.force }); @@ -129,7 +138,7 @@ export const command = new Command("ext:migrate") const shouldUninstall = await confirm({ message: `Functions kit ${kitInstanceId} successfully deployed. After checking function logs to verify that your backend is performing correctly, you should uninstall extension instance ${plan.instanceId}. Uninstall it now?`, - default: true, + default: false, nonInteractive: options.nonInteractive, force: options.force, }); diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index cdebb61683f..6b43c62d460 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -32,29 +32,23 @@ describe("functions:kits:install", () => { }); describe("command configuration", () => { - it("should have requireConfig and requireAuth as before hooks", () => { - expect(originalBefores).to.deep.equal([ - { fn: requireConfig, args: [] }, - { fn: requireAuth, args: [] }, - ]); + it("should assert kits experiment and require config/auth in before hooks", () => { + expect(originalBefores).to.have.lengthOf(3); + originalBefores[0].fn(); + expect(assertEnabledStub).to.have.been.calledWith("kits", "install a function kit"); + expect(originalBefores[1]).to.deep.equal({ fn: requireConfig, args: [] }); + expect(originalBefores[2]).to.deep.equal({ fn: requireAuth, args: [] }); }); - }); - describe("command action", () => { - it("should assert that kits experiment is enabled", async () => { + it("should fail if kits experiment is disabled", () => { assertEnabledStub.throws(new FirebaseError("kits experiment disabled")); - - await expect( - command.runner()({ - package: "@firebase-function-kits/firestore-bigquery-export", - cwd: "/mock/project", - nonInteractive: true, - }), - ).to.be.rejectedWith(FirebaseError, "kits experiment disabled"); - - expect(assertEnabledStub).to.have.been.calledWith("kits", "install a function kit"); + expect(() => { + originalBefores[0].fn(); + }).to.throw(FirebaseError, "kits experiment disabled"); }); + }); + describe("command action", () => { it("should throw an error if not in a Firebase project directory", async () => { await expect( command.runner()({ diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index ff089175db6..540f975d865 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -15,6 +15,9 @@ export interface FunctionsKitsInstallOptions extends Options { export const command = new Command("functions:kits:install") .description("install a function kit into your project") + .before(() => { + experiments.assertEnabled("kits", "install a function kit"); + }) .before(requireConfig) .before(requireAuth) .withForce() @@ -29,8 +32,6 @@ export const command = new Command("functions:kits:install") ) .option("--no-configure", "skip parameter prompting and configuration during installation") .action(async (options: FunctionsKitsInstallOptions): Promise => { - experiments.assertEnabled("kits", "install a function kit"); - if (!options.config) { throw new FirebaseError("Not in a Firebase project directory (firebase.json not found)."); } diff --git a/src/extensions/migrate.spec.ts b/src/extensions/migrate.spec.ts index 5c13243d98d..295817d1069 100644 --- a/src/extensions/migrate.spec.ts +++ b/src/extensions/migrate.spec.ts @@ -358,6 +358,12 @@ describe("ext:migrate core logic (Unique Veneer)", () => { }); }); describe("ensureInstanceUpToDate", () => { + let confirmStub: sinon.SinonStub; + + beforeEach(() => { + confirmStub = sandbox.stub(prompt, "confirm").resolves(true); + }); + it("should return original instance when instance is already up to date", async () => { sandbox.stub(extensionsApi, "getExtensionVersion").resolves({ name: "firebase/firestore-send-email@0.1.14", @@ -368,9 +374,10 @@ describe("ext:migrate core logic (Unique Veneer)", () => { const updated = await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1); expect(updated).to.equal(mockInstance1); + expect(confirmStub).to.not.have.been.called; }); - it("should automatically attempt upgrade when a newer version exists", async () => { + it("should prompt user and upgrade when a newer version exists and user confirms", async () => { sandbox.stub(extensionsApi, "getExtension").resolves({ latestVersion: "0.1.15", } as unknown as Extension); @@ -384,9 +391,69 @@ describe("ext:migrate core logic (Unique Veneer)", () => { await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1); + expect(confirmStub).to.have.been.calledWithMatch({ + message: sinon.match(/on version 0.1.18, but the latest version is 0.1.15/), + default: true, + }); expect(getExtVersionStub).to.have.been.called; }); + it("should throw FirebaseError with instructions to rerun with --force if user declines upgrade", async () => { + confirmStub.resolves(false); + sandbox.stub(extensionsApi, "getExtension").resolves({ + latestVersion: "0.1.19", + } as unknown as Extension); + + await expect( + migrateModule.ensureInstanceUpToDate("test-project", mockInstance1), + ).to.be.rejectedWith( + FirebaseError, + /Extension instance email-1 must be upgraded to version 0.1.19 before migrating. To bypass this requirement and migrate with the current version, rerun with --force./, + ); + }); + + it("should bypass upgrade with a warning if --force is specified", async () => { + sandbox.stub(extensionsApi, "getExtension").resolves({ + latestVersion: "0.1.19", + } as unknown as Extension); + const updateSpy = sandbox.spy(updateHelper, "update"); + + const result = await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1, { + force: true, + }); + + expect(result).to.equal(mockInstance1); + expect(confirmStub).to.not.have.been.called; + expect(updateSpy).to.not.have.been.called; + }); + + it("should resolve currentVersion from instance.config.extensionVersion if spec version is missing", async () => { + const instanceWithoutSpec: ExtensionInstance = { + ...mockInstance1, + config: { + ...mockInstance1.config, + extensionVersion: "0.1.14", + source: undefined, + }, + }; + sandbox.stub(extensionsApi, "getExtension").resolves({ + latestVersion: "0.1.15", + } as unknown as Extension); + sandbox.stub(extensionsApi, "getExtensionVersion").resolves({ + name: "firebase/firestore-send-email@0.1.15", + ref: "firebase/firestore-send-email@0.1.15", + spec: { name: "firestore-send-email", version: "0.1.15", params: [] }, + } as unknown as ExtensionVersion); + sandbox.stub(updateHelper, "update").resolves({} as unknown as ExtensionInstance); + sandbox.stub(extensionsApi, "getInstance").resolves(mockInstance1); + + await migrateModule.ensureInstanceUpToDate("test-project", instanceWithoutSpec); + + expect(confirmStub).to.have.been.calledWithMatch({ + message: sinon.match(/on version 0.1.14, but the latest version is 0.1.15/), + }); + }); + it("should merge systemParams into currentParams when prompting for new parameters", async () => { sandbox.stub(extensionsApi, "getExtension").resolves({ latestVersion: "0.1.15", @@ -441,7 +508,7 @@ describe("ext:migrate core logic (Unique Veneer)", () => { }); it("should prompt user when extension reference cannot be parsed and throw if user declines", async () => { - sandbox.stub(prompt, "confirm").resolves(false); + confirmStub.resolves(false); const invalidRefInstance = { ...mockInstance1, config: { ...mockInstance1.config, extensionRef: "invalid-ref-format" }, @@ -453,7 +520,7 @@ describe("ext:migrate core logic (Unique Veneer)", () => { }); it("should prompt user when extension reference cannot be parsed and continue if user accepts", async () => { - sandbox.stub(prompt, "confirm").resolves(true); + confirmStub.resolves(true); const invalidRefInstance = { ...mockInstance1, config: { ...mockInstance1.config, extensionRef: "invalid-ref-format" }, @@ -707,7 +774,7 @@ describe("ext:migrate core logic (Unique Veneer)", () => { message: sinon.match( /Functions kit email-1 successfully deployed.*uninstall extension instance email-1/, ), - default: true, + default: false, }), ); diff --git a/src/extensions/migrate.ts b/src/extensions/migrate.ts index a6f49e33113..07b2fb558ca 100644 --- a/src/extensions/migrate.ts +++ b/src/extensions/migrate.ts @@ -10,7 +10,7 @@ import { logLabeledSuccess, logLabeledWarning, } from "../utils"; -import { logPrefix } from "./extensionsHelper"; +import { ensureInstanceSpec, logPrefix } from "./extensionsHelper"; import { confirm, select } from "../prompt"; import * as extensionsApi from "./extensionsApi"; import * as refs from "./refs"; @@ -347,7 +347,11 @@ export async function ensureInstanceUpToDate( try { const parsed = refs.parse(rawRef); baseRef = refs.toExtensionRef(parsed); - currentVersion = parsed.version || instance.config.source?.spec?.version; + currentVersion = + parsed.version || + instance.config?.source?.spec?.version || + instance.config?.extensionVersion || + instance.extensionVersion; } catch (err: unknown) { logger.debug(`[ensureInstanceUpToDate] Could not parse extension reference '${rawRef}':`, err); logLabeledWarning( @@ -375,6 +379,25 @@ export async function ensureInstanceUpToDate( return instance; } + if (options?.force) { + logLabeledWarning( + logPrefix, + `Migrating extension instance ${clc.bold(instanceId)} using outdated version ${clc.bold(currentVersion)} because --force was specified. Migration may fail or behave unexpectedly.`, + ); + return instance; + } + + const shouldUpgrade = await confirm({ + message: `Extension instance ${clc.bold(instanceId)} is on version ${clc.bold(currentVersion)}, but the latest version is ${clc.bold(latestVersion)}. Upgrading is required before migrating to avoid breaking changes. Upgrade it now?`, + default: true, + nonInteractive: options?.nonInteractive, + }); + if (!shouldUpgrade) { + throw new FirebaseError( + `Extension instance ${clc.bold(instanceId)} must be upgraded to version ${clc.bold(latestVersion)} before migrating. To bypass this requirement and migrate with the current version, rerun with --force.`, + ); + } + logLabeledBullet( logPrefix, `Upgrading extension instance ${clc.bold(instanceId)} from version ${clc.bold(currentVersion)} to ${clc.bold(latestVersion)} to ensure a smooth migration...`, @@ -439,7 +462,7 @@ export async function ensureInstanceUpToDate( } const updatedInstance = await extensionsApi.getInstance(projectId, instanceId); - return updatedInstance ?? instance; + return updatedInstance ? await ensureInstanceSpec(updatedInstance) : instance; } /** From 380706cc62bcfa9d86c322e12303686df391e58a Mon Sep 17 00:00:00 2001 From: Andy Perelson Date: Thu, 10 Sep 2026 22:55:52 +0000 Subject: [PATCH 2/2] fix(extensions): allow continuing ext:migrate without --force when upgrade is declined ### Description In `firebase ext:migrate`, when an extension instance has a newer version available, prompt the user with a strongly recommended upgrade prompt, but honor a declined upgrade without requiring `--force`. If declined, log an informative warning indicating that migration continues with the outdated version, then proceed with parameter resolution, scaffolding, and deployment. ### Scenarios Tested - Declining extension upgrade continues migration with the current instance without calling the update helper. - Verified warning is logged specifying the instance ID and outdated version. - Passing `--force` forwards the option to the upgrade confirmation prompt. - Full mocha test suite passes for `src/extensions/migrate.spec.ts`. ### Sample Commands firebase ext:migrate --- src/extensions/migrate.spec.ts | 36 ++++++++++++++++++++++------------ src/extensions/migrate.ts | 17 ++++++---------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/extensions/migrate.spec.ts b/src/extensions/migrate.spec.ts index 295817d1069..512df23fb4e 100644 --- a/src/extensions/migrate.spec.ts +++ b/src/extensions/migrate.spec.ts @@ -398,33 +398,43 @@ describe("ext:migrate core logic (Unique Veneer)", () => { expect(getExtVersionStub).to.have.been.called; }); - it("should throw FirebaseError with instructions to rerun with --force if user declines upgrade", async () => { + it("should continue with current version if user declines upgrade", async () => { confirmStub.resolves(false); sandbox.stub(extensionsApi, "getExtension").resolves({ latestVersion: "0.1.19", } as unknown as Extension); + const updateSpy = sandbox.spy(updateHelper, "update"); + const warnSpy = sandbox.spy(utils, "logLabeledWarning"); - await expect( - migrateModule.ensureInstanceUpToDate("test-project", mockInstance1), - ).to.be.rejectedWith( - FirebaseError, - /Extension instance email-1 must be upgraded to version 0.1.19 before migrating. To bypass this requirement and migrate with the current version, rerun with --force./, + const result = await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1); + + expect(result).to.equal(mockInstance1); + expect(updateSpy).to.not.have.been.called; + expect(warnSpy).to.have.been.calledWithMatch( + "extensions", + /Continuing migration with extension instance email-1 on outdated version 0\.1\.18\./, ); }); - it("should bypass upgrade with a warning if --force is specified", async () => { + it("should pass force option to confirm prompt when --force is specified", async () => { sandbox.stub(extensionsApi, "getExtension").resolves({ - latestVersion: "0.1.19", + latestVersion: "0.1.15", } as unknown as Extension); - const updateSpy = sandbox.spy(updateHelper, "update"); + sandbox.stub(extensionsApi, "getExtensionVersion").resolves({ + name: "firebase/firestore-send-email@0.1.15", + ref: "firebase/firestore-send-email@0.1.15", + spec: { name: "firestore-send-email", version: "0.1.15", params: [] }, + } as unknown as ExtensionVersion); + sandbox.stub(updateHelper, "update").resolves({} as unknown as ExtensionInstance); + sandbox.stub(extensionsApi, "getInstance").resolves(mockInstance1); - const result = await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1, { + await migrateModule.ensureInstanceUpToDate("test-project", mockInstance1, { force: true, }); - expect(result).to.equal(mockInstance1); - expect(confirmStub).to.not.have.been.called; - expect(updateSpy).to.not.have.been.called; + expect(confirmStub).to.have.been.calledWithMatch({ + force: true, + }); }); it("should resolve currentVersion from instance.config.extensionVersion if spec version is missing", async () => { diff --git a/src/extensions/migrate.ts b/src/extensions/migrate.ts index 07b2fb558ca..78bec989023 100644 --- a/src/extensions/migrate.ts +++ b/src/extensions/migrate.ts @@ -379,23 +379,18 @@ export async function ensureInstanceUpToDate( return instance; } - if (options?.force) { - logLabeledWarning( - logPrefix, - `Migrating extension instance ${clc.bold(instanceId)} using outdated version ${clc.bold(currentVersion)} because --force was specified. Migration may fail or behave unexpectedly.`, - ); - return instance; - } - const shouldUpgrade = await confirm({ - message: `Extension instance ${clc.bold(instanceId)} is on version ${clc.bold(currentVersion)}, but the latest version is ${clc.bold(latestVersion)}. Upgrading is required before migrating to avoid breaking changes. Upgrade it now?`, + message: `Extension instance ${clc.bold(instanceId)} is on version ${clc.bold(currentVersion)}, but the latest version is ${clc.bold(latestVersion)}. An upgrade is strongly recommended before migrating to avoid breaking changes. Upgrade it now?`, default: true, nonInteractive: options?.nonInteractive, + force: options?.force, }); if (!shouldUpgrade) { - throw new FirebaseError( - `Extension instance ${clc.bold(instanceId)} must be upgraded to version ${clc.bold(latestVersion)} before migrating. To bypass this requirement and migrate with the current version, rerun with --force.`, + logLabeledWarning( + logPrefix, + `Continuing migration with extension instance ${clc.bold(instanceId)} on outdated version ${clc.bold(currentVersion)}.`, ); + return instance; } logLabeledBullet(