Skip to content
Merged
129 changes: 127 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ cli/
│ │ │ └── index.ts
│ │ ├── consts.ts # Pure constants (NO imports from other core modules)
│ │ ├── config.ts # Path helpers (global dir, templates, API URL)
│ │ ├── errors.ts # Error classes
│ │ ├── errors.ts # CLIError hierarchy (UserError, SystemError, etc.)
│ │ └── index.ts # Barrel export for all core modules
│ └── cli/
│ ├── program.ts # createProgram(context) factory
Expand Down Expand Up @@ -416,6 +416,128 @@ import { entityResource } from "@/core/resources/entity/index.js";
import { base44Client } from "@/core/api/index.js";
```

## Error Handling

The CLI uses a structured error hierarchy to provide clear, actionable error messages with hints for users and AI agents.

### Error Hierarchy

```
CLIError (abstract base class)
├── UserError (user did something wrong - fixable by user)
│ ├── AuthRequiredError # Not logged in
│ ├── AuthExpiredError # Token expired
│ ├── ConfigNotFoundError # No project found
│ ├── ConfigInvalidError # Invalid config syntax/structure
│ ├── ConfigExistsError # Project already exists
│ ├── SchemaValidationError # Zod validation failed
│ └── InvalidInputError # Bad user input (template not found, etc.)
└── SystemError (something broke - needs investigation)
├── ApiError # HTTP/network failures
├── FileNotFoundError # File doesn't exist
├── FileReadError # Can't read file
└── InternalError # Unexpected errors
```

### Error Properties

All errors extend `CLIError` and have these properties:

```typescript
interface CLIError {
code: string; // e.g., "AUTH_REQUIRED", "CONFIG_NOT_FOUND"
isUserError: boolean; // true for UserError, false for SystemError
hints: ErrorHint[]; // Actionable suggestions
cause?: Error; // Original error for stack traces
}

interface ErrorHint {
message: string; // Human-readable hint
command?: string; // Optional command to run (for AI agents)
}
```

### Throwing Errors

Import errors from `@/core/errors.js`:

```typescript
import {
ConfigNotFoundError,
ConfigExistsError,
SchemaValidationError,
ApiError,
InvalidInputError,
} from "@/core/errors.js";

// User errors - provide helpful hints
throw new ConfigNotFoundError(); // Has default hints for create/link

throw new ConfigExistsError("Project already exists at /path/to/config.jsonc");

throw new InvalidInputError(`Template "${templateId}" not found`, {
hints: [
{ message: `Use one of: ${validIds}` },
],
});

// API errors - include status code for automatic hint generation
throw new ApiError("Failed to sync entities", { statusCode: response.status });
// 401 → hints to run `base44 login`
// 404 → hints about resource not found
// Other → hints to check network
```

### SchemaValidationError with Zod

`SchemaValidationError` requires a context message and a `ZodError`. It formats the error automatically using `z.prettifyError()`:

```typescript
import { SchemaValidationError } from "@/core/errors.js";

const result = EntitySchema.safeParse(parsed);

if (!result.success) {
// Pass context message + ZodError - formatting is handled automatically
throw new SchemaValidationError("Invalid entity file at " + entityPath, result.error);
}

// Output:
// Invalid entity file at /path/to/entity.jsonc:
// ✖ Invalid input: expected string, received number
// → at name
```

**Important**: Do NOT manually call `z.prettifyError()` - the class does this internally.

### Error Code Reference

| Code | Class | When to use |
| ------------------ | ----------------------- | ------------------------------------- |
| `AUTH_REQUIRED` | `AuthRequiredError` | User not logged in |
| `AUTH_EXPIRED` | `AuthExpiredError` | Token expired, needs re-login |
| `CONFIG_NOT_FOUND` | `ConfigNotFoundError` | No project/config file found |
| `CONFIG_INVALID` | `ConfigInvalidError` | Config file has invalid content |
| `CONFIG_EXISTS` | `ConfigExistsError` | Project already exists at location |
| `SCHEMA_INVALID` | `SchemaValidationError` | Zod validation failed |
| `INVALID_INPUT` | `InvalidInputError` | User provided invalid input |
| `API_ERROR` | `ApiError` | API request failed |
| `FILE_NOT_FOUND` | `FileNotFoundError` | File doesn't exist |
| `FILE_READ_ERROR` | `FileReadError` | Can't read/write file |
| `INTERNAL_ERROR` | `InternalError` | Unexpected error |

### CLIExitError (Special Case)

`CLIExitError` in `src/cli/errors.ts` is for controlled exits (e.g., user cancellation). It's NOT reported to telemetry:

```typescript
import { CLIExitError } from "@/cli/errors.js";

