From 77758f0d6c1dda5747f81155f66dc38ce275dc7e Mon Sep 17 00:00:00 2001 From: yardend Date: Sun, 8 Mar 2026 12:11:02 +0200 Subject: [PATCH 1/6] feat(functions): add `functions list` command Add a new `functions list` CLI command that lists all deployed functions on the remote, showing automation counts. Restructure the functions command group to use an index.ts that registers subcommands. - Add ListFunctionsResponseSchema and FunctionInfo types to schema - Add listDeployedFunctions API client function - Create functions/list.ts command with automation count display - Refactor functions/deploy.ts export to getDeployCommand - Create functions/index.ts to register deploy + list subcommands - Update program.ts to use getFunctionsCommand - Add test mocks and 5 tests for the list command Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/deploy.ts | 24 +++-- .../cli/src/cli/commands/functions/index.ts | 11 +++ .../cli/src/cli/commands/functions/list.ts | 38 ++++++++ packages/cli/src/cli/program.ts | 4 +- .../cli/src/core/resources/function/api.ts | 22 +++++ .../cli/src/core/resources/function/schema.ts | 20 +++++ packages/cli/tests/cli/functions_list.spec.ts | 89 +++++++++++++++++++ .../cli/tests/cli/testkit/Base44APIMock.ts | 29 ++++++ 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/cli/commands/functions/index.ts create mode 100644 packages/cli/src/cli/commands/functions/list.ts create mode 100644 packages/cli/tests/cli/functions_list.spec.ts diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index 4c89ac38..c87c7d8b 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -51,18 +51,14 @@ async function deployFunctionsAction(): Promise { return { outroMessage: "Functions deployed to Base44" }; } -export function getFunctionsDeployCommand(context: CLIContext): Command { - return new Command("functions") - .description("Manage project functions") - .addCommand( - new Command("deploy") - .description("Deploy local functions to Base44") - .action(async () => { - await runCommand( - deployFunctionsAction, - { requireAuth: true }, - context, - ); - }), - ); +export function getDeployCommand(context: CLIContext): Command { + return new Command("deploy") + .description("Deploy local functions to Base44") + .action(async () => { + await runCommand( + deployFunctionsAction, + { requireAuth: true }, + context, + ); + }); } diff --git a/packages/cli/src/cli/commands/functions/index.ts b/packages/cli/src/cli/commands/functions/index.ts new file mode 100644 index 00000000..8d7c7f0e --- /dev/null +++ b/packages/cli/src/cli/commands/functions/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getDeployCommand } from "./deploy.js"; +import { getListCommand } from "./list.js"; + +export function getFunctionsCommand(context: CLIContext): Command { + return new Command("functions") + .description("Manage backend functions") + .addCommand(getDeployCommand(context)) + .addCommand(getListCommand(context)); +} diff --git a/packages/cli/src/cli/commands/functions/list.ts b/packages/cli/src/cli/commands/functions/list.ts new file mode 100644 index 00000000..e8a09113 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/list.ts @@ -0,0 +1,38 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { theme } from "@/cli/utils/theme.js"; +import { listDeployedFunctions } from "@/core/resources/function/api.js"; + +async function listFunctionsAction(): Promise { + const { functions } = await listDeployedFunctions(); + + if (functions.length === 0) { + return { outroMessage: "No functions on remote" }; + } + + for (const fn of functions) { + const autoCount = fn.automations.length; + const autoLabel = + autoCount > 0 + ? theme.styles.dim( + ` (${autoCount} automation${autoCount > 1 ? "s" : ""})`, + ) + : ""; + log.message(` ${fn.name}${autoLabel}`); + } + + return { + outroMessage: `${functions.length} function${functions.length !== 1 ? "s" : ""} on remote`, + }; +} + +export function getListCommand(context: CLIContext): Command { + return new Command("list") + .description("List all deployed functions") + .action(async () => { + await runCommand(listFunctionsAction, { requireAuth: true }, context); + }); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index e83edde4..a6d4ec02 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -6,7 +6,7 @@ import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; -import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; +import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; @@ -55,7 +55,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getConnectorsCommand(context)); // Register functions commands - program.addCommand(getFunctionsDeployCommand(context)); + program.addCommand(getFunctionsCommand(context)); // Register secrets commands program.addCommand(getSecretsCommand(context)); diff --git a/packages/cli/src/core/resources/function/api.ts b/packages/cli/src/core/resources/function/api.ts index 43e0dbf8..9a71b976 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -6,10 +6,12 @@ import type { FunctionLogFilters, FunctionLogsResponse, FunctionWithCode, + ListFunctionsResponse, } from "@/core/resources/function/schema.js"; import { DeployFunctionsResponseSchema, FunctionLogsResponseSchema, + ListFunctionsResponseSchema, } from "@/core/resources/function/schema.js"; function toDeployPayloadItem(fn: FunctionWithCode) { @@ -111,3 +113,23 @@ 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/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 50f355cb..2b87f9a2 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -93,12 +93,32 @@ export const DeployFunctionsResponseSchema = z.object({ .nullable(), }); +const FunctionAutomationInfoSchema = z.object({ + name: z.string(), + type: z.string(), + is_active: z.boolean(), +}); + +const FunctionInfoSchema = z.object({ + name: z.string(), + deployment_id: z.string(), + entry: z.string(), + files: z.array(FunctionFileSchema), + automations: z.array(FunctionAutomationInfoSchema), +}); + +export const ListFunctionsResponseSchema = z.object({ + functions: z.array(FunctionInfoSchema), +}); + export type FunctionConfig = z.infer; export type BackendFunction = z.infer; export type FunctionFile = z.infer; export type DeployFunctionsResponse = z.infer< typeof DeployFunctionsResponseSchema >; +export type FunctionInfo = z.infer; +export type ListFunctionsResponse = z.infer; export type FunctionWithCode = Omit & { files: FunctionFile[]; diff --git a/packages/cli/tests/cli/functions_list.spec.ts b/packages/cli/tests/cli/functions_list.spec.ts new file mode 100644 index 00000000..6a39a1d8 --- /dev/null +++ b/packages/cli/tests/cli/functions_list.spec.ts @@ -0,0 +1,89 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("functions list command", () => { + const t = setupCLITests(); + + it("shows message when no functions deployed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ functions: [] }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No functions on remote"); + }); + + it("lists deployed functions", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "func-a", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [], + }, + { + name: "func-b", + deployment_id: "d2", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("func-a"); + t.expectResult(result).toContain("func-b"); + t.expectResult(result).toContain("2 functions on remote"); + }); + + it("shows automation count for functions with automations", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "func-a", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [ + { name: "auto1", type: "scheduled", is_active: true }, + ], + }, + ], + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("func-a"); + t.expectResult(result).toContain("1 automation"); + t.expectResult(result).toContain("1 function on remote"); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toFail(); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsListError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/testkit/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts index 9c68f21b..8a55f20e 100644 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ b/packages/cli/tests/cli/testkit/Base44APIMock.ts @@ -38,6 +38,16 @@ interface FunctionsPushResponse { errors: Array<{ name: string; message: string }> | null; } +interface FunctionsListResponse { + functions: Array<{ + name: string; + deployment_id: string; + entry: string; + files: Array<{ path: string; content: string }>; + automations: Array<{ name: string; type: string; is_active: boolean }>; + }>; +} + interface SiteDeployResponse { app_url: string; } @@ -189,6 +199,16 @@ export class Base44APIMock { return this; } + /** Mock GET /api/apps/{appId}/backend-functions - List deployed functions */ + mockFunctionsList(response: FunctionsListResponse): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}/backend-functions`, () => + HttpResponse.json(response), + ), + ); + return this; + } + /** Mock POST /api/apps/{appId}/deploy-dist - Deploy site */ mockSiteDeploy(response: SiteDeployResponse): this { this.handlers.push( @@ -384,6 +404,15 @@ export class Base44APIMock { ); } + /** Mock functions list to return an error */ + mockFunctionsListError(error: ErrorResponse): this { + return this.mockError( + "get", + `/api/apps/${this.appId}/backend-functions`, + error, + ); + } + /** Mock site deploy to return an error */ mockSiteDeployError(error: ErrorResponse): this { return this.mockError("post", `/api/apps/${this.appId}/deploy-dist`, error); From 5c77e6057afa9e343d73e53656dc4cf7ad8c393d Mon Sep 17 00:00:00 2001 From: yardend Date: Mon, 9 Mar 2026 10:24:10 +0200 Subject: [PATCH 2/6] fix: lint formatting and remove unused FunctionInfo type Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/cli/commands/functions/deploy.ts | 6 +----- packages/cli/src/core/resources/function/schema.ts | 1 - packages/cli/tests/cli/functions_list.spec.ts | 4 +--- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index c87c7d8b..9f57321a 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -55,10 +55,6 @@ export function getDeployCommand(context: CLIContext): Command { return new Command("deploy") .description("Deploy local functions to Base44") .action(async () => { - await runCommand( - deployFunctionsAction, - { requireAuth: true }, - context, - ); + await runCommand(deployFunctionsAction, { requireAuth: true }, context); }); } diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 2b87f9a2..711335a9 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -117,7 +117,6 @@ export type FunctionFile = z.infer; export type DeployFunctionsResponse = z.infer< typeof DeployFunctionsResponseSchema >; -export type FunctionInfo = z.infer; export type ListFunctionsResponse = z.infer; export type FunctionWithCode = Omit & { diff --git a/packages/cli/tests/cli/functions_list.spec.ts b/packages/cli/tests/cli/functions_list.spec.ts index 6a39a1d8..fc549f5e 100644 --- a/packages/cli/tests/cli/functions_list.spec.ts +++ b/packages/cli/tests/cli/functions_list.spec.ts @@ -52,9 +52,7 @@ describe("functions list command", () => { deployment_id: "d1", entry: "index.ts", files: [{ path: "index.ts", content: "" }], - automations: [ - { name: "auto1", type: "scheduled", is_active: true }, - ], + automations: [{ name: "auto1", type: "scheduled", is_active: true }], }, ], }); From 83f723c872fc49eb2808ef22802db999a24a3d21 Mon Sep 17 00:00:00 2001 From: yardend Date: Mon, 9 Mar 2026 11:59:15 +0200 Subject: [PATCH 3/6] feat(functions): rewrite deploy to per-function API with zero-config support Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/deploy.ts | 131 ++++++++++++----- .../cli/src/cli/utils/formatDeployResult.ts | 21 +++ packages/cli/src/cli/utils/parseNames.ts | 10 ++ .../cli/src/core/resources/function/api.ts | 88 ++++++------ .../cli/src/core/resources/function/deploy.ts | 96 +++++++++++-- .../src/core/resources/function/resource.ts | 4 +- .../cli/src/core/resources/function/schema.ts | 14 +- .../cli/tests/cli/functions_deploy.spec.ts | 134 ++++++++---------- .../cli/tests/cli/testkit/Base44APIMock.ts | 24 ++++ 9 files changed, 352 insertions(+), 170 deletions(-) create mode 100644 packages/cli/src/cli/utils/formatDeployResult.ts create mode 100644 packages/cli/src/cli/utils/parseNames.ts diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index 9f57321a..b96e6c74 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,16 +1,75 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask } from "@/cli/utils/index.js"; +import { formatDeployResult } from "@/cli/utils/formatDeployResult.js"; +import { runCommand } from "@/cli/utils/index.js"; +import { parseNames } from "@/cli/utils/parseNames.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 !== "error").length; + const failed = results.filter((r) => r.status === "error").length; + + const parts: string[] = []; + if (deployed > 0) parts.push(`${deployed}/${results.length} deployed`); + 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 +77,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/utils/formatDeployResult.ts b/packages/cli/src/cli/utils/formatDeployResult.ts new file mode 100644 index 00000000..5a4be23f --- /dev/null +++ b/packages/cli/src/cli/utils/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/utils/parseNames.ts b/packages/cli/src/cli/utils/parseNames.ts new file mode 100644 index 00000000..e6e63230 --- /dev/null +++ b/packages/cli/src/cli/utils/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 9a71b976..dfa04030 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -2,54 +2,74 @@ 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 function "${name}"`); + } + + const result = DeploySingleFunctionResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + return result.data; +} + +export async function deleteSingleFunction(name: string): Promise { + const appClient = getAppClient(); + try { + await appClient.delete(`backend-functions/${encodeURIComponent(name)}`, { + timeout: 60_000, }); } catch (error) { - throw await ApiError.fromHttpError(error, "deploying functions"); + throw await ApiError.fromHttpError(error, `deleting function "${name}"`); } +} - const result = DeployFunctionsResponseSchema.safeParse(await response.json()); +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; } @@ -113,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..f61c7856 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -1,34 +1,108 @@ 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"; import { readTextFile } from "@/core/utils/fs.js"; -async function loadFunctionCode( +export 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 711335a9..9b8a54cd 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 FunctionAutomationInfoSchema = z.object({ @@ -114,9 +109,10 @@ 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; export type FunctionWithCode = Omit & { diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 1fae5b8e..56801e6d 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/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/1 deployed"); }); 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/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/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts index 8a55f20e..3528d2cc 100644 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ b/packages/cli/tests/cli/testkit/Base44APIMock.ts @@ -38,6 +38,10 @@ interface FunctionsPushResponse { errors: Array<{ name: string; message: string }> | null; } +interface SingleFunctionDeployResponse { + status: "deployed" | "unchanged"; +} + interface FunctionsListResponse { functions: Array<{ name: string; @@ -209,6 +213,17 @@ export class Base44APIMock { return this; } + /** Mock PUT /api/apps/{appId}/backend-functions/{name} - Deploy single function */ + mockSingleFunctionDeploy(response: SingleFunctionDeployResponse): this { + this.handlers.push( + http.put( + `${BASE_URL}/api/apps/${this.appId}/backend-functions/:name`, + () => HttpResponse.json(response), + ), + ); + return this; + } + /** Mock POST /api/apps/{appId}/deploy-dist - Deploy site */ mockSiteDeploy(response: SiteDeployResponse): this { this.handlers.push( @@ -404,6 +419,15 @@ export class Base44APIMock { ); } + /** Mock single function deploy to return an error */ + mockSingleFunctionDeployError(error: ErrorResponse): this { + return this.mockError( + "put", + `/api/apps/${this.appId}/backend-functions/:name`, + error, + ); + } + /** Mock functions list to return an error */ mockFunctionsListError(error: ErrorResponse): this { return this.mockError( From 3e8e89b8c96dc9b18fdff2a13496397d079ecc6d Mon Sep 17 00:00:00 2001 From: yardend Date: Mon, 9 Mar 2026 12:04:41 +0200 Subject: [PATCH 4/6] feat(deploy): migrate unified deploy to per-function API Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/project/deploy.ts | 32 +++++++++++-------- packages/cli/src/core/project/deploy.ts | 17 ++++++++-- packages/cli/tests/cli/deploy.spec.ts | 32 +++++-------------- 3 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index da58b570..8971b97f 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -5,12 +5,8 @@ import { promptOAuthFlows, } from "@/cli/commands/connectors/oauth-prompt.js"; import type { CLIContext } from "@/cli/types.js"; -import { - getDashboardUrl, - runCommand, - runTask, - theme, -} from "@/cli/utils/index.js"; +import { formatDeployResult } from "@/cli/utils/formatDeployResult.js"; +import { getDashboardUrl, runCommand, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { deployAll, @@ -80,16 +76,24 @@ export async function deployAction( log.info(`Deploying:\n${summaryLines.join("\n")}`); } - const result = await runTask( - "Deploying your app...", - async () => { - return await deployAll(projectData); + // Deploy resources with per-function progress + let functionCompleted = 0; + const functionTotal = functions.length; + + const result = await deployAll(projectData, { + onFunctionStart: (names) => { + const label = names.length === 1 ? names[0] : `${names.length} functions`; + log.step( + theme.styles.dim( + `[${functionCompleted + 1}/${functionTotal}] Deploying ${label}...`, + ), + ); }, - { - successMessage: theme.colors.base44Orange("Deployment completed"), - errorMessage: "Deployment failed", + onFunctionResult: (r) => { + functionCompleted++; + formatDeployResult(r); }, - ); + }); // Handle connector OAuth flows const needsOAuth = filterPendingOAuth(result.connectorResults ?? []); diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 9d6bff02..3e34713d 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -6,7 +6,10 @@ import { pushConnectors, } from "@/core/resources/connector/index.js"; import { entityResource } from "@/core/resources/entity/index.js"; -import { functionResource } from "@/core/resources/function/index.js"; +import { + deployFunctionsSequentially, + type SingleFunctionDeployResult, +} from "@/core/resources/function/deploy.js"; import { deploySite } from "@/core/site/index.js"; /** @@ -40,19 +43,29 @@ interface DeployAllResult { connectorResults?: ConnectorSyncResult[]; } +export interface DeployAllOptions { + onFunctionStart?: (names: string[]) => void; + onFunctionResult?: (result: SingleFunctionDeployResult) => void; +} + /** * Deploys all project resources (entities, functions, agents, connectors, and site) to Base44. * * @param projectData - The project configuration and resources to deploy + * @param options - Optional progress callbacks for resource deployment * @returns The deployment result including app URL if site was deployed */ export async function deployAll( projectData: ProjectData, + options?: DeployAllOptions, ): Promise { const { project, entities, functions, agents, connectors } = projectData; await entityResource.push(entities); - await functionResource.push(functions); + await deployFunctionsSequentially(functions, { + onStart: options?.onFunctionStart, + onResult: options?.onFunctionResult, + }); await agentResource.push(agents); const { results: connectorResults } = await pushConnectors(connectors); diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index eab73e56..245a54a4 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -35,7 +35,6 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("App deployed successfully"); }); @@ -52,47 +51,39 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "--yes"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys entities and functions together", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("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: [] }); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys entities, functions, and site together", async () => { await t.givenLoggedInWithProject(fixture("full-project")); t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: ["hello"], deleted: [], errors: null }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockSiteDeploy({ app_url: "https://full-project.base44.app" }); @@ -100,14 +91,13 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); t.expectResult(result).toContain("https://full-project.base44.app"); }); it("deploys agents successfully with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: ["customer_support", "order_assistant", "data_analyst"], updated: [], @@ -118,14 +108,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("App deployed successfully"); }); it("deploys agents and entities together", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: ["customer_support"], updated: ["order_assistant"], @@ -136,13 +124,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys connectors successfully with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockConnectorSet({ @@ -154,14 +141,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("3 connectors"); }); it("shows OAuth info when connectors need authorization with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockConnectorSet({ @@ -173,7 +158,6 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("require authorization"); t.expectResult(result).toContain("base44 connectors push"); }); From 0777843355eb41ad0a48bb1612bb3c0dd31e7b6d Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 10 Mar 2026 15:28:08 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20camelCase=20transforms,=20runTask=20?= =?UTF-8?q?spinner,=20rename=20auto=E2=86=92automation=20vars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/list.ts | 16 ++++---- .../cli/src/core/resources/function/schema.ts | 40 +++++++++++++------ 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/cli/commands/functions/list.ts b/packages/cli/src/cli/commands/functions/list.ts index e8a09113..ab8bd45d 100644 --- a/packages/cli/src/cli/commands/functions/list.ts +++ b/packages/cli/src/cli/commands/functions/list.ts @@ -1,27 +1,29 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand } from "@/cli/utils/index.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { theme } from "@/cli/utils/theme.js"; import { listDeployedFunctions } from "@/core/resources/function/api.js"; async function listFunctionsAction(): Promise { - const { functions } = await listDeployedFunctions(); + const { functions } = await runTask("Fetching functions...", async () => + listDeployedFunctions(), + ); if (functions.length === 0) { return { outroMessage: "No functions on remote" }; } for (const fn of functions) { - const autoCount = fn.automations.length; - const autoLabel = - autoCount > 0 + const automationCount = fn.automations.length; + const automationLabel = + automationCount > 0 ? theme.styles.dim( - ` (${autoCount} automation${autoCount > 1 ? "s" : ""})`, + ` (${automationCount} automation${automationCount > 1 ? "s" : ""})`, ) : ""; - log.message(` ${fn.name}${autoLabel}`); + log.message(` ${fn.name}${automationLabel}`); } return { diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 711335a9..854f57bb 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -93,19 +93,33 @@ export const DeployFunctionsResponseSchema = z.object({ .nullable(), }); -const FunctionAutomationInfoSchema = z.object({ - name: z.string(), - type: z.string(), - is_active: z.boolean(), -}); - -const FunctionInfoSchema = z.object({ - name: z.string(), - deployment_id: z.string(), - entry: z.string(), - files: z.array(FunctionFileSchema), - automations: z.array(FunctionAutomationInfoSchema), -}); +const FunctionAutomationInfoSchema = z + .object({ + name: z.string(), + type: z.string(), + is_active: z.boolean(), + }) + .transform((data) => ({ + name: data.name, + type: data.type, + isActive: data.is_active, + })); + +const FunctionInfoSchema = z + .object({ + name: z.string(), + deployment_id: z.string(), + entry: z.string(), + files: z.array(FunctionFileSchema), + automations: z.array(FunctionAutomationInfoSchema), + }) + .transform((data) => ({ + name: data.name, + deploymentId: data.deployment_id, + entry: data.entry, + files: data.files, + automations: data.automations, + })); export const ListFunctionsResponseSchema = z.object({ functions: z.array(FunctionInfoSchema), From fe03e42097dd2197bae3ab9d2d043606f40aaae1 Mon Sep 17 00:00:00 2001 From: yardend Date: Thu, 12 Mar 2026 15:55:29 +0200 Subject: [PATCH 6/6] resolved comments --- packages/cli/src/cli/commands/functions/deploy.ts | 10 ++++++---- .../functions}/formatDeployResult.ts | 0 .../cli/{utils => commands/functions}/parseNames.ts | 0 packages/cli/src/core/resources/function/deploy.ts | 2 +- packages/cli/tests/cli/functions_deploy.spec.ts | 6 +++--- packages/cli/tests/cli/testkit/TestAPIServer.ts | 8 -------- 6 files changed, 10 insertions(+), 16 deletions(-) rename packages/cli/src/cli/{utils => commands/functions}/formatDeployResult.ts (100%) rename packages/cli/src/cli/{utils => commands/functions}/parseNames.ts (100%) diff --git a/packages/cli/src/cli/commands/functions/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index b96e6c74..34a21296 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,9 +1,9 @@ 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 { formatDeployResult } from "@/cli/utils/formatDeployResult.js"; import { runCommand } from "@/cli/utils/index.js"; -import { parseNames } from "@/cli/utils/parseNames.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { theme } from "@/cli/utils/theme.js"; import { InvalidInputError } from "@/core/errors.js"; @@ -47,11 +47,13 @@ function formatPruneResults(pruneResults: PruneResult[]): void { } function buildDeploySummary(results: SingleFunctionDeployResult[]): string { - const deployed = results.filter((r) => r.status !== "error").length; + 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}/${results.length} deployed`); + 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"; } diff --git a/packages/cli/src/cli/utils/formatDeployResult.ts b/packages/cli/src/cli/commands/functions/formatDeployResult.ts similarity index 100% rename from packages/cli/src/cli/utils/formatDeployResult.ts rename to packages/cli/src/cli/commands/functions/formatDeployResult.ts diff --git a/packages/cli/src/cli/utils/parseNames.ts b/packages/cli/src/cli/commands/functions/parseNames.ts similarity index 100% rename from packages/cli/src/cli/utils/parseNames.ts rename to packages/cli/src/cli/commands/functions/parseNames.ts diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index f61c7856..10964756 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -11,7 +11,7 @@ import type { } from "@/core/resources/function/schema.js"; import { readTextFile } from "@/core/utils/fs.js"; -export async function loadFunctionCode( +async function loadFunctionCode( fn: BackendFunction, ): Promise { const functionDir = dirname(fn.entryPath); diff --git a/packages/cli/tests/cli/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 56801e6d..44af56b2 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -31,7 +31,7 @@ describe("functions deploy command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Deploying process-order"); t.expectResult(result).toContain("deployed"); - t.expectResult(result).toContain("1/1 deployed"); + t.expectResult(result).toContain("1 deployed"); }); it("reports unchanged function", async () => { @@ -42,7 +42,7 @@ describe("functions deploy command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("unchanged"); - t.expectResult(result).toContain("1/1 deployed"); + t.expectResult(result).toContain("1 unchanged"); }); it("deploys zero-config and path-named functions successfully", async () => { @@ -77,7 +77,7 @@ describe("functions deploy command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Deploying process-order"); - t.expectResult(result).toContain("1/1 deployed"); + t.expectResult(result).toContain("1 deployed"); }); it("fails when function name not found in project", async () => { diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 7692e362..12abb610 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -452,14 +452,6 @@ export class TestAPIServer { ); } - mockFunctionsPushError(error: ErrorResponse): this { - return this.addErrorRoute( - "PUT", - `/api/apps/${this.appId}/backend-functions`, - error, - ); - } - mockFunctionsListError(error: ErrorResponse): this { return this.addErrorRoute( "GET",