From 797e00b2f79afb3fbc0b17053d3897d596246882 Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Feb 2026 14:41:48 +0200 Subject: [PATCH 1/8] feat: add `functions invoke` CLI command Adds a new `base44 functions invoke ` command that allows invoking deployed backend functions directly from the CLI. This enables faster development and testing workflows without requiring a browser. Options: -d, --data JSON data to send to the function -t, --timeout Timeout in seconds (default: 300) Note: Function invocation uses the app domain (base44.app) rather than the platform domain (app.base44.com), as backend functions are only accessible through the app subdomain. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/functions/deploy.ts | 4 +- src/cli/commands/functions/invoke.ts | 68 +++++++++++++++++++++++++++ src/core/resources/function/index.ts | 1 + src/core/resources/function/invoke.ts | 53 +++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands/functions/invoke.ts create mode 100644 src/core/resources/function/invoke.ts 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..e3759fc1 --- /dev/null +++ b/src/cli/commands/functions/invoke.ts @@ -0,0 +1,68 @@ +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 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 }, +): Promise { + const data = options.data ? parseJsonArg(options.data) : {}; + const timeout = options.timeout + ? parseInt(options.timeout, 10) * 1000 + : undefined; + + log.info(`Invoking function ${theme.styles.bold(functionName)}`); + + const result = await runTask( + "Running function", + async () => { + return await invokeFunction(functionName, data, { timeout }); + }, + { + successMessage: "Function executed successfully", + errorMessage: "Function execution failed", + }, + ); + + const output = + typeof result === "string" ? result : JSON.stringify(result, null, 2); + + log.info(`Response:\n${output}`); + + return { + outroMessage: `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("-d, --data ", "JSON data to send to the function") + .option("-t, --timeout ", "Timeout in seconds (default: 300)") + .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..5081fae9 --- /dev/null +++ b/src/core/resources/function/invoke.ts @@ -0,0 +1,53 @@ +import ky from "ky"; +import { + isTokenExpired, + readAuth, + refreshAndSaveTokens, +} from "@/core/auth/config.js"; +import { getAppConfig } from "@/core/project/index.js"; + +// Function invocation must go through the app domain (base44.app), +// not the platform domain (app.base44.com). +const APP_DOMAIN_BASE_URL = + process.env.BASE44_APP_URL || "https://base44.app"; + +/** + * 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 + */ +export async function invokeFunction( + functionName: string, + data: Record, + options?: { timeout?: number }, +): Promise { + const { id } = getAppConfig(); + + // 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 response = await ky.post( + `${APP_DOMAIN_BASE_URL}/api/apps/${id}/functions/${functionName}`, + { + json: data, + headers: { + Authorization: `Bearer ${token}`, + "X-App-Id": id, + "User-Agent": "Base44 CLI", + }, + timeout: options?.timeout ?? 300_000, + }, + ); + + return response.json(); +} From 2eff76edef0a2159a9c3546060b6659492f7ce8a Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Feb 2026 15:13:31 +0200 Subject: [PATCH 2/8] feat: use app domain URL and support all HTTP methods Resolve the app's published URL via getSiteUrl() instead of hardcoding base44.app, and call /api/functions/{name} on the app domain. Add -X flag for HTTP method selection (GET, POST, PUT, DELETE, PATCH), defaulting to POST. Body is only sent for methods that support it. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/functions/invoke.ts | 10 +++++--- src/core/resources/function/invoke.ts | 35 +++++++++++++++------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/functions/invoke.ts b/src/cli/commands/functions/invoke.ts index e3759fc1..df714b93 100644 --- a/src/cli/commands/functions/invoke.ts +++ b/src/cli/commands/functions/invoke.ts @@ -22,19 +22,22 @@ function parseJsonArg(value: string): Record { async function invokeFunctionAction( functionName: string, - options: { data?: string; timeout?: string }, + options: { data?: string; timeout?: string; method?: string }, ): Promise { const data = options.data ? parseJsonArg(options.data) : {}; + const method = options.method?.toUpperCase() ?? "POST"; const timeout = options.timeout ? parseInt(options.timeout, 10) * 1000 : undefined; - log.info(`Invoking function ${theme.styles.bold(functionName)}`); + log.info( + `Invoking function ${theme.styles.bold(functionName)} (${method})`, + ); const result = await runTask( "Running function", async () => { - return await invokeFunction(functionName, data, { timeout }); + return await invokeFunction(functionName, data, { timeout, method }); }, { successMessage: "Function executed successfully", @@ -56,6 +59,7 @@ 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("-d, --data ", "JSON data to send to the function") .option("-t, --timeout ", "Timeout in seconds (default: 300)") .action(async (functionName: string, options) => { diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts index 5081fae9..ba67d720 100644 --- a/src/core/resources/function/invoke.ts +++ b/src/core/resources/function/invoke.ts @@ -5,11 +5,11 @@ import { refreshAndSaveTokens, } from "@/core/auth/config.js"; import { getAppConfig } from "@/core/project/index.js"; +import { getSiteUrl } from "@/core/site/api.js"; -// Function invocation must go through the app domain (base44.app), -// not the platform domain (app.base44.com). -const APP_DOMAIN_BASE_URL = - process.env.BASE44_APP_URL || "https://base44.app"; +type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); /** * Invokes a deployed backend function by name. @@ -22,9 +22,14 @@ const APP_DOMAIN_BASE_URL = export async function invokeFunction( functionName: string, data: Record, - options?: { timeout?: number }, + options?: { timeout?: number; method?: string }, ): 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(); @@ -36,18 +41,16 @@ export async function invokeFunction( } } - const response = await ky.post( - `${APP_DOMAIN_BASE_URL}/api/apps/${id}/functions/${functionName}`, - { - json: data, - headers: { - Authorization: `Bearer ${token}`, - "X-App-Id": id, - "User-Agent": "Base44 CLI", - }, - timeout: options?.timeout ?? 300_000, + const response = await ky(url, { + method, + ...(METHODS_WITH_BODY.has(method) ? { json: data } : {}), + headers: { + Authorization: `Bearer ${token}`, + "X-App-Id": id, + "User-Agent": "Base44 CLI", }, - ); + timeout: options?.timeout ?? 300_000, + }); return response.json(); } From 33b613b3ffd1d8a5bc53819f0673e28cf4a6c79b Mon Sep 17 00:00:00 2001 From: Netanel Gilad Date: Wed, 18 Feb 2026 16:45:02 +0200 Subject: [PATCH 3/8] feat: add -H/--header flag for custom headers Support repeatable -H "Name: Value" headers, matching curl syntax. Custom headers are merged on top of the default auth/app-id headers. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/functions/invoke.ts | 22 ++++++++++++++++++++-- src/core/resources/function/invoke.ts | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/functions/invoke.ts b/src/cli/commands/functions/invoke.ts index df714b93..693e8820 100644 --- a/src/cli/commands/functions/invoke.ts +++ b/src/cli/commands/functions/invoke.ts @@ -6,6 +6,19 @@ 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); @@ -22,7 +35,7 @@ function parseJsonArg(value: string): Record { async function invokeFunctionAction( functionName: string, - options: { data?: string; timeout?: string; method?: string }, + options: { data?: string; timeout?: string; method?: string; header?: Record }, ): Promise { const data = options.data ? parseJsonArg(options.data) : {}; const method = options.method?.toUpperCase() ?? "POST"; @@ -37,7 +50,11 @@ async function invokeFunctionAction( const result = await runTask( "Running function", async () => { - return await invokeFunction(functionName, data, { timeout, method }); + return await invokeFunction(functionName, data, { + timeout, + method, + headers: options.header, + }); }, { successMessage: "Function executed successfully", @@ -60,6 +77,7 @@ export function getFunctionsInvokeCommand(context: CLIContext): Command { .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)") .action(async (functionName: string, options) => { diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts index ba67d720..b0fb8ec2 100644 --- a/src/core/resources/function/invoke.ts +++ b/src/core/resources/function/invoke.ts @@ -22,7 +22,7 @@ const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); export async function invokeFunction( functionName: string, data: Record, - options?: { timeout?: number; method?: string }, + options?: { timeout?: number; method?: string; headers?: Record }, ): Promise { const { id } = getAppConfig(); const method = (options?.method?.toUpperCase() ?? "POST") as HttpMethod; @@ -48,6 +48,7 @@ export async function invokeFunction( Authorization: `Bearer ${token}`, "X-App-Id": id, "User-Agent": "Base44 CLI", + ...options?.headers, }, timeout: options?.timeout ?? 300_000, }); From 6e4af957e7f8a02b266197d6f8ca878dab2e44a5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:48:40 +0000 Subject: [PATCH 4/8] fix: resolve Biome lint issues in functions invoke - Fix formatting in invoke.ts files (wrap long lines) - Inline ConnectorOAuthStatusResponse type to fix unused interface warning Co-authored-by: Netanel Gilad --- src/cli/commands/functions/invoke.ts | 24 ++++++++++++++++++------ src/core/resources/function/invoke.ts | 6 +++++- tests/cli/testkit/Base44APIMock.ts | 8 +++----- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/functions/invoke.ts b/src/cli/commands/functions/invoke.ts index 693e8820..726a3f21 100644 --- a/src/cli/commands/functions/invoke.ts +++ b/src/cli/commands/functions/invoke.ts @@ -22,7 +22,11 @@ function collectHeader( function parseJsonArg(value: string): Record { try { const parsed: unknown = JSON.parse(value); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { throw new Error("Data must be a JSON object"); } return parsed as Record; @@ -35,7 +39,12 @@ function parseJsonArg(value: string): Record { async function invokeFunctionAction( functionName: string, - options: { data?: string; timeout?: string; method?: string; header?: Record }, + options: { + data?: string; + timeout?: string; + method?: string; + header?: Record; + }, ): Promise { const data = options.data ? parseJsonArg(options.data) : {}; const method = options.method?.toUpperCase() ?? "POST"; @@ -43,9 +52,7 @@ async function invokeFunctionAction( ? parseInt(options.timeout, 10) * 1000 : undefined; - log.info( - `Invoking function ${theme.styles.bold(functionName)} (${method})`, - ); + log.info(`Invoking function ${theme.styles.bold(functionName)} (${method})`); const result = await runTask( "Running function", @@ -77,7 +84,12 @@ export function getFunctionsInvokeCommand(context: CLIContext): Command { .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( + "-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)") .action(async (functionName: string, options) => { diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts index b0fb8ec2..eb22bbe7 100644 --- a/src/core/resources/function/invoke.ts +++ b/src/core/resources/function/invoke.ts @@ -22,7 +22,11 @@ const METHODS_WITH_BODY = new Set(["POST", "PUT", "PATCH"]); export async function invokeFunction( functionName: string, data: Record, - options?: { timeout?: number; method?: string; headers?: Record }, + options?: { + timeout?: number; + method?: string; + headers?: Record; + }, ): Promise { const { id } = getAppConfig(); const method = (options?.method?.toUpperCase() ?? "POST") as HttpMethod; diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index ff597ed4..f96320e6 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; @@ -246,7 +242,9 @@ export class Base44APIMock { } /** Mock GET /api/apps/{appId}/external-auth/status - Get OAuth status */ - mockConnectorOAuthStatus(response: ConnectorOAuthStatusResponse): this { + mockConnectorOAuthStatus(response: { + status: "ACTIVE" | "FAILED" | "PENDING"; + }): this { this.handlers.push( http.get(`${BASE_URL}/api/apps/${this.appId}/external-auth/status`, () => HttpResponse.json(response), From 996b8748c086cc261b38a0809773b14ae0bb95b0 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:56:40 +0000 Subject: [PATCH 5/8] fix: remove unused mockConnectorOAuthStatus method The mockConnectorOAuthStatus method was added to the test utilities but is not actually used by any tests or the functions invoke feature. This was causing the Knip check (unused exports detector) to fail. Co-authored-by: Netanel Gilad --- docs/testing.md | 1 - tests/cli/testkit/Base44APIMock.ts | 12 ------------ 2 files changed, 13 deletions(-) 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/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index 4a1bf507..240c6f97 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -241,18 +241,6 @@ export class Base44APIMock { return this; } - /** Mock GET /api/apps/{appId}/external-auth/status - Get OAuth status */ - mockConnectorOAuthStatus(response: { - status: "ACTIVE" | "FAILED" | "PENDING"; - }): this { - this.handlers.push( - http.get(`${BASE_URL}/api/apps/${this.appId}/external-auth/status`, () => - HttpResponse.json(response), - ), - ); - return this; - } - /** Mock DELETE /api/apps/{appId}/external-auth/integrations/{type}/remove */ mockConnectorRemove(response: ConnectorRemoveResponse): this { this.handlers.push( From 736d74daf09a75a9caa32d4854eeac5b5d7f392b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:18:10 +0000 Subject: [PATCH 6/8] feat: improve error handling and add test coverage for functions invoke - Add try/catch with ApiError.fromHttpError() in invoke.ts for better error messages - Add comprehensive test suite with 9 test cases covering success and error scenarios - Tests include: POST/GET methods, custom headers, JSON data, error handling Co-authored-by: Netanel Gilad --- src/core/resources/function/invoke.ts | 29 ++-- tests/cli/functions_invoke.spec.ts | 190 ++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 tests/cli/functions_invoke.spec.ts diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts index eb22bbe7..96669996 100644 --- a/src/core/resources/function/invoke.ts +++ b/src/core/resources/function/invoke.ts @@ -1,9 +1,11 @@ +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"; @@ -45,17 +47,22 @@ export async function invokeFunction( } } - const response = await ky(url, { - method, - ...(METHODS_WITH_BODY.has(method) ? { json: data } : {}), - headers: { - Authorization: `Bearer ${token}`, - "X-App-Id": id, - "User-Agent": "Base44 CLI", - ...options?.headers, - }, - timeout: options?.timeout ?? 300_000, - }); + let response: KyResponse; + try { + response = await ky(url, { + method, + ...(METHODS_WITH_BODY.has(method) ? { json: data } : {}), + headers: { + Authorization: `Bearer ${token}`, + "X-App-Id": id, + "User-Agent": "Base44 CLI", + ...options?.headers, + }, + timeout: options?.timeout ?? 300_000, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "invoking function"); + } return response.json(); } diff --git a/tests/cli/functions_invoke.spec.ts b/tests/cli/functions_invoke.spec.ts new file mode 100644 index 00000000..5fae05c1 --- /dev/null +++ b/tests/cli/functions_invoke.spec.ts @@ -0,0 +1,190 @@ +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"); + }); + + 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"); + } + }); +}); From da31ffb2af7b9e0cfd5a1286f7749aa4332ff94d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:39:49 +0000 Subject: [PATCH 7/8] feat: add verbose mode (-v) to functions invoke command - Added curl-like verbose output with -v flag - Shows request details (method, URL, headers) with redacted auth token - Shows response status, headers, and timing (duration in ms) - Returns full metadata from invokeFunction (InvokeFunctionResult interface) - Added 5 comprehensive tests for verbose output format - All 14 tests pass Co-authored-by: Netanel Gilad --- src/cli/commands/functions/invoke.ts | 56 +++++++++- src/core/resources/function/invoke.ts | 58 +++++++++-- tests/cli/functions_invoke.spec.ts | 143 ++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/functions/invoke.ts b/src/cli/commands/functions/invoke.ts index 726a3f21..a171e9ac 100644 --- a/src/cli/commands/functions/invoke.ts +++ b/src/cli/commands/functions/invoke.ts @@ -44,6 +44,7 @@ async function invokeFunctionAction( timeout?: string; method?: string; header?: Record; + verbose?: boolean; }, ): Promise { const data = options.data ? parseJsonArg(options.data) : {}; @@ -52,7 +53,11 @@ async function invokeFunctionAction( ? parseInt(options.timeout, 10) * 1000 : undefined; - log.info(`Invoking function ${theme.styles.bold(functionName)} (${method})`); + if (!options.verbose) { + log.info( + `Invoking function ${theme.styles.bold(functionName)} (${method})`, + ); + } const result = await runTask( "Running function", @@ -64,18 +69,55 @@ async function invokeFunctionAction( }); }, { - successMessage: "Function executed successfully", + 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 === "string" ? result : JSON.stringify(result, null, 2); + typeof result.body === "string" + ? result.body + : JSON.stringify(result.body, null, 2); - log.info(`Response:\n${output}`); + if (options.verbose) { + log.info(output); + } else { + log.info(`Response:\n${output}`); + } return { - outroMessage: `Function ${theme.styles.bold(functionName)} completed`, + outroMessage: options.verbose + ? undefined + : `Function ${theme.styles.bold(functionName)} completed`, }; } @@ -92,6 +134,10 @@ export function getFunctionsInvokeCommand(context: CLIContext): Command { ) .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), diff --git a/src/core/resources/function/invoke.ts b/src/core/resources/function/invoke.ts index 96669996..bcf216a6 100644 --- a/src/core/resources/function/invoke.ts +++ b/src/core/resources/function/invoke.ts @@ -13,13 +13,32 @@ 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 + * @returns The function's response data and metadata */ export async function invokeFunction( functionName: string, @@ -29,7 +48,7 @@ export async function invokeFunction( method?: string; headers?: Record; }, -): Promise { +): Promise { const { id } = getAppConfig(); const method = (options?.method?.toUpperCase() ?? "POST") as HttpMethod; @@ -47,22 +66,43 @@ export async function invokeFunction( } } + 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: { - Authorization: `Bearer ${token}`, - "X-App-Id": id, - "User-Agent": "Base44 CLI", - ...options?.headers, - }, + 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 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 index 5fae05c1..cc31023e 100644 --- a/tests/cli/functions_invoke.spec.ts +++ b/tests/cli/functions_invoke.spec.ts @@ -30,6 +30,7 @@ describe("functions invoke command", () => { 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 () => { @@ -187,4 +188,146 @@ describe("functions invoke command", () => { 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*{/); + }); }); From 2d761b009ee9a291b5aba1351766ce368365e261 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 18 Feb 2026 16:42:23 +0000 Subject: [PATCH 8/8] docs: update README to match CLI (command table, install, or quick start) --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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