diff --git a/docs/error-handling.md b/docs/error-handling.md index 6792a6c5..fb17a850 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -15,7 +15,8 @@ CLIError (abstract base class) │ ├── ConfigInvalidError # Invalid config syntax/structure │ ├── ConfigExistsError # Project already exists │ ├── SchemaValidationError # Zod validation failed -│ └── InvalidInputError # Bad user input (template not found, etc.) +│ ├── InvalidInputError # Bad user input (template not found, etc.) +│ └── DeploymentFailedError # Deploy completed with failed resources │ └── SystemError (something broke - needs investigation) ├── ApiError # HTTP/network failures @@ -109,6 +110,7 @@ See [api-patterns.md](api-patterns.md) for the full `ApiError.fromHttpError()` p | `CONFIG_EXISTS` | `ConfigExistsError` | Project already exists at location | | `SCHEMA_INVALID` | `SchemaValidationError` | Zod validation failed | | `INVALID_INPUT` | `InvalidInputError` | User provided invalid input | +| `DEPLOYMENT_FAILED` | `DeploymentFailedError` | Deploy completed with failed resources | | `API_ERROR` | `ApiError` | API request failed | | `FILE_NOT_FOUND` | `FileNotFoundError` | File doesn't exist | | `FILE_READ_ERROR` | `FileReadError` | Can't read/write file | diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index b6beb023..c279dca5 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,5 +1,6 @@ import type { Logger } from "@base44-cli/logger"; import type { Command } from "commander"; +import { throwIfFunctionDeployFailed } from "@/cli/commands/functions/deployFailures.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import { parseNames } from "@/cli/commands/functions/parseNames.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; @@ -100,6 +101,12 @@ async function deployFunctionsAction( }, }); + const summary = buildDeploySummary(results); + if (results.some((result) => result.status === "error")) { + log.info(summary); + } + throwIfFunctionDeployFailed(results); + if (options.force) { const allLocalNames = functions.map((f) => f.name); let pruneCompleted = 0; @@ -126,7 +133,7 @@ async function deployFunctionsAction( formatPruneSummary(pruneResults, log); } - return { outroMessage: buildDeploySummary(results) }; + return { outroMessage: summary }; } export function getDeployCommand(): Command { diff --git a/packages/cli/src/cli/commands/functions/deployFailures.ts b/packages/cli/src/cli/commands/functions/deployFailures.ts new file mode 100644 index 00000000..aebb9e37 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/deployFailures.ts @@ -0,0 +1,20 @@ +import { DeploymentFailedError } from "@/core/errors.js"; +import type { SingleFunctionDeployResult } from "@/core/resources/function/deploy.js"; + +function buildFunctionDeployFailureMessage( + results: SingleFunctionDeployResult[], +): string | null { + const failed = results.filter((result) => result.status === "error").length; + if (failed === 0) return null; + + return `${failed} ${failed === 1 ? "function" : "functions"} failed to deploy`; +} + +export function throwIfFunctionDeployFailed( + results: SingleFunctionDeployResult[], +): void { + const message = buildFunctionDeployFailureMessage(results); + if (message) { + throw new DeploymentFailedError(message); + } +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index ad07fa79..2664743d 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -5,6 +5,7 @@ import { filterPendingOAuth, promptOAuthFlows, } from "@/cli/commands/connectors/oauth-prompt.js"; +import { throwIfFunctionDeployFailed } from "@/cli/commands/functions/deployFailures.js"; import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { @@ -130,6 +131,8 @@ export async function deployAction( ); } + throwIfFunctionDeployFailed(result.functionResults); + return { outroMessage: "App deployed successfully" }; } diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index 5a358842..e3d8466a 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -262,6 +262,23 @@ export class InvalidInputError extends UserError { readonly code = "INVALID_INPUT"; } +/** + * Thrown when deployment completes with one or more failed resources. + */ +export class DeploymentFailedError extends UserError { + readonly code = "DEPLOYMENT_FAILED"; + + constructor(message: string, options?: CLIErrorOptions) { + super(message, { + hints: options?.hints ?? [ + { message: "Check the deployment errors above and try again" }, + ], + details: options?.details, + cause: options?.cause, + }); + } +} + /** * Thrown when a required external dependency is not installed (e.g., Deno, Git). */ diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 68089173..d8fcff1d 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -43,6 +43,10 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { * Result of deploying all project resources. */ interface DeployAllResult { + /** + * Per-function deployment results, including any failed functions. + */ + functionResults: SingleFunctionDeployResult[]; /** * The app URL if a site was deployed, undefined otherwise. */ @@ -73,7 +77,7 @@ export async function deployAll( projectData; await entityResource.push(entities); - await deployFunctionsSequentially(functions, { + const functionResults = await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); @@ -84,8 +88,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 { functionResults, appUrl, connectorResults }; } - return { connectorResults }; + return { functionResults, connectorResults }; } diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index bd59390d..7a43dc12 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -82,6 +82,25 @@ describe("deploy command (unified)", () => { t.expectResult(result).toContain("App deployed successfully"); }); + it("fails 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"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid function code"); + 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: [] }); diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 44af56b2..79b06c56 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -98,9 +98,10 @@ describe("functions deploy command", () => { const result = await t.run("functions", "deploy"); - t.expectResult(result).toSucceed(); + t.expectResult(result).toFail(); t.expectResult(result).toContain("error"); t.expectResult(result).toContain("1 error"); + t.expectResult(result).toContain("1 function failed to deploy"); }); it("reports validation error from 422 response", async () => { @@ -114,11 +115,12 @@ describe("functions deploy command", () => { const result = await t.run("functions", "deploy"); - t.expectResult(result).toSucceed(); + t.expectResult(result).toFail(); t.expectResult(result).toContain("error"); t.expectResult(result).toContain( "Minimum interval for minute-based schedules is 5 minutes.", ); + t.expectResult(result).toContain("1 function failed to deploy"); }); it("reports too-many-functions error from 422 response", async () => { @@ -132,11 +134,64 @@ describe("functions deploy command", () => { const result = await t.run("functions", "deploy"); - t.expectResult(result).toSucceed(); + t.expectResult(result).toFail(); t.expectResult(result).toContain("error"); t.expectResult(result).toContain( "Maximum of 50 functions per app reached.", ); + t.expectResult(result).toContain("1 function failed to deploy"); + }); + + it("prunes remote functions missing locally with --force", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); + t.api.mockFunctionsList({ + functions: [ + { + name: "stale-function", + deployment_id: "dep_123", + entry: "index.ts", + files: [], + automations: [], + }, + ], + }); + t.api.mockSingleFunctionDelete(); + + const result = await t.run("functions", "deploy", "--force"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Found 1 remote function to delete"); + t.expectResult(result).toContain("stale-function"); + t.expectResult(result).toContain("deleted"); + t.expectResult(result).toContain("1 deleted"); + }); + + it("does not prune remote functions when deploy fails with --force", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, + }); + t.api.mockFunctionsList({ + functions: [ + { + name: "stale-function", + deployment_id: "dep_123", + entry: "index.ts", + files: [], + automations: [], + }, + ], + }); + t.api.mockSingleFunctionDelete(); + + const result = await t.run("functions", "deploy", "--force"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("1 function failed to deploy"); + t.expectResult(result).toNotContain("Found 1 remote function to delete"); + t.expectResult(result).toNotContain("stale-function"); }); it("rejects --force with specific function names", async () => {