diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e127a8fea..aef3f145c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ - Added local machine config persistence to `appcheck:debugtokens:create`, `--local` support to `appcheck:debugtokens:list`, and `appcheck:debugtokens:prune` to clean up stale local debug tokens. - Fixed `database:import` failing with an unhandled promise rejection when a batch write fails. - Fixed emulator crashes when a Firestore rules reload or emulator HTTP handler throws. +- Fixed `firebase deploy` failing to find the Cloud Functions SDK when the package manager does not create a `node_modules/.bin/firebase-functions` file, such as Bun on Windows. (#11386) diff --git a/src/deploy/functions/runtimes/node/index.spec.ts b/src/deploy/functions/runtimes/node/index.spec.ts index 88066744961..596b7e02eca 100644 --- a/src/deploy/functions/runtimes/node/index.spec.ts +++ b/src/deploy/functions/runtimes/node/index.spec.ts @@ -1,5 +1,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import * as node from "."; @@ -136,4 +138,138 @@ describe("NodeDelegate", () => { expect(detectFromYamlMock).to.have.been.called; }); }); + describe("findFunctionsBinary", () => { + const BIN_ENTRY = path.join("lib", "bin", "firebase-functions.js"); + let projectDir: string; + let sourceDir: string; + + function installSdk(storeDir: string, binShims: string[], bin: unknown): string { + const nodeModules = path.join(sourceDir, "node_modules"); + const sdkDir = path.join(nodeModules, storeDir, "firebase-functions"); + fs.mkdirSync(path.join(sdkDir, "lib", "v2"), { recursive: true }); + fs.mkdirSync(path.join(sdkDir, "lib", "bin"), { recursive: true }); + fs.writeFileSync(path.join(sdkDir, "lib", "v2", "index.js"), ""); + fs.writeFileSync(path.join(sdkDir, BIN_ENTRY), ""); + fs.writeFileSync( + path.join(sdkDir, "package.json"), + JSON.stringify({ name: "firebase-functions", main: "lib/v2/index.js", bin }), + ); + if (storeDir) { + fs.symlinkSync(sdkDir, path.join(nodeModules, "firebase-functions"), "junction"); + } + fs.mkdirSync(path.join(nodeModules, ".bin"), { recursive: true }); + for (const shim of binShims) { + fs.writeFileSync(path.join(nodeModules, ".bin", shim), ""); + } + return fs.realpathSync(sdkDir); + } + + function sdkMain(sdkDir: string): string { + return path.join(sdkDir, "lib", "v2", "index.js"); + } + + function findFunctionsBinary(): { binary: string[]; bin: string } { + const delegate = new node.Delegate(PROJECT_ID, projectDir, sourceDir, "nodejs24"); + return { binary: delegate["findFunctionsBinary"](), bin: delegate.bin }; + } + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "functions-binary-")); + sourceDir = path.join(projectDir, "functions"); + fs.mkdirSync(sourceDir); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it("prefers the node_modules/.bin shim when it exists", () => { + installSdk("", ["firebase-functions"], { + "firebase-functions": "lib/bin/firebase-functions.js", + }); + const { binary } = findFunctionsBinary(); + expect(binary).to.deep.equal([ + path.join(sourceDir, "node_modules", ".bin", "firebase-functions"), + ]); + }); + + it("runs the SDK bin entry with the resolved node binary when no shim exists", () => { + const sdkDir = installSdk( + path.join(".bun", "firebase-functions@7.4.0+abc", "node_modules"), + ["firebase-functions.exe", "firebase-functions.bunx"], + { "firebase-functions": "lib/bin/firebase-functions.js" }, + ); + const { binary, bin } = findFunctionsBinary(); + expect(binary).to.deep.equal([bin, path.join(sdkDir, BIN_ENTRY)]); + }); + + it("throws when the SDK declares no bin entry and no shim exists", () => { + installSdk("", [], undefined); + expect(() => findFunctionsBinary()).to.throw( + FirebaseError, + "Failed to find location of Firebase Functions SDK", + ); + }); + + it("passes the source directory to the SDK bin entry", async () => { + const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js"); + fs.writeFileSync( + path.join(sdkDir, BIN_ENTRY), + "process.stdout.write(JSON.stringify(process.argv.slice(2)));", + ); + const delegate = new node.Delegate(PROJECT_ID, projectDir, sourceDir, "nodejs24"); + const child = delegate["spawnFunctionsProcess"]({}, {}); + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8"))); + await new Promise((resolve) => child.on("close", resolve)); + expect(stdout).to.equal(JSON.stringify([sourceDir])); + }); + + describe("findSdkBinEntry", () => { + it("finds the bin entry in a Bun store", () => { + const sdkDir = installSdk( + path.join(".bun", "firebase-functions@7.4.0+abc", "node_modules"), + ["firebase-functions.exe", "firebase-functions.bunx"], + { "firebase-functions": "lib/bin/firebase-functions.js" }, + ); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY)); + }); + + it("finds the bin entry in a .pnpm store", () => { + const sdkDir = installSdk( + path.join(".pnpm", "firebase-functions@7.4.0", "node_modules"), + ["firebase-functions.cmd", "firebase-functions.ps1"], + { "firebase-functions": "lib/bin/firebase-functions.js" }, + ); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY)); + }); + + it("accepts a bin entry declared as a single path", () => { + const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js"); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY)); + }); + + it("returns undefined when the SDK declares no bin entry", () => { + const sdkDir = installSdk("", [], undefined); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined; + }); + + it("returns undefined when the bin entry points to a missing file", () => { + const sdkDir = installSdk("", [], { "firebase-functions": "lib/bin/missing.js" }); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined; + }); + + it("skips a malformed package.json and keeps walking up", () => { + const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js"); + fs.writeFileSync(path.join(sdkDir, "lib", "v2", "package.json"), "{"); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.equal(path.join(sdkDir, BIN_ENTRY)); + }); + + it("returns undefined when the SDK package.json is malformed", () => { + const sdkDir = installSdk("", [], "lib/bin/firebase-functions.js"); + fs.writeFileSync(path.join(sdkDir, "package.json"), ""); + expect(node.findSdkBinEntry(sdkMain(sdkDir))).to.be.undefined; + }); + }); + }); }); diff --git a/src/deploy/functions/runtimes/node/index.ts b/src/deploy/functions/runtimes/node/index.ts index 4c56c4eeffc..6a8dc9f85f9 100644 --- a/src/deploy/functions/runtimes/node/index.ts +++ b/src/deploy/functions/runtimes/node/index.ts @@ -6,7 +6,7 @@ import * as semver from "semver"; import * as spawn from "cross-spawn"; import { ChildProcess } from "child_process"; -import { FirebaseError } from "../../../../error"; +import { FirebaseError, getErrMsg } from "../../../../error"; import { getRuntimeChoice } from "./parseRuntimeAndValidateSDK"; import { logger } from "../../../../logger"; import { @@ -53,6 +53,39 @@ export async function tryCreateDelegate(context: DelegateContext): Promise }; + +/** @internal */ +export function findSdkBinEntry(sdkPath: string): string | undefined { + for ( + let dir = path.dirname(sdkPath); + path.basename(dir) !== "node_modules" && path.dirname(dir) !== dir; + dir = path.dirname(dir) + ) { + const packageJsonPath = path.join(dir, "package.json"); + if (!fileExistsSync(packageJsonPath)) { + continue; + } + let pkg: SdkPackageJson | null; + try { + pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as SdkPackageJson | null; + } catch (err: unknown) { + logger.debug(`Failed to read ${packageJsonPath}: ${getErrMsg(err)}`); + continue; + } + if (pkg?.name !== "firebase-functions") { + continue; + } + const entry = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["firebase-functions"]; + if (typeof entry !== "string" || !entry) { + return undefined; + } + const binEntry = path.join(dir, entry); + return fileExistsSync(binEntry) ? binEntry : undefined; + } + return undefined; +} + // TODO(inlined): Consider moving contents in parseRuntimeAndValidateSDK and validate around. // Those two files are currently pretty coupled (e.g. they borrow error messages from each other) // and both files load package.json. Maybe the delegate should be constructed with a package.json and @@ -158,7 +191,7 @@ export class Delegate { return Promise.resolve(() => Promise.resolve()); } - private findFunctionsBinary(): string { + private findFunctionsBinary(): string[] { // Location of the binary included in the Firebase Functions SDK // differs depending on the developer's setup and choice of package manager. // @@ -188,10 +221,16 @@ export class Delegate { const binPath = path.join(nodeModulesPath, ".bin", "firebase-functions"); if (fileExistsSync(binPath)) { logger.debug(`Found firebase-functions binary at '${binPath}'`); - return binPath; + return [binPath]; } } + const binEntry = findSdkBinEntry(sdkPath); + if (binEntry) { + logger.debug(`Found firebase-functions bin entry at '${binEntry}'`); + return [this.bin, binEntry]; + } + throw new FirebaseError( "Failed to find location of Firebase Functions SDK. " + "Please file a bug on Github (https://github.com/firebase/firebase-tools/).", @@ -217,8 +256,8 @@ export class Delegate { env.CLOUD_RUNTIME_CONFIG = JSON.stringify(config); } - const binPath = this.findFunctionsBinary(); - const childProcess = spawn(binPath, [this.sourceDir], { + const [command, ...args] = this.findFunctionsBinary(); + const childProcess = spawn(command, [...args, this.sourceDir], { env, cwd: this.sourceDir, stdio: [/* stdin=*/ "ignore", /* stdout=*/ "pipe", /* stderr=*/ "pipe"],