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
2 changes: 2 additions & 0 deletions src/cli/commands/functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(getPullCommand(context))
.addCommand(getListCommand(context))
.addCommand(getDeleteCommand(context));
}
79 changes: 79 additions & 0 deletions src/cli/commands/functions/pull.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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",
},
);

if (written.length > 0) {
log.success(`Written: ${written.join(", ")}`);
}
if (skipped.length > 0) {
log.info(`Skipped (unchanged): ${skipped.join(", ")}`);
}

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,
);
});
}
1 change: 1 addition & 0 deletions src/core/resources/function/index.ts
Original file line number Diff line number Diff line change
@@ -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";
103 changes: 103 additions & 0 deletions src/core/resources/function/pull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { join } from "node:path";
import type { FunctionInfo } from "@/core/resources/function/schema.js";
import {
pathExists,
readJsonFile,
readTextFile,
writeFile,
writeJsonFile,
} from "@/core/utils/fs.js";

export 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<WriteFunctionsResult> {
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<string, unknown> = {
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<boolean> {
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;
}
const localAuto = JSON.stringify(localConfig.automations ?? []);
const remoteAuto = JSON.stringify(fn.automations);
if (localAuto !== remoteAuto) {
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;
}
Loading