Skip to content
Open
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
13 changes: 12 additions & 1 deletion bun.lock

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

78 changes: 43 additions & 35 deletions docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
# Adding & Modifying CLI Commands

**Keywords:** command, factory pattern, Base44Command, isNonInteractive, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro
**Keywords:** command, factory pattern, Base44Command, CLIContext, Logger, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro

Commands live in `src/cli/commands/<domain>/`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`.

## Command File Template

```typescript
// src/cli/commands/<domain>/<action>.ts
import { log } from "@clack/prompts";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask, theme } from "@/cli/utils/index.js";

async function myAction(): Promise<RunCommandResult> {
async function myAction({ logger }: CLIContext): Promise<RunCommandResult> {
const result = await runTask(
"Doing something...",
async () => {
Expand All @@ -26,7 +25,7 @@ async function myAction(): Promise<RunCommandResult> {
}
);

log.success("Operation completed!");
logger.success("Operation completed!");

return { outroMessage: `Created ${theme.styles.bold(result.name)}` };
}
Expand All @@ -44,6 +43,9 @@ export function getMyCommand(): Command {
- Use `Base44Command` class
- Commands must NOT call `intro()` or `outro()` directly
- The action function must return `RunCommandResult` with an `outroMessage`
- Action functions receive `CLIContext` as their first argument (injected by `Base44Command`), followed by Commander's positional args and options
- Destructure what you need from `CLIContext`: `{ logger }`, `{ logger, isNonInteractive }`, or `_ctx` if nothing needed
- Use `.action(fn)` directly — no wrapper needed

## Base44Command Options

Expand Down Expand Up @@ -78,63 +80,70 @@ program.addCommand(getMyCommand());

## CLIContext (Automatic Injection)

`CLIContext` is automatically injected as the **first argument** to all action functions by `Base44Command`. Destructure what you need:

```typescript
export interface CLIContext {
errorReporter: ErrorReporter;
isNonInteractive: boolean;
distribution: Distribution;
logger: Logger;
}
```

- Created once in `runCLI()` at startup
- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Controls quiet mode — when true, all clack UI is suppressed.
- `logger` is a `Logger` instance — `ClackLogger` in interactive mode, `SimpleLogger` in non-interactive mode.

### Using `isNonInteractive`

When a command needs to check `isNonInteractive` (e.g., to skip browser opens or confirmation prompts), access it from the command instance. Commander passes the command as the last argument to action handlers:
Destructure `isNonInteractive` from the context first argument:

```typescript
export function getMyCommand(): Command {
return new Base44Command("open")
.description("Open something in browser")
.action(async (_options: unknown, command: Base44Command) => {
return await myAction(command.isNonInteractive);
});
}

async function myAction(isNonInteractive: boolean): Promise<RunCommandResult> {
async function openDashboard({ isNonInteractive }: CLIContext): Promise<RunCommandResult> {
if (!isNonInteractive) {
await open(url); // Only open browser in interactive mode
}
return { outroMessage: `Opened at ${url}` };
}

export function getMyCommand(): Command {
return new Base44Command("open")
.description("Open something in browser")
.action(openDashboard);
}
```

### Guarding Interactive Commands

Commands that use interactive prompts (`select`, `text`, `confirm`, `group` from `@clack/prompts`) **must** guard against non-interactive environments. If the user didn't provide enough flags to skip all prompts, error early:
Commands that use interactive prompts (`select`, `text`, `confirm`, `group` from `@clack/prompts`) **must** guard against non-interactive environments. Move the guard into the action function:

```typescript
async function myAction(
{ isNonInteractive }: CLIContext,
options: MyOptions,
): Promise<RunCommandResult> {
const skipPrompts = !!options.name && !!options.path;
if (!skipPrompts && isNonInteractive) {
throw new InvalidInputError(
"--name and --path are required in non-interactive mode",
);
}
if (skipPrompts) {
return await createNonInteractive(options);
}
return await createInteractive(options);
}

export function getMyCommand(): Command {
return new Base44Command("my-cmd", { requireAppConfig: false })
.option("-n, --name <name>", "Project name")
.option("-p, --path <path>", "Project path")
.action(async (options: MyOptions, command: Base44Command) => {
const skipPrompts = !!options.name && !!options.path;
if (!skipPrompts && command.isNonInteractive) {
throw new InvalidInputError(
"--name and --path are required in non-interactive mode",
);
}
if (skipPrompts) {
return await createNonInteractive(options);
}
return await createInteractive(options);
});
.action(myAction);
}
```

Commands that only use `log.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/link.ts`, and `project/eject.ts` for real examples.
Commands that only use `logger.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/link.ts`, and `project/eject.ts` for real examples.

## runTask (Async Operations with Spinners)

Expand All @@ -153,7 +162,7 @@ const result = await runTask(
);
```

Avoid manual try/catch with `log.message` for async operations -- use `runTask()` instead.
Avoid manual try/catch with `logger.message` for async operations -- use `runTask()` instead.

### Subprocess Logging

Expand Down Expand Up @@ -211,9 +220,7 @@ export function getMyCommand(): Command {
.argument("[entries...]", "Input entries")
.option("--flag-a <value>", "Alternative input")
.hook("preAction", validateInput)
.action(async (entries, options) => {
return await myAction(entries, options);
});
.action(myAction);
}
```

Expand All @@ -223,9 +230,10 @@ Access `command.args` for positional arguments and `command.opts()` for options

- **Command factory pattern** - Commands export `getXCommand()` functions (no parameters), not static instances
- **Use `Base44Command`** - All commands use `new Base44Command(name, options?)`
- **No context plumbing** - Context is injected automatically. Command files should never import `CLIContext`.
- **CLIContext as first arg** - All action functions receive `CLIContext` as their first argument (auto-injected). Destructure `{ logger }`, `{ logger, isNonInteractive }`, or use `_ctx` if nothing is needed. Use `.action(fn)` directly — no wrappers.
- **Use `logger` for output** - Never import `log` from `@clack/prompts` in command files. Use the `logger` from CLIContext so output works correctly in both interactive and non-interactive modes. Pass `logger` to helper functions as a parameter.
- **Task wrapper** - Use `runTask()` for async operations with spinners
- **Use theme for styling** - Never use `chalk` directly; import `theme` from `@/cli/utils/` and use semantic names
- **Use fs.ts utilities** - Always use `@/core/utils/fs.js` for file operations
- **Guard interactive prompts** - Commands using `select`, `text`, `confirm`, or `group` from `@clack/prompts` must check `command.isNonInteractive` and throw `InvalidInputError` if required flags are missing. Never let prompts hang in CI.
- **Guard interactive prompts** - Commands using `select`, `text`, `confirm`, or `group` from `@clack/prompts` must check `isNonInteractive` (from CLIContext) and throw `InvalidInputError` if required flags are missing. Never let prompts hang in CI.
- **Consistent copy across related commands** - User-facing messages (errors, success, hints) for commands in the same group should use consistent language and structure. When writing validation errors, outro messages, or spinner text, check sibling commands for parity so the product voice stays coherent.
3 changes: 3 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"project": ["src/**/*.ts", "tests/**/*.ts"],
"ignore": ["dist/**", "tests/fixtures/**"],
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need "dist/**" in ignore?

"ignoreDependencies": ["@types/deno"]
},
"packages/logger": {
"project": ["src/**/*.ts"]
Copy link
Contributor

Choose a reason for hiding this comment

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

Here you don't need entry?
Why it's needed in packages/cli?

}
}
}
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"url": "https://github.com/base44/cli"
},
"devDependencies": {
"@base44-cli/logger": "workspace:*",
"@clack/prompts": "^1.0.1",
"@seald-io/nedb": "^4.1.2",
"@types/bun": "^1.2.15",
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/cli/commands/agents/pull.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { dirname, join } from "node:path";
import { log } from "@clack/prompts";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask } from "@/cli/utils/index.js";
import { readProjectConfig } from "@/core/index.js";
import { fetchAgents, writeAgents } from "@/core/resources/agent/index.js";

