diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index 9f57321a..34a21296 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,16 +1,77 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; +import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; +import { parseNames } from "@/cli/commands/functions/parseNames.js"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; +import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { ApiError } from "@/core/errors.js"; +import { theme } from "@/cli/utils/theme.js"; +import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; -import { pushFunctions } from "@/core/resources/function/index.js"; +import { + deployFunctionsSequentially, + type PruneResult, + pruneRemovedFunctions, + type SingleFunctionDeployResult, +} from "@/core/resources/function/deploy.js"; +import type { BackendFunction } from "@/core/resources/function/schema.js"; + +function resolveFunctionsToDeploy( + names: string[], + allFunctions: BackendFunction[], +): BackendFunction[] { + if (names.length === 0) return allFunctions; + + const notFound = names.filter((n) => !allFunctions.some((f) => f.name === n)); + if (notFound.length > 0) { + throw new InvalidInputError( + `Function${notFound.length > 1 ? "s" : ""} not found in project: ${notFound.join(", ")}`, + ); + } + return allFunctions.filter((f) => names.includes(f.name)); +} + +function formatPruneResults(pruneResults: PruneResult[]): void { + for (const pruneResult of pruneResults) { + if (pruneResult.deleted) { + log.success(`${pruneResult.name.padEnd(25)} deleted`); + } else { + log.error(`${pruneResult.name.padEnd(25)} error: ${pruneResult.error}`); + } + } + + if (pruneResults.length > 0) { + const pruned = pruneResults.filter((r) => r.deleted).length; + log.info(`${pruned} function${pruned !== 1 ? "s" : ""} removed`); + } +} + +function buildDeploySummary(results: SingleFunctionDeployResult[]): string { + const deployed = results.filter((r) => r.status === "deployed").length; + const unchanged = results.filter((r) => r.status === "unchanged").length; + const failed = results.filter((r) => r.status === "error").length; + + const parts: string[] = []; + if (deployed > 0) parts.push(`${deployed} deployed`); + if (unchanged > 0) parts.push(`${unchanged} unchanged`); + if (failed > 0) parts.push(`${failed} error${failed !== 1 ? "s" : ""}`); + return parts.join(", ") || "No functions deployed"; +} + +async function deployFunctionsAction( + names: string[], + options: { force?: boolean }, +): Promise { + if (options.force && names.length > 0) { + throw new InvalidInputError( + "--force cannot be used when specifying function names", + ); + } -async function deployFunctionsAction(): Promise { const { functions } = await readProjectConfig(); + const toDeploy = resolveFunctionsToDeploy(names, functions); - if (functions.length === 0) { + if (toDeploy.length === 0) { return { outroMessage: "No functions found. Create functions in the 'functions' directory.", @@ -18,43 +79,51 @@ async function deployFunctionsAction(): Promise { } log.info( - `Found ${functions.length} ${functions.length === 1 ? "function" : "functions"} to deploy`, + `Found ${toDeploy.length} ${toDeploy.length === 1 ? "function" : "functions"} to deploy`, ); - const result = await runTask( - "Deploying functions to Base44", - async () => { - return await pushFunctions(functions); + let completed = 0; + const total = toDeploy.length; + + const results = await deployFunctionsSequentially(toDeploy, { + onStart: (startNames) => { + const label = + startNames.length === 1 + ? startNames[0] + : `${startNames.length} functions`; + log.step( + theme.styles.dim(`[${completed + 1}/${total}] Deploying ${label}...`), + ); }, - { - successMessage: "Functions deployed successfully", - errorMessage: "Failed to deploy functions", + onResult: (result) => { + completed++; + formatDeployResult(result); }, - ); + }); - if (result.deployed.length > 0) { - log.success(`Deployed: ${result.deployed.join(", ")}`); - } - if (result.deleted.length > 0) { - log.warn(`Deleted: ${result.deleted.join(", ")}`); - } - if (result.errors && result.errors.length > 0) { - throw new ApiError("Function deployment errors", { - details: result.errors.map((e) => `'${e.name}': ${e.message}`), - hints: [ - { message: "Check the function code for syntax errors" }, - { message: "Ensure all imports are valid" }, - ], - }); + if (options.force) { + log.info("Removing remote functions not found locally..."); + const allLocalNames = functions.map((f) => f.name); + const pruneResults = await pruneRemovedFunctions(allLocalNames); + formatPruneResults(pruneResults); } - return { outroMessage: "Functions deployed to Base44" }; + return { outroMessage: buildDeploySummary(results) }; } export function getDeployCommand(context: CLIContext): Command { return new Command("deploy") - .description("Deploy local functions to Base44") - .action(async () => { - await runCommand(deployFunctionsAction, { requireAuth: true }, context); + .description("Deploy functions to Base44") + .argument("[names...]", "Function names to deploy (deploys all if omitted)") + .option("--force", "Delete remote functions not found locally") + .action(async (rawNames: string[], options: { force?: boolean }) => { + await runCommand( + () => { + const names = parseNames(rawNames); + return deployFunctionsAction(names, options); + }, + { requireAuth: true }, + context, + ); }); } diff --git a/packages/cli/src/cli/commands/functions/formatDeployResult.ts b/packages/cli/src/cli/commands/functions/formatDeployResult.ts new file mode 100644 index 00000000..5a4be23f --- /dev/null +++ b/packages/cli/src/cli/commands/functions/formatDeployResult.ts @@ -0,0 +1,21 @@ +import { log } from "@clack/prompts"; +import { theme } from "@/cli/utils/theme.js"; +import type { SingleFunctionDeployResult } from "@/core/resources/function/deploy.js"; + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +export function formatDeployResult(result: SingleFunctionDeployResult): void { + const label = result.name.padEnd(25); + if (result.status === "deployed") { + const timing = result.durationMs + ? theme.styles.dim(` (${formatDuration(result.durationMs)})`) + : ""; + log.success(`${label} deployed${timing}`); + } else if (result.status === "unchanged") { + log.success(`${label} unchanged`); + } else { + log.error(`${label} error: ${result.error}`); + } +} diff --git a/packages/cli/src/cli/commands/functions/parseNames.ts b/packages/cli/src/cli/commands/functions/parseNames.ts new file mode 100644 index 00000000..e6e63230 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/parseNames.ts @@ -0,0 +1,10 @@ +/** + * Parse names from variadic CLI args, supporting comma-separated values. + * e.g. ["fn-a", "fn-b,fn-c"] → ["fn-a", "fn-b", "fn-c"] + */ +export function parseNames(args: string[]): string[] { + return args + .flatMap((arg) => arg.split(",")) + .map((n) => n.trim()) + .filter(Boolean); +} diff --git a/packages/cli/src/core/resources/function/api.ts b/packages/cli/src/core/resources/function/api.ts index 22c88ae0..dfa04030 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -2,54 +2,43 @@ import type { KyResponse } from "ky"; import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { - DeployFunctionsResponse, + DeploySingleFunctionResponse, + FunctionFile, FunctionLogFilters, FunctionLogsResponse, - FunctionWithCode, ListFunctionsResponse, } from "@/core/resources/function/schema.js"; import { - DeployFunctionsResponseSchema, + DeploySingleFunctionResponseSchema, FunctionLogsResponseSchema, ListFunctionsResponseSchema, } from "@/core/resources/function/schema.js"; -function toDeployPayloadItem(fn: FunctionWithCode) { - return { - name: fn.name, - entry: fn.entry, - files: fn.files, - automations: fn.automations, - }; -} - -export async function deployFunctions( - functions: FunctionWithCode[], -): Promise { +export async function deploySingleFunction( + name: string, + payload: { entry: string; files: FunctionFile[]; automations?: unknown[] }, +): Promise { const appClient = getAppClient(); - const payload = { - functions: functions.map(toDeployPayloadItem), - }; let response: KyResponse; try { - response = await appClient.put("backend-functions", { - json: payload, - timeout: false, - }); + response = await appClient.put( + `backend-functions/${encodeURIComponent(name)}`, + { json: payload, timeout: false }, + ); } catch (error) { - throw await ApiError.fromHttpError(error, "deploying functions"); + throw await ApiError.fromHttpError(error, `deploying function "${name}"`); } - const result = DeployFunctionsResponseSchema.safeParse(await response.json()); - + const result = DeploySingleFunctionResponseSchema.safeParse( + await response.json(), + ); if (!result.success) { throw new SchemaValidationError( "Invalid response from server", result.error, ); } - return result.data; } @@ -64,6 +53,26 @@ export async function deleteSingleFunction(name: string): Promise { } } +export async function listDeployedFunctions(): Promise { + const appClient = getAppClient(); + + let response: KyResponse; + try { + response = await appClient.get("backend-functions", { timeout: 30_000 }); + } catch (error) { + throw await ApiError.fromHttpError(error, "listing deployed functions"); + } + + const result = ListFunctionsResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + // ─── FUNCTION LOGS API ────────────────────────────────────── /** @@ -124,23 +133,3 @@ export async function fetchFunctionLogs( return result.data; } - -export async function listDeployedFunctions(): Promise { - const appClient = getAppClient(); - - let response: KyResponse; - try { - response = await appClient.get("backend-functions", { timeout: 30_000 }); - } catch (error) { - throw await ApiError.fromHttpError(error, "listing deployed functions"); - } - - const result = ListFunctionsResponseSchema.safeParse(await response.json()); - if (!result.success) { - throw new SchemaValidationError( - "Invalid response from server", - result.error, - ); - } - return result.data; -} diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index d592a785..10964756 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -1,8 +1,11 @@ import { dirname, relative } from "node:path"; -import { deployFunctions } from "@/core/resources/function/api.js"; +import { + deleteSingleFunction, + deploySingleFunction, + listDeployedFunctions, +} from "@/core/resources/function/api.js"; import type { BackendFunction, - DeployFunctionsResponse, FunctionFile, FunctionWithCode, } from "@/core/resources/function/schema.js"; @@ -12,23 +15,94 @@ async function loadFunctionCode( fn: BackendFunction, ): Promise { const functionDir = dirname(fn.entryPath); - const loadedFiles: FunctionFile[] = await Promise.all( + const resolvedFiles: FunctionFile[] = await Promise.all( fn.filePaths.map(async (filePath) => { const content = await readTextFile(filePath); const path = relative(functionDir, filePath).split(/[/\\]/).join("/"); return { path, content }; }), ); - return { ...fn, files: loadedFiles }; + return { ...fn, files: resolvedFiles }; } -export async function pushFunctions( +export interface SingleFunctionDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + +async function deployOne( + fn: BackendFunction, +): Promise { + const start = Date.now(); + try { + const functionWithCode = await loadFunctionCode(fn); + const response = await deploySingleFunction(functionWithCode.name, { + entry: functionWithCode.entry, + files: functionWithCode.files, + automations: functionWithCode.automations, + }); + return { + name: functionWithCode.name, + status: response.status, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name: fn.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function deployFunctionsSequentially( functions: BackendFunction[], -): Promise { - if (functions.length === 0) { - return { deployed: [], deleted: [], skipped: [], errors: null }; + options?: { + onStart?: (names: string[]) => void; + onResult?: (result: SingleFunctionDeployResult) => void; + }, +): Promise { + if (functions.length === 0) return []; + + const results: SingleFunctionDeployResult[] = []; + for (const fn of functions) { + options?.onStart?.([fn.name]); + const result = await deployOne(fn); + results.push(result); + options?.onResult?.(result); + } + return results; +} + +export interface PruneResult { + name: string; + deleted: boolean; + error?: string; +} + +export async function pruneRemovedFunctions( + localFunctionNames: string[], +): Promise { + const remote = await listDeployedFunctions(); + const localSet = new Set(localFunctionNames); + const toDelete = remote.functions.filter((f) => !localSet.has(f.name)); + + const results: PruneResult[] = []; + + for (const fn of toDelete) { + try { + await deleteSingleFunction(fn.name); + results.push({ name: fn.name, deleted: true }); + } catch (error) { + results.push({ + name: fn.name, + deleted: false, + error: error instanceof Error ? error.message : String(error), + }); + } } - const functionsWithCode = await Promise.all(functions.map(loadFunctionCode)); - return deployFunctions(functionsWithCode); + return results; } diff --git a/packages/cli/src/core/resources/function/resource.ts b/packages/cli/src/core/resources/function/resource.ts index 11f448cc..8cf9815d 100644 --- a/packages/cli/src/core/resources/function/resource.ts +++ b/packages/cli/src/core/resources/function/resource.ts @@ -1,9 +1,9 @@ import { readAllFunctions } from "@/core/resources/function/config.js"; -import { pushFunctions } from "@/core/resources/function/deploy.js"; +import { deployFunctionsSequentially } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; import type { Resource } from "@/core/resources/types.js"; export const functionResource: Resource = { readAll: readAllFunctions, - push: pushFunctions, + push: (functions) => deployFunctionsSequentially(functions), }; diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 220f7c87..7d3a5ae2 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -84,13 +84,8 @@ const BackendFunctionSchema = FunctionConfigSchema.extend({ filePaths: z.array(z.string()).min(1, "Function must have at least one file"), }); -export const DeployFunctionsResponseSchema = z.object({ - deployed: z.array(z.string()), - deleted: z.array(z.string()), - skipped: z.array(z.string()).optional().nullable(), - errors: z - .array(z.object({ name: z.string(), message: z.string() })) - .nullable(), +export const DeploySingleFunctionResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), }); const FunctionInfoSchema = z @@ -116,8 +111,8 @@ export const ListFunctionsResponseSchema = z.object({ export type FunctionConfig = z.infer; export type BackendFunction = z.infer; export type FunctionFile = z.infer; -export type DeployFunctionsResponse = z.infer< - typeof DeployFunctionsResponseSchema +export type DeploySingleFunctionResponse = z.infer< + typeof DeploySingleFunctionResponseSchema >; export type FunctionInfo = z.infer; export type ListFunctionsResponse = z.infer; diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 1fae5b8e..44af56b2 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -24,144 +24,134 @@ describe("functions deploy command", () => { it("deploys functions successfully", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); + + const result = await t.run("functions", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying process-order"); + t.expectResult(result).toContain("deployed"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("reports unchanged function", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeploy({ status: "unchanged" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Functions deployed successfully"); - t.expectResult(result).toContain("Deployed: process-order"); + t.expectResult(result).toContain("unchanged"); + t.expectResult(result).toContain("1 unchanged"); }); it("deploys zero-config and path-named functions successfully", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Functions deployed successfully"); - t.expectResult(result).toContain("Deployed: foo/bar"); + t.expectResult(result).toContain("foo/bar"); t.expectResult(result).toContain("foo/kfir/hello"); t.expectResult(result).toContain("custom-name"); + t.expectResult(result).toContain("stam"); }); it("does not collect zero-config functions whose path contains a dot", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployed: foo/bar"); t.expectResult(result).toNotContain("all.products"); t.expectResult(result).toNotContain("getProducts/all.products"); }); - it("fails when API returns error", async () => { + it("deploys specific function by name", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ - status: 400, - body: { error: "Invalid function code" }, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); - const result = await t.run("functions", "deploy"); + const result = await t.run("functions", "deploy", "process-order"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying process-order"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("fails when function name not found in project", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + + const result = await t.run("functions", "deploy", "nonexistent"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid function code"); + t.expectResult(result).toContain("not found in project"); }); - it("shows per-function errors from successful response with errors array", async () => { + it("reports error when API fails for a function", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: [], - deleted: [], - errors: [{ name: "process-order", message: "Syntax error on line 42" }], + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); - t.expectResult(result).toContain( - "'process-order': Syntax error on line 42", - ); - t.expectResult(result).toContain( - "Check the function code for syntax errors", - ); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); + t.expectResult(result).toContain("1 error"); }); - it("shows validation errors from 422 response with extra_data.errors", async () => { + it("reports validation error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ + t.api.mockSingleFunctionDeployError({ status: 422, body: { - message: "Validation failed", - detail: null, - extra_data: { - errors: [ - { - name: "myFunc", - message: - "Minimum interval for minute-based schedules is 5 minutes.", - }, - ], - }, + message: "Minimum interval for minute-based schedules is 5 minutes.", }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); - t.expectResult(result).toContain("Validation failed"); - t.expectResult(result).toContain("myFunc"); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); t.expectResult(result).toContain( "Minimum interval for minute-based schedules is 5 minutes.", ); }); - it("shows too-many-functions error from 422 response", async () => { + it("reports too-many-functions error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ + t.api.mockSingleFunctionDeployError({ status: 422, body: { - message: "Too many functions. Maximum is 50, got 51.", - detail: null, + message: "Maximum of 50 functions per app reached.", }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); t.expectResult(result).toContain( - "Too many functions. Maximum is 50, got 51.", + "Maximum of 50 functions per app reached.", ); - t.expectResult(result).toContain("validation error"); }); - it("shows deployed and deleted functions together", async () => { + it("rejects --force with specific function names", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: ["old-handler"], - errors: null, - }); - const result = await t.run("functions", "deploy"); + const result = await t.run( + "functions", + "deploy", + "process-order", + "--force", + ); - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployed: process-order"); - t.expectResult(result).toContain("Deleted: old-handler"); + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--force cannot be used when specifying function names", + ); }); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index e1c33dd7..a34f9d94 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -75,13 +75,17 @@ interface SecretsDeleteResponse { success: boolean; } +interface SingleFunctionDeployResponse { + status: "deployed" | "unchanged"; +} + interface FunctionsListResponse { functions: Array<{ name: string; deployment_id: string; entry: string; files: Array<{ path: string; content: string }>; - automations: Record[]; + automations: Array<{ name: string; type: string; is_active: boolean }>; }>; } @@ -325,6 +329,15 @@ export class TestAPIServer { return this; } + /** Mock PUT /api/apps/{appId}/backend-functions/{name} - Deploy single function */ + mockSingleFunctionDeploy(response: SingleFunctionDeployResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/backend-functions/:name`, + response, + ); + } + mockSiteDeploy(response: SiteDeployResponse): this { return this.addRoute( "POST", @@ -491,18 +504,19 @@ export class TestAPIServer { ); } - mockFunctionsPushError(error: ErrorResponse): this { + mockFunctionsListError(error: ErrorResponse): this { return this.addErrorRoute( - "PUT", + "GET", `/api/apps/${this.appId}/backend-functions`, error, ); } - mockFunctionsListError(error: ErrorResponse): this { + /** Mock single function deploy to return an error */ + mockSingleFunctionDeployError(error: ErrorResponse): this { return this.addErrorRoute( - "GET", - `/api/apps/${this.appId}/backend-functions`, + "PUT", + `/api/apps/${this.appId}/backend-functions/:name`, error, ); }