diff --git a/src/commands/discover.ts b/src/commands/discover.ts new file mode 100644 index 0000000..f5f199c --- /dev/null +++ b/src/commands/discover.ts @@ -0,0 +1,39 @@ +import { Command } from "commander"; +import { discover } from "../tools/lightning/discover.js"; +import { handleError, output } from "../utils.js"; + +export function registerDiscoverCommand(program: Command) { + program + .command("discover") + .description( + "Search 402index.io for paid API services that accept bitcoin/lightning", + ) + .option("-q, --query ", "Search query") + .option( + "-p, --protocol ", + "Filter by protocol (L402, x402, MPP)", + ) + .option( + "--health ", + "Filter by health (healthy, degraded, down, unknown)", + "healthy", + ) + .option( + "-s, --sort ", + "Sort by (reliability, latency, price, name)", + "reliability", + ) + .option("-l, --limit ", "Number of results", "10") + .action(async (options) => { + await handleError(async () => { + const result = await discover({ + query: options.query, + protocol: options.protocol, + health: options.health, + sort: options.sort, + limit: parseInt(options.limit, 10), + }); + output(result); + }); + }); +} diff --git a/src/index.ts b/src/index.ts index 47e716a..018a51f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { registerRequestInvoiceFromLightningAddressCommand } from "./commands/re import { registerFetch402Command } from "./commands/fetch.js"; import { registerConnectCommand } from "./commands/connect.js"; import { registerAuthCommand } from "./commands/auth.js"; +import { registerDiscoverCommand } from "./commands/discover.js"; const program = new Command(); @@ -96,6 +97,10 @@ registerRequestInvoiceFromLightningAddressCommand(program); program.commandsGroup("HTTP 402 Payments (requires wallet connection):"); registerFetch402Command(program); +// Register service discovery +program.commandsGroup("Service Discovery:"); +registerDiscoverCommand(program); + // Register setup commands program.commandsGroup("Setup:"); registerAuthCommand(program); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts new file mode 100644 index 0000000..6aa1db4 --- /dev/null +++ b/src/tools/lightning/discover.ts @@ -0,0 +1,79 @@ +export interface DiscoverParams { + query?: string; + protocol?: string; + health?: string; + sort?: string; + limit?: number; +} + +export async function discover(params: DiscoverParams) { + const url = new URL("https://402index.io/api/v1/services"); + const requestedLimit = params.limit ?? 10; + + if (params.query) url.searchParams.set("q", params.query); + if (params.protocol) url.searchParams.set("protocol", params.protocol); + if (params.health) url.searchParams.set("health", params.health); + if (params.sort) url.searchParams.set("sort", params.sort); + + // Filter to BTC (Lightning) services server-side + url.searchParams.set("payment_asset", "BTC"); + url.searchParams.set("limit", String(requestedLimit)); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + let response: Response; + try { + response = await fetch(url.toString(), { signal: controller.signal }); + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Request to 402index.io timed out"); + } + throw error; + } finally { + clearTimeout(timer); + } + if (!response.ok) { + throw new Error( + `402index.io returned status ${response.status}: ${await response.text()}`, + ); + } + + const data = (await response.json()) as { + services: Array<{ + name: string; + description: string; + url: string; + protocol: string; + price_sats: number | null; + price_usd: number | null; + payment_network: string; + category: string; + provider: string; + health_status: string; + reliability_score: number | null; + latency_p50_ms: number | null; + http_method: string; + }>; + total: number; + limit: number; + offset: number; + }; + + return { + services: data.services.map((s) => ({ + name: s.name, + description: s.description, + url: s.url, + protocol: s.protocol, + price_sats: s.price_sats, + price_usd: s.price_usd, + category: s.category, + provider: s.provider, + health_status: s.health_status, + reliability_score: s.reliability_score, + latency_p50_ms: s.latency_p50_ms, + http_method: s.http_method, + })), + total: data.total, + }; +}