diff --git a/packages/cli/src/cli/commands/functions/index.ts b/packages/cli/src/cli/commands/functions/index.ts index d5c3b831..1e724c69 100644 --- a/packages/cli/src/cli/commands/functions/index.ts +++ b/packages/cli/src/cli/commands/functions/index.ts @@ -3,11 +3,13 @@ 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(getListCommand(context)) + .addCommand(getPullCommand(context)); } 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..4ef97f75 --- /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]", "Pull a single function by name") + .action(async (name: string | undefined) => { + await runCommand( + () => pullFunctionsAction(name), + { requireAuth: true }, + context, + ); + }); +} 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/schema.ts b/packages/cli/src/core/resources/function/schema.ts index d62d6f1e..220f7c87 100644 --- a/packages/cli/src/core/resources/function/schema.ts +++ b/packages/cli/src/core/resources/function/schema.ts @@ -119,6 +119,7 @@ export type FunctionFile = z.infer; export type DeployFunctionsResponse = z.infer< typeof DeployFunctionsResponseSchema >; +export type FunctionInfo = z.infer; export type ListFunctionsResponse = z.infer; export type FunctionWithCode = Omit & { 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"); + }); +});