Skip to content
Open
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


<!--| [`eject`](https://docs.base44.com/developers/references/cli/commands/eject) | Create a Base44 backend project from an existing Base44 app | -->
| [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources |

## AI agent skills

Expand Down
1 change: 0 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" } });
Expand Down
4 changes: 3 additions & 1 deletion src/cli/commands/functions/deploy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -66,5 +67,6 @@ export function getFunctionsDeployCommand(context: CLIContext): Command {
context,
);
}),
);
)
.addCommand(getFunctionsInvokeCommand(context));
}
148 changes: 148 additions & 0 deletions src/cli/commands/functions/invoke.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
): Record<string, string> {
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<string, unknown> {
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<string, unknown>;
} 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<string, string>;
verbose?: boolean;
},
): Promise<RunCommandResult> {
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("<function-name>", "Name of the function to invoke")
.option("-X, --method <verb>", "HTTP method (default: POST)")
.option(
"-H, --header <header>",
"Custom header (Name: Value), repeatable",
collectHeader,
{},
)
.option("-d, --data <json>", "JSON data to send to the function")
.option("-t, --timeout <seconds>", "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,
);
});
}
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 "./invoke.js";
export * from "./resource.js";
export * from "./schema.js";
108 changes: 108 additions & 0 deletions src/core/resources/function/invoke.ts
Original file line number Diff line number Diff line change
@@ -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<HttpMethod>(["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<string, string>;
/** Request URL */
url: string;
/** Request method */
method: string;
/** Request headers that were sent */
requestHeaders: Record<string, string>;
/** 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<string, unknown>,
options?: {
timeout?: number;
method?: string;
headers?: Record<string, string>;
},
): Promise<InvokeFunctionResult> {
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<string, string> = {};
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,
};
}
Loading
Loading