From 3759b47f8155b793e72e30780f62fb943350b7f8 Mon Sep 17 00:00:00 2001 From: Cody McCabe Date: Mon, 2 Mar 2026 16:52:26 -0500 Subject: [PATCH] feat: expand app and network discovery flows Improve CLI discovery by adding app list pagination/filtering, introducing a shared RPC network catalog, and supporting configured-network lookups so users can inspect available versus app-enabled networks more reliably. Made-with: Cursor --- .gitignore | 4 + src/commands/apps.ts | 190 ++++++++ src/commands/chains.ts | 6 +- src/commands/config.ts | 27 +- src/commands/interactive.ts | 15 +- src/commands/network.ts | 100 +++-- src/lib/networks.ts | 248 +++++++++++ src/lib/resolve.ts | 40 +- tests/commands/integration.test.ts | 676 +++++++++++++++++++++++++++++ tests/e2e/cli.e2e.test.ts | 203 +++++++++ 10 files changed, 1455 insertions(+), 54 deletions(-) create mode 100644 src/lib/networks.ts diff --git a/.gitignore b/.gitignore index ccb3b11..bd3f85e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ plans/ # Claude Code .claude/ + +# Agent artifacts +.agents/ +skills-lock.json diff --git a/src/commands/apps.ts b/src/commands/apps.ts index 742957f..f17fef7 100644 --- a/src/commands/apps.ts +++ b/src/commands/apps.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { adminClientFromFlags } from "../lib/resolve.js"; +import type { App } from "../lib/admin-client.js"; import { errInvalidArgs } from "../lib/errors.js"; import { isJSONMode, printJSON } from "../lib/output.js"; import { exitWithError } from "../index.js"; @@ -21,6 +22,69 @@ function maskAppSecrets(a }; } +function printFetchSummary( + appsCount: number, + pagesCount: number, + opts?: { suffix?: string }, +): void { + const suffix = opts?.suffix ? ` ${opts.suffix}` : ""; + console.log(`\n ${dim(`Fetched ${appsCount} apps across ${pagesCount} pages${suffix}`)}`); +} + +type PaginationAction = "next" | "all" | "stop"; + +async function promptPaginationAction(): Promise { + const { select, isCancel, cancel } = await import("@clack/prompts"); + const action = await select({ + message: "More apps are available. What do you want to do?", + options: [ + { label: "Load next page", value: "next" }, + { label: "Load all remaining pages", value: "all" }, + { label: "Stop here", value: "stop" }, + ], + initialValue: "next", + }); + + if (isCancel(action)) { + cancel("Stopped pagination."); + return "stop"; + } + + if (action === "next" || action === "all" || action === "stop") { + return action; + } + return "stop"; +} + +async function listAllApps( + listApps: (opts?: { cursor?: string; limit?: number }) => Promise<{ apps: App[]; cursor?: string }>, + opts?: { limit?: number }, +): Promise<{ apps: Awaited>["apps"]; pages: number }> { + const apps: Awaited>["apps"] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + let pages = 0; + + do { + const page = await listApps({ + ...(cursor && { cursor }), + ...(opts?.limit !== undefined && { limit: opts.limit }), + }); + pages += 1; + apps.push(...page.apps); + cursor = page.cursor; + if (cursor && seenCursors.has(cursor)) break; + if (cursor) seenCursors.add(cursor); + } while (cursor); + + return { apps, pages }; +} + +function matchesSearch(app: App, query: string): boolean { + const q = query.toLowerCase(); + return app.name.toLowerCase().includes(q) || app.id.toLowerCase().includes(q); +} + export function registerApps(program: Command) { const cmd = program.command("apps").description("Manage Alchemy apps"); @@ -31,9 +95,87 @@ export function registerApps(program: Command) { .description("List all apps") .option("--cursor ", "Pagination cursor") .option("--limit ", "Max results per page", parseInt) + .option("--all", "Fetch all pages") + .option("--search ", "Search apps by name or id (client-side)") + .option("--id ", "Filter by exact app id (client-side)") .action(async (opts) => { try { const admin = adminClientFromFlags(program); + const fetchAll = Boolean(opts.all); + const hasSearch = typeof opts.search === "string"; + const hasId = typeof opts.id === "string"; + const searchQuery = hasSearch ? opts.search.trim() : ""; + const idQuery = hasId ? opts.id.trim() : ""; + + if (opts.all && opts.cursor) { + throw errInvalidArgs("Cannot combine --all with --cursor"); + } + if (hasSearch && hasId) { + throw errInvalidArgs("Cannot combine --search with --id"); + } + if (opts.cursor && (hasSearch || hasId)) { + throw errInvalidArgs("Cannot combine --cursor with --search or --id"); + } + if (hasSearch && !searchQuery) { + throw errInvalidArgs("--search cannot be empty"); + } + if (hasId && !idQuery) { + throw errInvalidArgs("--id cannot be empty"); + } + + const isFilteredList = hasSearch || hasId; + if (fetchAll || isFilteredList) { + const result = await withSpinner("Fetching apps…", "Apps fetched", () => + listAllApps(admin.listApps.bind(admin), { limit: opts.limit }), + ); + const filteredApps = hasId + ? result.apps.filter((a) => a.id === idQuery) + : hasSearch + ? result.apps.filter((a) => matchesSearch(a, searchQuery)) + : result.apps; + + if (isJSONMode()) { + printJSON({ + apps: filteredApps.map(maskAppSecrets), + pageInfo: { + mode: fetchAll ? "all" : "search", + pages: result.pages, + scannedApps: result.apps.length, + ...(hasSearch && { search: searchQuery }), + ...(hasId && { id: idQuery }), + }, + }); + return; + } + + if (filteredApps.length === 0) { + emptyState( + hasId + ? `No apps found with id "${idQuery}".` + : hasSearch + ? `No apps found matching "${searchQuery}".` + : "No apps found.", + ); + printFetchSummary(result.apps.length, result.pages); + return; + } + + const rows = filteredApps.map((a) => [ + a.id, + a.name, + String(a.chainNetworks.length), + a.createdAt, + ]); + + printTable(["ID", "Name", "Networks", "Created"], rows); + if (isFilteredList) { + const filterLabel = hasId ? `id "${idQuery}"` : `"${searchQuery}"`; + console.log(`\n ${dim(`Matched ${filteredApps.length} apps for ${filterLabel}`)}`); + } + printFetchSummary(result.apps.length, result.pages); + return; + } + const result = await withSpinner("Fetching apps…", "Apps fetched", () => admin.listApps({ cursor: opts.cursor, limit: opts.limit }), ); @@ -43,6 +185,53 @@ export function registerApps(program: Command) { return; } + const interactivePagination = process.stdin.isTTY && !opts.all; + if (interactivePagination) { + let page = result; + let autoFetchRemaining = false; + let pagesFetched = 0; + let appsFetched = 0; + + while (true) { + if (page.apps.length > 0) { + pagesFetched += 1; + appsFetched += page.apps.length; + const rows = page.apps.map((a) => [ + a.id, + a.name, + String(a.chainNetworks.length), + a.createdAt, + ]); + printTable(["ID", "Name", "Networks", "Created"], rows); + } else { + emptyState("No apps found."); + return; + } + + if (!page.cursor) { + printFetchSummary(appsFetched, pagesFetched); + return; + } + + if (!autoFetchRemaining) { + printFetchSummary(appsFetched, pagesFetched, { suffix: "so far" }); + const action = await promptPaginationAction(); + if (action === "stop") { + console.log(`\n ${dim(`Next cursor: ${page.cursor}`)}`); + printFetchSummary(appsFetched, pagesFetched); + return; + } + if (action === "all") { + autoFetchRemaining = true; + } + } + + page = await withSpinner("Fetching next page…", "Page fetched", () => + admin.listApps({ cursor: page.cursor, limit: opts.limit }), + ); + } + } + if (result.apps.length === 0) { emptyState("No apps found."); return; @@ -56,6 +245,7 @@ export function registerApps(program: Command) { ]); printTable(["ID", "Name", "Networks", "Created"], rows); + printFetchSummary(result.apps.length, 1); if (result.cursor) { console.log(`\n ${dim(`Next cursor: ${result.cursor}`)}`); diff --git a/src/commands/chains.ts b/src/commands/chains.ts index 94a81f6..ee74c23 100644 --- a/src/commands/chains.ts +++ b/src/commands/chains.ts @@ -5,11 +5,13 @@ import { exitWithError } from "../index.js"; import { dim, green, withSpinner, printTable, emptyState } from "../lib/ui.js"; export function registerChains(program: Command) { - const cmd = program.command("chains").description("Manage chain networks"); + const cmd = program + .command("chains") + .description("Manage Admin API chain enums"); cmd .command("list") - .description("List available chain networks") + .description("List available Admin API chain enums") .action(async () => { try { const admin = adminClientFromFlags(program); diff --git a/src/commands/config.ts b/src/commands/config.ts index 90a2c03..da77095 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -45,14 +45,31 @@ async function saveAppWithPrompt(app: App): Promise { return true; } +async function listAllApps(admin: AdminClient): Promise { + const apps: App[] = []; + let cursor: string | undefined; + const seenCursors = new Set(); + + // Follow admin API cursors so interactive selection can show all apps. + do { + const page = await admin.listApps(cursor ? { cursor } : undefined); + apps.push(...page.apps); + cursor = page.cursor; + if (cursor && seenCursors.has(cursor)) break; + if (cursor) seenCursors.add(cursor); + } while (cursor); + + return apps; +} + async function selectOrCreateApp(admin: AdminClient): Promise { const { select, text, multiselect, confirm, isCancel, cancel } = await import( "@clack/prompts" ); - let apps: Awaited>; + let apps: App[]; try { - apps = await admin.listApps(); + apps = await listAllApps(admin); } catch { console.log( ` ${dim("Could not fetch apps. Skipping app selection.")}`, @@ -60,10 +77,10 @@ async function selectOrCreateApp(admin: AdminClient): Promise { return; } - if (apps.apps.length > 0) { + if (apps.length > 0) { const CREATE_NEW = "__create_new__"; const options = [ - ...apps.apps.map((a) => ({ + ...apps.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, })), @@ -80,7 +97,7 @@ async function selectOrCreateApp(admin: AdminClient): Promise { } if (selected !== CREATE_NEW) { - const app = apps.apps.find((a) => a.id === selected)!; + const app = apps.find((a) => a.id === selected)!; const saved = await saveAppWithPrompt(app); if (saved) { console.log(`${green("✓")} Default app set to ${app.name} (${app.id})`); diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 9e727ef..a5c75d9 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -12,6 +12,7 @@ import { } from "../lib/ui.js"; import { isJSONMode } from "../lib/output.js"; import { setReplMode } from "../index.js"; +import { getRPCNetworkIds } from "../lib/networks.js"; const COMMAND_NAMES = [ "apps", @@ -47,19 +48,7 @@ const COMMAND_NAMES = [ "version", ]; -const NETWORK_NAMES = [ - "eth-mainnet", - "eth-sepolia", - "eth-holesky", - "polygon-mainnet", - "polygon-amoy", - "arb-mainnet", - "arb-sepolia", - "opt-mainnet", - "opt-sepolia", - "base-mainnet", - "base-sepolia", -]; +const NETWORK_NAMES = getRPCNetworkIds(); const REPL_HISTORY_MAX = 100; diff --git a/src/commands/network.ts b/src/commands/network.ts index eb2987d..7ec2bd3 100644 --- a/src/commands/network.ts +++ b/src/commands/network.ts @@ -1,46 +1,80 @@ import { Command } from "commander"; -import { resolveNetwork } from "../lib/resolve.js"; +import { + resolveAppId, + resolveConfiguredNetworkSlugs, + resolveNetwork, +} from "../lib/resolve.js"; import { isJSONMode, printJSON } from "../lib/output.js"; -import { green, printTable } from "../lib/ui.js"; - -const SUPPORTED_NETWORKS = [ - { id: "eth-mainnet", name: "Ethereum Mainnet", chain: "Ethereum" }, - { id: "eth-sepolia", name: "Ethereum Sepolia", chain: "Ethereum" }, - { id: "eth-holesky", name: "Ethereum Holesky", chain: "Ethereum" }, - { id: "polygon-mainnet", name: "Polygon Mainnet", chain: "Polygon" }, - { id: "polygon-amoy", name: "Polygon Amoy", chain: "Polygon" }, - { id: "arb-mainnet", name: "Arbitrum One", chain: "Arbitrum" }, - { id: "arb-sepolia", name: "Arbitrum Sepolia", chain: "Arbitrum" }, - { id: "opt-mainnet", name: "Optimism Mainnet", chain: "Optimism" }, - { id: "opt-sepolia", name: "Optimism Sepolia", chain: "Optimism" }, - { id: "base-mainnet", name: "Base Mainnet", chain: "Base" }, - { id: "base-sepolia", name: "Base Sepolia", chain: "Base" }, -]; +import { dim, green, printTable } from "../lib/ui.js"; +import { getRPCNetworks } from "../lib/networks.js"; +import { exitWithError } from "../index.js"; export function registerNetwork(program: Command) { const cmd = program.command("network").description("Manage networks"); cmd .command("list") - .description("List supported networks") - .action(() => { - if (isJSONMode()) { - printJSON(SUPPORTED_NETWORKS); - return; - } + .description("List supported RPC network slugs") + .option( + "--configured", + "List only configured app RPC networks (requires access key and app context)", + ) + .option( + "--app-id ", + "App ID for configured network lookups (overrides saved app)", + ) + .action(async (opts: { configured?: boolean; appId?: string }) => { + try { + const supported = getRPCNetworks(); + const current = resolveNetwork(program); + const configured = opts.configured + ? await resolveConfiguredNetworkSlugs(program, opts.appId) + : null; + const configuredSet = new Set(configured ?? []); + const appId = opts.configured + ? opts.appId || resolveAppId(program) + : undefined; + + const display = configured + ? supported.filter((network) => configuredSet.has(network.id)) + : supported; + + if (isJSONMode()) { + if (configured) { + printJSON({ + mode: "configured", + appId, + configuredNetworkIds: configured, + networks: display, + }); + return; + } - const current = resolveNetwork(program); + printJSON(display); + return; + } - const rows = SUPPORTED_NETWORKS.map((n) => { - const isCurrent = n.id === current; - return [ - isCurrent ? green(n.id) : n.id, - isCurrent ? green(n.name) : n.name, - n.chain, - ]; - }); + const rows = display.map((network) => { + const isCurrent = network.id === current; + const idCell = isCurrent ? green(network.id) : network.id; + const nameCell = isCurrent ? green(network.name) : network.name; + const testnetCell = network.isTestnet ? dim("yes") : "no"; + return [idCell, nameCell, network.family, testnetCell]; + }); - printTable(["Network ID", "Name", "Chain"], rows); - console.log(`\n Current: ${green(current)}`); + printTable(["Network ID", "Name", "Family", "Testnet"], rows); + + if (configured) { + console.log( + `\n ${dim(`Configured networks for app ${appId}: ${display.length}`)}`, + ); + } + console.log(`\n Current: ${green(current)}`); + console.log( + ` ${dim("Need Admin API chain enums instead? Run: alchemy chains list")}`, + ); + } catch (err) { + exitWithError(err); + } }); } diff --git a/src/lib/networks.ts b/src/lib/networks.ts new file mode 100644 index 0000000..b86154e --- /dev/null +++ b/src/lib/networks.ts @@ -0,0 +1,248 @@ +export interface RPCNetwork { + id: string; + name: string; + family: string; + isTestnet: boolean; + httpsUrlTemplate: string; +} + +const TESTNET_TOKEN_RE = + /(testnet|sepolia|holesky|hoodi|devnet|minato|amoy|fuji|saigon|cardona|aeneid|curtis|chiado|cassiopeia|blaze|ropsten|signet|mocha|fam|bepolia)$/i; + +const FAMILY_ALIASES: Record = { + arb: "Arbitrum", + arbnova: "Arbitrum Nova", + avax: "Avalanche", + bnb: "BNB Smart Chain", + eth: "Ethereum", + opt: "OP Mainnet", + polygonzkevm: "Polygon zkEVM", +}; + +const NAME_ALIASES: Record = { + arb: "Arbitrum", + avax: "Avalanche", + bnb: "BNB", + eth: "Ethereum", + opbnb: "opBNB", + opt: "OP Mainnet", + sui: "SUI", + xmtp: "XMTP", + zksync: "ZKsync", +}; + +export const RPC_NETWORK_IDS: readonly string[] = [ + "abstract-mainnet", + "abstract-testnet", + "adi-mainnet", + "adi-testnet", + "alchemy-internal", + "alchemy-sepolia", + "alchemyarb-fam", + "alchemyarb-sepolia", + "alterscope-mainnet", + "anime-mainnet", + "anime-sepolia", + "apechain-curtis", + "apechain-mainnet", + "aptos-mainnet", + "aptos-testnet", + "arb-mainnet", + "arb-sepolia", + "arbnova-mainnet", + "arc-testnet", + "astar-mainnet", + "avax-fuji", + "avax-mainnet", + "base-mainnet", + "base-sepolia", + "berachain-bepolia", + "berachain-mainnet", + "bitcoin-mainnet", + "bitcoin-signet", + "bitcoin-testnet", + "blast-mainnet", + "blast-sepolia", + "bnb-mainnet", + "bnb-testnet", + "bob-mainnet", + "bob-sepolia", + "boba-mainnet", + "boba-sepolia", + "botanix-mainnet", + "botanix-testnet", + "celestiabridge-mainnet", + "celestiabridge-mocha", + "celo-mainnet", + "celo-sepolia", + "citrea-mainnet", + "citrea-testnet", + "clankermon-mainnet", + "commons-mainnet", + "crossfi-mainnet", + "crossfi-testnet", + "degen-mainnet", + "degen-sepolia", + "earnm-mainnet", + "earnm-sepolia", + "edge-mainnet", + "edge-testnet", + "eth-holesky", + "eth-holeskybeacon", + "eth-hoodi", + "eth-hoodibeacon", + "eth-mainnet", + "eth-mainnetbeacon", + "eth-sepolia", + "eth-sepoliabeacon", + "flow-mainnet", + "flow-testnet", + "frax-hoodi", + "frax-mainnet", + "galactica-cassiopeia", + "galactica-mainnet", + "gensyn-mainnet", + "gensyn-testnet", + "gnosis-chiado", + "gnosis-mainnet", + "humanity-mainnet", + "humanity-testnet", + "hyperliquid-mainnet", + "hyperliquid-testnet", + "ink-mainnet", + "ink-sepolia", + "lens-mainnet", + "lens-sepolia", + "linea-mainnet", + "linea-sepolia", + "mantle-mainnet", + "mantle-sepolia", + "megaeth-mainnet", + "megaeth-testnet", + "metis-mainnet", + "mode-mainnet", + "mode-sepolia", + "monad-mainnet", + "monad-testnet", + "moonbeam-mainnet", + "mythos-mainnet", + "opbnb-mainnet", + "opbnb-testnet", + "openloot-sepolia", + "opt-mainnet", + "opt-sepolia", + "plasma-mainnet", + "plasma-testnet", + "polygon-amoy", + "polygon-mainnet", + "polygonzkevm-cardona", + "polygonzkevm-mainnet", + "polynomial-mainnet", + "polynomial-sepolia", + "race-mainnet", + "race-sepolia", + "risa-testnet", + "rise-testnet", + "ronin-mainnet", + "ronin-saigon", + "rootstock-mainnet", + "rootstock-testnet", + "scroll-mainnet", + "scroll-sepolia", + "sei-mainnet", + "sei-testnet", + "settlus-mainnet", + "settlus-septestnet", + "shape-mainnet", + "shape-sepolia", + "solana-devnet", + "solana-mainnet", + "soneium-mainnet", + "soneium-minato", + "sonic-blaze", + "sonic-mainnet", + "sonic-testnet", + "stable-mainnet", + "stable-testnet", + "standard-mainnet", + "starknet-mainnet", + "starknet-sepolia", + "story-aeneid", + "story-mainnet", + "sui-mainnet", + "sui-testnet", + "superseed-mainnet", + "superseed-sepolia", + "synd-mainnet", + "syndicate-manchego", + "tea-sepolia", + "tempo-testnet", + "tron-mainnet", + "tron-testnet", + "unichain-mainnet", + "unichain-sepolia", + "unite-mainnet", + "unite-testnet", + "worldchain-mainnet", + "worldchain-sepolia", + "worldl3-devnet", + "worldmobile-devnet", + "worldmobile-testnet", + "worldmobilechain-mainnet", + "xmtp-mainnet", + "xmtp-ropsten", + "xprotocol-mainnet", + "zetachain-mainnet", + "zetachain-testnet", + "zksync-mainnet", + "zksync-sepolia", + "zora-mainnet", + "zora-sepolia", +]; + +function isTestnetNetwork(id: string): boolean { + return TESTNET_TOKEN_RE.test(id); +} + +function tokenToName(token: string): string { + const alias = NAME_ALIASES[token]; + if (alias) return alias; + return token.charAt(0).toUpperCase() + token.slice(1); +} + +function toFamily(id: string): string { + const [head] = id.split("-"); + return FAMILY_ALIASES[head] ?? tokenToName(head); +} + +function toDisplayName(id: string): string { + return id + .split("-") + .map((part) => tokenToName(part)) + .join(" "); +} + +function toHttpsUrlTemplate(id: string): string { + if (id === "starknet-mainnet" || id === "starknet-sepolia") { + return `https://${id}.g.alchemy.com/starknet/version/rpc/v0_10/{apiKey}`; + } + return `https://${id}.g.alchemy.com/v2/{apiKey}`; +} + +export function getRPCNetworks(): RPCNetwork[] { + return RPC_NETWORK_IDS.map((id) => ({ + id, + name: toDisplayName(id), + family: toFamily(id), + isTestnet: isTestnetNetwork(id), + httpsUrlTemplate: toHttpsUrlTemplate(id), + })); +} + +export function getRPCNetworkIds(): string[] { + return [...RPC_NETWORK_IDS]; +} + +export function getRPCNetworkById(id: string): RPCNetwork | undefined { + return getRPCNetworks().find((network) => network.id === id); +} diff --git a/src/lib/resolve.ts b/src/lib/resolve.ts index c3147fb..17e3579 100644 --- a/src/lib/resolve.ts +++ b/src/lib/resolve.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { load } from "./config.js"; import { Client } from "./client.js"; import { AdminClient } from "./admin-client.js"; -import { errAuthRequired, errAccessKeyRequired } from "./errors.js"; +import { errAppRequired, errAuthRequired, errAccessKeyRequired } from "./errors.js"; import { debug } from "./output.js"; export function resolveAPIKey(program: Command): string | undefined { @@ -34,6 +34,14 @@ export function resolveNetwork(program: Command): string { return "eth-mainnet"; } +export function resolveAppId(program: Command): string | undefined { + const opts = program.opts(); + if (opts.appId) return opts.appId; + const cfg = load(); + if (cfg.app?.id) return cfg.app.id; + return undefined; +} + export function adminClientFromFlags(program: Command): AdminClient { const accessKey = resolveAccessKey(program); if (!accessKey) throw errAccessKeyRequired(); @@ -48,3 +56,33 @@ export function clientFromFlags(program: Command): Client { debug(`using network=${network}`); return new Client(apiKey, network); } + +function appNetworkToSlug(rpcUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(rpcUrl); + } catch { + return null; + } + + const suffix = ".g.alchemy.com"; + if (!parsed.hostname.endsWith(suffix)) return null; + + const slug = parsed.hostname.slice(0, -suffix.length); + return slug || null; +} + +export async function resolveConfiguredNetworkSlugs( + program: Command, + appIdOverride?: string, +): Promise { + const appId = appIdOverride || resolveAppId(program); + if (!appId) throw errAppRequired(); + + const admin = adminClientFromFlags(program); + const app = await admin.getApp(appId); + const slugs = app.chainNetworks + .map((network) => appNetworkToSlug(network.rpcUrl)) + .filter((slug): slug is string => Boolean(slug)); + return Array.from(new Set(slugs)).sort((a, b) => a.localeCompare(b)); +} diff --git a/tests/commands/integration.test.ts b/tests/commands/integration.test.ts index fc8ec4a..d753326 100644 --- a/tests/commands/integration.test.ts +++ b/tests/commands/integration.test.ts @@ -126,6 +126,92 @@ describe("command integration coverage", () => { expect(exitWithError).not.toHaveBeenCalled(); }); + it("network list returns full catalog in JSON mode", async () => { + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + resolveNetwork: () => "eth-mainnet", + resolveConfiguredNetworkSlugs: vi.fn(), + resolveAppId: vi.fn(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + dim: (s: string) => s, + green: (s: string) => s, + printTable: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerNetwork } = await import("../../src/commands/network.js"); + const program = new Command(); + registerNetwork(program); + + await program.parseAsync(["node", "test", "network", "list"], { + from: "node", + }); + + expect(exitWithError).not.toHaveBeenCalled(); + expect(printJSON).toHaveBeenCalledTimes(1); + const payload = printJSON.mock.calls[0][0] as Array<{ id: string }>; + expect(Array.isArray(payload)).toBe(true); + expect(payload.length).toBeGreaterThan(100); + expect(payload.some((network) => network.id === "eth-mainnet")).toBe(true); + expect(payload.some((network) => network.id === "base-mainnet")).toBe(true); + }); + + it("network list --configured returns configured app slugs", async () => { + const printJSON = vi.fn(); + const resolveConfiguredNetworkSlugs = vi + .fn() + .mockResolvedValue(["eth-mainnet", "base-sepolia"]); + const resolveAppId = vi.fn().mockReturnValue("app_123"); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + resolveNetwork: () => "eth-mainnet", + resolveConfiguredNetworkSlugs, + resolveAppId, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + dim: (s: string) => s, + green: (s: string) => s, + printTable: vi.fn(), + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerNetwork } = await import("../../src/commands/network.js"); + const program = new Command(); + registerNetwork(program); + + await program.parseAsync( + ["node", "test", "network", "list", "--configured"], + { + from: "node", + }, + ); + + expect(resolveConfiguredNetworkSlugs).toHaveBeenCalledTimes(1); + expect(resolveAppId).toHaveBeenCalledTimes(1); + expect(exitWithError).not.toHaveBeenCalled(); + + expect(printJSON).toHaveBeenCalledTimes(1); + expect(printJSON).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "configured", + appId: "app_123", + configuredNetworkIds: ["eth-mainnet", "base-sepolia"], + }), + ); + }); + it("tokens filters zero balances and prints table rows", async () => { const call = vi.fn().mockResolvedValue({ address: ADDRESS, @@ -179,6 +265,487 @@ describe("command integration coverage", () => { expect(exitWithError).not.toHaveBeenCalled(); }); + it("apps list is single-page in JSON mode by default", async () => { + const listApps = vi + .fn() + .mockResolvedValue({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "list"], { from: "node" }); + + expect(listApps).toHaveBeenCalledTimes(1); + expect(listApps).toHaveBeenCalledWith({ cursor: undefined, limit: undefined }); + expect(printJSON).toHaveBeenCalledWith({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps list with --cursor remains single-page in JSON mode", async () => { + const listApps = vi.fn().mockResolvedValue({ + apps: [ + { + id: "app_1", + name: "Cursor App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync( + ["node", "test", "apps", "list", "--cursor", "abc"], + { from: "node" }, + ); + + expect(listApps).toHaveBeenCalledTimes(1); + expect(listApps).toHaveBeenCalledWith({ cursor: "abc", limit: undefined }); + expect(printJSON).toHaveBeenCalledWith({ + apps: [ + { + id: "app_1", + name: "Cursor App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps list with --all paginates in JSON mode", async () => { + const listApps = vi + .fn() + .mockResolvedValueOnce({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }) + .mockResolvedValueOnce({ + apps: [ + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + }); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "list", "--all"], { + from: "node", + }); + + expect(listApps).toHaveBeenCalledTimes(2); + expect(listApps).toHaveBeenNthCalledWith(1, {}); + expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2" }); + expect(printJSON).toHaveBeenCalledWith({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + pageInfo: { mode: "all", pages: 2, scannedApps: 2 }, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps list --search filters by name or id in JSON mode", async () => { + const listApps = vi + .fn() + .mockResolvedValueOnce({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }) + .mockResolvedValueOnce({ + apps: [ + { + id: "target_2", + name: "Second Target", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + }); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "list", "--search", "target"], { + from: "node", + }); + + expect(listApps).toHaveBeenCalledTimes(2); + expect(printJSON).toHaveBeenCalledWith({ + apps: [ + { + id: "target_2", + name: "Second Target", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + pageInfo: { + mode: "search", + pages: 2, + scannedApps: 2, + search: "target", + }, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps list --id returns exact app match in JSON mode", async () => { + const listApps = vi.fn().mockResolvedValue({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + }); + const printJSON = vi.fn(); + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable: vi.fn(), + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "list", "--id", "app_2"], { + from: "node", + }); + + expect(printJSON).toHaveBeenCalledWith({ + apps: [ + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + pageInfo: { + mode: "search", + pages: 1, + scannedApps: 2, + id: "app_2", + }, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + + it("apps list prompts to load next page in TTY mode", async () => { + const listApps = vi + .fn() + .mockResolvedValueOnce({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }) + .mockResolvedValueOnce({ + apps: [ + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + }); + const printTable = vi.fn(); + const emptyState = vi.fn(); + const exitWithError = vi.fn(); + const select = vi.fn().mockResolvedValue("next"); + const isCancel = vi.fn().mockReturnValue(false); + const cancel = vi.fn(); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + + vi.doMock("../../src/lib/resolve.js", () => ({ + adminClientFromFlags: () => ({ listApps }), + })); + vi.doMock("@clack/prompts", () => ({ + select, + isCancel, + cancel, + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + withSpinner: async ( + _start: string, + _end: string, + fn: () => Promise, + ) => fn(), + printTable, + printKeyValueBox: vi.fn(), + emptyState, + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerApps } = await import("../../src/commands/apps.js"); + const program = new Command(); + registerApps(program); + + await program.parseAsync(["node", "test", "apps", "list"], { + from: "node", + }); + + expect(listApps).toHaveBeenCalledTimes(2); + expect(listApps).toHaveBeenNthCalledWith(1, { cursor: undefined, limit: undefined }); + expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2", limit: undefined }); + expect(select).toHaveBeenCalledTimes(1); + expect(printTable).toHaveBeenCalledTimes(2); + expect(emptyState).not.toHaveBeenCalled(); + expect(exitWithError).not.toHaveBeenCalled(); + }); + it("apps create supports dry-run JSON payload", async () => { const printJSON = vi.fn(); const adminClientFromFlags = vi.fn(); @@ -282,6 +849,115 @@ describe("command integration coverage", () => { expect(exitWithError).not.toHaveBeenCalled(); }); + it("config set access-key app selector includes paginated apps", async () => { + const load = vi + .fn() + .mockReturnValueOnce({}) + .mockReturnValueOnce({ access_key: "ak_test" }); + const save = vi.fn(); + const printHuman = vi.fn(); + const select = vi.fn().mockResolvedValue("app_2"); + const isCancel = vi.fn().mockReturnValue(false); + const cancel = vi.fn(); + const listApps = vi + .fn() + .mockResolvedValueOnce({ + apps: [ + { + id: "app_1", + name: "First App", + apiKey: "api_1", + webhookApiKey: "wh_1", + chainNetworks: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + ], + cursor: "cursor_2", + }) + .mockResolvedValueOnce({ + apps: [ + { + id: "app_2", + name: "Second App", + apiKey: "api_2", + webhookApiKey: "wh_2", + chainNetworks: [], + createdAt: "2025-01-02T00:00:00.000Z", + }, + ], + }); + class MockAdminClient { + constructor(_accessKey: string) {} + listApps = listApps; + listChains = vi.fn(); + createApp = vi.fn(); + } + const exitWithError = vi.fn(); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + + vi.doMock("../../src/lib/config.js", () => ({ + load, + save, + get: vi.fn(), + toMap: vi.fn(), + })); + vi.doMock("../../src/lib/admin-client.js", () => ({ + AdminClient: MockAdminClient, + })); + vi.doMock("@clack/prompts", () => ({ + select, + text: vi.fn(), + multiselect: vi.fn(), + confirm: vi.fn(), + isCancel, + cancel, + })); + vi.doMock("../../src/lib/errors.js", async () => { + const actual = await vi.importActual("../../src/lib/errors.js"); + return actual; + }); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printHuman, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + green: (s: string) => s, + dim: (s: string) => s, + printKeyValueBox: vi.fn(), + emptyState: vi.fn(), + maskIf: (s: string) => s, + })); + vi.doMock("../../src/index.js", () => ({ exitWithError })); + + const { registerConfig } = await import("../../src/commands/config.js"); + const program = new Command(); + registerConfig(program); + + await program.parseAsync(["node", "test", "config", "set", "access-key", "ak_test"], { + from: "node", + }); + + expect(listApps).toHaveBeenCalledTimes(2); + expect(listApps).toHaveBeenNthCalledWith(1, undefined); + expect(listApps).toHaveBeenNthCalledWith(2, { cursor: "cursor_2" }); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Select an app to use as default:", + options: expect.arrayContaining([ + { label: "First App (app_1)", value: "app_1" }, + { label: "Second App (app_2)", value: "app_2" }, + ]), + }), + ); + expect(save).toHaveBeenNthCalledWith(1, { access_key: "ak_test" }); + expect(save).toHaveBeenNthCalledWith(2, { + access_key: "ak_test", + app: { id: "app_2", name: "Second App", apiKey: "api_2" }, + }); + expect(exitWithError).not.toHaveBeenCalled(); + }); + it("config reset --yes clears all values without prompting", async () => { const load = vi.fn().mockReturnValue({ api_key: "k", diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index 7b56fdd..f270c1d 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -134,6 +134,138 @@ describe("CLI mock E2E", () => { ); }); + it("apps list --all paginates in JSON mode for complete results", async () => { + server = await startMockServer((request) => { + if (request.path === "/v1/apps" && !request.query.get("cursor")) { + return { + status: 200, + json: { + data: { + apps: [ + { + id: "app-1", + name: "Page One", + apiKey: "api-key-1", + webhookApiKey: "webhook-key-1", + chainNetworks: [], + createdAt: "2026-01-01T00:00:00Z", + }, + ], + cursor: "cursor_2", + }, + }, + }; + } + if (request.path === "/v1/apps" && request.query.get("cursor") === "cursor_2") { + return { + status: 200, + json: { + data: { + apps: [ + { + id: "app-2", + name: "Page Two", + apiKey: "api-key-2", + webhookApiKey: "webhook-key-2", + chainNetworks: [], + createdAt: "2026-01-02T00:00:00Z", + }, + ], + }, + }, + }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--access-key", "test-access-key", "apps", "list", "--all"], + { ALCHEMY_ADMIN_API_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(parseJSON(result.stdout)).toMatchObject({ + apps: [ + { id: "app-1", name: "Page One" }, + { id: "app-2", name: "Page Two" }, + ], + pageInfo: { + mode: "all", + pages: 2, + scannedApps: 2, + }, + }); + expect(server.requests).toHaveLength(2); + expect(server.requests[0].headers.authorization).toBe( + "Bearer test-access-key", + ); + expect(server.requests[1].query.get("cursor")).toBe("cursor_2"); + }); + + it("apps list --search scans pages and returns matches", async () => { + server = await startMockServer((request) => { + if (request.path === "/v1/apps" && !request.query.get("cursor")) { + return { + status: 200, + json: { + data: { + apps: [ + { + id: "app-1", + name: "Page One", + apiKey: "api-key-1", + webhookApiKey: "webhook-key-1", + chainNetworks: [], + createdAt: "2026-01-01T00:00:00Z", + }, + ], + cursor: "cursor_2", + }, + }, + }; + } + if (request.path === "/v1/apps" && request.query.get("cursor") === "cursor_2") { + return { + status: 200, + json: { + data: { + apps: [ + { + id: "target-2", + name: "Target App", + apiKey: "api-key-2", + webhookApiKey: "webhook-key-2", + chainNetworks: [], + createdAt: "2026-01-02T00:00:00Z", + }, + ], + }, + }, + }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + ["--json", "--access-key", "test-access-key", "apps", "list", "--search", "target"], + { ALCHEMY_ADMIN_API_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(parseJSON(result.stdout)).toMatchObject({ + apps: [{ id: "target-2", name: "Target App" }], + pageInfo: { + mode: "search", + pages: 2, + scannedApps: 2, + search: "target", + }, + }); + expect(server.requests).toHaveLength(2); + }); + it("returns INVALID_ACCESS_KEY contract on admin auth failures", async () => { server = await startMockServer((request) => { if (request.path === "/v1/apps") { @@ -154,4 +286,75 @@ describe("CLI mock E2E", () => { }, }); }); + + it("lists full RPC network catalog without auth", async () => { + const result = await runCLI(["--json", "network", "list"]); + + expect(result.exitCode).toBe(0); + const payload = parseJSON(result.stdout) as Array<{ id: string }>; + expect(Array.isArray(payload)).toBe(true); + expect(payload.length).toBeGreaterThan(100); + expect(payload.some((network) => network.id === "eth-mainnet")).toBe(true); + expect(payload.some((network) => network.id === "base-mainnet")).toBe(true); + }); + + it("lists configured app network slugs with access key mode", async () => { + server = await startMockServer((request) => { + if (request.path === "/v1/apps/app-123") { + return { + status: 200, + json: { + data: { + id: "app-123", + name: "Configured App", + description: "test", + apiKey: "api_key", + webhookApiKey: "wh_key", + chainNetworks: [ + { + id: "ETH_MAINNET", + name: "Ethereum Mainnet", + rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/api_key", + }, + { + id: "BASE_SEPOLIA", + name: "Base Sepolia", + rpcUrl: "https://base-sepolia.g.alchemy.com/v2/api_key", + }, + ], + products: [], + createdAt: "2025-01-01T00:00:00.000Z", + }, + }, + }; + } + return { status: 404, text: "unknown path" }; + }); + + const result = await runCLI( + [ + "--json", + "--access-key", + "test-access-key", + "network", + "list", + "--configured", + "--app-id", + "app-123", + ], + { ALCHEMY_ADMIN_API_BASE_URL: server.baseURL }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(parseJSON(result.stdout)).toMatchObject({ + mode: "configured", + appId: "app-123", + configuredNetworkIds: ["base-sepolia", "eth-mainnet"], + }); + expect(server.requests).toHaveLength(1); + expect(server.requests[0].headers.authorization).toBe( + "Bearer test-access-key", + ); + }); });