Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/cli/src/cli/commands/functions/delete.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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("<names...>", "Function names to delete")
.hook("preAction", validateNames)
.action(async (rawNames: string[]) => {
const names = parseNames(rawNames);
await runCommand(
() => deleteFunctionsAction(names),
{ requireAuth: true },
context,
);
});
}
2 changes: 2 additions & 0 deletions packages/cli/src/cli/commands/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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";

export function getFunctionsCommand(context: CLIContext): Command {
return new Command("functions")
.description("Manage backend functions")
.addCommand(getDeployCommand(context))
.addCommand(getDeleteCommand(context))
.addCommand(getListCommand(context));
}
11 changes: 11 additions & 0 deletions packages/cli/src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ export async function deployFunctions(
return result.data;
}

export async function deleteSingleFunction(name: string): Promise<void> {
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 ──────────────────────────────────────

/**
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/tests/cli/functions_delete.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading