Skip to content
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,
);
});
}
164 changes: 122 additions & 42 deletions packages/cli/src/cli/commands/functions/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,148 @@
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 { runCommand, runTask } from "@/cli/utils/index.js";
import { runCommand } from "@/cli/utils/index.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 formatPruneResult(pruneResult: PruneResult): void {
if (pruneResult.deleted) {
log.success(`${pruneResult.name.padEnd(25)} deleted`);
} else {
log.error(`${pruneResult.name.padEnd(25)} error: ${pruneResult.error}`);
}
}

function formatPruneSummary(pruneResults: PruneResult[]): void {
if (pruneResults.length > 0) {
const pruned = pruneResults.filter((r) => r.deleted).length;
log.info(`${pruned} deleted`);
}
}

function buildDeploySummary(results: SingleFunctionDeployResult[]): string {
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} deployed`);
if (unchanged > 0) parts.push(`${unchanged} unchanged`);
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<RunCommandResult> {
if (options.force && names.length > 0) {
throw new InvalidInputError(
"--force cannot be used when specifying function names",
);
}

async function deployFunctionsAction(): Promise<RunCommandResult> {
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.",
};
}

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) {
const allLocalNames = functions.map((f) => f.name);
let pruneCompleted = 0;
let pruneTotal = 0;
const pruneResults = await pruneRemovedFunctions(allLocalNames, {
onStart: (total) => {
pruneTotal = total;
if (total > 0) {
log.info(
`Found ${total} remote ${total === 1 ? "function" : "functions"} to delete`,
);
}
},
onBeforeDelete: (name) => {
pruneCompleted++;
log.step(
theme.styles.dim(
`[${pruneCompleted}/${pruneTotal}] Deleting ${name}...`,
),
);
},
onResult: formatPruneResult,
});
formatPruneSummary(pruneResults);
}

return { outroMessage: "Functions deployed to Base44" };
return { outroMessage: buildDeploySummary(results) };
}

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 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,
);
});
}
21 changes: 21 additions & 0 deletions packages/cli/src/cli/commands/functions/formatDeployResult.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
15 changes: 15 additions & 0 deletions packages/cli/src/cli/commands/functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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";
import { getPullCommand } from "./pull.js";

export function getFunctionsCommand(context: CLIContext): Command {
return new Command("functions")
.description("Manage backend functions")
.addCommand(getDeployCommand(context))
.addCommand(getDeleteCommand(context))
.addCommand(getListCommand(context))
.addCommand(getPullCommand(context));
}
42 changes: 42 additions & 0 deletions packages/cli/src/cli/commands/functions/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 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<RunCommandResult> {
const { functions } = await runTask(
"Fetching functions...",
async () => listDeployedFunctions(),
{ errorMessage: "Failed to fetch functions" },
);

if (functions.length === 0) {
return { outroMessage: "No functions on remote" };
}

for (const fn of functions) {
const automationCount = fn.automations.length;
const automationLabel =
automationCount > 0
? theme.styles.dim(
` (${automationCount} automation${automationCount > 1 ? "s" : ""})`,
)
: "";
log.message(` ${fn.name}${automationLabel}`);
}

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);
});
}
10 changes: 10 additions & 0 deletions packages/cli/src/cli/commands/functions/parseNames.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading