Skip to content
Draft
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 bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 70 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
@@ -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/<domain>/`. They use a **factory pattern** with dependency injection via `CLIContext`.

Expand Down Expand Up @@ -79,11 +79,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

Expand Down Expand Up @@ -195,6 +197,73 @@ 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<RunCommandResult> {
const items = await fetchItems();

return {
outroMessage: `Found ${items.length} items`,
stdout: formatItems(items), // human mode
data: { items }, // json mode: { "items": [...] }
};
}
```

- `data` is `Record<string, unknown>` -- 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 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
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);
});
}
```

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this is too mcuh

List specific missing flags so users know exactly what to add (e.g., `Missing: --project-id <id>, --path <path>`).

## Rules (Command-Specific)

- **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances
Expand Down
29 changes: 22 additions & 7 deletions src/cli/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,23 @@ async function getTemplateById(templateId: string): Promise<Template> {
return template;
}

function validateNonInteractiveFlags(command: Command): void {
const { path } = command.opts<CreateOptions>();
function validateFlags(context: CLIContext) {
return (command: Command): void => {
const opts = command.opts<CreateOptions>();
const name = command.args[0];

if (path && !command.args.length) {
command.error("Non-interactive mode requires all flags: --name, --path");
}
if (opts.path && !(opts.name ?? name)) {
command.error("Non-interactive mode requires all flags: --name, --path");
}

if (context.isJsonMode || context.isNonInteractive) {
if (!(opts.name ?? name) || !opts.path) {
command.error(
"Non-interactive mode requires: <name> and --path <path>",
);
}
}
};
}

async function createInteractive(
Expand Down Expand Up @@ -290,7 +301,7 @@ export function getCreateCommand(context: CLIContext): Command {
)
.option("--deploy", "Build and deploy the site")
.option("--no-skills", "Skip AI agent skills installation")
.hook("preAction", validateNonInteractiveFlags)
.hook("preAction", validateFlags(context))
.action(async (name: string | undefined, options: CreateOptions) => {
const isNonInteractive = !!(options.name ?? name) && !!options.path;

Expand All @@ -304,7 +315,11 @@ export function getCreateCommand(context: CLIContext): Command {
} else {
await runCommand(
() => createInteractive({ name, ...options }),
{ fullBanner: true, requireAuth: true, requireAppConfig: false },
{
fullBanner: true,
requireAuth: true,
requireAppConfig: false,
},
context,
);
}
Expand Down
10 changes: 10 additions & 0 deletions src/cli/commands/project/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,22 @@ export async function deployAction(
return { outroMessage: "App deployed successfully" };
}

function validateNonInteractiveMode(context: CLIContext) {
return (command: Command): void => {
if (!context.isJsonMode && !context.isNonInteractive) return;
if (!command.opts<DeployOptions>().yes) {
command.error("Non-interactive mode requires: --yes");
}
};
}

export function getDeployCommand(context: CLIContext): Command {
return new Command("deploy")
.description(
"Deploy all project resources (entities, functions, agents, connectors, and site)",
)
.option("-y, --yes", "Skip confirmation prompt")
.hook("preAction", validateNonInteractiveMode(context))
.action(async (options: DeployOptions) => {
await runCommand(
() =>
Expand Down
14 changes: 14 additions & 0 deletions src/cli/commands/project/eject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,19 @@ async function eject(options: EjectOptions): Promise<RunCommandResult> {
return { outroMessage: "Your new project is set and ready to use" };
}

function validateNonInteractiveMode(context: CLIContext) {
return (command: Command): void => {
if (!context.isJsonMode && !context.isNonInteractive) return;
const opts = command.opts<EjectOptions>();
const missing: string[] = [];
if (!opts.projectId) missing.push("--project-id <id>");
if (!opts.path) missing.push("--path <path>");
if (missing.length > 0) {
command.error(`Non-interactive mode requires: ${missing.join(", ")}`);
}
};
}

export function getEjectCommand(context: CLIContext): Command {
return new Command("eject")
.description("Download the code for an existing Base44 project")
Expand All @@ -178,6 +191,7 @@ export function getEjectCommand(context: CLIContext): Command {
"Project ID to eject (skips interactive selection)",
)
.option("-y, --yes", "Skip confirmation prompts")
.hook("preAction", validateNonInteractiveMode(context))
.action(async (options: EjectOptions) => {
await runCommand(
() => eject({ ...options, isNonInteractive: context.isNonInteractive }),
Expand Down
28 changes: 19 additions & 9 deletions src/cli/commands/project/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,26 @@ interface LinkOptions {

type LinkAction = "create" | "choose";

function validateNonInteractiveFlags(command: Command): void {
const { create, name, projectId } = command.opts<LinkOptions>();
function validateFlags(context: CLIContext) {
return (command: Command): void => {
const { create, name, projectId } = command.opts<LinkOptions>();

if (create && projectId) {
command.error("--create and --projectId cannot be used together");
}
if (create && projectId) {
command.error("--create and --projectId cannot be used together");
}

if (create && !name) {
command.error("--name is required when using --create");
}
if (create && !name) {
command.error("--name is required when using --create");
}

if (context.isJsonMode || context.isNonInteractive) {
if (!projectId && !create) {
command.error(
"Non-interactive mode requires --projectId <id> or --create --name <name>",
);
}
}
};
}

async function promptForLinkAction(): Promise<LinkAction> {
Expand Down Expand Up @@ -260,7 +270,7 @@ export function getLinkCommand(context: CLIContext): Command {
"-p, --projectId <id>",
"Project ID to link to an existing project (skips selection prompt)",
)
.hook("preAction", validateNonInteractiveFlags)
.hook("preAction", validateFlags(context))
.action(async (options: LinkOptions) => {
await runCommand(
() => link(options),
Expand Down
11 changes: 5 additions & 6 deletions src/cli/commands/project/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ interface LogsOptions {
level?: string;
limit?: string;
order?: string;
json?: boolean;
}

/**
Expand Down Expand Up @@ -187,11 +186,11 @@ async function logsAction(options: LogsOptions): Promise<RunCommandResult> {
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 {
Expand Down
10 changes: 10 additions & 0 deletions src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,20 @@ async function deployAction(options: DeployOptions): Promise<RunCommandResult> {
return { outroMessage: `Visit your site at: ${result.appUrl}` };
}

function validateNonInteractiveMode(context: CLIContext) {
return (command: Command): void => {
if (!context.isJsonMode && !context.isNonInteractive) return;
if (!command.opts<DeployOptions>().yes) {
command.error("Non-interactive mode requires: --yes");
}
};
}

export function getSiteDeployCommand(context: CLIContext): Command {
return new Command("deploy")
.description("Deploy built site files to Base44 hosting")
.option("-y, --yes", "Skip confirmation prompt")
.hook("preAction", validateNonInteractiveMode(context))
.action(async (options: DeployOptions) => {
await runCommand(
() =>
Expand Down
6 changes: 5 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ async function runCLI(): Promise<void> {

// 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);
Expand Down
11 changes: 11 additions & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import type { ErrorReporter } from "./telemetry/error-reporter.js";
export interface CLIContext {
errorReporter: ErrorReporter;
isNonInteractive: boolean;
isJsonMode: boolean;
}
Loading