diff --git a/AGENTS.md b/AGENTS.md index 3c889631..f5fa8f56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,6 +108,8 @@ cli/ │ │ ├── prompts.ts # Prompt utilities │ │ ├── theme.ts # Centralized theme configuration (colors, styles) │ │ ├── urls.ts # URL utilities (getDashboardUrl) +│ │ ├── json.ts # JSON output utilities for --json flag +│ │ ├── log.ts # Wrapped @clack/prompts log (auto-suppresses in JSON mode) │ │ └── index.ts │ ├── errors.ts # CLI-specific errors (CLIExitError) │ ├── program.ts # Commander program definition @@ -128,11 +130,20 @@ Commands live in `src/cli/commands/`. Follow these steps: ```typescript // src/cli/commands//.ts import { Command } from "commander"; -import { log } from "@clack/prompts"; -import { runCommand, runTask, theme } from "../../utils/index.js"; +import { runCommand, runTask, theme, log } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/index.js"; -async function myAction(): Promise { +/** + * JSON output result for the my-action command. + */ +interface MyActionResult { + /** The display name of the created resource. */ + name: string; + /** The unique identifier of the created resource. */ + id: string; +} + +async function myAction(): Promise> { // Use runTask for async operations with spinners const result = await runTask( "Doing something...", @@ -147,10 +158,17 @@ async function myAction(): Promise { } ); + // log.* calls are automatically suppressed in JSON mode log.success("Operation completed!"); - // Return an optional outro message (displayed at the end) - return { outroMessage: `Created ${theme.styles.bold(result.name)}` }; + // Return both human-friendly message and structured data for JSON output + return { + outroMessage: `Created ${theme.styles.bold(result.name)}`, + data: { + name: result.name, + id: result.id, + }, + }; } export const myCommand = new Command("") @@ -367,6 +385,113 @@ base44 deploy -y # Skip confirmation base44 deploy --yes # Skip confirmation ``` +## JSON Output Mode + +All commands support the `--json` flag for machine-readable output, except `login` and `logout` (user-facing auth commands, rarely scripted). + +### Behavior + +When `--json` is passed: +- Suppresses all human-friendly output (banners, spinners, colored text, progress messages) +- Outputs a single JSON object to stdout +- Errors output JSON to stderr with exit code 1 +- Interactive prompts are disabled; required flags must be provided +- Commands that open browsers skip that action (see `dashboard --no-open`) + +### JSON Output Structure + +```typescript +// Success response (stdout) +{ + "success": true, + "data": { /* command-specific result */ } +} + +// Error response (stderr) +{ + "success": false, + "error": { + "message": "Error description", + "code": "ERROR_CODE" // optional + } +} +``` + +### Implementation Requirements + +When creating new commands: + +1. **Define a typed result interface** after the options interface: + +```typescript +interface MyCommandOptions { + flag?: boolean; +} + +/** + * JSON output result for the my-command command. + */ +interface MyCommandResult { + /** The unique identifier of the created resource. */ + resourceId: string; + /** The URL to access the resource. */ + resourceUrl: string; +} +``` + +2. **Return structured `data`** with the typed `RunCommandResult`: + +```typescript +async function myAction(): Promise> { + // ... do work ... + return { + outroMessage: "Human-friendly message", + data: { + resourceId: "abc123", + resourceUrl: "https://...", + }, + }; +} +``` + +For commands that don't support `--json` (like `login`, `logout`), use `never`: + +```typescript +async function login(): Promise> { + // ... interactive auth flow ... + return { outroMessage: "Logged in successfully" }; +} +``` + +3. **Use wrapped `log` from utils** - The wrapped `log` automatically suppresses output in JSON mode: + +```typescript +import { log } from "../../utils/index.js"; + +// No need to check isJsonMode() - log is automatically suppressed +log.info("Found 5 entities to push"); +``` + +4. **Handle interactive prompts** - In JSON mode, either: + - Require flags that provide the same information (fail early with clear error) + - Auto-confirm prompts (for non-destructive actions) + +5. **Validate required flags** in `preAction` hook (throw errors, never use `command.error()`): + +```typescript +function validateFlags(command: Command): void { + const opts = command.optsWithGlobals(); + if (opts.json && !opts.requiredFlag) { + throw new Error("JSON mode requires: --required-flag"); + } +} +``` + +### Exceptions + +- `login`: Does not support `--json` (interactive browser authentication) +- `logout`: Does not support `--json` (user-facing command, rarely scripted) + ## Path Aliases Single alias defined in `tsconfig.json`: @@ -392,7 +517,10 @@ import { base44Client } from "@core/api/index.js"; 10. **Zero-dependency distribution** - All packages go in `devDependencies`; they get bundled at build time 11. **Use theme for styling** - Never use `chalk` directly in commands; import `theme` from utils and use semantic color/style names 12. **Use fs.ts utilities** - Always use `@core/utils/fs.js` for file operations -13. **No direct process.exit()** - Throw `CLIExitError` instead; entry points handle the actual exit +13. **No direct process.exit()** - Throw `CLIExitError` instead; entry points handle the actual exit +14. **JSON output support** - Commands must return `data` in `RunCommandResult` +15. **Never use `command.error()`** - Always use `throw new Error()` instead; errors are caught globally and output as JSON when `--json` is used +16. **Use wrapped log for output** - Import `log` from `../../utils/index.js`, not from `@clack/prompts`. The wrapped `log` automatically suppresses output in JSON mode. Only use `isJsonMode()` directly for non-logging decisions (prompts, browser opens) ## Development diff --git a/bin/dev.js b/bin/dev.js index 76f2de09..448f155a 100755 --- a/bin/dev.js +++ b/bin/dev.js @@ -1,5 +1,6 @@ #!/usr/bin/env tsx -import { program, CLIExitError } from "../src/cli/index.ts"; +import { CommanderError } from "commander"; +import { program, CLIExitError, isJsonMode, outputJsonError } from "../src/cli/index.ts"; try { await program.parseAsync(); @@ -7,6 +8,23 @@ try { if (error instanceof CLIExitError) { process.exit(error.code); } - console.error(error); + + if (error instanceof CommanderError) { + if (isJsonMode()) { + outputJsonError(error.message, error.code); + } + // Non-JSON mode: Commander already output the error via configureOutput + process.exit(error.exitCode); + } + + // Handle all other errors + if (isJsonMode()) { + outputJsonError(error instanceof Error ? error : String(error)); + process.exit(1); + } + + // Non-JSON mode: show clean error message + const message = error instanceof Error ? error.message : String(error); + console.error(`error: ${message}`); process.exit(1); } diff --git a/bin/run.js b/bin/run.js index 33a48126..dffa7db4 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { program, CLIExitError } from "../dist/index.js"; +import { CommanderError } from "commander"; +import { program, CLIExitError, isJsonMode, outputJsonError } from "../dist/index.js"; try { await program.parseAsync(); @@ -7,6 +8,23 @@ try { if (error instanceof CLIExitError) { process.exit(error.code); } - console.error(error); + + if (error instanceof CommanderError) { + if (isJsonMode()) { + outputJsonError(error.message, error.code); + } + // Non-JSON mode: Commander already output the error via configureOutput + process.exit(error.exitCode); + } + + // Handle all other errors + if (isJsonMode()) { + outputJsonError(error instanceof Error ? error : String(error)); + process.exit(1); + } + + // Non-JSON mode: show clean error message + const message = error instanceof Error ? error.message : String(error); + console.error(`error: ${message}`); process.exit(1); } diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index cae9edaa..ff55ddb5 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -1,5 +1,4 @@ import { Command } from "commander"; -import { log } from "@clack/prompts"; import pWaitFor from "p-wait-for"; import { writeAuth, @@ -12,10 +11,15 @@ import type { TokenResponse, UserInfoResponse, } from "@core/auth/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; +import { runCommand, runTask, log } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; import { theme } from "../../utils/theme.js"; +/** + * Login command does not support --json output. + * It requires interactive browser authentication via device code flow. + */ + async function generateAndDisplayDeviceCode(): Promise { const deviceCodeResponse = await runTask( "Generating device code...", @@ -96,7 +100,7 @@ async function saveAuthData( }); } -export async function login(): Promise { +export async function login(): Promise> { const deviceCodeResponse = await generateAndDisplayDeviceCode(); const token = await waitForAuthentication( diff --git a/src/cli/commands/auth/logout.ts b/src/cli/commands/auth/logout.ts index 6781ab38..df67c373 100644 --- a/src/cli/commands/auth/logout.ts +++ b/src/cli/commands/auth/logout.ts @@ -3,7 +3,11 @@ import { deleteAuth } from "@core/auth/index.js"; import { runCommand } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; -async function logout(): Promise { +/** + * Logout command does not support --json output. + * It is a user-facing auth command that is rarely scripted. + */ +async function logout(): Promise> { await deleteAuth(); return { outroMessage: "Logged out successfully" }; } diff --git a/src/cli/commands/auth/whoami.ts b/src/cli/commands/auth/whoami.ts index a3770009..9fe173ea 100644 --- a/src/cli/commands/auth/whoami.ts +++ b/src/cli/commands/auth/whoami.ts @@ -3,9 +3,25 @@ import { readAuth } from "@core/auth/index.js"; import { runCommand, theme } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; -async function whoami(): Promise { +/** + * JSON output result for the whoami command. + */ +interface WhoamiResult { + /** The email address of the authenticated user. */ + email: string; + /** The display name of the authenticated user. */ + name: string; +} + +async function whoami(): Promise> { const auth = await readAuth(); - return { outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}` }; + return { + outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}`, + data: { + email: auth.email, + name: auth.name, + }, + }; } export const whoamiCommand = new Command("whoami") diff --git a/src/cli/commands/entities/push.ts b/src/cli/commands/entities/push.ts index 65f70c65..e3d94ff4 100644 --- a/src/cli/commands/entities/push.ts +++ b/src/cli/commands/entities/push.ts @@ -1,15 +1,29 @@ import { Command } from "commander"; -import { log } from "@clack/prompts"; import { pushEntities } from "@core/resources/entity/index.js"; import { readProjectConfig } from "@core/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; +import { runCommand, runTask, log } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; -async function pushEntitiesAction(): Promise { +/** + * JSON output result for the entities push command. + */ +interface EntitiesPushResult { + /** Names of entities that were newly created. */ + created: string[]; + /** Names of entities that were updated. */ + updated: string[]; + /** Names of entities that were deleted. */ + deleted: string[]; +} + +async function pushEntitiesAction(): Promise> { const { entities } = await readProjectConfig(); if (entities.length === 0) { - return { outroMessage: "No entities found in project" }; + return { + outroMessage: "No entities found in project", + data: { created: [], updated: [], deleted: [] }, + }; } log.info(`Found ${entities.length} entities to push`); @@ -25,7 +39,6 @@ async function pushEntitiesAction(): Promise { } ); - // Print the results if (result.created.length > 0) { log.success(`Created: ${result.created.join(", ")}`); } @@ -36,7 +49,13 @@ async function pushEntitiesAction(): Promise { log.warn(`Deleted: ${result.deleted.join(", ")}`); } - return {}; + return { + data: { + created: result.created, + updated: result.updated, + deleted: result.deleted, + }, + }; } export const entitiesPushCommand = new Command("entities") diff --git a/src/cli/commands/functions/deploy.ts b/src/cli/commands/functions/deploy.ts index ea9818f4..eddc59ec 100644 --- a/src/cli/commands/functions/deploy.ts +++ b/src/cli/commands/functions/deploy.ts @@ -1,17 +1,27 @@ import { Command } from "commander"; -import { log } from "@clack/prompts"; import { pushFunctions } from "@core/resources/function/index.js"; import { readProjectConfig } from "@core/index.js"; -import { runCommand, runTask } from "../../utils/index.js"; +import { runCommand, runTask, log } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; -async function deployFunctionsAction(): Promise { +/** + * JSON output result for the functions deploy command. + */ +interface FunctionsDeployResult { + /** Names of functions that were successfully deployed. */ + deployed: string[]; + /** Names of functions that were deleted from the server. */ + deleted: string[]; +} + +async function deployFunctionsAction(): Promise> { const { functions } = await readProjectConfig(); if (functions.length === 0) { return { outroMessage: "No functions found. Create functions in the 'functions' directory.", + data: { deployed: [], deleted: [] }, }; } @@ -36,6 +46,7 @@ async function deployFunctionsAction(): Promise { if (result.deleted.length > 0) { log.warn(`Deleted: ${result.deleted.join(", ")}`); } + if (result.errors && result.errors.length > 0) { const errorMessages = result.errors .map((e) => `'${e.name}' function: ${e.message}`) @@ -43,7 +54,12 @@ async function deployFunctionsAction(): Promise { throw new Error(`Function deployment errors:\n${errorMessages}`); } - return {}; + return { + data: { + deployed: result.deployed, + deleted: result.deleted, + }, + }; } export const functionsDeployCommand = new Command("functions") diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index 235e3df0..afd383ce 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -1,7 +1,7 @@ import { resolve, join } from "node:path"; import { execa } from "execa"; import { Command } from "commander"; -import { log, group, text, select, confirm, isCancel } from "@clack/prompts"; +import { group, text, select, confirm, isCancel } from "@clack/prompts"; import type { Option } from "@clack/prompts"; import kebabCase from "lodash.kebabcase"; import { createProjectFiles, listTemplates, readProjectConfig, setAppConfig } from "@core/project/index.js"; @@ -13,6 +13,7 @@ import { onPromptCancel, theme, getDashboardUrl, + log, } from "../../utils/index.js"; import type { RunCommandResult } from "../../utils/runCommand.js"; @@ -27,6 +28,20 @@ interface CreateOptions { skills?: boolean; } +/** + * JSON output result for the create command. + */ +interface CreateResult { + /** The unique identifier of the created project. */ + projectId: string; + /** The absolute path where the project was created. */ + path: string; + /** The URL to the project dashboard. */ + dashboardUrl: string; + /** The public URL where the site is hosted (if deployed). */ + appUrl?: string; +} + async function getTemplateById(templateId: string): Promise