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/deploy.ts b/packages/cli/src/cli/commands/functions/deploy.ts index 4c89ac38..0d5ae969 100644 --- a/packages/cli/src/cli/commands/functions/deploy.ts +++ b/packages/cli/src/cli/commands/functions/deploy.ts @@ -1,16 +1,77 @@ 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 { + if (options.force && names.length > 0) { + throw new InvalidInputError( + "--force cannot be used when specifying function names", + ); + } -async function deployFunctionsAction(): Promise { 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.", @@ -18,51 +79,70 @@ async function deployFunctionsAction(): Promise { } 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, + ); + }); } diff --git a/packages/cli/src/cli/commands/functions/formatDeployResult.ts b/packages/cli/src/cli/commands/functions/formatDeployResult.ts new file mode 100644 index 00000000..5a4be23f --- /dev/null +++ b/packages/cli/src/cli/commands/functions/formatDeployResult.ts @@ -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}`); + } +} diff --git a/packages/cli/src/cli/commands/functions/index.ts b/packages/cli/src/cli/commands/functions/index.ts new file mode 100644 index 00000000..1e724c69 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/index.ts @@ -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)); +} diff --git a/packages/cli/src/cli/commands/functions/list.ts b/packages/cli/src/cli/commands/functions/list.ts new file mode 100644 index 00000000..e0cae503 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/list.ts @@ -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 { + 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); + }); +} diff --git a/packages/cli/src/cli/commands/functions/parseNames.ts b/packages/cli/src/cli/commands/functions/parseNames.ts new file mode 100644 index 00000000..e6e63230 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/parseNames.ts @@ -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); +} diff --git a/packages/cli/src/cli/commands/functions/pull.ts b/packages/cli/src/cli/commands/functions/pull.ts new file mode 100644 index 00000000..3d952220 --- /dev/null +++ b/packages/cli/src/cli/commands/functions/pull.ts @@ -0,0 +1,79 @@ +import { dirname, join } from "node:path"; +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 { readProjectConfig } from "@/core/index.js"; +import { listDeployedFunctions } from "@/core/resources/function/api.js"; +import { writeFunctions } from "@/core/resources/function/pull.js"; + +async function pullFunctionsAction( + name: string | undefined, +): Promise { + const { project } = await readProjectConfig(); + + const configDir = dirname(project.configPath); + const functionsDir = join(configDir, project.functionsDir); + + const remoteFunctions = await runTask( + "Fetching functions from Base44", + async () => { + const { functions } = await listDeployedFunctions(); + return functions; + }, + { + successMessage: "Functions fetched successfully", + errorMessage: "Failed to fetch functions", + }, + ); + + const toPull = name + ? remoteFunctions.filter((f) => f.name === name) + : remoteFunctions; + + if (name && toPull.length === 0) { + return { + outroMessage: `Function "${name}" not found on remote`, + }; + } + + if (toPull.length === 0) { + return { outroMessage: "No functions found on remote" }; + } + + const { written, skipped } = await runTask( + "Writing function files", + async () => { + return await writeFunctions(functionsDir, toPull); + }, + { + successMessage: "Function files written successfully", + errorMessage: "Failed to write function files", + }, + ); + + for (const name of written) { + log.success(`${name.padEnd(25)} written`); + } + for (const name of skipped) { + log.info(`${name.padEnd(25)} unchanged`); + } + + return { + outroMessage: `Pulled ${toPull.length} function${toPull.length !== 1 ? "s" : ""} to ${functionsDir}`, + }; +} + +export function getPullCommand(context: CLIContext): Command { + return new Command("pull") + .description("Pull deployed functions from Base44") + .argument("[name]", "Function name to pull (pulls all if omitted)") + .action(async (name: string | undefined) => { + await runCommand( + () => pullFunctionsAction(name), + { requireAuth: true }, + context, + ); + }); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index e528ccae..f0381b64 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -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"; @@ -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 ?? []; diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index e83edde4..a6d4ec02 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -6,7 +6,7 @@ import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; -import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; +import { getFunctionsCommand } from "@/cli/commands/functions/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; @@ -55,7 +55,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getConnectorsCommand(context)); // Register functions commands - program.addCommand(getFunctionsDeployCommand(context)); + program.addCommand(getFunctionsCommand(context)); // Register secrets commands program.addCommand(getSecretsCommand(context)); diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 9d6bff02..adcb6f5e 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -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"; /** @@ -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 { 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); diff --git a/packages/cli/src/core/resources/function/api.ts b/packages/cli/src/core/resources/function/api.ts index 43e0dbf8..dfa04030 100644 --- a/packages/cli/src/core/resources/function/api.ts +++ b/packages/cli/src/core/resources/function/api.ts @@ -2,52 +2,74 @@ 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 { +export async function deploySingleFunction( + name: string, + payload: { entry: string; files: FunctionFile[]; automations?: unknown[] }, +): Promise { 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 function "${name}"`); + } + + const result = DeploySingleFunctionResponseSchema.safeParse( + await response.json(), + ); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + 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, "deploying functions"); + throw await ApiError.fromHttpError(error, `deleting function "${name}"`); } +} + +export async function listDeployedFunctions(): Promise { + const appClient = getAppClient(); - const result = DeployFunctionsResponseSchema.safeParse(await response.json()); + 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; } diff --git a/packages/cli/src/core/resources/function/deploy.ts b/packages/cli/src/core/resources/function/deploy.ts index d592a785..c0e3f66b 100644 --- a/packages/cli/src/core/resources/function/deploy.ts +++ b/packages/cli/src/core/resources/function/deploy.ts @@ -1,8 +1,11 @@ import { dirname, relative } from "node:path"; -import { deployFunctions } from "@/core/resources/function/api.js"; +import { + deleteSingleFunction, + deploySingleFunction, + listDeployedFunctions, +} from "@/core/resources/function/api.js"; import type { BackendFunction, - DeployFunctionsResponse, FunctionFile, FunctionWithCode, } from "@/core/resources/function/schema.js"; @@ -12,23 +15,105 @@ async function loadFunctionCode( fn: BackendFunction, ): Promise { const functionDir = dirname(fn.entryPath); - const loadedFiles: FunctionFile[] = await Promise.all( + const resolvedFiles: FunctionFile[] = await Promise.all( fn.filePaths.map(async (filePath) => { const content = await readTextFile(filePath); const path = relative(functionDir, filePath).split(/[/\\]/).join("/"); return { path, content }; }), ); - return { ...fn, files: loadedFiles }; + return { ...fn, files: resolvedFiles }; } -export async function pushFunctions( +export interface SingleFunctionDeployResult { + name: string; + status: "deployed" | "unchanged" | "error"; + error?: string | null; + durationMs?: number; +} + +async function deployOne( + fn: BackendFunction, +): Promise { + const start = Date.now(); + try { + const functionWithCode = await loadFunctionCode(fn); + const response = await deploySingleFunction(functionWithCode.name, { + entry: functionWithCode.entry, + files: functionWithCode.files, + automations: functionWithCode.automations, + }); + return { + name: functionWithCode.name, + status: response.status, + durationMs: Date.now() - start, + }; + } catch (error) { + return { + name: fn.name, + status: "error", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function deployFunctionsSequentially( functions: BackendFunction[], -): Promise { - if (functions.length === 0) { - return { deployed: [], deleted: [], skipped: [], errors: null }; + options?: { + onStart?: (names: string[]) => void; + onResult?: (result: SingleFunctionDeployResult) => void; + }, +): Promise { + if (functions.length === 0) return []; + + const results: SingleFunctionDeployResult[] = []; + for (const fn of functions) { + options?.onStart?.([fn.name]); + const result = await deployOne(fn); + results.push(result); + options?.onResult?.(result); + } + return results; +} + +export interface PruneResult { + name: string; + deleted: boolean; + error?: string; +} + +export async function pruneRemovedFunctions( + localFunctionNames: string[], + options?: { + onStart?: (total: number) => void; + onBeforeDelete?: (name: string) => void; + onResult?: (result: PruneResult) => void; + }, +): Promise { + const remote = await listDeployedFunctions(); + const localSet = new Set(localFunctionNames); + const toDelete = remote.functions.filter((f) => !localSet.has(f.name)); + + options?.onStart?.(toDelete.length); + + const results: PruneResult[] = []; + + for (const fn of toDelete) { + options?.onBeforeDelete?.(fn.name); + let result: PruneResult; + try { + await deleteSingleFunction(fn.name); + result = { name: fn.name, deleted: true }; + } catch (error) { + result = { + name: fn.name, + deleted: false, + error: error instanceof Error ? error.message : String(error), + }; + } + results.push(result); + options?.onResult?.(result); } - const functionsWithCode = await Promise.all(functions.map(loadFunctionCode)); - return deployFunctions(functionsWithCode); + return results; } diff --git a/packages/cli/src/core/resources/function/index.ts b/packages/cli/src/core/resources/function/index.ts index 90b197a7..08b63274 100644 --- a/packages/cli/src/core/resources/function/index.ts +++ b/packages/cli/src/core/resources/function/index.ts @@ -1,5 +1,6 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; +export * from "./pull.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/function/pull.ts b/packages/cli/src/core/resources/function/pull.ts new file mode 100644 index 00000000..69abfc90 --- /dev/null +++ b/packages/cli/src/core/resources/function/pull.ts @@ -0,0 +1,102 @@ +import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import type { FunctionInfo } from "@/core/resources/function/schema.js"; +import { + pathExists, + readJsonFile, + readTextFile, + writeFile, + writeJsonFile, +} from "@/core/utils/fs.js"; + +interface WriteFunctionsResult { + written: string[]; + skipped: string[]; +} + +/** + * Writes remote function data to local function directories. + * Creates function.jsonc config and all source files for each function. + * Skips functions whose local files already match remote content. + */ +export async function writeFunctions( + functionsDir: string, + functions: FunctionInfo[], +): Promise { + const written: string[] = []; + const skipped: string[] = []; + + for (const fn of functions) { + const functionDir = join(functionsDir, fn.name); + const configPath = join(functionDir, "function.jsonc"); + + // Check if all files already match remote content + if (await isFunctionUnchanged(functionDir, fn)) { + skipped.push(fn.name); + continue; + } + + // Write function config + const config: Record = { + name: fn.name, + entry: fn.entry, + }; + if (fn.automations.length > 0) { + config.automations = fn.automations; + } + await writeJsonFile(configPath, config); + + // Write all source files + for (const file of fn.files) { + await writeFile(join(functionDir, file.path), file.content); + } + + written.push(fn.name); + } + + return { written, skipped }; +} + +async function isFunctionUnchanged( + functionDir: string, + fn: FunctionInfo, +): Promise { + if (!(await pathExists(functionDir))) { + return false; + } + + // Compare function config (entry, automations) + const configPath = join(functionDir, "function.jsonc"); + try { + const localConfig = (await readJsonFile(configPath)) as Record< + string, + unknown + >; + if (localConfig.entry !== fn.entry) { + return false; + } + if (!isDeepStrictEqual(localConfig.automations ?? [], fn.automations)) { + return false; + } + } catch { + return false; + } + + // Compare source files + for (const file of fn.files) { + const filePath = join(functionDir, file.path); + if (!(await pathExists(filePath))) { + return false; + } + try { + const localContent = await readTextFile(filePath); + if (localContent !== file.content) { + return false; + } + } catch { + return false; + } + } + + return true; +} diff --git a/packages/cli/src/core/resources/function/resource.ts b/packages/cli/src/core/resources/function/resource.ts index 11f448cc..8cf9815d 100644 --- a/packages/cli/src/core/resources/function/resource.ts +++ b/packages/cli/src/core/resources/function/resource.ts @@ -1,9 +1,9 @@ import { readAllFunctions } from "@/core/resources/function/config.js"; -import { pushFunctions } from "@/core/resources/function/deploy.js"; +import { deployFunctionsSequentially } from "@/core/resources/function/deploy.js"; import type { BackendFunction } from "@/core/resources/function/schema.js"; import type { Resource } from "@/core/resources/types.js"; export const functionResource: Resource = { readAll: readAllFunctions, - push: pushFunctions, + push: (functions) => deployFunctionsSequentially(functions), }; diff --git a/packages/cli/src/core/resources/function/schema.ts b/packages/cli/src/core/resources/function/schema.ts index 50f355cb..7d3a5ae2 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -84,21 +84,38 @@ const BackendFunctionSchema = FunctionConfigSchema.extend({ filePaths: z.array(z.string()).min(1, "Function must have at least one file"), }); -export const DeployFunctionsResponseSchema = z.object({ - deployed: z.array(z.string()), - deleted: z.array(z.string()), - skipped: z.array(z.string()).optional().nullable(), - errors: z - .array(z.object({ name: z.string(), message: z.string() })) - .nullable(), +export const DeploySingleFunctionResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), +}); + +const FunctionInfoSchema = z + .object({ + name: z.string(), + deployment_id: z.string(), + entry: z.string(), + files: z.array(FunctionFileSchema), + automations: z.array(AutomationSchema), + }) + .transform((data) => ({ + name: data.name, + deploymentId: data.deployment_id, + entry: data.entry, + files: data.files, + automations: data.automations, + })); + +export const ListFunctionsResponseSchema = z.object({ + functions: z.array(FunctionInfoSchema), }); export type FunctionConfig = z.infer; export type BackendFunction = z.infer; export type FunctionFile = z.infer; -export type DeployFunctionsResponse = z.infer< - typeof DeployFunctionsResponseSchema +export type DeploySingleFunctionResponse = z.infer< + typeof DeploySingleFunctionResponseSchema >; +export type FunctionInfo = z.infer; +export type ListFunctionsResponse = z.infer; export type FunctionWithCode = Omit & { files: FunctionFile[]; diff --git a/packages/cli/tests/cli/deploy.spec.ts b/packages/cli/tests/cli/deploy.spec.ts index a19d6ddd..c2f5cb92 100644 --- a/packages/cli/tests/cli/deploy.spec.ts +++ b/packages/cli/tests/cli/deploy.spec.ts @@ -36,7 +36,6 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("App deployed successfully"); }); @@ -54,17 +53,13 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "--yes"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys entities and functions together", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); t.api.mockEntitiesPush({ created: ["Order"], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockStripeStatus({ stripe_mode: null }); @@ -72,30 +67,26 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys zero-config functions (path-based names) with unified deploy", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys entities, functions, and site together", async () => { await t.givenLoggedInWithProject(fixture("full-project")); t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: ["hello"], deleted: [], errors: null }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockStripeStatus({ stripe_mode: null }); @@ -104,14 +95,13 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); t.expectResult(result).toContain("https://full-project.base44.app"); }); it("deploys agents successfully with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: ["customer_support", "order_assistant", "data_analyst"], updated: [], @@ -123,14 +113,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("App deployed successfully"); }); it("deploys agents and entities together", async () => { await t.givenLoggedInWithProject(fixture("with-agents")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: ["customer_support"], updated: ["order_assistant"], @@ -142,13 +130,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); }); it("deploys connectors successfully with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockStripeStatus({ stripe_mode: null }); @@ -161,14 +148,12 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("3 connectors"); }); it("shows OAuth info when connectors need authorization with -y flag", async () => { await t.givenLoggedInWithProject(fixture("with-connectors")); t.api.mockEntitiesPush({ created: [], updated: [], deleted: [] }); - t.api.mockFunctionsPush({ deployed: [], deleted: [], errors: null }); t.api.mockAgentsPush({ created: [], updated: [], deleted: [] }); t.api.mockConnectorsList({ integrations: [] }); t.api.mockStripeStatus({ stripe_mode: null }); @@ -181,7 +166,6 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); t.expectResult(result).toContain("require authorization"); t.expectResult(result).toContain("base44 connectors push"); }); @@ -206,7 +190,7 @@ describe("deploy command (unified)", () => { const result = await t.run("deploy", "-y"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployment completed"); + t.expectResult(result).toContain("App deployed successfully"); t.expectResult(result).toContain("2 connectors"); t.expectResult(result).toContain("Stripe sandbox provisioned"); t.expectResult(result).toContain("connect.stripe.com/setup/claim/xxx"); 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/functions_deploy.spec.ts b/packages/cli/tests/cli/functions_deploy.spec.ts index 1fae5b8e..44af56b2 100644 --- a/packages/cli/tests/cli/functions_deploy.spec.ts +++ b/packages/cli/tests/cli/functions_deploy.spec.ts @@ -24,144 +24,134 @@ describe("functions deploy command", () => { it("deploys functions successfully", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); + + const result = await t.run("functions", "deploy"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying process-order"); + t.expectResult(result).toContain("deployed"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("reports unchanged function", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSingleFunctionDeploy({ status: "unchanged" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Functions deployed successfully"); - t.expectResult(result).toContain("Deployed: process-order"); + t.expectResult(result).toContain("unchanged"); + t.expectResult(result).toContain("1 unchanged"); }); it("deploys zero-config and path-named functions successfully", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Functions deployed successfully"); - t.expectResult(result).toContain("Deployed: foo/bar"); + t.expectResult(result).toContain("foo/bar"); t.expectResult(result).toContain("foo/kfir/hello"); t.expectResult(result).toContain("custom-name"); + t.expectResult(result).toContain("stam"); }); it("does not collect zero-config functions whose path contains a dot", async () => { await t.givenLoggedInWithProject(fixture("with-zero-config-functions")); - t.api.mockFunctionsPush({ - deployed: ["foo/bar", "foo/kfir/hello", "stam", "custom-name"], - deleted: [], - errors: null, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); const result = await t.run("functions", "deploy"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployed: foo/bar"); t.expectResult(result).toNotContain("all.products"); t.expectResult(result).toNotContain("getProducts/all.products"); }); - it("fails when API returns error", async () => { + it("deploys specific function by name", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ - status: 400, - body: { error: "Invalid function code" }, - }); + t.api.mockSingleFunctionDeploy({ status: "deployed" }); - const result = await t.run("functions", "deploy"); + const result = await t.run("functions", "deploy", "process-order"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Deploying process-order"); + t.expectResult(result).toContain("1 deployed"); + }); + + it("fails when function name not found in project", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + + const result = await t.run("functions", "deploy", "nonexistent"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid function code"); + t.expectResult(result).toContain("not found in project"); }); - it("shows per-function errors from successful response with errors array", async () => { + it("reports error when API fails for a function", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: [], - deleted: [], - errors: [{ name: "process-order", message: "Syntax error on line 42" }], + t.api.mockSingleFunctionDeployError({ + status: 400, + body: { error: "Invalid function code" }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); - t.expectResult(result).toContain( - "'process-order': Syntax error on line 42", - ); - t.expectResult(result).toContain( - "Check the function code for syntax errors", - ); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); + t.expectResult(result).toContain("1 error"); }); - it("shows validation errors from 422 response with extra_data.errors", async () => { + it("reports validation error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ + t.api.mockSingleFunctionDeployError({ status: 422, body: { - message: "Validation failed", - detail: null, - extra_data: { - errors: [ - { - name: "myFunc", - message: - "Minimum interval for minute-based schedules is 5 minutes.", - }, - ], - }, + message: "Minimum interval for minute-based schedules is 5 minutes.", }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); - t.expectResult(result).toContain("Validation failed"); - t.expectResult(result).toContain("myFunc"); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); t.expectResult(result).toContain( "Minimum interval for minute-based schedules is 5 minutes.", ); }); - it("shows too-many-functions error from 422 response", async () => { + it("reports too-many-functions error from 422 response", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPushError({ + t.api.mockSingleFunctionDeployError({ status: 422, body: { - message: "Too many functions. Maximum is 50, got 51.", - detail: null, + message: "Maximum of 50 functions per app reached.", }, }); const result = await t.run("functions", "deploy"); - t.expectResult(result).toFail(); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("error"); t.expectResult(result).toContain( - "Too many functions. Maximum is 50, got 51.", + "Maximum of 50 functions per app reached.", ); - t.expectResult(result).toContain("validation error"); }); - it("shows deployed and deleted functions together", async () => { + it("rejects --force with specific function names", async () => { await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); - t.api.mockFunctionsPush({ - deployed: ["process-order"], - deleted: ["old-handler"], - errors: null, - }); - const result = await t.run("functions", "deploy"); + const result = await t.run( + "functions", + "deploy", + "process-order", + "--force", + ); - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Deployed: process-order"); - t.expectResult(result).toContain("Deleted: old-handler"); + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "--force cannot be used when specifying function names", + ); }); }); diff --git a/packages/cli/tests/cli/functions_list.spec.ts b/packages/cli/tests/cli/functions_list.spec.ts new file mode 100644 index 00000000..9487a1f7 --- /dev/null +++ b/packages/cli/tests/cli/functions_list.spec.ts @@ -0,0 +1,103 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("functions list command", () => { + const t = setupCLITests(); + + it("shows message when no functions deployed", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ functions: [] }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No functions on remote"); + }); + + it("lists deployed functions", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "func-a", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [], + }, + { + name: "func-b", + deployment_id: "d2", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("func-a"); + t.expectResult(result).toContain("func-b"); + t.expectResult(result).toContain("2 functions on remote"); + }); + + it("shows automation count for functions with automations", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "func-a", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "" }], + automations: [ + { + name: "daily-cron", + type: "scheduled", + schedule_mode: "recurring", + schedule_type: "cron", + cron_expression: "0 9 * * *", + is_active: true, + }, + { + name: "on-order-create", + type: "entity", + entity_name: "orders", + event_types: ["create", "update"], + is_active: true, + }, + ], + }, + ], + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("func-a"); + t.expectResult(result).toContain("2 automations"); + t.expectResult(result).toContain("1 function on remote"); + }); + + 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", "list"); + + t.expectResult(result).toFail(); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsListError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("functions", "list"); + + t.expectResult(result).toFail(); + }); +}); diff --git a/packages/cli/tests/cli/functions_pull.spec.ts b/packages/cli/tests/cli/functions_pull.spec.ts new file mode 100644 index 00000000..f241e9f6 --- /dev/null +++ b/packages/cli/tests/cli/functions_pull.spec.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("functions pull command", () => { + const t = setupCLITests(); + + it("reports no functions when remote has none", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ functions: [] }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No functions found on remote"); + }); + + it("pulls functions successfully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "my-func", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "Deno.serve(() => {})" }], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Functions fetched successfully"); + t.expectResult(result).toContain("Function files written successfully"); + t.expectResult(result).toContain("Pulled 1 function"); + }); + + it("pulls a single function by name", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "func-a", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "Deno.serve(() => {})" }], + automations: [], + }, + { + name: "func-b", + deployment_id: "d2", + entry: "index.ts", + files: [{ path: "index.ts", content: "Deno.serve(() => {})" }], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "pull", "func-a"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Pulled 1 function"); + }); + + it("reports function not found on remote", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ functions: [] }); + + const result = await t.run("functions", "pull", "nonexistent"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("not found on remote"); + }); + + 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", "pull"); + + t.expectResult(result).toFail(); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsListError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toFail(); + }); + + it("pulls function with multiple files (imports)", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "with-imports", + deployment_id: "d1", + entry: "index.ts", + files: [ + { + path: "index.ts", + content: + 'import { helper } from "./utils/helper.ts";\nDeno.serve(() => helper())', + }, + { + path: "utils/helper.ts", + content: "export function helper() { return 'ok'; }", + }, + ], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Pulled 1 function"); + const entry = await t.readProjectFile( + "base44/functions/with-imports/index.ts", + ); + expect(entry).toContain("import { helper }"); + const helper = await t.readProjectFile( + "base44/functions/with-imports/utils/helper.ts", + ); + expect(helper).toContain("export function helper"); + }); + + it("pulls function with nested folder structure", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "nested-func", + deployment_id: "d1", + entry: "src/main.ts", + files: [ + { + path: "src/main.ts", + content: + 'import { db } from "./lib/db.ts";\nDeno.serve(() => db())', + }, + { + path: "src/lib/db.ts", + content: "export function db() { return 'connected'; }", + }, + { + path: "src/lib/types.ts", + content: "export type Config = { key: string };", + }, + ], + automations: [], + }, + ], + }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toSucceed(); + expect(await t.fileExists("base44/functions/nested-func/src/main.ts")).toBe( + true, + ); + expect( + await t.fileExists("base44/functions/nested-func/src/lib/db.ts"), + ).toBe(true); + expect( + await t.fileExists("base44/functions/nested-func/src/lib/types.ts"), + ).toBe(true); + }); + + it("pulls function with automations", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionsList({ + functions: [ + { + name: "automated-func", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "Deno.serve(() => {})" }], + automations: [ + { + name: "daily-cron", + type: "scheduled", + schedule_mode: "recurring", + schedule_type: "cron", + cron_expression: "0 9 * * *", + is_active: true, + }, + { + name: "on-order-create", + type: "entity", + entity_name: "orders", + event_types: ["create", "update"], + is_active: true, + }, + ], + }, + ], + }); + + const result = await t.run("functions", "pull"); + + t.expectResult(result).toSucceed(); + const config = await t.readProjectFile( + "base44/functions/automated-func/function.jsonc", + ); + expect(config).toContain("automations"); + expect(config).toContain("daily-cron"); + expect(config).toContain("cron_expression"); + expect(config).toContain("on-order-create"); + expect(config).toContain("entity_name"); + expect(config).toContain("orders"); + }); + + it("skips unchanged functions on second pull", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + const mockData = { + functions: [ + { + name: "stable-func", + deployment_id: "d1", + entry: "index.ts", + files: [{ path: "index.ts", content: "Deno.serve(() => {})" }], + automations: [], + }, + ], + }; + + // First pull — writes files + t.api.mockFunctionsList(mockData); + const first = await t.run("functions", "pull"); + t.expectResult(first).toSucceed(); + t.expectResult(first).toContain("written"); + + // Second pull — should skip unchanged + t.api.mockFunctionsList(mockData); + const second = await t.run("functions", "pull"); + t.expectResult(second).toSucceed(); + t.expectResult(second).toContain("unchanged"); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index d7701345..44185bd0 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -75,6 +75,69 @@ interface SecretsDeleteResponse { success: boolean; } +interface SingleFunctionDeployResponse { + status: "deployed" | "unchanged"; +} + +interface AutomationBase { + name: string; + description?: string | null; + function_args?: Record | null; + is_active?: boolean; +} + +interface ScheduledOneTimeAutomation extends AutomationBase { + type: "scheduled"; + schedule_mode: "one-time"; + one_time_date: string; +} + +interface ScheduledCronAutomation extends AutomationBase { + type: "scheduled"; + schedule_mode: "recurring"; + schedule_type: "cron"; + cron_expression: string; + ends_type?: "never" | "on" | "after"; + ends_on_date?: string | null; + ends_after_count?: number | null; +} + +interface ScheduledSimpleAutomation extends AutomationBase { + type: "scheduled"; + schedule_mode: "recurring"; + schedule_type: "simple"; + repeat_unit: "minutes" | "hours" | "days" | "weeks" | "months"; + repeat_interval?: number; + start_time?: string | null; + repeat_on_days?: number[] | null; + repeat_on_day_of_month?: number | null; + ends_type?: "never" | "on" | "after"; + ends_on_date?: string | null; + ends_after_count?: number | null; +} + +interface EntityAutomation extends AutomationBase { + type: "entity"; + entity_name: string; + event_types: Array<"create" | "update" | "delete">; +} + +type AutomationData = + | ScheduledOneTimeAutomation + | ScheduledCronAutomation + | ScheduledSimpleAutomation + | EntityAutomation; + +interface FunctionsListResponse { + functions: Array<{ + name: string; + deployment_id: string; + entry: string; + files: Array<{ path: string; content: string }>; + automations: AutomationData[]; + }>; +} + interface ConnectorsListResponse { integrations: Array<{ integration_type: string; @@ -295,6 +358,35 @@ export class TestAPIServer { ); } + mockFunctionsList(response: FunctionsListResponse): this { + return this.addRoute( + "GET", + `/api/apps/${this.appId}/backend-functions`, + response, + ); + } + + /** 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; + } + + /** Mock PUT /api/apps/{appId}/backend-functions/{name} - Deploy single function */ + mockSingleFunctionDeploy(response: SingleFunctionDeployResponse): this { + return this.addRoute( + "PUT", + `/api/apps/${this.appId}/backend-functions/:name`, + response, + ); + } + mockSiteDeploy(response: SiteDeployResponse): this { return this.addRoute( "POST", @@ -461,14 +553,32 @@ export class TestAPIServer { ); } - mockFunctionsPushError(error: ErrorResponse): this { + mockFunctionsListError(error: ErrorResponse): this { return this.addErrorRoute( - "PUT", + "GET", `/api/apps/${this.appId}/backend-functions`, error, ); } + /** Mock single function deploy to return an error */ + mockSingleFunctionDeployError(error: ErrorResponse): this { + return this.addErrorRoute( + "PUT", + `/api/apps/${this.appId}/backend-functions/:name`, + error, + ); + } + + /** 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",