async function pullAgentsAction(): Promise<RunCommandResult> {
async function pullAgentsAction({
log,
}: CLIContext): Promise<RunCommandResult> {
const { project } = await readProjectConfig();

const configDir = dirname(project.configPath);
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/cli/commands/agents/push.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { log } from "@clack/prompts";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask } from "@/cli/utils/index.js";
import { readProjectConfig } from "@/core/index.js";
import { pushAgents } from "@/core/resources/agent/index.js";

async function pushAgentsAction(): Promise<RunCommandResult> {
async function pushAgentsAction({
log,
}: CLIContext): Promise<RunCommandResult> {
const { agents } = await readProjectConfig();

log.info(
Expand Down
12 changes: 7 additions & 5 deletions packages/cli/src/cli/commands/auth/login-flow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { log } from "@clack/prompts";
import type { Logger } from "@base44-cli/logger";
import pWaitFor from "p-wait-for";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { runTask } from "@/cli/utils/index.js";
import { theme } from "@/cli/utils/theme.js";
import type {
Expand All @@ -15,7 +15,9 @@ import {
writeAuth,
} from "@/core/auth/index.js";

async function generateAndDisplayDeviceCode(): Promise<DeviceCodeResponse> {
async function generateAndDisplayDeviceCode(
log: Logger,
): Promise<DeviceCodeResponse> {
const deviceCodeResponse = await runTask(
"Generating device code...",
async () => {
Expand Down Expand Up @@ -99,8 +101,8 @@ async function saveAuthData(
* Execute the login flow (device code authentication).
* This function is separate from the command to avoid circular dependencies.
*/
export async function login(): Promise<RunCommandResult> {
const deviceCodeResponse = await generateAndDisplayDeviceCode();
export async function login({ log }: CLIContext): Promise<RunCommandResult> {
const deviceCodeResponse = await generateAndDisplayDeviceCode(log);

const token = await waitForAuthentication(
deviceCodeResponse.deviceCode,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { deleteAuth } from "@/core/auth/index.js";

async function logout(): Promise<RunCommandResult> {
async function logout(_ctx: CLIContext): Promise<RunCommandResult> {
await deleteAuth();
return { outroMessage: "Logged out successfully" };
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { readAuth } from "@/core/auth/index.js";

async function whoami(): Promise<RunCommandResult> {
async function whoami(_ctx: CLIContext): Promise<RunCommandResult> {
const auth = await readAuth();
return { outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}` };
}
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/cli/commands/connectors/list-available.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { log } from "@clack/prompts";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import {
Base44Command,
formatYaml,
Expand All @@ -9,7 +8,9 @@ import {
} from "@/cli/utils/index.js";
import { listAvailableIntegrations } from "@/core/resources/connector/index.js";

async function listAvailableAction(): Promise<RunCommandResult> {
async function listAvailableAction({
log,
}: CLIContext): Promise<RunCommandResult> {
const { integrations } = await runTask(
"Fetching available integrations from Base44",
async () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli/commands/connectors/oauth-prompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { confirm, isCancel, log, spinner } from "@clack/prompts";
import type { Logger } from "@base44-cli/logger";
import { confirm, isCancel, spinner } from "@clack/prompts";
import open from "open";
import pWaitFor, { TimeoutError } from "p-wait-for";
import { theme } from "@/cli/utils/index.js";
Expand Down Expand Up @@ -103,6 +104,7 @@ async function runOAuthFlowWithSkip(
*/
export async function promptOAuthFlows(
pending: OAuthSyncResult[],
log: Logger,
options?: OAuthPromptOptions,
): Promise<Map<IntegrationType, OAuthFlowStatus>> {
const outcomes = new Map<IntegrationType, OAuthFlowStatus>();
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/cli/commands/connectors/pull.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { dirname, join } from "node:path";
import { log } from "@clack/prompts";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask } from "@/cli/utils/index.js";
import { readProjectConfig } from "@/core/index.js";
import {
pullAllConnectors,
writeConnectors,
} from "@/core/resources/connector/index.js";

async function pullConnectorsAction(): Promise<RunCommandResult> {
async function pullConnectorsAction({
log,
}: CLIContext): Promise<RunCommandResult> {
const { project } = await readProjectConfig();

const configDir = dirname(project.configPath);
Expand Down
Loading
Loading