From 0c73214c74c434b64cd0ab5729f4361bfec56ea6 Mon Sep 17 00:00:00 2001 From: yardend Date: Sun, 8 Mar 2026 12:29:46 +0200 Subject: [PATCH 1/3] feat(functions): add `functions delete` command Add a new `functions delete` CLI command that deletes one or more deployed functions by name. Handles 404 (not found) gracefully and supports comma-separated name arguments. - Add deleteSingleFunction API client function - Create parseNames utility for variadic CLI args - Create functions/delete.ts command with multi-name support - Refactor functions command group with index.ts - Add test mocks and 5 tests for the delete command Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/delete.ts | 67 +++++++++++++++++++ .../cli/src/cli/commands/functions/deploy.ts | 24 +++---- .../cli/src/cli/commands/functions/index.ts | 11 +++ packages/cli/src/cli/program.ts | 4 +- packages/cli/src/cli/utils/parseNames.ts | 10 +++ .../cli/src/core/resources/function/api.ts | 12 ++++ .../cli/tests/cli/functions_delete.spec.ts | 62 +++++++++++++++++ .../cli/tests/cli/testkit/Base44APIMock.ts | 20 ++++++ 8 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/cli/commands/functions/delete.ts create mode 100644 packages/cli/src/cli/commands/functions/index.ts create mode 100644 packages/cli/src/cli/utils/parseNames.ts create mode 100644 packages/cli/tests/cli/functions_delete.spec.ts diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts new file mode 100644 index 00000000..14b3c16a --- /dev/null +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -0,0 +1,67 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, theme } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { parseNames } from "@/cli/utils/parseNames.js"; +import { ApiError, InvalidInputError } from "@/core/errors.js"; +import { deleteSingleFunction } from "@/core/resources/function/api.js"; + +async function deleteFunctionsAction( + names: string[], +): Promise { + let deleted = 0; + let notFound = 0; + let errors = 0; + let completed = 0; + const total = names.length; + + for (const name of names) { + log.step(theme.styles.dim(`[${completed + 1}/${total}] Deleting ${name}...`)); + try { + await deleteSingleFunction(name); + log.success(`${name.padEnd(25)} deleted`); + deleted++; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + log.warn(`${name.padEnd(25)} not found`); + notFound++; + } else { + log.error(`${name.padEnd(25)} error: ${error instanceof Error ? error.message : String(error)}`); + errors++; + } + } + completed++; + } + + if (names.length === 1) { + if (deleted) return { outroMessage: `Function "${names[0]}" deleted` }; + if (notFound) return { outroMessage: `Function "${names[0]}" not found` }; + return { outroMessage: `Failed to delete "${names[0]}"` }; + } + + const parts: string[] = []; + if (deleted > 0) parts.push(`${deleted}/${total} deleted`); + if (notFound > 0) parts.push(`${notFound} not found`); + if (errors > 0) parts.push(`${errors} error${errors !== 1 ? "s" : ""}`); + return { outroMessage: parts.join(", ") }; +} + +export function getDeleteCommand(context: CLIContext): Command { + return new Command("delete") + .description("Delete deployed functions") + .argument("", "Function names to delete") + .action(async (rawNames: string[]) => { + await runCommand( + () => { + const names = parseNames(rawNames); + if (names.length === 0) { + throw new InvalidInputError("At least one function name is required"); + } + return deleteFunctionsAction(names); + }, + { requireAuth: true }, + context, + ); + }); +} 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..30da6695 --- /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 { getDeleteCommand } from "./delete.js"; +import { getDeployCommand } from "./deploy.js"; + +export function getFunctionsCommand(context: CLIContext): Command { + return new Command("functions") + .description("Manage backend functions") + .addCommand(getDeployCommand(context)) + .addCommand(getDeleteCommand(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/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 43e0dbf8..8f213459 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -111,3 +111,15 @@ export async function fetchFunctionLogs( 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, `deleting function "${name}"`); + } +} diff --git a/packages/cli/tests/cli/functions_delete.spec.ts b/packages/cli/tests/cli/functions_delete.spec.ts new file mode 100644 index 00000000..1df93a02 --- /dev/null +++ b/packages/cli/tests/cli/functions_delete.spec.ts @@ -0,0 +1,62 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("functions delete command", () => { + const t = setupCLITests(); + + it("deletes a single function successfully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleFunctionDelete(); + + const result = await t.run("functions", "delete", "my-func"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("deleted"); + }); + + it("deletes multiple functions with summary", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleFunctionDelete(); + + const result = await t.run("functions", "delete", "func-a", "func-b"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deleting func-a"); + t.expectResult(result).toContain("Deleting func-b"); + t.expectResult(result).toContain("2/2 deleted"); + }); + + it("reports not found for non-existent function", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleFunctionDeleteError({ + status: 404, + body: { error: "Not found" }, + }); + + const result = await t.run("functions", "delete", "nonexistent"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("not found"); + }); + + it("reports API errors gracefully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockSingleFunctionDeleteError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("functions", "delete", "my-func"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); + }); + + 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", "delete", "my-func"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/testkit/Base44APIMock.ts b/packages/cli/tests/cli/testkit/Base44APIMock.ts index 9c68f21b..8b83d46e 100644 --- a/packages/cli/tests/cli/testkit/Base44APIMock.ts +++ b/packages/cli/tests/cli/testkit/Base44APIMock.ts @@ -189,6 +189,17 @@ export class Base44APIMock { return this; } + /** Mock DELETE /api/apps/{appId}/backend-functions/{name} - Delete single function */ + mockSingleFunctionDelete(): this { + this.handlers.push( + http.delete( + `${BASE_URL}/api/apps/${this.appId}/backend-functions/:name`, + () => new HttpResponse(null, { status: 204 }), + ), + ); + return this; + } + /** Mock POST /api/apps/{appId}/deploy-dist - Deploy site */ mockSiteDeploy(response: SiteDeployResponse): this { this.handlers.push( @@ -384,6 +395,15 @@ export class Base44APIMock { ); } + /** Mock single function delete to return an error */ + mockSingleFunctionDeleteError(error: ErrorResponse): this { + return this.mockError( + "delete", + `/api/apps/${this.appId}/backend-functions/:name`, + error, + ); + } + /** Mock site deploy to return an error */ mockSiteDeployError(error: ErrorResponse): this { return this.mockError("post", `/api/apps/${this.appId}/deploy-dist`, error); From a79cebb457e86cdb5d93861f9e6c65f3dcf9d575 Mon Sep 17 00:00:00 2001 From: yardend Date: Mon, 9 Mar 2026 10:27:07 +0200 Subject: [PATCH 2/3] fix: lint formatting Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/delete.ts | 14 +++++++---- .../cli/src/cli/commands/functions/deploy.ts | 6 +---- .../cli/src/core/resources/function/api.ts | 23 +++++++++---------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts index 14b3c16a..6e0bb951 100644 --- a/packages/cli/src/cli/commands/functions/delete.ts +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -2,8 +2,8 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { runCommand, theme } from "@/cli/utils/index.js"; -import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { parseNames } from "@/cli/utils/parseNames.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { ApiError, InvalidInputError } from "@/core/errors.js"; import { deleteSingleFunction } from "@/core/resources/function/api.js"; @@ -17,7 +17,9 @@ async function deleteFunctionsAction( const total = names.length; for (const name of names) { - log.step(theme.styles.dim(`[${completed + 1}/${total}] Deleting ${name}...`)); + log.step( + theme.styles.dim(`[${completed + 1}/${total}] Deleting ${name}...`), + ); try { await deleteSingleFunction(name); log.success(`${name.padEnd(25)} deleted`); @@ -27,7 +29,9 @@ async function deleteFunctionsAction( log.warn(`${name.padEnd(25)} not found`); notFound++; } else { - log.error(`${name.padEnd(25)} error: ${error instanceof Error ? error.message : String(error)}`); + log.error( + `${name.padEnd(25)} error: ${error instanceof Error ? error.message : String(error)}`, + ); errors++; } } @@ -56,7 +60,9 @@ export function getDeleteCommand(context: CLIContext): Command { () => { const names = parseNames(rawNames); if (names.length === 0) { - throw new InvalidInputError("At least one function name is required"); + throw new InvalidInputError( + "At least one function name is required", + ); } return deleteFunctionsAction(names); }, 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/api.ts b/packages/cli/src/core/resources/function/api.ts index 8f213459..1d4d0c8a 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -51,6 +51,17 @@ export async function deployFunctions( 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, `deleting function "${name}"`); + } +} + // ─── FUNCTION LOGS API ────────────────────────────────────── /** @@ -111,15 +122,3 @@ export async function fetchFunctionLogs( 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, `deleting function "${name}"`); - } -} From 1d149c90527301830336e2c82acd05d18cf67526 Mon Sep 17 00:00:00 2001 From: yardend Date: Tue, 10 Mar 2026 18:11:39 +0200 Subject: [PATCH 3/3] refactor: address review comments on functions delete - Move parseNames inline into delete.ts, remove separate utility file - Add preAction hook for input validation (following link.ts/create.ts pattern) - Wrap each deletion in runTask for spinner UX - Update test expectations to match new output Co-Authored-By: Claude Opus 4.6 --- .../cli/src/cli/commands/functions/delete.ts | 50 +++++++++---------- packages/cli/src/cli/utils/parseNames.ts | 10 ---- .../cli/tests/cli/functions_delete.spec.ts | 6 +-- .../cli/tests/cli/testkit/TestAPIServer.ts | 14 ------ 4 files changed, 28 insertions(+), 52 deletions(-) delete mode 100644 packages/cli/src/cli/utils/parseNames.ts diff --git a/packages/cli/src/cli/commands/functions/delete.ts b/packages/cli/src/cli/commands/functions/delete.ts index 6e0bb951..df1a995d 100644 --- a/packages/cli/src/cli/commands/functions/delete.ts +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -1,10 +1,8 @@ -import { log } from "@clack/prompts"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, theme } from "@/cli/utils/index.js"; -import { parseNames } from "@/cli/utils/parseNames.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import { ApiError, InvalidInputError } from "@/core/errors.js"; +import { ApiError } from "@/core/errors.js"; import { deleteSingleFunction } from "@/core/resources/function/api.js"; async function deleteFunctionsAction( @@ -13,29 +11,21 @@ async function deleteFunctionsAction( let deleted = 0; let notFound = 0; let errors = 0; - let completed = 0; - const total = names.length; for (const name of names) { - log.step( - theme.styles.dim(`[${completed + 1}/${total}] Deleting ${name}...`), - ); try { - await deleteSingleFunction(name); - log.success(`${name.padEnd(25)} deleted`); + await runTask(`Deleting ${name}...`, () => deleteSingleFunction(name), { + successMessage: `${name} deleted`, + errorMessage: `Failed to delete ${name}`, + }); deleted++; } catch (error) { if (error instanceof ApiError && error.statusCode === 404) { - log.warn(`${name.padEnd(25)} not found`); notFound++; } else { - log.error( - `${name.padEnd(25)} error: ${error instanceof Error ? error.message : String(error)}`, - ); errors++; } } - completed++; } if (names.length === 1) { @@ -44,6 +34,7 @@ async function deleteFunctionsAction( return { outroMessage: `Failed to delete "${names[0]}"` }; } + const total = names.length; const parts: string[] = []; if (deleted > 0) parts.push(`${deleted}/${total} deleted`); if (notFound > 0) parts.push(`${notFound} not found`); @@ -51,21 +42,30 @@ async function deleteFunctionsAction( return { outroMessage: parts.join(", ") }; } +/** Parse names from variadic CLI args, supporting comma-separated values. */ +function parseNames(args: string[]): string[] { + return args + .flatMap((arg) => arg.split(",")) + .map((n) => n.trim()) + .filter(Boolean); +} + +function validateNames(command: Command): void { + const names = parseNames(command.args); + if (names.length === 0) { + command.error("At least one function name is required"); + } +} + export function getDeleteCommand(context: CLIContext): Command { return new Command("delete") .description("Delete deployed functions") .argument("", "Function names to delete") + .hook("preAction", validateNames) .action(async (rawNames: string[]) => { + const names = parseNames(rawNames); await runCommand( - () => { - const names = parseNames(rawNames); - if (names.length === 0) { - throw new InvalidInputError( - "At least one function name is required", - ); - } - return deleteFunctionsAction(names); - }, + () => deleteFunctionsAction(names), { requireAuth: true }, context, ); diff --git a/packages/cli/src/cli/utils/parseNames.ts b/packages/cli/src/cli/utils/parseNames.ts deleted file mode 100644 index e6e63230..00000000 --- a/packages/cli/src/cli/utils/parseNames.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 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/tests/cli/functions_delete.spec.ts b/packages/cli/tests/cli/functions_delete.spec.ts index 1df93a02..167d4077 100644 --- a/packages/cli/tests/cli/functions_delete.spec.ts +++ b/packages/cli/tests/cli/functions_delete.spec.ts @@ -21,8 +21,8 @@ describe("functions delete command", () => { const result = await t.run("functions", "delete", "func-a", "func-b"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deleting func-a"); - t.expectResult(result).toContain("Deleting func-b"); + t.expectResult(result).toContain("func-a deleted"); + t.expectResult(result).toContain("func-b deleted"); t.expectResult(result).toContain("2/2 deleted"); }); @@ -49,7 +49,7 @@ describe("functions delete command", () => { const result = await t.run("functions", "delete", "my-func"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("error"); + t.expectResult(result).toContain("Failed to delete"); }); it("fails when not in a project directory", async () => { diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index a096ae11..32c914f8 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -292,13 +292,6 @@ export class TestAPIServer { } /** Mock GET /api/apps/{appId}/backend-functions - List deployed functions */ - mockFunctionsList(response: FunctionsListResponse): this { - return this.addRoute( - "GET", - `/api/apps/${this.appId}/backend-functions`, - response, - ); - } /** Mock DELETE /api/apps/{appId}/backend-functions/{name} - Delete single function */ mockSingleFunctionDelete(): this { @@ -461,13 +454,6 @@ export class TestAPIServer { } /** Mock functions list to return an error */ - mockFunctionsListError(error: ErrorResponse): this { - return this.addErrorRoute( - "GET", - `/api/apps/${this.appId}/backend-functions`, - error, - ); - } /** Mock single function delete to return an error */ mockSingleFunctionDeleteError(error: ErrorResponse): this {