From d58ec8aa003bf4cdbaa67b4cb09f019dc45d0813 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Wed, 4 Feb 2026 17:22:49 +0200 Subject: [PATCH 01/13] add `base44 logs` command --- src/cli/commands/logs/index.ts | 191 ++++++++++++++++++++++++ src/cli/program.ts | 4 + src/core/index.ts | 1 + src/core/logs/api.ts | 109 ++++++++++++++ src/core/logs/index.ts | 9 ++ src/core/logs/schema.ts | 95 ++++++++++++ tests/cli/logs.spec.ts | 227 +++++++++++++++++++++++++++++ tests/cli/testkit/Base44APIMock.ts | 60 ++++++++ 8 files changed, 696 insertions(+) create mode 100644 src/cli/commands/logs/index.ts create mode 100644 src/core/logs/api.ts create mode 100644 src/core/logs/index.ts create mode 100644 src/core/logs/schema.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..44c5b72a --- /dev/null +++ b/src/cli/commands/logs/index.ts @@ -0,0 +1,191 @@ +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 type { AuditLogFilters, AuditLogsResponse } from "@/core/logs/index.js"; +import { fetchAuditLogs } from "@/core/logs/index.js"; + +interface LogsOptions { + status?: string; + eventTypes?: string; + userEmail?: string; + startDate?: string; + endDate?: string; + limit?: string; + order?: string; + cursorTimestamp?: string; + cursorUserEmail?: string; + json?: boolean; +} + +/** + * Parse CLI options into AuditLogFilters. + */ +function parseOptions(options: LogsOptions): AuditLogFilters { + const filters: AuditLogFilters = {}; + + if (options.status) { + if (options.status !== "success" && options.status !== "failure") { + throw new InvalidInputError( + `Invalid status: "${options.status}". Must be "success" or "failure".` + ); + } + filters.status = options.status; + } + + if (options.eventTypes) { + try { + const parsed = JSON.parse(options.eventTypes); + if (!Array.isArray(parsed)) { + throw new Error("Not an array"); + } + filters.eventTypes = parsed; + } catch { + throw new InvalidInputError( + `Invalid event-types: "${options.eventTypes}". Must be a JSON array (e.g., '["api.function.call"]').` + ); + } + } + + if (options.userEmail) { + filters.userEmail = options.userEmail; + } + + if (options.startDate) { + filters.startDate = options.startDate; + } + + if (options.endDate) { + filters.endDate = options.endDate; + } + + 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.` + ); + } + filters.limit = limit; + } + + 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".` + ); + } + filters.order = order as "ASC" | "DESC"; + } + + if (options.cursorTimestamp) { + filters.cursorTimestamp = options.cursorTimestamp; + } + + if (options.cursorUserEmail) { + filters.cursorUserEmail = options.cursorUserEmail; + } + + return filters; +} + +/** + * Format a single log event for human-readable output. + */ +function formatEvent(event: AuditLogsResponse["events"][0]): string { + const timestamp = event.timestamp.padEnd(24); + const eventType = event.event_type.padEnd(28); + const user = (event.user_email ?? "-").padEnd(30); + const status = + event.status === "success" + ? theme.colors.base44Orange(event.status) + : theme.colors.white(event.status); + + return `${timestamp} ${eventType} ${user} ${status}`; +} + +/** + * Display logs in human-readable format. + */ +function displayLogs(response: AuditLogsResponse): void { + const { events, pagination } = response; + + if (events.length === 0) { + log.info("No events found matching the filters."); + return; + } + + // Header + log.info( + theme.styles.dim(`Showing ${events.length} of ${pagination.total} events\n`) + ); + + const header = `${"TIMESTAMP".padEnd(24)} ${"EVENT TYPE".padEnd(28)} ${"USER".padEnd(30)} STATUS`; + log.message(theme.styles.header(header)); + + // Events + for (const event of events) { + log.message(formatEvent(event)); + } + + // Pagination hint + if (pagination.has_more && pagination.next_cursor) { + log.info( + theme.styles.dim( + `\nMore results available. Use --cursor-timestamp="${pagination.next_cursor.timestamp}" --cursor-user-email="${pagination.next_cursor.user_email}" for next page.` + ) + ); + } +} + +async function logsAction(options: LogsOptions): Promise { + const filters = parseOptions(options); + + const response = await runTask( + "Fetching audit logs...", + async () => { + return await fetchAuditLogs(filters); + }, + { + successMessage: "Logs fetched successfully", + errorMessage: "Failed to fetch audit logs", + } + ); + + if (options.json) { + // Output raw JSON for scripting - use process.stdout for proper capture + process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + } else { + displayLogs(response); + } + + return {}; +} + +export function getLogsCommand(context: CLIContext): Command { + return new Command("logs") + .description("Fetch audit logs for this app") + .option("--status ", "Filter by outcome: success|failure") + .option( + "--event-types ", + "Filter by event types (JSON array, e.g., '[\"api.function.call\"]')" + ) + .option("--user-email ", "Filter by user email") + .option("--start-date ", "Filter events from this date (ISO format)") + .option("--end-date ", "Filter events until this date (ISO format)") + .option("-n, --limit ", "Results per page (1-1000, default: 50)") + .option("--order ", "Sort order: ASC|DESC (default: DESC)") + .option("--cursor-timestamp ", "Pagination cursor timestamp") + .option("--cursor-user-email ", "Pagination cursor user email") + .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..ea21a9d5 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"; @@ -53,6 +54,9 @@ 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/index.ts b/src/core/index.ts index 723c3e77..78381b29 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -3,6 +3,7 @@ export * from "./clients/index.js"; export * from "./config.js"; export * from "./consts.js"; export * from "./errors.js"; +export * from "./logs/index.js"; export * from "./project/index.js"; export * from "./resources/index.js"; export * from "./site/index.js"; diff --git a/src/core/logs/api.ts b/src/core/logs/api.ts new file mode 100644 index 00000000..49592539 --- /dev/null +++ b/src/core/logs/api.ts @@ -0,0 +1,109 @@ +/** + * Audit logs API functions. + */ + +import type { KyResponse } from "ky"; +import { base44Client } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { getAppConfig } from "@/core/project/index.js"; +import type { AuditLogFilters, AuditLogsResponse } from "./schema.js"; +import { AppInfoResponseSchema, AuditLogsResponseSchema } from "./schema.js"; + +/** + * Fetch the workspace (organization) ID for the current app. + * Required because audit logs are fetched at the workspace level. + */ +export async function getWorkspaceId(): Promise { + const { id: appId } = getAppConfig(); + + let response: KyResponse; + try { + // GET /api/apps/{app_id} returns app info including organization_id + response = await base44Client.get(`api/apps/${appId}`); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching app info"); + } + + const result = AppInfoResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid app info response from server", + result.error + ); + } + + return result.data.organization_id; +} + +/** + * Build the API request body from CLI filter options. + * Transforms camelCase options to snake_case for the API. + */ +function buildRequestBody( + appId: string, + filters: AuditLogFilters +): Record { + const body: Record = { + app_id: appId, + order: filters.order ?? "DESC", + limit: filters.limit ?? 50, + }; + + if (filters.status) { + body.status = filters.status; + } + if (filters.eventTypes && filters.eventTypes.length > 0) { + body.event_types = filters.eventTypes; + } + if (filters.userEmail) { + body.user_email = filters.userEmail; + } + if (filters.startDate) { + body.start_date = filters.startDate; + } + if (filters.endDate) { + body.end_date = filters.endDate; + } + if (filters.cursorTimestamp) { + body.cursor_timestamp = filters.cursorTimestamp; + } + if (filters.cursorUserEmail) { + body.cursor_user_email = filters.cursorUserEmail; + } + + return body; +} + +/** + * Fetch audit logs for the current app. + */ +export async function fetchAuditLogs( + filters: AuditLogFilters = {} +): Promise { + const { id: appId } = getAppConfig(); + const workspaceId = await getWorkspaceId(); + + let response: KyResponse; + try { + response = await base44Client.post( + `api/workspace/audit-logs/list?workspaceId=${workspaceId}`, + { + json: buildRequestBody(appId, filters), + } + ); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching audit logs"); + } + + const result = AuditLogsResponseSchema.safeParse(await response.json()); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid audit logs response from server", + result.error + ); + } + + return result.data; +} diff --git a/src/core/logs/index.ts b/src/core/logs/index.ts new file mode 100644 index 00000000..4b137f0b --- /dev/null +++ b/src/core/logs/index.ts @@ -0,0 +1,9 @@ +export { fetchAuditLogs, getWorkspaceId } from "./api.js"; +export type { + AuditLogEvent, + AuditLogFilters, + AuditLogRequest, + AuditLogsResponse, + Pagination, + PaginationCursor, +} from "./schema.js"; diff --git a/src/core/logs/schema.ts b/src/core/logs/schema.ts new file mode 100644 index 00000000..5a6377d9 --- /dev/null +++ b/src/core/logs/schema.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +/** + * Request body schema for the audit logs API. + * Uses snake_case to match API expectations. + */ +export const AuditLogRequestSchema = z.object({ + app_id: z.string(), + event_types: z.array(z.string()).optional(), + user_email: z.string().optional(), + status: z.enum(["success", "failure"]).optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), + limit: z.number().min(1).max(1000).default(50), + order: z.enum(["ASC", "DESC"]).default("DESC"), + cursor_timestamp: z.string().optional(), + cursor_user_email: z.string().optional(), +}); + +export type AuditLogRequest = z.infer; + +/** + * Single audit log event from the API response. + */ +export const AuditLogEventSchema = z.looseObject({ + timestamp: z.string(), + user_email: z.string().nullable(), + workspace_id: z.string(), + app_id: z.string(), + ip: z.string().nullable(), + user_agent: z.string().nullable(), + event_type: z.string(), + status: z.enum(["success", "failure"]), + error_code: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), +}); + +export type AuditLogEvent = z.infer; + +/** + * Pagination cursor for fetching the next page. + */ +export const PaginationCursorSchema = z.object({ + timestamp: z.string(), + user_email: z.string(), +}); + +export type PaginationCursor = z.infer; + +/** + * Pagination info from the API response. + */ +export const PaginationSchema = z.object({ + total: z.number(), + limit: z.number(), + has_more: z.boolean(), + next_cursor: PaginationCursorSchema.nullable(), +}); + +export type Pagination = z.infer; + +/** + * Full audit logs API response. + */ +export const AuditLogsResponseSchema = z.object({ + events: z.array(AuditLogEventSchema), + pagination: PaginationSchema, +}); + +export type AuditLogsResponse = z.infer; + +/** + * App info response schema (for extracting workspace/organization ID). + */ +export const AppInfoResponseSchema = z.looseObject({ + organization_id: z.string(), +}); + +export type AppInfoResponse = z.infer; + +/** + * CLI filter options (camelCase for TypeScript). + * These are transformed to snake_case when building the API request. + */ +export interface AuditLogFilters { + status?: "success" | "failure"; + eventTypes?: string[]; + userEmail?: string; + startDate?: string; + endDate?: string; + limit?: number; + order?: "ASC" | "DESC"; + cursorTimestamp?: string; + cursorUserEmail?: string; +} diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts new file mode 100644 index 00000000..33e028b9 --- /dev/null +++ b/tests/cli/logs.spec.ts @@ -0,0 +1,227 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const TEST_WORKSPACE_ID = "test-workspace-id"; + +describe("logs command", () => { + const t = setupCLITests(); + + it("fetches and displays logs successfully", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [ + { + timestamp: "2024-01-15T10:30:00Z", + user_email: "user@example.com", + workspace_id: TEST_WORKSPACE_ID, + app_id: "test-app-id", + event_type: "api.function.call", + status: "success", + ip: null, + user_agent: null, + error_code: null, + metadata: null, + }, + { + timestamp: "2024-01-15T10:29:00Z", + user_email: "user@example.com", + workspace_id: TEST_WORKSPACE_ID, + app_id: "test-app-id", + event_type: "app.entity.created", + status: "failure", + ip: null, + user_agent: null, + error_code: "VALIDATION_ERROR", + metadata: { entity_name: "Task" }, + }, + ], + pagination: { + total: 2, + limit: 50, + has_more: false, + next_cursor: null, + }, + }); + + const result = await t.run("logs"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Logs fetched successfully"); + t.expectResult(result).toContain("Showing 2 of 2 events"); + t.expectResult(result).toContain("api.function.call"); + t.expectResult(result).toContain("app.entity.created"); + }); + + it("shows no events message when empty", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [], + pagination: { + total: 0, + limit: 50, + has_more: false, + next_cursor: null, + }, + }); + + const result = await t.run("logs"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No events found"); + }); + + it("outputs raw JSON with --json flag", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [ + { + timestamp: "2024-01-15T10:30:00Z", + user_email: "user@example.com", + workspace_id: TEST_WORKSPACE_ID, + app_id: "test-app-id", + event_type: "api.function.call", + status: "success", + ip: null, + user_agent: null, + error_code: null, + metadata: null, + }, + ], + pagination: { + total: 1, + limit: 50, + has_more: false, + next_cursor: null, + }, + }); + + const result = await t.run("logs", "--json"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain('"events"'); + t.expectResult(result).toContain('"pagination"'); + }); + + it("shows pagination hint when more results available", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [ + { + timestamp: "2024-01-15T10:30:00Z", + user_email: "user@example.com", + workspace_id: TEST_WORKSPACE_ID, + app_id: "test-app-id", + event_type: "api.function.call", + status: "success", + ip: null, + user_agent: null, + error_code: null, + metadata: null, + }, + ], + pagination: { + total: 100, + limit: 50, + has_more: true, + next_cursor: { + timestamp: "2024-01-15T10:29:00Z", + user_email: "user@example.com", + }, + }, + }); + + const result = await t.run("logs"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("More results available"); + t.expectResult(result).toContain("--cursor-timestamp"); + }); + + 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", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogsError({ + status: 500, + body: { error: "Server error" }, + }); + + const result = await t.run("logs"); + + t.expectResult(result).toFail(); + }); + + it("fails with invalid status option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs", "--status", "invalid"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid status"); + }); + + it("fails with invalid event-types option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs", "--event-types", "not-json"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid event-types"); + }); + + 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.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [], + pagination: { + total: 0, + limit: 10, + has_more: false, + next_cursor: null, + }, + }); + + const result = await t.run( + "logs", + "--status", + "failure", + "--limit", + "10", + "--order", + "ASC" + ); + + t.expectResult(result).toSucceed(); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index c1e1d4ec..c356cbab 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -57,6 +57,29 @@ export interface AgentsFetchResponse { total: number; } +export interface AppInfoResponse { + organization_id: string; + [key: string]: unknown; +} + +export interface AuditLogsResponse { + events: Array<{ + timestamp: string; + user_email: string | null; + workspace_id: string; + app_id: string; + event_type: string; + status: "success" | "failure"; + [key: string]: unknown; + }>; + pagination: { + total: number; + limit: number; + has_more: boolean; + next_cursor: { timestamp: string; user_email: string } | null; + }; +} + export interface CreateAppResponse { id: string; name: string; @@ -182,6 +205,33 @@ export class Base44APIMock { return this; } + /** Mock GET /api/apps/{appId} - Get app info (for workspace ID) */ + mockAppInfo(response: AppInfoResponse): this { + this.handlers.push( + http.get(`${BASE_URL}/api/apps/${this.appId}`, () => + HttpResponse.json(response) + ) + ); + return this; + } + + /** Mock POST /api/workspace/audit-logs/list - Fetch audit logs */ + mockAuditLogs(workspaceId: string, response: AuditLogsResponse): this { + this.handlers.push( + http.post(`${BASE_URL}/api/workspace/audit-logs/list`, ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("workspaceId") === workspaceId) { + return HttpResponse.json(response); + } + return HttpResponse.json( + { error: "Workspace not found" }, + { status: 404 } + ); + }) + ); + return this; + } + // ─── GENERAL ENDPOINTS ───────────────────────────────────── /** Mock POST /api/apps - Create new app */ @@ -263,6 +313,16 @@ export class Base44APIMock { ); } + /** Mock app info to return an error */ + mockAppInfoError(error: ErrorResponse): this { + return this.mockError("get", `/api/apps/${this.appId}`, error); + } + + /** Mock audit logs to return an error */ + mockAuditLogsError(error: ErrorResponse): this { + return this.mockError("post", "/api/workspace/audit-logs/list", error); + } + /** Mock token endpoint to return an error (for auth failure testing) */ mockTokenError(error: ErrorResponse): this { return this.mockError("post", "/oauth/token", error); From 842dc9faf94b90e16928a5b9ac4cf7c051ccf1da Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Wed, 4 Feb 2026 17:27:59 +0200 Subject: [PATCH 02/13] fix: improve logs table formatting - Shorten timestamp to readable format (YYYY-MM-DD HH:MM:SS) - Adjust column widths for better alignment - Dim the timestamp for visual hierarchy Co-authored-by: Cursor --- src/cli/commands/logs/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 44c5b72a..022429a0 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -96,15 +96,16 @@ function parseOptions(options: LogsOptions): AuditLogFilters { * Format a single log event for human-readable output. */ function formatEvent(event: AuditLogsResponse["events"][0]): string { - const timestamp = event.timestamp.padEnd(24); - const eventType = event.event_type.padEnd(28); - const user = (event.user_email ?? "-").padEnd(30); + // Shorten timestamp to readable format (remove microseconds) + const timestamp = event.timestamp.substring(0, 19).replace("T", " "); + const eventType = event.event_type; + const user = event.user_email || "-"; const status = event.status === "success" ? theme.colors.base44Orange(event.status) : theme.colors.white(event.status); - return `${timestamp} ${eventType} ${user} ${status}`; + return `${theme.styles.dim(timestamp)} ${eventType.padEnd(24)} ${user.padEnd(28)} ${status}`; } /** @@ -123,7 +124,7 @@ function displayLogs(response: AuditLogsResponse): void { theme.styles.dim(`Showing ${events.length} of ${pagination.total} events\n`) ); - const header = `${"TIMESTAMP".padEnd(24)} ${"EVENT TYPE".padEnd(28)} ${"USER".padEnd(30)} STATUS`; + const header = `${"TIME".padEnd(19)} ${"EVENT TYPE".padEnd(24)} ${"USER".padEnd(28)} STATUS`; log.message(theme.styles.header(header)); // Events From 0996ba275ee49b80957a2d73cdd3f995190a032c Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Wed, 4 Feb 2026 17:29:45 +0200 Subject: [PATCH 03/13] fix: correct status colors - dim success, highlight failure Co-authored-by: Cursor --- src/cli/commands/logs/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 022429a0..bde31dbe 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -102,8 +102,8 @@ function formatEvent(event: AuditLogsResponse["events"][0]): string { const user = event.user_email || "-"; const status = event.status === "success" - ? theme.colors.base44Orange(event.status) - : theme.colors.white(event.status); + ? theme.styles.dim(event.status) + : theme.colors.base44Orange(event.status); return `${theme.styles.dim(timestamp)} ${eventType.padEnd(24)} ${user.padEnd(28)} ${status}`; } From edd3b8f7e82ff56798c01bb2e88ae014aea5b2eb Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 5 Feb 2026 10:36:39 +0200 Subject: [PATCH 04/13] add `base44 functions logs` command --- src/cli/commands/functions/deploy.ts | 18 +-- src/cli/commands/functions/index.ts | 11 ++ src/cli/commands/functions/logs.ts | 166 ++++++++++++++++++++++++++ src/cli/program.ts | 4 +- src/core/resources/function/api.ts | 57 ++++++++- src/core/resources/function/schema.ts | 36 ++++++ tests/cli/functions_logs.spec.ts | 130 ++++++++++++++++++++ tests/cli/testkit/Base44APIMock.ts | 28 +++++ 8 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 src/cli/commands/functions/index.ts create mode 100644 src/cli/commands/functions/logs.ts create mode 100644 tests/cli/functions_logs.spec.ts diff --git a/src/cli/commands/functions/deploy.ts b/src/cli/commands/functions/deploy.ts index 1c5fa797..abd438e6 100644 --- a/src/cli/commands/functions/deploy.ts +++ b/src/cli/commands/functions/deploy.ts @@ -54,17 +54,9 @@ async function deployFunctionsAction(): Promise { } export function getFunctionsDeployCommand(context: CLIContext): Command { - return new Command("functions") - .description("Manage project functions") - .addCommand( - new Command("deploy") - .description("Deploy local functions to Base44") - .action(async () => { - await runCommand( - deployFunctionsAction, - { requireAuth: true }, - context - ); - }) - ); + return new Command("deploy") + .description("Deploy local functions to Base44") + .action(async () => { + await runCommand(deployFunctionsAction, { requireAuth: true }, context); + }); } diff --git a/src/cli/commands/functions/index.ts b/src/cli/commands/functions/index.ts new file mode 100644 index 00000000..f5e46d21 --- /dev/null +++ b/src/cli/commands/functions/index.ts @@ -0,0 +1,11 @@ +import { Command } from "commander"; +import type { CLIContext } from "@/cli/types.js"; +import { getFunctionsDeployCommand } from "./deploy.js"; +import { getFunctionsLogsCommand } from "./logs.js"; + +export function getFunctionsCommand(context: CLIContext): Command { + return new Command("functions") + .description("Manage project functions") + .addCommand(getFunctionsDeployCommand(context)) + .addCommand(getFunctionsLogsCommand(context)); +} diff --git a/src/cli/commands/functions/logs.ts b/src/cli/commands/functions/logs.ts new file mode 100644 index 00000000..f7cc1e36 --- /dev/null +++ b/src/cli/commands/functions/logs.ts @@ -0,0 +1,166 @@ +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 type { + FunctionLogEntry, + FunctionLogFilters, + FunctionLogsResponse, + LogLevel, +} from "@/core/resources/function/index.js"; +import { fetchFunctionLogs } from "@/core/resources/function/index.js"; + +interface LogsOptions { + since?: string; + until?: string; + level?: string; + json?: boolean; +} + +const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; + +/** + * Parse CLI options into FunctionLogFilters. + */ +function parseOptions(options: LogsOptions): FunctionLogFilters { + const filters: FunctionLogFilters = {}; + + if (options.since) { + filters.since = options.since; + } + + if (options.until) { + filters.until = options.until; + } + + if (options.level) { + if (!VALID_LEVELS.includes(options.level)) { + throw new InvalidInputError( + `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` + ); + } + filters.level = options.level as LogLevel; + } + + return filters; +} + +/** + * Get color/style for a log level. + */ +function formatLevel(level: LogLevel): 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); + } +} + +/** + * Format a single log entry for human-readable output. + */ +function formatLogEntry(entry: FunctionLogEntry): string { + // Shorten timestamp to readable format + const time = entry.time.substring(0, 19).replace("T", " "); + const level = formatLevel(entry.level); + // Truncate long messages for display (full message shown in JSON mode) + const message = + entry.message.length > 100 + ? `${entry.message.substring(0, 100)}...` + : entry.message; + + return `${theme.styles.dim(time)} ${level} ${message}`; +} + +/** + * Display logs in human-readable format. + */ +function displayLogs( + logs: FunctionLogsResponse, + functionName: string, + levelFilter?: LogLevel +): void { + // Filter by level if specified (API doesn't support level filtering) + const filteredLogs = levelFilter + ? logs.filter((entry) => entry.level === levelFilter) + : logs; + + if (filteredLogs.length === 0) { + log.info(`No logs found for function "${functionName}".`); + return; + } + + // Header + log.info( + theme.styles.dim( + `Showing ${filteredLogs.length} log entries for "${functionName}"\n` + ) + ); + + const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; + log.message(theme.styles.header(header)); + + // Log entries + for (const entry of filteredLogs) { + log.message(formatLogEntry(entry)); + } +} + +async function logsAction( + functionName: string, + options: LogsOptions +): Promise { + const filters = parseOptions(options); + + const logs = await runTask( + `Fetching logs for "${functionName}"...`, + async () => { + return await fetchFunctionLogs(functionName, filters); + }, + { + successMessage: "Logs fetched successfully", + errorMessage: "Failed to fetch function logs", + } + ); + + if (options.json) { + // Filter by level for JSON output too + const output = filters.level + ? logs.filter((entry) => entry.level === filters.level) + : logs; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + } else { + displayLogs(logs, functionName, filters.level); + } + + return {}; +} + +export function getFunctionsLogsCommand(context: CLIContext): Command { + return new Command("logs") + .description("Fetch runtime logs for a function") + .argument("", "Name of the function") + .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("--json", "Output raw JSON") + .action(async (functionName: string, options: LogsOptions) => { + await runCommand( + () => logsAction(functionName, options), + { requireAuth: true }, + context + ); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index ea21a9d5..6658dce4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,7 +5,7 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; 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 { getFunctionsCommand } from "@/cli/commands/functions/index.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"; @@ -47,7 +47,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getAgentsCommand(context)); // Register functions commands - program.addCommand(getFunctionsDeployCommand(context)); + program.addCommand(getFunctionsCommand(context)); // Register site commands program.addCommand(getSiteCommand(context)); diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index bf4edca0..4b47c93b 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 { @@ -44,3 +49,53 @@ 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); + } + + 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) { + 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 c12adb45..5ca3ed09 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -48,3 +48,39 @@ 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; +} diff --git a/tests/cli/functions_logs.spec.ts b/tests/cli/functions_logs.spec.ts new file mode 100644 index 00000000..b58d8f01 --- /dev/null +++ b/tests/cli/functions_logs.spec.ts @@ -0,0 +1,130 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("functions logs command", () => { + const t = setupCLITests(); + + it("fetches and displays logs successfully", 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("functions", "logs", "my-function"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Logs fetched successfully"); + t.expectResult(result).toContain('Showing 2 log entries for "my-function"'); + t.expectResult(result).toContain("Processing request"); + }); + + it("shows no logs message when empty", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", []); + + const result = await t.run("functions", "logs", "my-function"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("No logs found"); + }); + + it("filters by log level", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Info message", + }, + { + time: "2024-01-15T10:30:00.050Z", + level: "error", + message: "Error message", + }, + ]); + + const result = await t.run( + "functions", + "logs", + "my-function", + "--level", + "error" + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("Showing 1 log entries"); + t.expectResult(result).toContain("Error message"); + }); + + it("outputs raw JSON with --json flag", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "log", + message: "Test message", + }, + ]); + + const result = await t.run("functions", "logs", "my-function", "--json"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain('"time"'); + t.expectResult(result).toContain('"level"'); + t.expectResult(result).toContain('"message"'); + }); + + it("fails when not in a project directory", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + const result = await t.run("functions", "logs", "my-function"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("fails when API returns error", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogsError("my-function", { + status: 404, + body: { error: "Function not found" }, + }); + + const result = await t.run("functions", "logs", "my-function"); + + t.expectResult(result).toFail(); + }); + + it("fails with invalid level option", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run( + "functions", + "logs", + "my-function", + "--level", + "invalid" + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Invalid level"); + }); + + it("requires function name argument", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("functions", "logs"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("missing required argument"); + }); +}); diff --git a/tests/cli/testkit/Base44APIMock.ts b/tests/cli/testkit/Base44APIMock.ts index c356cbab..edefb0c7 100644 --- a/tests/cli/testkit/Base44APIMock.ts +++ b/tests/cli/testkit/Base44APIMock.ts @@ -80,6 +80,14 @@ export interface AuditLogsResponse { }; } +export interface FunctionLogEntry { + time: string; + level: "log" | "info" | "warn" | "error" | "debug"; + message: string; +} + +export type FunctionLogsResponse = FunctionLogEntry[]; + export interface CreateAppResponse { id: string; name: string; @@ -232,6 +240,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 */ @@ -323,6 +342,15 @@ export class Base44APIMock { return this.mockError("post", "/api/workspace/audit-logs/list", error); } + /** 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 777843164819f206507c090dbb63c171bd9c0759 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Thu, 5 Feb 2026 11:40:51 +0200 Subject: [PATCH 05/13] consolidate commands --- src/cli/commands/functions/index.ts | 4 +- src/cli/commands/functions/logs.ts | 166 --------- src/cli/commands/logs/index.ts | 543 +++++++++++++++++++++++----- src/core/logs/api.ts | 8 +- src/core/logs/schema.ts | 4 +- 5 files changed, 467 insertions(+), 258 deletions(-) delete mode 100644 src/cli/commands/functions/logs.ts diff --git a/src/cli/commands/functions/index.ts b/src/cli/commands/functions/index.ts index f5e46d21..ed509d28 100644 --- a/src/cli/commands/functions/index.ts +++ b/src/cli/commands/functions/index.ts @@ -1,11 +1,9 @@ import { Command } from "commander"; import type { CLIContext } from "@/cli/types.js"; import { getFunctionsDeployCommand } from "./deploy.js"; -import { getFunctionsLogsCommand } from "./logs.js"; export function getFunctionsCommand(context: CLIContext): Command { return new Command("functions") .description("Manage project functions") - .addCommand(getFunctionsDeployCommand(context)) - .addCommand(getFunctionsLogsCommand(context)); + .addCommand(getFunctionsDeployCommand(context)); } diff --git a/src/cli/commands/functions/logs.ts b/src/cli/commands/functions/logs.ts deleted file mode 100644 index f7cc1e36..00000000 --- a/src/cli/commands/functions/logs.ts +++ /dev/null @@ -1,166 +0,0 @@ -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 type { - FunctionLogEntry, - FunctionLogFilters, - FunctionLogsResponse, - LogLevel, -} from "@/core/resources/function/index.js"; -import { fetchFunctionLogs } from "@/core/resources/function/index.js"; - -interface LogsOptions { - since?: string; - until?: string; - level?: string; - json?: boolean; -} - -const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; - -/** - * Parse CLI options into FunctionLogFilters. - */ -function parseOptions(options: LogsOptions): FunctionLogFilters { - const filters: FunctionLogFilters = {}; - - if (options.since) { - filters.since = options.since; - } - - if (options.until) { - filters.until = options.until; - } - - if (options.level) { - if (!VALID_LEVELS.includes(options.level)) { - throw new InvalidInputError( - `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` - ); - } - filters.level = options.level as LogLevel; - } - - return filters; -} - -/** - * Get color/style for a log level. - */ -function formatLevel(level: LogLevel): 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); - } -} - -/** - * Format a single log entry for human-readable output. - */ -function formatLogEntry(entry: FunctionLogEntry): string { - // Shorten timestamp to readable format - const time = entry.time.substring(0, 19).replace("T", " "); - const level = formatLevel(entry.level); - // Truncate long messages for display (full message shown in JSON mode) - const message = - entry.message.length > 100 - ? `${entry.message.substring(0, 100)}...` - : entry.message; - - return `${theme.styles.dim(time)} ${level} ${message}`; -} - -/** - * Display logs in human-readable format. - */ -function displayLogs( - logs: FunctionLogsResponse, - functionName: string, - levelFilter?: LogLevel -): void { - // Filter by level if specified (API doesn't support level filtering) - const filteredLogs = levelFilter - ? logs.filter((entry) => entry.level === levelFilter) - : logs; - - if (filteredLogs.length === 0) { - log.info(`No logs found for function "${functionName}".`); - return; - } - - // Header - log.info( - theme.styles.dim( - `Showing ${filteredLogs.length} log entries for "${functionName}"\n` - ) - ); - - const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; - log.message(theme.styles.header(header)); - - // Log entries - for (const entry of filteredLogs) { - log.message(formatLogEntry(entry)); - } -} - -async function logsAction( - functionName: string, - options: LogsOptions -): Promise { - const filters = parseOptions(options); - - const logs = await runTask( - `Fetching logs for "${functionName}"...`, - async () => { - return await fetchFunctionLogs(functionName, filters); - }, - { - successMessage: "Logs fetched successfully", - errorMessage: "Failed to fetch function logs", - } - ); - - if (options.json) { - // Filter by level for JSON output too - const output = filters.level - ? logs.filter((entry) => entry.level === filters.level) - : logs; - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); - } else { - displayLogs(logs, functionName, filters.level); - } - - return {}; -} - -export function getFunctionsLogsCommand(context: CLIContext): Command { - return new Command("logs") - .description("Fetch runtime logs for a function") - .argument("", "Name of the function") - .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("--json", "Output raw JSON") - .action(async (functionName: string, options: LogsOptions) => { - await runCommand( - () => logsAction(functionName, options), - { requireAuth: true }, - context - ); - }); -} diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index bde31dbe..49d5f9e4 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -4,61 +4,247 @@ 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 type { AuditLogFilters, AuditLogsResponse } from "@/core/logs/index.js"; +import { readProjectConfig } from "@/core/index.js"; +import type { + AuditLogEvent, + AuditLogFilters, + AuditLogsResponse, +} from "@/core/logs/index.js"; import { fetchAuditLogs } from "@/core/logs/index.js"; +import type { + FunctionLogFilters, + LogLevel, +} from "@/core/resources/function/index.js"; +import { fetchFunctionLogs } from "@/core/resources/function/index.js"; + +// ─── TYPES ────────────────────────────────────────────────── interface LogsOptions { - status?: string; - eventTypes?: string; - userEmail?: string; - startDate?: string; - endDate?: string; + function?: string; + since?: string; + until?: string; + level?: string; limit?: string; order?: string; - cursorTimestamp?: string; - cursorUserEmail?: string; json?: boolean; } /** - * Parse CLI options into AuditLogFilters. + * Unified log entry for display (normalized from both sources). */ -function parseOptions(options: LogsOptions): AuditLogFilters { - const filters: AuditLogFilters = {}; +interface UnifiedLogEntry { + time: string; + level: string; + message: string; + source: string; // "App" for audit logs, function name for function logs +} - if (options.status) { - if (options.status !== "success" && options.status !== "failure") { - throw new InvalidInputError( - `Invalid status: "${options.status}". Must be "success" or "failure".` - ); +// ─── CONSTANTS ────────────────────────────────────────────── + +const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; + +// ─── AUDIT LOG MESSAGE FORMATTING ─────────────────────────── + +/** + * Format an audit log event into a human-readable message. + */ +function formatAuditMessage(event: AuditLogEvent): string { + const m = (event.metadata ?? {}) as Record; + const isFailure = event.status === "failure"; + + switch (event.event_type) { + // Function calls + case "api.function.call": { + const fnName = m.function_name ?? "unknown"; + const statusCode = m.status_code ?? ""; + return isFailure + ? `function ${fnName} failed (status ${statusCode})` + : `function ${fnName} called (status ${statusCode})`; } - filters.status = options.status; - } - if (options.eventTypes) { - try { - const parsed = JSON.parse(options.eventTypes); - if (!Array.isArray(parsed)) { - throw new Error("Not an array"); - } - filters.eventTypes = parsed; - } catch { - throw new InvalidInputError( - `Invalid event-types: "${options.eventTypes}". Must be a JSON array (e.g., '["api.function.call"]').` - ); + // Entity operations + case "app.entity.created": { + const entity = m.entity_name ?? "unknown"; + const id = m.entity_id ?? ""; + return isFailure + ? `failed to create entity ${entity}` + : `entity ${entity} created (id: ${id})`; + } + case "app.entity.updated": { + const entity = m.entity_name ?? "unknown"; + const id = m.entity_id ?? ""; + return isFailure + ? `failed to update entity ${entity}` + : `entity ${entity} updated (id: ${id})`; + } + case "app.entity.deleted": { + const entity = m.entity_name ?? "unknown"; + const id = m.entity_id ?? ""; + return isFailure + ? `failed to delete entity ${entity}` + : `entity ${entity} deleted (id: ${id})`; + } + case "app.entity.bulk_created": { + const entity = m.entity_name ?? "unknown"; + const count = m.count ?? 0; + const method = m.method ?? ""; + return isFailure + ? `bulk create failed for ${entity}` + : `${count} ${entity} entities created via ${method}`; + } + case "app.entity.bulk_deleted": { + const entity = m.entity_name ?? "unknown"; + const count = m.count ?? 0; + const method = m.method ?? ""; + return isFailure + ? `bulk delete failed for ${entity}` + : `${count} ${entity} entities deleted via ${method}`; + } + case "app.entity.query": { + const entity = m.entity_name ?? "unknown"; + return isFailure ? `query failed for ${entity}` : `queried ${entity}`; + } + + // User operations (always success) + case "app.user.registered": { + const email = m.target_email ?? "unknown"; + const role = m.role ?? ""; + return `user ${email} registered as ${role}`; + } + case "app.user.updated": { + const email = m.target_email ?? "unknown"; + return `user ${email} updated`; + } + case "app.user.deleted": { + const email = m.target_email ?? "unknown"; + return `user ${email} deleted`; + } + case "app.user.role_changed": { + const email = m.target_email ?? "unknown"; + const oldRole = m.old_role ?? ""; + const newRole = m.new_role ?? ""; + return `user ${email} role changed: ${oldRole} → ${newRole}`; + } + case "app.user.invited": { + const email = m.invitee_email ?? "unknown"; + const role = m.role ?? ""; + return `invited ${email} as ${role}`; + } + case "app.user.page_visit": { + const page = m.page_name ?? "unknown"; + return `page visit: ${page}`; + } + + // Auth operations (always success) + case "app.auth.login": { + const method = m.auth_method ?? "unknown"; + return `login via ${method}`; + } + + // Access operations (always success) + case "app.access.requested": { + const email = m.requester_email ?? "unknown"; + return `access requested by ${email}`; + } + case "app.access.approved": { + const email = m.target_email ?? "unknown"; + return `access approved for ${email}`; + } + case "app.access.denied": { + const email = m.target_email ?? "unknown"; + return `access denied for ${email}`; + } + + // Automation & Integration (can fail) + case "app.automation.executed": { + const name = m.automation_name ?? "unknown"; + return isFailure + ? `automation ${name} failed` + : `automation ${name} executed`; + } + case "app.integration.executed": { + const name = m.integration_name ?? "unknown"; + const action = m.action ?? ""; + const duration = m.duration_ms ?? ""; + return isFailure + ? `integration ${name} failed: ${action}` + : `integration ${name}: ${action} (${duration}ms)`; } + + // Agent conversation (always success) + case "app.agent.conversation": { + const name = m.agent_name ?? "unknown"; + const model = m.model ?? ""; + return `agent ${name} conversation (${model})`; + } + + // Fallback for unknown event types + default: + return isFailure ? `${event.event_type} failed` : event.event_type; } +} + +/** + * Normalize an audit log event to unified format. + */ +function normalizeAuditLog(event: AuditLogEvent): UnifiedLogEntry { + const level = event.status === "failure" ? "error" : "info"; + const message = formatAuditMessage(event); + return { time: event.timestamp, level, message, source: "App" }; +} + +/** + * Normalize a function log entry to unified format. + */ +function normalizeFunctionLog( + entry: { time: string; level: string; message: string }, + functionName: string +): UnifiedLogEntry { + return { + time: entry.time, + level: entry.level, + message: `[${functionName}] ${entry.message}`, + source: functionName, + }; +} - if (options.userEmail) { - filters.userEmail = options.userEmail; +// ─── OPTION PARSING ───────────────────────────────────────── + +/** + * Map --level to audit log status filter. + * - log/info → success + * - error → failure + * - debug → skip audit logs (returns null) + * - warn → no filter (show all) + */ +function mapLevelToStatus( + level: string | undefined +): "success" | "failure" | null | undefined { + if (!level) return undefined; // No filter + if (level === "debug") return null; // Skip audit logs + if (level === "log" || level === "info") return "success"; + if (level === "error") return "failure"; + return undefined; // warn - show all +} + +/** + * Parse CLI options into AuditLogFilters. + */ +function parseAuditFilters(options: LogsOptions): AuditLogFilters { + const filters: AuditLogFilters = {}; + + // Map level to status + const status = mapLevelToStatus(options.level); + if (status) { + filters.status = status; } - if (options.startDate) { - filters.startDate = options.startDate; + if (options.since) { + filters.since = options.since; } - if (options.endDate) { - filters.endDate = options.endDate; + if (options.until) { + filters.until = options.until; } if (options.limit) { @@ -81,106 +267,297 @@ function parseOptions(options: LogsOptions): AuditLogFilters { filters.order = order as "ASC" | "DESC"; } - if (options.cursorTimestamp) { - filters.cursorTimestamp = options.cursorTimestamp; + return filters; +} + +/** + * Check if audit logs should be fetched based on level filter. + */ +function shouldFetchAuditLogs(level: string | undefined): boolean { + return mapLevelToStatus(level) !== null; +} + +/** + * Parse CLI options into FunctionLogFilters. + */ +function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { + const filters: FunctionLogFilters = {}; + + if (options.since) { + filters.since = options.since; } - if (options.cursorUserEmail) { - filters.cursorUserEmail = options.cursorUserEmail; + if (options.until) { + filters.until = options.until; + } + + if (options.level) { + if (!VALID_LEVELS.includes(options.level)) { + throw new InvalidInputError( + `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` + ); + } + filters.level = options.level as LogLevel; } return filters; } /** - * Format a single log event for human-readable output. + * Parse --function option into array of function names. */ -function formatEvent(event: AuditLogsResponse["events"][0]): string { - // Shorten timestamp to readable format (remove microseconds) - const timestamp = event.timestamp.substring(0, 19).replace("T", " "); - const eventType = event.event_type; - const user = event.user_email || "-"; - const status = - event.status === "success" - ? theme.styles.dim(event.status) - : theme.colors.base44Orange(event.status); +function parseFunctionNames(option: string | undefined): string[] { + if (!option) return []; + return option + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} - return `${theme.styles.dim(timestamp)} ${eventType.padEnd(24)} ${user.padEnd(28)} ${status}`; +// ─── 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); + } } /** - * Display logs in human-readable format. + * Wrap text at specified width, returning array of lines. */ -function displayLogs(response: AuditLogsResponse): void { - const { events, pagination } = response; +function wrapText(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; - if (events.length === 0) { - log.info("No events found matching the filters."); +/** + * Format a unified log entry for display. + */ +function formatEntry(entry: UnifiedLogEntry): string { + const time = entry.time.substring(0, 19).replace("T", " "); + const level = formatLevel(entry.level); + + // Wrap message at 80 characters + const messageLines = wrapText(entry.message, MESSAGE_WIDTH); + const firstLine = `${theme.styles.dim(time)} ${level} ${messageLines[0]}`; + + if (messageLines.length === 1) { + return firstLine; + } + + // Join continuation lines with proper indentation + const continuationLines = messageLines + .slice(1) + .map((line) => `${MESSAGE_INDENT}${line}`) + .join("\n"); + + return `${firstLine}\n${continuationLines}`; +} + +/** + * Display unified logs. + */ +function displayLogs( + entries: UnifiedLogEntry[], + pagination?: AuditLogsResponse["pagination"] +): void { + if (entries.length === 0) { + log.info("No logs found matching the filters."); return; } // Header - log.info( - theme.styles.dim(`Showing ${events.length} of ${pagination.total} events\n`) - ); + const countInfo = pagination + ? `Showing ${entries.length} of ${pagination.total} events` + : `Showing ${entries.length} log entries`; + log.info(theme.styles.dim(`${countInfo}\n`)); - const header = `${"TIME".padEnd(19)} ${"EVENT TYPE".padEnd(24)} ${"USER".padEnd(28)} STATUS`; + const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; log.message(theme.styles.header(header)); - // Events - for (const event of events) { - log.message(formatEvent(event)); + // Entries + for (const entry of entries) { + log.message(formatEntry(entry)); } - // Pagination hint - if (pagination.has_more && pagination.next_cursor) { + // Pagination hint (only for audit logs) + if (pagination?.has_more) { log.info( - theme.styles.dim( - `\nMore results available. Use --cursor-timestamp="${pagination.next_cursor.timestamp}" --cursor-user-email="${pagination.next_cursor.user_email}" for next page.` - ) + theme.styles.dim("\nMore results available. Use --limit to fetch more.") ); } } -async function logsAction(options: LogsOptions): Promise { - const filters = parseOptions(options); +// ─── ACTIONS ──────────────────────────────────────────────── + +/** + * Fetch and display audit logs. + */ +async function fetchAuditLogsAction(options: LogsOptions): Promise<{ + entries: UnifiedLogEntry[]; + pagination: AuditLogsResponse["pagination"]; +}> { + const filters = parseAuditFilters(options); const response = await runTask( - "Fetching audit logs...", + "Fetching app logs...", async () => { return await fetchAuditLogs(filters); }, { successMessage: "Logs fetched successfully", - errorMessage: "Failed to fetch audit logs", + errorMessage: "Failed to fetch app logs", } ); + const entries = response.events.map(normalizeAuditLog); + return { entries, pagination: response.pagination }; +} + +/** + * Fetch and display function logs. + */ +async function fetchFunctionLogsAction( + functionNames: string[], + options: LogsOptions +): Promise { + const filters = parseFunctionFilters(options); + const allEntries: UnifiedLogEntry[] = []; + + 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}"`, + } + ); + + // Filter by level if specified (API doesn't support level filtering) + const filteredLogs = filters.level + ? logs.filter((entry) => entry.level === filters.level) + : logs; + + const entries = filteredLogs.map((entry) => + normalizeFunctionLog(entry, functionName) + ); + allEntries.push(...entries); + } + + // Sort by time (respecting order option) + 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 { + const specifiedFunctions = parseFunctionNames(options.function); + const allEntries: UnifiedLogEntry[] = []; + let pagination: AuditLogsResponse["pagination"] | undefined; + + if (specifiedFunctions.length > 0) { + // --function specified: fetch only those function logs (no audit logs) + const entries = await fetchFunctionLogsAction(specifiedFunctions, options); + allEntries.push(...entries); + } else { + // No --function: fetch both audit logs and all project function logs + + // Fetch audit logs (unless --level=debug) + if (shouldFetchAuditLogs(options.level)) { + const result = await fetchAuditLogsAction(options); + allEntries.push(...result.entries); + pagination = result.pagination; + } + + // Fetch all project function logs + const functionNames = await getAllFunctionNames(); + if (functionNames.length > 0) { + const functionEntries = await fetchFunctionLogsAction( + functionNames, + options + ); + allEntries.push(...functionEntries); + } + + // Sort combined entries by time + const order = options.order?.toUpperCase() === "ASC" ? 1 : -1; + allEntries.sort((a, b) => order * a.time.localeCompare(b.time)); + } + if (options.json) { - // Output raw JSON for scripting - use process.stdout for proper capture - process.stdout.write(`${JSON.stringify(response, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(allEntries, null, 2)}\n`); } else { - displayLogs(response); + displayLogs(allEntries, pagination); } return {}; } +// ─── COMMAND ──────────────────────────────────────────────── + export function getLogsCommand(context: CLIContext): Command { return new Command("logs") - .description("Fetch audit logs for this app") - .option("--status ", "Filter by outcome: success|failure") + .description("Fetch logs for this app (app logs + all function logs)") + .option( + "--function ", + "Filter by function name(s), comma-separated. If provided, fetches only those function logs" + ) + .option("--since ", "Show logs from this time (ISO format)") + .option("--until ", "Show logs until this time (ISO format)") .option( - "--event-types ", - "Filter by event types (JSON array, e.g., '[\"api.function.call\"]')" + "--level ", + "Filter by log level: log, info, warn, error, debug" ) - .option("--user-email ", "Filter by user email") - .option("--start-date ", "Filter events from this date (ISO format)") - .option("--end-date ", "Filter events until this date (ISO format)") .option("-n, --limit ", "Results per page (1-1000, default: 50)") .option("--order ", "Sort order: ASC|DESC (default: DESC)") - .option("--cursor-timestamp ", "Pagination cursor timestamp") - .option("--cursor-user-email ", "Pagination cursor user email") .option("--json", "Output raw JSON") .action(async (options: LogsOptions) => { await runCommand( diff --git a/src/core/logs/api.ts b/src/core/logs/api.ts index 49592539..b81f25ee 100644 --- a/src/core/logs/api.ts +++ b/src/core/logs/api.ts @@ -59,11 +59,11 @@ function buildRequestBody( if (filters.userEmail) { body.user_email = filters.userEmail; } - if (filters.startDate) { - body.start_date = filters.startDate; + if (filters.since) { + body.start_date = filters.since; } - if (filters.endDate) { - body.end_date = filters.endDate; + if (filters.until) { + body.end_date = filters.until; } if (filters.cursorTimestamp) { body.cursor_timestamp = filters.cursorTimestamp; diff --git a/src/core/logs/schema.ts b/src/core/logs/schema.ts index 5a6377d9..4b836dae 100644 --- a/src/core/logs/schema.ts +++ b/src/core/logs/schema.ts @@ -86,8 +86,8 @@ export interface AuditLogFilters { status?: "success" | "failure"; eventTypes?: string[]; userEmail?: string; - startDate?: string; - endDate?: string; + since?: string; + until?: string; limit?: number; order?: "ASC" | "DESC"; cursorTimestamp?: string; From 965d3ff39c7b522bc86d82910cf9836bf4979bac Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 14:27:03 +0200 Subject: [PATCH 06/13] restore files --- src/cli/commands/functions/deploy.ts | 18 +++++++++++++----- src/cli/commands/functions/index.ts | 9 --------- src/cli/program.ts | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) delete mode 100644 src/cli/commands/functions/index.ts diff --git a/src/cli/commands/functions/deploy.ts b/src/cli/commands/functions/deploy.ts index abd438e6..1c5fa797 100644 --- a/src/cli/commands/functions/deploy.ts +++ b/src/cli/commands/functions/deploy.ts @@ -54,9 +54,17 @@ async function deployFunctionsAction(): Promise { } export function getFunctionsDeployCommand(context: CLIContext): Command { - return new Command("deploy") - .description("Deploy local functions to Base44") - .action(async () => { - await runCommand(deployFunctionsAction, { requireAuth: true }, context); - }); + return new Command("functions") + .description("Manage project functions") + .addCommand( + new Command("deploy") + .description("Deploy local functions to Base44") + .action(async () => { + await runCommand( + deployFunctionsAction, + { requireAuth: true }, + context + ); + }) + ); } diff --git a/src/cli/commands/functions/index.ts b/src/cli/commands/functions/index.ts deleted file mode 100644 index ed509d28..00000000 --- a/src/cli/commands/functions/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Command } from "commander"; -import type { CLIContext } from "@/cli/types.js"; -import { getFunctionsDeployCommand } from "./deploy.js"; - -export function getFunctionsCommand(context: CLIContext): Command { - return new Command("functions") - .description("Manage project functions") - .addCommand(getFunctionsDeployCommand(context)); -} diff --git a/src/cli/program.ts b/src/cli/program.ts index 6658dce4..39b86d45 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,7 +5,7 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; 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 { getFunctionsCommand } from "@/cli/commands/functions/index.js"; +import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy"; import { getLogsCommand } from "@/cli/commands/logs/index.js"; import { getCreateCommand } from "@/cli/commands/project/create.js"; import { getDeployCommand } from "@/cli/commands/project/deploy.js"; @@ -47,7 +47,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getAgentsCommand(context)); // Register functions commands - program.addCommand(getFunctionsCommand(context)); + program.addCommand(getFunctionsDeployCommand(context)); // Register site commands program.addCommand(getSiteCommand(context)); From a51c6e99b357ab382e35d7388e2be440fe6840bb Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 14:27:36 +0200 Subject: [PATCH 07/13] fix import --- src/cli/program.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index 39b86d45..ea21a9d5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -5,7 +5,7 @@ import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; 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"; +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"; From cf689fc57606abc43f7e1c858897fd2b0a9a8a02 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 15:35:22 +0200 Subject: [PATCH 08/13] work on logs --- src/cli/commands/logs/index.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 49d5f9e4..1423814e 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -335,9 +335,9 @@ function formatLevel(level: string): string { } /** - * Wrap text at specified width, returning array of lines. + * Wrap a single line at specified width, returning array of lines. */ -function wrapText(text: string, width: number): string[] { +function wrapLine(text: string, width: number): string[] { if (text.length <= width) return [text]; const lines: string[] = []; @@ -365,21 +365,29 @@ const MESSAGE_WIDTH = 80; /** * Format a unified log entry for display. + * Preserves original newlines in the message and wraps long lines. */ function formatEntry(entry: UnifiedLogEntry): string { const time = entry.time.substring(0, 19).replace("T", " "); const level = formatLevel(entry.level); - // Wrap message at 80 characters - const messageLines = wrapText(entry.message, MESSAGE_WIDTH); - const firstLine = `${theme.styles.dim(time)} ${level} ${messageLines[0]}`; + // Split by original newlines first, then wrap each line + const originalLines = entry.message.split("\n"); + const allLines: string[] = []; - if (messageLines.length === 1) { + 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 = messageLines + const continuationLines = allLines .slice(1) .map((line) => `${MESSAGE_INDENT}${line}`) .join("\n"); From dbe86f581af8d17822c472689f6ab79ce67acd8c Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 15:42:55 +0200 Subject: [PATCH 09/13] refactor --- src/core/logs/index.ts | 2 - src/core/logs/schema.ts | 21 ----- tests/cli/functions_logs.spec.ts | 130 ----------------------------- tests/cli/logs.spec.ts | 138 ++++++++++++++++++++++++------- 4 files changed, 108 insertions(+), 183 deletions(-) delete mode 100644 tests/cli/functions_logs.spec.ts diff --git a/src/core/logs/index.ts b/src/core/logs/index.ts index 4b137f0b..94ea5aa4 100644 --- a/src/core/logs/index.ts +++ b/src/core/logs/index.ts @@ -2,8 +2,6 @@ export { fetchAuditLogs, getWorkspaceId } from "./api.js"; export type { AuditLogEvent, AuditLogFilters, - AuditLogRequest, AuditLogsResponse, Pagination, - PaginationCursor, } from "./schema.js"; diff --git a/src/core/logs/schema.ts b/src/core/logs/schema.ts index 4b836dae..2a929b2c 100644 --- a/src/core/logs/schema.ts +++ b/src/core/logs/schema.ts @@ -1,24 +1,5 @@ import { z } from "zod"; -/** - * Request body schema for the audit logs API. - * Uses snake_case to match API expectations. - */ -export const AuditLogRequestSchema = z.object({ - app_id: z.string(), - event_types: z.array(z.string()).optional(), - user_email: z.string().optional(), - status: z.enum(["success", "failure"]).optional(), - start_date: z.string().optional(), - end_date: z.string().optional(), - limit: z.number().min(1).max(1000).default(50), - order: z.enum(["ASC", "DESC"]).default("DESC"), - cursor_timestamp: z.string().optional(), - cursor_user_email: z.string().optional(), -}); - -export type AuditLogRequest = z.infer; - /** * Single audit log event from the API response. */ @@ -45,8 +26,6 @@ export const PaginationCursorSchema = z.object({ user_email: z.string(), }); -export type PaginationCursor = z.infer; - /** * Pagination info from the API response. */ diff --git a/tests/cli/functions_logs.spec.ts b/tests/cli/functions_logs.spec.ts deleted file mode 100644 index b58d8f01..00000000 --- a/tests/cli/functions_logs.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it } from "vitest"; -import { fixture, setupCLITests } from "./testkit/index.js"; - -describe("functions logs command", () => { - const t = setupCLITests(); - - it("fetches and displays logs successfully", 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("functions", "logs", "my-function"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Logs fetched successfully"); - t.expectResult(result).toContain('Showing 2 log entries for "my-function"'); - t.expectResult(result).toContain("Processing request"); - }); - - it("shows no logs message when empty", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockFunctionLogs("my-function", []); - - const result = await t.run("functions", "logs", "my-function"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("No logs found"); - }); - - it("filters by log level", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockFunctionLogs("my-function", [ - { - time: "2024-01-15T10:30:00.000Z", - level: "info", - message: "Info message", - }, - { - time: "2024-01-15T10:30:00.050Z", - level: "error", - message: "Error message", - }, - ]); - - const result = await t.run( - "functions", - "logs", - "my-function", - "--level", - "error" - ); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("Showing 1 log entries"); - t.expectResult(result).toContain("Error message"); - }); - - it("outputs raw JSON with --json flag", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockFunctionLogs("my-function", [ - { - time: "2024-01-15T10:30:00.000Z", - level: "log", - message: "Test message", - }, - ]); - - const result = await t.run("functions", "logs", "my-function", "--json"); - - t.expectResult(result).toSucceed(); - t.expectResult(result).toContain('"time"'); - t.expectResult(result).toContain('"level"'); - t.expectResult(result).toContain('"message"'); - }); - - it("fails when not in a project directory", async () => { - await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); - - const result = await t.run("functions", "logs", "my-function"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("No Base44 project found"); - }); - - it("fails when API returns error", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - t.api.mockFunctionLogsError("my-function", { - status: 404, - body: { error: "Function not found" }, - }); - - const result = await t.run("functions", "logs", "my-function"); - - t.expectResult(result).toFail(); - }); - - it("fails with invalid level option", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - - const result = await t.run( - "functions", - "logs", - "my-function", - "--level", - "invalid" - ); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid level"); - }); - - it("requires function name argument", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - - const result = await t.run("functions", "logs"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("missing required argument"); - }); -}); diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index 33e028b9..8fdbaa82 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -6,7 +6,7 @@ const TEST_WORKSPACE_ID = "test-workspace-id"; describe("logs command", () => { const t = setupCLITests(); - it("fetches and displays logs successfully", async () => { + it("fetches and displays audit logs successfully", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); t.api.mockAuditLogs(TEST_WORKSPACE_ID, { @@ -49,11 +49,11 @@ describe("logs command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Logs fetched successfully"); t.expectResult(result).toContain("Showing 2 of 2 events"); - t.expectResult(result).toContain("api.function.call"); - t.expectResult(result).toContain("app.entity.created"); + t.expectResult(result).toContain("function unknown called"); + t.expectResult(result).toContain("failed to create entity Task"); }); - it("shows no events message when empty", async () => { + it("shows no logs message when empty", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); t.api.mockAuditLogs(TEST_WORKSPACE_ID, { @@ -69,10 +69,10 @@ describe("logs command", () => { const result = await t.run("logs"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain("No events found"); + t.expectResult(result).toContain("No logs found matching the filters."); }); - it("outputs raw JSON with --json flag", async () => { + it("outputs unified JSON with --json flag", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); t.api.mockAuditLogs(TEST_WORKSPACE_ID, { @@ -101,8 +101,10 @@ describe("logs command", () => { const result = await t.run("logs", "--json"); t.expectResult(result).toSucceed(); - t.expectResult(result).toContain('"events"'); - t.expectResult(result).toContain('"pagination"'); + t.expectResult(result).toContain('"time"'); + t.expectResult(result).toContain('"level"'); + t.expectResult(result).toContain('"message"'); + t.expectResult(result).toContain('"source"'); }); it("shows pagination hint when more results available", async () => { @@ -138,7 +140,98 @@ describe("logs command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("More results available"); - t.expectResult(result).toContain("--cursor-timestamp"); + t.expectResult(result).toContain("--limit"); + }); + + it("fetches only 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 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("filters function logs by --level", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { time: "2024-01-15T10:30:00.000Z", level: "info", message: "Info message" }, + { 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("fetches both audit and function logs when no --function specified", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + t.api.mockAppInfo({ organization_id: TEST_WORKSPACE_ID }); + t.api.mockAuditLogs(TEST_WORKSPACE_ID, { + events: [ + { + timestamp: "2024-01-15T10:30:00Z", + user_email: "user@example.com", + workspace_id: TEST_WORKSPACE_ID, + app_id: "test-app-id", + event_type: "app.entity.created", + status: "success", + ip: null, + user_agent: null, + error_code: null, + metadata: { entity_name: "Task", entity_id: "1" }, + }, + ], + pagination: { + total: 1, + limit: 50, + has_more: false, + next_cursor: null, + }, + }); + 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("Logs fetched successfully"); + t.expectResult(result).toContain("entity Task created"); + t.expectResult(result).toContain("Hello world"); }); it("fails when not in a project directory", async () => { @@ -163,22 +256,15 @@ describe("logs command", () => { t.expectResult(result).toFail(); }); - it("fails with invalid status option", async () => { - await t.givenLoggedInWithProject(fixture("basic")); - - const result = await t.run("logs", "--status", "invalid"); - - t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid status"); - }); - - it("fails with invalid event-types option", async () => { + it("fails with invalid level option", async () => { await t.givenLoggedInWithProject(fixture("basic")); + // --level is validated when fetching function logs; use --function so level is parsed + t.api.mockFunctionLogs("dummy", []); - const result = await t.run("logs", "--event-types", "not-json"); + const result = await t.run("logs", "--function", "dummy", "--level", "invalid"); t.expectResult(result).toFail(); - t.expectResult(result).toContain("Invalid event-types"); + t.expectResult(result).toContain("Invalid level"); }); it("fails with invalid limit option", async () => { @@ -212,15 +298,7 @@ describe("logs command", () => { }, }); - const result = await t.run( - "logs", - "--status", - "failure", - "--limit", - "10", - "--order", - "ASC" - ); + const result = await t.run("logs", "--limit", "10", "--order", "ASC"); t.expectResult(result).toSucceed(); }); From 2f8ea4a58eab71a377f19dcd5d9e23fa689d8502 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 15:48:53 +0200 Subject: [PATCH 10/13] lint --- tests/cli/logs.spec.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index 8fdbaa82..ca2c673b 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -161,7 +161,7 @@ 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('Logs for "my-function" fetched'); t.expectResult(result).toContain("Showing 2 log entries"); t.expectResult(result).toContain("Processing request"); t.expectResult(result).toContain("Something went wrong"); @@ -186,11 +186,25 @@ describe("logs command", () => { it("filters function logs by --level", async () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockFunctionLogs("my-function", [ - { time: "2024-01-15T10:30:00.000Z", level: "info", message: "Info message" }, - { time: "2024-01-15T10:30:00.050Z", level: "error", message: "Error message" }, + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Info message", + }, + { + time: "2024-01-15T10:30:00.050Z", + level: "error", + message: "Error message", + }, ]); - const result = await t.run("logs", "--function", "my-function", "--level", "error"); + const result = await t.run( + "logs", + "--function", + "my-function", + "--level", + "error" + ); t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Error message"); @@ -261,7 +275,13 @@ describe("logs command", () => { // --level is validated when fetching function logs; use --function so level is parsed t.api.mockFunctionLogs("dummy", []); - const result = await t.run("logs", "--function", "dummy", "--level", "invalid"); + const result = await t.run( + "logs", + "--function", + "dummy", + "--level", + "invalid" + ); t.expectResult(result).toFail(); t.expectResult(result).toContain("Invalid level"); From 2f13a6ae77e5d0cf2f7078cd522e25e5ea1cb0ad Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 17:01:41 +0200 Subject: [PATCH 11/13] fix bugs --- src/cli/commands/logs/index.ts | 118 +++++++++++++++++++------- src/core/resources/function/api.ts | 11 +++ src/core/resources/function/schema.ts | 1 + tests/cli/logs.spec.ts | 4 +- 4 files changed, 100 insertions(+), 34 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 1423814e..4afa85f1 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -39,6 +39,16 @@ interface UnifiedLogEntry { source: string; // "App" for audit logs, function name for function logs } +/** + * Counts for display header (audit vs function breakdown and pagination). + */ +interface LogCounts { + auditCount: number; + functionCount: number; + auditTotal?: number; + hasMore?: boolean; +} + // ─── CONSTANTS ────────────────────────────────────────────── const VALID_LEVELS = ["log", "info", "warn", "error", "debug"]; @@ -214,17 +224,16 @@ function normalizeFunctionLog( * Map --level to audit log status filter. * - log/info → success * - error → failure - * - debug → skip audit logs (returns null) - * - warn → no filter (show all) + * - debug/warn → skip audit logs (returns null; audit has no warn level) */ function mapLevelToStatus( level: string | undefined ): "success" | "failure" | null | undefined { if (!level) return undefined; // No filter - if (level === "debug") return null; // Skip audit logs + if (level === "debug" || level === "warn") return null; // Skip audit logs if (level === "log" || level === "info") return "success"; if (level === "error") return "failure"; - return undefined; // warn - show all + return undefined; } /** @@ -248,23 +257,11 @@ function parseAuditFilters(options: LogsOptions): AuditLogFilters { } 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.` - ); - } - filters.limit = limit; + filters.limit = Number.parseInt(options.limit, 10); } 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".` - ); - } - filters.order = order as "ASC" | "DESC"; + filters.order = options.order.toUpperCase() as "ASC" | "DESC"; } return filters; @@ -292,14 +289,13 @@ function parseFunctionFilters(options: LogsOptions): FunctionLogFilters { } if (options.level) { - if (!VALID_LEVELS.includes(options.level)) { - throw new InvalidInputError( - `Invalid level: "${options.level}". Must be one of: ${VALID_LEVELS.join(", ")}.` - ); - } filters.level = options.level as LogLevel; } + if (options.limit) { + filters.limit = Number.parseInt(options.limit, 10); + } + return filters; } @@ -314,6 +310,41 @@ 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 ──────────────────────────────────────────────── /** @@ -396,21 +427,28 @@ function formatEntry(entry: UnifiedLogEntry): string { } /** - * Display unified logs. + * Display unified logs with count breakdown. */ function displayLogs( entries: UnifiedLogEntry[], - pagination?: AuditLogsResponse["pagination"] + counts: LogCounts ): void { if (entries.length === 0) { log.info("No logs found matching the filters."); return; } - // Header - const countInfo = pagination - ? `Showing ${entries.length} of ${pagination.total} events` - : `Showing ${entries.length} log entries`; + const { auditCount, functionCount, auditTotal, hasMore } = counts; + let countInfo: string; + if (auditCount > 0 && functionCount > 0) { + countInfo = `Showing ${entries.length} log entries (${auditCount} app events, ${functionCount} function logs)`; + } else if (auditCount > 0 && auditTotal !== undefined) { + countInfo = `Showing ${entries.length} of ${auditTotal} app events`; + } else if (functionCount > 0) { + countInfo = `Showing ${entries.length} function log entries`; + } else { + countInfo = `Showing ${entries.length} log entries`; + } log.info(theme.styles.dim(`${countInfo}\n`)); const header = `${"TIME".padEnd(19)} ${"LEVEL".padEnd(5)} MESSAGE`; @@ -421,8 +459,7 @@ function displayLogs( log.message(formatEntry(entry)); } - // Pagination hint (only for audit logs) - if (pagination?.has_more) { + if (hasMore) { log.info( theme.styles.dim("\nMore results available. Use --limit to fetch more.") ); @@ -507,6 +544,10 @@ async function getAllFunctionNames(): Promise { * 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); const allEntries: UnifiedLogEntry[] = []; let pagination: AuditLogsResponse["pagination"] | undefined; @@ -540,10 +581,23 @@ async function logsAction(options: LogsOptions): Promise { allEntries.sort((a, b) => order * a.time.localeCompare(b.time)); } + const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined; + if (limit !== undefined && allEntries.length > limit) { + allEntries.length = limit; + } + if (options.json) { process.stdout.write(`${JSON.stringify(allEntries, null, 2)}\n`); } else { - displayLogs(allEntries, pagination); + const auditCount = allEntries.filter((e) => e.source === "App").length; + const functionCount = allEntries.length - auditCount; + const counts: LogCounts = { + auditCount, + functionCount, + auditTotal: functionCount === 0 ? pagination?.total : undefined, + hasMore: functionCount === 0 ? pagination?.has_more : undefined, + }; + displayLogs(allEntries, counts); } return {}; diff --git a/src/core/resources/function/api.ts b/src/core/resources/function/api.ts index 4b47c93b..495861cf 100644 --- a/src/core/resources/function/api.ts +++ b/src/core/resources/function/api.ts @@ -1,4 +1,5 @@ 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 { @@ -64,6 +65,9 @@ function buildLogsQueryString(filters: FunctionLogFilters): string { if (filters.until) { params.set("until", filters.until); } + if (filters.limit !== undefined) { + params.set("limit", String(filters.limit)); + } const queryString = params.toString(); return queryString ? `?${queryString}` : ""; @@ -85,6 +89,13 @@ 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" }], + }); + } throw await ApiError.fromHttpError(error, "fetching function logs"); } diff --git a/src/core/resources/function/schema.ts b/src/core/resources/function/schema.ts index 5ca3ed09..5501c9c0 100644 --- a/src/core/resources/function/schema.ts +++ b/src/core/resources/function/schema.ts @@ -83,4 +83,5 @@ export interface FunctionLogFilters { since?: string; until?: string; level?: LogLevel; + limit?: number; } diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index ca2c673b..6248bd2b 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -48,7 +48,7 @@ describe("logs command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain("Logs fetched successfully"); - t.expectResult(result).toContain("Showing 2 of 2 events"); + t.expectResult(result).toContain("Showing 2 of 2 app events"); t.expectResult(result).toContain("function unknown called"); t.expectResult(result).toContain("failed to create entity Task"); }); @@ -162,7 +162,7 @@ describe("logs command", () => { t.expectResult(result).toSucceed(); t.expectResult(result).toContain('Logs for "my-function" fetched'); - t.expectResult(result).toContain("Showing 2 log entries"); + t.expectResult(result).toContain("Showing 2 function log entries"); t.expectResult(result).toContain("Processing request"); t.expectResult(result).toContain("Something went wrong"); }); From afcc270dbdb910f6f4fd674b894bfeec7510aae2 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 17:10:04 +0200 Subject: [PATCH 12/13] lint --- src/cli/commands/logs/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/cli/commands/logs/index.ts b/src/cli/commands/logs/index.ts index 4afa85f1..c84c3128 100644 --- a/src/cli/commands/logs/index.ts +++ b/src/cli/commands/logs/index.ts @@ -429,10 +429,7 @@ function formatEntry(entry: UnifiedLogEntry): string { /** * Display unified logs with count breakdown. */ -function displayLogs( - entries: UnifiedLogEntry[], - counts: LogCounts -): void { +function displayLogs(entries: UnifiedLogEntry[], counts: LogCounts): void { if (entries.length === 0) { log.info("No logs found matching the filters."); return; From 33a629755f7175952871770c4d9bda82c60c5f90 Mon Sep 17 00:00:00 2001 From: Oz Sayag Date: Sun, 8 Feb 2026 17:14:44 +0200 Subject: [PATCH 13/13] lint --- src/cli/program.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index ea21a9d5..024b71e1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -54,7 +54,7 @@ export function createProgram(context: CLIContext): Command { // Register types command program.addCommand(getTypesCommand(context), { hidden: true }); - + // Register logs command program.addCommand(getLogsCommand(context));