diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c870566b5..8e584cc99d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ - Configured OneMCP server tools to require a Firebase project by default, with options to opt-out specific tools (such as Developer Knowledge document search). - Fixed a bug where deploying functions with the `dartfunctions` experiment enabled could incorrectly prompt to delete existing GCF v2 functions. +- Added a warning when a functions lockfile omits peer dependencies that the Cloud Functions build server expects, and a clearer message when a build fails because `npm ci` rejected the lockfile (#5673). - Added `outputSchema` support for local MCP tools. - Skip functions lifecycle hooks during partial (filtered) deployments, and print instructions for running them manually. - Added `appcheck:providers:list`, `appcheck:providers:get` and `appcheck:providers:set` to configure App Check attestation providers for an app. diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 7e13b1094e2..1b59c373d3f 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -33,6 +33,7 @@ import { } from "./functionsDeployHelper"; import { logLabeledBullet, logLabeledWarning } from "../../utils"; import { isDartEndpoint, classifyNonProductionEndpoints } from "./runtimes/dart/triggerSupport"; +import * as nodeValidate from "./runtimes/node/validate"; import { getFunctionsConfig, prepareFunctionsUpload } from "./prepareFunctionsUpload"; import { promptForFailurePolicies, promptForMinInstances } from "./prompts"; import { needProjectId, needProjectNumber } from "../../projectUtils"; @@ -414,6 +415,14 @@ export async function prepare( "functions", `preparing ${clc.bold(sourceDirName)} directory for uploading...`, ); + // Describes how the build server will treat the lockfile we are about to + // upload, so it belongs here rather than anywhere shared with the emulator + // or with commands that only inspect the source. + if ( + backend.someEndpoint(wantBackend, (e) => supported.runtimeIsLanguage(e.runtime, "nodejs")) + ) { + nodeValidate.warnIfLockfileOmitsPeerDeps(sourceDir, localCfg.ignore); + } } if (backend.someEndpoint(wantBackend, (e) => e.platform === "gcfv2" || e.platform === "run")) { diff --git a/src/deploy/functions/release/reporter.spec.ts b/src/deploy/functions/release/reporter.spec.ts index 6f24ebdf78c..a11a6afb9fa 100644 --- a/src/deploy/functions/release/reporter.spec.ts +++ b/src/deploy/functions/release/reporter.spec.ts @@ -344,6 +344,132 @@ describe("reporter", () => { ); }); + // Captured verbatim from a failed Cloud Functions deploy, so the matcher is + // tested against the shape the Functions API actually returns. + it("prints lockfile errors", () => { + const rawError = new Error( + "Build failed: npm error code EUSAGE\nnpm error\nnpm error `npm ci` can only install " + + "packages when your package.json and package-lock.json or npm-shrinkwrap.json are in " + + "sync. Please update your lock file with `npm install` before continuing.\nnpm error\n" + + "npm error Missing: jest@29.7.0 from lock file\nnpm error Missing: @jest/core@29.7.0 " + + "from lock file", + ); + const summary: reporter.Summary = { + totalTime: 1_000, + results: [ + { + endpoint: ENDPOINT, + durationMs: 1_000, + error: new reporter.DeploymentError(ENDPOINT, "create", rawError), + }, + ], + }; + + reporter.printErrors(summary); + expect(infoStub).to.have.been.calledWithMatch( + "your lockfile is out of sync with package.json", + ); + expect(infoStub).to.have.been.calledWithMatch("legacy-peer-deps"); + }); + + it("finds lockfile errors nested in the original error", () => { + const rawError = new Error("Deployment failed") as Error & { original?: unknown }; + rawError.original = { + message: + "npm ERR! `npm ci` can only install packages when your package.json and " + + "package-lock.json are in sync. Missing: p-limit@2.3.0 from lock file", + }; + const summary: reporter.Summary = { + totalTime: 1_000, + results: [ + { + endpoint: ENDPOINT, + durationMs: 1_000, + error: new reporter.DeploymentError(ENDPOINT, "create", rawError), + }, + ], + }; + + reporter.printErrors(summary); + expect(infoStub).to.have.been.calledWithMatch( + "your lockfile is out of sync with package.json", + ); + }); + + it("matches the invalid-version shape of the same failure", () => { + const rawError = new Error( + "Build failed: npm error `npm ci` can only install packages when your package.json " + + "and package-lock.json are in sync. npm error Invalid: lock file's ms@2.1.2 does " + + "not satisfy ms@2.1.3", + ); + const summary: reporter.Summary = { + totalTime: 1_000, + results: [ + { + endpoint: ENDPOINT, + durationMs: 1_000, + error: new reporter.DeploymentError(ENDPOINT, "create", rawError), + }, + ], + }; + + reporter.printErrors(summary); + expect(infoStub).to.have.been.calledWithMatch( + "your lockfile is out of sync with package.json", + ); + }); + + it("finds lockfile errors however deeply the build failure is wrapped", () => { + const rawError = new Error("Deployment failed") as Error & { original?: unknown }; + rawError.original = { + original: { + context: { + body: { + error: { + message: + "Build failed: npm error `npm ci` can only install packages when your " + + "package.json and package-lock.json are in sync. Missing: jest@29.7.0 " + + "from lock file", + }, + }, + }, + }, + }; + const summary: reporter.Summary = { + totalTime: 1_000, + results: [ + { + endpoint: ENDPOINT, + durationMs: 1_000, + error: new reporter.DeploymentError(ENDPOINT, "create", rawError), + }, + ], + }; + + reporter.printErrors(summary); + expect(infoStub).to.have.been.calledWithMatch( + "your lockfile is out of sync with package.json", + ); + }); + + it("does not print lockfile errors for unrelated failures", () => { + const summary: reporter.Summary = { + totalTime: 1_000, + results: [ + { + endpoint: ENDPOINT, + durationMs: 1_000, + error: new reporter.DeploymentError(ENDPOINT, "create", new Error("Build failed")), + }, + ], + }; + + reporter.printErrors(summary); + expect(infoStub).to.not.have.been.calledWithMatch( + "your lockfile is out of sync with package.json", + ); + }); + it("prints aborted errors", () => { const summary: reporter.Summary = { totalTime: 1_000, diff --git a/src/deploy/functions/release/reporter.ts b/src/deploy/functions/release/reporter.ts index fa395bc9dab..ad72086364d 100644 --- a/src/deploy/functions/release/reporter.ts +++ b/src/deploy/functions/release/reporter.ts @@ -151,9 +151,76 @@ export function printErrors(summary: Summary): void { printIamErrors(errored); printQuotaErrors(errored); + printLockfileErrors(errored); printAbortedErrors(errored); } +/** + * The shape a build failure arrives in once it has been wrapped for reporting. + * Every level is optional because the nesting depends on which layer failed. + */ +interface NestedError { + message?: string; + original?: unknown; + cause?: unknown; + context?: { body?: { error?: { message?: string } } }; +} + +/** + * Collects every message in an error's cause chain so we can pattern match on them. + * + * Walks rather than reaching into fixed paths, since how deeply a build failure + * is wrapped depends on which layer reported it. + */ +function errorMessages(err: NestedError, depth = 0): string { + if (!err || depth > 5) { + return ""; + } + return [ + err.message, + err.context?.body?.error?.message, + errorMessages(err.original as NestedError, depth + 1), + errorMessages(err.cause as NestedError, depth + 1), + ] + .filter(Boolean) + .join(" "); +} + +/** Print errors for builds that failed because `npm ci` rejected the lockfile. */ +function printLockfileErrors(results: Array>): void { + const hadLockfileError = results.find((r) => { + if (!(r.error instanceof DeploymentError)) { + return false; + } + const message = errorMessages(r.error); + if (!message.includes("npm ci")) { + return false; + } + // "Missing: x from lock file" and "Invalid: lock file's x does not satisfy y" + // are the two shapes npm uses for the same out-of-sync failure. + return message.includes("from lock file") || message.includes("lock file's"); + }); + if (!hadLockfileError) { + return; + } + + logger.info(""); + logger.info( + "The build failed because your lockfile is out of sync with package.json, so " + + "`npm ci` refused to install. Run " + + `${clc.bold("npm install")} in your functions directory and commit the updated lockfile.`, + ); + logger.info(""); + logger.info( + "If the lockfile already looks up to date, it was most likely resolved with " + + `${clc.bold("legacy-peer-deps")} enabled, which the build server does not use, so peer ` + + `dependencies it expects are absent. Check with ` + + `${clc.bold("npm config get legacy-peer-deps")} and regenerate the lockfile with the ` + + "setting off, or add an .npmrc to your functions directory containing only " + + `${clc.bold("legacy-peer-deps=true")} so the build server resolves the same way you do.`, + ); +} + /** Print errors for failures to set invoker. */ function printIamErrors(results: Array>): void { const iamFailures = results.filter( diff --git a/src/deploy/functions/runtimes/node/validate.spec.ts b/src/deploy/functions/runtimes/node/validate.spec.ts index 05e92185687..97edb2f57a7 100644 --- a/src/deploy/functions/runtimes/node/validate.spec.ts +++ b/src/deploy/functions/runtimes/node/validate.spec.ts @@ -1,9 +1,12 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import * as path from "path"; +import * as fs from "fs"; import { FirebaseError } from "../../../../error"; import * as validate from "./validate"; import * as fsutils from "../../../../fsutils"; +import * as utils from "../../../../utils"; const cjson = require("cjson"); @@ -60,4 +63,293 @@ describe("validate", () => { }).to.not.throw(); }); }); + + describe("parseLegacyPeerDeps", () => { + it("reads the setting", () => { + expect(validate.parseLegacyPeerDeps("legacy-peer-deps=true\n")).to.be.true; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps=false\n")).to.be.false; + expect(validate.parseLegacyPeerDeps(" legacy-peer-deps = true \n")).to.be.true; + }); + + it("returns undefined when the file does not set it", () => { + expect(validate.parseLegacyPeerDeps("registry=https://example.com\n")).to.be.undefined; + expect(validate.parseLegacyPeerDeps("")).to.be.undefined; + }); + + it("ignores commented out settings", () => { + expect(validate.parseLegacyPeerDeps("; legacy-peer-deps=true\n")).to.be.undefined; + expect(validate.parseLegacyPeerDeps("# legacy-peer-deps=true\n")).to.be.undefined; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps=true ; why\n")).to.be.true; + }); + + it("takes the last assignment, as npm does", () => { + expect(validate.parseLegacyPeerDeps("legacy-peer-deps=true\nlegacy-peer-deps=false\n")).to.be + .false; + }); + + // ini strips quotes and reads a bare key as true, so all of these are on as + // far as npm is concerned. + it("reads the forms ini accepts", () => { + expect(validate.parseLegacyPeerDeps('legacy-peer-deps="true"\n')).to.be.true; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps='true'\n")).to.be.true; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps\n")).to.be.true; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps = TRUE\n")).to.be.true; + expect(validate.parseLegacyPeerDeps('legacy-peer-deps="false"\n')).to.be.false; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps=\n")).to.be.false; + }); + + it("does not confuse a different setting for this one", () => { + expect(validate.parseLegacyPeerDeps("not-legacy-peer-deps=true\n")).to.be.undefined; + expect(validate.parseLegacyPeerDeps("legacy-peer-deps-x=true\n")).to.be.undefined; + }); + }); + + describe("findMissingPeerDeps", () => { + it("returns nothing for a lockfile with every peer present", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/jest": {}, + "node_modules/firebase-functions-test": { + peerDependencies: { jest: ">=28.0.0" }, + }, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("finds a peer the lockfile omits", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/firebase-functions-test": { + peerDependencies: { jest: ">=28.0.0", "firebase-admin": "^13.0.0" }, + }, + "node_modules/firebase-admin": {}, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal(["jest"]); + }); + + it("skips optional peers", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/pkg": { + peerDependencies: { "not-installed": "*" }, + peerDependenciesMeta: { "not-installed": { optional: true } }, + }, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("resolves a peer nested beside its dependent", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/a/node_modules/b": { peerDependencies: { c: "*" } }, + "node_modules/a/node_modules/c": {}, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("resolves a peer hoisted to the root", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/a/node_modules/b": { peerDependencies: { c: "*" } }, + "node_modules/c": {}, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("returns nothing for a lockfileVersion 1 lockfile", () => { + expect(validate.findMissingPeerDeps({ dependencies: {} } as any)).to.deep.equal([]); + }); + + it("resolves a peer provided by a workspace sibling", () => { + const lockfile = { + packages: { + "": {}, + "packages/api": {}, + "node_modules/api": { link: true } as never, + "packages/api/node_modules/plugin": { peerDependencies: { host: "*" } }, + "packages/api/node_modules/host": {}, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("resolves a peer in a non-node_modules parent directory", () => { + const lockfile = { + packages: { + "": {}, + "apps/web": { peerDependencies: { react: "*" } }, + "apps/node_modules/react": {}, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + + it("handles scoped peer names", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/@scope/pkg": { peerDependencies: { "@scope/peer": "*" } }, + }, + }; + + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal(["@scope/peer"]); + }); + + it("does not treat a null entry as a present package", () => { + const lockfile = { + packages: { + "": {}, + "node_modules/pkg": { peerDependencies: { peer: "*" } }, + "node_modules/peer": null as never, + }, + }; + + // The name is present as a key, which is all npm needs to resolve it. + expect(validate.findMissingPeerDeps(lockfile)).to.deep.equal([]); + }); + }); + + describe("warnIfLockfileOmitsPeerDeps", () => { + const sandbox: sinon.SinonSandbox = sinon.createSandbox(); + const LOCKFILE = path.join("sourceDir", "package-lock.json"); + const SHRINKWRAP = path.join("sourceDir", "npm-shrinkwrap.json"); + const NPMRC = path.join("sourceDir", ".npmrc"); + // firebase-functions-test peer depends on jest, which a legacy-peer-deps + // install leaves out of the lockfile entirely. + const BROKEN_LOCKFILE = JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/firebase-functions-test": { peerDependencies: { jest: ">=28.0.0" } }, + }, + }); + const GOOD_LOCKFILE = JSON.stringify({ + lockfileVersion: 3, + packages: { + "": {}, + "node_modules/jest": {}, + "node_modules/firebase-functions-test": { peerDependencies: { jest: ">=28.0.0" } }, + }, + }); + let fileExistsStub: sinon.SinonStub; + let readFileStub: sinon.SinonStub; + let warnStub: sinon.SinonStub; + + beforeEach(() => { + fileExistsStub = sandbox.stub(fsutils, "fileExistsSync").returns(false); + readFileStub = sandbox.stub(fs, "readFileSync"); + warnStub = sandbox.stub(utils, "logLabeledWarning"); + fileExistsStub.withArgs(LOCKFILE).returns(true); + readFileStub.withArgs(LOCKFILE, "utf8").returns(BROKEN_LOCKFILE); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("warns and names the missing peer", () => { + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.have.been.calledWithMatch("functions", "jest"); + }); + + it("checks npm-shrinkwrap.json too", () => { + fileExistsStub.withArgs(LOCKFILE).returns(false); + fileExistsStub.withArgs(SHRINKWRAP).returns(true); + readFileStub.withArgs(SHRINKWRAP, "utf8").returns(BROKEN_LOCKFILE); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.have.been.calledWithMatch("functions", "npm-shrinkwrap.json"); + }); + + it("does not warn when the lockfile is complete", () => { + readFileStub.withArgs(LOCKFILE, "utf8").returns(GOOD_LOCKFILE); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.not.have.been.called; + }); + + it("does not warn when there is no lockfile", () => { + fileExistsStub.withArgs(LOCKFILE).returns(false); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.not.have.been.called; + }); + + it("does not warn when a shipping .npmrc turns legacy-peer-deps on", () => { + fileExistsStub.withArgs(NPMRC).returns(true); + readFileStub.withArgs(NPMRC, "utf8").returns("legacy-peer-deps=true\n"); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.not.have.been.called; + }); + + it("warns when the .npmrc turns legacy-peer-deps off", () => { + fileExistsStub.withArgs(NPMRC).returns(true); + readFileStub.withArgs(NPMRC, "utf8").returns("legacy-peer-deps=false\n"); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.have.been.called; + }); + + it("warns when the .npmrc is configured out of the upload", () => { + fileExistsStub.withArgs(NPMRC).returns(true); + readFileStub.withArgs(NPMRC, "utf8").returns("legacy-peer-deps=true\n"); + + // The setting never reaches the build server if the file is not uploaded. + for (const glob of [".npmrc", "**/.npmrc", ".*", "*"]) { + warnStub.resetHistory(); + validate.warnIfLockfileOmitsPeerDeps("sourceDir", ["node_modules", glob]); + expect(warnStub, `ignoring ${glob} should not suppress the warning`).to.have.been.called; + } + }); + + it("truncates a long list of missing peers", () => { + const packages: Record = { "": {} }; + for (let i = 0; i < 8; i++) { + packages[`node_modules/pkg${i}`] = { peerDependencies: { [`peer${i}`]: "*" } }; + } + readFileStub.withArgs(LOCKFILE, "utf8").returns(JSON.stringify({ packages })); + + validate.warnIfLockfileOmitsPeerDeps("sourceDir"); + + expect(warnStub).to.have.been.calledWithMatch("functions", "and 3 more"); + }); + + it("stays quiet on an unreadable lockfile rather than failing the deploy", () => { + readFileStub.withArgs(LOCKFILE, "utf8").returns("{ not json"); + + expect(() => validate.warnIfLockfileOmitsPeerDeps("sourceDir")).to.not.throw(); + expect(warnStub).to.not.have.been.called; + }); + + it("stays quiet when reading the lockfile throws", () => { + readFileStub.withArgs(LOCKFILE, "utf8").throws(new Error("EACCES")); + + expect(() => validate.warnIfLockfileOmitsPeerDeps("sourceDir")).to.not.throw(); + expect(warnStub).to.not.have.been.called; + }); + }); }); diff --git a/src/deploy/functions/runtimes/node/validate.ts b/src/deploy/functions/runtimes/node/validate.ts index e03f6796ade..90906dd0874 100644 --- a/src/deploy/functions/runtimes/node/validate.ts +++ b/src/deploy/functions/runtimes/node/validate.ts @@ -1,8 +1,17 @@ import * as path from "path"; +import * as fs from "fs"; +import * as minimatch from "minimatch"; import { FirebaseError } from "../../../../error"; import { logger } from "../../../../logger"; import * as fsutils from "../../../../fsutils"; +import * as utils from "../../../../utils"; + +// `npm ci` accepts either, so either can be the artifact that fails the build. +const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"]; + +// Enough to identify the problem without printing a wall of names. +const MAX_REPORTED_PEERS = 5; // have to require this because no @types/cjson available // tslint:disable-next-line @@ -53,3 +62,158 @@ export function packageJsonIsValid( throw new FirebaseError(msg); } } + +/** The subset of a lockfile's `packages` map that we care about. */ +interface LockfileEntry { + peerDependencies?: Record; + peerDependenciesMeta?: Record; +} + +interface Lockfile { + packages?: Record; +} + +/** + * Reads the effective value of legacy-peer-deps from .npmrc contents. + * @param contents Raw text of an .npmrc file. + * @return The configured value, or undefined if the file does not set it. + */ +export function parseLegacyPeerDeps(contents: string): boolean | undefined { + let value: boolean | undefined; + for (const line of contents.split("\n")) { + // npm treats both ; and # as comment markers. + const setting = line.split(/[;#]/)[0]; + const match = /^\s*legacy-peer-deps\s*(?:=\s*(.*?))?\s*$/.exec(setting); + if (!match) { + continue; + } + // ini reads a bare key as true, and strips quotes around a value. + const raw = (match[1] ?? "true").replace(/^(["'])(.*)\1$/, "$2").toLowerCase(); + // Last assignment wins, as it does in npm, which coerces anything that is + // neither empty nor "false" to true. + value = raw !== "" && raw !== "false"; + } + return value; +} + +/** + * Returns true if the source dir ships an .npmrc turning legacy-peer-deps on. + * + * Such an .npmrc travels with the upload and the build server's npm honors it, + * so the build resolves the same way the developer's install did. + * @param sourceDir Absolute path of the functions source directory. + * @param ignore The codebase's configured ignore globs, which may exclude .npmrc. + */ +function shipsLegacyPeerDeps(sourceDir: string, ignore: string[]): boolean { + // Same options the packaging code matches ignore globs with, so we agree with + // it about whether the file is actually uploaded. + if (ignore.some((glob) => minimatch(".npmrc", glob, { matchBase: true, dot: true }))) { + // Configured out of the upload, so whatever it says never reaches the build. + return false; + } + const npmrc = path.join(sourceDir, ".npmrc"); + if (!fsutils.fileExistsSync(npmrc)) { + return false; + } + return parseLegacyPeerDeps(fs.readFileSync(npmrc, "utf8")) === true; +} + +/** + * Finds peer dependencies the lockfile requires but does not contain. + * + * npm satisfies a peer by walking up node_modules from the dependent, so we + * resolve each peer the same way against the lockfile's `packages` map. + * Names only, so it does not check that a present peer satisfies the required + * range, and it only walks peers of packages the lockfile already contains. + * Both are fine for an advisory warning, which does not aim to reproduce + * `npm ci`. + * @param lockfile Parsed package-lock.json or npm-shrinkwrap.json. + * @return Sorted names of missing peers, empty if none were found. + */ +export function findMissingPeerDeps(lockfile: Lockfile): string[] { + const packages = lockfile.packages; + if (!packages) { + // lockfileVersion 1 has no package metadata to check. + return []; + } + const missing = new Set(); + for (const [dir, entry] of Object.entries(packages)) { + for (const peer of Object.keys(entry?.peerDependencies ?? {})) { + if (entry.peerDependenciesMeta?.[peer]?.optional) { + continue; + } + let prefix = dir; + let found = false; + for (;;) { + const candidate = prefix ? `${prefix}/node_modules/${peer}` : `node_modules/${peer}`; + if (candidate in packages) { + found = true; + break; + } + if (!prefix) { + break; + } + // Walk one directory up, whether or not it is a node_modules boundary. + const parent = prefix.lastIndexOf("/"); + prefix = parent === -1 ? "" : prefix.slice(0, parent); + } + if (!found) { + missing.add(peer); + } + } + } + return [...missing].sort(); +} + +/** + * Warns when the lockfile we are about to upload omits peer dependencies. + * + * The build server runs `npm ci` with npm's default legacy-peer-deps=false. A + * lockfile resolved with the setting on omits the peers npm would otherwise + * install, so the build fails with "Missing: from lock file" even + * though the local install succeeded. Reading the lockfile catches this however + * the setting was applied, including a one-off `npm install --legacy-peer-deps` + * that leaves no trace in npm's config. + * + * Advisory only: never throws, so it can never fail a deploy that would + * otherwise have worked. + * @param sourceDir Absolute path of the functions source directory. + * @param ignore The codebase's configured ignore globs. + */ +export function warnIfLockfileOmitsPeerDeps(sourceDir: string, ignore: string[] = []): void { + try { + const lockfilePath = LOCKFILES.map((f) => path.join(sourceDir, f)).find((f) => + fsutils.fileExistsSync(f), + ); + if (!lockfilePath) { + // Without a lockfile the builder falls back to `npm install` and resolves remotely. + return; + } + + const missing = findMissingPeerDeps(JSON.parse(fs.readFileSync(lockfilePath, "utf8"))); + if (missing.length === 0) { + return; + } + if (shipsLegacyPeerDeps(sourceDir, ignore)) { + // The build server will resolve the same way the lockfile was written. + return; + } + + const named = missing.slice(0, MAX_REPORTED_PEERS).join(", "); + const rest = missing.length - MAX_REPORTED_PEERS; + utils.logLabeledWarning( + "functions", + `${path.basename(lockfilePath)} is missing peer dependencies that the Cloud Functions ` + + `build server expects: ${named}${rest > 0 ? `, and ${rest} more` : ""}.\n` + + "This usually means it was resolved with legacy-peer-deps enabled, which the build " + + 'server does not use, so `npm ci` may fail there with "Missing: from lock ' + + 'file".\n' + + "To fix it, regenerate the lockfile with `npm install` and legacy-peer-deps off. To " + + "keep resolving this way instead, add an .npmrc to your functions directory containing " + + "only legacy-peer-deps=true, since that file is uploaded with your source.", + ); + } catch (err: unknown) { + // A warning is never worth failing a deploy over. + logger.debug("Unable to check the functions lockfile for missing peer dependencies:", err); + } +}