From 3b2694559100504e36cd28f545ee821126960286 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 26 Jan 2026 16:39:57 -0800 Subject: [PATCH 1/3] Add Grid CLI commands Commands for interacting with the Grid API: - config: Get/update platform configuration - customers: List, get, create, update, delete customers and generate KYC links - accounts: List internal accounts (balances), list/create external accounts - quotes: List, get, create, execute cross-currency transfer quotes - transactions: List, get, approve, reject transactions - transfers: Same-currency transfer-in and transfer-out - receiver: Look up UMA addresses and external accounts - sandbox: Fund accounts, simulate send/receive in sandbox mode --- cli/package-lock.json | 251 +++++++++++++++++++++++++++++++ cli/src/client.ts | 6 +- cli/src/commands/accounts.ts | 212 ++++++++++++++++++++++++++ cli/src/commands/config.ts | 55 +++++++ cli/src/commands/customers.ts | 189 +++++++++++++++++++++++ cli/src/commands/quotes.ts | 165 ++++++++++++++++++++ cli/src/commands/receiver.ts | 66 ++++++++ cli/src/commands/sandbox.ts | 73 +++++++++ cli/src/commands/transactions.ts | 114 ++++++++++++++ cli/src/commands/transfers.ts | 71 +++++++++ cli/src/index.ts | 29 +++- 11 files changed, 1229 insertions(+), 2 deletions(-) create mode 100644 cli/package-lock.json create mode 100644 cli/src/commands/accounts.ts create mode 100644 cli/src/commands/config.ts create mode 100644 cli/src/commands/customers.ts create mode 100644 cli/src/commands/quotes.ts create mode 100644 cli/src/commands/receiver.ts create mode 100644 cli/src/commands/sandbox.ts create mode 100644 cli/src/commands/transactions.ts create mode 100644 cli/src/commands/transfers.ts diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..0ec237b --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,251 @@ +{ + "name": "grid-cli", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "grid-cli", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "grid": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/cli/src/client.ts b/cli/src/client.ts index 30acc86..d97bc81 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -34,7 +34,11 @@ export class GridClient { path: string, params?: Record ): string { - const url = new URL(path, this.config.baseUrl); + const baseUrl = this.config.baseUrl.endsWith("/") + ? this.config.baseUrl + : this.config.baseUrl + "/"; + const fullPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(fullPath, baseUrl); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { diff --git a/cli/src/commands/accounts.ts b/cli/src/commands/accounts.ts new file mode 100644 index 0000000..6a0d720 --- /dev/null +++ b/cli/src/commands/accounts.ts @@ -0,0 +1,212 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface InternalAccount { + id: string; + customerId?: string; + currency: string; + balance: number; + availableBalance: number; + status: string; + paymentInstructions?: unknown; + createdAt: string; + updatedAt: string; +} + +interface ExternalAccount { + id: string; + customerId: string; + currency: string; + accountInfo: { + accountType: string; + [key: string]: unknown; + }; + status: string; + createdAt: string; + updatedAt: string; +} + +export function registerAccountsCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const accountsCmd = program + .command("accounts") + .description("Account management commands"); + + const internalCmd = accountsCmd + .command("internal") + .description("Internal account commands"); + + internalCmd + .command("list") + .description("List internal accounts (balances)") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--customer-id ", "Filter by customer ID") + .option("--currency ", "Filter by currency code") + .option("--platform", "List platform internal accounts instead of customer accounts") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + limit: parseInt(options.limit), + cursor: options.cursor, + customerId: options.customerId, + currency: options.currency, + }; + + const endpoint = options.platform + ? "/platform/internal-accounts" + : "/customers/internal-accounts"; + + const response = await client.get>( + endpoint, + params + ); + outputResponse(response); + }); + + const externalCmd = accountsCmd + .command("external") + .description("External account commands"); + + externalCmd + .command("list") + .description("List external accounts") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--customer-id ", "Filter by customer ID") + .option("--currency ", "Filter by currency code") + .option("--platform", "List platform external accounts instead of customer accounts") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + limit: parseInt(options.limit), + cursor: options.cursor, + customerId: options.customerId, + currency: options.currency, + }; + + const endpoint = options.platform + ? "/platform/external-accounts" + : "/customers/external-accounts"; + + const response = await client.get>( + endpoint, + params + ); + outputResponse(response); + }); + + externalCmd + .command("create") + .description("Create an external account") + .requiredOption("--customer-id ", "Customer ID") + .requiredOption("--currency ", "Currency code (USD, MXN, BRL, EUR, etc.)") + .requiredOption("--account-type ", "Account type (US_ACCOUNT, CLABE, PIX, IBAN, UPI, NGN_ACCOUNT, SPARK_WALLET, etc.)") + .option("--account-number ", "Account number (for US_ACCOUNT, NGN_ACCOUNT)") + .option("--routing-number ", "Routing number (for US_ACCOUNT)") + .option("--account-category ", "Account category: CHECKING or SAVINGS (for US_ACCOUNT)") + .option("--clabe ", "CLABE number (for Mexico)") + .option("--pix-key ", "PIX key (for Brazil)") + .option("--iban ", "IBAN (for Europe)") + .option("--upi-id ", "UPI ID (for India)") + .option("--bank-name ", "Bank name (for NGN_ACCOUNT)") + .option("--purpose ", "Purpose of payment (for NGN_ACCOUNT): GIFT, SELF, GOODS_OR_SERVICES, EDUCATION, etc.") + .option("--address ", "Wallet address (for SPARK_WALLET, SOLANA_WALLET, etc.)") + .option("--beneficiary-type ", "Beneficiary type: INDIVIDUAL or BUSINESS") + .option("--beneficiary-name ", "Beneficiary full name (individual) or legal name (business)") + .option("--beneficiary-birth-date ", "Beneficiary birth date YYYY-MM-DD (individual)") + .option("--beneficiary-nationality ", "Beneficiary nationality country code (individual)") + .option("--beneficiary-address-line1 ", "Beneficiary address line 1") + .option("--beneficiary-address-city ", "Beneficiary city") + .option("--beneficiary-address-state ", "Beneficiary state") + .option("--beneficiary-address-postal ", "Beneficiary postal code") + .option("--beneficiary-address-country ", "Beneficiary country code") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const accountInfo: Record = { + accountType: options.accountType, + }; + + switch (options.accountType) { + case "US_ACCOUNT": + if (options.accountNumber) accountInfo.accountNumber = options.accountNumber; + if (options.routingNumber) accountInfo.routingNumber = options.routingNumber; + if (options.accountCategory) accountInfo.accountCategory = options.accountCategory; + break; + case "CLABE": + if (options.clabe) accountInfo.clabeNumber = options.clabe; + break; + case "PIX": + if (options.pixKey) accountInfo.pixKey = options.pixKey; + break; + case "IBAN": + if (options.iban) accountInfo.iban = options.iban; + break; + case "UPI": + if (options.upiId) accountInfo.vpa = options.upiId; + break; + case "NGN_ACCOUNT": + if (options.accountNumber) accountInfo.accountNumber = options.accountNumber; + if (options.bankName) accountInfo.bankName = options.bankName; + if (options.purpose) accountInfo.purposeOfPayment = options.purpose; + break; + case "SPARK_WALLET": + case "SOLANA_WALLET": + case "TRON_WALLET": + case "POLYGON_WALLET": + case "BASE_WALLET": + if (options.address) accountInfo.address = options.address; + break; + } + + if (options.beneficiaryType || options.beneficiaryName) { + const beneficiary: Record = {}; + if (options.beneficiaryType) beneficiary.beneficiaryType = options.beneficiaryType; + + if (options.beneficiaryType === "INDIVIDUAL") { + if (options.beneficiaryName) beneficiary.fullName = options.beneficiaryName; + if (options.beneficiaryBirthDate) beneficiary.birthDate = options.beneficiaryBirthDate; + if (options.beneficiaryNationality) beneficiary.nationality = options.beneficiaryNationality; + } else if (options.beneficiaryType === "BUSINESS") { + if (options.beneficiaryName) beneficiary.legalName = options.beneficiaryName; + } + + if (options.beneficiaryAddressLine1 || options.beneficiaryAddressCity) { + beneficiary.address = { + line1: options.beneficiaryAddressLine1, + city: options.beneficiaryAddressCity, + state: options.beneficiaryAddressState, + postalCode: options.beneficiaryAddressPostal, + country: options.beneficiaryAddressCountry, + }; + } + + accountInfo.beneficiary = beneficiary; + } + + const body = { + customerId: options.customerId, + currency: options.currency, + accountInfo, + }; + + const response = await client.post( + "/customers/external-accounts", + body + ); + outputResponse(response); + }); +} diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts new file mode 100644 index 0000000..8638f20 --- /dev/null +++ b/cli/src/commands/config.ts @@ -0,0 +1,55 @@ +import { Command } from "commander"; +import { GridClient } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface PlatformConfig { + id: string; + umaDomain?: string; + webhookEndpoint?: string; + supportedCurrencies: Array<{ + currencyCode: string; + minAmount?: number; + maxAmount?: number; + enabledTransactionTypes?: string[]; + }>; +} + +export function registerConfigCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const configCmd = program + .command("config") + .description("Platform configuration commands"); + + configCmd + .command("get") + .description("Get platform configuration (currencies, limits, webhook)") + .action(async () => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get("/config"); + outputResponse(response); + }); + + configCmd + .command("update") + .description("Update platform configuration") + .option("--uma-domain ", "UMA domain") + .option("--webhook-endpoint ", "Webhook endpoint URL") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = {}; + if (options.umaDomain) body.umaDomain = options.umaDomain; + if (options.webhookEndpoint) body.webhookEndpoint = options.webhookEndpoint; + + const response = await client.patch("/config", body); + outputResponse(response); + }); +} diff --git a/cli/src/commands/customers.ts b/cli/src/commands/customers.ts new file mode 100644 index 0000000..dfe5195 --- /dev/null +++ b/cli/src/commands/customers.ts @@ -0,0 +1,189 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse, formatError, output } from "../output"; +import { GlobalOptions } from "../index"; + +interface Customer { + id: string; + platformCustomerId: string; + customerType: "INDIVIDUAL" | "BUSINESS"; + umaAddress?: string; + fullName?: string; + birthDate?: string; + kycStatus?: string; + createdAt: string; + updatedAt: string; +} + +export function registerCustomersCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const customersCmd = program + .command("customers") + .description("Customer management commands"); + + customersCmd + .command("list") + .description("List customers") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--platform-id ", "Filter by platform customer ID") + .option("--type ", "Filter by type (INDIVIDUAL or BUSINESS)") + .option("--uma-address
", "Filter by UMA address") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + limit: parseInt(options.limit), + cursor: options.cursor, + platformCustomerId: options.platformId, + customerType: options.type, + umaAddress: options.umaAddress, + }; + + const response = await client.get>( + "/customers", + params + ); + outputResponse(response); + }); + + customersCmd + .command("get ") + .description("Get customer details") + .action(async (customerId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get(`/customers/${customerId}`); + outputResponse(response); + }); + + customersCmd + .command("create") + .description("Create a new customer") + .requiredOption("--platform-id ", "Platform-specific customer ID") + .option("--type ", "Customer type (INDIVIDUAL or BUSINESS)", "INDIVIDUAL") + .option("--uma-address
", "UMA address (optional, generated if not provided)") + .option("--full-name ", "Full name (for individuals)") + .option("--birth-date ", "Birth date YYYY-MM-DD (for individuals)") + .option("--legal-name ", "Legal name (for businesses)") + .option("--registration-number ", "Registration number (for businesses)") + .option("--tax-id ", "Tax ID (for businesses)") + .option("--address-line1 ", "Address line 1") + .option("--address-city ", "City") + .option("--address-state ", "State/Province") + .option("--address-postal ", "Postal code") + .option("--address-country ", "Country code (e.g., US)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + platformCustomerId: options.platformId, + customerType: options.type, + }; + + if (options.umaAddress) body.umaAddress = options.umaAddress; + + if (options.type === "INDIVIDUAL") { + if (options.fullName) body.fullName = options.fullName; + if (options.birthDate) body.birthDate = options.birthDate; + } else if (options.type === "BUSINESS") { + const businessInfo: Record = {}; + if (options.legalName) businessInfo.legalName = options.legalName; + if (options.registrationNumber) + businessInfo.registrationNumber = options.registrationNumber; + if (options.taxId) businessInfo.taxId = options.taxId; + body.businessInfo = businessInfo; + } + + if (options.addressLine1 || options.addressCity) { + body.address = { + line1: options.addressLine1, + city: options.addressCity, + state: options.addressState, + postalCode: options.addressPostal, + country: options.addressCountry, + }; + } + + const response = await client.post("/customers", body); + outputResponse(response); + }); + + customersCmd + .command("update ") + .description("Update a customer") + .option("--full-name ", "Full name") + .option("--birth-date ", "Birth date YYYY-MM-DD") + .option("--address-line1 ", "Address line 1") + .option("--address-city ", "City") + .option("--address-state ", "State/Province") + .option("--address-postal ", "Postal code") + .option("--address-country ", "Country code") + .action(async (customerId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = {}; + if (options.fullName) body.fullName = options.fullName; + if (options.birthDate) body.birthDate = options.birthDate; + + if (options.addressLine1 || options.addressCity) { + body.address = { + line1: options.addressLine1, + city: options.addressCity, + state: options.addressState, + postalCode: options.addressPostal, + country: options.addressCountry, + }; + } + + const response = await client.patch( + `/customers/${customerId}`, + body + ); + outputResponse(response); + }); + + customersCmd + .command("delete ") + .description("Delete a customer") + .action(async (customerId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.delete(`/customers/${customerId}`); + outputResponse(response); + }); + + customersCmd + .command("kyc-link") + .description("Generate a KYC link for a customer") + .requiredOption("--customer-id ", "Customer ID") + .requiredOption("--redirect-url ", "Redirect URL after KYC completion") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body = { + customerId: options.customerId, + redirectUrl: options.redirectUrl, + }; + + const response = await client.post<{ kycUrl: string }>( + "/customers/kyc-link", + body + ); + outputResponse(response); + }); +} diff --git a/cli/src/commands/quotes.ts b/cli/src/commands/quotes.ts new file mode 100644 index 0000000..152e235 --- /dev/null +++ b/cli/src/commands/quotes.ts @@ -0,0 +1,165 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface Quote { + id: string; + status: "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "EXPIRED"; + source: { + accountId?: string; + customerId?: string; + currency: string; + }; + destination: { + accountId?: string; + umaAddress?: string; + currency: string; + }; + lockedCurrencySide: "SENDING" | "RECEIVING"; + lockedCurrencyAmount: number; + sendingAmount: number; + sendingCurrency: string; + receivingAmount: number; + receivingCurrency: string; + exchangeRate: number; + fees?: Array<{ + type: string; + amount: number; + currency: string; + }>; + expiresAt: string; + createdAt: string; + updatedAt: string; +} + +export function registerQuotesCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const quotesCmd = program + .command("quotes") + .description("Quote management commands"); + + quotesCmd + .command("list") + .description("List transfer quotes") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--customer-id ", "Filter by sending customer ID") + .option("--sending-account ", "Filter by sending account ID") + .option("--receiving-account ", "Filter by receiving account ID") + .option("--sending-uma
", "Filter by sending UMA address") + .option("--receiving-uma
", "Filter by receiving UMA address") + .option("--status ", "Filter by status (PENDING, PROCESSING, COMPLETED, FAILED, EXPIRED)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + limit: parseInt(options.limit), + cursor: options.cursor, + customerId: options.customerId, + sendingAccountId: options.sendingAccount, + receivingAccountId: options.receivingAccount, + sendingUmaAddress: options.sendingUma, + receivingUmaAddress: options.receivingUma, + status: options.status, + }; + + const response = await client.get>( + "/quotes", + params + ); + outputResponse(response); + }); + + quotesCmd + .command("get ") + .description("Get quote details") + .action(async (quoteId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get(`/quotes/${quoteId}`); + outputResponse(response); + }); + + quotesCmd + .command("create") + .description("Create a transfer quote") + .requiredOption("--amount ", "Amount in smallest currency unit (e.g., cents)") + .requiredOption("--lock-side ", "Lock SENDING or RECEIVING amount") + .option("--source-account ", "Source account ID (InternalAccount:...)") + .option("--source-customer ", "Source customer ID (for customer-funded quotes)") + .option("--source-currency ", "Source currency (required with --source-customer)") + .option("--dest-account ", "Destination account ID") + .option("--dest-uma
", "Destination UMA address") + .option("--dest-currency ", "Destination currency") + .option("--description ", "Transfer description") + .option("--lookup-id ", "Lookup request ID (from receiver lookup)") + .option("--immediate", "Execute the quote immediately after creation") + .option("--sender-name ", "Sender full name (for UMA destinations)") + .option("--sender-birth-date ", "Sender birth date YYYY-MM-DD (for UMA destinations)") + .option("--sender-nationality ", "Sender nationality country code (for UMA destinations)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + lockedCurrencyAmount: parseInt(options.amount), + lockedCurrencySide: options.lockSide, + }; + + if (options.sourceAccount) { + body.source = { accountId: options.sourceAccount }; + } else if (options.sourceCustomer) { + body.source = { + customerId: options.sourceCustomer, + currency: options.sourceCurrency, + }; + } + + if (options.destAccount) { + body.destination = { + accountId: options.destAccount, + currency: options.destCurrency, + }; + } else if (options.destUma) { + body.destination = { + umaAddress: options.destUma, + currency: options.destCurrency, + }; + } + + if (options.description) body.description = options.description; + if (options.lookupId) body.lookupId = options.lookupId; + if (options.immediate) body.immediatelyExecute = true; + + const senderInfo: Record = {}; + if (options.senderName) senderInfo.FULL_NAME = options.senderName; + if (options.senderBirthDate) senderInfo.BIRTH_DATE = options.senderBirthDate; + if (options.senderNationality) senderInfo.NATIONALITY = options.senderNationality; + if (Object.keys(senderInfo).length > 0) { + body.senderCustomerInfo = senderInfo; + } + + const response = await client.post("/quotes", body); + outputResponse(response); + }); + + quotesCmd + .command("execute ") + .description("Execute a pending quote") + .action(async (quoteId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post(`/quotes/${quoteId}/execute`); + outputResponse(response); + }); +} diff --git a/cli/src/commands/receiver.ts b/cli/src/commands/receiver.ts new file mode 100644 index 0000000..ced420c --- /dev/null +++ b/cli/src/commands/receiver.ts @@ -0,0 +1,66 @@ +import { Command } from "commander"; +import { GridClient } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface ReceiverLookup { + id: string; + umaAddress?: string; + accountId?: string; + currencies: Array<{ + code: string; + name: string; + symbol: string; + minAmount?: number; + maxAmount?: number; + }>; + requiredPayerData?: Array<{ + name: string; + mandatory: boolean; + }>; +} + +export function registerReceiverCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const receiverCmd = program + .command("receiver") + .description("Receiver lookup commands"); + + receiverCmd + .command("lookup-uma ") + .description("Look up a UMA address to get payment capabilities") + .option("--customer-id ", "Sender customer ID") + .option("--sender-uma
", "Sender UMA address") + .action(async (umaAddress: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + customerId: options.customerId, + senderUmaAddress: options.senderUma, + }; + + const response = await client.get( + `/receiver/uma/${encodeURIComponent(umaAddress)}`, + params + ); + outputResponse(response); + }); + + receiverCmd + .command("lookup-account ") + .description("Look up an external account to get payment capabilities") + .action(async (accountId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get( + `/receiver/external-account/${encodeURIComponent(accountId)}` + ); + outputResponse(response); + }); +} diff --git a/cli/src/commands/sandbox.ts b/cli/src/commands/sandbox.ts new file mode 100644 index 0000000..8e4a2c2 --- /dev/null +++ b/cli/src/commands/sandbox.ts @@ -0,0 +1,73 @@ +import { Command } from "commander"; +import { GridClient } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +export function registerSandboxCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const sandboxCmd = program + .command("sandbox") + .description("Sandbox testing commands"); + + sandboxCmd + .command("send") + .description("Simulate sending a payment in sandbox") + .requiredOption("--quote-id ", "Quote ID to simulate sending") + .requiredOption("--currency ", "Currency code for the funds to send") + .option("--amount ", "Amount in smallest unit (derived from quote if not provided)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + quoteId: options.quoteId, + currencyCode: options.currency, + }; + if (options.amount) body.currencyAmount = parseInt(options.amount); + const response = await client.post("/sandbox/send", body); + outputResponse(response); + }); + + sandboxCmd + .command("receive") + .description("Simulate receiving a UMA payment in sandbox") + .requiredOption("--uma-address
", "Receiver UMA address") + .requiredOption("--amount ", "Amount in smallest unit") + .requiredOption("--currency ", "Currency code") + .option("--sender-uma
", "Sender UMA address") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + receiverUmaAddress: options.umaAddress, + amount: parseInt(options.amount), + currency: options.currency, + }; + if (options.senderUma) body.senderUmaAddress = options.senderUma; + + const response = await client.post("/sandbox/uma/receive", body); + outputResponse(response); + }); + + sandboxCmd + .command("fund ") + .description("Fund an internal account in sandbox") + .requiredOption("--amount ", "Amount in smallest unit") + .action(async (accountId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body = { amount: parseInt(options.amount) }; + const response = await client.post( + `/sandbox/internal-accounts/${accountId}/fund`, + body + ); + outputResponse(response); + }); +} diff --git a/cli/src/commands/transactions.ts b/cli/src/commands/transactions.ts new file mode 100644 index 0000000..48f9155 --- /dev/null +++ b/cli/src/commands/transactions.ts @@ -0,0 +1,114 @@ +import { Command } from "commander"; +import { GridClient, PaginatedResponse } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface Transaction { + id: string; + type: "INCOMING" | "OUTGOING"; + status: string; + amount: number; + currency: string; + senderAccountIdentifier?: string; + receiverAccountIdentifier?: string; + reference?: string; + description?: string; + createdAt: string; + updatedAt: string; +} + +export function registerTransactionsCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const transactionsCmd = program + .command("transactions") + .description("Transaction management commands"); + + transactionsCmd + .command("list") + .description("List transactions") + .option("-l, --limit ", "Maximum results (default 20, max 100)", "20") + .option("--cursor ", "Pagination cursor") + .option("--customer-id ", "Filter by customer ID") + .option("--platform-customer-id ", "Filter by platform customer ID") + .option("--sender ", "Filter by sender account identifier") + .option("--receiver ", "Filter by receiver account identifier") + .option("--status ", "Filter by status") + .option("--type ", "Filter by type (INCOMING or OUTGOING)") + .option("--reference ", "Filter by reference") + .option("--start-date ", "Filter by start date (ISO 8601)") + .option("--end-date ", "Filter by end date (ISO 8601)") + .option("--sort ", "Sort order: asc or desc (default: desc)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const params: Record = { + limit: parseInt(options.limit), + cursor: options.cursor, + customerId: options.customerId, + platformCustomerId: options.platformCustomerId, + senderAccountIdentifier: options.sender, + receiverAccountIdentifier: options.receiver, + status: options.status, + type: options.type, + reference: options.reference, + startDate: options.startDate, + endDate: options.endDate, + sortOrder: options.sort, + }; + + const response = await client.get>( + "/transactions", + params + ); + outputResponse(response); + }); + + transactionsCmd + .command("get ") + .description("Get transaction details") + .action(async (transactionId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.get( + `/transactions/${transactionId}` + ); + outputResponse(response); + }); + + transactionsCmd + .command("approve ") + .description("Approve an incoming payment transaction") + .action(async (transactionId: string) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const response = await client.post( + `/transactions/${transactionId}/approve` + ); + outputResponse(response); + }); + + transactionsCmd + .command("reject ") + .description("Reject an incoming payment transaction") + .option("--reason ", "Rejection reason") + .action(async (transactionId: string, options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body = options.reason ? { reason: options.reason } : undefined; + const response = await client.post( + `/transactions/${transactionId}/reject`, + body + ); + outputResponse(response); + }); +} diff --git a/cli/src/commands/transfers.ts b/cli/src/commands/transfers.ts new file mode 100644 index 0000000..2c76034 --- /dev/null +++ b/cli/src/commands/transfers.ts @@ -0,0 +1,71 @@ +import { Command } from "commander"; +import { GridClient } from "../client"; +import { outputResponse } from "../output"; +import { GlobalOptions } from "../index"; + +interface Transaction { + id: string; + type: "INCOMING" | "OUTGOING"; + status: string; + amount: number; + currency: string; + createdAt: string; + updatedAt: string; +} + +export function registerTransfersCommand( + program: Command, + getClient: (opts: GlobalOptions) => GridClient | null +): void { + const transfersCmd = program + .command("transfers") + .description("Same-currency transfer commands"); + + transfersCmd + .command("in") + .description("Transfer from external account to internal account (same currency)") + .requiredOption("--source ", "Source external account ID (ExternalAccount:...)") + .requiredOption("--dest ", "Destination internal account ID (InternalAccount:...)") + .option("--amount ", "Amount in smallest currency unit (optional for full balance)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + source: { accountId: options.source }, + destination: { accountId: options.dest }, + }; + + if (options.amount) { + body.amount = parseInt(options.amount); + } + + const response = await client.post("/transfer-in", body); + outputResponse(response); + }); + + transfersCmd + .command("out") + .description("Transfer from internal account to external account (same currency)") + .requiredOption("--source ", "Source internal account ID (InternalAccount:...)") + .requiredOption("--dest ", "Destination external account ID (ExternalAccount:...)") + .option("--amount ", "Amount in smallest currency unit (optional for full balance)") + .action(async (options) => { + const opts = program.opts(); + const client = getClient(opts); + if (!client) return; + + const body: Record = { + source: { accountId: options.source }, + destination: { accountId: options.dest }, + }; + + if (options.amount) { + body.amount = parseInt(options.amount); + } + + const response = await client.post("/transfer-out", body); + outputResponse(response); + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 860c550..91b82d2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -39,4 +39,31 @@ function getClient(options: GlobalOptions): GridClient | null { export { program, getClient, GridClient, GridConfig }; -program.parse(process.argv); +async function main() { + const { registerConfigCommand } = await import("./commands/config"); + const { registerCustomersCommand } = await import("./commands/customers"); + const { registerAccountsCommand } = await import("./commands/accounts"); + const { registerQuotesCommand } = await import("./commands/quotes"); + const { registerTransactionsCommand } = await import( + "./commands/transactions" + ); + const { registerTransfersCommand } = await import("./commands/transfers"); + const { registerSandboxCommand } = await import("./commands/sandbox"); + const { registerReceiverCommand } = await import("./commands/receiver"); + + registerConfigCommand(program, getClient); + registerCustomersCommand(program, getClient); + registerAccountsCommand(program, getClient); + registerQuotesCommand(program, getClient); + registerTransactionsCommand(program, getClient); + registerTransfersCommand(program, getClient); + registerSandboxCommand(program, getClient); + registerReceiverCommand(program, getClient); + + await program.parseAsync(process.argv); +} + +main().catch((err) => { + output(formatError(err.message)); + process.exitCode = 1; +}); From 294da993d3187f29d39f771c91d224bbce4255e9 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 27 Jan 2026 11:42:12 -0800 Subject: [PATCH 2/3] Add readme --- cli/README.md | 353 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 cli/README.md diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..5176be8 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,353 @@ +# Grid CLI + +A command-line interface for the Grid API, enabling global payments across fiat, stablecoins, and Bitcoin. + +## Installation + +```bash +cd cli +npm install +npm run build +``` + +## Configuration + +The CLI requires API credentials via environment variables: + +```bash +export GRID_API_TOKEN_ID="your-token-id" +export GRID_API_CLIENT_SECRET="your-client-secret" +export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" # optional +``` + +## Usage + +Run commands from the repository root: + +```bash +node cli/dist/index.js [options] +``` + +### Global Options + +| Option | Description | +|--------|-------------| +| `-c, --config ` | Path to credentials file | +| `-u, --base-url ` | Override API base URL | +| `-V, --version` | Show version | +| `-h, --help` | Show help | + +## Commands + +### Platform Configuration + +```bash +# Get platform config (currencies, limits, webhook) +node cli/dist/index.js config get + +# Update webhook endpoint +node cli/dist/index.js config update --webhook-endpoint https://example.com/webhooks +``` + +### Customers + +```bash +# List customers +node cli/dist/index.js customers list [--limit 20] [--type INDIVIDUAL|BUSINESS] + +# Get customer details +node cli/dist/index.js customers get + +# Create individual customer +node cli/dist/index.js customers create \ + --platform-id "your-id" \ + --type INDIVIDUAL \ + --full-name "John Doe" \ + --birth-date "1990-01-15" \ + --address-line1 "123 Main St" \ + --address-city "Seattle" \ + --address-state "WA" \ + --address-postal "98101" \ + --address-country "US" + +# Create business customer +node cli/dist/index.js customers create \ + --platform-id "biz-123" \ + --type BUSINESS \ + --legal-name "Acme Inc" \ + --tax-id "12-3456789" + +# Generate KYC link +node cli/dist/index.js customers kyc-link \ + --customer-id \ + --redirect-url https://example.com/kyc-complete + +# Update customer +node cli/dist/index.js customers update --full-name "Jane Doe" + +# Delete customer +node cli/dist/index.js customers delete +``` + +### Accounts + +#### Internal Accounts (Grid-managed balances) + +```bash +# List customer internal accounts +node cli/dist/index.js accounts internal list [--customer-id ] [--currency USD] + +# List platform internal accounts +node cli/dist/index.js accounts internal list --platform +``` + +#### External Accounts (Bank accounts, wallets) + +```bash +# List external accounts +node cli/dist/index.js accounts external list [--customer-id ] + +# Create US bank account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency USD \ + --account-type US_ACCOUNT \ + --account-number "123456789" \ + --routing-number "021000021" \ + --account-category CHECKING \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "John Doe" + +# Create Mexico CLABE account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency MXN \ + --account-type CLABE \ + --clabe "012345678901234567" \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Carlos Garcia" \ + --beneficiary-birth-date "1988-03-20" \ + --beneficiary-nationality MX + +# Create India UPI account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency INR \ + --account-type UPI \ + --upi-id "name@okaxis" \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Rajesh Kumar" \ + --beneficiary-birth-date "1985-06-15" \ + --beneficiary-nationality IN + +# Create Brazil PIX account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency BRL \ + --account-type PIX \ + --pix-key "12345678901" \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Maria Silva" \ + --beneficiary-birth-date "1990-05-10" \ + --beneficiary-nationality BR + +# Create Nigeria account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency NGN \ + --account-type NGN_ACCOUNT \ + --account-number "1234567890" \ + --bank-name "First Bank" \ + --purpose GOODS_OR_SERVICES \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Chidi Okonkwo" \ + --beneficiary-birth-date "1992-08-20" \ + --beneficiary-nationality NG + +# Create Europe IBAN account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency EUR \ + --account-type IBAN \ + --iban "DE89370400440532013000" \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Hans Mueller" + +# Create crypto wallet (Solana USDC) +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency USDC \ + --account-type SOLANA_WALLET \ + --address "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" +``` + +### Quotes (Cross-Currency Transfers) + +```bash +# List quotes +node cli/dist/index.js quotes list [--status PENDING] [--customer-id ] + +# Get quote details +node cli/dist/index.js quotes get + +# Create quote from internal account (prefunded) +node cli/dist/index.js quotes create \ + --source-account \ + --dest-uma '$user@domain.com' \ + --amount 10000 \ + --lock-side SENDING + +# Create quote with JIT funding (real-time) +node cli/dist/index.js quotes create \ + --source-customer \ + --source-currency USDC \ + --dest-account \ + --dest-currency MXN \ + --amount 100000 \ + --lock-side RECEIVING + +# Execute a pending quote +node cli/dist/index.js quotes execute +``` + +### Same-Currency Transfers + +```bash +# Transfer in (external → internal) +node cli/dist/index.js transfers in \ + --source \ + --dest \ + --amount 10000 + +# Transfer out (internal → external) +node cli/dist/index.js transfers out \ + --source \ + --dest \ + --amount 10000 +``` + +### Transactions + +```bash +# List transactions +node cli/dist/index.js transactions list \ + [--customer-id ] \ + [--status PENDING|PROCESSING|COMPLETED|FAILED] \ + [--type INCOMING|OUTGOING] \ + [--start-date 2024-01-01] \ + [--end-date 2024-12-31] + +# Get transaction details +node cli/dist/index.js transactions get + +# Approve incoming payment +node cli/dist/index.js transactions approve + +# Reject incoming payment +node cli/dist/index.js transactions reject --reason "Invalid sender" +``` + +### Receiver Lookup + +```bash +# Look up UMA address +node cli/dist/index.js receiver lookup-uma '$user@domain.com' + +# Look up external account +node cli/dist/index.js receiver lookup-account +``` + +### Sandbox Testing + +```bash +# Fund an internal account +node cli/dist/index.js sandbox fund --amount 100000 + +# Simulate sending funds to a JIT quote +node cli/dist/index.js sandbox send --quote-id --currency USDC + +# Simulate receiving a UMA payment +node cli/dist/index.js sandbox receive \ + --uma-address '$user@domain.com' \ + --amount 1000 \ + --currency USD +``` + +## Output Format + +All commands output JSON: + +```json +{ + "success": true, + "data": { ... } +} +``` + +On error: + +```json +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "message": "Human readable message" + } +} +``` + +## Common Workflows + +### Send USDC to Mexico (JIT Funding) + +```bash +# 1. Create external account +node cli/dist/index.js accounts external create \ + --customer-id \ + --currency MXN \ + --account-type CLABE \ + --clabe "012345678901234567" \ + --beneficiary-type INDIVIDUAL \ + --beneficiary-name "Carlos Garcia" \ + --beneficiary-birth-date "1988-03-20" \ + --beneficiary-nationality MX + +# 2. Create quote (returns payment instructions) +node cli/dist/index.js quotes create \ + --source-customer \ + --source-currency USDC \ + --dest-account \ + --dest-currency MXN \ + --amount 100000 \ + --lock-side RECEIVING + +# 3. In sandbox, simulate the USDC deposit +node cli/dist/index.js sandbox send --quote-id --currency USDC + +# 4. Check transaction status +node cli/dist/index.js transactions get +``` + +### Send to UMA Address + +```bash +# 1. Look up the receiver +node cli/dist/index.js receiver lookup-uma '$alice@example.com' + +# 2. Create and execute quote +node cli/dist/index.js quotes create \ + --source-account \ + --dest-uma '$alice@example.com' \ + --amount 5000 \ + --lock-side SENDING + +# 3. Execute the quote +node cli/dist/index.js quotes execute +``` + +## Notes + +- All amounts are in the **smallest currency unit** (cents for USD, satoshis for BTC) +- Quotes expire in 1-5 minutes +- JIT quotes auto-execute when funds are received (no manual execute needed) +- Use `--lock-side SENDING` to fix the send amount, `RECEIVING` to fix the receive amount From 8adaf9e99fff83a8687c90b06b0fcaaaf6382337 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Fri, 30 Jan 2026 15:33:26 -0800 Subject: [PATCH 3/3] Improve Grid CLI with validation, configure command, and UX enhancements - Add interactive `grid configure` command for credential setup - Add input validation for dates, amounts, currencies before API calls - Add confirmation prompt for destructive operations (customer delete) - Add --format option (json/table) with colored JSON output - Add --no-color flag for disabling colors - Add command aliases: cust, tx, acct - Add 30s request timeout to prevent hanging - Add .gitignore for dist/ and node_modules/ - Add Node.js engine requirement (>=18.0.0) - Fix parseInt calls to use radix 10 - Improve JSON parse error messages in config loading - Read version from package.json instead of hardcoding - Update README with new features and examples Co-Authored-By: Claude Opus 4.5 --- cli/.gitignore | 9 +++ cli/README.md | 71 ++++++++++++++++++-- cli/package.json | 3 + cli/src/client.ts | 18 ++++- cli/src/commands/accounts.ts | 36 +++++++++- cli/src/commands/configure.ts | 110 +++++++++++++++++++++++++++++++ cli/src/commands/customers.ts | 37 ++++++++++- cli/src/commands/quotes.ts | 34 +++++++++- cli/src/commands/sandbox.ts | 37 +++++++++-- cli/src/commands/transactions.ts | 2 +- cli/src/commands/transfers.ts | 25 ++++++- cli/src/config.ts | 11 +++- cli/src/index.ts | 31 ++++++++- cli/src/output.ts | 81 ++++++++++++++++++++++- cli/src/prompt.ts | 35 ++++++++++ cli/src/validation.ts | 94 ++++++++++++++++++++++++++ 16 files changed, 608 insertions(+), 26 deletions(-) create mode 100644 cli/.gitignore create mode 100644 cli/src/commands/configure.ts create mode 100644 cli/src/prompt.ts create mode 100644 cli/src/validation.ts diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..be4d2b8 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,9 @@ +# Build output +dist/ + +# Dependencies +node_modules/ + +# Environment +.env +.env.local diff --git a/cli/README.md b/cli/README.md index 5176be8..7fcb909 100644 --- a/cli/README.md +++ b/cli/README.md @@ -10,14 +10,21 @@ npm install npm run build ``` -## Configuration +## Quick Start -The CLI requires API credentials via environment variables: +Configure your credentials interactively: + +```bash +node cli/dist/index.js configure +``` + +This will prompt for your API Token ID and Client Secret, validate them, and save to `~/.grid-credentials`. + +Alternatively, set environment variables: ```bash export GRID_API_TOKEN_ID="your-token-id" export GRID_API_CLIENT_SECRET="your-client-secret" -export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" # optional ``` ## Usage @@ -34,11 +41,38 @@ node cli/dist/index.js [options] |--------|-------------| | `-c, --config ` | Path to credentials file | | `-u, --base-url ` | Override API base URL | +| `-f, --format ` | Output format: `json` (default) or `table` | +| `--no-color` | Disable colored output | | `-V, --version` | Show version | | `-h, --help` | Show help | +### Command Aliases + +For convenience, common commands have short aliases: + +| Alias | Command | +|-------|---------| +| `cust` | `customers` | +| `tx` | `transactions` | +| `acct` | `accounts` | + +Example: `grid tx list` is equivalent to `grid transactions list` + ## Commands +### Setup + +```bash +# Interactive configuration +node cli/dist/index.js configure + +# Non-interactive configuration +node cli/dist/index.js configure --token-id --client-secret + +# Skip credential verification +node cli/dist/index.js configure --no-verify +``` + ### Platform Configuration ```bash @@ -85,8 +119,11 @@ node cli/dist/index.js customers kyc-link \ # Update customer node cli/dist/index.js customers update --full-name "Jane Doe" -# Delete customer +# Delete customer (prompts for confirmation) node cli/dist/index.js customers delete + +# Delete customer without confirmation +node cli/dist/index.js customers delete --yes ``` ### Accounts @@ -345,9 +382,35 @@ node cli/dist/index.js quotes create \ node cli/dist/index.js quotes execute ``` +## Output Formats + +### JSON (default) + +```bash +node cli/dist/index.js customers list +``` + +Output includes syntax highlighting when running in a terminal. + +### Table + +```bash +node cli/dist/index.js customers list --format table +``` + +Displays results in a human-readable table format. + +### Disable Colors + +```bash +node cli/dist/index.js customers list --no-color +``` + ## Notes - All amounts are in the **smallest currency unit** (cents for USD, satoshis for BTC) - Quotes expire in 1-5 minutes - JIT quotes auto-execute when funds are received (no manual execute needed) - Use `--lock-side SENDING` to fix the send amount, `RECEIVING` to fix the receive amount +- Destructive operations (like delete) require confirmation unless `--yes` is passed +- Input validation runs before API calls to catch errors early diff --git a/cli/package.json b/cli/package.json index 49b0286..182a816 100644 --- a/cli/package.json +++ b/cli/package.json @@ -6,6 +6,9 @@ "bin": { "grid": "./dist/index.js" }, + "engines": { + "node": ">=18.0.0" + }, "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", diff --git a/cli/src/client.ts b/cli/src/client.ts index d97bc81..af06189 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -20,9 +20,11 @@ export interface PaginatedResponse { export class GridClient { private config: GridConfig; + private timeoutMs: number; - constructor(config: GridConfig) { + constructor(config: GridConfig, timeoutMs: number = 30000) { this.config = config; + this.timeoutMs = timeoutMs; } private getAuthHeader(): string { @@ -75,7 +77,12 @@ export class GridClient { } try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + fetchOptions.signal = controller.signal; + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); const contentType = response.headers.get("content-type"); let data: unknown = null; @@ -101,6 +108,15 @@ export class GridClient { return { success: true, data: data as T }; } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + return { + success: false, + error: { + status: 0, + message: `Request timed out after ${this.timeoutMs}ms`, + }, + }; + } const message = err instanceof Error ? err.message : "Unknown error"; return { success: false, diff --git a/cli/src/commands/accounts.ts b/cli/src/commands/accounts.ts index 6a0d720..98dfd33 100644 --- a/cli/src/commands/accounts.ts +++ b/cli/src/commands/accounts.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +import { validateCurrency, validateDate, validateAll } from "../validation"; interface InternalAccount { id: string; @@ -53,8 +54,17 @@ export function registerAccountsCommand( const client = getClient(opts); if (!client) return; + if (options.currency) { + const validation = validateCurrency(options.currency, "currency"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + } + const params: Record = { - limit: parseInt(options.limit), + limit: parseInt(options.limit, 10), cursor: options.cursor, customerId: options.customerId, currency: options.currency, @@ -88,8 +98,17 @@ export function registerAccountsCommand( const client = getClient(opts); if (!client) return; + if (options.currency) { + const validation = validateCurrency(options.currency, "currency"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + } + const params: Record = { - limit: parseInt(options.limit), + limit: parseInt(options.limit, 10), cursor: options.cursor, customerId: options.customerId, currency: options.currency, @@ -136,6 +155,17 @@ export function registerAccountsCommand( const client = getClient(opts); if (!client) return; + const validations = [validateCurrency(options.currency, "currency")]; + if (options.beneficiaryBirthDate) { + validations.push(validateDate(options.beneficiaryBirthDate, "beneficiary-birth-date")); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + const accountInfo: Record = { accountType: options.accountType, }; diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts new file mode 100644 index 0000000..ca38f99 --- /dev/null +++ b/cli/src/commands/configure.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import * as readline from "readline"; +import { saveCredentials, getCredentialsPath } from "../config"; +import { formatSuccess, formatError, output } from "../output"; + +function prompt(question: string, hidden = false): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + if (hidden && process.stdin.isTTY) { + process.stdout.write(question); + const stdin = process.stdin; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + + let input = ""; + const onData = (char: string) => { + if (char === "\n" || char === "\r" || char === "\u0004") { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + rl.close(); + process.stdout.write("\n"); + resolve(input); + } else if (char === "\u0003") { + process.exit(); + } else if (char === "\u007F" || char === "\b") { + if (input.length > 0) { + input = input.slice(0, -1); + } + } else { + input += char; + } + }; + stdin.on("data", onData); + } else { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + } + }); +} + +async function testCredentials(apiTokenId: string, apiClientSecret: string, baseUrl: string): Promise { + const credentials = `${apiTokenId}:${apiClientSecret}`; + const auth = `Basic ${Buffer.from(credentials).toString("base64")}`; + + try { + const response = await fetch(`${baseUrl}/config`, { + headers: { Authorization: auth, Accept: "application/json" }, + }); + return response.ok; + } catch { + return false; + } +} + +export function registerConfigureCommand(program: Command): void { + program + .command("configure") + .description("Configure Grid API credentials interactively") + .option("--token-id ", "API token ID (skip prompt)") + .option("--client-secret ", "API client secret (skip prompt)") + .option("--base-url ", "API base URL") + .option("--no-verify", "Skip credential verification") + .action(async (options) => { + const credentialsPath = getCredentialsPath(); + console.log(`\nGrid CLI Configuration\n`); + console.log(`Credentials will be saved to: ${credentialsPath}\n`); + + let apiTokenId = options.tokenId; + let apiClientSecret = options.clientSecret; + const baseUrl = options.baseUrl || "https://api.lightspark.com/grid/2025-10-13"; + + if (!apiTokenId) { + apiTokenId = await prompt("API Token ID: "); + } + if (!apiClientSecret) { + apiClientSecret = await prompt("API Client Secret: ", true); + } + + if (!apiTokenId || !apiClientSecret) { + output(formatError("API Token ID and Client Secret are required")); + process.exitCode = 1; + return; + } + + if (options.verify !== false) { + process.stdout.write("Verifying credentials... "); + const valid = await testCredentials(apiTokenId, apiClientSecret, baseUrl); + if (!valid) { + console.log("FAILED"); + output(formatError("Credentials verification failed. Check your API Token ID and Client Secret.")); + process.exitCode = 1; + return; + } + console.log("OK"); + } + + saveCredentials({ apiTokenId, apiClientSecret, baseUrl }); + output(formatSuccess({ + message: "Configuration saved successfully", + credentialsPath, + })); + }); +} diff --git a/cli/src/commands/customers.ts b/cli/src/commands/customers.ts index dfe5195..8e12282 100644 --- a/cli/src/commands/customers.ts +++ b/cli/src/commands/customers.ts @@ -2,6 +2,8 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +import { validateDate, validateCustomerType, validateAll } from "../validation"; +import { confirm } from "../prompt"; interface Customer { id: string; @@ -37,7 +39,7 @@ export function registerCustomersCommand( if (!client) return; const params: Record = { - limit: parseInt(options.limit), + limit: parseInt(options.limit, 10), cursor: options.cursor, platformCustomerId: options.platformId, customerType: options.type, @@ -84,6 +86,17 @@ export function registerCustomersCommand( const client = getClient(opts); if (!client) return; + const validations = [validateCustomerType(options.type)]; + if (options.birthDate) { + validations.push(validateDate(options.birthDate, "birth-date")); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + const body: Record = { platformCustomerId: options.platformId, customerType: options.type, @@ -132,6 +145,15 @@ export function registerCustomersCommand( const client = getClient(opts); if (!client) return; + if (options.birthDate) { + const validation = validateDate(options.birthDate, "birth-date"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + } + const body: Record = {}; if (options.fullName) body.fullName = options.fullName; if (options.birthDate) body.birthDate = options.birthDate; @@ -156,11 +178,22 @@ export function registerCustomersCommand( customersCmd .command("delete ") .description("Delete a customer") - .action(async (customerId: string) => { + .option("-y, --yes", "Skip confirmation prompt") + .action(async (customerId: string, options) => { const opts = program.opts(); const client = getClient(opts); if (!client) return; + if (!options.yes) { + const confirmed = await confirm( + `Are you sure you want to delete customer ${customerId}? This action cannot be undone.` + ); + if (!confirmed) { + console.log("Aborted."); + return; + } + } + const response = await client.delete(`/customers/${customerId}`); outputResponse(response); }); diff --git a/cli/src/commands/quotes.ts b/cli/src/commands/quotes.ts index 152e235..97c28f5 100644 --- a/cli/src/commands/quotes.ts +++ b/cli/src/commands/quotes.ts @@ -1,7 +1,15 @@ import { Command } from "commander"; import { GridClient, PaginatedResponse } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +import { + validateAmount, + validateLockSide, + validateDate, + validateCurrency, + validateAll, + parseAmount, +} from "../validation"; interface Quote { id: string; @@ -58,7 +66,7 @@ export function registerQuotesCommand( if (!client) return; const params: Record = { - limit: parseInt(options.limit), + limit: parseInt(options.limit, 10), cursor: options.cursor, customerId: options.customerId, sendingAccountId: options.sendingAccount, @@ -109,8 +117,28 @@ export function registerQuotesCommand( const client = getClient(opts); if (!client) return; + const validations = [ + validateAmount(options.amount, "amount"), + validateLockSide(options.lockSide), + ]; + if (options.sourceCurrency) { + validations.push(validateCurrency(options.sourceCurrency, "source-currency")); + } + if (options.destCurrency) { + validations.push(validateCurrency(options.destCurrency, "dest-currency")); + } + if (options.senderBirthDate) { + validations.push(validateDate(options.senderBirthDate, "sender-birth-date")); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + const body: Record = { - lockedCurrencyAmount: parseInt(options.amount), + lockedCurrencyAmount: parseAmount(options.amount), lockedCurrencySide: options.lockSide, }; diff --git a/cli/src/commands/sandbox.ts b/cli/src/commands/sandbox.ts index 8e4a2c2..d15cac0 100644 --- a/cli/src/commands/sandbox.ts +++ b/cli/src/commands/sandbox.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import { GridClient } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +import { validateAmount, validateCurrency, validateAll, parseAmount } from "../validation"; export function registerSandboxCommand( program: Command, @@ -22,11 +23,22 @@ export function registerSandboxCommand( const client = getClient(opts); if (!client) return; + const validations = [validateCurrency(options.currency, "currency")]; + if (options.amount) { + validations.push(validateAmount(options.amount, "amount")); + } + const validation = validateAll(validations); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + const body: Record = { quoteId: options.quoteId, currencyCode: options.currency, }; - if (options.amount) body.currencyAmount = parseInt(options.amount); + if (options.amount) body.currencyAmount = parseAmount(options.amount); const response = await client.post("/sandbox/send", body); outputResponse(response); }); @@ -43,9 +55,19 @@ export function registerSandboxCommand( const client = getClient(opts); if (!client) return; + const validation = validateAll([ + validateAmount(options.amount, "amount"), + validateCurrency(options.currency, "currency"), + ]); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + const body: Record = { receiverUmaAddress: options.umaAddress, - amount: parseInt(options.amount), + amount: parseAmount(options.amount), currency: options.currency, }; if (options.senderUma) body.senderUmaAddress = options.senderUma; @@ -63,7 +85,14 @@ export function registerSandboxCommand( const client = getClient(opts); if (!client) return; - const body = { amount: parseInt(options.amount) }; + const validation = validateAmount(options.amount, "amount"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + + const body = { amount: parseAmount(options.amount) }; const response = await client.post( `/sandbox/internal-accounts/${accountId}/fund`, body diff --git a/cli/src/commands/transactions.ts b/cli/src/commands/transactions.ts index 48f9155..cbd449f 100644 --- a/cli/src/commands/transactions.ts +++ b/cli/src/commands/transactions.ts @@ -46,7 +46,7 @@ export function registerTransactionsCommand( if (!client) return; const params: Record = { - limit: parseInt(options.limit), + limit: parseInt(options.limit, 10), cursor: options.cursor, customerId: options.customerId, platformCustomerId: options.platformCustomerId, diff --git a/cli/src/commands/transfers.ts b/cli/src/commands/transfers.ts index 2c76034..bfd324c 100644 --- a/cli/src/commands/transfers.ts +++ b/cli/src/commands/transfers.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import { GridClient } from "../client"; -import { outputResponse } from "../output"; +import { outputResponse, formatError, output } from "../output"; import { GlobalOptions } from "../index"; +import { validateAmount, parseAmount } from "../validation"; interface Transaction { id: string; @@ -32,13 +33,22 @@ export function registerTransfersCommand( const client = getClient(opts); if (!client) return; + if (options.amount) { + const validation = validateAmount(options.amount, "amount"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + } + const body: Record = { source: { accountId: options.source }, destination: { accountId: options.dest }, }; if (options.amount) { - body.amount = parseInt(options.amount); + body.amount = parseAmount(options.amount); } const response = await client.post("/transfer-in", body); @@ -56,13 +66,22 @@ export function registerTransfersCommand( const client = getClient(opts); if (!client) return; + if (options.amount) { + const validation = validateAmount(options.amount, "amount"); + if (!validation.valid) { + output(formatError(validation.error!)); + process.exitCode = 1; + return; + } + } + const body: Record = { source: { accountId: options.source }, destination: { accountId: options.dest }, }; if (options.amount) { - body.amount = parseInt(options.amount); + body.amount = parseAmount(options.amount); } const response = await client.post("/transfer-out", body); diff --git a/cli/src/config.ts b/cli/src/config.ts index 2fc464b..3ba31c7 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -19,11 +19,20 @@ function loadCredentialsFile(): Partial { const credentialsPath = getCredentialsPath(); if (fs.existsSync(credentialsPath)) { const content = fs.readFileSync(credentialsPath, "utf-8"); - return JSON.parse(content); + try { + return JSON.parse(content); + } catch { + throw new Error( + `Invalid JSON in credentials file: ${credentialsPath}. ` + + `Please fix the file or delete it and run 'grid configure'.` + ); + } } return {}; } +export { getCredentialsPath }; + export function loadConfig(options: { configPath?: string; baseUrl?: string; diff --git a/cli/src/index.ts b/cli/src/index.ts index 91b82d2..2bc74f2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,26 +1,41 @@ #!/usr/bin/env node import { Command } from "commander"; +import * as fs from "fs"; +import * as path from "path"; import { loadConfig, GridConfig } from "./config"; import { GridClient } from "./client"; -import { formatError, output } from "./output"; +import { formatError, output, setOutputFormat, setUseColors, OutputFormat } from "./output"; export interface GlobalOptions { config?: string; baseUrl?: string; + format?: OutputFormat; + color?: boolean; } +const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8") +); + const program = new Command(); program .name("grid") .description("CLI for Grid API - manage global payments") - .version("1.0.0") + .version(packageJson.version) .option("-c, --config ", "Path to credentials file") .option( "-u, --base-url ", "Base URL for API (default: https://api.lightspark.com/grid/2025-10-13)" - ); + ) + .option("-f, --format ", "Output format: json or table", "json") + .option("--no-color", "Disable colored output") + .hook("preAction", (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.format) setOutputFormat(opts.format as OutputFormat); + if (opts.color === false) setUseColors(false); + }); function getClient(options: GlobalOptions): GridClient | null { try { @@ -40,6 +55,7 @@ function getClient(options: GlobalOptions): GridClient | null { export { program, getClient, GridClient, GridConfig }; async function main() { + const { registerConfigureCommand } = await import("./commands/configure"); const { registerConfigCommand } = await import("./commands/config"); const { registerCustomersCommand } = await import("./commands/customers"); const { registerAccountsCommand } = await import("./commands/accounts"); @@ -51,6 +67,7 @@ async function main() { const { registerSandboxCommand } = await import("./commands/sandbox"); const { registerReceiverCommand } = await import("./commands/receiver"); + registerConfigureCommand(program); registerConfigCommand(program, getClient); registerCustomersCommand(program, getClient); registerAccountsCommand(program, getClient); @@ -60,6 +77,14 @@ async function main() { registerSandboxCommand(program, getClient); registerReceiverCommand(program, getClient); + const customersCmd = program.commands.find(c => c.name() === "customers"); + const transactionsCmd = program.commands.find(c => c.name() === "transactions"); + const accountsCmd = program.commands.find(c => c.name() === "accounts"); + + if (customersCmd) customersCmd.alias("cust"); + if (transactionsCmd) transactionsCmd.alias("tx"); + if (accountsCmd) accountsCmd.alias("acct"); + await program.parseAsync(process.argv); } diff --git a/cli/src/output.ts b/cli/src/output.ts index dfd8060..98b86ec 100644 --- a/cli/src/output.ts +++ b/cli/src/output.ts @@ -1,5 +1,33 @@ import { ApiResponse } from "./client"; +export type OutputFormat = "json" | "table"; + +let currentFormat: OutputFormat = "json"; +let useColors = process.stdout.isTTY ?? false; + +export function setOutputFormat(format: OutputFormat): void { + currentFormat = format; +} + +export function setUseColors(colors: boolean): void { + useColors = colors; +} + +const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m", + dim: "\x1b[2m", +}; + +function colorize(text: string, color: keyof typeof colors): string { + if (!useColors) return text; + return `${colors[color]}${text}${colors.reset}`; +} + export interface CliOutput { success: boolean; data?: T; @@ -10,7 +38,58 @@ export interface CliOutput { }; } +function colorizeJson(json: string): string { + if (!useColors) return json; + return json + .replace(/"([^"]+)":/g, `${colors.cyan}"$1"${colors.reset}:`) + .replace(/: "([^"]+)"/g, `: ${colors.green}"$1"${colors.reset}`) + .replace(/: (\d+)/g, `: ${colors.yellow}$1${colors.reset}`) + .replace(/: (true|false)/g, `: ${colors.blue}$1${colors.reset}`) + .replace(/: (null)/g, `: ${colors.dim}$1${colors.reset}`); +} + +function formatAsTable(data: T): string { + if (Array.isArray(data)) { + if (data.length === 0) return colorize("No results", "dim"); + const items = data as Record[]; + const keys = Object.keys(items[0]).filter(k => + typeof items[0][k] !== "object" || items[0][k] === null + ); + const widths = keys.map(k => + Math.max(k.length, ...items.map(item => String(item[k] ?? "").length)) + ); + const header = keys.map((k, i) => k.padEnd(widths[i])).join(" "); + const separator = widths.map(w => "-".repeat(w)).join(" "); + const rows = items.map(item => + keys.map((k, i) => String(item[k] ?? "").padEnd(widths[i])).join(" ") + ); + return [colorize(header, "cyan"), colorize(separator, "dim"), ...rows].join("\n"); + } + if (typeof data === "object" && data !== null) { + const obj = data as Record; + const maxKeyLen = Math.max(...Object.keys(obj).map(k => k.length)); + return Object.entries(obj) + .filter(([, v]) => typeof v !== "object" || v === null) + .map(([k, v]) => `${colorize(k.padEnd(maxKeyLen), "cyan")} ${v}`) + .join("\n"); + } + return String(data); +} + export function formatOutput(response: ApiResponse): string { + if (currentFormat === "table") { + if (!response.success) { + const err = response.error; + return colorize(`Error: ${err?.message || "Unknown error"}`, "red") + + (err?.code ? colorize(` (${err.code})`, "dim") : ""); + } + const data = response.data; + if (data && typeof data === "object" && "data" in data) { + return formatAsTable((data as { data: unknown }).data); + } + return formatAsTable(data); + } + const output: CliOutput = { success: response.success, }; @@ -25,7 +104,7 @@ export function formatOutput(response: ApiResponse): string { }; } - return JSON.stringify(output, null, 2); + return colorizeJson(JSON.stringify(output, null, 2)); } export function formatError(message: string, details?: unknown): string { diff --git a/cli/src/prompt.ts b/cli/src/prompt.ts new file mode 100644 index 0000000..ae01982 --- /dev/null +++ b/cli/src/prompt.ts @@ -0,0 +1,35 @@ +import * as readline from "readline"; + +export async function confirm(message: string, defaultValue = false): Promise { + const suffix = defaultValue ? "[Y/n]" : "[y/N]"; + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${message} ${suffix} `, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + if (normalized === "") { + resolve(defaultValue); + } else { + resolve(normalized === "y" || normalized === "yes"); + } + }); + }); +} + +export async function promptInput(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(message, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} diff --git a/cli/src/validation.ts b/cli/src/validation.ts new file mode 100644 index 0000000..c54e429 --- /dev/null +++ b/cli/src/validation.ts @@ -0,0 +1,94 @@ +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export function validateDate(value: string, fieldName: string): ValidationResult { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(value)) { + return { + valid: false, + error: `${fieldName} must be in YYYY-MM-DD format (got: ${value})`, + }; + } + const date = new Date(value); + if (isNaN(date.getTime())) { + return { + valid: false, + error: `${fieldName} is not a valid date (got: ${value})`, + }; + } + return { valid: true }; +} + +export function validateAmount(value: string, fieldName: string): ValidationResult { + const amount = parseInt(value, 10); + if (isNaN(amount)) { + return { + valid: false, + error: `${fieldName} must be a valid integer (got: ${value})`, + }; + } + if (amount < 0) { + return { + valid: false, + error: `${fieldName} must be non-negative (got: ${value})`, + }; + } + return { valid: true }; +} + +export function parseAmount(value: string): number { + const amount = parseInt(value, 10); + if (isNaN(amount)) { + throw new Error(`Invalid amount: ${value}`); + } + return amount; +} + +const VALID_CURRENCIES = new Set([ + "USD", "EUR", "GBP", "MXN", "BRL", "INR", "NGN", "PHP", "KES", + "BTC", "SAT", "USDC", "USDT", +]); + +export function validateCurrency(value: string, fieldName: string): ValidationResult { + const upper = value.toUpperCase(); + if (!VALID_CURRENCIES.has(upper)) { + return { + valid: false, + error: `${fieldName} "${value}" is not a recognized currency. Valid: ${Array.from(VALID_CURRENCIES).join(", ")}`, + }; + } + return { valid: true }; +} + +const VALID_CUSTOMER_TYPES = new Set(["INDIVIDUAL", "BUSINESS"]); + +export function validateCustomerType(value: string): ValidationResult { + if (!VALID_CUSTOMER_TYPES.has(value)) { + return { + valid: false, + error: `Customer type must be INDIVIDUAL or BUSINESS (got: ${value})`, + }; + } + return { valid: true }; +} + +const VALID_LOCK_SIDES = new Set(["SENDING", "RECEIVING"]); + +export function validateLockSide(value: string): ValidationResult { + if (!VALID_LOCK_SIDES.has(value)) { + return { + valid: false, + error: `Lock side must be SENDING or RECEIVING (got: ${value})`, + }; + } + return { valid: true }; +} + +export function validateAll(validations: ValidationResult[]): ValidationResult { + for (const v of validations) { + if (!v.valid) return v; + } + return { valid: true }; +}