diff --git a/README.md b/README.md index cd048a80..437de46a 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,23 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | Command | Description | | ------- | ----------- | | [`create`](https://docs.base44.com/developers/references/cli/commands/create) | Create a new Base44 project from a template | -| [`deploy`](https://docs.base44.com/developers/references/cli/commands/deploy) | Deploy resources and site to Base44 | -| [`link`](https://docs.base44.com/developers/references/cli/commands/link) | Link a local project to a project on Base44 | +| [`deploy`](https://docs.base44.com/developers/references/cli/commands/deploy) | Deploy all project resources (entities, functions, agents, connectors, and site) | +| [`link`](https://docs.base44.com/developers/references/cli/commands/link) | Link a local project to a Base44 project | +| [`eject`](https://docs.base44.com/developers/references/cli/commands/eject) | Download the code for an existing Base44 project | | [`dashboard open`](https://docs.base44.com/developers/references/cli/commands/dashboard) | Open the app dashboard in your browser | | [`login`](https://docs.base44.com/developers/references/cli/commands/login) | Authenticate with Base44 | -| [`logout`](https://docs.base44.com/developers/references/cli/commands/logout) | Sign out and clear stored credentials | -| [`whoami`](https://docs.base44.com/developers/references/cli/commands/whoami) | Display the current authenticated user | +| [`logout`](https://docs.base44.com/developers/references/cli/commands/logout) | Logout from current device | +| [`whoami`](https://docs.base44.com/developers/references/cli/commands/whoami) | Display current authenticated user | | [`agents pull`](https://docs.base44.com/developers/references/cli/commands/agents-pull) | Pull agents from Base44 to local files | | [`agents push`](https://docs.base44.com/developers/references/cli/commands/agents-push) | Push local agents to Base44 | +| [`connectors pull`](https://docs.base44.com/developers/references/cli/commands/connectors-pull) | Pull connectors from Base44 to local files | +| [`connectors push`](https://docs.base44.com/developers/references/cli/commands/connectors-push) | Push local connectors to Base44 | | [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entity schemas to Base44 | | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | +| [`functions invoke`](https://docs.base44.com/developers/references/cli/commands/functions-invoke) | Invoke a deployed backend function | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | | [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser | - - - +| [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources | ## AI agent skills diff --git a/docs/testing.md b/docs/testing.md index d1711034..08631dcb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -223,7 +223,6 @@ t.api.mockConnectorSet({ connection_id: "conn-123", already_authorized: false, }); -t.api.mockConnectorOAuthStatus({ status: "ACTIVE" }); t.api.mockConnectorRemove({ status: "removed", integration_type: "googlecalendar" }); t.api.mockConnectorsListError({ status: 500, body: { error: "Server error" } }); t.api.mockConnectorSetError({ status: 401, body: { error: "Unauthorized" } }); diff --git a/src/cli/commands/functions/deploy.ts b/src/cli/commands/functions/deploy.ts index 80f33ba1..04045054 100644 --- a/src/cli/commands/functions/deploy.ts +++ b/src/cli/commands/functions/deploy.ts @@ -1,5 +1,6 @@ import { log } from "@clack/prompts"; import { Command } from "commander"; +import { getFunctionsInvokeCommand } from "@/cli/commands/functions/invoke.js"; import type { CLIContext } from "@/cli/types.js"; import { runCommand, runTask } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; @@ -66,5 +67,6 @@ export function getFunctionsDeployCommand(context: CLIContext): Command { context, ); }), - ); + ) + .addCommand(getFunctionsInvokeCommand(context)); } diff --git a/src/cli/commands/functions/invoke.ts b/src/cli/commands/functions/invoke.ts new file mode 100644 index 00000000..a171e9ac --- /dev/null +++ b/src/cli/commands/functions/invoke.ts @@ -0,0 +1,148 @@ +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 { invokeFunction } from "@/core/resources/function/index.js"; + +function collectHeader( + value: string, + previous: Record, +): Record { + const idx = value.indexOf(":"); + if (idx === -1) { + throw new Error(`Invalid header (expected "Name: Value"): ${value}`); + } + const name = value.slice(0, idx).trim(); + const headerValue = value.slice(idx + 1).trim(); + return { ...previous, [name]: headerValue }; +} + +function parseJsonArg(value: string): Record { + try { + const parsed: unknown = JSON.parse(value); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error("Data must be a JSON object"); + } + return parsed as Record; + } catch (e) { + throw new Error( + `Invalid JSON data: ${e instanceof Error ? e.message : String(e)}`, + ); + } +} + +async function invokeFunctionAction( + functionName: string, + options: { + data?: string; + timeout?: string; + method?: string; + header?: Record; + verbose?: boolean; + }, +): Promise { + const data = options.data ? parseJsonArg(options.data) : {}; + const method = options.method?.toUpperCase() ?? "POST"; + const timeout = options.timeout + ? parseInt(options.timeout, 10) * 1000 + : undefined; + + if (!options.verbose) { + log.info( + `Invoking function ${theme.styles.bold(functionName)} (${method})`, + ); + } + + const result = await runTask( + "Running function", + async () => { + return await invokeFunction(functionName, data, { + timeout, + method, + headers: options.header, + }); + }, + { + successMessage: options.verbose + ? undefined + : "Function executed successfully", + errorMessage: "Function execution failed", + }, + ); + + if (options.verbose) { + // curl-like verbose output + log.info(theme.styles.dim("* Request:")); + log.info(theme.styles.dim(`> ${result.method} ${result.url}`)); + for (const [key, value] of Object.entries(result.requestHeaders)) { + // Don't show the full auth token for security + const displayValue = + key === "Authorization" ? "Bearer [REDACTED]" : value; + log.info(theme.styles.dim(`> ${key}: ${displayValue}`)); + } + if (Object.keys(data).length > 0) { + log.info(theme.styles.dim(">")); + log.info(theme.styles.dim(`> ${JSON.stringify(data)}`)); + } + log.info(theme.styles.dim("*")); + log.info(theme.styles.dim("* Response:")); + log.info( + theme.styles.dim( + `< HTTP ${result.status} ${result.statusText} (${result.durationMs}ms)`, + ), + ); + for (const [key, value] of Object.entries(result.headers)) { + log.info(theme.styles.dim(`< ${key}: ${value}`)); + } + log.info(theme.styles.dim("<")); + } + + const output = + typeof result.body === "string" + ? result.body + : JSON.stringify(result.body, null, 2); + + if (options.verbose) { + log.info(output); + } else { + log.info(`Response:\n${output}`); + } + + return { + outroMessage: options.verbose + ? undefined + : `Function ${theme.styles.bold(functionName)} completed`, + }; +} + +export function getFunctionsInvokeCommand(context: CLIContext): Command { + return new Command("invoke") + .description("Invoke a deployed backend function") + .argument("", "Name of the function to invoke") + .option("-X, --method ", "HTTP method (default: POST)") + .option( + "-H, --header
", + "Custom header (Name: Value), repeatable", + collectHeader, + {}, + ) + .option("-d, --data ", "JSON data to send to the function") + .option("-t, --timeout ", "Timeout in seconds (default: 300)") + .option( + "-v, --verbose", + "Verbose output (show request/response headers, status, timing)", + ) + .action(async (functionName: string, options) => { + await runCommand( + () => invokeFunctionAction(functionName, options), + { requireAuth: true }, + context, + ); + }); +} diff --git a/src/core/resources/function/index.ts b/src/core/resources/function/index.ts index 90b197a7..5aeffaad 100644 --- a/src/core/resources/function/index.ts +++ b/src/core/resources/function/index.ts @@ -1,5 +1,6 @@ export * from "./api.js"; export * from "./config.js"; export * from "./deploy.js"; +export * from "./invoke.js"; export * from "./resource.js"; export * from "./schema.js"; diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts new file mode 100644 index 00000000..bcf216a6 --- /dev/null +++ b/src/core/resources/function/invoke.ts @@ -0,0 +1,108 @@ +import type { KyResponse } from "ky"; +import ky from "ky"; +import { + isTokenExpired, + readAuth, + refreshAndSaveTokens, +} from "@/core/auth/config.js"; +import { ApiError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/index.js"; +import { getSiteUrl } from "@/core/site/api.js"; + +type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); + +interface InvokeFunctionResult { + /** The function's response body */ + body: unknown; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Response headers */ + headers: Record; + /** Request URL */ + url: string; + /** Request method */ + method: string; + /** Request headers that were sent */ + requestHeaders: Record; + /** Time taken in milliseconds */ + durationMs: number; +} + +/** + * Invokes a deployed backend function by name. + * + * @param functionName - The name of the function to invoke + * @param data - JSON-serializable data to pass to the function + * @param options - Optional configuration for the invocation + * @returns The function's response data and metadata + */ +export async function invokeFunction( + functionName: string, + data: Record, + options?: { + timeout?: number; + method?: string; + headers?: Record; + }, +): Promise { + const { id } = getAppConfig(); + const method = (options?.method?.toUpperCase() ?? "POST") as HttpMethod; + + // Resolve the app's published URL (e.g. https://my-app.base44.app) + const siteUrl = await getSiteUrl(); + const url = `${siteUrl.replace(/\/+$/, "")}/api/functions/${functionName}`; + + // Get a valid access token + const auth = await readAuth(); + let token = auth.accessToken; + if (isTokenExpired(auth)) { + const refreshed = await refreshAndSaveTokens(); + if (refreshed) { + token = refreshed; + } + } + + const requestHeaders = { + Authorization: `Bearer ${token}`, + "X-App-Id": id, + "User-Agent": "Base44 CLI", + ...options?.headers, + }; + + const startTime = Date.now(); + let response: KyResponse; + try { + response = await ky(url, { + method, + ...(METHODS_WITH_BODY.has(method) ? { json: data } : {}), + headers: requestHeaders, + timeout: options?.timeout ?? 300_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "invoking function"); + } + const durationMs = Date.now() - startTime; + + // Extract response headers + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const body = await response.json(); + + return { + body, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + url, + method, + requestHeaders, + durationMs, + }; +} diff --git a/tests/cli/functions_invoke.spec.ts b/tests/cli/functions_invoke.spec.ts new file mode 100644 index 00000000..cc31023e --- /dev/null +++ b/tests/cli/functions_invoke.spec.ts @@ -0,0 +1,333 @@ +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { fixture, mswServer, setupCLITests } from "./testkit/index.js"; + +describe("functions invoke command", () => { + const t = setupCLITests(); + + 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", "invoke", "my-function"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("invokes function successfully with POST", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + // Mock the function invocation endpoint at the published URL + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ success: true, result: "Hello" }), + ), + ); + + const result = await t.run("functions", "invoke", "my-function"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Function executed successfully"); + t.expectResult(result).toContain("success"); + t.expectResult(result).toContain('"result": "Hello"'); + }); + + it("invokes function with custom method", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + // Mock the function invocation endpoint at the published URL + mswServer.use( + http.get("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ data: "fetched" }), + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-X", + "GET", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Function executed successfully"); + t.expectResult(result).toContain("fetched"); + }); + + it("invokes function with custom headers", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + let capturedHeaders: Headers | undefined; + mswServer.use( + http.post( + "https://test-app.base44.app/api/functions/my-function", + ({ request }) => { + capturedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }, + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-H", + "X-Custom: test-value", + ); + + t.expectResult(result).toSucceed(); + if (capturedHeaders) { + expect(capturedHeaders.get("X-Custom")).toBe("test-value"); + } + }); + + it("invokes function with JSON data", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + let capturedBody: unknown; + mswServer.use( + http.post( + "https://test-app.base44.app/api/functions/my-function", + async ({ request }) => { + capturedBody = await request.json(); + return HttpResponse.json({ success: true }); + }, + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-d", + '{"name": "test", "count": 42}', + ); + + t.expectResult(result).toSucceed(); + expect(capturedBody).toEqual({ name: "test", count: 42 }); + }); + + it("fails with invalid JSON data", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-d", + "not-valid-json", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid JSON data"); + }); + + it("fails when function returns error", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json( + { error: "Function execution failed" }, + { status: 500 }, + ), + ), + ); + + const result = await t.run("functions", "invoke", "my-function"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("invoking function"); + }); + + it("fails when site URL is not available", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrlError({ status: 404, body: { error: "Not found" } }); + + const result = await t.run("functions", "invoke", "my-function"); + + t.expectResult(result).toFail(); + }); + + it("invokes function with multiple headers", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + let capturedHeaders: Headers | undefined; + mswServer.use( + http.post( + "https://test-app.base44.app/api/functions/my-function", + ({ request }) => { + capturedHeaders = request.headers; + return HttpResponse.json({ success: true }); + }, + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-H", + "X-Custom-1: value1", + "-H", + "X-Custom-2: value2", + ); + + t.expectResult(result).toSucceed(); + if (capturedHeaders) { + expect(capturedHeaders.get("X-Custom-1")).toBe("value1"); + expect(capturedHeaders.get("X-Custom-2")).toBe("value2"); + } + }); + + it("shows verbose output with -v flag", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json( + { success: true, data: "result" }, + { + headers: { + "Content-Type": "application/json", + "X-Custom-Response": "test-value", + }, + }, + ), + ), + ); + + const result = await t.run("functions", "invoke", "my-function", "-v"); + + t.expectResult(result).toSucceed(); + + // Check request details + t.expectResult(result).toContain("* Request:"); + t.expectResult(result).toContain( + "> POST https://test-app.base44.app/api/functions/my-function", + ); + t.expectResult(result).toContain("> Authorization: Bearer [REDACTED]"); + t.expectResult(result).toContain("> User-Agent: Base44 CLI"); + + // Check response details + t.expectResult(result).toContain("* Response:"); + t.expectResult(result).toContain("< HTTP 200"); + t.expectResult(result).toContain("< content-type: application/json"); + + // Check response body + t.expectResult(result).toContain('"success": true'); + t.expectResult(result).toContain('"data": "result"'); + + // Should NOT contain the non-verbose messages + expect(result.stdout).not.toContain("Function executed successfully"); + expect(result.stdout).not.toContain("Function my-function completed"); + }); + + it("shows request body in verbose output when data is provided", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ received: true }), + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-v", + "-d", + '{"name":"test","count":42}', + ); + + t.expectResult(result).toSucceed(); + + // Check that request body is shown + t.expectResult(result).toContain('> {"name":"test","count":42}'); + }); + + it("shows custom headers in verbose output", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ success: true }), + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-v", + "-H", + "X-Custom-Header: custom-value", + ); + + t.expectResult(result).toSucceed(); + + // Check that custom header is shown in request + t.expectResult(result).toContain("> X-Custom-Header: custom-value"); + }); + + it("shows timing information in verbose output", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.post("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ success: true }), + ), + ); + + const result = await t.run("functions", "invoke", "my-function", "-v"); + + t.expectResult(result).toSucceed(); + + // Check that timing is shown (should have some ms value) + expect(result.stdout).toMatch(/< HTTP 200 .* \(\d+ms\)/); + }); + + it("shows GET request correctly in verbose output", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + + mswServer.use( + http.get("https://test-app.base44.app/api/functions/my-function", () => + HttpResponse.json({ data: "fetched" }), + ), + ); + + const result = await t.run( + "functions", + "invoke", + "my-function", + "-v", + "-X", + "GET", + ); + + t.expectResult(result).toSucceed(); + + // Check that GET method is shown + t.expectResult(result).toContain( + "> GET https://test-app.base44.app/api/functions/my-function", + ); + + // Should not show request body for GET + expect(result.stdout).not.toMatch(/>\s*>\s*{/); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index a8987f8e..240c6f97 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -75,10 +75,6 @@ interface ConnectorSetResponse { other_user_email?: string; } -interface ConnectorOAuthStatusResponse { - status: "ACTIVE" | "FAILED" | "PENDING"; -} - interface ConnectorRemoveResponse { status: "removed"; integration_type: string;