diff --git a/README.md b/README.md index 75b86528..5b7cf0bf 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`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 entities to Base44 | | [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 | +| `logs` | Fetch function logs for this app | | [`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 | diff --git a/src/cli/commands/project/logs.ts b/src/cli/commands/project/logs.ts new file mode 100644 index 00000000..a4c10113 --- /dev/null +++ b/src/cli/commands/project/logs.ts @@ -0,0 +1,229 @@ +import { Command, Option } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { ApiError, InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import type { + FunctionLogFilters, + FunctionLogsResponse, +} from "@/core/resources/function/index.js"; +import { fetchFunctionLogs } from "@/core/resources/function/index.js"; + +interface LogsOptions { + function?: string; + since?: string; + until?: string; + limit?: string; + order?: string; + json?: boolean; +} + +/** + * Unified log entry for display. + */ +interface LogEntry { + time: string; + level: string; + message: string; + source: string; // function name +} + +function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { + const filters: FunctionLogFilters = {}; + + if (options.since) { + filters.since = options.since; + } + + if (options.until) { + filters.until = options.until; + } + + if (options.limit) { + filters.limit = Number.parseInt(options.limit, 10); + } + + if (options.order) { + filters.order = options.order.toLowerCase() as "asc" | "desc"; + } + + return filters; +} + +function parseFunctionNames(option: string | undefined): string[] { + if (!option) return []; + return option + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function normalizeDatetime(value: string): string { + if (/Z$|[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function formatEntry(entry: LogEntry): string { + const time = entry.time.substring(0, 19).replace("T", " "); + const level = entry.level.toUpperCase().padEnd(5); + const message = entry.message.trim(); + return `${time} ${level} ${message}`; +} + +/** + * Build function logs output (log-file style). + */ +function formatLogs(entries: LogEntry[]): string { + if (entries.length === 0) { + return "No logs found matching the filters.\n"; + } + + const header = `Showing ${entries.length} function log entries\n`; + return [header, ...entries.map(formatEntry)].join("\n"); +} + +function normalizeLogEntry( + entry: { time: string; level: string; message: string }, + functionName: string, +): LogEntry { + return { + time: entry.time, + level: entry.level, + message: `[${functionName}] ${entry.message}`, + source: functionName, + }; +} + +async function fetchLogsForFunctions( + functionNames: string[], + options: LogsOptions, + availableFunctionNames: string[], +): Promise { + const filters = parseFunctionFilters(options); + const allEntries: LogEntry[] = []; + + for (const functionName of functionNames) { + let logs: FunctionLogsResponse; + try { + logs = await fetchFunctionLogs(functionName, filters); + } catch (error) { + if ( + error instanceof ApiError && + error.statusCode === 404 && + availableFunctionNames.length > 0 + ) { + const available = availableFunctionNames.join(", "); + throw new InvalidInputError( + `Function "${functionName}" was not found in this app`, + { + hints: [ + { + message: `Available functions in this project: ${available}`, + }, + { + message: + "Make sure the function has been deployed before fetching logs", + command: "base44 functions deploy", + }, + ], + }, + ); + } + throw error; + } + + const entries = logs.map((entry) => normalizeLogEntry(entry, functionName)); + allEntries.push(...entries); + } + + // When fetching multiple functions, merge-sort the combined results + // (each function's logs are already sorted by the backend) + if (functionNames.length > 1) { + const order = options.order?.toUpperCase() === "ASC" ? 1 : -1; + allEntries.sort((a, b) => order * a.time.localeCompare(b.time)); + } + + return allEntries; +} + +async function getAllFunctionNames(): Promise { + const { functions } = await readProjectConfig(); + return functions.map((fn) => fn.name); +} + +async function logsAction(options: LogsOptions): Promise { + const specifiedFunctions = parseFunctionNames(options.function); + + // Always read project functions so we can list them in error messages + const allProjectFunctions = await getAllFunctionNames(); + + // Determine which functions to fetch logs for + const functionNames = + specifiedFunctions.length > 0 ? specifiedFunctions : allProjectFunctions; + + if (functionNames.length === 0) { + return { outroMessage: "No functions found in this project." }; + } + + let entries = await fetchLogsForFunctions( + functionNames, + options, + allProjectFunctions, + ); + + // Apply limit after merging logs from all functions + const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined; + if (limit !== undefined && entries.length > limit) { + entries = entries.slice(0, limit); + } + + const logsOutput = options.json + ? `${JSON.stringify(entries, null, 2)}\n` + : formatLogs(entries); + + return { outroMessage: "Fetched logs", stdout: logsOutput }; +} + +export function getLogsCommand(context: CLIContext): Command { + return new Command("logs") + .description("Fetch function logs for this app") + .option( + "--function ", + "Filter by function name(s), comma-separated. If omitted, fetches logs for all project functions", + ) + .option( + "--since ", + "Show logs from this time (ISO format)", + normalizeDatetime, + ) + .option( + "--until ", + "Show logs until this time (ISO format)", + normalizeDatetime, + ) + .option( + "-n, --limit ", + "Results per page (1-1000, default: 50)", + (v) => { + const n = Number.parseInt(v, 10); + if (Number.isNaN(n) || n < 1 || n > 1000) { + throw new InvalidInputError( + `Invalid limit: "${v}". Must be a number between 1 and 1000.`, + ); + } + return v; + }, + ) + .addOption( + new Option("--order ", "Sort order").choices(["asc", "desc"]), + ) + .option("--json", "Output raw JSON") + .action(async (options: LogsOptions) => { + await runCommand( + () => logsAction(options), + { requireAuth: true }, + context, + ); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index ec50d379..219e5e9c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,6 +10,7 @@ import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.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"; +import { getLogsCommand } from "@/cli/commands/project/logs.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import packageJson from "../../package.json"; @@ -64,5 +65,8 @@ export function createProgram(context: CLIContext): Command { // Register development commands program.addCommand(getDevCommand(context), { hidden: true }); + // Register logs command + program.addCommand(getLogsCommand(context)); + return program; } diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index f7352145..b8cdb6cd 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -31,6 +31,11 @@ interface RunCommandOptions { export interface RunCommandResult { outroMessage?: string; + /** + * Raw text to write to stdout after the command UI (intro/outro) finishes. + * Useful for commands that produce machine-readable or pipeable output. + */ + stdout?: string; } /** @@ -65,15 +70,12 @@ export async function runCommand( options: RunCommandOptions | undefined, context: CLIContext, ): Promise { - console.log(); - if (options?.fullBanner) { await printBanner(context.isNonInteractive); intro(""); } else { intro(theme.colors.base44OrangeBackground(" Base 44 ")); } - await printUpgradeNotificationIfAvailable(); try { @@ -102,8 +104,12 @@ export async function runCommand( context.errorReporter.setContext({ appId: appConfig.id }); } - const { outroMessage } = await commandFn(); - outro(outroMessage || ""); + const result = await commandFn(); + outro(result.outroMessage || ""); + + if (result.stdout) { + process.stdout.write(result.stdout); + } } catch (error) { // Display error message const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/core/errors.ts b/src/core/errors.ts index c16841d6..d43e135c 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -304,9 +304,11 @@ export class ApiError extends SystemError { * Creates an ApiError from a caught error (typically HTTPError from ky). * Extracts status code, request info, and response body for error reporting. * + * Normalizes backend KeyError responses (Python dict lookup failures) to + * 404 status, since they represent "resource not found" conditions. + * * @param error - The caught error (HTTPError, Error, or unknown) * @param context - Description of what operation failed (e.g., "syncing agents") - * @returns ApiError with formatted message, status code, and request/response data * * @example * try { @@ -334,12 +336,16 @@ export class ApiError extends SystemError { message = error.message; } + const statusCode = ApiError.normalizeStatusCode( + error.response.status, + responseBody, + ); const requestBody = error.options.context?.__requestBody; return new ApiError( `Error ${context}: ${message}`, { - statusCode: error.response.status, + statusCode, requestUrl: error.request.url, requestMethod: error.request.method, requestBody, @@ -414,6 +420,23 @@ export class ApiError extends SystemError { return REASON_HINTS[reason]; } + + /** + * Backend KeyError responses (Python dict lookup failures) are semantically + * "not found" — normalize them to 404. + */ + private static normalizeStatusCode( + statusCode: number, + responseBody: unknown, + ): number { + if ( + (responseBody as Record | null)?.error_type === + "KeyError" + ) { + return 404; + } + return statusCode; + } } /** diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 6337248b..43e0dbf8 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -3,9 +3,14 @@ import { getAppClient } from "@/core/clients/index.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { DeployFunctionsResponse, + FunctionLogFilters, + FunctionLogsResponse, FunctionWithCode, } from "@/core/resources/function/schema.js"; -import { DeployFunctionsResponseSchema } from "@/core/resources/function/schema.js"; +import { + DeployFunctionsResponseSchema, + FunctionLogsResponseSchema, +} from "@/core/resources/function/schema.js"; function toDeployPayloadItem(fn: FunctionWithCode) { return { @@ -45,3 +50,64 @@ export async function deployFunctions( return result.data; } + +// ─── FUNCTION LOGS API ────────────────────────────────────── + +/** + * Build query string from filter options. + */ +function buildLogsQueryString(filters: FunctionLogFilters): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.since) { + params.set("since", filters.since); + } + if (filters.until) { + params.set("until", filters.until); + } + if (filters.level) { + params.set("level", filters.level); + } + if (filters.limit !== undefined) { + params.set("limit", String(filters.limit)); + } + if (filters.order) { + params.set("order", filters.order); + } + + return params; +} + +/** + * Fetch runtime logs for a specific function from Deno Deploy. + */ +export async function fetchFunctionLogs( + functionName: string, + filters: FunctionLogFilters = {}, +): Promise { + const appClient = getAppClient(); + const searchParams = buildLogsQueryString(filters); + + let response: KyResponse; + try { + response = await appClient.get(`functions-mgmt/${functionName}/logs`, { + searchParams, + }); + } catch (error) { + throw await ApiError.fromHttpError( + error, + `fetching function logs: '${functionName}'`, + ); + } + + const result = FunctionLogsResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid function logs response from server", + result.error, + ); + } + + return result.data; +} diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index 6da71f15..8371274a 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -103,3 +103,28 @@ export type DeployFunctionsResponse = z.infer< export type FunctionWithCode = Omit & { files: FunctionFile[]; }; + +/** + * Log level from Deno Deploy runtime. + */ +const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]); + +export type LogLevel = z.infer; + +const FunctionLogEntrySchema = z.object({ + time: z.string(), + level: LogLevelSchema, + message: z.string(), +}); + +export const FunctionLogsResponseSchema = z.array(FunctionLogEntrySchema); + +export type FunctionLogsResponse = z.infer; + +export interface FunctionLogFilters { + since?: string; + until?: string; + level?: LogLevel; + limit?: number; + order?: "asc" | "desc"; +} diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts new file mode 100644 index 00000000..ef244614 --- /dev/null +++ b/tests/cli/logs.spec.ts @@ -0,0 +1,151 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("logs command", () => { + const t = setupCLITests(); + + it("fetches and displays function logs when --function is specified", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Processing request", + }, + { + time: "2024-01-15T10:30:00.050Z", + level: "error", + message: "Something went wrong", + }, + ]); + + const result = await t.run("logs", "--function", "my-function"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Showing 2 function log entries"); + t.expectResult(result).toContain("Processing request"); + t.expectResult(result).toContain("Something went wrong"); + }); + + it("fetches logs for multiple functions with --function comma-separated", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("fn1", [ + { time: "2024-01-15T10:30:00Z", level: "info", message: "From fn1" }, + ]); + t.api.mockFunctionLogs("fn2", [ + { time: "2024-01-15T10:29:00Z", level: "info", message: "From fn2" }, + ]); + + const result = await t.run("logs", "--function", "fn1,fn2"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("From fn1"); + t.expectResult(result).toContain("From fn2"); + }); + + it("fetches logs for all project functions when no --function specified", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + t.api.mockFunctionLogs("hello", [ + { time: "2024-01-15T10:29:00Z", level: "log", message: "Hello world" }, + ]); + + const result = await t.run("logs"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Hello world"); + }); + + it("shows no functions message when project has no functions", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No functions found in this project"); + }); + + it("shows no logs message when empty", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", []); + + const result = await t.run("logs", "--function", "my-function"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No logs found matching the filters."); + }); + + it("outputs JSON with --json flag", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Test log", + }, + ]); + + const result = await t.run("logs", "--function", "my-function", "--json"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain('"time"'); + t.expectResult(result).toContain('"level"'); + t.expectResult(result).toContain('"message"'); + t.expectResult(result).toContain('"source"'); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("logs"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("fails when API returns error for function logs", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogsError("my-function", { + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("logs", "--function", "my-function"); + + t.expectResult(result).toFail(); + }); + + it("fails with invalid limit option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs", "--limit", "9999"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid limit"); + }); + + it("fails with invalid order option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs", "--order", "RANDOM"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("is invalid"); + }); + + it("passes filter options to API", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", []); + + const result = await t.run( + "logs", + "--function", + "my-function", + "--limit", + "10", + "--order", + "asc", + ); + + t.expectResult(result).toSucceed(); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index a8987f8e..3b2a1273 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -57,6 +57,14 @@ interface AgentsFetchResponse { total: number; } +interface FunctionLogEntry { + time: string; + level: "log" | "info" | "warn" | "error" | "debug"; + message: string; +} + +type FunctionLogsResponse = FunctionLogEntry[]; + interface ConnectorsListResponse { integrations: Array<{ integration_type: string; @@ -75,10 +83,6 @@ interface ConnectorSetResponse { other_user_email?: string; } -interface ConnectorOAuthStatusResponse { - status: "ACTIVE" | "FAILED" | "PENDING"; -} - interface ConnectorRemoveResponse { status: "removed"; integration_type: string; @@ -256,6 +260,17 @@ export class Base44APIMock { return this; } + /** Mock GET /api/apps/{appId}/functions-mgmt/{functionName}/logs - Fetch function logs */ + mockFunctionLogs(functionName: string, response: FunctionLogsResponse): this { + this.handlers.push( + http.get( + `${BASE_URL}/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, + () => HttpResponse.json(response), + ), + ); + return this; + } + // ─── GENERAL ENDPOINTS ───────────────────────────────────── /** Mock POST /api/apps - Create new app */ @@ -359,6 +374,17 @@ export class Base44APIMock { ); } + /** Mock function logs to return an error */ + mockFunctionLogsError(functionName: string, error: ErrorResponse): this { + return this.mockError( + "get", + `/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, + error, + ); + } + + /** Mock token endpoint to return an error (for auth failure testing) */ + /** Mock connectors list to return an error */ mockConnectorsListError(error: ErrorResponse): this { return this.mockError(