// User cancelled a prompt
throw new CLIExitError(0); // Exit code 0 = success (user chose to cancel)
```

## Telemetry & Error Reporting

The CLI reports errors to PostHog for monitoring. This is handled by the `ErrorReporter` class.
Expand Down Expand Up @@ -463,6 +585,7 @@ Set the environment variable: `BASE44_DISABLE_TELEMETRY=1`
- App ID (if in a project)
- System info (Node version, OS, platform)
- Error stack traces
- Error code and isUserError (for CLIError instances)

## Important Rules

Expand All @@ -480,7 +603,9 @@ Set the environment variable: `BASE44_DISABLE_TELEMETRY=1`
12. **Use theme for styling** - Never use `chalk` directly in commands; import `theme` from utils and use semantic color/style names
13. **Use fs.ts utilities** - Always use `@/core/utils/fs.js` for file operations
14. **No direct process.exit()** - Throw `CLIExitError` instead; entry points handle the actual exit
15. **No dynamic imports** - Avoid `await import()` inside functions; use static imports at top of file
15. **Use structured errors** - Never `throw new Error()`; use specific error classes from `@/core/errors.js` with appropriate hints
16. **SchemaValidationError requires ZodError** - Always pass `ZodError`: `new SchemaValidationError("context", result.error)` - don't call `z.prettifyError()` manually
17. **No dynamic imports** - Avoid `await import()` inside functions; use static imports at top of file

## Development

Expand Down
2 changes: 1 addition & 1 deletion bin/dev.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env tsx
import { createProgram, runCLI } from "../src/cli/index.ts";
import { runCLI } from "../src/cli/index.ts";

// Disable Clack spinners and animations in non-interactive environments.
// Clack only checks the CI env var, so we set it when stdin/stdout aren't TTYs.
Expand Down
2 changes: 1 addition & 1 deletion bin/run.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { createProgram, runCLI } from "../dist/index.js";
import { runCLI } from "../dist/index.js";

// Disable Clack spinners and animations in non-interactive environments.
// Clack only checks the CI env var, so we set it when stdin/stdout aren't TTYs.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

8 changes: 7 additions & 1 deletion src/cli/commands/functions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { log } from "@clack/prompts";
import type { CLIContext } from "@/cli/types.js";
import { pushFunctions } from "@/core/resources/function/index.js";
import { readProjectConfig } from "@/core/index.js";
import { ApiError } from "@/core/errors.js";
import { runCommand, runTask } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";

Expand Down Expand Up @@ -41,7 +42,12 @@ async function deployFunctionsAction(): Promise<RunCommandResult> {
const errorMessages = result.errors
.map((e) => `'${e.name}' function: ${e.message}`)
.join("\n");
throw new Error(`Function deployment errors:\n${errorMessages}`);
throw new ApiError(`Function deployment errors:\n${errorMessages}`, {
hints: [
{ message: "Check the function code for syntax errors" },
{ message: "Ensure all imports are valid" },
],
});
}

return { outroMessage: "Functions deployed to Base44" };
Expand Down
10 changes: 9 additions & 1 deletion src/cli/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CLIContext } from "@/cli/types.js";
import { createProjectFiles, listTemplates, readProjectConfig, setAppConfig } from "@/core/project/index.js";
import type { Template } from "@/core/project/index.js";
import { deploySite, isDirEmpty, pushEntities } from "@/core/index.js";
import { InvalidInputError } from "@/core/errors.js";
import {
runCommand,
runTask,
Expand All @@ -32,7 +33,14 @@ async function getTemplateById(templateId: string): Promise<Template> {
const template = templates.find((t) => t.id === templateId);
if (!template) {
const validIds = templates.map((t) => t.id).join(", ");
throw new Error(`Template "${templateId}" not found. Available templates: ${validIds}`);
throw new InvalidInputError(
`Template "${templateId}" not found.`,
{
hints: [
{ message: `Use one of: ${validIds}` },
],
}
);
}
return template;
}
Expand Down
22 changes: 17 additions & 5 deletions src/cli/commands/project/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
listProjects,
} from "@/core/project/index.js";
import type { Project } from "@/core/project/index.js";
import { ConfigNotFoundError, ConfigExistsError, InvalidInputError } from "@/core/errors.js";
import {
runCommand,
runTask,
Expand Down Expand Up @@ -124,14 +125,19 @@ async function link(options: LinkOptions): Promise<RunCommandResult> {
const projectRoot = await findProjectRoot();

if (!projectRoot) {
throw new Error(
throw new ConfigNotFoundError(
"No Base44 project found. Run this command from a project directory with a config.jsonc file."
);
}

if (await appConfigExists(projectRoot.root)) {
throw new Error(
"Project is already linked. An .app.jsonc file with the appId already exists."
throw new ConfigExistsError(
"Project is already linked. An .app.jsonc file with the appId already exists.",
{
hints: [
{ message: "If you want to re-link, delete the existing .app.jsonc file first" },
],
}
);
}

Expand Down Expand Up @@ -160,8 +166,14 @@ async function link(options: LinkOptions): Promise<RunCommandResult> {
// Validate that the provided project ID exists and is linkable
const project = linkableProjects.find((p) => p.id === options.projectId);
if (!project) {
throw new Error(
`Project with ID "${options.projectId}" not found or not available for linking.`
throw new InvalidInputError(
`Project with ID "${options.projectId}" not found or not available for linking.`,
{
hints: [
{ message: "Check the project ID is correct" },
{ message: "Use 'base44 link' without --projectId to see available projects" },
],
}
);
}
projectId = options.projectId;
Expand Down
10 changes: 8 additions & 2 deletions src/cli/commands/site/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { confirm, isCancel } from "@clack/prompts";
import type { CLIContext } from "@/cli/types.js";
import { readProjectConfig } from "@/core/project/index.js";
import { deploySite } from "@/core/site/index.js";
import { ConfigNotFoundError } from "@/core/errors.js";
import { runCommand, runTask } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";

Expand All @@ -15,8 +16,13 @@ async function deployAction(options: DeployOptions): Promise<RunCommandResult> {
const { project } = await readProjectConfig();

if (!project.site?.outputDirectory) {
throw new Error(
"No site configuration found. Please add 'site.outputDirectory' to your config.jsonc"
throw new ConfigNotFoundError(
"No site configuration found.",
{
hints: [
{ message: "Add 'site.outputDirectory' to your config.jsonc (e.g., \"site\": { \"outputDirectory\": \"dist\" })" },
],
}
);
}

Expand Down
20 changes: 18 additions & 2 deletions src/cli/telemetry/error-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { release, type } from "node:os";
import { nanoid } from "nanoid";
import { determineAgent } from "@vercel/detect-agent";
import { getPostHogClient, isTelemetryEnabled } from "./posthog.js";
import { isCLIError, isUserError } from "@/core/errors.js";
import packageJson from "../../../package.json";

/**
Expand Down Expand Up @@ -58,9 +59,13 @@ export class ErrorReporter {
return this.context.user?.email ?? `anon-${this.sessionId}`;
}

private buildProperties(): Record<string, unknown> {
private buildProperties(error?: Error): Record<string, unknown> {
const { user, command, appId, api } = this.context;

// Extract CLIError-specific properties if applicable
const errorCode = error && isCLIError(error) ? error.code : undefined;
const userError = error ? isUserError(error) : undefined;

return {
// Session
session_id: this.sessionId,
Expand All @@ -78,6 +83,12 @@ export class ErrorReporter {
// App
app_id: appId,

// Error context (from CLIError)
...(errorCode !== undefined && {
error_code: errorCode,
is_user_error: userError,
}),

// API error
api_status_code: api?.statusCode,
api_error_body: api?.errorBody,
Expand All @@ -100,14 +111,19 @@ export class ErrorReporter {
};
}

/**
* Capture an exception and report it to PostHog.
* Includes error code and isUserError for CLIError instances.
* Safe to call - never throws, logs errors to console.
*/
captureException(error: Error): void {
if (!isTelemetryEnabled()) {
return;
}

try {
const client = getPostHogClient();
client?.captureException(error, this.getDistinctId(), this.buildProperties());
client?.captureException(error, this.getDistinctId(), this.buildProperties(error));
} catch {
// Silent - don't let error reporting break the CLI
}
Expand Down
Loading