From c83fae1f10860c971389adec2eb032f0db059815 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Wed, 25 Feb 2026 16:54:18 +0200 Subject: [PATCH 1/2] introduce global --json flag --- bun.lock | 1 + docs/commands.md | 58 +++++++++++- src/cli/commands/project/create.ts | 7 +- src/cli/commands/project/eject.ts | 2 +- src/cli/commands/project/link.ts | 2 +- src/cli/commands/project/logs.ts | 11 ++- src/cli/commands/site/deploy.ts | 2 +- src/cli/index.ts | 6 +- src/cli/program.ts | 11 +++ src/cli/types.ts | 1 + src/cli/utils/runCommand.ts | 137 +++++++++++++++++++---------- tests/cli/logs.spec.ts | 67 +++++++++++++- tests/cli/testkit/CLITestkit.ts | 2 + 13 files changed, 249 insertions(+), 58 deletions(-) diff --git a/bun.lock b/bun.lock index 69879b14..eba1bd39 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "base44", diff --git a/docs/commands.md b/docs/commands.md index 8086d3c1..17559d1b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,6 @@ # Adding & Modifying CLI Commands -**Keywords:** command, factory pattern, CLIContext, isNonInteractive, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro +**Keywords:** command, factory pattern, CLIContext, isNonInteractive, isJsonMode, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro, json, --json, data, piping Commands live in `src/cli/commands//`. They use a **factory pattern** with dependency injection via `CLIContext`. @@ -72,6 +72,7 @@ await runCommand(myAction, { fullBanner: true, requireAuth: true }, context); - `fullBanner` - Show ASCII art banner instead of simple tag (for special commands like `create`) - `requireAuth` - Check authentication before running, auto-triggers login if needed - `requireAppConfig` - Load `.app.jsonc` and cache for sync access (default: `true`) +- `interactive` - Mark command as requiring interactive prompts; throws if `--json` is used ## CLIContext (Dependency Injection) @@ -79,11 +80,13 @@ await runCommand(myAction, { fullBanner: true, requireAuth: true }, context); export interface CLIContext { errorReporter: ErrorReporter; isNonInteractive: boolean; + isJsonMode: boolean; } ``` - Created once in `runCLI()` at startup - `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Use it to skip interactive prompts, browser opens, and animations. +- `isJsonMode` is set by the global `--json` flag via a `preAction` hook. Commands don't need to check it directly -- `runCommand` handles all mode-switching. - Passed to `createProgram(context)`, which passes it to each command factory - Commands pass it to `runCommand()` for error reporting integration @@ -195,6 +198,59 @@ export function getMyCommand(context: CLIContext): Command { Access `command.args` for positional arguments and `command.opts()` for options inside the hook. See `secrets/set.ts` and `project/create.ts` for real examples. +## JSON Mode (`--json`) + +The CLI supports a global `--json` flag that outputs structured JSON instead of human-readable text. This enables piping output to tools like `jq`: + +```bash +base44 logs --function my-fn --json | jq '.logs[] | .message' +``` + +### How it works + +When `--json` is set, `runCommand` mutes `process.stdout.write` before calling `commandFn()`. This silences all clack output (intro, outro, `log.*`, spinners) automatically. Only the serialized `result.data` is written to stdout. Errors are written to stderr as JSON. + +### Adding JSON support to a command + +Return a `data` field from your action. Always use a top-level object (wrap arrays): + +```typescript +async function listAction(): Promise { + const items = await fetchItems(); + + return { + outroMessage: `Found ${items.length} items`, + stdout: formatItems(items), // human mode + data: { items }, // json mode: { "items": [...] } + }; +} +``` + +- `data` is `Record` -- always an object, never a raw array +- If `data` is not set, `runCommand` falls back to `{ "message": outroMessage }` +- Commands need zero changes to be silenced -- the stdout muting handles `log.*` and spinners + +### Error format + +On error in JSON mode, a JSON object is written to stderr: + +```json +{ + "error": true, + "code": "API_ERROR", + "message": "Request failed with status 500", + "hints": [{ "message": "Check your network connection" }] +} +``` + +### Interactive commands + +Commands that use interactive prompts (`select`, `confirm`, `text`) must set `interactive: true` in their `runCommand` options. This causes an immediate error if `--json` is used: + +```typescript +await runCommand(myAction, { requireAuth: true, interactive: true }, context); +``` + ## Rules (Command-Specific) - **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index 20c3a4e5..182162f3 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -304,7 +304,12 @@ export function getCreateCommand(context: CLIContext): Command { } else { await runCommand( () => createInteractive({ name, ...options }), - { fullBanner: true, requireAuth: true, requireAppConfig: false }, + { + fullBanner: true, + requireAuth: true, + requireAppConfig: false, + interactive: true, + }, context, ); } diff --git a/src/cli/commands/project/eject.ts b/src/cli/commands/project/eject.ts index 63f88d9b..a6f961ff 100644 --- a/src/cli/commands/project/eject.ts +++ b/src/cli/commands/project/eject.ts @@ -181,7 +181,7 @@ export function getEjectCommand(context: CLIContext): Command { .action(async (options: EjectOptions) => { await runCommand( () => eject({ ...options, isNonInteractive: context.isNonInteractive }), - { requireAuth: true, requireAppConfig: false }, + { requireAuth: true, requireAppConfig: false, interactive: true }, context, ); }); diff --git a/src/cli/commands/project/link.ts b/src/cli/commands/project/link.ts index 67b8d72c..75829a53 100644 --- a/src/cli/commands/project/link.ts +++ b/src/cli/commands/project/link.ts @@ -264,7 +264,7 @@ export function getLinkCommand(context: CLIContext): Command { .action(async (options: LinkOptions) => { await runCommand( () => link(options), - { requireAuth: true, requireAppConfig: false }, + { requireAuth: true, requireAppConfig: false, interactive: true }, context, ); }); diff --git a/src/cli/commands/project/logs.ts b/src/cli/commands/project/logs.ts index e7e468a5..009ec88f 100644 --- a/src/cli/commands/project/logs.ts +++ b/src/cli/commands/project/logs.ts @@ -21,7 +21,6 @@ interface LogsOptions { level?: string; limit?: string; order?: string; - json?: boolean; } /** @@ -187,11 +186,11 @@ async function logsAction(options: LogsOptions): Promise { entries = entries.slice(0, limit); } - const logsOutput = options.json - ? `${JSON.stringify(entries, null, 2)}\n` - : formatLogs(entries); - - return { outroMessage: "Fetched logs", stdout: logsOutput }; + return { + outroMessage: "Fetched logs", + stdout: formatLogs(entries), + data: { logs: entries }, + }; } export function getLogsCommand(context: CLIContext): Command { diff --git a/src/cli/commands/site/deploy.ts b/src/cli/commands/site/deploy.ts index 4d717986..51095c0c 100644 --- a/src/cli/commands/site/deploy.ts +++ b/src/cli/commands/site/deploy.ts @@ -64,7 +64,7 @@ export function getSiteDeployCommand(context: CLIContext): Command { ...options, isNonInteractive: context.isNonInteractive, }), - { requireAuth: true }, + { requireAuth: true, interactive: !options.yes }, context, ); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 262f71b1..96ab5399 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,7 +14,11 @@ async function runCLI(): Promise { // Create context for dependency injection const isNonInteractive = !process.stdin.isTTY || !process.stdout.isTTY; - const context: CLIContext = { errorReporter, isNonInteractive }; + const context: CLIContext = { + errorReporter, + isNonInteractive, + isJsonMode: false, + }; // Create program with injected context const program = createProgram(context); diff --git a/src/cli/program.ts b/src/cli/program.ts index e24279b0..265f7168 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -29,6 +29,17 @@ export function createProgram(context: CLIContext): Command { ) .version(packageJson.version); + program.option( + "--json", + "Output results as JSON instead of human-readable text", + ); + + program.hook("preAction", (thisCommand) => { + if (thisCommand.opts().json) { + context.isJsonMode = true; + } + }); + program.configureHelp({ sortSubcommands: true, }); diff --git a/src/cli/types.ts b/src/cli/types.ts index 124ac9de..78c708a9 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -3,4 +3,5 @@ import type { ErrorReporter } from "./telemetry/error-reporter.js"; export interface CLIContext { errorReporter: ErrorReporter; isNonInteractive: boolean; + isJsonMode: boolean; } diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index b8cdb6cd..8a32a1a2 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -5,7 +5,11 @@ import { printBanner } from "@/cli/utils/banner.js"; import { theme } from "@/cli/utils/theme.js"; import { printUpgradeNotificationIfAvailable } from "@/cli/utils/upgradeNotification.js"; import { isLoggedIn, readAuth } from "@/core/auth/index.js"; -import { isCLIError } from "@/core/errors.js"; +import { + AuthRequiredError, + InvalidInputError, + isCLIError, +} from "@/core/errors.js"; import { initAppConfig } from "@/core/project/index.js"; interface RunCommandOptions { @@ -27,6 +31,12 @@ interface RunCommandOptions { * @default true */ requireAppConfig?: boolean; + /** + * Mark this command as requiring interactive prompts (select, confirm, text). + * When set, the command will throw if --json is used. + * @default false + */ + interactive?: boolean; } export interface RunCommandResult { @@ -36,12 +46,25 @@ export interface RunCommandResult { * Useful for commands that produce machine-readable or pipeable output. */ stdout?: string; + /** + * Structured data for --json output. Serialized by runCommand when + * isJsonMode is true. Always a top-level object (wrap arrays: + * `{ logs: [...] }` not `[...]`). + * + * When --json is set but data is not provided, runCommand falls back to + * `{ "message": outroMessage }`. + */ + data?: Record; } /** * Wraps a command function with the Base44 intro/outro and error handling. * All CLI commands should use this utility to ensure consistent branding. * + * In JSON mode (--json flag), stdout is muted so all clack output (intro, + * outro, log.*, spinners) is silenced automatically. Only the serialized + * `result.data` is written to stdout. Errors are written to stderr as JSON. + * * **Important**: Commands should NOT call `intro()` or `outro()` directly. * This function handles both. Commands can return an optional `outroMessage` * which will be displayed at the end. @@ -49,41 +72,43 @@ export interface RunCommandResult { * @param commandFn - The async function to execute. Returns `RunCommandResult` with optional `outroMessage`. * @param options - Optional configuration for the command wrapper * @param context - CLI context with dependencies (errorReporter, etc.) - * - * @example - * export function getMyCommand(context: CLIContext): Command { - * return new Command("my-command") - * .action(async () => { - * await runCommand( - * async () => { - * // ... do work ... - * return { outroMessage: "Done!" }; - * }, - * { requireAuth: true }, - * context - * ); - * }); - * } */ export async function runCommand( commandFn: () => Promise, options: RunCommandOptions | undefined, context: CLIContext, ): Promise { - if (options?.fullBanner) { - await printBanner(context.isNonInteractive); - intro(""); - } else { - intro(theme.colors.base44OrangeBackground(" Base 44 ")); - } - await printUpgradeNotificationIfAvailable(); + const json = context.isJsonMode; + let savedStdoutWrite: typeof process.stdout.write | undefined; try { - // Check authentication if required + if (json) { + if (options?.interactive) { + throw new InvalidInputError( + "This command requires interactive input and cannot be used with --json", + ); + } + savedStdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (() => true) as typeof process.stdout.write; + } + + if (options?.fullBanner) { + await printBanner(context.isNonInteractive); + intro(""); + } else { + intro(theme.colors.base44OrangeBackground(" Base 44 ")); + } + await printUpgradeNotificationIfAvailable(); + if (options?.requireAuth) { const loggedIn = await isLoggedIn(); if (!loggedIn) { + if (json) { + throw new AuthRequiredError( + "Authentication required. Run: base44 login", + ); + } log.info("You need to login first to continue."); await login(); } @@ -98,41 +123,63 @@ export async function runCommand( } } - // Initialize app config unless explicitly disabled if (options?.requireAppConfig !== false) { const appConfig = await initAppConfig(); context.errorReporter.setContext({ appId: appConfig.id }); } const result = await commandFn(); - outro(result.outroMessage || ""); - if (result.stdout) { - process.stdout.write(result.stdout); + if (json) { + const output = result.data ?? { + message: result.outroMessage ?? "Done", + }; + savedStdoutWrite!(`${JSON.stringify(output, null, 2)}\n`); + } else { + 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); - log.error(errorMessage); + if (json) { + const errorContext = context.errorReporter.getErrorContext(); + const jsonError: Record = { + error: true, + message: error instanceof Error ? error.message : String(error), + context: errorContext, + }; + if (isCLIError(error)) { + jsonError.code = error.code; + if (error.hints.length > 0) { + jsonError.hints = error.hints; + } + } + process.stderr.write(`${JSON.stringify(jsonError, null, 2)}\n`); + } else { + const errorMessage = + error instanceof Error ? error.message : String(error); + log.error(errorMessage); - // Show stack trace if DEBUG mode - if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { - log.error(theme.styles.dim(error.stack)); - } + if (process.env.DEBUG === "1" && error instanceof Error && error.stack) { + log.error(theme.styles.dim(error.stack)); + } - // Display hints if this is a CLIError with hints - if (isCLIError(error)) { - const hints = theme.format.agentHints(error.hints); - if (hints) { - log.error(hints); + if (isCLIError(error)) { + const hints = theme.format.agentHints(error.hints); + if (hints) { + log.error(hints); + } } - } - // Get error context and display in outro - const errorContext = context.errorReporter.getErrorContext(); - outro(theme.format.errorContext(errorContext)); + const errorContext = context.errorReporter.getErrorContext(); + outro(theme.format.errorContext(errorContext)); + } - // Re-throw for runCLI to handle (error reporting, exit code) throw error; + } finally { + if (savedStdoutWrite) { + process.stdout.write = savedStdoutWrite; + } } } diff --git a/tests/cli/logs.spec.ts b/tests/cli/logs.spec.ts index 9ccf9632..2ca4ce86 100644 --- a/tests/cli/logs.spec.ts +++ b/tests/cli/logs.spec.ts @@ -1,4 +1,4 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { fixture, setupCLITests } from "./testkit/index.js"; describe("logs command", () => { @@ -183,4 +183,69 @@ describe("logs command", () => { t.expectResult(result).toSucceed(); }); + + describe("--json mode", () => { + it("outputs structured JSON with logs wrapped in an object", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Test log", + }, + ]); + + const result = await t.run("logs", "--function", "my-function", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty("logs"); + expect(parsed.logs).toHaveLength(1); + expect(parsed.logs[0]).toMatchObject({ + time: "2024-01-15T10:30:00.000Z", + level: "info", + source: "my-function", + }); + }); + + it("outputs empty logs array when no logs found", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", []); + + const result = await t.run("logs", "--function", "my-function", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.logs).toEqual([]); + }); + + it("suppresses clack UI output in JSON mode", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockFunctionLogs("my-function", [ + { + time: "2024-01-15T10:30:00.000Z", + level: "info", + message: "Test", + }, + ]); + + const result = await t.run("logs", "--function", "my-function", "--json"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).not.toContain("Base 44"); + expect(result.stdout).not.toContain("Fetched logs"); + }); + + it("falls back to message object when command has no data", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const result = await t.run("logs", "--json"); + + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ + message: "No functions found in this project.", + }); + }); + }); }); diff --git a/tests/cli/testkit/CLITestkit.ts b/tests/cli/testkit/CLITestkit.ts index e81ad96a..1f68b66b 100644 --- a/tests/cli/testkit/CLITestkit.ts +++ b/tests/cli/testkit/CLITestkit.ts @@ -18,6 +18,7 @@ interface CLIContext { getErrorContext: () => { sessionId?: string; appId?: string }; }; isNonInteractive: boolean; + isJsonMode: boolean; } /** Type for the bundled program module */ @@ -140,6 +141,7 @@ export class CLITestkit { getErrorContext: () => ({ sessionId: "test-session" }), }, isNonInteractive: true, + isJsonMode: false, }; const program = createProgram(mockContext); From 70764b094d721098a19245b60ef8238722812afa Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Wed, 25 Feb 2026 17:29:22 +0200 Subject: [PATCH 2/2] update verification --- docs/commands.md | 19 ++++++++++++++++--- src/cli/commands/project/create.ts | 24 +++++++++++++++++------- src/cli/commands/project/deploy.ts | 10 ++++++++++ src/cli/commands/project/eject.ts | 16 +++++++++++++++- src/cli/commands/project/link.ts | 30 ++++++++++++++++++++---------- src/cli/commands/site/deploy.ts | 12 +++++++++++- src/cli/utils/runCommand.ts | 17 +---------------- 7 files changed, 90 insertions(+), 38 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 17559d1b..23db0edf 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -72,7 +72,6 @@ await runCommand(myAction, { fullBanner: true, requireAuth: true }, context); - `fullBanner` - Show ASCII art banner instead of simple tag (for special commands like `create`) - `requireAuth` - Check authentication before running, auto-triggers login if needed - `requireAppConfig` - Load `.app.jsonc` and cache for sync access (default: `true`) -- `interactive` - Mark command as requiring interactive prompts; throws if `--json` is used ## CLIContext (Dependency Injection) @@ -245,12 +244,26 @@ On error in JSON mode, a JSON object is written to stderr: ### Interactive commands -Commands that use interactive prompts (`select`, `confirm`, `text`) must set `interactive: true` in their `runCommand` options. This causes an immediate error if `--json` is used: +Commands with interactive prompts (`select`, `confirm`, `text`) should validate required flags in a `preAction` hook. This fires before `runCommand` (no wasted auth/API calls) and works for both `--json` and piped/CI sessions: ```typescript -await runCommand(myAction, { requireAuth: true, interactive: true }, context); +export function getMyCommand(context: CLIContext): Command { + return new Command("my-cmd") + .option("-y, --yes", "Skip confirmation prompt") + .hook("preAction", (command) => { + if (!context.isJsonMode && !context.isNonInteractive) return; + if (!command.opts().yes) { + command.error("Non-interactive mode requires: --yes"); + } + }) + .action(async (options) => { + await runCommand(() => myAction(options), { requireAuth: true }, context); + }); +} ``` +List specific missing flags so users know exactly what to add (e.g., `Missing: --project-id , --path `). + ## Rules (Command-Specific) - **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index 182162f3..1718935f 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -45,12 +45,23 @@ async function getTemplateById(templateId: string): Promise