Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th
| [`connectors push`](https://docs.base44.com/developers/references/cli/commands/connectors-push) | Push local connectors to Base44 |
| [`entities push`](https://docs.base44.com/developers/references/cli/commands/entities-push) | Push local entities to Base44 |
| [`functions deploy`](https://docs.base44.com/developers/references/cli/commands/functions-deploy) | Deploy local functions to Base44 |
| `logs` | Fetch function logs for this app |
| [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting |
| [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser |
| [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources |
Expand Down
229 changes: 229 additions & 0 deletions src/cli/commands/project/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { Command, Option } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import { ApiError, InvalidInputError } from "@/core/errors.js";
import { readProjectConfig } from "@/core/index.js";
import type {
FunctionLogFilters,
FunctionLogsResponse,
} from "@/core/resources/function/index.js";
import { fetchFunctionLogs } from "@/core/resources/function/index.js";

interface LogsOptions {
function?: string;
since?: string;
until?: string;
limit?: string;
order?: string;
json?: boolean;
}

/**
* Unified log entry for display.
*/
interface LogEntry {
time: string;
level: string;
message: string;
source: string; // function name
}

function parseFunctionFilters(options: LogsOptions): FunctionLogFilters {
const filters: FunctionLogFilters = {};

if (options.since) {
filters.since = options.since;
}

if (options.until) {
filters.until = options.until;
}

if (options.limit) {
filters.limit = Number.parseInt(options.limit, 10);
}

if (options.order) {
filters.order = options.order.toLowerCase() as "asc" | "desc";
}

return filters;
}

function parseFunctionNames(option: string | undefined): string[] {
if (!option) return [];
return option
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}

function normalizeDatetime(value: string): string {
if (/Z$|[+-]\d{2}:\d{2}$/.test(value)) return value;
return `${value}Z`;
}

function formatEntry(entry: LogEntry): string {
const time = entry.time.substring(0, 19).replace("T", " ");
const level = entry.level.toUpperCase().padEnd(5);
const message = entry.message.trim();
return `${time} ${level} ${message}`;
}

/**
* Build function logs output (log-file style).
*/
function formatLogs(entries: LogEntry[]): string {
if (entries.length === 0) {
return "No logs found matching the filters.\n";
}

const header = `Showing ${entries.length} function log entries\n`;
return [header, ...entries.map(formatEntry)].join("\n");
}

function normalizeLogEntry(
entry: { time: string; level: string; message: string },
functionName: string,
): LogEntry {
return {
time: entry.time,
level: entry.level,
message: `[${functionName}] ${entry.message}`,
source: functionName,
};
}

async function fetchLogsForFunctions(
functionNames: string[],
options: LogsOptions,
availableFunctionNames: string[],
): Promise<LogEntry[]> {
const filters = parseFunctionFilters(options);
const allEntries: LogEntry[] = [];

for (const functionName of functionNames) {
let logs: FunctionLogsResponse;
try {
logs = await fetchFunctionLogs(functionName, filters);
} catch (error) {
if (
error instanceof ApiError &&
error.statusCode === 404 &&
availableFunctionNames.length > 0
) {
const available = availableFunctionNames.join(", ");
throw new InvalidInputError(
`Function "${functionName}" was not found in this app`,
{
hints: [
{
message: `Available functions in this project: ${available}`,
},
{
message:
"Make sure the function has been deployed before fetching logs",
command: "base44 functions deploy",
},
],
},
);
}
throw error;
}

const entries = logs.map((entry) => normalizeLogEntry(entry, functionName));
allEntries.push(...entries);
}

// When fetching multiple functions, merge-sort the combined results
// (each function's logs are already sorted by the backend)
if (functionNames.length > 1) {
const order = options.order?.toUpperCase() === "ASC" ? 1 : -1;
allEntries.sort((a, b) => order * a.time.localeCompare(b.time));
}

return allEntries;
}

async function getAllFunctionNames(): Promise<string[]> {
const { functions } = await readProjectConfig();
return functions.map((fn) => fn.name);
}

async function logsAction(options: LogsOptions): Promise<RunCommandResult> {
const specifiedFunctions = parseFunctionNames(options.function);

// Always read project functions so we can list them in error messages
const allProjectFunctions = await getAllFunctionNames();

// Determine which functions to fetch logs for
const functionNames =
specifiedFunctions.length > 0 ? specifiedFunctions : allProjectFunctions;

if (functionNames.length === 0) {
return { outroMessage: "No functions found in this project." };
}

let entries = await fetchLogsForFunctions(
functionNames,
options,
allProjectFunctions,
);

// Apply limit after merging logs from all functions
const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined;
if (limit !== undefined && entries.length > limit) {
entries = entries.slice(0, limit);
}

const logsOutput = options.json
? `${JSON.stringify(entries, null, 2)}\n`
: formatLogs(entries);

return { outroMessage: "Fetched logs", stdout: logsOutput };
}

export function getLogsCommand(context: CLIContext): Command {
return new Command("logs")
.description("Fetch function logs for this app")
.option(
"--function <names>",
"Filter by function name(s), comma-separated. If omitted, fetches logs for all project functions",
)
.option(
"--since <datetime>",
"Show logs from this time (ISO format)",
normalizeDatetime,
)
.option(
"--until <datetime>",
"Show logs until this time (ISO format)",
normalizeDatetime,
)
.option(
"-n, --limit <n>",
"Results per page (1-1000, default: 50)",
(v) => {
const n = Number.parseInt(v, 10);
if (Number.isNaN(n) || n < 1 || n > 1000) {
throw new InvalidInputError(
`Invalid limit: "${v}". Must be a number between 1 and 1000.`,
);
}
return v;
},
)
.addOption(
new Option("--order <order>", "Sort order").choices(["asc", "desc"]),
)
.option("--json", "Output raw JSON")
.action(async (options: LogsOptions) => {
await runCommand(
() => logsAction(options),
{ requireAuth: true },
context,
);
});
}
4 changes: 4 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getFunctionsDeployCommand } from "@/cli/commands/functions/deploy.js";
import { getCreateCommand } from "@/cli/commands/project/create.js";
import { getDeployCommand } from "@/cli/commands/project/deploy.js";
import { getLinkCommand } from "@/cli/commands/project/link.js";
import { getLogsCommand } from "@/cli/commands/project/logs.js";
import { getSiteCommand } from "@/cli/commands/site/index.js";
import { getTypesCommand } from "@/cli/commands/types/index.js";
import packageJson from "../../package.json";
Expand Down Expand Up @@ -64,5 +65,8 @@ export function createProgram(context: CLIContext): Command {
// Register development commands
program.addCommand(getDevCommand(context), { hidden: true });

// Register logs command
program.addCommand(getLogsCommand(context));

return program;
}
16 changes: 11 additions & 5 deletions src/cli/utils/runCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ interface RunCommandOptions {

export interface RunCommandResult {
outroMessage?: string;
/**
* Raw text to write to stdout after the command UI (intro/outro) finishes.
* Useful for commands that produce machine-readable or pipeable output.
*/
stdout?: string;
}

/**
Expand Down Expand Up @@ -65,15 +70,12 @@ export async function runCommand(
options: RunCommandOptions | undefined,
context: CLIContext,
): Promise<void> {
console.log();

if (options?.fullBanner) {
await printBanner(context.isNonInteractive);
intro("");
} else {
intro(theme.colors.base44OrangeBackground(" Base 44 "));
}

await printUpgradeNotificationIfAvailable();

try {
Expand Down Expand Up @@ -102,8 +104,12 @@ export async function runCommand(
context.errorReporter.setContext({ appId: appConfig.id });
}

const { outroMessage } = await commandFn();
outro(outroMessage || "");
const result = await commandFn();
outro(result.outroMessage || "");

if (result.stdout) {
process.stdout.write(result.stdout);
}
} catch (error) {
// Display error message
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down
27 changes: 25 additions & 2 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,11 @@ export class ApiError extends SystemError {
* Creates an ApiError from a caught error (typically HTTPError from ky).
* Extracts status code, request info, and response body for error reporting.
*
* Normalizes backend KeyError responses (Python dict lookup failures) to
* 404 status, since they represent "resource not found" conditions.
*
* @param error - The caught error (HTTPError, Error, or unknown)
* @param context - Description of what operation failed (e.g., "syncing agents")
* @returns ApiError with formatted message, status code, and request/response data
*
* @example
* try {
Expand Down Expand Up @@ -334,12 +336,16 @@ export class ApiError extends SystemError {
message = error.message;
}

const statusCode = ApiError.normalizeStatusCode(
error.response.status,
responseBody,
);
const requestBody = error.options.context?.__requestBody;

return new ApiError(
`Error ${context}: ${message}`,
{
statusCode: error.response.status,
statusCode,
requestUrl: error.request.url,
requestMethod: error.request.method,
requestBody,
Expand Down Expand Up @@ -414,6 +420,23 @@ export class ApiError extends SystemError {

return REASON_HINTS[reason];
}

/**
* Backend KeyError responses (Python dict lookup failures) are semantically
* "not found" — normalize them to 404.
*/
private static normalizeStatusCode(
statusCode: number,
responseBody: unknown,
): number {
if (
(responseBody as Record<string, unknown> | null)?.error_type ===
"KeyError"
) {
return 404;
}
return statusCode;
}
}

/**
Expand Down
Loading
Loading