From 77758f0d6c1dda5747f81155f66dc38ce275dc7e Mon Sep 17 00:00:00 2001 From: yardend Date: Sun, 8 Mar 2026 12:11:02 +0200 Subject: [PATCH 1/5] 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/5] 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 0777843355eb41ad0a48bb1612bb3c0dd31e7b6d Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 10 Mar 2026 15:28:08 +0200 Subject: [PATCH 3/5] =?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 e8fa31eb3951236b34e02e005a1b251aa19b02cc Mon Sep 17 00:00:00 2001 From: yardend Date: Wed, 11 Mar 2026 13:03:45 +0200 Subject: [PATCH 4/5] fix: use full AutomationSchema for list endpoint response parsing Replace FunctionAutomationInfoSchema (3-field summary) with the existing full AutomationSchema so list response correctly parses all automation fields. Add entity automation to test mock alongside scheduled. Co-Authored-By: Claude Opus 4.6 --- .../cli/src/core/resources/function/schema.ts | 14 +------------ packages/cli/tests/cli/functions_list.spec.ts | 20 +++++++++++++++++-- .../cli/tests/cli/testkit/TestAPIServer.ts | 2 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 854f57bb..d62d6f1e 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -93,25 +93,13 @@ export const DeployFunctionsResponseSchema = z.object({ .nullable(), }); -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), + automations: z.array(AutomationSchema), }) .transform((data) => ({ name: data.name, diff --git a/packages/cli/tests/cli/functions_list.spec.ts b/packages/cli/tests/cli/functions_list.spec.ts index fc549f5e..9487a1f7 100644 --- a/packages/cli/tests/cli/functions_list.spec.ts +++ b/packages/cli/tests/cli/functions_list.spec.ts @@ -52,7 +52,23 @@ 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: "daily-cron", + type: "scheduled", + schedule_mode: "recurring", + schedule_type: "cron", + cron_expression: "0 9 * * *", + is_active: true, + }, + { + name: "on-order-create", + type: "entity", + entity_name: "orders", + event_types: ["create", "update"], + is_active: true, + }, + ], }, ], }); @@ -61,7 +77,7 @@ describe("functions list command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("func-a"); - t.expectResult(result).toContain("1 automation"); + t.expectResult(result).toContain("2 automations"); t.expectResult(result).toContain("1 function on remote"); }); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index f143513c..135b7ae1 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -81,7 +81,7 @@ interface FunctionsListResponse { deployment_id: string; entry: string; files: Array<{ path: string; content: string }>; - automations: Array<{ name: string; type: string; is_active: boolean }>; + automations: Record[]; }>; } From 296e08619bb4b1335de7b1f241dcb8902927cd3b Mon Sep 17 00:00:00 2001 From: yardend Date: Thu, 12 Mar 2026 14:53:09 +0200 Subject: [PATCH 5/5] fix: add errorMessage to runTask in functions list command Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/cli/commands/functions/list.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/functions/list.ts b/packages/cli/src/cli/commands/functions/list.ts index ab8bd45d..e0cae503 100644 --- a/packages/cli/src/cli/commands/functions/list.ts +++ b/packages/cli/src/cli/commands/functions/list.ts @@ -7,8 +7,10 @@ import { theme } from "@/cli/utils/theme.js"; import { listDeployedFunctions } from "@/core/resources/function/api.js"; async function listFunctionsAction(): Promise { - const { functions } = await runTask("Fetching functions...", async () => - listDeployedFunctions(), + const { functions } = await runTask( + "Fetching functions...", + async () => listDeployedFunctions(), + { errorMessage: "Failed to fetch functions" }, ); if (functions.length === 0) {