diff --git a/src/commands/functions-kits-install.spec.ts b/src/commands/functions-kits-install.spec.ts index e27bbd76eea..992ad2181db 100644 --- a/src/commands/functions-kits-install.spec.ts +++ b/src/commands/functions-kits-install.spec.ts @@ -11,17 +11,26 @@ import { sanitizePackageNameToKitName, isThirdPartyPackage, checkPackageHasShrinkwrap, + isKitConfiguredForProject, + extractExistingFunctionsInfo, + addKitToConfig, + buildAndInstallKit, + promptExistingInstanceForProject, } 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 { ValidatedKitSingle } from "../functions/projectConfig"; +import * as env from "../functions/env"; 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 +45,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 +240,323 @@ describe("functions:kits:install", () => { }); }); + describe("isKitConfiguredForProject", () => { + let hasProjectEnvStub: sinon.SinonStub; + + beforeEach(() => { + hasProjectEnvStub = sinon.stub(env, "hasProjectEnv"); + }); + + 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; + hasProjectEnvStub.returns(false); + + 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 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: { + inst1: "function-kits/test-kit/config-inst1", + inst2: "function-kits/test-kit/config-inst2", + }, + } as unknown as ValidatedKitSingle; + 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; + }); + }); + + 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("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")); @@ -827,5 +1155,363 @@ 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; + 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 { + 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(env, "hasProjectEnv").returns(true); + + 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 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/), + ); + }); + + 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(/functions:/), + 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(/functions:/), + 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(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/), + ); + }); + }); }); }); diff --git a/src/commands/functions-kits-install.ts b/src/commands/functions-kits-install.ts index 3fd10b292a5..291be6d7383 100644 --- a/src/commands/functions-kits-install.ts +++ b/src/commands/functions-kits-install.ts @@ -5,23 +5,28 @@ 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, logLabeledSuccess } from "../utils"; 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"; +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", @@ -33,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. @@ -140,13 +161,463 @@ export async function checkPackageHasShrinkwrap(rawPkgName: string): Promise string }, + kit: ValidatedKitSingle, + projectId?: string, + projectAlias?: string, +): boolean { + return Object.values(kit.instances || {}).some((configDir) => + hasProjectEnv(config.path(configDir), projectId, projectAlias), + ); +} + +/** + * 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); + } + } + + return { + existingFunctions, + existingKitIds, + existingCodebases, + existingInstanceIds, + }; +} + +/** + * 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; + }, + }); + + 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 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; + }, + }); + + 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).`, + ), + ); + } + + 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.`, + ), + ); + } + + 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."); + } + } + + return isThirdParty; +} + +/** + * 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, + ); + + const configDirPath = path.join(FUNCTION_KITS_DIR, existingKit.kit, `config-${instanceId}`); + const absConfigDirPath = options.config.path(configDirPath); + await fs.ensureDir(absConfigDirPath); + + existingKit.instances[instanceId] = configDirPath; + + options.config.writeProjectFile("firebase.json", options.config.src); + logLabeledSuccess( + "functions", + `Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(existingKit.kit)}.`, + ); +} + +/** + * Guides the user on configuring an existing instance for the active project. + */ +export 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.`); + } + + 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 })), + }); + } + + 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, + ); + + 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 (action === "addInstance") { + await addInstanceToExistingKit(options, existingKit, existingFunctionsInfo); + return; + } + + 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)); + } + } + + // 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"; + + 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 [installation|migration]", + `--template [${Object.keys(TEMPLATES).join("|")}]`, "template to use for the kit index file", - "installation", + DEFAULT_TEMPLATE, ) .action(async (options: FunctionsKitsInstallOptions): Promise => { experiments.assertEnabled("kits", "install a function kit"); @@ -155,10 +626,13 @@ export const command = new Command("functions:kits:install") throw new FirebaseError("Not in a Firebase project directory (firebase.json not found)."); } - const templateType = options.template || "installation"; + 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( - `Invalid template '${templateType}'. Template must be 'installation' or 'migration'.`, + `Invalid template '${templateType}'. Template must be ${validTemplates}.`, ); } @@ -170,228 +644,47 @@ export const command = new Command("functions:kits:install") const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); validateNpmPackageName(packageName); - 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 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.`, - ), - ); - } - - 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."); - } - } - - 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); - - 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.`, - ); - } - - 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; - }, - }); - - 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 existingFunctionsInfo = extractExistingFunctionsInfo(options.config.src.functions); + const existingKit = existingFunctionsInfo.existingFunctions.find( + (c): c is ValidatedKitSingle => isKitConfig(c) && c.sourcePackage?.name === packageName, + ); - 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)); - } + if (existingKit) { + await addKitInstanceOrConfigureProject(options, existingKit, existingFunctionsInfo); + return; } - // 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"; - - 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 isThirdParty = await promptSecurityConfirmation( + rawPkgName, + packageName, + options.nonInteractive, ); - 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); - } + const kitId = await promptKitId( + packageName, + existingFunctionsInfo.existingKitIds, + options.nonInteractive, + ); - 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 instanceId = await promptKitInstanceId( + kitId, + existingFunctionsInfo.existingInstanceIds, + existingFunctionsInfo.existingCodebases, + 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 { sourcePath, configDirPath, absSourcePath } = await scaffoldKitFiles( + options.config, + kitId, + instanceId, + packageName, + version, + templateType, + ); - const newKitConfig: KitFunctionConfig = { - kit: kitId, - sourcePackage: { - name: packageName, - }, - source: sourcePath, - instances: { - [instanceId]: configDirPath, - }, - predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], - }; + await buildAndInstallKit(absSourcePath, isThirdParty); - if (!options.config.src.functions) { - options.config.src.functions = [newKitConfig]; - } else if (Array.isArray(options.config.src.functions)) { - options.config.src.functions.push(newKitConfig); - } else { - options.config.src.functions = [options.config.src.functions, newKitConfig]; - } + addKitToConfig(options.config, kitId, instanceId, packageName, sourcePath, configDirPath); - options.config.writeProjectFile("firebase.json", options.config.src); logger.info(clc.green(`✔ Function kit ${clc.bold(kitId)} successfully installed.`)); }); 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. *