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
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);
}
26 changes: 17 additions & 9 deletions packages/cli/src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import {
filterPendingOAuth,
promptOAuthFlows,
} from "@/cli/commands/connectors/oauth-prompt.js";
import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js";
import type { CLIContext } from "@/cli/types.js";
import {
getConnectorsUrl,
getDashboardUrl,
runCommand,
runTask,
theme,
} from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
Expand Down Expand Up @@ -85,16 +85,24 @@ export async function deployAction(
log.info(`Deploying:\n${summaryLines.join("\n")}`);
}

const result = await runTask(
"Deploying your app...",
async () => {
return await deployAll(projectData);
// Deploy resources with per-function progress
let functionCompleted = 0;
const functionTotal = functions.length;

const result = await deployAll(projectData, {
onFunctionStart: (names) => {
const label = names.length === 1 ? names[0] : `${names.length} functions`;
log.step(
theme.styles.dim(
`[${functionCompleted + 1}/${functionTotal}] Deploying ${label}...`,
),
);
},
{
successMessage: theme.colors.base44Orange("Deployment completed"),
errorMessage: "Deployment failed",
onFunctionResult: (r) => {
functionCompleted++;
formatDeployResult(r);
},
);
});

// Handle connector-specific post-deploy flows
const connectorResults = result.connectorResults ?? [];
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/core/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
pushConnectors,
} from "@/core/resources/connector/index.js";
import { entityResource } from "@/core/resources/entity/index.js";
import { functionResource } from "@/core/resources/function/index.js";
import {
deployFunctionsSequentially,
type SingleFunctionDeployResult,
} from "@/core/resources/function/deploy.js";
import { deploySite } from "@/core/site/index.js";

/**
Expand Down Expand Up @@ -40,19 +43,29 @@ interface DeployAllResult {
connectorResults?: ConnectorSyncResult[];
}

interface DeployAllOptions {
onFunctionStart?: (names: string[]) => void;
onFunctionResult?: (result: SingleFunctionDeployResult) => void;
}

/**
* Deploys all project resources (entities, functions, agents, connectors, and site) to Base44.
*
* @param projectData - The project configuration and resources to deploy
* @param options - Optional progress callbacks for resource deployment
* @returns The deployment result including app URL if site was deployed
*/
export async function deployAll(
projectData: ProjectData,
options?: DeployAllOptions,
): Promise<DeployAllResult> {
const { project, entities, functions, agents, connectors } = projectData;

await entityResource.push(entities);
await functionResource.push(functions);
await deployFunctionsSequentially(functions, {
onStart: options?.onFunctionStart,
onResult: options?.onFunctionResult,
});
await agentResource.push(agents);
const { results: connectorResults } = await pushConnectors(connectors);

Expand Down
Loading
Loading