From cd0f168f0f90f8fef4b9dc5f799fd9d51779c234 Mon Sep 17 00:00:00 2001 From: blake duncan Date: Fri, 10 Apr 2026 12:05:13 -0400 Subject: [PATCH] refactor: move read commands under data namespace Group balance, tokens, NFTs, history, prices, and portfolio under the new data surface, and update help, docs, prompts, and tests to reflect the nested command model. Made-with: Cursor --- MAINTAINERS.md | 2 +- README.md | 34 +++---- src/commands/agent-prompt.ts | 16 +-- src/commands/balance.ts | 12 +-- src/commands/data.ts | 17 ++++ src/commands/interactive.ts | 42 ++++---- src/commands/nfts.ts | 8 +- src/commands/prices.ts | 6 +- src/commands/tokens.ts | 8 +- src/commands/transfers.ts | 10 +- src/index.ts | 20 +--- src/lib/resolve.ts | 39 +++++-- tests/commands/api-namespaces.test.ts | 6 +- tests/commands/balance.test.ts | 18 ++-- tests/commands/nfts.test.ts | 11 +- tests/commands/portfolio.test.ts | 12 ++- tests/commands/tokens.test.ts | 12 +-- tests/commands/transfers.test.ts | 11 +- tests/e2e/cli.e2e.test.ts | 11 +- tests/lib/resolve.test.ts | 39 ++++++- tests/live/data.live.test.ts | 141 ++++++++++++++++++++++++++ 21 files changed, 341 insertions(+), 134 deletions(-) create mode 100644 src/commands/data.ts create mode 100644 tests/live/data.live.test.ts diff --git a/MAINTAINERS.md b/MAINTAINERS.md index ff74dd9..babf11d 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -84,7 +84,7 @@ This creates a file like `.changeset/cool-dogs-fly.md`: "@alchemy/cli": minor --- -Add `alchemy portfolio transactions` command for portfolio transaction history. +Add `alchemy data portfolio transactions` command for portfolio transaction history. ``` Write a 1-2 sentence summary of the change from a user's perspective. Commit this file with your PR. diff --git a/README.md b/README.md index bae3880..d4c3d35 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Quick usage examples: alchemy # Agent/script-friendly command -alchemy balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json --no-interactive +alchemy data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json --no-interactive # Agent checks whether a newer CLI version is available alchemy update-check --json --no-interactive @@ -95,7 +95,6 @@ Use `alchemy help` or `alchemy help ` for generated command help. | Command | What it does | Example | |---|---|---| -| `balance [address]` (`bal [address]`) | Gets ETH balance for an address | `alchemy bal 0x...` | | `tx [hash]` | Gets transaction + receipt by hash | `alchemy tx 0x...` | | `receipt [hash]` | Gets transaction receipt (status, gas, logs) | `alchemy receipt 0x...` | | `block ` | Gets block details (`latest`, decimal, or hex) | `alchemy block latest` | @@ -109,21 +108,22 @@ Use `alchemy help` or `alchemy help ` for generated command help. | Command | What it does | Example | |---|---|---| -| `tokens [address]` | Lists ERC-20 balances for an address | `alchemy tokens 0x...` | -| `tokens metadata ` | Gets ERC-20 metadata | `alchemy tokens metadata 0x...` | -| `tokens allowance --owner --spender --contract` | Gets ERC-20 allowance | `alchemy tokens allowance --owner 0x... --spender 0x... --contract 0x...` | -| `nfts [address]` | Lists NFTs owned by an address | `alchemy nfts 0x...` | -| `nfts metadata --contract --token-id ` | Gets NFT metadata by contract/token | `alchemy nfts metadata --contract 0x... --token-id 1` | -| `nfts contract
` | Gets NFT contract metadata | `alchemy nfts contract 0x...` | -| `transfers [address]` | Gets transfer history (`alchemy_getAssetTransfers`) | `alchemy transfers 0x... --category erc20,erc721` | -| `prices symbol ` | Gets current token prices by symbol | `alchemy prices symbol ETH,USDC` | -| `prices address --addresses ` | Gets current token prices by address/network pairs | `alchemy prices address --addresses '[{"network":"eth-mainnet","address":"0x..."}]'` | -| `prices historical --body ` | Gets historical prices | `alchemy prices historical --body '{"symbol":"ETH","startTime":"...","endTime":"..."}'` | -| `portfolio tokens --body ` | Gets token portfolio data | `alchemy portfolio tokens --body '{...}'` | -| `portfolio token-balances --body ` | Gets token balance snapshots | `alchemy portfolio token-balances --body '{...}'` | -| `portfolio nfts --body ` | Gets NFT portfolio data | `alchemy portfolio nfts --body '{...}'` | -| `portfolio nft-contracts --body ` | Gets NFT contract portfolio data | `alchemy portfolio nft-contracts --body '{...}'` | -| `portfolio transactions --body ` | Gets portfolio transaction history | `alchemy portfolio transactions --body '{...}'` | +| `data balance [address]` | Gets native token balance for an address | `alchemy data balance 0x...` | +| `data tokens balances [address]` | Lists ERC-20 balances for an address | `alchemy data tokens balances 0x...` | +| `data tokens metadata ` | Gets ERC-20 metadata | `alchemy data tokens metadata 0x...` | +| `data tokens allowance --owner --spender --contract` | Gets ERC-20 allowance | `alchemy data tokens allowance --owner 0x... --spender 0x... --contract 0x...` | +| `data nfts [address]` | Lists NFTs owned by an address | `alchemy data nfts 0x...` | +| `data nfts metadata --contract --token-id ` | Gets NFT metadata by contract/token | `alchemy data nfts metadata --contract 0x... --token-id 1` | +| `data nfts contract
` | Gets NFT contract metadata | `alchemy data nfts contract 0x...` | +| `data history [address]` | Gets transfer history (`alchemy_getAssetTransfers`) | `alchemy data history 0x... --category erc20,erc721` | +| `data price symbol ` | Gets current token prices by symbol | `alchemy data price symbol ETH,USDC` | +| `data price address --addresses ` | Gets current token prices by address/network pairs | `alchemy data price address --addresses '[{"network":"eth-mainnet","address":"0x..."}]'` | +| `data price historical --body ` | Gets historical prices | `alchemy data price historical --body '{"symbol":"ETH","startTime":"...","endTime":"..."}'` | +| `data portfolio tokens --body ` | Gets token portfolio data | `alchemy data portfolio tokens --body '{...}'` | +| `data portfolio token-balances --body ` | Gets token balance snapshots | `alchemy data portfolio token-balances --body '{...}'` | +| `data portfolio nfts --body ` | Gets NFT portfolio data | `alchemy data portfolio nfts --body '{...}'` | +| `data portfolio nft-contracts --body ` | Gets NFT contract portfolio data | `alchemy data portfolio nft-contracts --body '{...}'` | +| `data portfolio transactions --body ` | Gets portfolio transaction history | `alchemy data portfolio transactions --body '{...}'` | | `simulate asset-changes --tx ` | Simulates asset changes | `alchemy simulate asset-changes --tx '{"from":"0x...","to":"0x..."}'` | | `simulate execution --tx ` | Simulates execution traces | `alchemy simulate execution --tx '{"from":"0x...","to":"0x..."}'` | | `simulate asset-changes-bundle --txs ` | Simulates bundle asset changes | `alchemy simulate asset-changes-bundle --txs '[{...}]'` | diff --git a/src/commands/agent-prompt.ts b/src/commands/agent-prompt.ts index 828ac0d..305047f 100644 --- a/src/commands/agent-prompt.ts +++ b/src/commands/agent-prompt.ts @@ -141,17 +141,12 @@ function buildAgentPrompt(program: Command): AgentPrompt { flag: "--api-key ", configKey: "api-key", commandFamilies: [ - "balance", + "data", "tx", "block", "rpc", "trace", "debug", - "tokens", - "nfts", - "transfers", - "prices", - "portfolio", "simulate", "solana", ], @@ -176,15 +171,12 @@ function buildAgentPrompt(program: Command): AgentPrompt { flag: "--x402 --wallet-key-file ", configKey: "x402", commandFamilies: [ - "balance", + "data", "tx", "block", "rpc", "trace", "debug", - "tokens", - "nfts", - "transfers", ], }, ], @@ -193,7 +185,7 @@ function buildAgentPrompt(program: Command): AgentPrompt { examples: [ "alchemy --json --no-interactive setup status", "alchemy --json --no-interactive update-check", - "alchemy --json --no-interactive balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --api-key $ALCHEMY_API_KEY", + "alchemy --json --no-interactive data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --api-key $ALCHEMY_API_KEY", "alchemy --json --no-interactive apps list --access-key $ALCHEMY_ACCESS_KEY", "alchemy --json --no-interactive rpc eth_blockNumber --api-key $ALCHEMY_API_KEY", "alchemy --json --no-interactive network list", @@ -258,7 +250,7 @@ export function registerAgentPrompt(program: Command) { program .command("agent-prompt") .description("Emit complete agent/automation usage instructions") - .option("--commands ", "Filter to specific commands in JSON output (requires --json). Comma-separated (e.g. balance,tokens,gas)") + .option("--commands ", "Filter to specific commands in JSON output (requires --json). Comma-separated (e.g. data,rpc,gas)") .action((opts: { commands?: string }) => { const payload = buildAgentPrompt(program); if (opts.commands) { diff --git a/src/commands/balance.ts b/src/commands/balance.ts index 25ad3d0..b92c8ba 100644 --- a/src/commands/balance.ts +++ b/src/commands/balance.ts @@ -72,12 +72,12 @@ export function registerBalance(program: Command) { "after", ` Examples: - alchemy balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - alchemy balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -n polygon-mainnet - echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy balance - alchemy balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --block 15537393 - alchemy balance vitalik.eth - cat addresses.txt | alchemy balance`, + alchemy data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + alchemy data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 -n polygon-mainnet + echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy data balance + alchemy data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --block 15537393 + alchemy data balance vitalik.eth + cat addresses.txt | alchemy data balance`, ) .option("--block ", "Block number, hex, or tag (default: latest)") .action(async (addressArg?: string, opts?: { block?: string }) => { diff --git a/src/commands/data.ts b/src/commands/data.ts new file mode 100644 index 0000000..ba80285 --- /dev/null +++ b/src/commands/data.ts @@ -0,0 +1,17 @@ +import { Command } from "commander"; +import { registerBalance } from "./balance.js"; +import { registerNFTs } from "./nfts.js"; +import { registerTokens } from "./tokens.js"; +import { registerTransfers } from "./transfers.js"; +import { registerPrices } from "./prices.js"; +import { registerPortfolio } from "./portfolio.js"; + +export function registerData(program: Command) { + const cmd = program.command("data").description("Query blockchain data"); + registerBalance(cmd); + registerTokens(cmd); + registerNFTs(cmd); + registerTransfers(cmd); + registerPrices(cmd); + registerPortfolio(cmd); +} diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 4159453..357c845 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -30,23 +30,30 @@ const COMMAND_NAMES = [ "apps address-allowlist", "apps origin-allowlist", "apps ip-allowlist", - "bal", - "balance", "block", "apps chains", "trace", "debug", - "transfers", - "prices", - "prices symbol", - "prices address", - "prices historical", - "portfolio", - "portfolio tokens", - "portfolio token-balances", - "portfolio nfts", - "portfolio nft-contracts", - "portfolio transactions", + "data", + "data balance", + "data tokens", + "data tokens balances", + "data tokens metadata", + "data tokens allowance", + "data nfts", + "data nfts metadata", + "data nfts contract", + "data history", + "data price", + "data price symbol", + "data price address", + "data price historical", + "data portfolio", + "data portfolio tokens", + "data portfolio token-balances", + "data portfolio nfts", + "data portfolio nft-contracts", + "data portfolio transactions", "simulate", "simulate asset-changes", "simulate execution", @@ -77,15 +84,9 @@ const COMMAND_NAMES = [ "update-check", "network", "network list", - "nfts", - "nfts metadata", - "nfts contract", "rpc", "setup", "setup status", - "tokens", - "tokens metadata", - "tokens allowance", "tx", "receipt", "gas", @@ -451,7 +452,8 @@ export async function startREPL( // - `help ` -> scoped command help const isHelpRequest = words[0] === "help" || words.includes("--help") || words.includes("-h"); if (isHelpRequest) { - // Normalize: "help balance" → ["balance", "--help"], "balance --help" → ["balance", "--help"] + // Normalize: "help data balance" → ["data", "balance", "--help"], + // "data balance --help" → ["data", "balance", "--help"] const target = words[0] === "help" ? [...words.slice(1).filter(w => w !== "--help" && w !== "-h"), "--help"] : [...words.filter(w => w !== "--help" && w !== "-h"), "--help"]; diff --git a/src/commands/nfts.ts b/src/commands/nfts.ts index 36976d8..271240b 100644 --- a/src/commands/nfts.ts +++ b/src/commands/nfts.ts @@ -53,10 +53,10 @@ export function registerNFTs(program: Command) { "after", ` Examples: - alchemy nfts 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - alchemy nfts metadata --contract 0x... --token-id 1 - alchemy nfts contract 0x... - echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy nfts`, + alchemy data nfts 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + alchemy data nfts metadata --contract 0x... --token-id 1 + alchemy data nfts contract 0x... + echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy data nfts`, ) .action(async (addressArg: string | undefined, opts: { limit?: number; pageKey?: string }) => { try { diff --git a/src/commands/prices.ts b/src/commands/prices.ts index d73ccae..ec92351 100644 --- a/src/commands/prices.ts +++ b/src/commands/prices.ts @@ -7,7 +7,7 @@ import { resolveAPIKey, resolveX402Client } from "../lib/resolve.js"; import { splitCommaList } from "../lib/validators.js"; export function registerPrices(program: Command) { - const cmd = program.command("prices").description("Prices API wrappers"); + const cmd = program.command("price").description("Token price data"); cmd .command("symbol ") @@ -58,8 +58,8 @@ export function registerPrices(program: Command) { "after", ` Examples: - alchemy prices historical --body '{"symbol":"ETH","startTime":"2024-01-01T00:00:00Z","endTime":"2024-01-02T00:00:00Z","interval":"1h"}' - alchemy prices historical --body '{"address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","network":"eth-mainnet","startTime":"2024-06-01","endTime":"2024-06-07","interval":"1d"}'`, + alchemy data price historical --body '{"symbol":"ETH","startTime":"2024-01-01T00:00:00Z","endTime":"2024-01-02T00:00:00Z","interval":"1h"}' + alchemy data price historical --body '{"address":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","network":"eth-mainnet","startTime":"2024-06-01","endTime":"2024-06-07","interval":"1d"}'`, ) .action(async (opts: { body: string }) => { try { diff --git a/src/commands/tokens.ts b/src/commands/tokens.ts index 869f0b5..6c3a67c 100644 --- a/src/commands/tokens.ts +++ b/src/commands/tokens.ts @@ -148,9 +148,9 @@ export function registerTokens(program: Command) { "after", ` Examples: - alchemy tokens balances 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - alchemy tokens balances 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --metadata - echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy tokens balances`, + alchemy data tokens balances 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + alchemy data tokens balances 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --metadata + echo 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 | alchemy data tokens balances`, ) .action(async (addressArg: string | undefined, opts: { pageKey?: string; metadata?: boolean }) => { try { @@ -284,7 +284,7 @@ Examples: "after", ` Examples: - alchemy tokens metadata 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eB48`, + alchemy data tokens metadata 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eB48`, ) .action(async (contract: string) => { try { diff --git a/src/commands/transfers.ts b/src/commands/transfers.ts index 01068f5..e7a3bf3 100644 --- a/src/commands/transfers.ts +++ b/src/commands/transfers.ts @@ -55,7 +55,7 @@ function formatTransferRows(transfers: Transfer[]): string[][] { export function registerTransfers(program: Command) { program - .command("transfers") + .command("history") .argument("[address]", "Wallet address or ENS name — queries outgoing transfers (use --to-address for incoming)") .description("Get transfer history (alchemy_getAssetTransfers)") .option("--from-address
", "Filter sender address") @@ -70,16 +70,16 @@ export function registerTransfers(program: Command) { ` Examples: # Outgoing transfers from an address - alchemy transfers 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + alchemy data history 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 # Incoming transfers to an address - alchemy transfers --to-address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + alchemy data history --to-address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 # Outgoing ERC-20 transfers only - alchemy transfers --from-address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --category erc20 + alchemy data history --from-address 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --category erc20 # Transfers within a block range - alchemy transfers 0xd8dA... --from-block 0x100000 --to-block latest`, + alchemy data history 0xd8dA... --from-block 0x100000 --to-block latest`, ) .action(async (addressArg: string | undefined, opts) => { try { diff --git a/src/index.ts b/src/index.ts index 4fb405d..8659ac1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,10 @@ import { brandedHelp } from "./lib/ui.js"; import { noColor, setNoColor, identity, esc } from "./lib/colors.js"; import { registerConfig } from "./commands/config.js"; import { registerRPC } from "./commands/rpc.js"; -import { registerBalance } from "./commands/balance.js"; +import { registerData } from "./commands/data.js"; import { registerTx } from "./commands/tx.js"; import { registerReceipt } from "./commands/receipt.js"; import { registerBlock } from "./commands/block.js"; -import { registerNFTs } from "./commands/nfts.js"; -import { registerTokens } from "./commands/tokens.js"; import { registerNetwork } from "./commands/network.js"; import { registerVersion } from "./commands/version.js"; import { registerApps } from "./commands/apps.js"; @@ -21,9 +19,6 @@ import { registerSetup } from "./commands/setup.js"; import { registerAuth } from "./commands/auth.js"; import { registerTrace } from "./commands/trace.js"; import { registerDebug } from "./commands/debug.js"; -import { registerTransfers } from "./commands/transfers.js"; -import { registerPrices } from "./commands/prices.js"; -import { registerPortfolio } from "./commands/portfolio.js"; import { registerSimulate } from "./commands/simulate.js"; import { registerWebhooks } from "./commands/webhooks.js"; import { registerSolana } from "./commands/solana.js"; @@ -63,11 +58,11 @@ const ROOT_OPTION_GROUPS = [ const ROOT_COMMAND_PILLARS = [ { label: "Node", - commands: ["balance", "tx", "block", "rpc", "trace", "debug", "gas", "logs"], + commands: ["tx", "block", "rpc", "trace", "debug", "gas", "logs"], }, { label: "Data", - commands: ["tokens", "nfts", "transfers", "prices", "portfolio", "simulate"], + commands: ["data", "simulate"], }, { label: "Execution", @@ -310,7 +305,7 @@ program `${hBrand("◆")} ${hBold("Quick Start")}`, ` ${hDim("────────────────────────────────────")}`, ` ${hBrand("alchemy")} ${hDim("Interactive mode with guided setup")}`, - ` ${hBrand("alchemy balance")} ${hDim("
")} ${hDim("Get native token balance")}`, + ` ${hBrand("alchemy data balance")} ${hDim("
")} ${hDim("Get native token balance")}`, ` ${hBrand("alchemy block latest")} ${hDim("Latest block summary")}`, ` ${hBrand("alchemy rpc eth_chainId")} ${hDim("Raw JSON-RPC call")}`, ` ${hBrand("alchemy config list")} ${hDim("View current configuration")}`, @@ -429,7 +424,6 @@ program // Node registerRPC(program); -registerBalance(program); registerTx(program); registerReceipt(program); registerBlock(program); @@ -439,11 +433,7 @@ registerGas(program); registerLogs(program); // Data -registerTokens(program); -registerNFTs(program); -registerTransfers(program); -registerPrices(program); -registerPortfolio(program); +registerData(program); registerSimulate(program); // Execution diff --git a/src/lib/resolve.ts b/src/lib/resolve.ts index e906c75..d4a8b03 100644 --- a/src/lib/resolve.ts +++ b/src/lib/resolve.ts @@ -10,8 +10,27 @@ import { AdminClient } from "./admin-client.js"; import { errAppRequired, errAuthRequired, errAccessKeyRequired, errInvalidArgs, errWalletKeyRequired } from "./errors.js"; import { debug } from "./output.js"; +interface CommandOptions { + apiKey?: string; + accessKey?: string; + network?: string; + appId?: string; + x402?: boolean; + walletKeyFile?: string; + solanaWalletKeyFile?: string; + gasSponsored?: boolean; + gasPolicyId?: string; +} + +function getCommandOptions(program: Command): CommandOptions { + const commandWithGlobals = program as Command & { + optsWithGlobals?: () => CommandOptions; + }; + return commandWithGlobals.optsWithGlobals?.() ?? program.opts(); +} + export function resolveAPIKey(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.apiKey) return opts.apiKey; if (process.env.ALCHEMY_API_KEY) return process.env.ALCHEMY_API_KEY; const config = cfg ?? load(); @@ -22,7 +41,7 @@ export function resolveAPIKey(program: Command, cfg?: Config): string | undefine } export function resolveAccessKey(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.accessKey) return opts.accessKey; if (process.env.ALCHEMY_ACCESS_KEY) return process.env.ALCHEMY_ACCESS_KEY; const config = cfg ?? load(); @@ -31,7 +50,7 @@ export function resolveAccessKey(program: Command, cfg?: Config): string | undef } export function resolveNetwork(program: Command, cfg?: Config, defaultNetwork?: string): string { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.network) return opts.network; if (process.env.ALCHEMY_NETWORK) return process.env.ALCHEMY_NETWORK; const config = cfg ?? load(); @@ -40,7 +59,7 @@ export function resolveNetwork(program: Command, cfg?: Config, defaultNetwork?: } export function resolveAppId(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.appId) return opts.appId; const config = cfg ?? load(); if (config.app?.id) return config.app.id; @@ -72,7 +91,7 @@ export function adminClientFromFlags(program: Command): AdminClient { } export function resolveX402(program: Command, cfg?: Config): boolean { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.x402) return true; const config = cfg ?? load(); return config.x402 === true; @@ -87,7 +106,7 @@ export function resolveX402Client(program: Command): X402Client | null { } export function resolveWalletKey(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); // 1. --wallet-key-file flag if (opts.walletKeyFile) { @@ -109,7 +128,7 @@ export function resolveWalletKey(program: Command, cfg?: Config): string | undef } export function resolveSolanaWalletKey(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.solanaWalletKeyFile) { return readFileSync(opts.solanaWalletKeyFile, "utf-8").trim(); @@ -128,7 +147,7 @@ export function resolveSolanaWalletKey(program: Command, cfg?: Config): string | } export function resolveGasSponsored(program: Command, cfg?: Config): boolean { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.gasSponsored) return true; if (process.env.ALCHEMY_GAS_SPONSORED) { return process.env.ALCHEMY_GAS_SPONSORED.trim().toLowerCase() === "true"; @@ -138,7 +157,7 @@ export function resolveGasSponsored(program: Command, cfg?: Config): boolean { } export function resolveGasPolicyId(program: Command, cfg?: Config): string | undefined { - const opts = program.opts(); + const opts = getCommandOptions(program); if (opts.gasPolicyId) return opts.gasPolicyId; if (process.env.ALCHEMY_GAS_POLICY_ID) return process.env.ALCHEMY_GAS_POLICY_ID; const config = cfg ?? load(); @@ -151,7 +170,7 @@ export function clientFromFlags(program: Command, opts?: { defaultNetwork?: stri debug(`using network=${network}`); // Reject --access-key on RPC commands — it's only for admin commands - const programOpts = program.opts(); + const programOpts = getCommandOptions(program); if (programOpts.accessKey) { throw errInvalidArgs( "--access-key is for admin commands (apps, chains, webhooks). Use --api-key for RPC commands.", diff --git a/tests/commands/api-namespaces.test.ts b/tests/commands/api-namespaces.test.ts index b0260c1..8812f4f 100644 --- a/tests/commands/api-namespaces.test.ts +++ b/tests/commands/api-namespaces.test.ts @@ -51,10 +51,10 @@ describe("new API namespace commands", () => { })); vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError: vi.fn() })); - const { registerPrices } = await import("../../src/commands/prices.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerPrices(program); - await program.parseAsync(["node", "test", "prices", "symbol", "ETH,USDC"], { from: "node" }); + registerData(program); + await program.parseAsync(["node", "test", "data", "price", "symbol", "ETH,USDC"], { from: "node" }); expect(callApiPrices).toHaveBeenCalled(); }); diff --git a/tests/commands/balance.test.ts b/tests/commands/balance.test.ts index e6c2519..2d74d1a 100644 --- a/tests/commands/balance.test.ts +++ b/tests/commands/balance.test.ts @@ -44,11 +44,11 @@ describe("balance command", () => { nativeTokenSymbol: () => "ETH", })); - const { registerBalance } = await import("../../src/commands/balance.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerBalance(program); + registerData(program); - await program.parseAsync(["node", "test", "balance"], { from: "node" }); + await program.parseAsync(["node", "test", "data", "balance"], { from: "node" }); expect(readStdinLines).toHaveBeenCalledWith("address"); expect(resolveAddress).toHaveBeenCalledWith(ADDRESS, expect.anything()); @@ -103,11 +103,11 @@ describe("balance command", () => { nativeTokenSymbol: () => "ETH", })); - const { registerBalance } = await import("../../src/commands/balance.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerBalance(program); + registerData(program); - await program.parseAsync(["node", "test", "balance"], { from: "node" }); + await program.parseAsync(["node", "test", "data", "balance"], { from: "node" }); expect(readStdinLines).toHaveBeenCalledWith("address"); expect(printJSON).toHaveBeenCalledTimes(2); @@ -148,11 +148,11 @@ describe("balance command", () => { nativeTokenSymbol: () => "ETH", })); - const { registerBalance } = await import("../../src/commands/balance.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerBalance(program); + registerData(program); - await program.parseAsync(["node", "test", "balance"], { from: "node" }); + await program.parseAsync(["node", "test", "data", "balance"], { from: "node" }); expect(exitWithError).toHaveBeenCalledWith(err); }); diff --git a/tests/commands/nfts.test.ts b/tests/commands/nfts.test.ts index 24baf16..71c3560 100644 --- a/tests/commands/nfts.test.ts +++ b/tests/commands/nfts.test.ts @@ -50,14 +50,15 @@ describe("nfts command", () => { })); vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - const { registerNFTs } = await import("../../src/commands/nfts.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerNFTs(program); + registerData(program); await program.parseAsync( [ "node", "test", + "data", "nfts", ADDRESS, "--limit", @@ -123,11 +124,11 @@ describe("nfts command", () => { })); vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - const { registerNFTs } = await import("../../src/commands/nfts.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerNFTs(program); + registerData(program); - await program.parseAsync(["node", "test", "nfts", ADDRESS], { from: "node" }); + await program.parseAsync(["node", "test", "data", "nfts", ADDRESS], { from: "node" }); expect(callEnhanced).toHaveBeenCalled(); expect(emptyState).toHaveBeenCalledWith("No NFTs found."); diff --git a/tests/commands/portfolio.test.ts b/tests/commands/portfolio.test.ts index 500a0e9..6cabefd 100644 --- a/tests/commands/portfolio.test.ts +++ b/tests/commands/portfolio.test.ts @@ -14,12 +14,13 @@ describe("portfolio command", () => { vi.doMock("../../src/lib/rest.js", () => ({ callApiData })); vi.doMock("../../src/lib/resolve.js", () => ({ resolveAPIKey: () => "api_key", resolveX402Client: () => null })); - const { registerPortfolio } = await import("../../src/commands/portfolio.js"); - await runRegisteredCommand(registerPortfolio, [ + const { registerData } = await import("../../src/commands/data.js"); + await runRegisteredCommand(registerData, [ + "data", "portfolio", "tokens", "--body", - '{"addresses":[{"network":"eth-mainnet","address":"0xabc"}]}', + '{"addresses":[{"address":"0xabc","networks":["eth-mainnet"]}]}', ]); expect(callApiData).toHaveBeenCalledWith( @@ -40,8 +41,9 @@ describe("portfolio command", () => { vi.doMock("../../src/lib/rest.js", () => ({ callApiData })); vi.doMock("../../src/lib/resolve.js", () => ({ resolveAPIKey: () => "api_key", resolveX402Client: () => null })); - const { registerPortfolio } = await import("../../src/commands/portfolio.js"); - await runRegisteredCommand(registerPortfolio, [ + const { registerData } = await import("../../src/commands/data.js"); + await runRegisteredCommand(registerData, [ + "data", "portfolio", "tokens", "--body", diff --git a/tests/commands/tokens.test.ts b/tests/commands/tokens.test.ts index eceb861..e1ce12b 100644 --- a/tests/commands/tokens.test.ts +++ b/tests/commands/tokens.test.ts @@ -55,11 +55,11 @@ describe("tokens command", () => { })); vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - const { registerTokens } = await import("../../src/commands/tokens.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerTokens(program); + registerData(program); - await program.parseAsync(["node", "test", "tokens", "balances", ADDRESS], { + await program.parseAsync(["node", "test", "data", "tokens", "balances", ADDRESS], { from: "node", }); @@ -122,12 +122,12 @@ describe("tokens command", () => { })); vi.doMock("../../src/lib/errors.js", async () => ({ ...(await vi.importActual("../../src/lib/errors.js")), exitWithError })); - const { registerTokens } = await import("../../src/commands/tokens.js"); + const { registerData } = await import("../../src/commands/data.js"); const program = new Command(); - registerTokens(program); + registerData(program); await program.parseAsync( - ["node", "test", "tokens", "balances", ADDRESS, "--page-key", "pk_next"], + ["node", "test", "data", "tokens", "balances", ADDRESS, "--page-key", "pk_next"], { from: "node" }, ); diff --git a/tests/commands/transfers.test.ts b/tests/commands/transfers.test.ts index 7392968..4aa00dd 100644 --- a/tests/commands/transfers.test.ts +++ b/tests/commands/transfers.test.ts @@ -17,9 +17,10 @@ describe("transfers command", () => { clientFromFlags: () => ({ call }), })); - const { registerTransfers } = await import("../../src/commands/transfers.js"); - await runRegisteredCommand(registerTransfers, [ - "transfers", + const { registerData } = await import("../../src/commands/data.js"); + await runRegisteredCommand(registerData, [ + "data", + "history", ADDRESS, "--category", "erc20,erc721", @@ -46,8 +47,8 @@ describe("transfers command", () => { clientFromFlags: () => ({ call }), })); - const { registerTransfers } = await import("../../src/commands/transfers.js"); - await runRegisteredCommand(registerTransfers, ["transfers", "not-an-address"]); + const { registerData } = await import("../../src/commands/data.js"); + await runRegisteredCommand(registerData, ["data", "history", "not-an-address"]); expect(call).not.toHaveBeenCalled(); expect(exitWithError).toHaveBeenCalledTimes(1); diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index f3d2aa2..746b98f 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -39,6 +39,7 @@ describe("CLI mock E2E", () => { "test-api-key", "--network", "eth-mainnet", + "data", "balance", ADDRESS, ], @@ -50,7 +51,8 @@ describe("CLI mock E2E", () => { expect(parseJSON(result.stdout)).toEqual({ address: ADDRESS, wei: "16", - eth: "0.000000000000000016", + balance: "0.000000000000000016", + symbol: "ETH", network: "eth-mainnet", }); expect(server.requests).toHaveLength(1); @@ -71,7 +73,7 @@ describe("CLI mock E2E", () => { }); const result = await runCLI( - ["--json", "--api-key", "test-api-key", "balance", ADDRESS], + ["--json", "--api-key", "test-api-key", "data", "balance", ADDRESS], { ALCHEMY_RPC_BASE_URL: server.baseURL }, ); @@ -390,10 +392,13 @@ describe("CLI mock E2E", () => { const commands = payload.commands as Array<{ name: string }>; expect(commands.length).toBeGreaterThan(10); - expect(commands.some((c) => c.name === "balance")).toBe(true); + expect(commands.some((c) => c.name === "data")).toBe(true); expect(commands.some((c) => c.name === "update-check")).toBe(true); expect(commands.some((c) => c.name === "agent-prompt")).toBe(false); expect(payload.examples).toContain("alchemy --json --no-interactive update-check"); + expect(payload.examples).toContain( + "alchemy --json --no-interactive data balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --api-key $ALCHEMY_API_KEY", + ); }); it("returns update status JSON from the cached version check", async () => { diff --git a/tests/lib/resolve.test.ts b/tests/lib/resolve.test.ts index 637aa7d..9cf1987 100644 --- a/tests/lib/resolve.test.ts +++ b/tests/lib/resolve.test.ts @@ -2,9 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Command } from "commander"; import { ErrorCode } from "../../src/lib/errors.js"; -function makeProgram(opts: Record = {}): Command { +function makeProgram( + opts: Record = {}, + globalOpts?: Record, +): Command { return { opts: () => opts, + ...(globalOpts ? { optsWithGlobals: () => globalOpts } : {}), } as unknown as Command; } @@ -51,6 +55,17 @@ describe("resolve.ts precedence", () => { expect(resolveAccessKey(makeProgram({}))).toBe("cfg-access"); }); + it("resolveAPIKey reads root flags from nested commands", async () => { + vi.doMock("../../src/lib/config.js", () => ({ + load: () => ({}), + })); + const { resolveAPIKey } = await import("../../src/lib/resolve.js"); + + expect( + resolveAPIKey(makeProgram({}, { apiKey: "root-flag-key" })), + ).toBe("root-flag-key"); + }); + it("resolveWalletKey order: --wallet-key-file > env > config wallet_key_file", async () => { const readFileSync = vi.fn((path: string) => { if (path === "/flag/key.txt") return "flag-key\n"; @@ -139,6 +154,28 @@ describe("resolve.ts precedence", () => { expect(x402Ctor).not.toHaveBeenCalled(); }); + it("clientFromFlags uses root global opts for nested commands", async () => { + const clientCtor = vi.fn(); + vi.doMock("../../src/lib/config.js", () => ({ + load: () => ({}), + })); + vi.doMock("../../src/lib/x402-client.js", () => ({ + X402Client: class {}, + })); + vi.doMock("../../src/lib/client.js", () => ({ + Client: class { + constructor(...args: unknown[]) { + clientCtor(...args); + } + }, + })); + + const { clientFromFlags } = await import("../../src/lib/resolve.js"); + clientFromFlags(makeProgram({}, { apiKey: "root-api-key", network: "base-mainnet" })); + + expect(clientCtor).toHaveBeenCalledWith("root-api-key", "base-mainnet"); + }); + it("resolveGasSponsored order: flag > env > config", async () => { vi.doMock("../../src/lib/config.js", () => ({ load: () => ({ gas_sponsored: true }), diff --git a/tests/live/data.live.test.ts b/tests/live/data.live.test.ts new file mode 100644 index 0000000..671c67d --- /dev/null +++ b/tests/live/data.live.test.ts @@ -0,0 +1,141 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + parseJSON, + requireLiveConfig, + runLiveEvmCLI, +} from "./helpers/live-harness.js"; +import type { LiveConfig } from "./helpers/live-env.js"; + +interface BalancePayload { + address: string; + wei: string; + balance: string; + symbol: string; + network: string; +} + +interface TokenBalancesPayload { + address: string; + tokenBalances: unknown[]; + pageKey?: string; +} + +interface NFTsPayload { + ownedNfts: unknown[]; + totalCount: number; + pageKey?: string; +} + +interface HistoryPayload { + transfers: unknown[]; + pageKey?: string; +} + +describe("live data commands", () => { + let config: LiveConfig; + + beforeAll(async () => { + config = await requireLiveConfig("evm"); + }); + + it("smoke tests data balance", async () => { + const result = await runLiveEvmCLI( + ["data", "balance", config.evmAddress], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(payload).toMatchObject({ + address: config.evmAddress, + symbol: "ETH", + network: config.evmNetwork, + }); + expect(payload.wei).toEqual(expect.any(String)); + expect(payload.balance).toEqual(expect.any(String)); + }); + + it("smoke tests data tokens balances", async () => { + const result = await runLiveEvmCLI( + ["data", "tokens", "balances", config.evmAddress], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(payload.address.toLowerCase()).toBe(config.evmAddress.toLowerCase()); + expect(Array.isArray(payload.tokenBalances)).toBe(true); + }); + + it("smoke tests data nfts", async () => { + const result = await runLiveEvmCLI( + ["data", "nfts", config.evmAddress], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(Array.isArray(payload.ownedNfts)).toBe(true); + expect(typeof payload.totalCount).toBe("number"); + }); + + it("smoke tests data history", async () => { + const result = await runLiveEvmCLI( + ["data", "history", config.evmAddress, "--max-count", "1"], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(Array.isArray(payload.transfers)).toBe(true); + }); + + it("smoke tests data price symbol", async () => { + const result = await runLiveEvmCLI( + ["data", "price", "symbol", "ETH"], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(payload).not.toBeNull(); + expect(typeof payload).toBe("object"); + }); + + it("smoke tests data portfolio tokens", async () => { + const result = await runLiveEvmCLI( + [ + "data", + "portfolio", + "tokens", + "--body", + JSON.stringify({ + addresses: [ + { + address: config.evmAddress, + networks: [config.evmNetwork], + }, + ], + }), + ], + config, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + + const payload = parseJSON(result.stdout); + expect(payload).not.toBeNull(); + expect(typeof payload).toBe("object"); + }); +});