From dbd14a4ddacd9417bdec17f5bd79f3fa3d98838e Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 10 Feb 2026 13:48:17 +0200 Subject: [PATCH 1/9] implement function logs --- src/cli/commands/logs/index.ts | 345 ++++++++++++++++++++++++++ src/cli/program.ts | 4 + src/core/resources/function/api.ts | 74 +++++- src/core/resources/function/schema.ts | 38 +++ tests/cli/logs.spec.ts | 191 ++++++++++++++ tests/cli/testkit/Base44APIMock.ts | 28 +++ 6 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands/logs/index.ts create mode 100644 tests/cli/logs.spec.ts diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts new file mode 100644 index 00000000..16a0a45f --- /dev/null +++ b/src/cli/commands/logs/index.ts @@ -0,0 +1,345 @@ +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand, runTask, theme } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/index.js"; +import type { + FunctionLogFilters, + LogLevel, +} from "@/core/resources/function/index.js"; +import { fetchFunctionLogs } from "@/core/resources/function/index.js"; + +// ─── TYPES ────────────────────────────────────────────────── + +interface LogsOptions { + function?: string; + since?: string; + until?: string; + level?: string; + limit?: string; + order?: string; + json?: boolean; +} + +/** + * Unified log entry for display. + */ +interface LogEntry { + time: string; + level: string; + message: string; + source: string; // function name +} + +// ─── CONSTANTS ────────────────────────────────────────────── + +const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; + +// ─── OPTION PARSING ───────────────────────────────────────── + +/** + * Parse CLI options into FunctionLogFilters. + */ +function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { + const filters: FunctionLogFilters = {}; + + if (options.since) { + filters.since = options.since; + } + + if (options.until) { + filters.until = options.until; + } + + if (options.level) { + filters.level = options.level as LogLevel; + } + + if (options.limit) { + filters.limit = Number.parseInt(options.limit, 10); + } + + if (options.order) { + filters.order = options.order.toLowerCase() as "asc" | "desc"; + } + + return filters; +} + +/** + * Parse --function option into array of function names. + */ +function parseFunctionNames(option: string | undefined): string[] { + if (!option) return []; + return option + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Ensure datetime has a timezone (append Z if missing) for APIs that require it. + */ +function normalizeDatetime(value: string): string { + if (/Z|[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +/** + * Validate CLI options upfront before any API calls. + */ +function validateOptions(options: LogsOptions): void { + if (options.level && !VALID_LEVELS.includes(options.level)) { + throw new InvalidInputError( + `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` + ); + } + if (options.limit) { + const limit = Number.parseInt(options.limit, 10); + if (Number.isNaN(limit) || limit < 1 || limit > 1000) { + throw new InvalidInputError( + `Invalid limit: "${options.limit}". Must be a number between 1 and 1000.` + ); + } + } + if (options.order) { + const order = options.order.toUpperCase(); + if (order !== "ASC" && order !== "DESC") { + throw new InvalidInputError( + `Invalid order: "${options.order}". Must be "ASC" or "DESC".` + ); + } + } +} + +// ─── DISPLAY ──────────────────────────────────────────────── + +/** + * Get color/style for a log level. + */ +function formatLevel(level: string): string { + switch (level) { + case "error": + return theme.colors.base44Orange(level.padEnd(5)); + case "warn": + return theme.colors.shinyOrange(level.padEnd(5)); + case "info": + return theme.colors.links(level.padEnd(5)); + case "debug": + return theme.styles.dim(level.padEnd(5)); + default: + return level.padEnd(5); + } +} + +/** + * Wrap a single line at specified width, returning array of lines. + */ +function wrapLine(text: string, width: number): string[] { + if (text.length <= width) return [text]; + + const lines: string[] = []; + let remaining = text; + + while (remaining.length > width) { + // Find last space within width, or break at width if no space + let breakPoint = remaining.lastIndexOf(" ", width); + if (breakPoint <= 0) breakPoint = width; + + lines.push(remaining.substring(0, breakPoint)); + remaining = remaining.substring(breakPoint).trimStart(); + } + + if (remaining.length > 0) { + lines.push(remaining); + } + + return lines; +} + +// Column widths: TIME(19) + 2 spaces + LEVEL(5) + 2 spaces = 28 chars before message +const MESSAGE_INDENT = " ".repeat(28); +const MESSAGE_WIDTH = 80; + +/** + * Format a log entry for display. + * Preserves original newlines in the message and wraps long lines. + */ +function formatEntry(entry: LogEntry): string { + const time = entry.time.substring(0, 19).replace("T", " "); + const level = formatLevel(entry.level); + + // Split by original newlines first, then wrap each line + const originalLines = entry.message.split("\n"); + const allLines: string[] = []; + + for (const line of originalLines) { + const wrappedLines = wrapLine(line, MESSAGE_WIDTH); + allLines.push(...wrappedLines); + } + + const firstLine = `${theme.styles.dim(time)} ${level} ${allLines[0] ?? ""}`; + + if (allLines.length <= 1) { + return firstLine; + } + + // Join continuation lines with proper indentation + const continuationLines = allLines + .slice(1) + .map((line) => `${MESSAGE_INDENT}${line}`) + .join("\n"); + + return `${firstLine}\n${continuationLines}`; +} + +/** + * Display function logs. + */ +function displayLogs(entries: LogEntry[]): void { + if (entries.length === 0) { + log.info("No logs found matching the filters."); + return; + } + + log.info( + theme.styles.dim(`Showing ${entries.length} function log entries\n`) + ); + + const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; + log.message(theme.styles.header(header)); + + for (const entry of entries) { + log.message(formatEntry(entry)); + } +} + +// ─── ACTIONS ──────────────────────────────────────────────── + +/** + * Normalize a function log entry to display format. + */ +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, + }; +} + +/** + * Fetch logs for specified functions. + */ +async function fetchLogsForFunctions( + functionNames: string[], + options: LogsOptions +): Promise { + const filters = parseFunctionFilters(options); + const allEntries: LogEntry[] = []; + + for (const functionName of functionNames) { + const logs = await runTask( + `Fetching logs for "${functionName}"...`, + async () => { + return await fetchFunctionLogs(functionName, filters); + }, + { + successMessage: `Logs for "${functionName}" fetched`, + errorMessage: `Failed to fetch logs for "${functionName}"`, + } + ); + + 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; +} + +/** + * Get all function names from project config. + */ +async function getAllFunctionNames(): Promise { + const { functions } = await readProjectConfig(); + return functions.map((fn) => fn.name); +} + +/** + * Main logs action. + */ +async function logsAction(options: LogsOptions): Promise { + if (options.since) options.since = normalizeDatetime(options.since); + if (options.until) options.until = normalizeDatetime(options.until); + validateOptions(options); + + const specifiedFunctions = parseFunctionNames(options.function); + + // Determine which functions to fetch logs for + const functionNames = + specifiedFunctions.length > 0 + ? specifiedFunctions + : await getAllFunctionNames(); + + if (functionNames.length === 0) { + log.info("No functions found in this project."); + return {}; + } + + let entries = await fetchLogsForFunctions(functionNames, options); + + // 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); + } + + if (options.json) { + process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`); + } else { + displayLogs(entries); + } + + return {}; +} + +// ─── COMMAND ──────────────────────────────────────────────── + +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)") + .option("--until ", "Show logs until this time (ISO format)") + .option( + "--level ", + "Filter by log level: log, info, warn, error, debug" + ) + .option("-n, --limit ", "Results per page (1-1000, default: 50)") + .option("--order ", "Sort order: ASC|DESC (default: 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 15e4f1d7..024b71e1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -6,6 +6,7 @@ import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; +import { getLogsCommand } from "@/cli/commands/logs/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; import { getLinkCommand } from "@/cli/commands/project/link.js"; @@ -54,5 +55,8 @@ export function createProgram(context: CLIContext): Command { // Register types command program.addCommand(getTypesCommand(context), { hidden: true }); + // Register logs command + program.addCommand(getLogsCommand(context)); + return program; } diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 03974785..2f09b4ea 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,11 +1,17 @@ import type { KyResponse } from "ky"; +import { HTTPError } from "ky"; 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 +51,69 @@ export async function deployFunctions( return result.data; } + +// ─── FUNCTION LOGS API ────────────────────────────────────── + +/** + * Build query string from filter options. + */ +function buildLogsQueryString(filters: FunctionLogFilters): string { + 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); + } + + const queryString = params.toString(); + return queryString ? `?${queryString}` : ""; +} + +/** + * Fetch runtime logs for a specific function from Deno Deploy. + */ +export async function fetchFunctionLogs( + functionName: string, + filters: FunctionLogFilters = {} +): Promise { + const appClient = getAppClient(); + const queryString = buildLogsQueryString(filters); + + let response: KyResponse; + try { + response = await appClient.get( + `functions-mgmt/${functionName}/logs${queryString}` + ); + } catch (error) { + if (error instanceof HTTPError && error.response.status === 404) { + throw new ApiError(`Function "${functionName}" not found`, { + statusCode: 404, + cause: error, + hints: [{ message: "Check the function name and try again" }], + }); + } + throw await ApiError.fromHttpError(error, "fetching function logs"); + } + + 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..80109d69 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -103,3 +103,41 @@ export type DeployFunctionsResponse = z.infer< export type FunctionWithCode = Omit & { files: FunctionFile[]; }; + +// ─── FUNCTION LOGS SCHEMAS ────────────────────────────────── + +/** + * Log level from Deno Deploy runtime. + */ +export const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]); + +export type LogLevel = z.infer; + +/** + * Single log entry from the function runtime (Deno Deploy). + */ +export const FunctionLogEntrySchema = z.object({ + time: z.string(), + level: LogLevelSchema, + message: z.string(), +}); + +export type FunctionLogEntry = z.infer; + +/** + * Response from the function logs API - array of log entries. + */ +export const FunctionLogsResponseSchema = z.array(FunctionLogEntrySchema); + +export type FunctionLogsResponse = z.infer; + +/** + * CLI filter options for function logs. + */ +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..c9733ec2 --- /dev/null +++ b/tests/cli/logs.spec.ts @@ -0,0 +1,191 @@ +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('Logs for "my-function" fetched'); + 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("filters function logs by --level", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + // Backend filters by level, so mock returns only matching entries + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.050Z", + level: "error", + message: "Error message", + }, + ]); + + const result = await t.run( + "logs", + "--function", + "my-function", + "--level", + "error" + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Error message"); + t.expectResult(result).toNotContain("Info message"); + }); + + 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 level option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run( + "logs", + "--function", + "dummy", + "--level", + "invalid" + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid level"); + }); + + 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("Invalid order"); + }); + + 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 c1e1d4ec..ada3be22 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -57,6 +57,14 @@ export interface AgentsFetchResponse { total: number; } +export interface FunctionLogEntry { + time: string; + level: "log" | "info" | "warn" | "error" | "debug"; + message: string; +} + +export type FunctionLogsResponse = FunctionLogEntry[]; + export interface CreateAppResponse { id: string; name: string; @@ -182,6 +190,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 */ @@ -263,6 +282,15 @@ 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) */ mockTokenError(error: ErrorResponse): this { return this.mockError("post", "/oauth/token", error); From dbf69e2cf045043a7f67d16f42f6065d74264eba Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 10 Feb 2026 14:04:22 +0200 Subject: [PATCH 2/9] print logs like log files --- src/cli/commands/logs/index.ts | 108 ++++----------------------------- src/cli/utils/runCommand.ts | 41 ++++++++++--- tests/cli/logs.spec.ts | 1 - 3 files changed, 43 insertions(+), 107 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 16a0a45f..fe2c86fc 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -1,7 +1,6 @@ -import { log } from "@clack/prompts"; import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; -import { runCommand, runTask, theme } from "@/cli/utils/index.js"; +import { runCommand } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; @@ -117,102 +116,28 @@ function validateOptions(options: LogsOptions): void { // ─── DISPLAY ──────────────────────────────────────────────── /** - * Get color/style for a log level. - */ -function formatLevel(level: string): string { - switch (level) { - case "error": - return theme.colors.base44Orange(level.padEnd(5)); - case "warn": - return theme.colors.shinyOrange(level.padEnd(5)); - case "info": - return theme.colors.links(level.padEnd(5)); - case "debug": - return theme.styles.dim(level.padEnd(5)); - default: - return level.padEnd(5); - } -} - -/** - * Wrap a single line at specified width, returning array of lines. - */ -function wrapLine(text: string, width: number): string[] { - if (text.length <= width) return [text]; - - const lines: string[] = []; - let remaining = text; - - while (remaining.length > width) { - // Find last space within width, or break at width if no space - let breakPoint = remaining.lastIndexOf(" ", width); - if (breakPoint <= 0) breakPoint = width; - - lines.push(remaining.substring(0, breakPoint)); - remaining = remaining.substring(breakPoint).trimStart(); - } - - if (remaining.length > 0) { - lines.push(remaining); - } - - return lines; -} - -// Column widths: TIME(19) + 2 spaces + LEVEL(5) + 2 spaces = 28 chars before message -const MESSAGE_INDENT = " ".repeat(28); -const MESSAGE_WIDTH = 80; - -/** - * Format a log entry for display. - * Preserves original newlines in the message and wraps long lines. + * Format a single log entry as a plain log-file line. */ function formatEntry(entry: LogEntry): string { const time = entry.time.substring(0, 19).replace("T", " "); - const level = formatLevel(entry.level); - - // Split by original newlines first, then wrap each line - const originalLines = entry.message.split("\n"); - const allLines: string[] = []; - - for (const line of originalLines) { - const wrappedLines = wrapLine(line, MESSAGE_WIDTH); - allLines.push(...wrappedLines); - } - - const firstLine = `${theme.styles.dim(time)} ${level} ${allLines[0] ?? ""}`; - - if (allLines.length <= 1) { - return firstLine; - } - - // Join continuation lines with proper indentation - const continuationLines = allLines - .slice(1) - .map((line) => `${MESSAGE_INDENT}${line}`) - .join("\n"); - - return `${firstLine}\n${continuationLines}`; + const level = entry.level.toUpperCase().padEnd(5); + const message = entry.message.trim(); + return `${time} ${level} ${message}\n`; } /** - * Display function logs. + * Display function logs (log-file style, plain stdout). */ function displayLogs(entries: LogEntry[]): void { if (entries.length === 0) { - log.info("No logs found matching the filters."); + process.stdout.write("No logs found matching the filters.\n"); return; } - log.info( - theme.styles.dim(`Showing ${entries.length} function log entries\n`) - ); - - const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; - log.message(theme.styles.header(header)); + process.stdout.write(`Showing ${entries.length} function log entries\n\n`); for (const entry of entries) { - log.message(formatEntry(entry)); + process.stdout.write(formatEntry(entry)); } } @@ -244,16 +169,7 @@ async function fetchLogsForFunctions( const allEntries: LogEntry[] = []; for (const functionName of functionNames) { - const logs = await runTask( - `Fetching logs for "${functionName}"...`, - async () => { - return await fetchFunctionLogs(functionName, filters); - }, - { - successMessage: `Logs for "${functionName}" fetched`, - errorMessage: `Failed to fetch logs for "${functionName}"`, - } - ); + const logs = await fetchFunctionLogs(functionName, filters); const entries = logs.map((entry) => normalizeLogEntry(entry, functionName) @@ -296,7 +212,7 @@ async function logsAction(options: LogsOptions): Promise { : await getAllFunctionNames(); if (functionNames.length === 0) { - log.info("No functions found in this project."); + process.stdout.write("No functions found in this project.\n"); return {}; } @@ -338,7 +254,7 @@ export function getLogsCommand(context: CLIContext): Command { .action(async (options: LogsOptions) => { await runCommand( () => logsAction(options), - { requireAuth: true }, + { requireAuth: true, skipIntro: true, skipOutro: true }, context ); }); diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 0dcd35a2..5abd980a 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -27,6 +27,18 @@ export interface RunCommandOptions { * @default true */ requireAppConfig?: boolean; + /** + * Skip intro and upgrade notification for pipe-friendly or log-file-style output. + * Use for commands that write raw content to stdout (e.g. logs). + * @default false + */ + skipIntro?: boolean; + /** + * Skip outro for pipe-friendly or log-file-style output. + * Use for commands that write raw content to stdout (e.g. logs). + * @default false + */ + skipOutro?: boolean; } export interface RunCommandResult { @@ -65,17 +77,20 @@ export async function runCommand( options: RunCommandOptions | undefined, context: CLIContext ): Promise { - console.log(); + const skipIntro = options?.skipIntro === true; + const skipOutro = options?.skipOutro === true; - if (options?.fullBanner) { - await printBanner(); - intro(""); - } else { - intro(theme.colors.base44OrangeBackground(" Base 44 ")); + if (!skipIntro) { + console.log(); + if (options?.fullBanner) { + await printBanner(); + intro(""); + } else { + intro(theme.colors.base44OrangeBackground(" Base 44 ")); + } + await printUpgradeNotificationIfAvailable(); } - await printUpgradeNotificationIfAvailable(); - try { // Check authentication if required if (options?.requireAuth) { @@ -103,7 +118,9 @@ export async function runCommand( } const { outroMessage } = await commandFn(); - outro(outroMessage || ""); + if (!skipOutro) { + outro(outroMessage || ""); + } } catch (error) { // Display error message const errorMessage = error instanceof Error ? error.message : String(error); @@ -124,7 +141,11 @@ export async function runCommand( // Get error context and display in outro const errorContext = context.errorReporter.getErrorContext(); - outro(theme.format.errorContext(errorContext)); + if (!skipOutro) { + outro(theme.format.errorContext(errorContext)); + } else { + process.stderr.write(`${theme.format.errorContext(errorContext)}\n`); + } // Re-throw for runCLI to handle (error reporting, exit code) throw error; diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index c9733ec2..62296e94 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -22,7 +22,6 @@ describe("logs command", () => { const result = await t.run("logs", "--function", "my-function"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain('Logs for "my-function" fetched'); t.expectResult(result).toContain("Showing 2 function log entries"); t.expectResult(result).toContain("Processing request"); t.expectResult(result).toContain("Something went wrong"); From bd3ae8b2983612aaa7ff4713d04aca91c9a1a0a0 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 10 Feb 2026 14:18:28 +0200 Subject: [PATCH 3/9] fix function not found errors --- src/cli/commands/logs/index.ts | 45 +++++++++++++++++++++++--- src/core/resources/function/api.ts | 52 ++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index fe2c86fc..7ff7f85a 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -8,7 +8,10 @@ import type { FunctionLogFilters, LogLevel, } from "@/core/resources/function/index.js"; -import { fetchFunctionLogs } from "@/core/resources/function/index.js"; +import { + fetchFunctionLogs, + FunctionNotFoundError, +} from "@/core/resources/function/index.js"; // ─── TYPES ────────────────────────────────────────────────── @@ -160,16 +163,41 @@ function normalizeLogEntry( /** * Fetch logs for specified functions. + * If a function is not found, re-throws with a hint listing available functions. */ async function fetchLogsForFunctions( functionNames: string[], - options: LogsOptions + options: LogsOptions, + availableFunctionNames: string[] ): Promise { const filters = parseFunctionFilters(options); const allEntries: LogEntry[] = []; for (const functionName of functionNames) { - const logs = await fetchFunctionLogs(functionName, filters); + let logs; + try { + logs = await fetchFunctionLogs(functionName, filters); + } catch (error) { + if (error instanceof FunctionNotFoundError && 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) @@ -205,18 +233,25 @@ 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 - : await getAllFunctionNames(); + : allProjectFunctions; if (functionNames.length === 0) { process.stdout.write("No functions found in this project.\n"); return {}; } - let entries = await fetchLogsForFunctions(functionNames, options); + 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; diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 2f09b4ea..476656c5 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -13,6 +13,30 @@ import { FunctionLogsResponseSchema, } from "@/core/resources/function/schema.js"; +/** + * Create a well-structured ApiError for "function not found" scenarios. + */ +class FunctionNotFoundError extends ApiError { + constructor(functionName: string, cause: Error) { + super(`Function "${functionName}" was not found in this app`, { + statusCode: 404, + cause, + hints: [ + { + message: + "Make sure the function name is correct and has been deployed", + command: "base44 functions deploy", + }, + { + message: "List project functions by checking the base44/functions/ directory", + }, + ], + }); + } +} + +export { FunctionNotFoundError }; + function toDeployPayloadItem(fn: FunctionWithCode) { return { name: fn.name, @@ -96,14 +120,28 @@ export async function fetchFunctionLogs( `functions-mgmt/${functionName}/logs${queryString}` ); } catch (error) { - if (error instanceof HTTPError && error.response.status === 404) { - throw new ApiError(`Function "${functionName}" not found`, { - statusCode: 404, - cause: error, - hints: [{ message: "Check the function name and try again" }], - }); + if (error instanceof HTTPError) { + if (error.response.status === 404) { + throw new FunctionNotFoundError(functionName, error); + } + + // The server returns a 500 with a KeyError when the function doesn't + // exist: {"error_type":"KeyError","message":"'fn-name'", ...} + // Detect this and throw a clear "not found" error instead. + try { + const body = (await error.response.clone().json()) as Record; + if (body.error_type === "KeyError") { + throw new FunctionNotFoundError(functionName, error); + } + } catch (parseError) { + if (parseError instanceof ApiError) throw parseError; + // JSON parse failed — fall through to generic handler + } } - throw await ApiError.fromHttpError(error, "fetching function logs"); + throw await ApiError.fromHttpError( + error, + `fetching function logs: '${functionName}'` + ); } const result = FunctionLogsResponseSchema.safeParse(await response.json()); From 4f5dea819eb329ccb12bab118fedb262623aae0b Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 10 Feb 2026 14:25:45 +0200 Subject: [PATCH 4/9] lint --- src/cli/commands/logs/index.ts | 18 +++++++++--------- src/core/resources/function/api.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 7ff7f85a..d781dc14 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -6,11 +6,12 @@ import { InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import type { FunctionLogFilters, + FunctionLogsResponse, LogLevel, } from "@/core/resources/function/index.js"; import { - fetchFunctionLogs, FunctionNotFoundError, + fetchFunctionLogs, } from "@/core/resources/function/index.js"; // ─── TYPES ────────────────────────────────────────────────── @@ -174,11 +175,14 @@ async function fetchLogsForFunctions( const allEntries: LogEntry[] = []; for (const functionName of functionNames) { - let logs; + let logs: FunctionLogsResponse; try { logs = await fetchFunctionLogs(functionName, filters); } catch (error) { - if (error instanceof FunctionNotFoundError && availableFunctionNames.length > 0) { + if ( + error instanceof FunctionNotFoundError && + availableFunctionNames.length > 0 + ) { const available = availableFunctionNames.join(", "); throw new InvalidInputError( `Function "${functionName}" was not found in this app`, @@ -199,9 +203,7 @@ async function fetchLogsForFunctions( throw error; } - const entries = logs.map((entry) => - normalizeLogEntry(entry, functionName) - ); + const entries = logs.map((entry) => normalizeLogEntry(entry, functionName)); allEntries.push(...entries); } @@ -238,9 +240,7 @@ async function logsAction(options: LogsOptions): Promise { // Determine which functions to fetch logs for const functionNames = - specifiedFunctions.length > 0 - ? specifiedFunctions - : allProjectFunctions; + specifiedFunctions.length > 0 ? specifiedFunctions : allProjectFunctions; if (functionNames.length === 0) { process.stdout.write("No functions found in this project.\n"); diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 476656c5..16ef0e95 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -28,7 +28,8 @@ class FunctionNotFoundError extends ApiError { command: "base44 functions deploy", }, { - message: "List project functions by checking the base44/functions/ directory", + message: + "List project functions by checking the base44/functions/ directory", }, ], }); @@ -129,7 +130,10 @@ export async function fetchFunctionLogs( // exist: {"error_type":"KeyError","message":"'fn-name'", ...} // Detect this and throw a clear "not found" error instead. try { - const body = (await error.response.clone().json()) as Record; + const body = (await error.response.clone().json()) as Record< + string, + unknown + >; if (body.error_type === "KeyError") { throw new FunctionNotFoundError(functionName, error); } From 350bf6814410de5d7d568a0be90ee67905db8710 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Tue, 10 Feb 2026 15:48:21 +0200 Subject: [PATCH 5/9] fix: move FunctionNotFoundError to errors.ts and anchor regex in normalizeDatetime - Move FunctionNotFoundError class to core/errors.ts per project conventions - Fix regex alternation bug: anchor Z to end-of-string in normalizeDatetime Co-authored-by: Cursor --- src/cli/commands/logs/index.ts | 2 +- src/core/errors.ts | 23 +++++++++++++++++++++++ src/core/resources/function/api.ts | 29 +++++------------------------ 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index d781dc14..15951019 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -86,7 +86,7 @@ function parseFunctionNames(option: string | undefined): string[] { * Ensure datetime has a timezone (append Z if missing) for APIs that require it. */ function normalizeDatetime(value: string): string { - if (/Z|[+-]\d{2}:\d{2}$/.test(value)) return value; + if (/Z$|[+-]\d{2}:\d{2}$/.test(value)) return value; return `${value}Z`; } diff --git a/src/core/errors.ts b/src/core/errors.ts index 32c1e1a8..4f74fb66 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -424,6 +424,29 @@ export class FileReadError extends SystemError { } } +/** + * Thrown when a specific function is not found in the app. + */ +export class FunctionNotFoundError extends ApiError { + constructor(functionName: string, cause: Error) { + super(`Function "${functionName}" was not found in this app`, { + statusCode: 404, + cause, + hints: [ + { + message: + "Make sure the function name is correct and has been deployed", + command: "base44 functions deploy", + }, + { + message: + "List project functions by checking the base44/functions/ directory", + }, + ], + }); + } +} + /** * Thrown for unexpected internal errors. */ diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 16ef0e95..31e61b48 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,7 +1,11 @@ import type { KyResponse } from "ky"; import { HTTPError } from "ky"; import { getAppClient } from "@/core/clients/index.js"; -import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { + ApiError, + FunctionNotFoundError, + SchemaValidationError, +} from "@/core/errors.js"; import type { DeployFunctionsResponse, FunctionLogFilters, @@ -13,29 +17,6 @@ import { FunctionLogsResponseSchema, } from "@/core/resources/function/schema.js"; -/** - * Create a well-structured ApiError for "function not found" scenarios. - */ -class FunctionNotFoundError extends ApiError { - constructor(functionName: string, cause: Error) { - super(`Function "${functionName}" was not found in this app`, { - statusCode: 404, - cause, - hints: [ - { - message: - "Make sure the function name is correct and has been deployed", - command: "base44 functions deploy", - }, - { - message: - "List project functions by checking the base44/functions/ directory", - }, - ], - }); - } -} - export { FunctionNotFoundError }; function toDeployPayloadItem(fn: FunctionWithCode) { From deb987135d637827c15821ab6305e71545b1cb3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 13:00:20 +0000 Subject: [PATCH 6/9] docs: update README to match CLI (command table, install, or quick start) --- README.md | 1 + 1 file changed, 1 insertion(+) 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 | From f0f8fee3768faaaac292a0b763c01ac3a6aa4778 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 22 Feb 2026 17:05:19 +0200 Subject: [PATCH 7/9] small fixes --- src/cli/commands/logs/index.ts | 140 +++++++++----------------- src/cli/utils/runCommand.ts | 1 + src/core/errors.ts | 48 +++++---- src/core/resources/function/api.ts | 48 ++------- src/core/resources/function/schema.ts | 15 +-- 5 files changed, 80 insertions(+), 172 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 3fe19286..4c4e9cae 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -1,20 +1,15 @@ -import { Command } from "commander"; +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 { InvalidInputError } from "@/core/errors.js"; +import { ApiError, InvalidInputError } from "@/core/errors.js"; import { readProjectConfig } from "@/core/index.js"; import type { FunctionLogFilters, FunctionLogsResponse, LogLevel, } from "@/core/resources/function/index.js"; -import { - FunctionNotFoundError, - fetchFunctionLogs, -} from "@/core/resources/function/index.js"; - -// ─── TYPES ────────────────────────────────────────────────── +import { fetchFunctionLogs } from "@/core/resources/function/index.js"; interface LogsOptions { function?: string; @@ -36,15 +31,8 @@ interface LogEntry { source: string; // function name } -// ─── CONSTANTS ────────────────────────────────────────────── - const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; -// ─── OPTION PARSING ───────────────────────────────────────── - -/** - * Parse CLI options into FunctionLogFilters. - */ function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { const filters: FunctionLogFilters = {}; @@ -71,9 +59,6 @@ function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { return filters; } -/** - * Parse --function option into array of function names. - */ function parseFunctionNames(option: string | undefined): string[] { if (!option) return []; return option @@ -82,51 +67,16 @@ function parseFunctionNames(option: string | undefined): string[] { .filter((s) => s.length > 0); } -/** - * Ensure datetime has a timezone (append Z if missing) for APIs that require it. - */ function normalizeDatetime(value: string): string { if (/Z$|[+-]\d{2}:\d{2}$/.test(value)) return value; return `${value}Z`; } -/** - * Validate CLI options upfront before any API calls. - */ -function validateOptions(options: LogsOptions): void { - if (options.level && !VALID_LEVELS.includes(options.level)) { - throw new InvalidInputError( - `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` - ); - } - if (options.limit) { - const limit = Number.parseInt(options.limit, 10); - if (Number.isNaN(limit) || limit < 1 || limit > 1000) { - throw new InvalidInputError( - `Invalid limit: "${options.limit}". Must be a number between 1 and 1000.` - ); - } - } - if (options.order) { - const order = options.order.toUpperCase(); - if (order !== "ASC" && order !== "DESC") { - throw new InvalidInputError( - `Invalid order: "${options.order}". Must be "ASC" or "DESC".` - ); - } - } -} - -// ─── DISPLAY ──────────────────────────────────────────────── - -/** - * Format a single log entry as a plain log-file line. - */ 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}\n`; + return `${time} ${level} ${message}`; } /** @@ -137,21 +87,13 @@ function formatLogs(entries: LogEntry[]): string { return "No logs found matching the filters.\n"; } - let output = `Showing ${entries.length} function log entries\n\n`; - for (const entry of entries) { - output += formatEntry(entry); - } - return output; + const header = `Showing ${entries.length} function log entries\n`; + return [header, ...entries.map(formatEntry)].join("\n"); } -// ─── ACTIONS ──────────────────────────────────────────────── - -/** - * Normalize a function log entry to display format. - */ function normalizeLogEntry( entry: { time: string; level: string; message: string }, - functionName: string + functionName: string, ): LogEntry { return { time: entry.time, @@ -161,14 +103,10 @@ function normalizeLogEntry( }; } -/** - * Fetch logs for specified functions. - * If a function is not found, re-throws with a hint listing available functions. - */ async function fetchLogsForFunctions( functionNames: string[], options: LogsOptions, - availableFunctionNames: string[] + availableFunctionNames: string[], ): Promise { const filters = parseFunctionFilters(options); const allEntries: LogEntry[] = []; @@ -179,7 +117,8 @@ async function fetchLogsForFunctions( logs = await fetchFunctionLogs(functionName, filters); } catch (error) { if ( - error instanceof FunctionNotFoundError && + error instanceof ApiError && + error.statusCode === 404 && availableFunctionNames.length > 0 ) { const available = availableFunctionNames.join(", "); @@ -196,7 +135,7 @@ async function fetchLogsForFunctions( command: "base44 functions deploy", }, ], - } + }, ); } throw error; @@ -216,22 +155,12 @@ async function fetchLogsForFunctions( return allEntries; } -/** - * Get all function names from project config. - */ async function getAllFunctionNames(): Promise { const { functions } = await readProjectConfig(); return functions.map((fn) => fn.name); } -/** - * Main logs action. - */ async function logsAction(options: LogsOptions): Promise { - if (options.since) options.since = normalizeDatetime(options.since); - if (options.until) options.until = normalizeDatetime(options.until); - validateOptions(options); - const specifiedFunctions = parseFunctionNames(options.function); // Always read project functions so we can list them in error messages @@ -242,7 +171,7 @@ async function logsAction(options: LogsOptions): Promise { specifiedFunctions.length > 0 ? specifiedFunctions : allProjectFunctions; if (functionNames.length === 0) { - return { stdout: "No functions found in this project.\n" }; + return { outroMessage: "No functions found in this project." }; } let entries = await fetchLogsForFunctions( @@ -257,36 +186,57 @@ async function logsAction(options: LogsOptions): Promise { entries = entries.slice(0, limit); } - const stdout = options.json + const logsOutput = options.json ? `${JSON.stringify(entries, null, 2)}\n` : formatLogs(entries); - return { stdout }; + return { outroMessage: "Fetched logs", stdout: logsOutput }; } -// ─── COMMAND ──────────────────────────────────────────────── - 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" + "Filter by function name(s), comma-separated. If omitted, fetches logs for all project functions", ) - .option("--since ", "Show logs from this time (ISO format)") - .option("--until ", "Show logs until this time (ISO format)") .option( - "--level ", - "Filter by log level: log, info, warn, error, debug" + "--since ", + "Show logs from this time (ISO format)", + normalizeDatetime, + ) + .option( + "--until ", + "Show logs until this time (ISO format)", + normalizeDatetime, + ) + .addOption( + new Option("--level ", "Filter by log level").choices( + VALID_LEVELS, + ), + ) + .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("-n, --limit ", "Results per page (1-1000, default: 50)") - .option("--order ", "Sort order: ASC|DESC (default: DESC)") .option("--json", "Output raw JSON") .action(async (options: LogsOptions) => { await runCommand( () => logsAction(options), { requireAuth: true }, - context + context, ); }); } diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index c081b693..6c92bc32 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -106,6 +106,7 @@ export async function runCommand( const result = await commandFn(); outro(result.outroMessage || ""); + if (result.stdout) { process.stdout.write(result.stdout); } diff --git a/src/core/errors.ts b/src/core/errors.ts index 44d7146b..8527ebbb 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -272,6 +272,7 @@ interface ApiErrorOptions extends CLIErrorOptions { responseBody?: unknown; } + /** * Thrown when an API request fails. */ @@ -304,9 +305,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 +337,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 +421,20 @@ 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; + } } /** @@ -448,29 +469,6 @@ export class FileReadError extends SystemError { } } -/** - * Thrown when a specific function is not found in the app. - */ -export class FunctionNotFoundError extends ApiError { - constructor(functionName: string, cause: Error) { - super(`Function "${functionName}" was not found in this app`, { - statusCode: 404, - cause, - hints: [ - { - message: - "Make sure the function name is correct and has been deployed", - command: "base44 functions deploy", - }, - { - message: - "List project functions by checking the base44/functions/ directory", - }, - ], - }); - } -} - /** * Thrown for unexpected internal errors. */ diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 522c7395..5d12b9f0 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,11 +1,6 @@ import type { KyResponse } from "ky"; -import { HTTPError } from "ky"; import { getAppClient } from "@/core/clients/index.js"; -import { - ApiError, - FunctionNotFoundError, - SchemaValidationError, -} from "@/core/errors.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; import type { DeployFunctionsResponse, FunctionLogFilters, @@ -17,7 +12,6 @@ import { FunctionLogsResponseSchema, } from "@/core/resources/function/schema.js"; -export { FunctionNotFoundError }; function toDeployPayloadItem(fn: FunctionWithCode) { return { @@ -63,7 +57,7 @@ export async function deployFunctions( /** * Build query string from filter options. */ -function buildLogsQueryString(filters: FunctionLogFilters): string { +function buildLogsQueryString(filters: FunctionLogFilters): URLSearchParams { const params = new URLSearchParams(); if (filters.since) { @@ -82,8 +76,7 @@ function buildLogsQueryString(filters: FunctionLogFilters): string { params.set("order", filters.order); } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ""; + return params; } /** @@ -91,41 +84,20 @@ function buildLogsQueryString(filters: FunctionLogFilters): string { */ export async function fetchFunctionLogs( functionName: string, - filters: FunctionLogFilters = {} + filters: FunctionLogFilters = {}, ): Promise { const appClient = getAppClient(); - const queryString = buildLogsQueryString(filters); + const searchParams = buildLogsQueryString(filters); let response: KyResponse; try { - response = await appClient.get( - `functions-mgmt/${functionName}/logs${queryString}` - ); + response = await appClient.get(`functions-mgmt/${functionName}/logs`, { + searchParams, + }); } catch (error) { - if (error instanceof HTTPError) { - if (error.response.status === 404) { - throw new FunctionNotFoundError(functionName, error); - } - - // The server returns a 500 with a KeyError when the function doesn't - // exist: {"error_type":"KeyError","message":"'fn-name'", ...} - // Detect this and throw a clear "not found" error instead. - try { - const body = (await error.response.clone().json()) as Record< - string, - unknown - >; - if (body.error_type === "KeyError") { - throw new FunctionNotFoundError(functionName, error); - } - } catch (parseError) { - if (parseError instanceof ApiError) throw parseError; - // JSON parse failed — fall through to generic handler - } - } throw await ApiError.fromHttpError( error, - `fetching function logs: '${functionName}'` + `fetching function logs: '${functionName}'`, ); } @@ -134,7 +106,7 @@ export async function fetchFunctionLogs( if (!result.success) { throw new SchemaValidationError( "Invalid function logs response from server", - result.error + result.error, ); } diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index 80109d69..15c11ae1 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -104,8 +104,6 @@ export type FunctionWithCode = Omit & { files: FunctionFile[]; }; -// ─── FUNCTION LOGS SCHEMAS ────────────────────────────────── - /** * Log level from Deno Deploy runtime. */ @@ -113,27 +111,16 @@ export const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]); export type LogLevel = z.infer; -/** - * Single log entry from the function runtime (Deno Deploy). - */ -export const FunctionLogEntrySchema = z.object({ +const FunctionLogEntrySchema = z.object({ time: z.string(), level: LogLevelSchema, message: z.string(), }); -export type FunctionLogEntry = z.infer; - -/** - * Response from the function logs API - array of log entries. - */ export const FunctionLogsResponseSchema = z.array(FunctionLogEntrySchema); export type FunctionLogsResponse = z.infer; -/** - * CLI filter options for function logs. - */ export interface FunctionLogFilters { since?: string; until?: string; From 24d0c14f5410ac704c8014e3667aec6fb19d0f01 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 22 Feb 2026 17:13:59 +0200 Subject: [PATCH 8/9] removed some flags --- .../{logs/index.ts => project/logs.ts} | 12 ------ src/cli/program.ts | 2 +- tests/cli/logs.spec.ts | 43 +------------------ 3 files changed, 3 insertions(+), 54 deletions(-) rename src/cli/commands/{logs/index.ts => project/logs.ts} (95%) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/project/logs.ts similarity index 95% rename from src/cli/commands/logs/index.ts rename to src/cli/commands/project/logs.ts index 4c4e9cae..fa455bb0 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/project/logs.ts @@ -7,7 +7,6 @@ import { readProjectConfig } from "@/core/index.js"; import type { FunctionLogFilters, FunctionLogsResponse, - LogLevel, } from "@/core/resources/function/index.js"; import { fetchFunctionLogs } from "@/core/resources/function/index.js"; @@ -15,7 +14,6 @@ interface LogsOptions { function?: string; since?: string; until?: string; - level?: string; limit?: string; order?: string; json?: boolean; @@ -31,7 +29,6 @@ interface LogEntry { source: string; // function name } -const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { const filters: FunctionLogFilters = {}; @@ -44,10 +41,6 @@ function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { filters.until = options.until; } - if (options.level) { - filters.level = options.level as LogLevel; - } - if (options.limit) { filters.limit = Number.parseInt(options.limit, 10); } @@ -210,11 +203,6 @@ export function getLogsCommand(context: CLIContext): Command { "Show logs until this time (ISO format)", normalizeDatetime, ) - .addOption( - new Option("--level ", "Filter by log level").choices( - VALID_LEVELS, - ), - ) .option( "-n, --limit ", "Results per page (1-1000, default: 50)", diff --git a/src/cli/program.ts b/src/cli/program.ts index 93961e10..ed2cd871 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -7,7 +7,7 @@ import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; -import { getLogsCommand } from "@/cli/commands/logs/index.js"; +import { getLogsCommand } from "@/cli/commands/project/logs.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"; diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index 62296e94..eb498e94 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -74,30 +74,6 @@ describe("logs command", () => { t.expectResult(result).toContain("No logs found matching the filters."); }); - it("filters function logs by --level", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - // Backend filters by level, so mock returns only matching entries - t.api.mockFunctionLogs("my-function", [ - { - time: "2024-01-15T10:30:00.050Z", - level: "error", - message: "Error message", - }, - ]); - - const result = await t.run( - "logs", - "--function", - "my-function", - "--level", - "error" - ); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Error message"); - t.expectResult(result).toNotContain("Info message"); - }); - it("outputs JSON with --json flag", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockFunctionLogs("my-function", [ @@ -138,21 +114,6 @@ describe("logs command", () => { t.expectResult(result).toFail(); }); - it("fails with invalid level option", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - - const result = await t.run( - "logs", - "--function", - "dummy", - "--level", - "invalid" - ); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid level"); - }); - it("fails with invalid limit option", async () => { await t.givenLoggedInWithProject(fixture("basic")); @@ -168,7 +129,7 @@ describe("logs command", () => { const result = await t.run("logs", "--order", "RANDOM"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid order"); + t.expectResult(result).toContain("is invalid"); }); it("passes filter options to API", async () => { @@ -182,7 +143,7 @@ describe("logs command", () => { "--limit", "10", "--order", - "ASC" + "asc" ); t.expectResult(result).toSucceed(); From edbe9730ae953e31a34c27f38461b1d11beec6a8 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Sun, 22 Feb 2026 17:16:58 +0200 Subject: [PATCH 9/9] tiny changes --- src/cli/commands/project/logs.ts | 1 - src/cli/program.ts | 2 +- src/cli/utils/runCommand.ts | 2 +- src/core/errors.ts | 6 ++++-- src/core/resources/function/api.ts | 1 - src/core/resources/function/schema.ts | 2 +- tests/cli/logs.spec.ts | 2 +- tests/cli/testkit/Base44APIMock.ts | 15 ++++----------- 8 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/cli/commands/project/logs.ts b/src/cli/commands/project/logs.ts index fa455bb0..a4c10113 100644 --- a/src/cli/commands/project/logs.ts +++ b/src/cli/commands/project/logs.ts @@ -29,7 +29,6 @@ interface LogEntry { source: string; // function name } - function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { const filters: FunctionLogFilters = {}; diff --git a/src/cli/program.ts b/src/cli/program.ts index ed2cd871..219e5e9c 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -7,10 +7,10 @@ import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js"; -import { getLogsCommand } from "@/cli/commands/project/logs.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"; diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 6c92bc32..b8cdb6cd 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -106,7 +106,7 @@ export async function runCommand( const result = await commandFn(); outro(result.outroMessage || ""); - + if (result.stdout) { process.stdout.write(result.stdout); } diff --git a/src/core/errors.ts b/src/core/errors.ts index 8527ebbb..d43e135c 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -272,7 +272,6 @@ interface ApiErrorOptions extends CLIErrorOptions { responseBody?: unknown; } - /** * Thrown when an API request fails. */ @@ -430,7 +429,10 @@ export class ApiError extends SystemError { statusCode: number, responseBody: unknown, ): number { - if ((responseBody as Record | null)?.error_type === "KeyError") { + 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 5d12b9f0..43e0dbf8 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -12,7 +12,6 @@ import { FunctionLogsResponseSchema, } from "@/core/resources/function/schema.js"; - function toDeployPayloadItem(fn: FunctionWithCode) { return { name: fn.name, diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index 15c11ae1..8371274a 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -107,7 +107,7 @@ export type FunctionWithCode = Omit & { /** * Log level from Deno Deploy runtime. */ -export const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]); +const LogLevelSchema = z.enum(["log", "info", "warn", "error", "debug"]); export type LogLevel = z.infer; diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index eb498e94..ef244614 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -143,7 +143,7 @@ describe("logs command", () => { "--limit", "10", "--order", - "asc" + "asc", ); t.expectResult(result).toSucceed(); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index 370b9b3e..3b2a1273 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -57,13 +57,13 @@ interface AgentsFetchResponse { total: number; } -export interface FunctionLogEntry { +interface FunctionLogEntry { time: string; level: "log" | "info" | "warn" | "error" | "debug"; message: string; } -export type FunctionLogsResponse = FunctionLogEntry[]; +type FunctionLogsResponse = FunctionLogEntry[]; interface ConnectorsListResponse { integrations: Array<{ @@ -83,10 +83,6 @@ interface ConnectorSetResponse { other_user_email?: string; } -interface ConnectorOAuthStatusResponse { - status: "ACTIVE" | "FAILED" | "PENDING"; -} - interface ConnectorRemoveResponse { status: "removed"; integration_type: string; @@ -269,8 +265,8 @@ export class Base44APIMock { this.handlers.push( http.get( `${BASE_URL}/api/apps/${this.appId}/functions-mgmt/${functionName}/logs`, - () => HttpResponse.json(response) - ) + () => HttpResponse.json(response), + ), ); return this; } @@ -388,9 +384,6 @@ export class Base44APIMock { } /** Mock token endpoint to return an error (for auth failure testing) */ - mockTokenError(error: ErrorResponse): this { - return this.mockError("post", "/oauth/token", error); - } /** Mock connectors list to return an error */ mockConnectorsListError(error: ErrorResponse): this {