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"); + }); +});