diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ff..b242c54c 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -7,6 +7,7 @@ import { } from "@/cli/commands/connectors/oauth-prompt.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { maybeBuildBeforeDeploy } from "@/cli/commands/project/site-build.js"; +import { CLIExitError } from "@/cli/errors.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command, @@ -141,6 +142,16 @@ export async function deployAction( ); } + const failedFunctions = result.functionResults.filter( + (functionResult) => functionResult.status === "error", + ).length; + if (failedFunctions > 0) { + log.warn( + `${failedFunctions} ${failedFunctions === 1 ? "function" : "functions"} failed to deploy`, + ); + throw new CLIExitError(1); + } + return { outroMessage: "App deployed successfully" }; } diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef9..cae4a432 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -58,6 +58,10 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { * Result of deploying all project resources. */ interface DeployAllResult { + /** + * Per-function deployment results, including failures. + */ + functionResults: SingleFunctionDeployResult[]; /** * The app URL if a site was deployed, undefined otherwise. */ @@ -100,7 +104,7 @@ export async function deployAll( options?.onVisibilitySet?.(project.visibility); } await entityResource.push(entities); - await deployFunctionsSequentially(functions, { + const functionResults = await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); @@ -119,8 +123,8 @@ export async function deployAll( if (project.site?.outputDirectory) { const outputDir = resolve(project.root, project.site.outputDirectory); const { appUrl } = await deploySite(outputDir); - return { appUrl, connectorResults }; + return { appUrl, connectorResults, functionResults }; } - return { connectorResults }; + return { connectorResults, functionResults }; } diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index 1c4208bb..c6b1f627 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; @@ -145,6 +147,92 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("exits with code 1 when a function deployment fails", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, + }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + + const result = await t.run("deploy", "-y"); + + expect(result.exitCode).toBe(1); + t.expectResult(result).toContain("Invalid function code"); + t.expectResult(result).toContain("1 function failed to deploy"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + + it("attempts remaining functions after one fails and exits with code 1", async () => { + await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); + t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); + const attemptedFunctions: string[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/backend-functions/:name`, + (req, res) => { + attemptedFunctions.push(String(req.params.name)); + if (attemptedFunctions.length === 1) { + res.status(400).json({ error: "Invalid function code" }); + return; + } + res.status(200).json({ status: "deployed" }); + }, + ); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + + const result = await t.run("deploy", "-y"); + + expect(result.exitCode).toBe(1); + expect(attemptedFunctions).toHaveLength(4); + expect(new Set(attemptedFunctions).size).toBe(4); + t.expectResult(result).toContain("[4/4]"); + t.expectResult(result).toContain("1 function failed to deploy"); + }); + + it("handles pending connectors before exiting for a function failure", async () => { + await t.givenLoggedInWithProject(fixture("with-connectors")); + const functionDir = join( + t.getTempDir(), + "project", + "functions", + "invalid-function", + ); + await mkdir(functionDir, { recursive: true }); + await writeFile( + join(functionDir, "entry.ts"), + 'Deno.serve(() => new Response("ok"));\n', + ); + t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, + }); + t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockConnectorSet({ + redirect_url: "https://accounts.example.com/oauth", + connection_id: "conn_123", + already_authorized: false, + }); + + const result = await t.run("deploy", "-y"); + + expect(result.exitCode).toBe(1); + t.expectResult(result).toContain("3 connector(s) require authorization"); + t.expectResult(result).toContain( + "Some connectors still require authorization", + ); + t.expectResult(result).toContain("Dashboard"); + t.expectResult(result).toContain("1 function failed to deploy"); + t.expectResult(result).toNotContain("App deployed successfully"); + }); + it("deploys zero-config functions (path-based names) with unified deploy", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] });