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
140 changes: 134 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -128,11 +130,20 @@ Commands live in `src/cli/commands/`. Follow these steps:
```typescript
// src/cli/commands/<domain>/<action>.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<RunCommandResult> {
/**
* 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<RunCommandResult<MyActionResult>> {
// Use runTask for async operations with spinners
const result = await runTask(
"Doing something...",
Expand All @@ -147,10 +158,17 @@ async function myAction(): Promise<RunCommandResult> {
}
);

// 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("<name>")
Expand Down Expand Up @@ -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<T>`:

```typescript
async function myAction(): Promise<RunCommandResult<MyCommandResult>> {
// ... 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<RunCommandResult<never>> {
// ... 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<Options & { json?: boolean }>();
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`:
Expand All @@ -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

Expand Down
22 changes: 20 additions & 2 deletions bin/dev.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
#!/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();
} catch (error) {
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);
}
22 changes: 20 additions & 2 deletions bin/run.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
#!/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();
} catch (error) {
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);
}
10 changes: 7 additions & 3 deletions src/cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Command } from "commander";
import { log } from "@clack/prompts";
import pWaitFor from "p-wait-for";
import {
writeAuth,
Expand All @@ -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<DeviceCodeResponse> {
const deviceCodeResponse = await runTask(
"Generating device code...",
Expand Down Expand Up @@ -96,7 +100,7 @@ async function saveAuthData(
});
}

export async function login(): Promise<RunCommandResult> {
export async function login(): Promise<RunCommandResult<never>> {
const deviceCodeResponse = await generateAndDisplayDeviceCode();

const token = await waitForAuthentication(
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunCommandResult> {
/**
* Logout command does not support --json output.
* It is a user-facing auth command that is rarely scripted.
*/
async function logout(): Promise<RunCommandResult<never>> {
await deleteAuth();
return { outroMessage: "Logged out successfully" };
}
Expand Down
20 changes: 18 additions & 2 deletions src/cli/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunCommandResult> {
/**
* 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<RunCommandResult<WhoamiResult>> {
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")
Expand Down
31 changes: 25 additions & 6 deletions src/cli/commands/entities/push.ts
Original file line number Diff line number Diff line change
@@ -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<RunCommandResult> {
/**
* 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<RunCommandResult<EntitiesPushResult>> {
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`);
Expand All @@ -25,7 +39,6 @@ async function pushEntitiesAction(): Promise<RunCommandResult> {
}
);

// Print the results
if (result.created.length > 0) {
log.success(`Created: ${result.created.join(", ")}`);
}
Expand All @@ -36,7 +49,13 @@ async function pushEntitiesAction(): Promise<RunCommandResult> {
log.warn(`Deleted: ${result.deleted.join(", ")}`);
}

return {};
return {
data: {
created: result.created,
updated: result.updated,
deleted: result.deleted,
},
};
}

export const entitiesPushCommand = new Command("entities")
Expand Down
Loading