Skip to content
Merged
133 changes: 101 additions & 32 deletions packages/cli/src/cli/commands/functions/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,129 @@
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 formatPruneResults(pruneResults: PruneResult[]): void {
for (const pruneResult of pruneResults) {
if (pruneResult.deleted) {
log.success(`${pruneResult.name.padEnd(25)} deleted`);
} else {
log.error(`${pruneResult.name.padEnd(25)} error: ${pruneResult.error}`);
}
}

if (pruneResults.length > 0) {
const pruned = pruneResults.filter((r) => r.deleted).length;
log.info(`${pruned} function${pruned !== 1 ? "s" : ""} removed`);
}
}

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) {
log.info("Removing remote functions not found locally...");
const allLocalNames = functions.map((f) => f.name);
const pruneResults = await pruneRemovedFunctions(allLocalNames);
formatPruneResults(pruneResults);
}

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

export function getDeployCommand(context: CLIContext): Command {
return new Command("deploy")
.description("Deploy local functions to Base44")
.action(async () => {
await runCommand(deployFunctionsAction, { requireAuth: true }, context);
.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}`);
}
}
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);
}
81 changes: 35 additions & 46 deletions packages/cli/src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,43 @@ import type { KyResponse } from "ky";
import { getAppClient } from "@/core/clients/index.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";
import type {
DeployFunctionsResponse,
DeploySingleFunctionResponse,
FunctionFile,
FunctionLogFilters,
FunctionLogsResponse,
FunctionWithCode,
ListFunctionsResponse,
} from "@/core/resources/function/schema.js";
import {
DeployFunctionsResponseSchema,
DeploySingleFunctionResponseSchema,
FunctionLogsResponseSchema,
ListFunctionsResponseSchema,
} from "@/core/resources/function/schema.js";

function toDeployPayloadItem(fn: FunctionWithCode) {
return {
name: fn.name,
entry: fn.entry,
files: fn.files,
automations: fn.automations,
};
}

export async function deployFunctions(
functions: FunctionWithCode[],
): Promise<DeployFunctionsResponse> {
export async function deploySingleFunction(
name: string,
payload: { entry: string; files: FunctionFile[]; automations?: unknown[] },
): Promise<DeploySingleFunctionResponse> {
const appClient = getAppClient();
const payload = {
functions: functions.map(toDeployPayloadItem),
};

let response: KyResponse;
try {
response = await appClient.put("backend-functions", {
json: payload,
timeout: false,
});
response = await appClient.put(
`backend-functions/${encodeURIComponent(name)}`,
{ json: payload, timeout: false },
);
} catch (error) {
throw await ApiError.fromHttpError(error, "deploying functions");
throw await ApiError.fromHttpError(error, `deploying function "${name}"`);
}

const result = DeployFunctionsResponseSchema.safeParse(await response.json());

const result = DeploySingleFunctionResponseSchema.safeParse(
await response.json(),
);
if (!result.success) {
throw new SchemaValidationError(
"Invalid response from server",
result.error,
);
}

return result.data;
}

Expand All @@ -64,6 +53,26 @@ export async function deleteSingleFunction(name: string): Promise<void> {
}
}

export async function listDeployedFunctions(): Promise<ListFunctionsResponse> {
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;
}

// ─── FUNCTION LOGS API ──────────────────────────────────────

/**
Expand Down Expand Up @@ -124,23 +133,3 @@ export async function fetchFunctionLogs(

return result.data;
}

export async function listDeployedFunctions(): Promise<ListFunctionsResponse> {
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;
}
Loading
Loading