diff --git a/.changeset/improve-error-handling.md b/.changeset/improve-error-handling.md new file mode 100644 index 0000000..dc72515 --- /dev/null +++ b/.changeset/improve-error-handling.md @@ -0,0 +1,5 @@ +--- +"@alchemy/cli": patch +--- + +Improve error messages for unknown commands, invalid networks, RPC failures, and access denied responses so users get actionable guidance instead of raw errors. diff --git a/src/commands/block.ts b/src/commands/block.ts index f6436a9..5fab6e8 100644 --- a/src/commands/block.ts +++ b/src/commands/block.ts @@ -41,6 +41,9 @@ Examples: "block must be a number, hex, or 'latest'", ); } + if (num < 0) { + throw errInvalidArgs("Block number must be non-negative."); + } blockParam = `0x${num.toString(16)}`; } diff --git a/src/index.ts b/src/index.ts index d6a843f..990cb5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { Command, Help } from "commander"; -import { EXIT_CODES, errSetupRequired, exitWithError } from "./lib/errors.js"; +import { EXIT_CODES, errInvalidArgs, errSetupRequired, exitWithError } from "./lib/errors.js"; import { setFlags, isJSONMode, quiet } from "./lib/output.js"; import { formatCommanderError } from "./lib/error-format.js"; import { load as loadConfig } from "./lib/config.js"; @@ -155,6 +155,7 @@ program .option("--debug", "Enable debug diagnostics") .option("--no-interactive", "Disable REPL and prompt-driven interactions") .addHelpCommand(false) + .allowExcessArguments(true) .exitOverride((err) => { if ( err.code === "commander.help" || @@ -335,7 +336,18 @@ program } resetUpdateNoticeState(); }) - .action(async () => { + .action(async (_opts: unknown, cmd: Command) => { + // Commander routes here when no subcommand matches. If the user passed + // positional args (e.g. "alchemy abcd"), those are unknown commands. + const excessArgs = cmd.args; + if (excessArgs.length > 0) { + exitWithError( + errInvalidArgs( + `Unknown command '${excessArgs[0]}'. Run 'alchemy help' for available commands.`, + ), + ); + } + const cfg = loadConfig(); if (!isSetupComplete(cfg) && !isInteractiveAllowed(program)) { throw errSetupRequired(getSetupStatus(cfg)); diff --git a/src/lib/admin-client.ts b/src/lib/admin-client.ts index d7d7e3d..817c3dc 100644 --- a/src/lib/admin-client.ts +++ b/src/lib/admin-client.ts @@ -1,5 +1,6 @@ import { errInvalidAccessKey, + errAccessDenied, errNotFound, errRateLimited, errAdminAPI, @@ -128,7 +129,19 @@ export class AdminClient { ...(body !== undefined && { body: JSON.stringify(body) }), }); - if (resp.status === 401 || resp.status === 403) throw errInvalidAccessKey(); + if (resp.status === 401) throw errInvalidAccessKey(); + if (resp.status === 403) { + const detail = await resp.text().catch(() => ""); + // Try to extract a reason from the response body + let reason: string | undefined; + try { + const parsed = JSON.parse(detail); + reason = parsed?.message || parsed?.error?.message || parsed?.error || undefined; + } catch { + reason = detail || undefined; + } + throw errAccessDenied(typeof reason === "string" ? reason : undefined); + } if (resp.status === 404) { const text = await resp.text().catch(() => ""); throw errNotFound(text || path); diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index aa9a5b4..999ad3a 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -1,4 +1,4 @@ -import { errInvalidArgs, errNetwork } from "./errors.js"; +import { CLIError, errInvalidArgs, errNetwork } from "./errors.js"; import { timeout as globalTimeout } from "./output.js"; export function isLocalhost(hostname: string): boolean { @@ -50,6 +50,26 @@ export async function fetchWithTimeout( if (err instanceof DOMException && err.name === "TimeoutError") { throw errNetwork(`Request timed out after ${globalTimeout}ms`); } - throw errNetwork((err as Error).message); + const message = (err as Error).message ?? String(err); + // Node's fetch wraps DNS errors in a TypeError with the detail in .cause + const causeMessage = (err as { cause?: { message?: string } }).cause?.message ?? ""; + const causeCode = (err as { cause?: { code?: string } }).cause?.code ?? ""; + const fullErrorText = `${message} ${causeMessage} ${causeCode}`; + // Detect DNS resolution failures — typically caused by an invalid network slug + if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(fullErrorText)) { + // Extract the hostname from the URL for a clearer error message + try { + const hostname = new URL(url).hostname; + const networkSlug = hostname.replace(/\.g\.alchemy\.com$/, ""); + if (networkSlug !== hostname) { + throw errInvalidArgs( + `Unknown network '${networkSlug}'. Run 'alchemy network list' to see available networks.`, + ); + } + } catch (innerErr) { + if (innerErr instanceof CLIError) throw innerErr; + } + } + throw errNetwork(message); } } diff --git a/src/lib/client.ts b/src/lib/client.ts index b52dae5..c2343a1 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -48,10 +48,14 @@ export class Client implements AlchemyClient { try { parsed = new URL(`https://${hostname}`); } catch { - throw errInvalidArgs(`Invalid network: ${network}`); + throw errInvalidArgs( + `Unknown network '${network}'. Run 'alchemy network list' to see available networks.`, + ); } if (!parsed.hostname.endsWith(".g.alchemy.com")) { - throw errInvalidArgs(`Invalid network: ${network} — hostname must end with .g.alchemy.com`); + throw errInvalidArgs( + `Unknown network '${network}'. Run 'alchemy network list' to see available networks.`, + ); } } @@ -87,6 +91,18 @@ export class Client implements AlchemyClient { return errInvalidAPIKey(detail || undefined); } + private tryParseRPCError(text: string): CLIError | null { + try { + const parsed = JSON.parse(text); + if (parsed?.error?.code !== undefined && parsed?.error?.message !== undefined) { + return errRPC(parsed.error.code, parsed.error.message); + } + } catch { + // Not JSON — fall through + } + return null; + } + private async doFetch(url: string, init: RequestInit): Promise { return fetchWithTimeout(url, init); } @@ -116,6 +132,9 @@ export class Client implements AlchemyClient { if (!resp.ok) { const text = await resp.text().catch(() => ""); + // Try to parse as JSON-RPC error before falling back to network error + const rpcError = this.tryParseRPCError(text); + if (rpcError) throw rpcError; throw errNetwork(`HTTP ${resp.status}: ${text}`); } @@ -149,6 +168,8 @@ export class Client implements AlchemyClient { if (!resp.ok) { const text = await resp.text().catch(() => ""); + const rpcError = this.tryParseRPCError(text); + if (rpcError) throw rpcError; throw errNetwork(`HTTP ${resp.status}: ${text}`); } diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c1c402d..8011db6 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -130,8 +130,17 @@ export function errNetwork(detail: string): CLIError { ); } +const RPC_ERROR_HINTS: Record = { + [-32700]: "Parse error. The request JSON is malformed.", + [-32600]: "Invalid request. Check the JSON-RPC request format.", + [-32601]: "Method not supported. Check the method name and ensure your plan supports it.", + [-32602]: "Invalid parameters. Check argument types and format.", + [-32603]: "Internal JSON-RPC error.", +}; + export function errRPC(code: number, message: string): CLIError { - return new CLIError(ErrorCode.RPC_ERROR, `RPC error ${code}: ${message}`); + const hint = RPC_ERROR_HINTS[code]; + return new CLIError(ErrorCode.RPC_ERROR, `RPC error ${code}: ${message}`, hint); } export function errInvalidArgs(detail: string): CLIError { @@ -158,6 +167,17 @@ export function errInvalidAccessKey(): CLIError { ); } +export function errAccessDenied(detail?: string): CLIError { + const message = detail + ? `Access denied: ${detail}` + : "Access denied. Your access key may not have permission for this operation."; + return new CLIError( + ErrorCode.INVALID_ACCESS_KEY, + message, + "Check your account tier and feature access at https://dashboard.alchemy.com/", + ); +} + export function errAppRequired(): CLIError { return new CLIError( ErrorCode.APP_REQUIRED, diff --git a/tests/commands/block.test.ts b/tests/commands/block.test.ts index b52d0b5..7d484cd 100644 --- a/tests/commands/block.test.ts +++ b/tests/commands/block.test.ts @@ -69,6 +69,40 @@ describe("block command", () => { expect(exitWithError).toHaveBeenCalledTimes(1); }); + it("block -1 rejects negative block numbers", async () => { + const exitWithError = vi.fn(); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: vi.fn(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + verbose: false, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + bold: (s: string) => s, + dim: (s: string) => s, + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + printSyntaxJSON: vi.fn(), + })); + vi.doMock("../../src/lib/block-format.js", () => ({ + formatBlockTimestamp: vi.fn(), + formatHexQuantity: vi.fn(), + formatGasSummary: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); + + const { registerBlock } = await import("../../src/commands/block.js"); + const program = new Command(); + registerBlock(program); + + await program.parseAsync(["node", "test", "block", "-1"], { from: "node" }); + expect(exitWithError).toHaveBeenCalledTimes(1); + const err = exitWithError.mock.calls[0][0]; + expect(err.message).toContain("non-negative"); + }); + it("block latest prints JSON payload in json mode", async () => { const call = vi.fn().mockResolvedValue({ number: "0x10", hash: "0xhash" }); const printJSON = vi.fn(); diff --git a/tests/lib/admin-client.test.ts b/tests/lib/admin-client.test.ts index 290bc2e..e7eb973 100644 --- a/tests/lib/admin-client.test.ts +++ b/tests/lib/admin-client.test.ts @@ -335,7 +335,7 @@ describe("AdminClient", () => { } }); - it("throws INVALID_ACCESS_KEY on 403", async () => { + it("throws INVALID_ACCESS_KEY on 403 with access denied message", async () => { const url = await createTestServer((_req, res) => { res.writeHead(403); res.end(); @@ -348,6 +348,24 @@ describe("AdminClient", () => { } catch (err) { expect(err).toBeInstanceOf(CLIError); expect((err as CLIError).code).toBe(ErrorCode.INVALID_ACCESS_KEY); + expect((err as CLIError).message).toContain("Access denied"); + } + }); + + it("includes reason from 403 response body", async () => { + const url = await createTestServer((_req, res) => { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: "Feature not available on your current plan" })); + }); + + const client = new TestAdminClient(url); + try { + await client.listApps(); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CLIError); + expect((err as CLIError).code).toBe(ErrorCode.INVALID_ACCESS_KEY); + expect((err as CLIError).message).toContain("Feature not available"); } }); diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index cf4607a..09979f1 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -150,6 +150,46 @@ describe("Client.call", () => { } }); + it("throws RPC error when HTTP error body contains JSON-RPC error", async () => { + const url = await createTestServer((_req, res) => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32601, message: "The method fake_method does not exist" }, + id: 1, + }), + ); + }); + + const client = new TestClient(url); + try { + await client.call("fake_method"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CLIError); + expect((err as CLIError).code).toBe(ErrorCode.RPC_ERROR); + expect((err as CLIError).message).toContain("-32601"); + expect((err as CLIError).hint).toBeDefined(); + } + }); + + it("throws network error when HTTP error body is not JSON-RPC", async () => { + const url = await createTestServer((_req, res) => { + res.writeHead(500); + res.end("Internal Server Error"); + }); + + const client = new TestClient(url); + try { + await client.call("eth_blockNumber"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CLIError); + expect((err as CLIError).code).toBe(ErrorCode.NETWORK_ERROR); + } + }); + it("throws on 429", async () => { const url = await createTestServer((_req, res) => { res.writeHead(429); diff --git a/tests/lib/errors.test.ts b/tests/lib/errors.test.ts index 0a69e18..43353c9 100644 --- a/tests/lib/errors.test.ts +++ b/tests/lib/errors.test.ts @@ -153,6 +153,24 @@ describe("convenience constructors", () => { } }); +describe("errRPC hints", () => { + it("includes hint for known RPC error codes", () => { + const err = errRPC(-32601, "Method not found"); + expect(err.hint).toBeDefined(); + expect(err.hint).toContain("Method not supported"); + }); + + it("includes hint for invalid params", () => { + const err = errRPC(-32602, "Invalid params"); + expect(err.hint).toContain("Invalid parameters"); + }); + + it("has no hint for unknown RPC error codes", () => { + const err = errRPC(-32000, "Execution reverted"); + expect(err.hint).toBeUndefined(); + }); +}); + describe("EXIT_CODES", () => { it("has a mapping for every ErrorCode", () => { for (const code of Object.values(ErrorCode)) {