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..df1a995d --- /dev/null +++ b/packages/cli/src/cli/commands/functions/delete.ts @@ -0,0 +1,73 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { ApiError } 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; + + for (const name of names) { + try { + await runTask(`Deleting ${name}...`, () => deleteSingleFunction(name), { + successMessage: `${name} deleted`, + errorMessage: `Failed to delete ${name}`, + }); + deleted++; + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + notFound++; + } else { + errors++; + } + } + } + + 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 total = names.length; + 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(", ") }; +} + +/** 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( + () => deleteFunctionsAction(names), + { requireAuth: true }, + context, + ); + }); +} diff --git a/packages/cli/src/cli/commands/functions/index.ts b/packages/cli/src/cli/commands/functions/index.ts index 8d7c7f0e..d5c3b831 100644 --- a/packages/cli/src/cli/commands/functions/index.ts +++ b/packages/cli/src/cli/commands/functions/index.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; +import { getDeleteCommand } from "./delete.js"; import { getDeployCommand } from "./deploy.js"; import { getListCommand } from "./list.js"; @@ -7,5 +8,6 @@ export function getFunctionsCommand(context: CLIContext): Command { return new Command("functions") .description("Manage backend functions") .addCommand(getDeployCommand(context)) + .addCommand(getDeleteCommand(context)) .addCommand(getListCommand(context)); } diff --git a/packages/cli/src/core/resources/function/api.ts b/packages/cli/src/core/resources/function/api.ts index 9a71b976..22c88ae0 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -53,6 +53,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 ────────────────────────────────────── /** 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..167d4077 --- /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("func-a deleted"); + t.expectResult(result).toContain("func-b deleted"); + 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("Failed to delete"); + }); + + 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/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 1465bcec..e1c33dd7 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -313,6 +313,18 @@ export class TestAPIServer { ); } + /** Mock DELETE /api/apps/{appId}/backend-functions/{name} - Delete single function */ + mockSingleFunctionDelete(): this { + this.pendingRoutes.push({ + method: "DELETE", + path: `/api/apps/${this.appId}/backend-functions/:name`, + handler: (_req, res) => { + res.status(204).end(); + }, + }); + return this; + } + mockSiteDeploy(response: SiteDeployResponse): this { return this.addRoute( "POST", @@ -495,6 +507,15 @@ export class TestAPIServer { ); } + /** Mock single function delete to return an error */ + mockSingleFunctionDeleteError(error: ErrorResponse): this { + return this.addErrorRoute( + "DELETE", + `/api/apps/${this.appId}/backend-functions/:name`, + error, + ); + } + mockSiteDeployError(error: ErrorResponse): this { return this.addErrorRoute( "POST",