Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" };
}

Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
});
Expand All @@ -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 };
}
88 changes: 88 additions & 0 deletions packages/cli/tests/cli/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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: [] });
Expand Down
Loading