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
20 changes: 6 additions & 14 deletions packages/cli/src/cli/commands/functions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,10 @@ async function deployFunctionsAction(): Promise<RunCommandResult> {
return { outroMessage: "Functions deployed to Base44" };
}

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 local functions to Base44")
.action(async () => {
await runCommand(deployFunctionsAction, { requireAuth: true }, context);
});
}
11 changes: 11 additions & 0 deletions packages/cli/src/cli/commands/functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getDeployCommand } from "./deploy.js";
import { getListCommand } from "./list.js";

export function getFunctionsCommand(context: CLIContext): Command {
return new Command("functions")
.description("Manage backend functions")
.addCommand(getDeployCommand(context))
.addCommand(getListCommand(context));
}
42 changes: 42 additions & 0 deletions packages/cli/src/cli/commands/functions/list.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
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);
});
}
4 changes: 2 additions & 2 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/core/resources/function/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import type {
FunctionLogFilters,
FunctionLogsResponse,
FunctionWithCode,
ListFunctionsResponse,
} from "@/core/resources/function/schema.js";
import {
DeployFunctionsResponseSchema,
FunctionLogsResponseSchema,
ListFunctionsResponseSchema,
} from "@/core/resources/function/schema.js";

function toDeployPayloadItem(fn: FunctionWithCode) {
Expand Down Expand Up @@ -111,3 +113,23 @@ 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;
}
21 changes: 21 additions & 0 deletions packages/cli/src/core/resources/function/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,33 @@ export const DeployFunctionsResponseSchema = z.object({
.nullable(),
});

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<typeof FunctionConfigSchema>;
export type BackendFunction = z.infer<typeof BackendFunctionSchema>;
export type FunctionFile = z.infer<typeof FunctionFileSchema>;
export type DeployFunctionsResponse = z.infer<
typeof DeployFunctionsResponseSchema
>;
export type ListFunctionsResponse = z.infer<typeof ListFunctionsResponseSchema>;

export type FunctionWithCode = Omit<BackendFunction, "filePaths"> & {
files: FunctionFile[];
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/tests/cli/functions_list.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
26 changes: 26 additions & 0 deletions packages/cli/tests/cli/testkit/TestAPIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ interface SecretsDeleteResponse {
success: boolean;
}

interface FunctionsListResponse {
functions: Array<{
name: string;
deployment_id: string;
entry: string;
files: Array<{ path: string; content: string }>;
automations: Record<string, unknown>[];
}>;
}

interface ConnectorsListResponse {
integrations: Array<{
integration_type: string;
Expand Down Expand Up @@ -281,6 +291,14 @@ export class TestAPIServer {
);
}

mockFunctionsList(response: FunctionsListResponse): this {
return this.addRoute(
"GET",
`/api/apps/${this.appId}/backend-functions`,
response,
);
}

mockSiteDeploy(response: SiteDeployResponse): this {
return this.addRoute(
"POST",
Expand Down Expand Up @@ -429,6 +447,14 @@ export class TestAPIServer {
);
}

mockFunctionsListError(error: ErrorResponse): this {
return this.addErrorRoute(
"GET",
`/api/apps/${this.appId}/backend-functions`,
error,
);
}

mockSiteDeployError(error: ErrorResponse): this {
return this.addErrorRoute(
"POST",
Expand Down
Loading