From 3f1c972767fcbf3977c4a106ee9bcb62cb643edd Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Wed, 12 Aug 2026 22:15:30 +0000 Subject: [PATCH 01/10] feat(functions): add instances on for 2+ installs of the same package --- src/commands/functions-kits-install.spec.ts | 480 ++++++++++++++++++++ src/commands/functions-kits-install.ts | 279 ++++++++++-- 2 files changed, 722 insertions(+), 37 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index e27bbd76eea..0f10682287c 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -11,17 +11,24 @@ import { sanitizePackageNameToKitName, isThirdPartyPackage, checkPackageHasShrinkwrap, + getProjectIdentifiers, + hasDotenvForProject, } from "./functions-kits-install"; import * as experiments from "../experiments"; import * as initSpawn from "../init/spawn"; import { Config } from "../config"; import { FirebaseError } from "../error"; import * as prompt from "../prompt"; +import { logger } from "../logger"; +import { Options } from "../options"; +import { ValidatedKitSingle } from "../functions/projectConfig"; +import { RC } from "../rc"; describe("functions:kits:install", () => { let assertEnabledStub: sinon.SinonStub; let wrapSpawnStub: sinon.SinonStub; let spawnWithOutputStub: sinon.SinonStub; + let loggerInfoStub: sinon.SinonStub; beforeEach(() => { (command as unknown as { befores: unknown[] }).befores = []; @@ -36,6 +43,8 @@ describe("functions:kits:install", () => { sinon.stub(fs, "readJson").resolves({}); sinon.stub(fs, "writeJson").resolves(); sinon.stub(fs, "writeFile").resolves(); + loggerInfoStub = sinon.stub(logger, "info"); + sinon.stub(logger, "warn"); }); afterEach(() => { @@ -229,6 +238,81 @@ describe("functions:kits:install", () => { }); }); + describe("getProjectIdentifiers", () => { + it("should return empty set when no project is provided", () => { + const ids = getProjectIdentifiers({} as unknown as Options); + expect(ids.size).to.equal(0); + }); + + it("should include options.project and options.projectId", () => { + const ids = getProjectIdentifiers({ + project: "my-alias", + projectId: "my-project-id", + } as unknown as Options); + expect(Array.from(ids)).to.include.members(["my-alias", "my-project-id"]); + }); + + it("should resolve project aliases from options.rc", () => { + const ids = getProjectIdentifiers({ + project: "staging", + rc: new RC(undefined, { + projects: { + staging: "my-staging-project-123", + prod: "my-prod-project-456", + }, + }), + } as unknown as Options); + expect(Array.from(ids)).to.include.members(["staging", "my-staging-project-123"]); + expect(Array.from(ids)).to.not.include("prod"); + }); + }); + + describe("hasDotenvForProject", () => { + it("should return false when projectIdentifiers is empty", () => { + const mockConfig = { path: (p: string) => `/mock/${p}` }; + const kit = { + kit: "test-kit", + source: "function-kits/test-kit", + instances: { inst: "function-kits/test-kit/config-inst" }, + } as unknown as ValidatedKitSingle; + expect(hasDotenvForProject(mockConfig, kit, new Set())).to.be.false; + }); + + it("should return true when a matching .env. file exists in instance configDir", () => { + const mockConfig = { path: (p: string) => `/mock/${p}` }; + const kit = { + kit: "test-kit", + source: "function-kits/test-kit", + instances: { inst: "function-kits/test-kit/config-inst" }, + } as unknown as ValidatedKitSingle; + sinon.stub(fs, "existsSync").returns(true); + sinon + .stub(fs, "readdirSync") + .returns([".env", ".env.local", ".env.my-target-proj"] as unknown as ReturnType< + typeof fs.readdirSync + >); + + expect(hasDotenvForProject(mockConfig, kit, new Set(["my-target-proj"]))).to.be.true; + }); + + it("should return false when only non-matching dotenv files exist", () => { + const mockConfig = { path: (p: string) => `/mock/${p}` }; + const kit = { + kit: "test-kit", + source: "function-kits/test-kit", + instances: { inst: "function-kits/test-kit/config-inst" }, + } as unknown as ValidatedKitSingle; + sinon.stub(fs, "existsSync").returns(true); + sinon + .stub(fs, "readdirSync") + .returns([".env", ".env.local", ".env.other-proj"] as unknown as ReturnType< + typeof fs.readdirSync + >); + + expect(hasDotenvForProject(mockConfig, kit, new Set(["my-target-proj"]))).to.be.false; + }); + }); + describe("command action", () => { it("should assert that kits experiment is enabled", async () => { assertEnabledStub.throws(new FirebaseError("kits experiment disabled")); @@ -827,5 +911,401 @@ describe("functions:kits:install", () => { }), ).to.be.rejectedWith(FirebaseError, "Installation cancelled."); }); + + describe("subsequent (2+) installs for already installed package", () => { + it("should add an instance in non-interactive mode when no current project dotenv exists", async () => { + const writtenFiles: Record = {}; + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "firestore-bigquery-export": + "function-kits/firestore-bigquery-export/config-firestore-bigquery-export", + }, + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + askWriteProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + return Promise.resolve(); + }, + } as unknown as Config; + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + nonInteractive: true, + project: "my-target-proj", + }); + + // NPM install and build should NOT run on subsequent install + expect(wrapSpawnStub).to.not.have.been.called; + + const updatedFunctions = ( + writtenFiles["firebase.json"] as { + functions: Array<{ + kit?: string; + instances?: Record; + }>; + } + ).functions; + expect(updatedFunctions).to.have.length(1); + const instances = updatedFunctions[0].instances || {}; + const instanceKeys = Object.keys(instances); + expect(instanceKeys).to.have.length(2); + expect(instanceKeys[0]).to.equal("firestore-bigquery-export"); + expect(instanceKeys[1]).to.match(/^firestore-bigquery-export-[a-f0-9]{4}$/); + expect(instances[instanceKeys[1]]).to.equal( + `function-kits/firestore-bigquery-export/config-${instanceKeys[1]}`, + ); + }); + + it("should add an instance interactively with a custom name when no current project dotenv exists", async () => { + const writtenFiles: Record = {}; + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + askWriteProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + return Promise.resolve(); + }, + } as unknown as Config; + + const selectStub = sinon.stub(prompt, "select").resolves("addInstance"); + sinon.stub(prompt, "input").resolves("custom-instance-2"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + project: "my-target-proj", + }); + + expect(wrapSpawnStub).to.not.have.been.called; + expect(selectStub).to.have.been.calledOnce; + + const updatedFunctions = ( + writtenFiles["firebase.json"] as { + functions: Array<{ + kit?: string; + instances?: Record; + }>; + } + ).functions; + const instances = updatedFunctions[0].instances || {}; + expect(instances).to.deep.equal({ + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + "custom-instance-2": "function-kits/firestore-bigquery-export/config-custom-instance-2", + }); + }); + + it("should directly add an instance without prompting action or logging deploy suggestion when current project dotenv already exists", async () => { + const writtenFiles: Record = {}; + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + askWriteProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + return Promise.resolve(); + }, + } as unknown as Config; + + sinon.stub(fs, "existsSync").returns(true); + sinon + .stub(fs, "readdirSync") + .returns([".env.my-target-proj"] as unknown as ReturnType); + + const selectStub = sinon.stub(prompt, "select"); + sinon.stub(prompt, "input").resolves("inst-2"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + project: "my-target-proj", + }); + + // select should NOT be called to ask addInstance vs addEnv + expect(selectStub).to.not.have.been.called; + + const updatedFunctions = ( + writtenFiles["firebase.json"] as { + functions: Array<{ + kit?: string; + instances?: Record; + }>; + } + ).functions; + const instances = updatedFunctions[0].instances || {}; + expect(instances).to.deep.equal({ + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + "inst-2": "function-kits/firestore-bigquery-export/config-inst-2", + }); + + // Should not log deploy suggestion when already configured for project + expect(loggerInfoStub).to.not.have.been.calledWith( + sinon.match(/To create a new instance in this project, deploy/), + ); + }); + + it("should reject duplicate instance ID when adding instance interactively", async () => { + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: sinon.stub(), + } as unknown as Config; + + sinon.stub(prompt, "select").resolves("addInstance"); + sinon.stub(prompt, "input").resolves("inst-1"); + + await expect( + command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + }), + ).to.be.rejectedWith( + FirebaseError, + /functions kit instance ID must be unique across all kits/, + ); + }); + + it("should suggest deploy command when configuring single instance with active project", async () => { + const writeProjectFileStub = sinon.stub(); + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: writeProjectFileStub, + } as unknown as Config; + + sinon.stub(prompt, "select").resolves("addEnv"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + project: "my-staging-project", + }); + + expect(writeProjectFileStub).to.not.have.been.called; + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/firebase deploy --only functions:inst-1 --project my-staging-project/), + ); + }); + + it("should suggest deploy command with placeholder when no active project is configured", async () => { + const writeProjectFileStub = sinon.stub(); + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: writeProjectFileStub, + } as unknown as Config; + + sinon.stub(prompt, "select").resolves("addEnv"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + }); + + expect(writeProjectFileStub).to.not.have.been.called; + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/firebase deploy --only functions:inst-1 --project /), + ); + }); + + it("should prompt to select instance when multiple instances exist for configuring instance in project", async () => { + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + "inst-2": "function-kits/firestore-bigquery-export/config-inst-2", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: sinon.stub(), + } as unknown as Config; + + const selectStub = sinon.stub(prompt, "select"); + selectStub.onFirstCall().resolves("addEnv"); + selectStub.onSecondCall().resolves("inst-2"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + project: "prod-project", + }); + + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/firebase deploy --only functions:inst-2 --project prod-project/), + ); + }); + + it("should allow choosing target kit if multiple kits have the same package name", async () => { + const writtenFiles: Record = {}; + const mockConfig = { + projectDir: "/mock/project", + src: { + functions: [ + { + kit: "kit-one", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/kit-one", + instances: { + "inst-a": "function-kits/kit-one/config-inst-a", + }, + }, + { + kit: "kit-two", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/kit-two", + instances: { + "inst-b": "function-kits/kit-two/config-inst-b", + }, + }, + ], + }, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + askWriteProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + return Promise.resolve(); + }, + } as unknown as Config; + + const selectStub = sinon.stub(prompt, "select"); + selectStub.onFirstCall().resolves("kit-two"); + selectStub.onSecondCall().resolves("addInstance"); + + sinon.stub(prompt, "input").resolves("inst-b-2"); + + await command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + }); + + const updatedFunctions = ( + writtenFiles["firebase.json"] as { + functions: Array<{ + kit?: string; + instances?: Record; + }>; + } + ).functions; + expect(updatedFunctions[1].instances).to.deep.equal({ + "inst-b": "function-kits/kit-two/config-inst-b", + "inst-b-2": "function-kits/kit-two/config-inst-b-2", + }); + }); + }); }); }); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 3fd10b292a5..15fb3ee6411 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -5,19 +5,21 @@ import * as fs from "fs-extra"; import { Command } from "../command"; import { FirebaseError, getErrMsg } from "../error"; -import { KitFunctionConfig } from "../firebaseConfig"; +import { FunctionsConfig, KitFunctionConfig } from "../firebaseConfig"; +import { getProjectId } from "../projectUtils"; import { isKitConfig, normalizeAndValidate, validateKit, validateKitInstanceId, - ValidatedConfig, + ValidatedKitSingle, + ValidatedSingle, } from "../functions/projectConfig"; import * as experiments from "../experiments"; import { logger } from "../logger"; import { Options } from "../options"; -import { confirm, input } from "../prompt"; +import { confirm, input, select } from "../prompt"; import { spawnWithOutput, wrapSpawn } from "../init/spawn"; import { readTemplateSync } from "../templates"; import * as supported from "../deploy/functions/runtimes/supported"; @@ -140,6 +142,64 @@ export async function checkPackageHasShrinkwrap(rawPkgName: string): Promise { + const ids = new Set(); + const projectId = getProjectId(options); + if (projectId) { + ids.add(projectId); + } + if (options.project) { + ids.add(options.project); + } + if (options.rc) { + const rcProjects = options.rc.projects; + for (const [alias, pid] of Object.entries(rcProjects)) { + if (ids.has(alias) || ids.has(pid)) { + ids.add(alias); + ids.add(pid); + } + } + } + return ids; +} + +/** + * Checks if any of the kit's instance configuration directories contain a dotenv file for the current project. + * A dotenv file is for the current project if its filename is `.env.` where `` + * matches one of the active project identifiers. + */ +export function hasDotenvForProject( + config: { path: (p: string) => string }, + kit: ValidatedKitSingle, + projectIdentifiers: Set, +): boolean { + if (projectIdentifiers.size === 0) { + return false; + } + for (const configDirPath of Object.values(kit.instances || {})) { + const absDir = config.path(configDirPath); + if (fs.existsSync(absDir)) { + try { + const files = fs.readdirSync(absDir); + for (const file of files) { + if (file.startsWith(".env.")) { + const suffix = file.slice(".env.".length); + if (projectIdentifiers.has(suffix)) { + return true; + } + } + } + } catch (err: unknown) { + logger.debug(`Failed to read directory ${absDir}: ${getErrMsg(err)}`); + } + } + } + return false; +} + export const command = new Command("functions:kits:install") .description("install a function kit into your project") .option("--npm_package ", "NPM package name or specifier to install as a function kit") @@ -170,6 +230,178 @@ export const command = new Command("functions:kits:install") const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); validateNpmPackageName(packageName); + let existingFunctions: ValidatedSingle[] = []; + const configSrc = options.config.src as unknown as { + functions?: FunctionsConfig; + [key: string]: unknown; + }; + const configFunctions = configSrc.functions; + if (configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0)) { + try { + existingFunctions = normalizeAndValidate(configFunctions); + } catch (err: unknown) { + throw new FirebaseError(`Invalid existing functions configuration: ${getErrMsg(err)}`); + } + } + + const existingKitIds = new Set(); + const existingCodebases = new Set(); + const existingInstanceIds = new Set(); + for (const c of existingFunctions) { + if (isKitConfig(c)) { + if (c.kit) { + existingKitIds.add(c.kit); + } + if (c.instances) { + for (const instId of Object.keys(c.instances)) { + existingInstanceIds.add(instId); + } + } + } else if (c.codebase) { + existingCodebases.add(c.codebase); + } + } + + const matchingKits = existingFunctions.filter( + (c): c is ValidatedKitSingle => isKitConfig(c) && c.sourcePackage?.name === packageName, + ); + + if (matchingKits.length > 0) { + let targetKit: ValidatedKitSingle = matchingKits[0]; + if (matchingKits.length > 1) { + if (!options.nonInteractive) { + const chosenKitId = await select({ + message: `Multiple kits found for package ${packageName}. Which kit would you like to configure?`, + choices: matchingKits.map((k) => ({ name: k.kit, value: k.kit })), + }); + const foundKit = matchingKits.find((k) => k.kit === chosenKitId); + if (foundKit) { + targetKit = foundKit; + } + } + } + + const projectIdentifiers = getProjectIdentifiers(options); + const hasCurrentProjectEnv = hasDotenvForProject( + options.config, + targetKit, + projectIdentifiers, + ); + + let action: "addInstance" | "addEnv"; + if (!hasCurrentProjectEnv && !options.nonInteractive) { + action = await select<"addInstance" | "addEnv">({ + message: `Package ${clc.bold(packageName)} is already installed for kit ${clc.bold(targetKit.kit)}. What would you like to do?`, + choices: [ + { + name: "Add an instance to the existing kit", + value: "addInstance", + }, + { + name: "Configure an existing instance for this project", + value: "addEnv", + }, + ], + }); + } else { + action = "addInstance"; + } + + if (action === "addInstance") { + const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); + const defaultInstanceId = generateUniqueId(targetKit.kit, instanceCollisions); + + const instanceId = await input({ + message: "What would you like to name this instance?", + default: defaultInstanceId, + nonInteractive: options.nonInteractive, + validate: (val: string) => { + try { + validateKitInstanceId(val); + } catch (err: unknown) { + return getErrMsg(err); + } + if (existingInstanceIds.has(val)) { + return `functions kit instance ID must be unique across all kits, but '${val}' was used more than once.`; + } + if (existingCodebases.has(val)) { + return `functions codebase name and kit instance ID must be mutually exclusive, but '${val}' was used as both a codebase name and a kit instance ID.`; + } + return true; + }, + }); + + validateKitInstanceId(instanceId); + if (existingInstanceIds.has(instanceId)) { + throw new FirebaseError( + `functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`, + ); + } + if (existingCodebases.has(instanceId)) { + throw new FirebaseError( + `functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`, + ); + } + + const configDirPath = path.join(FUNCTION_KITS_DIR, targetKit.kit, `config-${instanceId}`); + const absConfigDirPath = options.config.path(configDirPath); + await fs.ensureDir(absConfigDirPath); + + const functionsRaw = configSrc.functions as + | KitFunctionConfig + | KitFunctionConfig[] + | undefined; + if (Array.isArray(functionsRaw)) { + const rawKit = functionsRaw.find((f) => f.kit === targetKit.kit); + if (rawKit) { + rawKit.instances = rawKit.instances || {}; + rawKit.instances[instanceId] = configDirPath; + } + } else if ( + functionsRaw && + typeof functionsRaw === "object" && + functionsRaw.kit === targetKit.kit + ) { + functionsRaw.instances = functionsRaw.instances || {}; + functionsRaw.instances[instanceId] = configDirPath; + } + + options.config.writeProjectFile("firebase.json", configSrc); + logger.info( + clc.green( + `✔ Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(targetKit.kit)}.`, + ), + ); + return; + } + + if (action === "addEnv") { + const instanceIds: string[] = Object.keys(targetKit.instances); + if (instanceIds.length === 0) { + throw new FirebaseError(`Kit '${targetKit.kit}' has no instances configured.`); + } + + let selectedInstanceId = instanceIds[0]; + if (instanceIds.length > 1) { + if (!options.nonInteractive) { + selectedInstanceId = await select({ + message: "Which instance would you like to configure for this project?", + choices: instanceIds.map((id) => ({ name: id, value: id })), + }); + } + } + + const targetProject = getProjectId(options) || options.project || ""; + logger.info( + "\nTo create a new instance in this project, deploy the instance dedicated to this project using\n" + + clc.bold( + `firebase deploy --only functions:${selectedInstanceId} --project ${targetProject}`, + ), + ); + return; + } + } + const isThirdParty = isThirdPartyPackage(packageName); if (isThirdParty) { logger.warn( @@ -207,34 +439,6 @@ export const command = new Command("functions:kits:install") } } - let existingFunctions: ValidatedConfig | [] = []; - const configFunctions = options.config.src.functions; - if (configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0)) { - try { - existingFunctions = normalizeAndValidate(configFunctions); - } catch (err: unknown) { - throw new FirebaseError(`Invalid existing functions configuration: ${getErrMsg(err)}`); - } - } - - const existingKitIds = new Set(); - const existingCodebases = new Set(); - const existingInstanceIds = new Set(); - for (const c of existingFunctions) { - if (isKitConfig(c)) { - if (c.kit) { - existingKitIds.add(c.kit); - } - if (c.instances) { - for (const instId of Object.keys(c.instances)) { - existingInstanceIds.add(instId); - } - } - } else if (c.codebase) { - existingCodebases.add(c.codebase); - } - } - const baseKitId = sanitizePackageNameToKitName(packageName); const defaultKitId = generateUniqueId(baseKitId, existingKitIds); @@ -384,14 +588,15 @@ export const command = new Command("functions:kits:install") predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], }; - if (!options.config.src.functions) { - options.config.src.functions = [newKitConfig]; - } else if (Array.isArray(options.config.src.functions)) { - options.config.src.functions.push(newKitConfig); + const functionsRaw = configSrc.functions as KitFunctionConfig | KitFunctionConfig[] | undefined; + if (!functionsRaw) { + configSrc.functions = [newKitConfig]; + } else if (Array.isArray(functionsRaw)) { + functionsRaw.push(newKitConfig); } else { - options.config.src.functions = [options.config.src.functions, newKitConfig]; + configSrc.functions = [functionsRaw, newKitConfig]; } - options.config.writeProjectFile("firebase.json", options.config.src); + options.config.writeProjectFile("firebase.json", configSrc); logger.info(clc.green(`✔ Function kit ${clc.bold(kitId)} successfully installed.`)); }); From ead67b47ff748667e71570019475616e2513f0f3 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Wed, 12 Aug 2026 23:10:47 +0000 Subject: [PATCH 02/10] Remove check for multiple kits We should assume there is at most one kit containing the package to install. --- src/commands/functions-kits-install.spec.ts | 64 --------------------- src/commands/functions-kits-install.ts | 38 ++++-------- 2 files changed, 12 insertions(+), 90 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 0f10682287c..80b3403f4a1 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -1242,70 +1242,6 @@ describe("functions:kits:install", () => { sinon.match(/firebase deploy --only functions:inst-2 --project prod-project/), ); }); - - it("should allow choosing target kit if multiple kits have the same package name", async () => { - const writtenFiles: Record = {}; - const mockConfig = { - projectDir: "/mock/project", - src: { - functions: [ - { - kit: "kit-one", - sourcePackage: { - name: "@firebase-functions-kits/firestore-bigquery-export", - }, - source: "function-kits/kit-one", - instances: { - "inst-a": "function-kits/kit-one/config-inst-a", - }, - }, - { - kit: "kit-two", - sourcePackage: { - name: "@firebase-functions-kits/firestore-bigquery-export", - }, - source: "function-kits/kit-two", - instances: { - "inst-b": "function-kits/kit-two/config-inst-b", - }, - }, - ], - }, - path: (p: string) => path.join("/mock/project", p), - writeProjectFile: (file: string, content: unknown) => { - writtenFiles[file] = content; - }, - askWriteProjectFile: (file: string, content: unknown) => { - writtenFiles[file] = content; - return Promise.resolve(); - }, - } as unknown as Config; - - const selectStub = sinon.stub(prompt, "select"); - selectStub.onFirstCall().resolves("kit-two"); - selectStub.onSecondCall().resolves("addInstance"); - - sinon.stub(prompt, "input").resolves("inst-b-2"); - - await command.runner()({ - npm_package: "@firebase-functions-kits/firestore-bigquery-export", - cwd: "/mock/project", - config: mockConfig, - }); - - const updatedFunctions = ( - writtenFiles["firebase.json"] as { - functions: Array<{ - kit?: string; - instances?: Record; - }>; - } - ).functions; - expect(updatedFunctions[1].instances).to.deep.equal({ - "inst-b": "function-kits/kit-two/config-inst-b", - "inst-b-2": "function-kits/kit-two/config-inst-b-2", - }); - }); }); }); }); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 15fb3ee6411..5f69272ba17 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -155,7 +155,7 @@ export function getProjectIdentifiers(options: Options): Set { ids.add(options.project); } if (options.rc) { - const rcProjects = options.rc.projects; + const rcProjects = options.rc.projects || {}; for (const [alias, pid] of Object.entries(rcProjects)) { if (ids.has(alias) || ids.has(pid)) { ids.add(alias); @@ -262,36 +262,22 @@ export const command = new Command("functions:kits:install") } } - const matchingKits = existingFunctions.filter( + const existingKit = existingFunctions.find( (c): c is ValidatedKitSingle => isKitConfig(c) && c.sourcePackage?.name === packageName, ); - if (matchingKits.length > 0) { - let targetKit: ValidatedKitSingle = matchingKits[0]; - if (matchingKits.length > 1) { - if (!options.nonInteractive) { - const chosenKitId = await select({ - message: `Multiple kits found for package ${packageName}. Which kit would you like to configure?`, - choices: matchingKits.map((k) => ({ name: k.kit, value: k.kit })), - }); - const foundKit = matchingKits.find((k) => k.kit === chosenKitId); - if (foundKit) { - targetKit = foundKit; - } - } - } - + if (existingKit) { const projectIdentifiers = getProjectIdentifiers(options); const hasCurrentProjectEnv = hasDotenvForProject( options.config, - targetKit, + existingKit, projectIdentifiers, ); let action: "addInstance" | "addEnv"; if (!hasCurrentProjectEnv && !options.nonInteractive) { action = await select<"addInstance" | "addEnv">({ - message: `Package ${clc.bold(packageName)} is already installed for kit ${clc.bold(targetKit.kit)}. What would you like to do?`, + message: `Package ${clc.bold(packageName)} is already installed for kit ${clc.bold(existingKit.kit)}. What would you like to do?`, choices: [ { name: "Add an instance to the existing kit", @@ -309,7 +295,7 @@ export const command = new Command("functions:kits:install") if (action === "addInstance") { const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); - const defaultInstanceId = generateUniqueId(targetKit.kit, instanceCollisions); + const defaultInstanceId = generateUniqueId(existingKit.kit, instanceCollisions); const instanceId = await input({ message: "What would you like to name this instance?", @@ -343,7 +329,7 @@ export const command = new Command("functions:kits:install") ); } - const configDirPath = path.join(FUNCTION_KITS_DIR, targetKit.kit, `config-${instanceId}`); + const configDirPath = path.join(FUNCTION_KITS_DIR, existingKit.kit, `config-${instanceId}`); const absConfigDirPath = options.config.path(configDirPath); await fs.ensureDir(absConfigDirPath); @@ -352,7 +338,7 @@ export const command = new Command("functions:kits:install") | KitFunctionConfig[] | undefined; if (Array.isArray(functionsRaw)) { - const rawKit = functionsRaw.find((f) => f.kit === targetKit.kit); + const rawKit = functionsRaw.find((f) => f.kit === existingKit.kit); if (rawKit) { rawKit.instances = rawKit.instances || {}; rawKit.instances[instanceId] = configDirPath; @@ -360,7 +346,7 @@ export const command = new Command("functions:kits:install") } else if ( functionsRaw && typeof functionsRaw === "object" && - functionsRaw.kit === targetKit.kit + functionsRaw.kit === existingKit.kit ) { functionsRaw.instances = functionsRaw.instances || {}; functionsRaw.instances[instanceId] = configDirPath; @@ -369,16 +355,16 @@ export const command = new Command("functions:kits:install") options.config.writeProjectFile("firebase.json", configSrc); logger.info( clc.green( - `✔ Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(targetKit.kit)}.`, + `✔ Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(existingKit.kit)}.`, ), ); return; } if (action === "addEnv") { - const instanceIds: string[] = Object.keys(targetKit.instances); + const instanceIds: string[] = Object.keys(existingKit.instances); if (instanceIds.length === 0) { - throw new FirebaseError(`Kit '${targetKit.kit}' has no instances configured.`); + throw new FirebaseError(`Kit '${existingKit.kit}' has no instances configured.`); } let selectedInstanceId = instanceIds[0]; From 32c965c334e629d35f432bdfc223e305f7923259 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Wed, 12 Aug 2026 23:29:04 +0000 Subject: [PATCH 03/10] Simplify constructing instance config --- src/commands/functions-kits-install.spec.ts | 46 +++++++++++++++++++++ src/commands/functions-kits-install.ts | 33 +++++++-------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 80b3403f4a1..a3f005cbe9d 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -1132,6 +1132,52 @@ describe("functions:kits:install", () => { ); }); + it("should throw an error if the raw kit is missing from firebase.json when adding an instance", async () => { + let currentFunctions: unknown = [ + { + kit: "firestore-bigquery-export", + sourcePackage: { + name: "@firebase-functions-kits/firestore-bigquery-export", + }, + source: "function-kits/firestore-bigquery-export", + instances: { + "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", + }, + }, + ]; + const srcObj = { + get functions() { + return currentFunctions; + }, + set functions(val: unknown) { + currentFunctions = val; + }, + }; + const mockConfig = { + projectDir: "/mock/project", + src: srcObj, + path: (p: string) => path.join("/mock/project", p), + writeProjectFile: sinon.stub(), + } as unknown as Config; + + sinon.stub(prompt, "select").resolves("addInstance"); + sinon.stub(prompt, "input").callsFake(async () => { + currentFunctions = []; + return "inst-2"; + }); + + await expect( + command.runner()({ + npm_package: "@firebase-functions-kits/firestore-bigquery-export", + cwd: "/mock/project", + config: mockConfig, + }), + ).to.be.rejectedWith( + FirebaseError, + /Could not find kit 'firestore-bigquery-export' in firebase.json configuration/, + ); + }); + it("should suggest deploy command when configuring single instance with active project", async () => { const writeProjectFileStub = sinon.stub(); const mockConfig = { diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 5f69272ba17..8fbe023cd6d 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -333,25 +333,24 @@ export const command = new Command("functions:kits:install") const absConfigDirPath = options.config.path(configDirPath); await fs.ensureDir(absConfigDirPath); - const functionsRaw = configSrc.functions as - | KitFunctionConfig - | KitFunctionConfig[] - | undefined; - if (Array.isArray(functionsRaw)) { - const rawKit = functionsRaw.find((f) => f.kit === existingKit.kit); - if (rawKit) { - rawKit.instances = rawKit.instances || {}; - rawKit.instances[instanceId] = configDirPath; - } - } else if ( - functionsRaw && - typeof functionsRaw === "object" && - functionsRaw.kit === existingKit.kit - ) { - functionsRaw.instances = functionsRaw.instances || {}; - functionsRaw.instances[instanceId] = configDirPath; + const functionsList = Array.isArray(configSrc.functions) + ? configSrc.functions + : configSrc.functions + ? [configSrc.functions] + : []; + const rawKit = functionsList.find( + (f): f is KitFunctionConfig => + typeof f === "object" && f !== null && "kit" in f && f.kit === existingKit.kit, + ); + if (!rawKit) { + throw new FirebaseError( + `Could not find kit '${existingKit.kit}' in firebase.json configuration.`, + ); } + rawKit.instances = rawKit.instances || {}; + rawKit.instances[instanceId] = configDirPath; + options.config.writeProjectFile("firebase.json", configSrc); logger.info( clc.green( From a8d808deb93bdc765ad4e260d09305471b799ff7 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Thu, 13 Aug 2026 23:47:09 +0000 Subject: [PATCH 04/10] Update prompts and logs to match design doc --- src/commands/functions-kits-install.spec.ts | 20 ++++++++++++++++++++ src/commands/functions-kits-install.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index a3f005cbe9d..8a8ccdab3c3 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -1014,6 +1014,12 @@ describe("functions:kits:install", () => { expect(wrapSpawnStub).to.not.have.been.called; expect(selectStub).to.have.been.calledOnce; + expect(selectStub).to.have.been.calledWith( + sinon.match({ + message: + "The following instances already exist, but are not configured for this project: inst-1. What would you like to do?", + }), + ); const updatedFunctions = ( writtenFiles["firebase.json"] as { @@ -1090,6 +1096,14 @@ describe("functions:kits:install", () => { "inst-2": "function-kits/firestore-bigquery-export/config-inst-2", }); + // Should log info message that package is already installed as kit + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), + sinon.match( + /This package is already installed as kit firestore-bigquery-export, creating a new instance\./, + ), + ); + // Should not log deploy suggestion when already configured for project expect(loggerInfoStub).to.not.have.been.calledWith( sinon.match(/To create a new instance in this project, deploy/), @@ -1284,6 +1298,12 @@ describe("functions:kits:install", () => { project: "prod-project", }); + expect(selectStub.firstCall).to.have.been.calledWith( + sinon.match({ + message: + "The following instances already exist, but are not configured for this project: inst-1, inst-2. What would you like to do?", + }), + ); expect(loggerInfoStub).to.have.been.calledWith( sinon.match(/firebase deploy --only functions:inst-2 --project prod-project/), ); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 8fbe023cd6d..7b4dda3bde1 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -7,6 +7,7 @@ import { Command } from "../command"; import { FirebaseError, getErrMsg } from "../error"; import { FunctionsConfig, KitFunctionConfig } from "../firebaseConfig"; import { getProjectId } from "../projectUtils"; +import { logLabeledBullet } from "../utils"; import { isKitConfig, @@ -276,8 +277,9 @@ export const command = new Command("functions:kits:install") let action: "addInstance" | "addEnv"; if (!hasCurrentProjectEnv && !options.nonInteractive) { + const existingInstances = Object.keys(existingKit.instances || {}).join(", "); action = await select<"addInstance" | "addEnv">({ - message: `Package ${clc.bold(packageName)} is already installed for kit ${clc.bold(existingKit.kit)}. What would you like to do?`, + message: `The following instances already exist, but are not configured for this project: ${existingInstances}. What would you like to do?`, choices: [ { name: "Add an instance to the existing kit", @@ -290,6 +292,12 @@ export const command = new Command("functions:kits:install") ], }); } else { + if (hasCurrentProjectEnv) { + logLabeledBullet( + "functions", + `This package is already installed as kit ${existingKit.kit}, creating a new instance.`, + ); + } action = "addInstance"; } From 73fba87a54e7d6916c91b7949fce0cc7d378007e Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 14 Aug 2026 00:47:48 +0000 Subject: [PATCH 05/10] simplify adding kit instance to config and remove synthetic test Simplifies the instance assignment logic in `functions:kits:install` by directly updating `existingKit.instances` in place instead of performing a redundant search and defensive error check over `configSrc.functions`. Removes the synthetic unit test that artificially zeroed out config mid-execution to hit the unreachable error branch. --- src/commands/functions-kits-install.spec.ts | 46 --------------------- src/commands/functions-kits-install.ts | 18 +------- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 8a8ccdab3c3..2111beb0297 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -1146,52 +1146,6 @@ describe("functions:kits:install", () => { ); }); - it("should throw an error if the raw kit is missing from firebase.json when adding an instance", async () => { - let currentFunctions: unknown = [ - { - kit: "firestore-bigquery-export", - sourcePackage: { - name: "@firebase-functions-kits/firestore-bigquery-export", - }, - source: "function-kits/firestore-bigquery-export", - instances: { - "inst-1": "function-kits/firestore-bigquery-export/config-inst-1", - }, - }, - ]; - const srcObj = { - get functions() { - return currentFunctions; - }, - set functions(val: unknown) { - currentFunctions = val; - }, - }; - const mockConfig = { - projectDir: "/mock/project", - src: srcObj, - path: (p: string) => path.join("/mock/project", p), - writeProjectFile: sinon.stub(), - } as unknown as Config; - - sinon.stub(prompt, "select").resolves("addInstance"); - sinon.stub(prompt, "input").callsFake(async () => { - currentFunctions = []; - return "inst-2"; - }); - - await expect( - command.runner()({ - npm_package: "@firebase-functions-kits/firestore-bigquery-export", - cwd: "/mock/project", - config: mockConfig, - }), - ).to.be.rejectedWith( - FirebaseError, - /Could not find kit 'firestore-bigquery-export' in firebase.json configuration/, - ); - }); - it("should suggest deploy command when configuring single instance with active project", async () => { const writeProjectFileStub = sinon.stub(); const mockConfig = { diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 7b4dda3bde1..c51c83c0296 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -341,23 +341,7 @@ export const command = new Command("functions:kits:install") const absConfigDirPath = options.config.path(configDirPath); await fs.ensureDir(absConfigDirPath); - const functionsList = Array.isArray(configSrc.functions) - ? configSrc.functions - : configSrc.functions - ? [configSrc.functions] - : []; - const rawKit = functionsList.find( - (f): f is KitFunctionConfig => - typeof f === "object" && f !== null && "kit" in f && f.kit === existingKit.kit, - ); - if (!rawKit) { - throw new FirebaseError( - `Could not find kit '${existingKit.kit}' in firebase.json configuration.`, - ); - } - - rawKit.instances = rawKit.instances || {}; - rawKit.instances[instanceId] = configDirPath; + existingKit.instances[instanceId] = configDirPath; options.config.writeProjectFile("firebase.json", configSrc); logger.info( From dc6e5a2798a674df5630ad20b76118b133fa06c1 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 14 Aug 2026 17:44:04 +0000 Subject: [PATCH 06/10] refactor: simplify project env checks for function kits install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds `hasProjectEnv(dir, projectId, projectAlias)` to `src/functions/env.ts` as a generic helper to check for `.env.` and `.env.` in a directory. - Renames `hasDotenvForProject` in `functions-kits-install.ts` to `isKitConfiguredForProject` and delegates directory checks to `hasProjectEnv`. - Eliminates custom `getProjectIdentifiers` resolution and `readdirSync` directory itøeration, aligning with how the CLI resolves environment files. --- src/commands/functions-kits-install.spec.ts | 91 +++++++-------------- src/commands/functions-kits-install.ts | 72 ++++------------ src/functions/env.spec.ts | 36 ++++++++ src/functions/env.ts | 16 ++++ 4 files changed, 97 insertions(+), 118 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 2111beb0297..2ad77d194bf 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -11,8 +11,7 @@ import { sanitizePackageNameToKitName, isThirdPartyPackage, checkPackageHasShrinkwrap, - getProjectIdentifiers, - hasDotenvForProject, + isKitConfiguredForProject, } from "./functions-kits-install"; import * as experiments from "../experiments"; import * as initSpawn from "../init/spawn"; @@ -20,9 +19,8 @@ import { Config } from "../config"; import { FirebaseError } from "../error"; import * as prompt from "../prompt"; import { logger } from "../logger"; -import { Options } from "../options"; import { ValidatedKitSingle } from "../functions/projectConfig"; -import { RC } from "../rc"; +import * as env from "../functions/env"; describe("functions:kits:install", () => { let assertEnabledStub: sinon.SinonStub; @@ -238,78 +236,48 @@ describe("functions:kits:install", () => { }); }); - describe("getProjectIdentifiers", () => { - it("should return empty set when no project is provided", () => { - const ids = getProjectIdentifiers({} as unknown as Options); - expect(ids.size).to.equal(0); - }); - - it("should include options.project and options.projectId", () => { - const ids = getProjectIdentifiers({ - project: "my-alias", - projectId: "my-project-id", - } as unknown as Options); - expect(Array.from(ids)).to.include.members(["my-alias", "my-project-id"]); - }); - - it("should resolve project aliases from options.rc", () => { - const ids = getProjectIdentifiers({ - project: "staging", - rc: new RC(undefined, { - projects: { - staging: "my-staging-project-123", - prod: "my-prod-project-456", - }, - }), - } as unknown as Options); - expect(Array.from(ids)).to.include.members(["staging", "my-staging-project-123"]); - expect(Array.from(ids)).to.not.include("prod"); - }); - }); + describe("isKitConfiguredForProject", () => { + let hasProjectEnvStub: sinon.SinonStub; - describe("hasDotenvForProject", () => { - it("should return false when projectIdentifiers is empty", () => { - const mockConfig = { path: (p: string) => `/mock/${p}` }; - const kit = { - kit: "test-kit", - source: "function-kits/test-kit", - instances: { inst: "function-kits/test-kit/config-inst" }, - } as unknown as ValidatedKitSingle; - expect(hasDotenvForProject(mockConfig, kit, new Set())).to.be.false; + beforeEach(() => { + hasProjectEnvStub = sinon.stub(env, "hasProjectEnv"); }); - it("should return true when a matching .env. file exists in instance configDir", () => { + it("should return false when no instance has project env", () => { const mockConfig = { path: (p: string) => `/mock/${p}` }; const kit = { kit: "test-kit", source: "function-kits/test-kit", instances: { inst: "function-kits/test-kit/config-inst" }, } as unknown as ValidatedKitSingle; - sinon.stub(fs, "existsSync").returns(true); - sinon - .stub(fs, "readdirSync") - .returns([".env", ".env.local", ".env.my-target-proj"] as unknown as ReturnType< - typeof fs.readdirSync - >); + hasProjectEnvStub.returns(false); - expect(hasDotenvForProject(mockConfig, kit, new Set(["my-target-proj"]))).to.be.true; + expect(isKitConfiguredForProject(mockConfig, kit, "my-target-proj")).to.be.false; + expect(hasProjectEnvStub).to.have.been.calledWith( + "/mock/function-kits/test-kit/config-inst", + "my-target-proj", + undefined, + ); }); - it("should return false when only non-matching dotenv files exist", () => { + it("should return true when any instance has project env", () => { const mockConfig = { path: (p: string) => `/mock/${p}` }; const kit = { kit: "test-kit", source: "function-kits/test-kit", - instances: { inst: "function-kits/test-kit/config-inst" }, + instances: { + inst1: "function-kits/test-kit/config-inst1", + inst2: "function-kits/test-kit/config-inst2", + }, } as unknown as ValidatedKitSingle; - sinon.stub(fs, "existsSync").returns(true); - sinon - .stub(fs, "readdirSync") - .returns([".env", ".env.local", ".env.other-proj"] as unknown as ReturnType< - typeof fs.readdirSync - >); - - expect(hasDotenvForProject(mockConfig, kit, new Set(["my-target-proj"]))).to.be.false; + hasProjectEnvStub + .withArgs("/mock/function-kits/test-kit/config-inst1", "my-target-proj", "staging") + .returns(false); + hasProjectEnvStub + .withArgs("/mock/function-kits/test-kit/config-inst2", "my-target-proj", "staging") + .returns(true); + + expect(isKitConfiguredForProject(mockConfig, kit, "my-target-proj", "staging")).to.be.true; }); }); @@ -1064,10 +1032,7 @@ describe("functions:kits:install", () => { }, } as unknown as Config; - sinon.stub(fs, "existsSync").returns(true); - sinon - .stub(fs, "readdirSync") - .returns([".env.my-target-proj"] as unknown as ReturnType); + sinon.stub(env, "hasProjectEnv").returns(true); const selectStub = sinon.stub(prompt, "select"); sinon.stub(prompt, "input").resolves("inst-2"); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index c51c83c0296..03db8db9ffa 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -24,6 +24,7 @@ import { confirm, input, select } from "../prompt"; import { spawnWithOutput, wrapSpawn } from "../init/spawn"; import { readTemplateSync } from "../templates"; import * as supported from "../deploy/functions/runtimes/supported"; +import { hasProjectEnv } from "../functions/env"; import * as self from "./functions-kits-install"; const PACKAGE_NO_LINTING_TEMPLATE = readTemplateSync( @@ -143,62 +144,18 @@ export async function checkPackageHasShrinkwrap(rawPkgName: string): Promise { - const ids = new Set(); - const projectId = getProjectId(options); - if (projectId) { - ids.add(projectId); - } - if (options.project) { - ids.add(options.project); - } - if (options.rc) { - const rcProjects = options.rc.projects || {}; - for (const [alias, pid] of Object.entries(rcProjects)) { - if (ids.has(alias) || ids.has(pid)) { - ids.add(alias); - ids.add(pid); - } - } - } - return ids; -} - /** * Checks if any of the kit's instance configuration directories contain a dotenv file for the current project. - * A dotenv file is for the current project if its filename is `.env.` where `` - * matches one of the active project identifiers. */ -export function hasDotenvForProject( +export function isKitConfiguredForProject( config: { path: (p: string) => string }, kit: ValidatedKitSingle, - projectIdentifiers: Set, + projectId?: string, + projectAlias?: string, ): boolean { - if (projectIdentifiers.size === 0) { - return false; - } - for (const configDirPath of Object.values(kit.instances || {})) { - const absDir = config.path(configDirPath); - if (fs.existsSync(absDir)) { - try { - const files = fs.readdirSync(absDir); - for (const file of files) { - if (file.startsWith(".env.")) { - const suffix = file.slice(".env.".length); - if (projectIdentifiers.has(suffix)) { - return true; - } - } - } - } catch (err: unknown) { - logger.debug(`Failed to read directory ${absDir}: ${getErrMsg(err)}`); - } - } - } - return false; + return Object.values(kit.instances || {}).some((configDir) => + hasProjectEnv(config.path(configDir), projectId, projectAlias), + ); } export const command = new Command("functions:kits:install") @@ -268,15 +225,20 @@ export const command = new Command("functions:kits:install") ); if (existingKit) { - const projectIdentifiers = getProjectIdentifiers(options); - const hasCurrentProjectEnv = hasDotenvForProject( + const projectId = getProjectId(options); + const projectAlias = + options.rc?.hasProjects && options.project && options.rc.hasProjectAlias(options.project) + ? options.project + : undefined; + const isConfiguredForProject = isKitConfiguredForProject( options.config, existingKit, - projectIdentifiers, + projectId, + projectAlias, ); let action: "addInstance" | "addEnv"; - if (!hasCurrentProjectEnv && !options.nonInteractive) { + if (!isConfiguredForProject && !options.nonInteractive) { const existingInstances = Object.keys(existingKit.instances || {}).join(", "); action = await select<"addInstance" | "addEnv">({ message: `The following instances already exist, but are not configured for this project: ${existingInstances}. What would you like to do?`, @@ -292,7 +254,7 @@ export const command = new Command("functions:kits:install") ], }); } else { - if (hasCurrentProjectEnv) { + if (isConfiguredForProject) { logLabeledBullet( "functions", `This package is already installed as kit ${existingKit.kit}, creating a new instance.`, diff --git a/src/functions/env.spec.ts b/src/functions/env.spec.ts index 9dd80cad95e..2e66562b3b8 100644 --- a/src/functions/env.spec.ts +++ b/src/functions/env.spec.ts @@ -1019,4 +1019,40 @@ FOO=foo }); }); }); + + describe("hasProjectEnv", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "firebase-env-test-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("should return false if neither projectId nor projectAlias is provided", () => { + expect(env.hasProjectEnv(tmpDir)).to.be.false; + }); + + it("should return false if directory does not exist", () => { + expect(env.hasProjectEnv(path.join(tmpDir, "nonexistent"), "my-project")).to.be.false; + }); + + it("should return true when .env. exists", () => { + fs.writeFileSync(path.join(tmpDir, ".env.my-project"), "FOO=bar"); + expect(env.hasProjectEnv(tmpDir, "my-project")).to.be.true; + }); + + it("should return true when .env. exists", () => { + fs.writeFileSync(path.join(tmpDir, ".env.staging"), "FOO=bar"); + expect(env.hasProjectEnv(tmpDir, "my-project", "staging")).to.be.true; + }); + + it("should return false when only other dotenv files exist", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "FOO=bar"); + fs.writeFileSync(path.join(tmpDir, ".env.other"), "FOO=bar"); + expect(env.hasProjectEnv(tmpDir, "my-project", "staging")).to.be.false; + }); + }); }); diff --git a/src/functions/env.ts b/src/functions/env.ts index 64caef3eb3c..f6df40f7d15 100644 --- a/src/functions/env.ts +++ b/src/functions/env.ts @@ -274,6 +274,22 @@ export function hasUserEnvs(opts: UserEnvsOpts): boolean { return findEnvfiles(configDir, opts.projectId, opts.projectAlias, opts.isEmulator).length > 0; } +/** + * Checks if a directory contains a project-specific dotenv file (.env. or .env.). + */ +export function hasProjectEnv(dir: string, projectId?: string, projectAlias?: string): boolean { + if (!projectId && !projectAlias) { + return false; + } + if (!fs.existsSync(dir)) { + return false; + } + return ( + (!!projectId && fs.existsSync(path.join(dir, `.env.${projectId}`))) || + (!!projectAlias && fs.existsSync(path.join(dir, `.env.${projectAlias}`))) + ); +} + /** * Write new environment variables into a dotenv file. * From 01a90747fc9fddb77a0bec9fe564498925e6d699 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 14 Aug 2026 18:23:14 +0000 Subject: [PATCH 07/10] Simplify functions config validation --- src/commands/functions-kits-install.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 03db8db9ffa..b14ebf76af5 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -5,7 +5,7 @@ import * as fs from "fs-extra"; import { Command } from "../command"; import { FirebaseError, getErrMsg } from "../error"; -import { FunctionsConfig, KitFunctionConfig } from "../firebaseConfig"; +import { KitFunctionConfig } from "../firebaseConfig"; import { getProjectId } from "../projectUtils"; import { logLabeledBullet } from "../utils"; @@ -188,19 +188,12 @@ export const command = new Command("functions:kits:install") const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); validateNpmPackageName(packageName); - let existingFunctions: ValidatedSingle[] = []; - const configSrc = options.config.src as unknown as { - functions?: FunctionsConfig; - [key: string]: unknown; - }; + const configSrc = options.config.src; const configFunctions = configSrc.functions; - if (configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0)) { - try { - existingFunctions = normalizeAndValidate(configFunctions); - } catch (err: unknown) { - throw new FirebaseError(`Invalid existing functions configuration: ${getErrMsg(err)}`); - } - } + const existingFunctions: ValidatedSingle[] = + configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0) + ? normalizeAndValidate(configFunctions) + : []; const existingKitIds = new Set(); const existingCodebases = new Set(); From 4ba1ad8d089a4c2aca7a517320aa7b5392bde951 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Fri, 14 Aug 2026 22:42:16 +0000 Subject: [PATCH 08/10] refactor: split install logic into helper functions --- src/commands/functions-kits-install.spec.ts | 195 +++++ src/commands/functions-kits-install.ts | 822 ++++++++++++-------- 2 files changed, 683 insertions(+), 334 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 2ad77d194bf..750becb45bd 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -12,6 +12,9 @@ import { isThirdPartyPackage, checkPackageHasShrinkwrap, isKitConfiguredForProject, + extractExistingFunctionsInfo, + addKitToConfig, + buildAndInstallKit, } from "./functions-kits-install"; import * as experiments from "../experiments"; import * as initSpawn from "../init/spawn"; @@ -281,6 +284,195 @@ describe("functions:kits:install", () => { }); }); + describe("extractExistingFunctionsInfo", () => { + it("should return empty sets when configFunctions is undefined or empty", () => { + const resUndefined = extractExistingFunctionsInfo(undefined); + expect(resUndefined.existingFunctions).to.deep.equal([]); + expect(resUndefined.existingKitIds.size).to.equal(0); + expect(resUndefined.existingCodebases.size).to.equal(0); + expect(resUndefined.existingInstanceIds.size).to.equal(0); + + const resEmpty = extractExistingFunctionsInfo([]); + expect(resEmpty.existingFunctions).to.deep.equal([]); + expect(resEmpty.existingKitIds.size).to.equal(0); + expect(resEmpty.existingCodebases.size).to.equal(0); + expect(resEmpty.existingInstanceIds.size).to.equal(0); + }); + + it("should extract kit IDs, instance IDs, and codebases correctly", () => { + const functionsConfig = [ + { + codebase: "my-codebase", + source: "functions", + }, + { + kit: "my-kit", + source: "function-kits/my-kit", + instances: { + "inst-1": "function-kits/my-kit/config-inst-1", + "inst-2": "function-kits/my-kit/config-inst-2", + }, + }, + ]; + + const res = extractExistingFunctionsInfo(functionsConfig); + expect(res.existingCodebases.has("my-codebase")).to.be.true; + expect(res.existingKitIds.has("my-kit")).to.be.true; + expect(res.existingInstanceIds.has("inst-1")).to.be.true; + expect(res.existingInstanceIds.has("inst-2")).to.be.true; + }); + }); + + describe("addKitToConfig", () => { + it("should add kit to empty config functions", () => { + const writtenFiles: Record = {}; + const mockConfig = { + src: {}, + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + } as unknown as Config; + + addKitToConfig( + mockConfig, + "new-kit", + "new-instance", + "@scope/pkg", + "function-kits/new-kit", + "function-kits/new-kit/config-new-instance", + ); + + expect(writtenFiles["firebase.json"]).to.deep.equal({ + functions: [ + { + kit: "new-kit", + sourcePackage: { name: "@scope/pkg" }, + source: "function-kits/new-kit", + instances: { + "new-instance": "function-kits/new-kit/config-new-instance", + }, + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }, + ], + }); + }); + + it("should append kit when functions is already an array", () => { + const writtenFiles: Record = {}; + const existingEntry = { + codebase: "default", + source: "functions", + }; + const mockConfig = { + src: { + functions: [existingEntry], + }, + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + } as unknown as Config; + + addKitToConfig( + mockConfig, + "new-kit", + "new-instance", + "@scope/pkg", + "function-kits/new-kit", + "function-kits/new-kit/config-new-instance", + ); + + const functions = (writtenFiles["firebase.json"] as { functions: unknown[] }).functions; + expect(functions).to.have.length(2); + expect(functions[0]).to.deep.equal(existingEntry); + expect(functions[1]).to.deep.equal({ + kit: "new-kit", + sourcePackage: { name: "@scope/pkg" }, + source: "function-kits/new-kit", + instances: { + "new-instance": "function-kits/new-kit/config-new-instance", + }, + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }); + }); + + it("should convert single object functions config to array and append", () => { + const writtenFiles: Record = {}; + const existingEntry = { + source: "functions", + }; + const mockConfig = { + src: { + functions: existingEntry, + }, + writeProjectFile: (file: string, content: unknown) => { + writtenFiles[file] = content; + }, + } as unknown as Config; + + addKitToConfig( + mockConfig, + "new-kit", + "new-instance", + "@scope/pkg", + "function-kits/new-kit", + "function-kits/new-kit/config-new-instance", + ); + + const functions = (writtenFiles["firebase.json"] as { functions: unknown[] }).functions; + expect(functions).to.have.length(2); + expect(functions[0]).to.deep.equal(existingEntry); + }); + }); + + describe("buildAndInstallKit", () => { + it("should run npm install and npm run build without --ignore-scripts for first-party kit", async () => { + await buildAndInstallKit("/abs/path", false); + + expect(wrapSpawnStub).to.have.been.calledTwice; + expect(wrapSpawnStub.firstCall).to.have.been.calledWith("npm", ["install"], "/abs/path"); + expect(wrapSpawnStub.secondCall).to.have.been.calledWith( + "npm", + ["run", "build"], + "/abs/path", + ); + }); + + it("should run npm install with --ignore-scripts for third-party kit", async () => { + await buildAndInstallKit("/abs/path", true); + + expect(wrapSpawnStub).to.have.been.calledTwice; + expect(wrapSpawnStub.firstCall).to.have.been.calledWith( + "npm", + ["install", "--ignore-scripts"], + "/abs/path", + ); + expect(wrapSpawnStub.secondCall).to.have.been.calledWith( + "npm", + ["run", "build"], + "/abs/path", + ); + }); + + it("should throw FirebaseError if npm install fails", async () => { + wrapSpawnStub.onFirstCall().rejects(new Error("npm install error")); + + await expect(buildAndInstallKit("/abs/path", false)).to.be.rejectedWith( + FirebaseError, + /NPM install failed: npm install error/, + ); + }); + + it("should throw FirebaseError if typescript build fails", async () => { + wrapSpawnStub.onFirstCall().resolves(); + wrapSpawnStub.onSecondCall().rejects(new Error("tsc build error")); + + await expect(buildAndInstallKit("/abs/path", false)).to.be.rejectedWith( + FirebaseError, + /TypeScript build failed: tsc build error/, + ); + }); + }); + describe("command action", () => { it("should assert that kits experiment is enabled", async () => { assertEnabledStub.throws(new FirebaseError("kits experiment disabled")); @@ -1144,6 +1336,7 @@ describe("functions:kits:install", () => { expect(writeProjectFileStub).to.not.have.been.called; expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), sinon.match(/firebase deploy --only functions:inst-1 --project my-staging-project/), ); }); @@ -1180,6 +1373,7 @@ describe("functions:kits:install", () => { expect(writeProjectFileStub).to.not.have.been.called; expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), sinon.match(/firebase deploy --only functions:inst-1 --project /), ); }); @@ -1224,6 +1418,7 @@ describe("functions:kits:install", () => { }), ); expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), sinon.match(/firebase deploy --only functions:inst-2 --project prod-project/), ); }); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index b14ebf76af5..0973e276f71 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -5,9 +5,9 @@ import * as fs from "fs-extra"; import { Command } from "../command"; import { FirebaseError, getErrMsg } from "../error"; -import { KitFunctionConfig } from "../firebaseConfig"; +import { KitFunctionConfig, FunctionsConfig } from "../firebaseConfig"; import { getProjectId } from "../projectUtils"; -import { logLabeledBullet } from "../utils"; +import { logLabeledBullet, logLabeledSuccess } from "../utils"; import { isKitConfig, @@ -26,6 +26,7 @@ import { readTemplateSync } from "../templates"; import * as supported from "../deploy/functions/runtimes/supported"; import { hasProjectEnv } from "../functions/env"; import * as self from "./functions-kits-install"; +import { Config } from "../config"; const PACKAGE_NO_LINTING_TEMPLATE = readTemplateSync( "init/functions/typescript/package.nolint.json", @@ -37,18 +38,34 @@ const INDEX_KIT_MIGRATION_TEMPLATE = readTemplateSync( "init/functions/typescript/index-kit-migration.ts", ); -const TEMPLATES = { +export const TEMPLATES = { installation: INDEX_KIT_TEMPLATE, migration: INDEX_KIT_MIGRATION_TEMPLATE, }; -const FUNCTION_KITS_DIR = "function-kits"; +export type TemplateType = keyof typeof TEMPLATES; +export const DEFAULT_TEMPLATE: TemplateType = "installation"; + +export const FUNCTION_KITS_DIR = "function-kits"; export interface FunctionsKitsInstallOptions extends Options { npm_package?: string; template?: string; } +export interface ExistingFunctionsInfo { + existingFunctions: ValidatedSingle[]; + existingKitIds: Set; + existingCodebases: Set; + existingInstanceIds: Set; +} + +export interface ScaffoldedKitPaths { + sourcePath: string; + configDirPath: string; + absSourcePath: string; +} + /** * Generates a unique identifier by appending a random 4-character hex suffix if a collision exists. * Ensures the candidate is truncated so the total length does not exceed 40 characters. @@ -158,377 +175,514 @@ export function isKitConfiguredForProject( ); } -export const command = new Command("functions:kits:install") - .description("install a function kit into your project") - .option("--npm_package ", "NPM package name or specifier to install as a function kit") - .option( - "--template [installation|migration]", - "template to use for the kit index file", - "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)."); +/** + * Extracts and categorizes existing functions, kit IDs, instance IDs, and codebase names from configuration. + */ +export function extractExistingFunctionsInfo( + configFunctions?: FunctionsConfig, +): ExistingFunctionsInfo { + const existingFunctions: ValidatedSingle[] = + configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0) + ? normalizeAndValidate(configFunctions) + : []; + + const existingKitIds = new Set(); + const existingCodebases = new Set(); + const existingInstanceIds = new Set(); + + for (const c of existingFunctions) { + if (isKitConfig(c)) { + if (c.kit) { + existingKitIds.add(c.kit); + } + if (c.instances) { + for (const instId of Object.keys(c.instances)) { + existingInstanceIds.add(instId); + } + } + } else if (c.codebase) { + existingCodebases.add(c.codebase); } + } - const templateType = options.template || "installation"; - if (!(templateType in TEMPLATES)) { - throw new FirebaseError( - `Invalid template '${templateType}'. Template must be 'installation' or 'migration'.`, - ); - } + return { + existingFunctions, + existingKitIds, + existingCodebases, + existingInstanceIds, + }; +} - const rawPkgName = options.npm_package; - if (!rawPkgName) { - throw new FirebaseError("set the --npm_package option to a valid NPM package and try again."); - } +/** + * Prompts the user for a kit instance ID with validation against collision with existing instance IDs and codebase names. + */ +export async function promptKitInstanceId( + baseKitId: string, + existingInstanceIds: Set, + existingCodebases: Set, + nonInteractive?: boolean, +): Promise { + const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); + const defaultInstanceId = generateUniqueId(baseKitId, instanceCollisions); + + const instanceId = await input({ + message: "What would you like to name this instance?", + default: defaultInstanceId, + nonInteractive, + validate: (val: string) => { + try { + validateKitInstanceId(val); + } catch (err: unknown) { + return getErrMsg(err); + } + if (existingInstanceIds.has(val)) { + return `functions kit instance ID must be unique across all kits, but '${val}' was used more than once.`; + } + if (existingCodebases.has(val)) { + return `functions codebase name and kit instance ID must be mutually exclusive, but '${val}' was used as both a codebase name and a kit instance ID.`; + } + return true; + }, + }); - const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); - validateNpmPackageName(packageName); + validateKitInstanceId(instanceId); + if (existingInstanceIds.has(instanceId)) { + throw new FirebaseError( + `functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`, + ); + } + if (existingCodebases.has(instanceId)) { + throw new FirebaseError( + `functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`, + ); + } - const configSrc = options.config.src; - const configFunctions = configSrc.functions; - const existingFunctions: ValidatedSingle[] = - configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0) - ? normalizeAndValidate(configFunctions) - : []; - - const existingKitIds = new Set(); - const existingCodebases = new Set(); - const existingInstanceIds = new Set(); - for (const c of existingFunctions) { - if (isKitConfig(c)) { - if (c.kit) { - existingKitIds.add(c.kit); - } - if (c.instances) { - for (const instId of Object.keys(c.instances)) { - existingInstanceIds.add(instId); - } - } - } else if (c.codebase) { - existingCodebases.add(c.codebase); + return instanceId; +} + +/** + * Prompts the user for a kit ID with validation against existing kit IDs. + */ +export async function promptKitId( + packageName: string, + existingKitIds: Set, + nonInteractive?: boolean, +): Promise { + const baseKitId = sanitizePackageNameToKitName(packageName); + const defaultKitId = generateUniqueId(baseKitId, existingKitIds); + + const kitId = await input({ + message: "What would you like to name this kit?", + default: defaultKitId, + nonInteractive, + validate: (val: string) => { + try { + validateKit(val); + } catch (err: unknown) { + return getErrMsg(err); } - } + if (existingKitIds.has(val)) { + return `functions.kit must be unique but '${val}' was used more than once.`; + } + return true; + }, + }); - const existingKit = existingFunctions.find( - (c): c is ValidatedKitSingle => isKitConfig(c) && c.sourcePackage?.name === packageName, + validateKit(kitId); + if (existingKitIds.has(kitId)) { + throw new FirebaseError(`functions.kit must be unique but '${kitId}' was used more than once.`); + } + + return kitId; +} + +/** + * Warns about third-party packages or missing shrinkwrap, and prompts for user confirmation before installation. + */ +export async function promptSecurityConfirmation( + rawPkgName: string, + packageName: string, + nonInteractive?: boolean, +): Promise { + const isThirdParty = isThirdPartyPackage(packageName); + if (isThirdParty) { + logger.warn( + clc.yellow( + `Warning: Package ${clc.bold(packageName)} is a third-party kit (outside the @firebase-functions-kits scope).`, + ), ); + } - if (existingKit) { - const projectId = getProjectId(options); - const projectAlias = - options.rc?.hasProjects && options.project && options.rc.hasProjectAlias(options.project) - ? options.project - : undefined; - const isConfiguredForProject = isKitConfiguredForProject( - options.config, - existingKit, - projectId, - projectAlias, - ); + const hasShrinkwrap = await self.checkPackageHasShrinkwrap(rawPkgName); + if (!hasShrinkwrap) { + logger.warn( + clc.yellow( + `Warning: Package ${clc.bold(packageName)} does not have an npm-shrinkwrap.json file. npm-shrinkwrap guarantees that you deploy the same version of dependencies that the publisher tested against. Since this kit does not have an npm-shrinkwrap, it is possible that deploys or updates may introduce bugs or vulnerabilities in newer dependency versions that the publisher did not test against.`, + ), + ); + } - let action: "addInstance" | "addEnv"; - if (!isConfiguredForProject && !options.nonInteractive) { - const existingInstances = Object.keys(existingKit.instances || {}).join(", "); - action = await select<"addInstance" | "addEnv">({ - message: `The following instances already exist, but are not configured for this project: ${existingInstances}. What would you like to do?`, - choices: [ - { - name: "Add an instance to the existing kit", - value: "addInstance", - }, - { - name: "Configure an existing instance for this project", - value: "addEnv", - }, - ], - }); - } else { - if (isConfiguredForProject) { - logLabeledBullet( - "functions", - `This package is already installed as kit ${existingKit.kit}, creating a new instance.`, - ); - } - action = "addInstance"; - } + if (isThirdParty || !hasShrinkwrap) { + let confirmMessage: string; + if (isThirdParty && !hasShrinkwrap) { + confirmMessage = `Are you sure you want to install the third-party kit ${packageName} without locked dependencies?`; + } else if (isThirdParty) { + confirmMessage = `Are you sure you want to install the third-party kit ${packageName}?`; + } else { + confirmMessage = `Are you sure you want to install ${packageName} without locked dependencies?`; + } + const confirmInstallation = await confirm({ + message: confirmMessage, + default: false, + nonInteractive, + }); + if (!confirmInstallation) { + throw new FirebaseError("Installation cancelled."); + } + } - if (action === "addInstance") { - const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); - const defaultInstanceId = generateUniqueId(existingKit.kit, instanceCollisions); - - const instanceId = await input({ - message: "What would you like to name this instance?", - default: defaultInstanceId, - nonInteractive: options.nonInteractive, - validate: (val: string) => { - try { - validateKitInstanceId(val); - } catch (err: unknown) { - return getErrMsg(err); - } - if (existingInstanceIds.has(val)) { - return `functions kit instance ID must be unique across all kits, but '${val}' was used more than once.`; - } - if (existingCodebases.has(val)) { - return `functions codebase name and kit instance ID must be mutually exclusive, but '${val}' was used as both a codebase name and a kit instance ID.`; - } - return true; - }, - }); - - validateKitInstanceId(instanceId); - if (existingInstanceIds.has(instanceId)) { - throw new FirebaseError( - `functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`, - ); - } - if (existingCodebases.has(instanceId)) { - throw new FirebaseError( - `functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`, - ); - } + return isThirdParty; +} - const configDirPath = path.join(FUNCTION_KITS_DIR, existingKit.kit, `config-${instanceId}`); - const absConfigDirPath = options.config.path(configDirPath); - await fs.ensureDir(absConfigDirPath); +/** + * Adds a new instance to an existing kit in firebase.json. + */ +async function addInstanceToExistingKit( + options: FunctionsKitsInstallOptions, + existingKit: ValidatedKitSingle, + existingFunctionsInfo: ExistingFunctionsInfo, +): Promise { + const instanceId = await promptKitInstanceId( + existingKit.kit, + existingFunctionsInfo.existingInstanceIds, + existingFunctionsInfo.existingCodebases, + options.nonInteractive, + ); - existingKit.instances[instanceId] = configDirPath; + const configDirPath = path.join(FUNCTION_KITS_DIR, existingKit.kit, `config-${instanceId}`); + const absConfigDirPath = options.config.path(configDirPath); + await fs.ensureDir(absConfigDirPath); - options.config.writeProjectFile("firebase.json", configSrc); - logger.info( - clc.green( - `✔ Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(existingKit.kit)}.`, - ), - ); - return; - } + existingKit.instances[instanceId] = configDirPath; - if (action === "addEnv") { - const instanceIds: string[] = Object.keys(existingKit.instances); - if (instanceIds.length === 0) { - throw new FirebaseError(`Kit '${existingKit.kit}' has no instances configured.`); - } + options.config.writeProjectFile("firebase.json", options.config.src); + logLabeledSuccess( + "functions", + `Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(existingKit.kit)}.`, + ); +} - let selectedInstanceId = instanceIds[0]; - if (instanceIds.length > 1) { - if (!options.nonInteractive) { - selectedInstanceId = await select({ - message: "Which instance would you like to configure for this project?", - choices: instanceIds.map((id) => ({ name: id, value: id })), - }); - } - } +/** + * Guides the user on configuring an existing instance for the active project. + */ +async function promptExistingInstanceForProject( + options: FunctionsKitsInstallOptions, + existingKit: ValidatedKitSingle, +): Promise { + const instanceIds: string[] = Object.keys(existingKit.instances); + if (instanceIds.length === 0) { + throw new FirebaseError(`Kit '${existingKit.kit}' has no instances configured.`); + } - const targetProject = getProjectId(options) || options.project || ""; - logger.info( - "\nTo create a new instance in this project, deploy the instance dedicated to this project using\n" + - clc.bold( - `firebase deploy --only functions:${selectedInstanceId} --project ${targetProject}`, - ), - ); - return; - } - } + let selectedInstanceId = instanceIds[0]; + if (instanceIds.length > 1 && !options.nonInteractive) { + selectedInstanceId = await select({ + message: "Which instance would you like to configure for this project?", + choices: instanceIds.map((id) => ({ name: id, value: id })), + }); + } - const isThirdParty = isThirdPartyPackage(packageName); - if (isThirdParty) { - logger.warn( - clc.yellow( - `Warning: Package ${clc.bold(packageName)} is a third-party kit (outside the @firebase-functions-kits scope).`, - ), - ); - } + const targetProject = getProjectId(options) || options.project || ""; + logLabeledBullet( + "functions", + `To create a new instance in this project, deploy the instance dedicated to this project using\n` + + clc.bold(`firebase deploy --only functions:${selectedInstanceId} --project ${targetProject}`), + ); +} + +/** + * Handles installation when the kit package is already present in firebase.json. + */ +export async function addKitInstanceOrConfigureProject( + options: FunctionsKitsInstallOptions, + existingKit: ValidatedKitSingle, + existingFunctionsInfo: ExistingFunctionsInfo, +): Promise { + const projectId = getProjectId(options); + const projectAlias = + options.rc?.hasProjects && options.project && options.rc.hasProjectAlias(options.project) + ? options.project + : undefined; + const isConfiguredForProject = isKitConfiguredForProject( + options.config, + existingKit, + projectId, + projectAlias, + ); - const hasShrinkwrap = await self.checkPackageHasShrinkwrap(rawPkgName); - if (!hasShrinkwrap) { - logger.warn( - clc.yellow( - `Warning: Package ${clc.bold(packageName)} does not have an npm-shrinkwrap.json file. npm-shrinkwrap guarantees that you deploy the same version of dependencies that the publisher tested against. Since this kit does not have an npm-shrinkwrap, it is possible that deploys or updates may introduce bugs or vulnerabilities in newer dependency versions that the publisher did not test against.`, - ), + let action: "addInstance" | "addEnv"; + if (!isConfiguredForProject && !options.nonInteractive) { + const existingInstances = Object.keys(existingKit.instances || {}).join(", "); + action = await select<"addInstance" | "addEnv">({ + message: `The following instances already exist, but are not configured for this project: ${existingInstances}. What would you like to do?`, + choices: [ + { + name: "Add an instance to the existing kit", + value: "addInstance", + }, + { + name: "Configure an existing instance for this project", + value: "addEnv", + }, + ], + }); + } else { + if (isConfiguredForProject) { + logLabeledBullet( + "functions", + `This package is already installed as kit ${existingKit.kit}, creating a new instance.`, ); } + action = "addInstance"; + } - if (isThirdParty || !hasShrinkwrap) { - let confirmMessage: string; - if (isThirdParty && !hasShrinkwrap) { - confirmMessage = `Are you sure you want to install the third-party kit ${packageName} without locked dependencies?`; - } else if (isThirdParty) { - confirmMessage = `Are you sure you want to install the third-party kit ${packageName}?`; - } else { - confirmMessage = `Are you sure you want to install ${packageName} without locked dependencies?`; - } - const confirmInstallation = await confirm({ - message: confirmMessage, - default: false, - nonInteractive: options.nonInteractive, - }); - if (!confirmInstallation) { - throw new FirebaseError("Installation cancelled."); - } - } + if (action === "addInstance") { + await addInstanceToExistingKit(options, existingKit, existingFunctionsInfo); + return; + } - const baseKitId = sanitizePackageNameToKitName(packageName); - const defaultKitId = generateUniqueId(baseKitId, existingKitIds); - - const kitId = await input({ - message: "What would you like to name this kit?", - default: defaultKitId, - nonInteractive: options.nonInteractive, - validate: (val: string) => { - try { - validateKit(val); - } catch (err: unknown) { - return getErrMsg(err); - } - if (existingKitIds.has(val)) { - return `functions.kit must be unique but '${val}' was used more than once.`; - } - return true; - }, - }); - validateKit(kitId); - if (existingKitIds.has(kitId)) { - throw new FirebaseError( - `functions.kit must be unique but '${kitId}' was used more than once.`, - ); + if (action === "addEnv") { + await promptExistingInstanceForProject(options, existingKit); + return; + } +} + +/** + * Creates or updates package.json for a newly scaffolded kit wrapper. + */ +async function writeKitPackageJson( + config: Config, + sourcePath: string, + kitId: string, + packageName: string, + version?: string, +): Promise { + const relPackageJsonPath = path.join(sourcePath, "package.json"); + const absPackageJsonPath = config.path(relPackageJsonPath); + let pkgJson: { + name?: string; + version?: string; + main?: string; + scripts?: Record; + engines?: Record; + dependencies?: Record; + devDependencies?: Record; + private?: boolean; + } = {}; + + if (await fs.pathExists(absPackageJsonPath)) { + try { + pkgJson = (await fs.readJson(absPackageJsonPath)) as typeof pkgJson; + } catch (err: unknown) { + logger.debug(`Failed to read existing package.json: ${getErrMsg(err)}`); + } + } else { + const latestNodeVersion = supported.latest("nodejs").replace("nodejs", ""); + const subbedTemplate = PACKAGE_NO_LINTING_TEMPLATE.replace("{{RUNTIME}}", latestNodeVersion); + try { + pkgJson = JSON.parse(subbedTemplate) as typeof pkgJson; + } catch (err: unknown) { + throw new FirebaseError("Failed to parse package.nolint.json template: " + getErrMsg(err)); } + } - const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); - const defaultInstanceId = generateUniqueId(kitId, instanceCollisions); - - const instanceId = await input({ - message: "What would you like to name this instance?", - default: defaultInstanceId, - nonInteractive: options.nonInteractive, - validate: (val: string) => { - try { - validateKitInstanceId(val); - } catch (err: unknown) { - return getErrMsg(err); - } - if (existingInstanceIds.has(val)) { - return `functions kit instance ID must be unique across all kits, but '${val}' was used more than once.`; - } - if (existingCodebases.has(val)) { - return `functions codebase name and kit instance ID must be mutually exclusive, but '${val}' was used as both a codebase name and a kit instance ID.`; - } - return true; - }, - }); + // Ensure the wrapper package has a unique name and depends on the specified kit package and version. + pkgJson.name = `${kitId}-wrapper`; + pkgJson.dependencies = pkgJson.dependencies || {}; + pkgJson.dependencies[packageName] = version || "latest"; - validateKitInstanceId(instanceId); - if (existingInstanceIds.has(instanceId)) { - throw new FirebaseError( - `functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`, - ); + await config.askWriteProjectFile(relPackageJsonPath, pkgJson); +} + +/** + * Creates index.ts for a newly scaffolded kit wrapper from the appropriate template. + */ +async function writeKitIndexTs( + config: Config, + sourcePath: string, + packageName: string, + templateType: TemplateType, +): Promise { + const relIndexTsPath = path.join(sourcePath, "src", "index.ts"); + const absIndexTsPath = config.path(relIndexTsPath); + if (!(await fs.pathExists(absIndexTsPath))) { + const template = TEMPLATES[templateType]; + const indexContent = template.replace("{{PACKAGE_NAME}}", packageName); + await config.askWriteProjectFile(relIndexTsPath, indexContent); + } +} + +/** + * Scaffolds the kit directory structure, package.json, tsconfig, gitignore, and index.ts source files. + */ +export async function scaffoldKitFiles( + config: Config, + kitId: string, + instanceId: string, + packageName: string, + version?: string, + templateType: TemplateType = DEFAULT_TEMPLATE, +): Promise { + const sourcePath = path.join(FUNCTION_KITS_DIR, kitId); + const configDirPath = path.join(FUNCTION_KITS_DIR, kitId, `config-${instanceId}`); + + const absSourcePath = config.path(sourcePath); + const absConfigDirPath = config.path(configDirPath); + + await fs.ensureDir(absSourcePath); + await fs.ensureDir(absConfigDirPath); + + await writeKitPackageJson(config, sourcePath, kitId, packageName, version); + await config.askWriteProjectFile(path.join(sourcePath, "tsconfig.json"), TSCONFIG_TEMPLATE); + await config.askWriteProjectFile(path.join(sourcePath, ".gitignore"), GITIGNORE_TEMPLATE); + await writeKitIndexTs(config, sourcePath, packageName, templateType); + + return { sourcePath, configDirPath, absSourcePath }; +} + +/** + * Installs dependencies and compiles TypeScript source for the kit. + */ +export async function buildAndInstallKit( + absSourcePath: string, + isThirdParty: boolean, +): Promise { + const installArgs = isThirdParty ? ["install", "--ignore-scripts"] : ["install"]; + logger.info(clc.bold(`Running npm ${installArgs.join(" ")}...`)); + try { + await wrapSpawn("npm", installArgs, absSourcePath); + } catch (err: unknown) { + throw new FirebaseError(`NPM install failed: ${getErrMsg(err)}`); + } + + logger.info(clc.bold("Building TypeScript source...")); + try { + await wrapSpawn("npm", ["run", "build"], absSourcePath); + } catch (err: unknown) { + throw new FirebaseError(`TypeScript build failed: ${getErrMsg(err)}`); + } +} + +/** + * Appends the newly configured kit into the firebase.json configuration and saves the file. + */ +export function addKitToConfig( + config: Config, + kitId: string, + instanceId: string, + packageName: string, + sourcePath: string, + configDirPath: string, +): void { + const configSrc = config.src; + const newKitConfig: KitFunctionConfig = { + kit: kitId, + sourcePackage: { + name: packageName, + }, + source: sourcePath, + instances: { + [instanceId]: configDirPath, + }, + predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], + }; + + const functionsRaw = configSrc.functions as KitFunctionConfig | KitFunctionConfig[] | undefined; + if (!functionsRaw) { + configSrc.functions = [newKitConfig]; + } else if (Array.isArray(functionsRaw)) { + functionsRaw.push(newKitConfig); + } else { + configSrc.functions = [functionsRaw, newKitConfig]; + } + + config.writeProjectFile("firebase.json", configSrc); +} + +export const command = new Command("functions:kits:install") + .description("install a function kit into your project") + .option("--npm_package ", "NPM package name or specifier to install as a function kit") + .option( + `--template [${Object.keys(TEMPLATES).join("|")}]`, + "template to use for the kit index file", + DEFAULT_TEMPLATE, + ) + .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)."); } - if (existingCodebases.has(instanceId)) { + + const templateType = (options.template || DEFAULT_TEMPLATE) as TemplateType; + if (!(templateType in TEMPLATES)) { + const validTemplates = Object.keys(TEMPLATES) + .map((t) => `'${t}'`) + .join(" or "); throw new FirebaseError( - `functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`, + `Invalid template '${templateType}'. Template must be ${validTemplates}.`, ); } - const sourcePath = path.join(FUNCTION_KITS_DIR, kitId); - const configDirPath = path.join(FUNCTION_KITS_DIR, kitId, `config-${instanceId}`); - - const absSourcePath = options.config.path(sourcePath); - const absConfigDirPath = options.config.path(configDirPath); - - await fs.ensureDir(absSourcePath); - await fs.ensureDir(absConfigDirPath); - - const relPackageJsonPath = path.join(sourcePath, "package.json"); - const absPackageJsonPath = options.config.path(relPackageJsonPath); - let pkgJson: { - name?: string; - version?: string; - main?: string; - scripts?: Record; - engines?: Record; - dependencies?: Record; - devDependencies?: Record; - private?: boolean; - } = {}; - if (await fs.pathExists(absPackageJsonPath)) { - try { - pkgJson = (await fs.readJson(absPackageJsonPath)) as typeof pkgJson; - } catch (err: unknown) { - logger.debug(`Failed to read existing package.json: ${getErrMsg(err)}`); - } - } else { - const latestNodeVersion = supported.latest("nodejs").replace("nodejs", ""); - const subbedTemplate = PACKAGE_NO_LINTING_TEMPLATE.replace("{{RUNTIME}}", latestNodeVersion); - try { - pkgJson = JSON.parse(subbedTemplate) as typeof pkgJson; - } catch (err: unknown) { - throw new FirebaseError("Failed to parse package.nolint.json template: " + getErrMsg(err)); - } + const rawPkgName = options.npm_package; + if (!rawPkgName) { + throw new FirebaseError("set the --npm_package option to a valid NPM package and try again."); } - // Ensure the wrapper package has a unique name and depends on the specified kit package and version. - pkgJson.name = `${kitId}-wrapper`; - pkgJson.dependencies = pkgJson.dependencies || {}; - pkgJson.dependencies[packageName] = version || "latest"; + const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); + validateNpmPackageName(packageName); - await options.config.askWriteProjectFile(relPackageJsonPath, pkgJson); - await options.config.askWriteProjectFile( - path.join(sourcePath, "tsconfig.json"), - TSCONFIG_TEMPLATE, - ); - await options.config.askWriteProjectFile( - path.join(sourcePath, ".gitignore"), - GITIGNORE_TEMPLATE, + const existingFunctionsInfo = extractExistingFunctionsInfo(options.config.src.functions); + const existingKit = existingFunctionsInfo.existingFunctions.find( + (c): c is ValidatedKitSingle => isKitConfig(c) && c.sourcePackage?.name === packageName, ); - const relIndexTsPath = path.join(sourcePath, "src", "index.ts"); - const absIndexTsPath = options.config.path(relIndexTsPath); - if (!(await fs.pathExists(absIndexTsPath))) { - const template = - templateType === "migration" ? INDEX_KIT_MIGRATION_TEMPLATE : INDEX_KIT_TEMPLATE; - const indexContent = template.replace("{{PACKAGE_NAME}}", packageName); - await options.config.askWriteProjectFile(relIndexTsPath, indexContent); + if (existingKit) { + await addKitInstanceOrConfigureProject(options, existingKit, existingFunctionsInfo); + return; } - const installArgs = isThirdParty ? ["install", "--ignore-scripts"] : ["install"]; - logger.info(clc.bold(`Running npm ${installArgs.join(" ")}...`)); - try { - await wrapSpawn("npm", installArgs, absSourcePath); - } catch (err: unknown) { - throw new FirebaseError(`NPM install failed: ${getErrMsg(err)}`); - } + const isThirdParty = await promptSecurityConfirmation( + rawPkgName, + packageName, + options.nonInteractive, + ); - logger.info(clc.bold("Building TypeScript source...")); - try { - await wrapSpawn("npm", ["run", "build"], absSourcePath); - } catch (err: unknown) { - throw new FirebaseError(`TypeScript build failed: ${getErrMsg(err)}`); - } + const kitId = await promptKitId( + packageName, + existingFunctionsInfo.existingKitIds, + options.nonInteractive, + ); - const newKitConfig: KitFunctionConfig = { - kit: kitId, - sourcePackage: { - name: packageName, - }, - source: sourcePath, - instances: { - [instanceId]: configDirPath, - }, - predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], - }; + const instanceId = await promptKitInstanceId( + kitId, + existingFunctionsInfo.existingInstanceIds, + existingFunctionsInfo.existingCodebases, + options.nonInteractive, + ); - const functionsRaw = configSrc.functions as KitFunctionConfig | KitFunctionConfig[] | undefined; - if (!functionsRaw) { - configSrc.functions = [newKitConfig]; - } else if (Array.isArray(functionsRaw)) { - functionsRaw.push(newKitConfig); - } else { - configSrc.functions = [functionsRaw, newKitConfig]; - } + const { sourcePath, configDirPath, absSourcePath } = await scaffoldKitFiles( + options.config, + kitId, + instanceId, + packageName, + version, + templateType, + ); + + await buildAndInstallKit(absSourcePath, isThirdParty); + + addKitToConfig(options.config, kitId, instanceId, packageName, sourcePath, configDirPath); - options.config.writeProjectFile("firebase.json", configSrc); logger.info(clc.green(`✔ Function kit ${clc.bold(kitId)} successfully installed.`)); }); From 997868ded421e90f5566fd69ca901e4dcb47425e Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Mon, 17 Aug 2026 21:39:22 +0000 Subject: [PATCH 09/10] Explicitly check prompts in multiple instance configuration --- src/commands/functions-kits-install.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 750becb45bd..248ad5ac309 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -1411,12 +1411,18 @@ describe("functions:kits:install", () => { project: "prod-project", }); + expect(selectStub).to.have.been.calledTwice; expect(selectStub.firstCall).to.have.been.calledWith( sinon.match({ message: "The following instances already exist, but are not configured for this project: inst-1, inst-2. What would you like to do?", }), ); + expect(selectStub.secondCall).to.have.been.calledWith( + sinon.match({ + message: "Which instance would you like to configure for this project?", + }), + ); expect(loggerInfoStub).to.have.been.calledWith( sinon.match(/functions:/), sinon.match(/firebase deploy --only functions:inst-2 --project prod-project/), From 000ace715aa2b8013c1780bb11811b1a277aa130 Mon Sep 17 00:00:00 2001 From: Wanda Mora Date: Mon, 17 Aug 2026 22:36:21 +0000 Subject: [PATCH 10/10] Output log with instance id placeholder for promptExistingInstanceForProject non-interactive --- src/commands/functions-kits-install.spec.ts | 84 +++++++++++++++++++++ src/commands/functions-kits-install.ts | 8 +- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index 248ad5ac309..992ad2181db 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -15,6 +15,7 @@ import { extractExistingFunctionsInfo, addKitToConfig, buildAndInstallKit, + promptExistingInstanceForProject, } from "./functions-kits-install"; import * as experiments from "../experiments"; import * as initSpawn from "../init/spawn"; @@ -473,6 +474,89 @@ describe("functions:kits:install", () => { }); }); + describe("promptExistingInstanceForProject", () => { + it("should throw if kit has no instances configured", async () => { + const mockOptions = { project: "my-project" } as any; + const kit = { + kit: "my-kit", + instances: {}, + } as unknown as ValidatedKitSingle; + + await expect(promptExistingInstanceForProject(mockOptions, kit)).to.be.rejectedWith( + FirebaseError, + /Kit 'my-kit' has no instances configured\./, + ); + }); + + it("should suggest deploy command directly when only one instance exists", async () => { + const selectStub = sinon.stub(prompt, "select"); + const mockOptions = { project: "my-project" } as any; + const kit = { + kit: "my-kit", + instances: { + "inst-1": "function-kits/my-kit/config-inst-1", + }, + } as unknown as ValidatedKitSingle; + + await promptExistingInstanceForProject(mockOptions, kit); + + expect(selectStub).to.not.have.been.called; + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), + sinon.match(/firebase deploy --only functions:inst-1 --project my-project/), + ); + }); + + it("should prompt to select instance when multiple instances exist and nonInteractive is false", async () => { + const selectStub = sinon.stub(prompt, "select").resolves("inst-2"); + const mockOptions = { project: "my-project", nonInteractive: false } as any; + const kit = { + kit: "my-kit", + instances: { + "inst-1": "function-kits/my-kit/config-inst-1", + "inst-2": "function-kits/my-kit/config-inst-2", + }, + } as unknown as ValidatedKitSingle; + + await promptExistingInstanceForProject(mockOptions, kit); + + expect(selectStub).to.have.been.calledOnce; + expect(selectStub).to.have.been.calledWith( + sinon.match({ + message: "Which instance would you like to configure for this project?", + choices: [ + { name: "inst-1", value: "inst-1" }, + { name: "inst-2", value: "inst-2" }, + ], + }), + ); + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), + sinon.match(/firebase deploy --only functions:inst-2 --project my-project/), + ); + }); + + it("should suggest deploy command with instance placeholder when multiple instances exist and nonInteractive is true", async () => { + const selectStub = sinon.stub(prompt, "select"); + const mockOptions = { project: "my-project", nonInteractive: true } as any; + const kit = { + kit: "my-kit", + instances: { + "inst-1": "function-kits/my-kit/config-inst-1", + "inst-2": "function-kits/my-kit/config-inst-2", + }, + } as unknown as ValidatedKitSingle; + + await promptExistingInstanceForProject(mockOptions, kit); + + expect(selectStub).to.not.have.been.called; + expect(loggerInfoStub).to.have.been.calledWith( + sinon.match(/functions:/), + sinon.match(/firebase deploy --only functions: --project my-project/), + ); + }); + }); + describe("command action", () => { it("should assert that kits experiment is enabled", async () => { assertEnabledStub.throws(new FirebaseError("kits experiment disabled")); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 0973e276f71..291be6d7383 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -375,7 +375,7 @@ async function addInstanceToExistingKit( /** * Guides the user on configuring an existing instance for the active project. */ -async function promptExistingInstanceForProject( +export async function promptExistingInstanceForProject( options: FunctionsKitsInstallOptions, existingKit: ValidatedKitSingle, ): Promise { @@ -384,8 +384,10 @@ async function promptExistingInstanceForProject( throw new FirebaseError(`Kit '${existingKit.kit}' has no instances configured.`); } - let selectedInstanceId = instanceIds[0]; - if (instanceIds.length > 1 && !options.nonInteractive) { + let selectedInstanceId = ""; + if (instanceIds.length === 1) { + selectedInstanceId = instanceIds[0]; + } else if (!options.nonInteractive) { selectedInstanceId = await select({ message: "Which instance would you like to configure for this project?", choices: instanceIds.map((id) => ({ name: id, value: id })),