From 756789fa9b6432dec4f8c0f8077a056b38e1fe12 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 08:53:32 +0000 Subject: [PATCH 01/13] feat: add PostHog error monitoring for CLI executions - Implement error reporter module with PostHog client - Configure for short-running CLI tools (immediate flush, no batching) - Integrate error reporting in bin/run.js with proper shutdown - Add environment variable configuration (POSTHOG_API_KEY, POSTHOG_HOST) - Include error context (command, Node version, platform) - Add unit tests for error reporter - Graceful degradation when PostHog is not configured Resolves #55 Co-authored-by: Kfir Stri --- bin/run.js | 25 +++++++- package.json | 3 + src/cli/error-reporter.ts | 109 +++++++++++++++++++++++++++++++++++ src/cli/index.ts | 1 + tests/error-reporter.test.ts | 40 +++++++++++++ 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/cli/error-reporter.ts create mode 100644 tests/error-reporter.test.ts diff --git a/bin/run.js b/bin/run.js index 10a223ae..4c16ff43 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,19 +1,40 @@ #!/usr/bin/env node +import { program, CLIExitError, errorReporter } 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. if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.env.CI = 'true'; + process.env.CI = "true"; } -import { program, CLIExitError } from "../dist/index.js"; +// Initialize error reporter +// The API key should be provided via environment variable +const posthogApiKey = process.env.POSTHOG_API_KEY; +if (posthogApiKey) { + errorReporter.initialize(posthogApiKey, process.env.POSTHOG_HOST); +} try { await program.parseAsync(); } catch (error) { + // Report the error to PostHog if it's not a controlled exit + if (!(error instanceof CLIExitError)) { + await errorReporter.captureException(error, { + command: process.argv.slice(2).join(" "), + node_version: process.version, + platform: process.platform, + }); + } + + // Ensure PostHog events are sent before exiting + await errorReporter.shutdown(); + if (error instanceof CLIExitError) { process.exit(error.code); } console.error(error); process.exit(1); +} finally { + // Always shutdown error reporter on normal exit too + await errorReporter.shutdown(); } diff --git a/package.json b/package.json index 73ccb836..097270d9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "type": "git", "url": "https://github.com/base44/cli" }, + "dependencies": { + "posthog-node": "^4.2.1" + }, "devDependencies": { "@clack/prompts": "^0.11.0", "@stylistic/eslint-plugin": "^5.6.1", diff --git a/src/cli/error-reporter.ts b/src/cli/error-reporter.ts new file mode 100644 index 00000000..43222fca --- /dev/null +++ b/src/cli/error-reporter.ts @@ -0,0 +1,109 @@ +import { PostHog } from "posthog-node"; + +/** + * Error reporter using PostHog for CLI executions. + * Designed for short-running CLI tools with proper shutdown handling. + */ +class ErrorReporter { + private client: PostHog | null = null; + private isEnabled = false; + private shutdownPromise: Promise | null = null; + + /** + * Initialize the error reporter with PostHog configuration. + * @param apiKey - PostHog API key + * @param host - PostHog host URL (optional, defaults to PostHog cloud) + */ + initialize(apiKey: string, host?: string): void { + if (!apiKey) { + console.warn("PostHog API key not provided. Error reporting disabled."); + return; + } + + try { + this.client = new PostHog(apiKey, { + host: host || "https://us.i.posthog.com", + // Disable batch processing for CLI - we want immediate sends + flushAt: 1, + flushInterval: 0, + }); + this.isEnabled = true; + } catch (error) { + console.error("Failed to initialize PostHog client:", error); + this.isEnabled = false; + } + } + + /** + * Capture an exception and report it to PostHog. + * @param error - The error to capture + * @param context - Optional additional context about the error + */ + async captureException( + error: Error, + context?: Record + ): Promise { + if (!this.isEnabled || !this.client) { + return; + } + + try { + const properties: Record = { + error_name: error.name, + error_message: error.message, + error_stack: error.stack, + ...context, + }; + + // Capture as an event with error details + this.client.capture({ + distinctId: "cli-user", // For CLI, we use a generic ID unless user identification is added + event: "cli_error", + properties, + }); + + // For CLI tools, we need to flush immediately since the process may exit soon + await this.client.flush(); + } catch (captureError) { + // Don't let error reporting failures break the CLI + console.error("Failed to capture exception:", captureError); + } + } + + /** + * Shutdown the error reporter and ensure all events are sent. + * MUST be called before CLI exits to ensure events are flushed. + */ + async shutdown(): Promise { + if (!this.client || this.shutdownPromise) { + // Already shutting down or no client + return this.shutdownPromise || Promise.resolve(); + } + + this.shutdownPromise = (async () => { + try { + await this.client!.shutdown(); + } catch (error) { + console.error("Error during PostHog shutdown:", error); + } finally { + this.isEnabled = false; + this.client = null; + } + })(); + + return this.shutdownPromise; + } + + /** + * Check if error reporting is enabled. + */ + get enabled(): boolean { + return this.isEnabled; + } +} + +// Export a singleton instance +export const errorReporter = new ErrorReporter(); + +// Export the class for testing purposes +export { ErrorReporter }; diff --git a/src/cli/index.ts b/src/cli/index.ts index e3bb43a3..f06edb3d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,2 +1,3 @@ export { program } from "./program.js"; export { CLIExitError } from "./errors.js"; +export { errorReporter } from "./error-reporter.js"; diff --git a/tests/error-reporter.test.ts b/tests/error-reporter.test.ts new file mode 100644 index 00000000..17d9c9e7 --- /dev/null +++ b/tests/error-reporter.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ErrorReporter } from "../src/cli/error-reporter.js"; + +describe("ErrorReporter", () => { + let reporter: ErrorReporter; + + beforeEach(() => { + reporter = new ErrorReporter(); + }); + + it("should be disabled by default", () => { + expect(reporter.enabled).toBe(false); + }); + + it("should not throw when capturing exception without initialization", async () => { + const error = new Error("Test error"); + await expect(reporter.captureException(error)).resolves.not.toThrow(); + }); + + it("should not throw when shutting down without initialization", async () => { + await expect(reporter.shutdown()).resolves.not.toThrow(); + }); + + it("should warn when initialized without API key", () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + reporter.initialize(""); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "PostHog API key not provided. Error reporting disabled." + ); + expect(reporter.enabled).toBe(false); + consoleWarnSpy.mockRestore(); + }); + + it("should handle multiple shutdown calls gracefully", async () => { + reporter.initialize("test-key"); + const shutdown1 = reporter.shutdown(); + const shutdown2 = reporter.shutdown(); + await expect(Promise.all([shutdown1, shutdown2])).resolves.not.toThrow(); + }); +}); From 074ef1b6ca99c3663d8346ebaa65219614f0d613 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 29 Jan 2026 17:29:41 +0200 Subject: [PATCH 02/13] error handeling for CLI --- AGENTS.md | 21 ++- bin/dev.js | 16 +- bin/run.js | 35 +--- package-lock.json | 165 ++++++++++++++++++- package.json | 6 +- src/cli/error-reporter.ts | 109 ------------- src/cli/index.ts | 4 +- src/cli/program.ts | 94 +++++++---- src/cli/telemetry/commander-hooks.ts | 35 ++++ src/cli/telemetry/consts.ts | 4 + src/cli/telemetry/error-reporter.ts | 231 +++++++++++++++++++++++++++ src/cli/telemetry/index.ts | 3 + src/cli/telemetry/posthog.ts | 70 ++++++++ src/cli/utils/runCommand.ts | 4 +- src/core/project/app-config.ts | 10 +- tests/cli/testkit/CLITestkit.ts | 15 +- tests/error-reporter.test.ts | 40 ----- 17 files changed, 613 insertions(+), 249 deletions(-) delete mode 100644 src/cli/error-reporter.ts create mode 100644 src/cli/telemetry/commander-hooks.ts create mode 100644 src/cli/telemetry/consts.ts create mode 100644 src/cli/telemetry/error-reporter.ts create mode 100644 src/cli/telemetry/index.ts create mode 100644 src/cli/telemetry/posthog.ts delete mode 100644 tests/error-reporter.test.ts diff --git a/AGENTS.md b/AGENTS.md index 90c5f2ed..fb6f8b59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ cli/ │ │ ├── errors.ts # Error classes │ │ └── index.ts # Barrel export for all core modules │ └── cli/ -│ ├── program.ts # createProgram() factory + CLIExitError +│ ├── program.ts # createProgram() factory + runCLI() │ ├── commands/ │ │ ├── auth/ │ │ │ ├── login.ts @@ -111,6 +111,12 @@ cli/ │ │ │ └── deploy.ts │ │ └── site/ │ │ └── deploy.ts +│ ├── telemetry/ # Error reporting and telemetry +│ │ ├── consts.ts # PostHog API key, env var names +│ │ ├── posthog.ts # PostHog client singleton +│ │ ├── error-reporter.ts # ErrorReporter singleton for capturing exceptions +│ │ ├── commander-hooks.ts# Commander.js integration for command context +│ │ └── index.ts │ ├── utils/ │ │ ├── runCommand.ts # Command wrapper with branding │ │ ├── runTask.ts # Spinner wrapper @@ -120,8 +126,7 @@ cli/ │ │ ├── urls.ts # URL utilities (getDashboardUrl) │ │ └── index.ts │ ├── errors.ts # CLI-specific errors (CLIExitError) -│ ├── program.ts # Commander program definition -│ └── index.ts # Barrel export (program, CLIExitError) +│ └── index.ts # Barrel export (createProgram, runCLI, CLIExitError) ├── templates/ # Project templates ├── tests/ ├── dist/ # Build output (program.js + templates/) @@ -430,14 +435,16 @@ The CLI uses a split architecture for better development experience: - No build step required - changes are reflected immediately **CLI Module** (`src/cli/`): -- `program.ts` - Defines the Commander program and registers all commands +- `program.ts` - `createProgram()` factory and `runCLI()` execution with error handling +- `telemetry/` - Error reporting via PostHog (see folder structure above) - `errors.ts` - CLI-specific errors (CLIExitError) -- `index.ts` - Barrel export for entry points (exports program, CLIExitError) +- `index.ts` - Barrel export for entry points **Error Handling Flow**: - Commands throw errors → `runCommand()` catches, logs, and throws `CLIExitError(1)` -- Entry points (`bin/run.js`, `bin/dev.js`) catch `CLIExitError` and call `process.exit(code)` -- This keeps `process.exit()` out of core code, making it testable +- `runCLI()` catches errors, reports to PostHog (if not CLIExitError), and calls `process.exit(code)` +- Entry points (`bin/run.js`, `bin/dev.js`) simply call `createProgram()` and `runCLI(program)` +- Telemetry can be disabled via `BASE44_DISABLE_TELEMETRY=1` environment variable ### Node.js Version diff --git a/bin/dev.js b/bin/dev.js index 04cede2a..7bce19c0 100755 --- a/bin/dev.js +++ b/bin/dev.js @@ -1,19 +1,11 @@ #!/usr/bin/env tsx +import { createProgram, 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. if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.env.CI = 'true'; + process.env.CI = "true"; } -import { program, CLIExitError } from "../src/cli/index.ts"; - -try { - await program.parseAsync(); -} catch (error) { - if (error instanceof CLIExitError) { - process.exit(error.code); - } - console.error(error); - process.exit(1); -} +const program = createProgram(); +await runCLI(program); diff --git a/bin/run.js b/bin/run.js index 4c16ff43..5e2f2335 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { program, CLIExitError, errorReporter } from "../dist/index.js"; +import { createProgram, 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. @@ -7,34 +7,5 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { process.env.CI = "true"; } -// Initialize error reporter -// The API key should be provided via environment variable -const posthogApiKey = process.env.POSTHOG_API_KEY; -if (posthogApiKey) { - errorReporter.initialize(posthogApiKey, process.env.POSTHOG_HOST); -} - -try { - await program.parseAsync(); -} catch (error) { - // Report the error to PostHog if it's not a controlled exit - if (!(error instanceof CLIExitError)) { - await errorReporter.captureException(error, { - command: process.argv.slice(2).join(" "), - node_version: process.version, - platform: process.platform, - }); - } - - // Ensure PostHog events are sent before exiting - await errorReporter.shutdown(); - - if (error instanceof CLIExitError) { - process.exit(error.code); - } - console.error(error); - process.exit(1); -} finally { - // Always shutdown error reporter on normal exit too - await errorReporter.shutdown(); -} +const program = createProgram(); +await runCLI(program); diff --git a/package-lock.json b/package-lock.json index 1f1d047b..dbd604ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/tar": "^6.1.13", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", + "@vercel/detect-agent": "^1.1.0", "chalk": "^5.6.2", "commander": "^12.1.0", "ejs": "^3.1.10", @@ -33,8 +34,10 @@ "ky": "^1.14.2", "lodash.kebabcase": "^4.1.1", "msw": "^2.12.7", + "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", + "posthog-node": "^4.2.1", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", @@ -1406,6 +1409,16 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vercel/detect-agent": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.1.0.tgz", + "integrity": "sha512-Zfq6FbIcYl9gaAmVu6ROsqUiCNwpEj3Ljz/tMX5fl12Z95OFOxzf7vlO03WE5JBU/ri1tBDFHnW41dihMINOPQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@vitest/expect": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", @@ -1769,6 +1782,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1785,6 +1805,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2170,6 +2202,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2395,6 +2440,16 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/diff": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", @@ -3353,6 +3408,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3369,6 +3445,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/front-matter": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", @@ -4603,6 +4696,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -4715,9 +4831,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "dev": true, "funding": [ { @@ -4727,10 +4843,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -5143,6 +5259,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/posthog-node": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", + "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.8.2" + }, + "engines": { + "node": ">=15.0.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -5182,6 +5330,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 097270d9..4d0254ea 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,6 @@ "type": "git", "url": "https://github.com/base44/cli" }, - "dependencies": { - "posthog-node": "^4.2.1" - }, "devDependencies": { "@clack/prompts": "^0.11.0", "@stylistic/eslint-plugin": "^5.6.1", @@ -43,6 +40,7 @@ "@types/tar": "^6.1.13", "@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/parser": "^8.51.0", + "@vercel/detect-agent": "^1.1.0", "chalk": "^5.6.2", "commander": "^12.1.0", "ejs": "^3.1.10", @@ -56,8 +54,10 @@ "ky": "^1.14.2", "lodash.kebabcase": "^4.1.1", "msw": "^2.12.7", + "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", + "posthog-node": "^4.2.1", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", diff --git a/src/cli/error-reporter.ts b/src/cli/error-reporter.ts deleted file mode 100644 index 43222fca..00000000 --- a/src/cli/error-reporter.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { PostHog } from "posthog-node"; - -/** - * Error reporter using PostHog for CLI executions. - * Designed for short-running CLI tools with proper shutdown handling. - */ -class ErrorReporter { - private client: PostHog | null = null; - private isEnabled = false; - private shutdownPromise: Promise | null = null; - - /** - * Initialize the error reporter with PostHog configuration. - * @param apiKey - PostHog API key - * @param host - PostHog host URL (optional, defaults to PostHog cloud) - */ - initialize(apiKey: string, host?: string): void { - if (!apiKey) { - console.warn("PostHog API key not provided. Error reporting disabled."); - return; - } - - try { - this.client = new PostHog(apiKey, { - host: host || "https://us.i.posthog.com", - // Disable batch processing for CLI - we want immediate sends - flushAt: 1, - flushInterval: 0, - }); - this.isEnabled = true; - } catch (error) { - console.error("Failed to initialize PostHog client:", error); - this.isEnabled = false; - } - } - - /** - * Capture an exception and report it to PostHog. - * @param error - The error to capture - * @param context - Optional additional context about the error - */ - async captureException( - error: Error, - context?: Record - ): Promise { - if (!this.isEnabled || !this.client) { - return; - } - - try { - const properties: Record = { - error_name: error.name, - error_message: error.message, - error_stack: error.stack, - ...context, - }; - - // Capture as an event with error details - this.client.capture({ - distinctId: "cli-user", // For CLI, we use a generic ID unless user identification is added - event: "cli_error", - properties, - }); - - // For CLI tools, we need to flush immediately since the process may exit soon - await this.client.flush(); - } catch (captureError) { - // Don't let error reporting failures break the CLI - console.error("Failed to capture exception:", captureError); - } - } - - /** - * Shutdown the error reporter and ensure all events are sent. - * MUST be called before CLI exits to ensure events are flushed. - */ - async shutdown(): Promise { - if (!this.client || this.shutdownPromise) { - // Already shutting down or no client - return this.shutdownPromise || Promise.resolve(); - } - - this.shutdownPromise = (async () => { - try { - await this.client!.shutdown(); - } catch (error) { - console.error("Error during PostHog shutdown:", error); - } finally { - this.isEnabled = false; - this.client = null; - } - })(); - - return this.shutdownPromise; - } - - /** - * Check if error reporting is enabled. - */ - get enabled(): boolean { - return this.isEnabled; - } -} - -// Export a singleton instance -export const errorReporter = new ErrorReporter(); - -// Export the class for testing purposes -export { ErrorReporter }; diff --git a/src/cli/index.ts b/src/cli/index.ts index f06edb3d..fa00446b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,3 @@ -export { program } from "./program.js"; +export { createProgram, runCLI } from "./program.js"; export { CLIExitError } from "./errors.js"; -export { errorReporter } from "./error-reporter.js"; +export { errorReporter } from "./telemetry/index.js"; diff --git a/src/cli/program.ts b/src/cli/program.ts index ff62ed05..44e88b93 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,42 +10,80 @@ import { dashboardCommand } from "@/cli/commands/project/dashboard.js"; import { deployCommand } from "@/cli/commands/project/deploy.js"; import { linkCommand } from "@/cli/commands/project/link.js"; import { siteDeployCommand } from "@/cli/commands/site/deploy.js"; +import { CLIExitError } from "@/cli/errors.js"; +import { errorReporter, addCommandInfoToErrorReporter } from "@/cli/telemetry/index.js"; +import { readAuth } from "@/core/auth/index.js"; import packageJson from "../../package.json"; -const program = new Command(); +export function createProgram(): Command { + const program = new Command(); -program - .name("base44") - .description( - "Base44 CLI - Unified interface for managing Base44 applications" - ) - .version(packageJson.version); + program + .name("base44") + .description( + "Base44 CLI - Unified interface for managing Base44 applications" + ) + .version(packageJson.version); -program.configureHelp({ - sortSubcommands: true, -}); + program.configureHelp({ + sortSubcommands: true, + }); -// Register authentication commands -program.addCommand(loginCommand); -program.addCommand(whoamiCommand); -program.addCommand(logoutCommand); + // Register authentication commands + program.addCommand(loginCommand); + program.addCommand(whoamiCommand); + program.addCommand(logoutCommand); -// Register project commands -program.addCommand(createCommand); -program.addCommand(dashboardCommand); -program.addCommand(deployCommand); -program.addCommand(linkCommand); + // Register project commands + program.addCommand(createCommand); + program.addCommand(dashboardCommand); + program.addCommand(deployCommand); + program.addCommand(linkCommand); -// Register entities commands -program.addCommand(entitiesPushCommand); + // Register entities commands + program.addCommand(entitiesPushCommand); -// Register agents commands -program.addCommand(agentsCommand); + // Register agents commands + program.addCommand(agentsCommand); -// Register functions commands -program.addCommand(functionsDeployCommand); + // Register functions commands + program.addCommand(functionsDeployCommand); -// Register site commands -program.addCommand(siteDeployCommand); + // Register site commands + program.addCommand(siteDeployCommand); -export { program }; + return program; +} + +export async function runCLI(program: Command): Promise { + // Register process error handlers FIRST, do not add more things before this line + errorReporter.registerProcessErrorHandlers(); + + try { + const userInfo = await readAuth(); + errorReporter.setUser({ email: userInfo.email, name: userInfo.name }); + } catch { + // Ignore - user info is optional context + } + + addCommandInfoToErrorReporter(program); + + try { + await program.parseAsync(); + } catch (error) { + // CLIExitError = controlled exit, don't report + if (!(error instanceof CLIExitError)) { + errorReporter.displayErrorInfo(); + await errorReporter.captureException( + error instanceof Error ? error : new Error(String(error)) + ); + console.error(error); + } + + await errorReporter.shutdown(); + process.exit(error instanceof CLIExitError ? error.code : 1); + } + + // Normal exit + await errorReporter.shutdown(); +} diff --git a/src/cli/telemetry/commander-hooks.ts b/src/cli/telemetry/commander-hooks.ts new file mode 100644 index 00000000..67c00b2c --- /dev/null +++ b/src/cli/telemetry/commander-hooks.ts @@ -0,0 +1,35 @@ +import type { Command } from "commander"; +import { errorReporter } from "./error-reporter.js"; + +/** + * Get the full command name by traversing parent commands. + * e.g., "base44 entities push" → "entities push" + */ +function getFullCommandName(command: Command): string { + const parts: string[] = []; + let current: Command | null = command; + + while (current) { + const name = current.name(); + // Skip the root program name + if (current.parent) { + parts.unshift(name); + } + current = current.parent; + } + + return parts.join(" "); +} + + +export function addCommandInfoToErrorReporter(program: Command): void { + program.hook("preAction", (_, actionCommand) => { + const fullCommandName = getFullCommandName(actionCommand); + + errorReporter.setCommand({ + name: fullCommandName, + args: actionCommand.args, + options: actionCommand.opts(), + }); + }); +} diff --git a/src/cli/telemetry/consts.ts b/src/cli/telemetry/consts.ts new file mode 100644 index 00000000..8172cffd --- /dev/null +++ b/src/cli/telemetry/consts.ts @@ -0,0 +1,4 @@ +export const POSTHOG_API_KEY = "phc_VsHW5HxTzpORanESQh9A08tmZLQkKbtIBTYoQvRpPOp"; +export const TELEMETRY_DISABLED_ENV_VAR = "BASE44_DISABLE_TELEMETRY"; +export const POSTHOG_REQUEST_TIMEOUT_MS = 3000; +export const POSTHOG_SHUTDOWN_TIMEOUT_MS = 3000; diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts new file mode 100644 index 00000000..71084a32 --- /dev/null +++ b/src/cli/telemetry/error-reporter.ts @@ -0,0 +1,231 @@ +import { release, type } from "node:os"; +import { nanoid } from "nanoid"; +import { determineAgent } from "@vercel/detect-agent"; +import { getPostHogClient, shutdownPostHog, isTelemetryEnabled } from "./posthog.js"; +import packageJson from "../../../package.json"; + +/** + * User context from auth file. + */ +interface UserContext { + email: string; + name?: string; +} + +/** + * Command context from Commander. + */ +interface CommandContext { + name: string; + args: string[]; + options: Record; +} + +/** + * API error context for debugging Base44 API failures. + */ +interface ApiErrorContext { + statusCode?: number; + errorBody?: unknown; +} + +/** + * Agent context for AI agent detection. + */ +interface AgentContext { + isAgent: boolean; + name: string | null; +} + +/** + * Full context accumulated during CLI execution. + */ +interface ErrorReporterContext { + user?: UserContext; + command?: CommandContext; + session: { id: string; startedAt: Date }; + app?: { id: string }; + api?: ApiErrorContext; + agent?: AgentContext; + custom: Record; +} + +class ErrorReporter { + private context: ErrorReporterContext; + + constructor() { + this.context = { + session: { id: nanoid(12), startedAt: new Date() }, + custom: {}, + }; + + this.detectAgent(); + } + + private detectAgent(): void { + determineAgent() + .then((result) => { + this.context.agent = { + isAgent: result.isAgent, + name: result.isAgent ? result.agent.name : null, + }; + }) + .catch(() => { + // Ignore detection errors - agent info is optional + }); + } + + get sessionId(): string { + return this.context.session.id; + } + + setUser(user: UserContext): void { + this.context.user = user; + } + + setCommand(command: CommandContext): void { + this.context.command = command; + } + + setAppContext(appId: string): void { + this.context.app = { id: appId }; + } + + setApiError(statusCode: number, errorBody?: unknown): void { + this.context.api = { statusCode, errorBody }; + } + + setContext(key: string, value: unknown): void { + this.context.custom[key] = value; + } + + private getDistinctId(): string { + return this.context.user?.email || "anonymous-cli-user"; + } + + private buildProperties(): Record { + const executionDurationMs = Date.now() - this.context.session.startedAt.getTime(); + + return { + // Session context + session_id: this.context.session.id, + session_started_at: this.context.session.startedAt.toISOString(), + execution_duration_ms: executionDurationMs, + + // User context (also set via $set for person properties) + ...(this.context.user && { + $set: { + email: this.context.user.email, + name: this.context.user.name, + }, + }), + + // Command context + ...(this.context.command && { + command_name: this.context.command.name, + command_args: this.context.command.args, + command_options: this.context.command.options, + }), + + // App context + ...(this.context.app && { + app_id: this.context.app.id, + }), + + // API error context + ...(this.context.api && { + api_status_code: this.context.api.statusCode, + api_error_body: this.context.api.errorBody, + }), + + // System context + cli_version: packageJson.version, + node_version: process.version, + platform: process.platform, + arch: process.arch, + os_release: release(), + os_type: type(), + + // Environment context + is_tty: Boolean(process.stdout.isTTY), + cwd: process.cwd(), + + // Agent context + ...(this.context.agent && { + is_agent: this.context.agent.isAgent, + agent_name: this.context.agent.name, + }), + + // Custom context + ...this.context.custom, + }; + } + + displayErrorInfo(): void { + const info = [ + "", + "--- Error Details ---", + `Session: ${this.context.session.id}`, + `App ID: ${this.context.app?.id || "N/A"}`, + `Command: ${this.context.command?.name || "N/A"}`, + `CLI Version: ${packageJson.version}`, + `Time: ${new Date().toISOString()}`, + "---------------------", + "", + ]; + console.error(info.join("\n")); + } + + /** + * Capture an exception and report it to PostHog. + * Safe to call - never throws, logs errors to console. + */ + async captureException(error: Error): Promise { + if (!isTelemetryEnabled()) { + return; + } + + try { + const client = getPostHogClient(); + if (!client) { + return; + } + + client.captureException(error, this.getDistinctId(), this.buildProperties()); + + // Don't await flush - let shutdown handle it + } catch { + // Error during error reporting - silent + } + } + + /** + * Register process-level error handlers for uncaught exceptions. + * Should be called early in CLI startup. + */ + registerProcessErrorHandlers(): void { + const handleError = async (error: Error): Promise => { + this.displayErrorInfo(); + await this.captureException(error); + console.error(error); + await this.shutdown(); + process.exit(1); + }; + + process.on("uncaughtException", (error) => { + void handleError(error); + }); + + process.on("unhandledRejection", (reason) => { + const error = reason instanceof Error ? reason : new Error(String(reason)); + void handleError(error); + }); + } + + async shutdown(): Promise { + await shutdownPostHog(); + } +} + +// Singleton instance - created at module load +export const errorReporter = new ErrorReporter(); diff --git a/src/cli/telemetry/index.ts b/src/cli/telemetry/index.ts new file mode 100644 index 00000000..99d3415e --- /dev/null +++ b/src/cli/telemetry/index.ts @@ -0,0 +1,3 @@ +export { errorReporter } from "./error-reporter.js"; +export { addCommandInfoToErrorReporter } from "./commander-hooks.js"; +export { getPostHogClient, isTelemetryEnabled, shutdownPostHog } from "./posthog.js"; diff --git a/src/cli/telemetry/posthog.ts b/src/cli/telemetry/posthog.ts new file mode 100644 index 00000000..9a4dee6e --- /dev/null +++ b/src/cli/telemetry/posthog.ts @@ -0,0 +1,70 @@ +import { PostHog } from "posthog-node"; +import { + POSTHOG_API_KEY, + POSTHOG_REQUEST_TIMEOUT_MS, + POSTHOG_SHUTDOWN_TIMEOUT_MS, + TELEMETRY_DISABLED_ENV_VAR, +} from "./consts.js"; + +let client: PostHog | null = null; + +/** + * Check if telemetry/error reporting is enabled. + * Disabled via BASE44_DISABLE_TELEMETRY=1 + */ +export function isTelemetryEnabled(): boolean { + return process.env[TELEMETRY_DISABLED_ENV_VAR] !== "1"; +} + +/** + * Get or create the PostHog client singleton. + * Returns null if error reporting is disabled. + */ +export function getPostHogClient(): PostHog | null { + if (!isTelemetryEnabled()) { + return null; + } + + if (!client) { + try { + client = new PostHog(POSTHOG_API_KEY, { + host: "https://us.i.posthog.com", + // CLI settings: flush immediately since process may exit soon + flushAt: 1, + flushInterval: 0, + // Short timeout - don't block CLI on error path + requestTimeout: POSTHOG_REQUEST_TIMEOUT_MS, + }); + } catch (error) { + // Failed to create client - log and continue without error reporting + console.error("[PostHog] Failed to initialize client:", error); + return null; + } + } + + return client; +} + +export async function shutdownPostHog(): Promise { + if (!client) { + return; + } + + const clientToShutdown = client; + client = null; // Prevent further use + + try { + // Use Promise.race to enforce timeout on shutdown + await Promise.race([ + clientToShutdown.shutdown(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Shutdown timeout")), + POSTHOG_SHUTDOWN_TIMEOUT_MS + ) + ), + ]); + } catch { + // Ignore shutdown errors - don't block CLI exit + } +} diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index dfec88d7..f384236c 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -2,6 +2,7 @@ import { intro, log, outro } from "@clack/prompts"; import { isLoggedIn } from "@/core/auth/index.js"; import { initAppConfig } from "@/core/project/index.js"; import { CLIExitError } from "@/cli/errors.js"; +import { errorReporter } from "@/cli/telemetry/index.js"; import { printBanner } from "@/cli/utils/banner.js"; import { login } from "@/cli/commands/auth/login.js"; import { theme } from "@/cli/utils/theme.js"; @@ -87,7 +88,8 @@ export async function runCommand( // Initialize app config unless explicitly disabled if (options?.requireAppConfig !== false) { - await initAppConfig(); + const appConfig = await initAppConfig(); + errorReporter.setAppContext(appConfig.id); } const { outroMessage } = await commandFn(); diff --git a/src/core/project/app-config.ts b/src/core/project/app-config.ts index 6020e062..c27b97a9 100644 --- a/src/core/project/app-config.ts +++ b/src/core/project/app-config.ts @@ -37,17 +37,18 @@ function loadFromTestOverrides(): boolean { /** * Initialize app config by reading from .app.jsonc. - * Must be called before using getAppConfig(). + * Returns the cached config, reading from disk only on first call. + * @returns The app config with id and projectRoot * @throws Error if no project found or .app.jsonc missing */ -export async function initAppConfig(): Promise { +export async function initAppConfig(): Promise { // Check for test overrides first if (loadFromTestOverrides()) { - return; + return cache!; } if (cache) { - return; + return cache; } const projectRoot = await findProjectRoot(); @@ -65,6 +66,7 @@ export async function initAppConfig(): Promise { } cache = { projectRoot: projectRoot.root, id: config.id }; + return cache; } /** diff --git a/tests/cli/testkit/CLITestkit.ts b/tests/cli/testkit/CLITestkit.ts index 6addecd7..9c4962dc 100644 --- a/tests/cli/testkit/CLITestkit.ts +++ b/tests/cli/testkit/CLITestkit.ts @@ -13,7 +13,7 @@ const DIST_INDEX_PATH = join(__dirname, "../../../dist/index.js"); /** Type for the bundled program module */ interface ProgramModule { - program: Command; + createProgram: () => Command; CLIExitError: new (code: number) => Error & { code: number }; } @@ -32,7 +32,8 @@ export class CLITestkit { this.api = new Base44APIMock(appId); // Set HOME to temp dir for auth file isolation // Set CI to prevent browser opens during tests - this.env = { HOME: tempDir, CI: "true" }; + // Disable telemetry to prevent error reporting during tests + this.env = { HOME: tempDir, CI: "true", BASE44_DISABLE_TELEMETRY: "1" }; } /** Factory method - creates isolated test environment */ @@ -97,7 +98,8 @@ export class CLITestkit { this.api.apply(); // Dynamic import after vi.resetModules() to get fresh module instances - const { program, CLIExitError } = (await import(DIST_INDEX_PATH)) as ProgramModule; + const { createProgram, CLIExitError } = (await import(DIST_INDEX_PATH)) as ProgramModule; + const program = createProgram(); const buildResult = (exitCode: number): CLIResult => ({ stdout: stdout.join(""), @@ -145,17 +147,18 @@ export class CLITestkit { } /** Save original values of env vars we're about to modify */ - private captureEnvSnapshot(): { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string } { + private captureEnvSnapshot(): { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string } { return { HOME: process.env.HOME, BASE44_CLI_TEST_OVERRIDES: process.env.BASE44_CLI_TEST_OVERRIDES, CI: process.env.CI, + BASE44_DISABLE_TELEMETRY: process.env.BASE44_DISABLE_TELEMETRY, }; } /** Restore env vars to their original values (or delete if they didn't exist) */ - private restoreEnvSnapshot(snapshot: { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string }): void { - for (const key of ["HOME", "BASE44_CLI_TEST_OVERRIDES", "CI"] as const) { + private restoreEnvSnapshot(snapshot: { HOME?: string; BASE44_CLI_TEST_OVERRIDES?: string; CI?: string; BASE44_DISABLE_TELEMETRY?: string }): void { + for (const key of ["HOME", "BASE44_CLI_TEST_OVERRIDES", "CI", "BASE44_DISABLE_TELEMETRY"] as const) { if (snapshot[key] === undefined) { delete process.env[key]; } else { diff --git a/tests/error-reporter.test.ts b/tests/error-reporter.test.ts deleted file mode 100644 index 17d9c9e7..00000000 --- a/tests/error-reporter.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { ErrorReporter } from "../src/cli/error-reporter.js"; - -describe("ErrorReporter", () => { - let reporter: ErrorReporter; - - beforeEach(() => { - reporter = new ErrorReporter(); - }); - - it("should be disabled by default", () => { - expect(reporter.enabled).toBe(false); - }); - - it("should not throw when capturing exception without initialization", async () => { - const error = new Error("Test error"); - await expect(reporter.captureException(error)).resolves.not.toThrow(); - }); - - it("should not throw when shutting down without initialization", async () => { - await expect(reporter.shutdown()).resolves.not.toThrow(); - }); - - it("should warn when initialized without API key", () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - reporter.initialize(""); - expect(consoleWarnSpy).toHaveBeenCalledWith( - "PostHog API key not provided. Error reporting disabled." - ); - expect(reporter.enabled).toBe(false); - consoleWarnSpy.mockRestore(); - }); - - it("should handle multiple shutdown calls gracefully", async () => { - reporter.initialize("test-key"); - const shutdown1 = reporter.shutdown(); - const shutdown2 = reporter.shutdown(); - await expect(Promise.all([shutdown1, shutdown2])).resolves.not.toThrow(); - }); -}); From 36f26577dfb9aebd5ea4888e22baf5e7448f8a9f Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 29 Jan 2026 17:49:21 +0200 Subject: [PATCH 03/13] fix error bubbling --- src/cli/program.ts | 7 ++++-- src/cli/utils/runCommand.ts | 42 +++++++++++---------------------- tests/cli/testkit/CLITestkit.ts | 9 ++++--- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index 44e88b93..abc6fae1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -71,13 +71,16 @@ export async function runCLI(program: Command): Promise { try { await program.parseAsync(); } catch (error) { - // CLIExitError = controlled exit, don't report + // CLIExitError = controlled exit (e.g., user cancellation), don't report if (!(error instanceof CLIExitError)) { + // Display error + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + + // Report to PostHog and display session info for support errorReporter.displayErrorInfo(); await errorReporter.captureException( error instanceof Error ? error : new Error(String(error)) ); - console.error(error); } await errorReporter.shutdown(); diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index f384236c..4c9908bc 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -1,7 +1,6 @@ import { intro, log, outro } from "@clack/prompts"; import { isLoggedIn } from "@/core/auth/index.js"; import { initAppConfig } from "@/core/project/index.js"; -import { CLIExitError } from "@/cli/errors.js"; import { errorReporter } from "@/cli/telemetry/index.js"; import { printBanner } from "@/cli/utils/banner.js"; import { login } from "@/cli/commands/auth/login.js"; @@ -75,35 +74,22 @@ export async function runCommand( intro(theme.colors.base44OrangeBackground(" Base 44 ")); } - try { - // Check authentication if required - if (options?.requireAuth) { - const loggedIn = await isLoggedIn(); + // Check authentication if required + if (options?.requireAuth) { + const loggedIn = await isLoggedIn(); - if (!loggedIn) { - log.info("You need to login first to continue."); - await login(); - } - } - - // Initialize app config unless explicitly disabled - if (options?.requireAppConfig !== false) { - const appConfig = await initAppConfig(); - errorReporter.setAppContext(appConfig.id); + if (!loggedIn) { + log.info("You need to login first to continue."); + await login(); } + } - const { outroMessage } = await commandFn(); - outro(outroMessage || ""); - } catch (e) { - // Pass through CLIExitError without logging (intentional exits, e.g., user cancellation) - if (e instanceof CLIExitError) { - throw e; - } - if (e instanceof Error) { - log.error(e.stack ?? e.message); - } else { - log.error(String(e)); - } - throw new CLIExitError(1); + // Initialize app config unless explicitly disabled + if (options?.requireAppConfig !== false) { + const appConfig = await initAppConfig(); + errorReporter.setAppContext(appConfig.id); } + + const { outroMessage } = await commandFn(); + outro(outroMessage || ""); } diff --git a/tests/cli/testkit/CLITestkit.ts b/tests/cli/testkit/CLITestkit.ts index 9c4962dc..f6b8dec4 100644 --- a/tests/cli/testkit/CLITestkit.ts +++ b/tests/cli/testkit/CLITestkit.ts @@ -114,10 +114,13 @@ export class CLITestkit { // process.exit() was called - our mock throws after capturing the code // This catches Commander's exits for --help, --version, unknown options if (exitState.code !== null) { return buildResult(exitState.code); } - // CLI's clean exit mechanism (thrown by runCommand on errors) + // CLI's clean exit mechanism (user cancellation, etc.) if (e instanceof CLIExitError) { return buildResult(e.code); } - // Unexpected error - let it bubble up - throw e; + // Any other error = command failed with exit code 1 + // Capture error message in stderr for test assertions + const errorMessage = e instanceof Error ? (e.stack ?? e.message) : String(e); + stderr.push(errorMessage); + return buildResult(1); } finally { // Restore process.exit process.exit = originalExit; From d37ea7ce137edeff202d376b262e2d04d95db9c3 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Thu, 29 Jan 2026 19:10:57 +0200 Subject: [PATCH 04/13] fixes for error handlers --- .node-version | 2 +- package-lock.json | 134 ++++------------------------ package.json | 4 +- src/cli/program.ts | 6 +- src/cli/telemetry/consts.ts | 4 +- src/cli/telemetry/error-reporter.ts | 2 - src/cli/utils/runCommand.ts | 34 ++++--- tsdown.config.mjs | 1 + 8 files changed, 44 insertions(+), 143 deletions(-) diff --git a/.node-version b/.node-version index 5bd68117..586e275e 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.19.0 +20.20.0 diff --git a/package-lock.json b/package-lock.json index dbd604ff..088b2be6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", - "posthog-node": "^4.2.1", + "posthog-node": "^5.24.5", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", @@ -49,7 +49,7 @@ "zod": "^4.3.5" }, "engines": { - "node": ">=20.19.0" + "node": ">=20.20.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.56.0" @@ -647,6 +647,16 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@posthog/core": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.15.0.tgz", + "integrity": "sha512-n2/Yy0+qc8xhmlcOFiYqTcGHBZuuaQjVolfFXk7yTCynzdMe8Fx1zYvPPUrbdQK5tWwXyilkzybpqhK6I7aV4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, "node_modules/@quansync/fs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", @@ -1782,13 +1792,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1805,18 +1808,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2202,19 +2193,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2440,16 +2418,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/diff": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", @@ -3408,27 +3376,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3445,23 +3392,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/front-matter": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", @@ -4696,29 +4626,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5279,16 +5186,16 @@ } }, "node_modules/posthog-node": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.18.0.tgz", - "integrity": "sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.24.5.tgz", + "integrity": "sha512-FCo0k7OJPYohXIVD2yLuyHSm7nGN+kHIGLnOalDIZYhhsojy9XQXADqq6i+3emcr8wz6Vc0n4IkmLkCFa0Uoyw==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.8.2" + "@posthog/core": "1.15.0" }, "engines": { - "node": ">=15.0.0" + "node": "^20.20.0 || >=22.22.0" } }, "node_modules/powershell-utils": { @@ -5330,13 +5237,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 4d0254ea..a26542a0 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", - "posthog-node": "^4.2.1", + "posthog-node": "^5.24.5", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", @@ -69,7 +69,7 @@ "zod": "^4.3.5" }, "engines": { - "node": ">=20.19.0" + "node": ">=20.20.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.56.0" diff --git a/src/cli/program.ts b/src/cli/program.ts index abc6fae1..44b8bd47 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -73,10 +73,7 @@ export async function runCLI(program: Command): Promise { } catch (error) { // CLIExitError = controlled exit (e.g., user cancellation), don't report if (!(error instanceof CLIExitError)) { - // Display error - console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); - - // Report to PostHog and display session info for support + // Display session info for support and report to PostHog errorReporter.displayErrorInfo(); await errorReporter.captureException( error instanceof Error ? error : new Error(String(error)) @@ -87,6 +84,5 @@ export async function runCLI(program: Command): Promise { process.exit(error instanceof CLIExitError ? error.code : 1); } - // Normal exit await errorReporter.shutdown(); } diff --git a/src/cli/telemetry/consts.ts b/src/cli/telemetry/consts.ts index 8172cffd..01381435 100644 --- a/src/cli/telemetry/consts.ts +++ b/src/cli/telemetry/consts.ts @@ -1,4 +1,4 @@ export const POSTHOG_API_KEY = "phc_VsHW5HxTzpORanESQh9A08tmZLQkKbtIBTYoQvRpPOp"; export const TELEMETRY_DISABLED_ENV_VAR = "BASE44_DISABLE_TELEMETRY"; -export const POSTHOG_REQUEST_TIMEOUT_MS = 3000; -export const POSTHOG_SHUTDOWN_TIMEOUT_MS = 3000; +export const POSTHOG_REQUEST_TIMEOUT_MS = 1000; +export const POSTHOG_SHUTDOWN_TIMEOUT_MS = 1000; diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts index 71084a32..9fa89863 100644 --- a/src/cli/telemetry/error-reporter.ts +++ b/src/cli/telemetry/error-reporter.ts @@ -192,8 +192,6 @@ class ErrorReporter { } client.captureException(error, this.getDistinctId(), this.buildProperties()); - - // Don't await flush - let shutdown handle it } catch { // Error during error reporting - silent } diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 4c9908bc..95420b19 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -74,22 +74,28 @@ export async function runCommand( intro(theme.colors.base44OrangeBackground(" Base 44 ")); } - // Check authentication if required - if (options?.requireAuth) { - const loggedIn = await isLoggedIn(); + try { + // Check authentication if required + if (options?.requireAuth) { + const loggedIn = await isLoggedIn(); - if (!loggedIn) { - log.info("You need to login first to continue."); - await login(); + if (!loggedIn) { + log.info("You need to login first to continue."); + await login(); + } } - } - // Initialize app config unless explicitly disabled - if (options?.requireAppConfig !== false) { - const appConfig = await initAppConfig(); - errorReporter.setAppContext(appConfig.id); - } + // Initialize app config unless explicitly disabled + if (options?.requireAppConfig !== false) { + const appConfig = await initAppConfig(); + errorReporter.setAppContext(appConfig.id); + } - const { outroMessage } = await commandFn(); - outro(outroMessage || ""); + const { outroMessage } = await commandFn(); + outro(outroMessage || ""); + } catch (error) { + // Display error with nice formatting, then re-throw for runCLI to handle + log.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + throw error; + } } diff --git a/tsdown.config.mjs b/tsdown.config.mjs index 6cf3a5e1..5a010d24 100644 --- a/tsdown.config.mjs +++ b/tsdown.config.mjs @@ -8,4 +8,5 @@ export default defineConfig({ clean: true, tsconfig: "tsconfig.json", copy: ["templates"], + sourcemap: "inline", // Include inline sourcemaps for readable stack traces }); From e3224352862fb7876288417e29cfad98c5292e44 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Fri, 30 Jan 2026 19:50:04 +0200 Subject: [PATCH 05/13] error reporting fixes --- bin/run.js | 2 +- src/cli/index.ts | 39 ++++++++++++++++++++++++++--- src/cli/program.ts | 35 -------------------------- src/cli/telemetry/error-reporter.ts | 23 ++++++++--------- src/cli/telemetry/posthog.ts | 13 ++-------- 5 files changed, 49 insertions(+), 63 deletions(-) diff --git a/bin/run.js b/bin/run.js index 5e2f2335..4e3da003 100755 --- a/bin/run.js +++ b/bin/run.js @@ -8,4 +8,4 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { } const program = createProgram(); -await runCLI(program); +runCLI(program); diff --git a/src/cli/index.ts b/src/cli/index.ts index fa00446b..6104e326 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,36 @@ -export { createProgram, runCLI } from "./program.js"; -export { CLIExitError } from "./errors.js"; -export { errorReporter } from "./telemetry/index.js"; +import type { Command } from "commander"; +import { CLIExitError } from "./errors.js"; +import { errorReporter, addCommandInfoToErrorReporter } from "./telemetry/index.js"; +import { readAuth } from "@/core/auth/index.js"; +import { createProgram } from "@/cli/program.js"; + +async function runCLI(program: Command): Promise { + // Register process error handlers FIRST + errorReporter.registerProcessErrorHandlers(); + + try { + const userInfo = await readAuth(); + errorReporter.setUser({ email: userInfo.email, name: userInfo.name }); + } catch { + // User info is optional context + } + + addCommandInfoToErrorReporter(program); + + try { + await program.parseAsync(); + } catch (error) { + // CLIExitError = controlled exit (e.g., user cancellation), don't report + if (!(error instanceof CLIExitError)) { + errorReporter.displayErrorInfo(); + errorReporter.captureException( + error instanceof Error ? error : new Error(String(error)) + ); + } + + // Use exitCode instead of exit() to let event loop drain + process.exitCode = error instanceof CLIExitError ? error.code : 1; + } +} + +export { runCLI, createProgram, CLIExitError }; \ No newline at end of file diff --git a/src/cli/program.ts b/src/cli/program.ts index 44b8bd47..447bdf6e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,9 +10,6 @@ import { dashboardCommand } from "@/cli/commands/project/dashboard.js"; import { deployCommand } from "@/cli/commands/project/deploy.js"; import { linkCommand } from "@/cli/commands/project/link.js"; import { siteDeployCommand } from "@/cli/commands/site/deploy.js"; -import { CLIExitError } from "@/cli/errors.js"; -import { errorReporter, addCommandInfoToErrorReporter } from "@/cli/telemetry/index.js"; -import { readAuth } from "@/core/auth/index.js"; import packageJson from "../../package.json"; export function createProgram(): Command { @@ -54,35 +51,3 @@ export function createProgram(): Command { return program; } - -export async function runCLI(program: Command): Promise { - // Register process error handlers FIRST, do not add more things before this line - errorReporter.registerProcessErrorHandlers(); - - try { - const userInfo = await readAuth(); - errorReporter.setUser({ email: userInfo.email, name: userInfo.name }); - } catch { - // Ignore - user info is optional context - } - - addCommandInfoToErrorReporter(program); - - try { - await program.parseAsync(); - } catch (error) { - // CLIExitError = controlled exit (e.g., user cancellation), don't report - if (!(error instanceof CLIExitError)) { - // Display session info for support and report to PostHog - errorReporter.displayErrorInfo(); - await errorReporter.captureException( - error instanceof Error ? error : new Error(String(error)) - ); - } - - await errorReporter.shutdown(); - process.exit(error instanceof CLIExitError ? error.code : 1); - } - - await errorReporter.shutdown(); -} diff --git a/src/cli/telemetry/error-reporter.ts b/src/cli/telemetry/error-reporter.ts index 9fa89863..69f1cde0 100644 --- a/src/cli/telemetry/error-reporter.ts +++ b/src/cli/telemetry/error-reporter.ts @@ -1,7 +1,7 @@ import { release, type } from "node:os"; import { nanoid } from "nanoid"; import { determineAgent } from "@vercel/detect-agent"; -import { getPostHogClient, shutdownPostHog, isTelemetryEnabled } from "./posthog.js"; +import { getPostHogClient, isTelemetryEnabled } from "./posthog.js"; import packageJson from "../../../package.json"; /** @@ -180,7 +180,7 @@ class ErrorReporter { * Capture an exception and report it to PostHog. * Safe to call - never throws, logs errors to console. */ - async captureException(error: Error): Promise { + captureException(error: Error) { if (!isTelemetryEnabled()) { return; } @@ -193,7 +193,7 @@ class ErrorReporter { client.captureException(error, this.getDistinctId(), this.buildProperties()); } catch { - // Error during error reporting - silent + // Silent - don't let error reporting break the CLI } } @@ -202,27 +202,24 @@ class ErrorReporter { * Should be called early in CLI startup. */ registerProcessErrorHandlers(): void { - const handleError = async (error: Error): Promise => { + const handleError = (error: Error): void => { this.displayErrorInfo(); - await this.captureException(error); + // Fire-and-forget: captureException queues the event, PostHog flushes immediately + this.captureException(error); console.error(error); - await this.shutdown(); - process.exit(1); + // Use exitCode instead of exit() to let event loop drain and allow pending requests to complete + process.exitCode = 1; }; process.on("uncaughtException", (error) => { - void handleError(error); + handleError(error); }); process.on("unhandledRejection", (reason) => { const error = reason instanceof Error ? reason : new Error(String(reason)); - void handleError(error); + handleError(error); }); } - - async shutdown(): Promise { - await shutdownPostHog(); - } } // Singleton instance - created at module load diff --git a/src/cli/telemetry/posthog.ts b/src/cli/telemetry/posthog.ts index 9a4dee6e..3a0eee22 100644 --- a/src/cli/telemetry/posthog.ts +++ b/src/cli/telemetry/posthog.ts @@ -54,17 +54,8 @@ export async function shutdownPostHog(): Promise { client = null; // Prevent further use try { - // Use Promise.race to enforce timeout on shutdown - await Promise.race([ - clientToShutdown.shutdown(), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Shutdown timeout")), - POSTHOG_SHUTDOWN_TIMEOUT_MS - ) - ), - ]); + await clientToShutdown.shutdown(POSTHOG_SHUTDOWN_TIMEOUT_MS); } catch { - // Ignore shutdown errors - don't block CLI exit + // Silent - don't let shutdown errors block CLI exit } } From 627f28a8cd61e1da1f1395c93b94b15143495858 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Fri, 30 Jan 2026 21:23:50 +0200 Subject: [PATCH 06/13] Added await --- bin/run.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/run.js b/bin/run.js index 4e3da003..5e2f2335 100755 --- a/bin/run.js +++ b/bin/run.js @@ -8,4 +8,4 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { } const program = createProgram(); -runCLI(program); +await runCLI(program); From 28018ef8e238fa16d7bf4fef1dc5a1e38f106247 Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Fri, 30 Jan 2026 21:25:38 +0200 Subject: [PATCH 07/13] Remove posthog error log --- src/cli/telemetry/posthog.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/telemetry/posthog.ts b/src/cli/telemetry/posthog.ts index 3a0eee22..ecd46c11 100644 --- a/src/cli/telemetry/posthog.ts +++ b/src/cli/telemetry/posthog.ts @@ -35,9 +35,8 @@ export function getPostHogClient(): PostHog | null { // Short timeout - don't block CLI on error path requestTimeout: POSTHOG_REQUEST_TIMEOUT_MS, }); - } catch (error) { + } catch { // Failed to create client - log and continue without error reporting - console.error("[PostHog] Failed to initialize client:", error); return null; } } From 31c82b4405bda63c78f730226c38f1dc4f01c64b Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Fri, 30 Jan 2026 21:35:40 +0200 Subject: [PATCH 08/13] remove breaking node version change --- .node-version | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.node-version b/.node-version index 586e275e..5bd68117 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.20.0 +20.19.0 diff --git a/package-lock.json b/package-lock.json index 088b2be6..866013c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", - "posthog-node": "^5.24.5", + "posthog-node": "^5.21.2", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", diff --git a/package.json b/package.json index a26542a0..fd5c96e1 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "nanoid": "^5.1.6", "open": "^11.0.0", "p-wait-for": "^6.0.0", - "posthog-node": "^5.24.5", + "posthog-node": "^5.21.2", "strip-ansi": "^7.1.2", "tar": "^7.5.4", "tmp-promise": "^3.0.3", From 0248a0dc68616b693fd974b074e391ac35729d4b Mon Sep 17 00:00:00 2001 From: Kfir Strikovsky Date: Fri, 30 Jan 2026 21:02:26 +0200 Subject: [PATCH 09/13] many error updates --- AGENTS.md | 162 ++++++++++++++- src/cli/commands/auth/login.ts | 13 +- src/cli/commands/functions/deploy.ts | 8 +- src/cli/commands/project/create.ts | 10 +- src/cli/commands/project/link.ts | 22 +- src/cli/commands/site/deploy.ts | 10 +- src/cli/index.ts | 45 ++-- src/cli/telemetry/commander-hooks.ts | 1 - src/cli/telemetry/error-reporter.ts | 23 ++- src/cli/utils/runCommand.ts | 29 ++- src/core/auth/api.ts | 43 ++-- src/core/auth/config.ts | 39 ++-- src/core/errors.ts | 283 +++++++++++++++++++++++++- src/core/project/api.ts | 17 +- src/core/project/app-config.ts | 22 +- src/core/project/config.ts | 5 +- src/core/project/create.ts | 3 +- src/core/project/template.ts | 10 +- src/core/resources/agent/api.ts | 25 ++- src/core/resources/agent/config.ts | 9 +- src/core/resources/entity/api.ts | 18 +- src/core/resources/entity/config.ts | 9 +- src/core/resources/function/api.ts | 10 +- src/core/resources/function/config.ts | 11 +- src/core/site/api.ts | 9 +- src/core/site/deploy.ts | 19 +- src/core/utils/fs.ts | 26 ++- tests/core/errors.spec.ts | 161 +++++++++++++++ 28 files changed, 900 insertions(+), 142 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb6f8b59..c5cf38a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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() factory + runCLI() @@ -393,6 +393,156 @@ 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 Display + +When an error is thrown, the CLI displays: + +1. **Error message** - The main error text with stack trace +2. **Next Steps** (if hints exist) - Actionable suggestions for fixing the issue +3. **Error Details** - Session ID, error code, app ID, command, CLI version, timestamp + +Example output: +``` +■ Error: No Base44 project found in this directory + at initAppConfig (...) + +--- Next Steps --- + → Run 'base44 create' to create a new project + → Or run 'base44 link' to link an existing project +------------------ + +--- Error Details --- +Session: abc123xyz +Code: CONFIG_NOT_FOUND +App ID: N/A +Command: deploy +CLI Version: 0.0.25 +Time: 2026-01-29T12:00:00.000Z +--------------------- +``` + +### 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) +``` + ## Important Rules 1. **npm only** - Never use yarn @@ -407,7 +557,9 @@ 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. **Use structured errors** - Never `throw new Error()`; use specific error classes from `@/core/errors.js` with appropriate hints +15. **SchemaValidationError requires ZodError** - Always pass `ZodError`: `new SchemaValidationError("context", result.error)` - don't call `z.prettifyError()` manually ## Development @@ -441,10 +593,12 @@ The CLI uses a split architecture for better development experience: - `index.ts` - Barrel export for entry points **Error Handling Flow**: -- Commands throw errors → `runCommand()` catches, logs, and throws `CLIExitError(1)` -- `runCLI()` catches errors, reports to PostHog (if not CLIExitError), and calls `process.exit(code)` +- Commands throw structured errors (`CLIError` subclasses) with hints +- `runCommand()` catches errors, displays them with `log.error()`, shows hints if available, then re-throws +- `runCLI()` catches errors, displays error details (session, code, etc.), reports to PostHog (if not CLIExitError), sets exit code - Entry points (`bin/run.js`, `bin/dev.js`) simply call `createProgram()` and `runCLI(program)` - Telemetry can be disabled via `BASE44_DISABLE_TELEMETRY=1` environment variable +- Telemetry includes `error_code` and `is_user_error` properties for all errors ### Node.js Version diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index f15ffbd9..0ba9c0bb 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -12,6 +12,7 @@ import type { TokenResponse, UserInfoResponse, } from "@/core/auth/index.js"; +import { AuthRequiredError } from "@/core/errors.js"; import { runCommand, runTask } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; import { theme } from "@/cli/utils/theme.js"; @@ -69,13 +70,21 @@ async function waitForAuthentication( ); } catch (error) { if (error instanceof Error && error.message.includes("timed out")) { - throw new Error("Authentication timed out. Please try again."); + throw new AuthRequiredError("Authentication timed out. Please try again.", { + hints: [ + { message: "Run 'base44 login' to try again", command: "base44 login" }, + ], + }); } throw error; } if (tokenResponse === undefined) { - throw new Error("Failed to retrieve authentication token."); + throw new AuthRequiredError("Failed to retrieve authentication token.", { + hints: [ + { message: "Run 'base44 login' to try again", command: "base44 login" }, + ], + }); } return tokenResponse; diff --git a/src/cli/commands/functions/deploy.ts b/src/cli/commands/functions/deploy.ts index 4965a48c..3e43f28d 100644 --- a/src/cli/commands/functions/deploy.ts +++ b/src/cli/commands/functions/deploy.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { log } from "@clack/prompts"; 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"; @@ -40,7 +41,12 @@ async function deployFunctionsAction(): Promise { 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" }; diff --git a/src/cli/commands/project/create.ts b/src/cli/commands/project/create.ts index acc22218..4beb9248 100644 --- a/src/cli/commands/project/create.ts +++ b/src/cli/commands/project/create.ts @@ -7,6 +7,7 @@ import kebabCase from "lodash.kebabcase"; 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, @@ -31,7 +32,14 @@ async function getTemplateById(templateId: string): Promise