From d44bf2e8032941b1622c3aec09ca93fdfad8a7ff Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 07:33:34 -0300 Subject: [PATCH 01/10] =?UTF-8?q?First=20version=20=E2=80=94=20production-?= =?UTF-8?q?ready=20CLI=20for=20Blindpay=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip mock server, lifecycle, webhooks, and all related code - Live-only API client with config file and env var support - All resource commands with --help examples - Typed options, proper error handling, process.exit(1) on failures - CI workflow (typecheck + lint + test) and publish workflow (npm on v* tags) - 32 tests covering config, api-client, and output utilities --- .github/workflows/ci.yml | 34 ++ .github/workflows/publish.yml | 47 +++ .gitignore | 7 + README.md | 178 ++++++++ bun.lock | 82 ++++ oxlint.json | 24 ++ package.json | 60 +++ src/__tests__/api-client.test.ts | 79 ++++ src/__tests__/config.test.ts | 103 +++++ src/__tests__/output.test.ts | 108 +++++ src/commands/resources.ts | 691 +++++++++++++++++++++++++++++++ src/index.ts | 511 +++++++++++++++++++++++ src/utils/api-client.ts | 112 +++++ src/utils/config.ts | 115 +++++ src/utils/constants.ts | 14 + src/utils/output.ts | 63 +++ tsconfig.json | 21 + 17 files changed, 2249 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 oxlint.json create mode 100644 package.json create mode 100644 src/__tests__/api-client.test.ts create mode 100644 src/__tests__/config.test.ts create mode 100644 src/__tests__/output.test.ts create mode 100644 src/commands/resources.ts create mode 100644 src/index.ts create mode 100644 src/utils/api-client.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/output.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95c40c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.2' + + - run: bun install --frozen-lockfile + + - run: bun run typecheck + + - run: bun run lint + + - run: bun test + + - run: bun run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7b2093f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.2' + + - run: bun install --frozen-lockfile + + - name: Verify tag matches package.json version + run: | + PKG_VERSION="v$(node -p "require('./package.json').version")" + GIT_TAG="${GITHUB_REF#refs/tags/}" + if [ "$PKG_VERSION" != "$GIT_TAG" ]; then + echo "Tag $GIT_TAG does not match package.json version $PKG_VERSION" + exit 1 + fi + + - run: bun run typecheck + + - run: bun run lint + + - run: bun test + + - run: bun run build + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f730b50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.turbo/ +*.tsbuildinfo +.DS_Store +.env +.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ed1e13 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# @blindpay/cli + +Blindpay CLI — manage receivers, bank accounts, payouts, payins, and more from the terminal. + +## Installation + +```bash +npm install -g @blindpay/cli +``` + +Or run directly with npx: + +```bash +npx @blindpay/cli +``` + +## Configuration + +Set your API key and instance ID (from the [Blindpay dashboard](https://dashboard.blindpay.com)): + +```bash +blindpay config set --api-key sk_live_... --instance-id inst_... +``` + +You can also use environment variables: + +```bash +export BLINDPAY_API_KEY=sk_live_... +export BLINDPAY_INSTANCE_ID=inst_... +export BLINDPAY_API_URL=https://api.blindpay.com # optional +``` + +View current config: + +```bash +blindpay config get +``` + +## Quick Start + +```bash +# Create a receiver +blindpay receivers create --email user@example.com --name "John Doe" --country US + +# Add a bank account +blindpay bank_accounts create --receiver-id --type ach \ + --routing-number 021000021 --account-number 123456789 + +# Create a quote and payout +blindpay quotes create --bank-account-id --amount 5000 --network base --token USDC +blindpay payouts create --quote-id --network evm + +# Check payout status +blindpay payouts get +``` + +## Commands + +Every command supports `--help` for detailed usage and examples. + +### Config + +| Command | Description | +|---------|-------------| +| `config set` | Set API key, instance ID, or base URL | +| `config get` | Show current config (API key masked) | +| `config clear` | Remove saved config | +| `config path` | Print config file path | + +### Receivers + +| Command | Description | +|---------|-------------| +| `receivers list` | List all receivers | +| `receivers get ` | Get a receiver by ID | +| `receivers create` | Create a new receiver | +| `receivers update ` | Update a receiver | +| `receivers delete ` | Delete a receiver | + +### Bank Accounts + +| Command | Description | +|---------|-------------| +| `bank_accounts list` | List bank accounts (requires `--receiver-id`) | +| `bank_accounts get ` | Get a bank account (requires `--receiver-id`) | +| `bank_accounts create` | Create a bank account (requires `--receiver-id`) | +| `bank_accounts delete ` | Delete a bank account (requires `--receiver-id`) | + +### Blockchain Wallets + +| Command | Description | +|---------|-------------| +| `blockchain_wallets list` | List wallets (requires `--receiver-id`) | +| `blockchain_wallets get ` | Get a wallet (requires `--receiver-id`) | +| `blockchain_wallets create` | Create a wallet (requires `--receiver-id`, `--address`) | +| `blockchain_wallets delete ` | Delete a wallet (requires `--receiver-id`) | + +### Quotes & Payouts + +| Command | Description | +|---------|-------------| +| `quotes create` | Create a payout quote (requires `--bank-account-id`) | +| `payouts list` | List all payouts (optional `--status` filter) | +| `payouts get ` | Get a payout by ID | +| `payouts create` | Create a payout (requires `--quote-id`) | + +### Payin Quotes & Payins + +| Command | Description | +|---------|-------------| +| `payin_quotes create` | Create a payin quote (requires `--blockchain-wallet-id`, `--payment-method`) | +| `payins list` | List all payins | +| `payins get ` | Get a payin by ID | +| `payins create` | Create a payin (requires `--payin-quote-id`) | + +### Webhook Endpoints + +| Command | Description | +|---------|-------------| +| `webhook_endpoints list` | List webhook endpoints | +| `webhook_endpoints create` | Create a webhook endpoint (requires `--url`) | +| `webhook_endpoints delete ` | Delete a webhook endpoint | + +### Partner Fees + +| Command | Description | +|---------|-------------| +| `partner_fees list` | List partner fees | +| `partner_fees create` | Create a partner fee | +| `partner_fees delete ` | Delete a partner fee | + +### API Keys + +| Command | Description | +|---------|-------------| +| `api_keys list` | List API keys | +| `api_keys create` | Create an API key | +| `api_keys delete ` | Delete an API key | + +### Virtual Accounts + +| Command | Description | +|---------|-------------| +| `virtual_accounts list` | List virtual accounts (requires `--receiver-id`) | +| `virtual_accounts create` | Create a virtual account (requires `--receiver-id`, `--blockchain-wallet-id`) | + +### Offramp Wallets + +| Command | Description | +|---------|-------------| +| `offramp_wallets list` | List offramp wallets (requires `--receiver-id`, `--bank-account-id`) | + +### Reference Data + +| Command | Description | +|---------|-------------| +| `available rails` | List available payment rails | +| `available bank_details --rail ` | Show required fields for a rail | + +## Global Options + +| Option | Description | +|--------|-------------| +| `--json` | Output as JSON (available on most commands) | +| `--version` | Show CLI version | +| `--help` | Show help | + +## Updating + +```bash +blindpay update +# or directly: +npm install -g @blindpay/cli@latest +``` + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..86828d5 --- /dev/null +++ b/bun.lock @@ -0,0 +1,82 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@blindpay/cli", + "dependencies": { + "@clack/prompts": "^1.0.1", + "@commander-js/extra-typings": "^14.0.0", + "commander": "^14.0.0", + "picocolors": "^1.1.0", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "oxlint": "^1.48.0", + "typescript": "^5.5.0", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.0.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g=="], + + "@clack/prompts": ["@clack/prompts@1.0.1", "", { "dependencies": { "@clack/core": "1.0.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q=="], + + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.48.0", "", { "os": "android", "cpu": "arm" }, "sha512-1Pz/stJvveO9ZO7ll4ZoEY3f6j2FiUgBLBcCRCiW6ylId9L9UKs+gn3X28m3eTnoiFCkhKwmJJ+VO6vwsu7Qtg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.48.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zc42RWGE8huo6Ht0lXKjd0NH2lWNmimQHUmD0JFcvShLOuwN+RSEE/kRakc2/0LIgOUuU/R7PaDMCOdQlPgNUQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.48.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jgZs563/4vaG5jH2RSt2TSh8A2jwsFdmhLXrElMdm3Mmto0HPf85FgInLSNi9HcwzQFvkYV8JofcoUg2GH1HTA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.48.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-kvo87BujEUjCJREuWDC4aPh1WoXCRFFWE4C7uF6wuoMw2f6N2hypA/cHHcYn9DdL8R2RrgUZPefC8JExyeIMKA=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.48.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-eyzzPaHQKn0RIM+ueDfgfJF2RU//Wp4oaKs2JVoVYcM5HjbCL36+O0S3wO5Xe1NWpcZIG3cEHc/SuOCDRqZDSg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.48.0", "", { "os": "linux", "cpu": "arm" }, "sha512-p3kSloztK7GRO7FyO3u38UCjZxQTl92VaLDsMQAq0eGoiNmeeEF1KPeE4+Fr+LSkQhF8WvJKSuls6TwOlurdPA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.48.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uWM+wiTqLW/V0ZmY/eyTWs8ykhIkzU+K2tz/8m35YepYEzohiUGRbnkpAFXj2ioXpQL+GUe5vmM3SLH6ozlfFw=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.48.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OhQNPjs/OICaYqxYJjKKMaIY7p3nJ9IirXcFoHKD+CQE1BZFCeUUAknMzUeLclDCfudH9Vb/UgjFm8+ZM5puAg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.48.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-adu5txuwGvQ4C4fjYHJD+vnY+OCwCixBzn7J3KF3iWlVHBBImcosSv+Ye+fbMMJui4HGjifNXzonjKm9pXmOiw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.48.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-inlQQRUnHCny/7b7wA6NjEoJSSZPNea4qnDhWyeqBYWx8ukf2kzNDSiamfhOw6bfAYPm/PVlkVRYaNXQbkLeTQ=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.48.0", "", { "os": "linux", "cpu": "none" }, "sha512-YiJx6sW6bYebQDZRVWLKm/Drswx/hcjIgbLIhULSn0rRcBKc7d9V6mkqPjKDbhcxJgQD5Zi0yVccJiOdF40AWA=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.48.0", "", { "os": "linux", "cpu": "none" }, "sha512-zwSqxMgmb2ITamNfDv9Q9EKBc/4ZhCBP9gkg2hhcgR6sEVGPUDl1AKPC89CBKMxkmPUi3685C38EvqtZn5OtHw=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.48.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-c/+2oUWAOsQB5JTem0rW8ODlZllF6pAtGSGXoLSvPTonKI1vAwaKhD9Qw1X36jRbcI3Etkpu/9z/RRjMba8vFQ=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.48.0", "", { "os": "linux", "cpu": "x64" }, "sha512-PhauDqeFW5DGed6QxCY5lXZYKSlcBdCXJnH03ZNU6QmDZ0BFM/zSy1oPT2MNb1Afx1G6yOOVk8ErjWsQ7c59ng=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.48.0", "", { "os": "linux", "cpu": "x64" }, "sha512-6d7LIFFZGiavbHndhf1cK9kG9qmy2Dmr37sV9Ep7j3H+ciFdKSuOzdLh85mEUYMih+b+esMDlF5DU0WQRZPQjw=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.48.0", "", { "os": "none", "cpu": "arm64" }, "sha512-r+0KK9lK6vFp3tXAgDMOW32o12dxvKS3B9La1uYMGdWAMoSeu2RzG34KmzSpXu6MyLDl4aSVyZLFM8KGdEjwaw=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.48.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Nkw/MocyT3HSp0OJsKPXrcbxZqSPMTYnLLfsqsoiFKoL1ppVNL65MFa7vuTxJehPlBkjy+95gUgacZtuNMECrg=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.48.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-reO1SpefvRmeZSP+WeyWkQd1ArxxDD1MyKgMUKuB8lNuUoxk9QEohYtKnsfsxJuFwMT0JTr7p9wZjouA85GzGQ=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.48.0", "", { "os": "win32", "cpu": "x64" }, "sha512-T6zwhfcsrorqAybkOglZdPkTLlEwipbtdO1qjE+flbawvwOMsISoyiuaa7vM7zEyfq1hmDvMq1ndvkYFioranA=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "oxlint": ["oxlint@1.48.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.48.0", "@oxlint/binding-android-arm64": "1.48.0", "@oxlint/binding-darwin-arm64": "1.48.0", "@oxlint/binding-darwin-x64": "1.48.0", "@oxlint/binding-freebsd-x64": "1.48.0", "@oxlint/binding-linux-arm-gnueabihf": "1.48.0", "@oxlint/binding-linux-arm-musleabihf": "1.48.0", "@oxlint/binding-linux-arm64-gnu": "1.48.0", "@oxlint/binding-linux-arm64-musl": "1.48.0", "@oxlint/binding-linux-ppc64-gnu": "1.48.0", "@oxlint/binding-linux-riscv64-gnu": "1.48.0", "@oxlint/binding-linux-riscv64-musl": "1.48.0", "@oxlint/binding-linux-s390x-gnu": "1.48.0", "@oxlint/binding-linux-x64-gnu": "1.48.0", "@oxlint/binding-linux-x64-musl": "1.48.0", "@oxlint/binding-openharmony-arm64": "1.48.0", "@oxlint/binding-win32-arm64-msvc": "1.48.0", "@oxlint/binding-win32-ia32-msvc": "1.48.0", "@oxlint/binding-win32-x64-msvc": "1.48.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.12.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-m5vyVBgPtPhVCJc3xI//8je9lRc8bYuYB4R/1PH3VPGOjA4vjVhkHtyJukdEjYEjwrw4Qf1eIf+pP9xvfhfMow=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/oxlint.json b/oxlint.json new file mode 100644 index 0000000..a047bcb --- /dev/null +++ b/oxlint.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-json-schema/refs/heads/main/packages/schema/oxlint-config-schema.json", + "rules": { + "no-unused-vars": "warn", + "no-console": "off", + "eqeqeq": "error", + "no-var": "error", + "prefer-const": "warn", + "no-debugger": "error", + "no-empty": "warn", + "no-extra-boolean-cast": "warn", + "no-unsafe-negation": "error", + "no-constant-condition": "warn", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-pattern": "warn", + "no-self-assign": "error", + "no-self-compare": "error", + "no-template-curly-in-string": "warn", + "no-unreachable": "error", + "no-loss-of-precision": "error" + }, + "ignorePatterns": ["dist/**", "node_modules/**", "*.test.ts"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ddc5cf --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "@blindpay/cli", + "type": "module", + "version": "0.1.0", + "description": "Blindpay CLI - manage receivers, bank accounts, payouts, payins, and more from the terminal", + "license": "MIT", + "author": "Blindpay (https://blindpay.com/)", + "repository": { + "type": "git", + "url": "https://github.com/blindpaylabs/blindpay-cli.git" + }, + "homepage": "https://github.com/blindpaylabs/blindpay-cli#readme", + "bugs": { + "url": "https://github.com/blindpaylabs/blindpay-cli/issues" + }, + "keywords": [ + "blindpay", + "cli", + "api", + "payments", + "crypto", + "stablecoin", + "payout", + "payin" + ], + "bin": { + "blindpay": "./dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "bun run src/index.ts", + "build": "bun build src/index.ts --outdir dist --target node --minify && node -e \"const fs=require('fs');const f='dist/index.js';const c=fs.readFileSync(f,'utf8');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"", + "typecheck": "tsc --noEmit", + "lint": "oxlint -c oxlint.json src/", + "lint:fix": "oxlint -c oxlint.json --fix src/", + "test": "bun test", + "prepublishOnly": "bun run build" + }, + "dependencies": { + "@clack/prompts": "^1.0.1", + "@commander-js/extra-typings": "^14.0.0", + "commander": "^14.0.0", + "picocolors": "^1.1.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "oxlint": "^1.48.0", + "typescript": "^5.5.0" + } +} diff --git a/src/__tests__/api-client.test.ts b/src/__tests__/api-client.test.ts new file mode 100644 index 0000000..d795dd7 --- /dev/null +++ b/src/__tests__/api-client.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'bun:test' +import { resolveContext } from '../utils/api-client' + +function saveEnv(...keys: string[]) { + const saved = new Map() + for (const key of keys) + saved.set(key, process.env[key]) + return { + restore() { + for (const [key, val] of saved) { + if (val !== undefined) process.env[key] = val + else delete process.env[key] + } + }, + } +} + +describe('api-client', () => { + test('resolveContext throws when no config is set', () => { + const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'XDG_CONFIG_HOME') + delete process.env.BLINDPAY_API_KEY + delete process.env.BLINDPAY_INSTANCE_ID + process.env.XDG_CONFIG_HOME = '/tmp/blindpay-cli-test-no-config-' + Date.now() + + try { + expect(() => resolveContext()).toThrow('No API key configured') + } + finally { + env.restore() + } + }) + + test('resolveContext uses env vars', () => { + const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'BLINDPAY_API_URL') + process.env.BLINDPAY_API_KEY = 'sk_test_key' + process.env.BLINDPAY_INSTANCE_ID = 'inst_test' + delete process.env.BLINDPAY_API_URL + + try { + const ctx = resolveContext() + expect(ctx.instanceId).toBe('inst_test') + expect(ctx.headers.Authorization).toBe('Bearer sk_test_key') + expect(ctx.baseUrl).toBe('https://api.blindpay.com') + } + finally { + env.restore() + } + }) + + test('resolveContext uses custom base URL from env', () => { + const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID', 'BLINDPAY_API_URL') + process.env.BLINDPAY_API_KEY = 'sk_test_key' + process.env.BLINDPAY_INSTANCE_ID = 'inst_test' + process.env.BLINDPAY_API_URL = 'https://custom.example.com/' + + try { + const ctx = resolveContext() + expect(ctx.baseUrl).toBe('https://custom.example.com') + } + finally { + env.restore() + } + }) + + test('resolveContext includes User-Agent and X-Blindpay-Client headers', () => { + const env = saveEnv('BLINDPAY_API_KEY', 'BLINDPAY_INSTANCE_ID') + process.env.BLINDPAY_API_KEY = 'sk_test_key' + process.env.BLINDPAY_INSTANCE_ID = 'inst_test' + + try { + const ctx = resolveContext() + expect(ctx.headers['User-Agent']).toMatch(/^blindpay-cli\//) + expect(ctx.headers['X-Blindpay-Client']).toBe('cli') + } + finally { + env.restore() + } + }) +}) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..8a5eca9 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,103 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { getConfig, setConfig, clearConfig, getConfigPath, hasLiveConfig } from '../utils/config' + +const TEST_DIR = path.join(os.tmpdir(), `blindpay-cli-test-${Date.now()}`) +const TEST_CONFIG_DIR = path.join(TEST_DIR, 'blindpay') + +beforeEach(() => { + process.env.XDG_CONFIG_HOME = TEST_DIR + delete process.env.BLINDPAY_API_KEY + delete process.env.BLINDPAY_INSTANCE_ID + delete process.env.BLINDPAY_API_URL + if (fs.existsSync(TEST_CONFIG_DIR)) + fs.rmSync(TEST_CONFIG_DIR, { recursive: true }) +}) + +afterEach(() => { + delete process.env.XDG_CONFIG_HOME + if (fs.existsSync(TEST_DIR)) + fs.rmSync(TEST_DIR, { recursive: true }) +}) + +describe('config', () => { + test('getConfigPath returns path under XDG_CONFIG_HOME', () => { + const p = getConfigPath() + expect(p).toBe(path.join(TEST_CONFIG_DIR, 'config.json')) + }) + + test('getConfig returns defaults when no config file exists', () => { + const config = getConfig() + expect(config.api_key).toBeNull() + expect(config.instance_id).toBeNull() + expect(config.base_url).toBeNull() + }) + + test('setConfig writes and getConfig reads back', () => { + setConfig({ api_key: 'sk_test_123', instance_id: 'inst_abc' }) + const config = getConfig() + expect(config.api_key).toBe('sk_test_123') + expect(config.instance_id).toBe('inst_abc') + expect(config.base_url).toBeNull() + }) + + test('setConfig merges with existing config', () => { + setConfig({ api_key: 'sk_test_123' }) + setConfig({ instance_id: 'inst_abc' }) + const config = getConfig() + expect(config.api_key).toBe('sk_test_123') + expect(config.instance_id).toBe('inst_abc') + }) + + test('env vars override file config', () => { + setConfig({ api_key: 'file_key', instance_id: 'file_id' }) + process.env.BLINDPAY_API_KEY = 'env_key' + process.env.BLINDPAY_INSTANCE_ID = 'env_id' + const config = getConfig() + expect(config.api_key).toBe('env_key') + expect(config.instance_id).toBe('env_id') + }) + + test('BLINDPAY_API_URL env var overrides base_url', () => { + setConfig({ base_url: 'https://file.example.com' }) + process.env.BLINDPAY_API_URL = 'https://env.example.com' + const config = getConfig() + expect(config.base_url).toBe('https://env.example.com') + }) + + test('clearConfig removes config file', () => { + setConfig({ api_key: 'sk_test_123' }) + expect(fs.existsSync(getConfigPath())).toBe(true) + const result = clearConfig() + expect(result).toBe(true) + expect(fs.existsSync(getConfigPath())).toBe(false) + }) + + test('clearConfig returns false when no file exists', () => { + const result = clearConfig() + expect(result).toBe(false) + }) + + test('hasLiveConfig returns false with no config', () => { + expect(hasLiveConfig()).toBe(false) + }) + + test('hasLiveConfig returns true with api_key and instance_id', () => { + setConfig({ api_key: 'sk_test_123', instance_id: 'inst_abc' }) + expect(hasLiveConfig()).toBe(true) + }) + + test('hasLiveConfig returns false with only api_key', () => { + setConfig({ api_key: 'sk_test_123' }) + expect(hasLiveConfig()).toBe(false) + }) + + test('config file has restricted permissions', () => { + setConfig({ api_key: 'sk_test_123' }) + const stat = fs.statSync(getConfigPath()) + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + }) +}) diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts new file mode 100644 index 0000000..b547e13 --- /dev/null +++ b/src/__tests__/output.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect } from 'bun:test' +import { formatTable, formatJson, formatKeyValue, formatOutput, truncate } from '../utils/output' + +describe('formatTable', () => { + test('returns "No data found." for empty array', () => { + const result = formatTable([]) + expect(result).toContain('No data found.') + }) + + test('renders rows with headers', () => { + const data = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + ] + const result = formatTable(data) + expect(result).toContain('id') + expect(result).toContain('name') + expect(result).toContain('Alice') + expect(result).toContain('Bob') + }) + + test('uses specified columns', () => { + const data = [{ id: '1', name: 'Alice', secret: 'hidden' }] + const result = formatTable(data, ['id', 'name']) + expect(result).toContain('id') + expect(result).toContain('name') + expect(result).not.toContain('secret') + expect(result).not.toContain('hidden') + }) +}) + +describe('formatJson', () => { + test('formats object as JSON with indentation', () => { + const result = formatJson({ a: 1 }) + expect(result).toBe('{\n "a": 1\n}') + }) + + test('formats array as JSON', () => { + const result = formatJson([1, 2]) + expect(result).toBe('[\n 1,\n 2\n]') + }) +}) + +describe('formatKeyValue', () => { + test('renders key-value pairs', () => { + const result = formatKeyValue({ id: '123', name: 'Test' }) + expect(result).toContain('id') + expect(result).toContain('123') + expect(result).toContain('name') + expect(result).toContain('Test') + }) + + test('handles null values', () => { + const result = formatKeyValue({ id: '123', optional: null }) + expect(result).toContain('id') + expect(result).toContain('123') + }) + + test('returns (empty) for empty object', () => { + const result = formatKeyValue({}) + expect(result).toContain('(empty)') + }) + + test('stringifies nested objects', () => { + const result = formatKeyValue({ data: { nested: true } }) + expect(result).toContain('{"nested":true}') + }) +}) + +describe('formatOutput', () => { + test('uses JSON format when json=true', () => { + const result = formatOutput({ a: 1 }, true) + expect(result).toBe('{\n "a": 1\n}') + }) + + test('uses table format for arrays when json=false', () => { + const result = formatOutput([{ id: '1' }], false) + expect(result).toContain('id') + expect(result).toContain('1') + }) + + test('uses key-value format for objects when json=false', () => { + const result = formatOutput({ id: '123' }, false) + expect(result).toContain('id') + expect(result).toContain('123') + }) +}) + +describe('truncate', () => { + test('returns short strings unchanged', () => { + expect(truncate('hello', 10)).toBe('hello') + }) + + test('truncates long strings with ellipsis', () => { + expect(truncate('a very long string here', 10)).toBe('a very ...') + }) + + test('handles exact length', () => { + expect(truncate('hello', 5)).toBe('hello') + }) + + test('uses default max of 32', () => { + const long = 'a'.repeat(50) + const result = truncate(long) + expect(result.length).toBe(32) + expect(result).toEndWith('...') + }) +}) diff --git a/src/commands/resources.ts b/src/commands/resources.ts new file mode 100644 index 0000000..dd4a388 --- /dev/null +++ b/src/commands/resources.ts @@ -0,0 +1,691 @@ +import process from 'node:process' +import * as clack from '@clack/prompts' +import pc from 'picocolors' +import { formatOutput, truncate } from '../utils/output' +import type { ApiContext, ApiError, ValidationErrorItem } from '../utils/api-client' +import { apiGet, apiPost, apiPut, apiDelete, resolveContext } from '../utils/api-client' +import { availableRails } from '../utils/constants' + +function instancePath(ctx: ApiContext) { + return `/v1/instances/${ctx.instanceId}` +} + +function printResult(data: unknown, json: boolean, columns?: string[]) { + console.log(formatOutput(data, json, columns)) +} + +function formatValidationError(item: ValidationErrorItem): string { + const path = Array.isArray(item.path) ? item.path.filter(Boolean).join('.') : '' + return path ? `${path}: ${item.message}` : item.message +} + +function handleApiError(err: unknown): never { + const msg = err instanceof Error ? err.message : String(err) + clack.log.error(msg) + const apiErr = err as ApiError + if (apiErr.validationErrors && apiErr.validationErrors.length > 0) { + for (const ve of apiErr.validationErrors) + console.log(pc.dim(` • ${formatValidationError(ve)}`)) + } + process.exit(1) +} + +function extractList(res: any): any[] { + if (Array.isArray(res)) + return res + if (res?.data && Array.isArray(res.data)) + return res.data + return [] +} + +// Receivers +export async function listReceivers(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers`) + const list = extractList(res) + const display = list.map((r: any) => ({ + id: r.id, + type: r.type, + name: r.type === 'individual' ? `${r.first_name || ''} ${r.last_name || ''}`.trim() || '-' : r.legal_name || '-', + email: r.email, + country: r.country, + kyc_status: r.kyc_status, + })) + printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'email', 'country', 'kyc_status']) + } + catch (e) { + handleApiError(e) + } +} + +export async function getReceiver(id: string, options: { json: boolean }) { + try { + const ctx = resolveContext() + const receiver = await apiGet(ctx, `${instancePath(ctx)}/receivers/${id}`) + printResult(receiver, options.json) + } + catch (e) { + handleApiError(e) + } +} + +export async function createReceiver(options: { + email: string + type?: string + name?: string + firstName?: string + lastName?: string + legalName?: string + country?: string + taxId?: string + externalId?: string + kycStatus?: string + json: boolean +}) { + try { + const ctx = resolveContext() + let first_name = options.firstName ?? null + let last_name = options.lastName ?? null + if (options.name !== null && options.name !== undefined && String(options.name).trim()) { + const parts = String(options.name).trim().split(/\s+/) + if (parts.length >= 2) { + first_name = parts[0] + last_name = parts.slice(1).join(' ') + } + else { + first_name = parts[0] + } + } + const body = { + type: options.type || 'individual', + email: options.email, + tax_id: options.taxId ?? null, + first_name: first_name ?? null, + last_name: last_name ?? null, + legal_name: options.legalName ?? null, + country: options.country || 'US', + external_id: options.externalId ?? null, + kyc_status: options.kycStatus ?? 'approved', + } + const receiver = await apiPost<{ id: string, type: string }>(ctx, `${instancePath(ctx)}/receivers`, body) + const displayName = body.type === 'business' + ? (body.legal_name || '—') + : [body.first_name, body.last_name].filter(Boolean).join(' ').trim() || '—' + clack.log.success(`Created receiver ${receiver.id} (${receiver.type}, ${displayName})`) + if (options.json) + console.log(formatOutput(receiver, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function updateReceiver( + id: string, + options: { + name?: string + firstName?: string + lastName?: string + legalName?: string + email?: string + country?: string + kycStatus?: string + json?: boolean + }, +) { + try { + const ctx = resolveContext() + const body: Record = {} + if (options.firstName !== undefined) + body.first_name = options.firstName + if (options.lastName !== undefined) + body.last_name = options.lastName + if (options.legalName !== undefined) + body.legal_name = options.legalName + if (options.email !== undefined) + body.email = options.email + if (options.country !== undefined) + body.country = options.country + if (options.kycStatus !== undefined) + body.kyc_status = options.kycStatus + if (options.name !== undefined && options.name.trim()) { + const parts = options.name.trim().split(/\s+/) + if (parts.length >= 2) { + body.first_name = parts[0] + body.last_name = parts.slice(1).join(' ') + } + else { + body.first_name = parts[0] + body.last_name = null + } + } + if (Object.keys(body).length === 0) { + clack.log.error('Provide at least one field to update (e.g. --name, --kyc-status)') + process.exit(1) + } + const receiver = await apiPut>(ctx, `${instancePath(ctx)}/receivers/${id}`, body) + clack.log.success(`Updated receiver ${id}`) + if (options.json) + console.log(formatOutput(receiver, true)) + else + console.log(formatOutput(receiver, false)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deleteReceiver(id: string) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/receivers/${id}`) + clack.log.success(`Deleted receiver ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// Bank Accounts +export async function listBankAccounts(options: { receiverId: string, json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts`) + const list = extractList(res) + const display = list.map((a: any) => ({ id: a.id, type: a.type, name: a.name, status: a.status, country: a.country })) + printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'status', 'country']) + } + catch (e) { + handleApiError(e) + } +} + +export async function getBankAccount(id: string, options: { receiverId: string, json: boolean }) { + try { + const ctx = resolveContext() + const account = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${id}`) + printResult(account, options.json) + } + catch (e) { + handleApiError(e) + } +} + +export async function createBankAccount(options: { + receiverId: string + type?: string + name?: string + recipientRelationship?: string + pixKey?: string + beneficiaryName?: string + routingNumber?: string + accountNumber?: string + accountType?: string + accountClass?: string + country?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const body = { + type: options.type || 'ach', + name: options.name || 'CLI Bank Account', + recipient_relationship: options.recipientRelationship ?? null, + pix_key: options.pixKey ?? null, + beneficiary_name: options.beneficiaryName ?? null, + routing_number: options.routingNumber ?? null, + account_number: options.accountNumber ?? null, + account_type: options.accountType ?? null, + account_class: options.accountClass ?? null, + country: options.country ?? null, + } + const ba = await apiPost<{ id: string, type: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts`, body) + clack.log.success(`Created bank account ${ba.id} (${ba.type})`) + if (options.json) + console.log(formatOutput(ba, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deleteBankAccount(id: string, options: { receiverId: string }) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${id}`) + clack.log.success(`Deleted bank account ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// Blockchain Wallets +export async function listBlockchainWallets(options: { receiverId: string, json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets`) + const list = extractList(res) + const display = list.map((w: any) => ({ id: w.id, address: truncate(w.address, 20), network: w.network })) + printResult(options.json ? list : display, options.json, ['id', 'address', 'network']) + } + catch (e) { + handleApiError(e) + } +} + +export async function getBlockchainWallet(id: string, options: { receiverId: string, json: boolean }) { + try { + const ctx = resolveContext() + const wallet = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets/${id}`) + printResult(wallet, options.json) + } + catch (e) { + handleApiError(e) + } +} + +export async function createBlockchainWallet(options: { + receiverId: string + address: string + network?: string + externalId?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const body = { + address: options.address, + network: options.network || 'base', + external_id: options.externalId ?? null, + } + const wallet = await apiPost<{ id: string, network: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets`, body) + clack.log.success(`Created blockchain wallet ${wallet.id} (${wallet.network})`) + if (options.json) + console.log(formatOutput(wallet, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deleteBlockchainWallet(id: string, options: { receiverId: string }) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets/${id}`) + clack.log.success(`Deleted blockchain wallet ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// Payouts +export async function listPayouts(options: { json: boolean, status?: string }) { + try { + const ctx = resolveContext() + const endpoint = options.status ? `${instancePath(ctx)}/payouts?status=${encodeURIComponent(options.status)}` : `${instancePath(ctx)}/payouts` + const res = await apiGet(ctx, endpoint) + const list = extractList(res) + const display = list.map((p: any) => ({ + id: p.id, + status: p.status, + amount: p.sender_amount !== null && p.sender_amount !== undefined ? `${(p.sender_amount / 100)} ${p.token || 'USDC'}` : '-', + network: p.network || '-', + created_at: p.created_at, + })) + printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'network', 'created_at']) + } + catch (e) { + handleApiError(e) + } +} + +export async function getPayout(id: string, options: { json: boolean }) { + try { + const ctx = resolveContext() + const payout = await apiGet(ctx, `${instancePath(ctx)}/payouts/${id}`) + printResult(payout, options.json) + } + catch (e) { + handleApiError(e) + } +} + +export async function createPayout(options: { quoteId: string, network?: string, senderWalletAddress: string, json: boolean }) { + try { + const ctx = resolveContext() + const network = (options.network ?? 'evm').toLowerCase() + if (!['evm', 'solana', 'stellar'].includes(network)) { + clack.log.error(`Invalid network: ${options.network}. Use evm, solana, or stellar.`) + process.exit(1) + } + const body = { + quote_id: options.quoteId, + sender_wallet_address: options.senderWalletAddress, + } + const payout = await apiPost<{ id: string, status: string }>(ctx, `${instancePath(ctx)}/payouts/${network}`, body) + clack.log.success(`Created payout ${payout.id} (${payout.status})`) + if (options.json) + console.log(formatOutput(payout, true)) + } + catch (e) { + handleApiError(e) + } +} + +// Payins +export async function listPayins(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/payins`) + const list = extractList(res) + const display = list.map((p: any) => ({ + id: p.id, + status: p.status, + amount: p.sender_amount !== null && p.sender_amount !== undefined ? `${(p.sender_amount / 100)} ${p.currency || 'USD'}` : '-', + method: p.payment_method || '-', + created_at: p.created_at, + })) + printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'method', 'created_at']) + } + catch (e) { + handleApiError(e) + } +} + +export async function getPayin(id: string, options: { json: boolean }) { + try { + const ctx = resolveContext() + const payin = await apiGet(ctx, `${instancePath(ctx)}/payins/${id}`) + printResult(payin, options.json) + } + catch (e) { + handleApiError(e) + } +} + +export async function createPayin(options: { payinQuoteId: string, network?: string, externalId?: string, json: boolean }) { + try { + const ctx = resolveContext() + const network = (options.network ?? 'evm').toLowerCase() + if (!['evm', 'solana', 'stellar'].includes(network)) { + clack.log.error(`Invalid network: ${options.network}. Use evm, solana, or stellar.`) + process.exit(1) + } + const body = { + payin_quote_id: options.payinQuoteId, + external_id: options.externalId ?? null, + } + const payin = await apiPost<{ id: string, status: string }>(ctx, `${instancePath(ctx)}/payins/${network}`, body) + clack.log.success(`Created payin ${payin.id} (${payin.status})`) + if (options.json) + console.log(formatOutput(payin, true)) + } + catch (e) { + handleApiError(e) + } +} + +// Payin Quotes +export async function createPayinQuote(options: { blockchainWalletId: string, paymentMethod: string, amount?: string, currency?: string, json: boolean }) { + try { + const ctx = resolveContext() + const parsed = Number.parseInt(options.amount ?? '1000') + const body = { + blockchain_wallet_id: options.blockchainWalletId, + payment_method: options.paymentMethod, + request_amount: Number.isNaN(parsed) ? 1000 : parsed, + currency: options.currency ?? 'USD', + } + const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, payment_method: string, currency: string }>(ctx, `${instancePath(ctx)}/payin-quotes`, body) + clack.log.success(`Created payin quote ${quote.id} (${(quote.sender_amount || 0) / 100} ${quote.currency} via ${quote.payment_method})`) + if (!options.json) + clack.log.message(`Next: blindpay payins create --payin-quote-id ${quote.id}`) + if (options.json) + console.log(formatOutput(quote, true)) + } + catch (e) { + handleApiError(e) + } +} + +// Quotes +export async function createQuote(options: { + bankAccountId: string + network?: string + token?: string + amount?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const parsed = Number.parseInt(options.amount ?? '1000') + const body = { + bank_account_id: options.bankAccountId, + network: options.network || 'base', + token: options.token || 'USDC', + request_amount: Number.isNaN(parsed) ? 1000 : parsed, + } + const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, token?: string, currency?: string }>(ctx, `${instancePath(ctx)}/quotes`, body) + const token = quote.token ?? 'USDC' + const currency = (quote as any).currency ?? 'USD' + clack.log.success(`Created quote ${quote.id} (${(quote.sender_amount || 0) / 100} ${token} -> ${(quote.receiver_amount || 0) / 100} ${currency})`) + if (!options.json) + clack.log.message(`Next: blindpay payouts create --quote-id ${quote.id}`) + if (options.json) + console.log(formatOutput(quote, true)) + } + catch (e) { + handleApiError(e) + } +} + +// Webhook Endpoints +export async function listWebhookEndpoints(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/webhook-endpoints`) + const list = extractList(res) + const display = list.map((e: any) => ({ id: e.id, url: e.url, description: e.description })) + printResult(options.json ? list : display, options.json, ['id', 'url', 'description']) + } + catch (e) { + handleApiError(e) + } +} + +export async function createWebhookEndpoint(options: { url: string, description?: string, json: boolean }) { + try { + const ctx = resolveContext() + const endpoint = await apiPost<{ id: string, url: string }>(ctx, `${instancePath(ctx)}/webhook-endpoints`, { url: options.url, description: options.description || null }) + clack.log.success(`Created webhook endpoint ${endpoint.id} -> ${endpoint.url}`) + if (options.json) + console.log(formatOutput(endpoint, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deleteWebhookEndpoint(id: string) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/webhook-endpoints/${id}`) + clack.log.success(`Deleted webhook endpoint ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// Partner Fees +export async function listPartnerFees(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/partner-fees`) + const list = extractList(res) + const display = list.map((f: any) => ({ + id: f.id, + payout_pct: `${(f.payout_percentage_fee || 0) / 100}%`, + payout_flat: `$${(f.payout_flat_fee || 0) / 100}`, + payin_pct: `${(f.payin_percentage_fee || 0) / 100}%`, + payin_flat: `$${(f.payin_flat_fee || 0) / 100}`, + })) + printResult(options.json ? list : display, options.json, ['id', 'payout_pct', 'payout_flat', 'payin_pct', 'payin_flat']) + } + catch (e) { + handleApiError(e) + } +} + +export async function createPartnerFee(options: { + payinPercentage?: string + payinFlat?: string + payoutPercentage?: string + payoutFlat?: string + evmWallet?: string + stellarWallet?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const body = { + payin_percentage_fee: Number.parseFloat(options.payinPercentage ?? '0') * 100, + payin_flat_fee: Number.parseFloat(options.payinFlat ?? '0') * 100, + payout_percentage_fee: Number.parseFloat(options.payoutPercentage ?? '0') * 100, + payout_flat_fee: Number.parseFloat(options.payoutFlat ?? '0') * 100, + evm_wallet_address: options.evmWallet ?? null, + stellar_wallet_address: options.stellarWallet ?? null, + } + const fee = await apiPost<{ id: string }>(ctx, `${instancePath(ctx)}/partner-fees`, body) + clack.log.success(`Created partner fee ${fee.id}`) + if (options.json) + console.log(formatOutput(fee, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deletePartnerFee(id: string) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/partner-fees/${id}`) + clack.log.success(`Deleted partner fee ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// API Keys +export async function listApiKeys(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/api-keys`) + const list = extractList(res) + const display = list.map((k: any) => ({ id: k.id, name: k.name, key: `${(k.key || '').slice(0, 16)}...`, permission: k.permission })) + printResult(options.json ? list : display, options.json, ['id', 'name', 'key', 'permission']) + } + catch (e) { + handleApiError(e) + } +} + +export async function createApiKey(options: { name?: string, json: boolean }) { + try { + const ctx = resolveContext() + const key = await apiPost<{ id: string, key: string }>(ctx, `${instancePath(ctx)}/api-keys`, { name: options.name || 'CLI API Key' }) + clack.log.success(`Created API key ${key.id}: ${key.key}`) + if (options.json) + console.log(formatOutput(key, true)) + } + catch (e) { + handleApiError(e) + } +} + +export async function deleteApiKey(id: string) { + try { + const ctx = resolveContext() + await apiDelete(ctx, `${instancePath(ctx)}/api-keys/${id}`) + clack.log.success(`Deleted API key ${id}`) + } + catch (e) { + handleApiError(e) + } +} + +// Virtual Accounts +export async function listVirtualAccounts(options: { receiverId: string, json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/virtual-accounts`) + const list = extractList(res) + const display = list.map((a: any) => ({ id: a.id, account_number: a.account_number, routing_number: a.routing_number, kyc_status: a.kyc_status })) + printResult(options.json ? list : display, options.json, ['id', 'account_number', 'routing_number', 'kyc_status']) + } + catch (e) { + handleApiError(e) + } +} + +export async function createVirtualAccount(options: { receiverId: string, blockchainWalletId: string, json: boolean }) { + try { + const ctx = resolveContext() + const account = await apiPost<{ id: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/virtual-accounts`, { blockchain_wallet_id: options.blockchainWalletId }) + clack.log.success(`Created virtual account ${account.id}`) + if (options.json) + console.log(formatOutput(account, true)) + } + catch (e) { + handleApiError(e) + } +} + +// Offramp Wallets +export async function listOfframpWallets(options: { receiverId: string, bankAccountId: string, json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${options.bankAccountId}/offramp-wallets`) + const list = extractList(res) + const display = list.map((w: any) => ({ id: w.id, address: truncate(w.address, 20), network: w.network })) + printResult(options.json ? list : display, options.json, ['id', 'address', 'network']) + } + catch (e) { + handleApiError(e) + } +} + +// Available (local reference data - no HTTP) +export function listAvailableRails(options: { json: boolean }) { + printResult(availableRails, options.json, ['type', 'currency', 'country', 'name']) +} + +export function getAvailableBankDetails(options: { rail: string, json: boolean }) { + const fields: Record = { + ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'], + rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + pix: ['pix_key'], + pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'], + spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'], + transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'], + ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], + international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], + } + const result = fields[options.rail] + if (!result) { + const available = Object.keys(fields).join(', ') + clack.log.error(`Unknown rail "${options.rail}". Available rails: ${available}`) + process.exit(1) + } + if (options.json) { + console.log(formatOutput({ rail: options.rail, fields: result }, true)) + } + else { + clack.note(result.map(f => ` ${f}`).join('\n'), `Required fields for ${options.rail}`) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..43ec655 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,511 @@ +import process from 'node:process' +import { Command } from 'commander' +import * as clack from '@clack/prompts' +import { + listReceivers, + getReceiver, + createReceiver, + updateReceiver, + deleteReceiver, + listBankAccounts, + getBankAccount, + createBankAccount, + deleteBankAccount, + listBlockchainWallets, + getBlockchainWallet, + createBlockchainWallet, + deleteBlockchainWallet, + listPayouts, + getPayout, + createPayout, + listPayins, + getPayin, + createPayin, + createPayinQuote, + createQuote, + listWebhookEndpoints, + createWebhookEndpoint, + deleteWebhookEndpoint, + listPartnerFees, + createPartnerFee, + deletePartnerFee, + listApiKeys, + createApiKey, + deleteApiKey, + listVirtualAccounts, + createVirtualAccount, + listOfframpWallets, + listAvailableRails, + getAvailableBankDetails, +} from './commands/resources' +import { getConfig, setConfig, clearConfig, getConfigPath } from './utils/config' +import { CLI_VERSION } from './utils/constants' + +const program = new Command() + +program + .name('blindpay') + .description('Blindpay CLI — manage receivers, bank accounts, payouts, payins, and more from the terminal.') + .version(CLI_VERSION) + .addHelpText('after', ` +Examples: + $ blindpay config set --api-key --instance-id + $ blindpay receivers list --json + $ blindpay payouts list --status processing + $ blindpay available rails + +Documentation: https://github.com/blindpaylabs/blindpay-cli`) + +// ── Config ────────────────────────────────────────────────────────────── +const configCmd = program.command('config').description('Configure API credentials') + .addHelpText('after', ` +Examples: + $ blindpay config set --api-key sk_live_... --instance-id inst_... + $ blindpay config set --base-url https://api.blindpay.com + $ blindpay config get + $ blindpay config clear + $ blindpay config path`) + +configCmd + .command('set') + .description('Set API key, instance ID, or base URL') + .option('--api-key ', 'API key (from Blindpay dashboard)') + .option('--instance-id ', 'Instance ID') + .option('--base-url ', 'API base URL (default: https://api.blindpay.com)') + .action((opts) => { + const updates: { api_key?: string, instance_id?: string, base_url?: string } = {} + if (opts.apiKey !== undefined) + updates.api_key = opts.apiKey + if (opts.instanceId !== undefined) + updates.instance_id = opts.instanceId + if (opts.baseUrl !== undefined) + updates.base_url = opts.baseUrl + if (Object.keys(updates).length === 0) { + clack.log.error('Provide at least one option: --api-key, --instance-id, or --base-url') + process.exit(1) + } + setConfig(updates) + clack.log.success('Config updated') + }) + +configCmd + .command('get') + .description('Show current config (API key masked)') + .action(() => { + const c = getConfig() + const mask = (s: string | null) => (s ? `${s.slice(0, 3)}...${s.slice(-4)}` : '-') + console.log(` instance_id: ${c.instance_id ?? '-'}`) + console.log(` api_key: ${mask(c.api_key)}`) + console.log(` base_url: ${c.base_url ?? 'https://api.blindpay.com (default)'}`) + }) + +configCmd + .command('clear') + .description('Remove saved config') + .action(() => { + if (clearConfig()) + clack.log.success('Config cleared') + else + clack.log.message('No config file found') + }) + +configCmd + .command('path') + .description('Print config file path') + .action(() => console.log(getConfigPath())) + +// ── Receivers ─────────────────────────────────────────────────────────── +const receivers = program.command('receivers').description('Manage receivers') + .addHelpText('after', ` +Examples: + $ blindpay receivers list + $ blindpay receivers list --json + $ blindpay receivers get + $ blindpay receivers create --email user@example.com --name "John Doe" --country US + $ blindpay receivers create --type business --email biz@co.com --legal-name "Acme Inc" + $ blindpay receivers update --kyc-status approved + $ blindpay receivers delete `) + +receivers + .command('list') + .description('List all receivers') + .option('--json', 'Output as JSON', false) + .action(opts => listReceivers(opts)) + +receivers + .command('get ') + .description('Get a receiver by ID') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getReceiver(id, opts)) + +receivers + .command('create') + .description('Create a new receiver') + .requiredOption('--email ', 'Receiver email') + .option('--type ', 'individual or business', 'individual') + .option('--name ', 'Full name (individual); splits into first_name and last_name') + .option('--first-name ', 'First name (individual)') + .option('--last-name ', 'Last name (individual)') + .option('--legal-name ', 'Legal name (business)') + .option('--country ', 'ISO 3166 country code', 'US') + .option('--tax-id ', 'Tax ID') + .option('--external-id ', 'External ID') + .option('--kyc-status ', 'KYC status (verifying, approved, rejected, deprecated)', 'approved') + .option('--json', 'Output as JSON', false) + .action(opts => createReceiver(opts)) + +receivers + .command('update ') + .description('Update a receiver') + .option('--name ', 'Full name (individual); splits into first_name and last_name') + .option('--first-name ', 'First name (individual)') + .option('--last-name ', 'Last name (individual)') + .option('--legal-name ', 'Legal name (business)') + .option('--email ', 'Receiver email') + .option('--country ', 'ISO 3166 country code') + .option('--kyc-status ', 'KYC status (verifying, approved, rejected, deprecated)') + .option('--json', 'Output as JSON', false) + .action((id, opts) => updateReceiver(id, opts)) + +receivers + .command('delete ') + .description('Delete a receiver') + .action(id => deleteReceiver(id)) + +// ── Bank Accounts ─────────────────────────────────────────────────────── +const bankAccounts = program.command('bank_accounts').description('Manage bank accounts') + .addHelpText('after', ` +Examples: + $ blindpay bank_accounts list --receiver-id + $ blindpay bank_accounts get --receiver-id + $ blindpay bank_accounts create --receiver-id --type ach --routing-number 021000021 --account-number 123456789 + $ blindpay bank_accounts create --receiver-id --type pix --pix-key user@email.com + $ blindpay bank_accounts delete --receiver-id `) + +bankAccounts + .command('list') + .description('List bank accounts for a receiver') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) + .action(opts => listBankAccounts(opts)) + +bankAccounts + .command('get ') + .description('Get a bank account by ID') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getBankAccount(id, opts)) + +bankAccounts + .command('create') + .description('Create a new bank account') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--type ', 'Bank account type (ach, wire, pix, etc.)', 'ach') + .option('--name ', 'Account name') + .option('--beneficiary-name ', 'Beneficiary name') + .option('--routing-number ', 'Routing number') + .option('--account-number ', 'Account number') + .option('--account-type ', 'checking or saving') + .option('--account-class ', 'individual or business') + .option('--pix-key ', 'PIX key') + .option('--recipient-relationship ', 'Recipient relationship') + .option('--country ', 'Country code') + .option('--json', 'Output as JSON', false) + .action(opts => createBankAccount(opts)) + +bankAccounts + .command('delete ') + .description('Delete a bank account') + .requiredOption('--receiver-id ', 'Receiver ID') + .action((id, opts) => deleteBankAccount(id, opts)) + +// ── Blockchain Wallets ────────────────────────────────────────────────── +const blockchainWallets = program.command('blockchain_wallets').description('Manage blockchain wallets') + .addHelpText('after', ` +Examples: + $ blindpay blockchain_wallets list --receiver-id + $ blindpay blockchain_wallets get --receiver-id + $ blindpay blockchain_wallets create --receiver-id --address 0x... --network base + $ blindpay blockchain_wallets delete --receiver-id `) + +blockchainWallets + .command('list') + .description('List blockchain wallets for a receiver') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) + .action(opts => listBlockchainWallets(opts)) + +blockchainWallets + .command('get ') + .description('Get a blockchain wallet by ID') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getBlockchainWallet(id, opts)) + +blockchainWallets + .command('create') + .description('Create a new blockchain wallet') + .requiredOption('--receiver-id ', 'Receiver ID') + .requiredOption('--address
', 'Wallet address') + .option('--network ', 'Blockchain network', 'base') + .option('--external-id ', 'External ID') + .option('--json', 'Output as JSON', false) + .action(opts => createBlockchainWallet(opts)) + +blockchainWallets + .command('delete ') + .description('Delete a blockchain wallet') + .requiredOption('--receiver-id ', 'Receiver ID') + .action((id, opts) => deleteBlockchainWallet(id, opts)) + +// ── Quotes ────────────────────────────────────────────────────────────── +const quotes = program.command('quotes').description('Manage payout quotes') + .addHelpText('after', ` +Examples: + $ blindpay quotes create --bank-account-id --amount 5000 --network base --token USDC`) + +quotes + .command('create') + .description('Create a new payout quote') + .requiredOption('--bank-account-id ', 'Bank account ID') + .option('--network ', 'Blockchain network', 'base') + .option('--token ', 'Token (USDC, USDT, USDB)', 'USDC') + .option('--amount ', 'Amount in cents', '1000') + .option('--json', 'Output as JSON', false) + .action(opts => createQuote(opts)) + +// ── Payouts ───────────────────────────────────────────────────────────── +const payouts = program.command('payouts').description('Manage payouts') + .addHelpText('after', ` +Examples: + $ blindpay payouts list + $ blindpay payouts list --status processing --json + $ blindpay payouts get + $ blindpay payouts create --quote-id --sender-wallet-address 0x... --network evm`) + +payouts + .command('list') + .description('List all payouts') + .option('--status ', 'Filter by status (processing, failed, refunded, completed, on_hold)') + .option('--json', 'Output as JSON', false) + .action(opts => listPayouts(opts)) + +payouts + .command('create') + .description('Create a payout from a quote') + .requiredOption('--quote-id ', 'Payout quote ID from "blindpay quotes create"') + .option('--network ', 'Network: evm, solana, or stellar', 'evm') + .requiredOption('--sender-wallet-address
', 'Sender wallet address') + .option('--json', 'Output as JSON', false) + .action(opts => createPayout(opts)) + +payouts + .command('get ') + .description('Get a payout by ID') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getPayout(id, opts)) + +// ── Payin Quotes ──────────────────────────────────────────────────────── +const payinQuotes = program.command('payin_quotes').description('Manage payin quotes') + .addHelpText('after', ` +Examples: + $ blindpay payin_quotes create --blockchain-wallet-id --payment-method pix --amount 5000 --currency BRL`) + +payinQuotes + .command('create') + .description('Create a new payin quote') + .requiredOption('--blockchain-wallet-id ', 'Blockchain wallet ID') + .requiredOption('--payment-method ', 'Payment method (pix, ach, wire, spei, transfers, pse)') + .option('--amount ', 'Amount in cents', '1000') + .option('--currency ', 'Currency', 'USD') + .option('--json', 'Output as JSON', false) + .action(opts => createPayinQuote(opts)) + +// ── Payins ────────────────────────────────────────────────────────────── +const payins = program.command('payins').description('Manage payins') + .addHelpText('after', ` +Examples: + $ blindpay payins list + $ blindpay payins get + $ blindpay payins create --payin-quote-id --network evm`) + +payins + .command('create') + .description('Create a payin from a payin quote') + .requiredOption('--payin-quote-id ', 'Payin quote ID from "blindpay payin_quotes create"') + .option('--network ', 'Network: evm, solana, or stellar', 'evm') + .option('--external-id ', 'External ID') + .option('--json', 'Output as JSON', false) + .action(opts => createPayin(opts)) + +payins + .command('list') + .description('List all payins') + .option('--json', 'Output as JSON', false) + .action(opts => listPayins(opts)) + +payins + .command('get ') + .description('Get a payin by ID') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getPayin(id, opts)) + +// ── Webhook Endpoints ─────────────────────────────────────────────────── +const webhookEndpoints = program.command('webhook_endpoints').description('Manage webhook endpoints') + .addHelpText('after', ` +Examples: + $ blindpay webhook_endpoints list + $ blindpay webhook_endpoints create --url https://example.com/webhook --description "Production" + $ blindpay webhook_endpoints delete `) + +webhookEndpoints + .command('list') + .description('List webhook endpoints') + .option('--json', 'Output as JSON', false) + .action(opts => listWebhookEndpoints(opts)) + +webhookEndpoints + .command('create') + .description('Create a webhook endpoint') + .requiredOption('--url ', 'Webhook URL') + .option('--description ', 'Description') + .option('--json', 'Output as JSON', false) + .action(opts => createWebhookEndpoint(opts)) + +webhookEndpoints + .command('delete ') + .description('Delete a webhook endpoint') + .action(id => deleteWebhookEndpoint(id)) + +// ── Partner Fees ──────────────────────────────────────────────────────── +const partnerFees = program.command('partner_fees').description('Manage partner fees') + .addHelpText('after', ` +Examples: + $ blindpay partner_fees list + $ blindpay partner_fees create --payout-percentage 2.5 --payout-flat 1.00 --evm-wallet 0x... + $ blindpay partner_fees delete `) + +partnerFees + .command('list') + .description('List partner fees') + .option('--json', 'Output as JSON', false) + .action(opts => listPartnerFees(opts)) + +partnerFees + .command('create') + .description('Create a partner fee') + .option('--payout-percentage ', 'Payout percentage fee') + .option('--payout-flat ', 'Payout flat fee') + .option('--payin-percentage ', 'Payin percentage fee') + .option('--payin-flat ', 'Payin flat fee') + .option('--evm-wallet
', 'EVM wallet address') + .option('--stellar-wallet
', 'Stellar wallet address') + .option('--json', 'Output as JSON', false) + .action(opts => createPartnerFee(opts)) + +partnerFees + .command('delete ') + .description('Delete a partner fee') + .action(id => deletePartnerFee(id)) + +// ── API Keys ──────────────────────────────────────────────────────────── +const apiKeys = program.command('api_keys').description('Manage API keys') + .addHelpText('after', ` +Examples: + $ blindpay api_keys list + $ blindpay api_keys create --name "Production Key" + $ blindpay api_keys delete `) + +apiKeys + .command('list') + .description('List API keys') + .option('--json', 'Output as JSON', false) + .action(opts => listApiKeys(opts)) + +apiKeys + .command('create') + .description('Create an API key') + .option('--name ', 'Key name', 'CLI API Key') + .option('--json', 'Output as JSON', false) + .action(opts => createApiKey(opts)) + +apiKeys + .command('delete ') + .description('Delete an API key') + .action(id => deleteApiKey(id)) + +// ── Virtual Accounts ──────────────────────────────────────────────────── +const virtualAccounts = program.command('virtual_accounts').description('Manage virtual accounts') + .addHelpText('after', ` +Examples: + $ blindpay virtual_accounts list --receiver-id + $ blindpay virtual_accounts create --receiver-id --blockchain-wallet-id `) + +virtualAccounts + .command('list') + .description('List virtual accounts for a receiver') + .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) + .action(opts => listVirtualAccounts(opts)) + +virtualAccounts + .command('create') + .description('Create a virtual account') + .requiredOption('--receiver-id ', 'Receiver ID') + .requiredOption('--blockchain-wallet-id ', 'Blockchain wallet ID') + .option('--json', 'Output as JSON', false) + .action(opts => createVirtualAccount(opts)) + +// ── Offramp Wallets ───────────────────────────────────────────────────── +const offrampWallets = program.command('offramp_wallets').description('Manage offramp wallets') + .addHelpText('after', ` +Examples: + $ blindpay offramp_wallets list --receiver-id --bank-account-id `) + +offrampWallets + .command('list') + .description('List offramp wallets') + .requiredOption('--receiver-id ', 'Receiver ID') + .requiredOption('--bank-account-id ', 'Bank account ID') + .option('--json', 'Output as JSON', false) + .action(opts => listOfframpWallets(opts)) + +// ── Available ─────────────────────────────────────────────────────────── +const available = program.command('available').description('Reference data (no API key required)') + .addHelpText('after', ` +Examples: + $ blindpay available rails + $ blindpay available rails --json + $ blindpay available bank_details --rail ach + $ blindpay available bank_details --rail pix`) + +available + .command('rails') + .description('List available payment rails') + .option('--json', 'Output as JSON', false) + .action(opts => listAvailableRails(opts)) + +available + .command('bank_details') + .description('Show required bank details for a payment rail') + .requiredOption('--rail ', 'Rail type (ach, wire, pix, pix_safe, spei_bitso, transfers_bitso, ach_cop_bitso, international_swift)') + .option('--json', 'Output as JSON', false) + .action(opts => getAvailableBankDetails(opts)) + +// ── Update ────────────────────────────────────────────────────────────── +program + .command('update') + .description('Update the Blindpay CLI to the latest version') + .action(() => { + console.log() + clack.log.message('To update the Blindpay CLI, run:') + console.log() + console.log(' npm install -g @blindpay/cli@latest') + console.log() + clack.log.message('Or use npx to always run the latest version:') + console.log() + console.log(' npx @blindpay/cli@latest ') + console.log() + }) + +program.parse(process.argv) diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts new file mode 100644 index 0000000..4ffb74c --- /dev/null +++ b/src/utils/api-client.ts @@ -0,0 +1,112 @@ +import { getConfig, hasLiveConfig } from './config' +import { CLI_VERSION, DEFAULT_API_URL } from './constants' + +export interface ApiContext { + baseUrl: string + instanceId: string + headers: Record +} + +export type ValidationErrorItem = { path: (string | number)[]; message: string; [k: string]: unknown } + +export interface ApiError extends Error { + statusCode?: number + validationErrors?: ValidationErrorItem[] +} + +const NO_CONFIG_MSG = 'No API key configured. Run: blindpay config set --api-key --instance-id ' + +export function resolveContext(): ApiContext { + if (!hasLiveConfig()) + throw new Error(NO_CONFIG_MSG) + + const config = getConfig() + const baseUrl = (config.base_url ?? DEFAULT_API_URL).replace(/\/$/, '') + return { + baseUrl, + instanceId: config.instance_id!, + headers: { + 'User-Agent': `blindpay-cli/${CLI_VERSION}`, + 'X-Blindpay-Client': 'cli', + Authorization: `Bearer ${config.api_key!}`, + }, + } +} + +function buildUrl(ctx: ApiContext, path: string): string { + const p = path.startsWith('/') ? path : `/${path}` + return `${ctx.baseUrl}${p}` +} + +function parseErrorResponse(status: number, statusText: string, text: string): ApiError { + let msg = `Request failed: ${status} ${statusText}` + let validationErrors: ValidationErrorItem[] | undefined + try { + const j = JSON.parse(text) as { message?: string; errors?: ValidationErrorItem[] } + if (j.message) + msg = j.message + if (Array.isArray(j.errors) && j.errors.length > 0) + validationErrors = j.errors + } + catch { + if (text) + msg = text.slice(0, 200) + } + const err = new Error(msg) as ApiError + err.statusCode = status + if (validationErrors) + err.validationErrors = validationErrors + return err +} + +export async function apiGet(ctx: ApiContext, path: string): Promise { + const url = buildUrl(ctx, path) + const res = await fetch(url, { headers: ctx.headers }) + if (!res.ok) { + const body = await res.text() + throw parseErrorResponse(res.status, res.statusText, body) + } + return res.json() as Promise +} + +export async function apiPost(ctx: ApiContext, path: string, body?: object): Promise { + const url = buildUrl(ctx, path) + const headers = { ...ctx.headers, 'Content-Type': 'application/json' } + const res = await fetch(url, { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const text = await res.text() + throw parseErrorResponse(res.status, res.statusText, text) + } + return res.json() as Promise +} + +export async function apiPut(ctx: ApiContext, path: string, body: object): Promise { + const url = buildUrl(ctx, path) + const headers = { ...ctx.headers, 'Content-Type': 'application/json' } + const res = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text() + throw parseErrorResponse(res.status, res.statusText, text) + } + return res.json() as Promise +} + +export async function apiDelete(ctx: ApiContext, path: string): Promise { + const url = buildUrl(ctx, path) + const res = await fetch(url, { method: 'DELETE', headers: ctx.headers }) + if (!res.ok) { + const text = await res.text() + throw parseErrorResponse(res.status, res.statusText, text) + } + const contentType = res.headers.get('content-type') + if (contentType?.includes('application/json')) return res.json() as Promise + return undefined as T +} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..9cb23ca --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const CONFIG_DIR_NAME = 'blindpay' +const CONFIG_FILE_NAME = 'config.json' +const CONFIG_FILE_MODE = 0o600 + +export interface ConfigData { + api_key: string | null + instance_id: string | null + base_url: string | null +} + +const defaultConfig: ConfigData = { + api_key: null, + instance_id: null, + base_url: null, +} + +function getConfigDir(): string { + const xdg = process.env.XDG_CONFIG_HOME + if (xdg) + return path.join(xdg, CONFIG_DIR_NAME) + const home = os.homedir() + return path.join(home, '.config', CONFIG_DIR_NAME) +} + +export function getConfigPath(): string { + return path.join(getConfigDir(), CONFIG_FILE_NAME) +} + +function readConfigFile(): Partial { + const filePath = getConfigPath() + try { + const raw = fs.readFileSync(filePath, 'utf8') + const data = JSON.parse(raw) as Partial + return { + api_key: data.api_key ?? null, + instance_id: data.instance_id ?? null, + base_url: data.base_url ?? null, + } + } + catch { + return {} + } +} + +function writeConfigFile(data: ConfigData): void { + const dir = getConfigDir() + const filePath = getConfigPath() + if (!fs.existsSync(dir)) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: CONFIG_FILE_MODE }) +} + +/** + * Returns merged config: file defaults with env var overrides. + * Env: BLINDPAY_API_KEY, BLINDPAY_INSTANCE_ID, BLINDPAY_API_URL + */ +export function getConfig(): ConfigData { + const fromFile = readConfigFile() + return { + api_key: process.env.BLINDPAY_API_KEY ?? fromFile.api_key ?? null, + instance_id: process.env.BLINDPAY_INSTANCE_ID ?? fromFile.instance_id ?? null, + base_url: process.env.BLINDPAY_API_URL ?? fromFile.base_url ?? null, + } +} + +/** + * Update config file with provided values (only set keys that are defined). + * Env vars override at read time; this only persists to file. + */ +export function setConfig(updates: Partial): void { + const current = readConfigFile() + const next: ConfigData = { + api_key: updates.api_key !== undefined ? updates.api_key : (current.api_key ?? defaultConfig.api_key), + instance_id: updates.instance_id !== undefined ? updates.instance_id : (current.instance_id ?? defaultConfig.instance_id), + base_url: updates.base_url !== undefined ? updates.base_url : (current.base_url ?? defaultConfig.base_url), + } + writeConfigFile(next) +} + +/** + * Remove config file and directory if empty. + */ +export function clearConfig(): boolean { + const filePath = getConfigPath() + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + const dir = getConfigDir() + try { + if (fs.readdirSync(dir).length === 0) + fs.rmdirSync(dir) + } + catch { + // ignore + } + return true + } + } + catch { + // ignore + } + return false +} + +/** + * Whether the config has enough to call the live API (api_key and instance_id). + */ +export function hasLiveConfig(): boolean { + const c = getConfig() + return Boolean(c.api_key && c.instance_id) +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..b482765 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,14 @@ +export const CLI_VERSION = '0.1.0' +export const DEFAULT_API_URL = 'https://api.blindpay.com' + +export const availableRails = [ + { type: 'ach', currency: 'USD', country: 'US', name: 'ACH' }, + { type: 'wire', currency: 'USD', country: 'US', name: 'Domestic Wire' }, + { type: 'rtp', currency: 'USD', country: 'US', name: 'RTP' }, + { type: 'pix', currency: 'BRL', country: 'BR', name: 'PIX' }, + { type: 'pix_safe', currency: 'BRL', country: 'BR', name: 'PIX Safe' }, + { type: 'spei_bitso', currency: 'MXN', country: 'MX', name: 'SPEI' }, + { type: 'transfers_bitso', currency: 'ARS', country: 'AR', name: 'Transfers 3.0' }, + { type: 'ach_cop_bitso', currency: 'COP', country: 'CO', name: 'ACH Colombia' }, + { type: 'international_swift', currency: 'USD', country: 'INTL', name: 'International SWIFT' }, +] diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..0c8a3ab --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,63 @@ +import pc from 'picocolors' + +export function formatTable(data: Record[], columns?: string[]): string { + if (data.length === 0) + return pc.dim(' No data found.') + + const keys = columns || Object.keys(data[0]) + const widths = keys.map((key) => { + const maxDataWidth = Math.max(...data.map(row => String(row[key] ?? '').length)) + return Math.max(key.length, maxDataWidth, 4) + }) + + const header = keys.map((key, i) => pc.bold(key.padEnd(widths[i]))).join(' ') + const separator = keys.map((_, i) => pc.dim('-'.repeat(widths[i]))).join(' ') + const rows = data.map(row => + keys.map((key, i) => { + const val = row[key] ?? '' + return String(val).padEnd(widths[i]) + }).join(' '), + ) + + return ['', ` ${header}`, ` ${separator}`, ...rows.map(r => ` ${r}`), ''].join('\n') +} + +export function formatJson(data: any): string { + return JSON.stringify(data, null, 2) +} + +/** Key-value table for a single object (non-JSON human-readable get output). */ +export function formatKeyValue(obj: Record): string { + if (obj === null || obj === undefined || typeof obj !== 'object') + return String(obj) + const keys = Object.keys(obj) + if (keys.length === 0) + return pc.dim(' (empty)') + const maxKey = Math.max(...keys.map(k => k.length), 4) + const lines = keys.map((key) => { + const val = obj[key] + const display = val === null || val === undefined + ? '' + : typeof val === 'object' + ? JSON.stringify(val) + : String(val) + return ` ${key.padEnd(maxKey)} ${display}` + }) + return ['', ...lines, ''].join('\n') +} + +export function formatOutput(data: any, json: boolean, columns?: string[]): string { + if (json) + return formatJson(data) + if (Array.isArray(data)) + return formatTable(data, columns) + if (data !== null && data !== undefined && typeof data === 'object' && !Array.isArray(data)) + return formatKeyValue(data) + return formatJson(data) +} + +export function truncate(str: string, max: number = 32): string { + if (str.length <= max) + return str + return `${str.slice(0, max - 3)}...` +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..31487ca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "rootDir": "./src", + "module": "ES2022", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "types": ["bun-types"], + "strict": true, + "declaration": true, + "noEmit": true, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 702580e595ec34ef4ec639ee56c356592a1a90a1 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 07:56:44 -0300 Subject: [PATCH 02/10] Add LLM-friendly features: schema command, structured errors, exit codes - blindpay schema [resource] [--rail] for machine-readable field introspection - Structured JSON error output when --json flag is active - Exit code 1 for user/config errors, exit code 2 for API errors --- src/commands/resources.ts | 101 ++++++++++-------- src/commands/schema.ts | 216 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 22 ++++ 3 files changed, 297 insertions(+), 42 deletions(-) create mode 100644 src/commands/schema.ts diff --git a/src/commands/resources.ts b/src/commands/resources.ts index dd4a388..4df8c7d 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -19,15 +19,36 @@ function formatValidationError(item: ValidationErrorItem): string { return path ? `${path}: ${item.message}` : item.message } -function handleApiError(err: unknown): never { - const msg = err instanceof Error ? err.message : String(err) - clack.log.error(msg) +function handleApiError(err: unknown, json = false): never { const apiErr = err as ApiError - if (apiErr.validationErrors && apiErr.validationErrors.length > 0) { - for (const ve of apiErr.validationErrors) - console.log(pc.dim(` • ${formatValidationError(ve)}`)) + const statusCode = apiErr.statusCode + const exitCode = statusCode ? 2 : 1 + const msg = err instanceof Error ? err.message : String(err) + + if (json) { + const output: Record = { error: true, message: msg, exitCode } + if (statusCode) output.statusCode = statusCode + if (apiErr.validationErrors?.length) output.validationErrors = apiErr.validationErrors + console.log(JSON.stringify(output, null, 2)) + } + else { + clack.log.error(msg) + if (apiErr.validationErrors?.length) { + for (const ve of apiErr.validationErrors) + console.log(pc.dim(` • ${formatValidationError(ve)}`)) + } } - process.exit(1) + process.exit(exitCode) +} + +function exitWithError(message: string, exitCode: number, json = false): never { + if (json) { + console.log(JSON.stringify({ error: true, message, exitCode }, null, 2)) + } + else { + clack.log.error(message) + } + process.exit(exitCode) } function extractList(res: any): any[] { @@ -55,7 +76,7 @@ export async function listReceivers(options: { json: boolean }) { printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'email', 'country', 'kyc_status']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -66,7 +87,7 @@ export async function getReceiver(id: string, options: { json: boolean }) { printResult(receiver, options.json) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -117,7 +138,7 @@ export async function createReceiver(options: { console.log(formatOutput(receiver, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -161,8 +182,7 @@ export async function updateReceiver( } } if (Object.keys(body).length === 0) { - clack.log.error('Provide at least one field to update (e.g. --name, --kyc-status)') - process.exit(1) + exitWithError('Provide at least one field to update (e.g. --name, --kyc-status)', 1, options.json) } const receiver = await apiPut>(ctx, `${instancePath(ctx)}/receivers/${id}`, body) clack.log.success(`Updated receiver ${id}`) @@ -172,7 +192,7 @@ export async function updateReceiver( console.log(formatOutput(receiver, false)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -197,7 +217,7 @@ export async function listBankAccounts(options: { receiverId: string, json: bool printResult(options.json ? list : display, options.json, ['id', 'type', 'name', 'status', 'country']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -208,7 +228,7 @@ export async function getBankAccount(id: string, options: { receiverId: string, printResult(account, options.json) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -246,7 +266,7 @@ export async function createBankAccount(options: { console.log(formatOutput(ba, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -271,7 +291,7 @@ export async function listBlockchainWallets(options: { receiverId: string, json: printResult(options.json ? list : display, options.json, ['id', 'address', 'network']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -282,7 +302,7 @@ export async function getBlockchainWallet(id: string, options: { receiverId: str printResult(wallet, options.json) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -306,7 +326,7 @@ export async function createBlockchainWallet(options: { console.log(formatOutput(wallet, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -338,7 +358,7 @@ export async function listPayouts(options: { json: boolean, status?: string }) { printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'network', 'created_at']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -349,7 +369,7 @@ export async function getPayout(id: string, options: { json: boolean }) { printResult(payout, options.json) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -358,8 +378,7 @@ export async function createPayout(options: { quoteId: string, network?: string, const ctx = resolveContext() const network = (options.network ?? 'evm').toLowerCase() if (!['evm', 'solana', 'stellar'].includes(network)) { - clack.log.error(`Invalid network: ${options.network}. Use evm, solana, or stellar.`) - process.exit(1) + exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json) } const body = { quote_id: options.quoteId, @@ -371,7 +390,7 @@ export async function createPayout(options: { quoteId: string, network?: string, console.log(formatOutput(payout, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -391,7 +410,7 @@ export async function listPayins(options: { json: boolean }) { printResult(options.json ? list : display, options.json, ['id', 'status', 'amount', 'method', 'created_at']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -402,7 +421,7 @@ export async function getPayin(id: string, options: { json: boolean }) { printResult(payin, options.json) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -411,8 +430,7 @@ export async function createPayin(options: { payinQuoteId: string, network?: str const ctx = resolveContext() const network = (options.network ?? 'evm').toLowerCase() if (!['evm', 'solana', 'stellar'].includes(network)) { - clack.log.error(`Invalid network: ${options.network}. Use evm, solana, or stellar.`) - process.exit(1) + exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json) } const body = { payin_quote_id: options.payinQuoteId, @@ -424,7 +442,7 @@ export async function createPayin(options: { payinQuoteId: string, network?: str console.log(formatOutput(payin, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -447,7 +465,7 @@ export async function createPayinQuote(options: { blockchainWalletId: string, pa console.log(formatOutput(quote, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -478,7 +496,7 @@ export async function createQuote(options: { console.log(formatOutput(quote, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -492,7 +510,7 @@ export async function listWebhookEndpoints(options: { json: boolean }) { printResult(options.json ? list : display, options.json, ['id', 'url', 'description']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -505,7 +523,7 @@ export async function createWebhookEndpoint(options: { url: string, description? console.log(formatOutput(endpoint, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -536,7 +554,7 @@ export async function listPartnerFees(options: { json: boolean }) { printResult(options.json ? list : display, options.json, ['id', 'payout_pct', 'payout_flat', 'payin_pct', 'payin_flat']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -565,7 +583,7 @@ export async function createPartnerFee(options: { console.log(formatOutput(fee, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -590,7 +608,7 @@ export async function listApiKeys(options: { json: boolean }) { printResult(options.json ? list : display, options.json, ['id', 'name', 'key', 'permission']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -603,7 +621,7 @@ export async function createApiKey(options: { name?: string, json: boolean }) { console.log(formatOutput(key, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -628,7 +646,7 @@ export async function listVirtualAccounts(options: { receiverId: string, json: b printResult(options.json ? list : display, options.json, ['id', 'account_number', 'routing_number', 'kyc_status']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -641,7 +659,7 @@ export async function createVirtualAccount(options: { receiverId: string, blockc console.log(formatOutput(account, true)) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -655,7 +673,7 @@ export async function listOfframpWallets(options: { receiverId: string, bankAcco printResult(options.json ? list : display, options.json, ['id', 'address', 'network']) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -679,8 +697,7 @@ export function getAvailableBankDetails(options: { rail: string, json: boolean } const result = fields[options.rail] if (!result) { const available = Object.keys(fields).join(', ') - clack.log.error(`Unknown rail "${options.rail}". Available rails: ${available}`) - process.exit(1) + exitWithError(`Unknown rail "${options.rail}". Available rails: ${available}`, 1, options.json) } if (options.json) { console.log(formatOutput({ rail: options.rail, fields: result }, true)) diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 0000000..bf5758f --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,216 @@ +interface FieldDef { + name: string + type: 'string' | 'number' + required: boolean + description: string + default?: string + enum?: string[] +} + +interface ResourceSchema { + resource: string + commands: string[] + create?: { fields: FieldDef[] } + update?: { fields: FieldDef[] } +} + +const bankDetailFields: Record = { + ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'], + rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + pix: ['pix_key'], + pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'], + spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'], + transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'], + ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], + international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], +} + +const schemas: ResourceSchema[] = [ + { + resource: 'receivers', + commands: ['list', 'get', 'create', 'update', 'delete'], + create: { + fields: [ + { name: 'email', type: 'string', required: true, description: 'Receiver email address' }, + { name: 'type', type: 'string', required: false, description: 'Receiver type', default: 'individual', enum: ['individual', 'business'] }, + { name: 'name', type: 'string', required: false, description: 'Full name (individual); auto-splits into first_name and last_name' }, + { name: 'first_name', type: 'string', required: false, description: 'First name (individual)' }, + { name: 'last_name', type: 'string', required: false, description: 'Last name (individual)' }, + { name: 'legal_name', type: 'string', required: false, description: 'Legal name (business)' }, + { name: 'country', type: 'string', required: false, description: 'ISO 3166 country code', default: 'US' }, + { name: 'tax_id', type: 'string', required: false, description: 'Tax ID' }, + { name: 'external_id', type: 'string', required: false, description: 'External reference ID' }, + { name: 'kyc_status', type: 'string', required: false, description: 'KYC verification status', default: 'approved', enum: ['verifying', 'approved', 'rejected', 'deprecated'] }, + ], + }, + update: { + fields: [ + { name: 'name', type: 'string', required: false, description: 'Full name (individual); auto-splits into first_name and last_name' }, + { name: 'first_name', type: 'string', required: false, description: 'First name (individual)' }, + { name: 'last_name', type: 'string', required: false, description: 'Last name (individual)' }, + { name: 'legal_name', type: 'string', required: false, description: 'Legal name (business)' }, + { name: 'email', type: 'string', required: false, description: 'Receiver email address' }, + { name: 'country', type: 'string', required: false, description: 'ISO 3166 country code' }, + { name: 'kyc_status', type: 'string', required: false, description: 'KYC verification status', enum: ['verifying', 'approved', 'rejected', 'deprecated'] }, + ], + }, + }, + { + resource: 'bank_accounts', + commands: ['list', 'get', 'create', 'delete'], + create: { + fields: [ + { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID that owns this bank account' }, + { name: 'type', type: 'string', required: false, description: 'Bank account type / payment rail', default: 'ach', enum: Object.keys(bankDetailFields) }, + { name: 'name', type: 'string', required: false, description: 'Account display name', default: 'CLI Bank Account' }, + { name: 'beneficiary_name', type: 'string', required: false, description: 'Beneficiary name on the account' }, + { name: 'routing_number', type: 'string', required: false, description: 'Bank routing number (ACH/Wire/RTP)' }, + { name: 'account_number', type: 'string', required: false, description: 'Bank account number' }, + { name: 'account_type', type: 'string', required: false, description: 'Account type', enum: ['checking', 'saving'] }, + { name: 'account_class', type: 'string', required: false, description: 'Account class', enum: ['individual', 'business'] }, + { name: 'pix_key', type: 'string', required: false, description: 'PIX key (for PIX rail)' }, + { name: 'recipient_relationship', type: 'string', required: false, description: 'Relationship to recipient' }, + { name: 'country', type: 'string', required: false, description: 'Country code' }, + ], + }, + }, + { + resource: 'blockchain_wallets', + commands: ['list', 'get', 'create', 'delete'], + create: { + fields: [ + { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID that owns this wallet' }, + { name: 'address', type: 'string', required: true, description: 'Blockchain wallet address' }, + { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'base', enum: ['base', 'ethereum', 'polygon', 'solana', 'stellar', 'arbitrum', 'optimism'] }, + { name: 'external_id', type: 'string', required: false, description: 'External reference ID' }, + ], + }, + }, + { + resource: 'quotes', + commands: ['create'], + create: { + fields: [ + { name: 'bank_account_id', type: 'string', required: true, description: 'Bank account ID for the payout destination' }, + { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'base' }, + { name: 'token', type: 'string', required: false, description: 'Stablecoin token', default: 'USDC', enum: ['USDC', 'USDT', 'USDB'] }, + { name: 'amount', type: 'number', required: false, description: 'Amount in cents', default: '1000' }, + ], + }, + }, + { + resource: 'payouts', + commands: ['list', 'get', 'create'], + create: { + fields: [ + { name: 'quote_id', type: 'string', required: true, description: 'Quote ID from "blindpay quotes create"' }, + { name: 'sender_wallet_address', type: 'string', required: true, description: 'Sender wallet address' }, + { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'evm', enum: ['evm', 'solana', 'stellar'] }, + ], + }, + }, + { + resource: 'payin_quotes', + commands: ['create'], + create: { + fields: [ + { name: 'blockchain_wallet_id', type: 'string', required: true, description: 'Blockchain wallet ID to receive funds' }, + { name: 'payment_method', type: 'string', required: true, description: 'Fiat payment method', enum: ['pix', 'ach', 'wire', 'spei', 'transfers', 'pse'] }, + { name: 'amount', type: 'number', required: false, description: 'Amount in cents', default: '1000' }, + { name: 'currency', type: 'string', required: false, description: 'Fiat currency', default: 'USD' }, + ], + }, + }, + { + resource: 'payins', + commands: ['list', 'get', 'create'], + create: { + fields: [ + { name: 'payin_quote_id', type: 'string', required: true, description: 'Payin quote ID from "blindpay payin_quotes create"' }, + { name: 'network', type: 'string', required: false, description: 'Blockchain network', default: 'evm', enum: ['evm', 'solana', 'stellar'] }, + { name: 'external_id', type: 'string', required: false, description: 'External reference ID' }, + ], + }, + }, + { + resource: 'webhook_endpoints', + commands: ['list', 'create', 'delete'], + create: { + fields: [ + { name: 'url', type: 'string', required: true, description: 'Webhook URL to receive events' }, + { name: 'description', type: 'string', required: false, description: 'Endpoint description' }, + ], + }, + }, + { + resource: 'partner_fees', + commands: ['list', 'create', 'delete'], + create: { + fields: [ + { name: 'payout_percentage_fee', type: 'number', required: false, description: 'Payout percentage fee (e.g. 2.5 for 2.5%)', default: '0' }, + { name: 'payout_flat_fee', type: 'number', required: false, description: 'Payout flat fee in dollars (e.g. 1.00)', default: '0' }, + { name: 'payin_percentage_fee', type: 'number', required: false, description: 'Payin percentage fee (e.g. 2.5 for 2.5%)', default: '0' }, + { name: 'payin_flat_fee', type: 'number', required: false, description: 'Payin flat fee in dollars (e.g. 1.00)', default: '0' }, + { name: 'evm_wallet_address', type: 'string', required: false, description: 'EVM wallet address for fee collection' }, + { name: 'stellar_wallet_address', type: 'string', required: false, description: 'Stellar wallet address for fee collection' }, + ], + }, + }, + { + resource: 'api_keys', + commands: ['list', 'create', 'delete'], + create: { + fields: [ + { name: 'name', type: 'string', required: false, description: 'API key name', default: 'CLI API Key' }, + ], + }, + }, + { + resource: 'virtual_accounts', + commands: ['list', 'create'], + create: { + fields: [ + { name: 'receiver_id', type: 'string', required: true, description: 'Receiver ID' }, + { name: 'blockchain_wallet_id', type: 'string', required: true, description: 'Blockchain wallet ID' }, + ], + }, + }, + { + resource: 'offramp_wallets', + commands: ['list'], + }, +] + +export function listSchemas() { + const summary = schemas.map(s => ({ resource: s.resource, commands: s.commands })) + console.log(JSON.stringify(summary, null, 2)) +} + +export function getSchema(resource: string, rail?: string) { + const schema = schemas.find(s => s.resource === resource) + if (!schema) { + const available = schemas.map(s => s.resource).join(', ') + console.error(JSON.stringify({ error: true, message: `Unknown resource "${resource}". Available: ${available}`, exitCode: 1 }, null, 2)) + process.exit(1) + } + + const output: Record = { ...schema } + + if (resource === 'bank_accounts' && rail) { + const fields = bankDetailFields[rail] + if (!fields) { + const available = Object.keys(bankDetailFields).join(', ') + console.error(JSON.stringify({ error: true, message: `Unknown rail "${rail}". Available: ${available}`, exitCode: 1 }, null, 2)) + process.exit(1) + } + output.rail = rail + output.rail_fields = fields + } + + if (resource === 'bank_accounts' && !rail) { + output.available_rails = Object.keys(bankDetailFields) + } + + console.log(JSON.stringify(output, null, 2)) +} diff --git a/src/index.ts b/src/index.ts index 43ec655..0ef3ead 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import process from 'node:process' import { Command } from 'commander' import * as clack from '@clack/prompts' +import { listSchemas, getSchema } from './commands/schema' import { listReceivers, getReceiver, @@ -492,6 +493,27 @@ available .option('--json', 'Output as JSON', false) .action(opts => getAvailableBankDetails(opts)) +// ── Schema ───────────────────────────────────────────────────────────── +const schema = program.command('schema').description('Introspect CLI resource schemas (JSON output for LLM/automation use)') + .addHelpText('after', ` +Examples: + $ blindpay schema # list all resources + $ blindpay schema receivers # full schema for receivers + $ blindpay schema bank_accounts # schema + available rails + $ blindpay schema bank_accounts --rail ach # schema + rail-specific fields`) + +schema + .argument('[resource]', 'Resource name (e.g. receivers, payouts, bank_accounts)') + .option('--rail ', 'Show rail-specific fields (bank_accounts only)') + .action((resource, opts) => { + if (!resource) { + listSchemas() + } + else { + getSchema(resource, opts.rail) + } + }) + // ── Update ────────────────────────────────────────────────────────────── program .command('update') From 2a1418713e65868c18651218771f62543f74a375 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 14:26:42 -0300 Subject: [PATCH 03/10] Fix review findings and rewrite README - Fix truncate crash on null/undefined address - Validate amount and partner fee inputs (reject NaN/garbage) - Fix createReceiver single-word --name not clearing last_name - Extract bankDetailFields to constants.ts (was duplicated) - Add process import to schema.ts - Add API key save warning on creation - Mask short API keys in config get - Remove unused @commander-js/extra-typings dep - Rewrite README with BlindPay badges and human-friendly tone --- README.md | 174 +++++++++++--------------------------- bun.lock | 3 - package.json | 1 - src/commands/resources.ts | 51 ++++++----- src/commands/schema.ts | 15 +--- src/index.ts | 2 +- src/utils/constants.ts | 12 +++ src/utils/output.ts | 7 +- 8 files changed, 97 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 7ed1e13..02e428a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ -# @blindpay/cli +

BlindPay CLI

-Blindpay CLI — manage receivers, bank accounts, payouts, payins, and more from the terminal. +[![chat on Discord](https://img.shields.io/discord/856971667393609759.svg?logo=discord)](https://discord.gg/x7ap6Gkbe9) +[![twitter](https://img.shields.io/twitter/follow/blindpaylabs?style=social)](https://twitter.com/intent/follow?screen_name=blindpaylabs) +[![npm version](https://img.shields.io/npm/v/@blindpay/cli)](https://www.npmjs.com/package/@blindpay/cli) + +The official CLI for [BlindPay](https://blindpay.com) - Stablecoin API for global payments. ## Installation @@ -8,168 +12,88 @@ Blindpay CLI — manage receivers, bank accounts, payouts, payins, and more from npm install -g @blindpay/cli ``` -Or run directly with npx: +Or run without installing: ```bash npx @blindpay/cli ``` -## Configuration +## Setup -Set your API key and instance ID (from the [Blindpay dashboard](https://dashboard.blindpay.com)): +Grab your API key and instance ID from the [BlindPay dashboard](https://app.blindpay.com) and run: ```bash blindpay config set --api-key sk_live_... --instance-id inst_... ``` -You can also use environment variables: +That's it. Alternatively, set environment variables: ```bash export BLINDPAY_API_KEY=sk_live_... export BLINDPAY_INSTANCE_ID=inst_... -export BLINDPAY_API_URL=https://api.blindpay.com # optional ``` -View current config: - -```bash -blindpay config get -``` - -## Quick Start +## Quick start ```bash # Create a receiver -blindpay receivers create --email user@example.com --name "John Doe" --country US +blindpay receivers create --email john@example.com --name "John Doe" --country US # Add a bank account -blindpay bank_accounts create --receiver-id --type ach \ +blindpay bank_accounts create --receiver-id re_xxx --type ach \ --routing-number 021000021 --account-number 123456789 -# Create a quote and payout -blindpay quotes create --bank-account-id --amount 5000 --network base --token USDC -blindpay payouts create --quote-id --network evm +# Get a quote and execute a payout +blindpay quotes create --bank-account-id ba_xxx --amount 5000 --token USDC +blindpay payouts create --quote-id qt_xxx --sender-wallet-address 0x... -# Check payout status -blindpay payouts get +# Check status +blindpay payouts get po_xxx ``` ## Commands -Every command supports `--help` for detailed usage and examples. - -### Config - -| Command | Description | -|---------|-------------| -| `config set` | Set API key, instance ID, or base URL | -| `config get` | Show current config (API key masked) | -| `config clear` | Remove saved config | -| `config path` | Print config file path | - -### Receivers - -| Command | Description | -|---------|-------------| -| `receivers list` | List all receivers | -| `receivers get ` | Get a receiver by ID | -| `receivers create` | Create a new receiver | -| `receivers update ` | Update a receiver | -| `receivers delete ` | Delete a receiver | - -### Bank Accounts - -| Command | Description | -|---------|-------------| -| `bank_accounts list` | List bank accounts (requires `--receiver-id`) | -| `bank_accounts get ` | Get a bank account (requires `--receiver-id`) | -| `bank_accounts create` | Create a bank account (requires `--receiver-id`) | -| `bank_accounts delete ` | Delete a bank account (requires `--receiver-id`) | - -### Blockchain Wallets - -| Command | Description | -|---------|-------------| -| `blockchain_wallets list` | List wallets (requires `--receiver-id`) | -| `blockchain_wallets get ` | Get a wallet (requires `--receiver-id`) | -| `blockchain_wallets create` | Create a wallet (requires `--receiver-id`, `--address`) | -| `blockchain_wallets delete ` | Delete a wallet (requires `--receiver-id`) | - -### Quotes & Payouts +Every command supports `--help` for detailed usage and `--json` for machine-readable output. -| Command | Description | -|---------|-------------| -| `quotes create` | Create a payout quote (requires `--bank-account-id`) | -| `payouts list` | List all payouts (optional `--status` filter) | -| `payouts get ` | Get a payout by ID | -| `payouts create` | Create a payout (requires `--quote-id`) | - -### Payin Quotes & Payins - -| Command | Description | -|---------|-------------| -| `payin_quotes create` | Create a payin quote (requires `--blockchain-wallet-id`, `--payment-method`) | -| `payins list` | List all payins | -| `payins get ` | Get a payin by ID | -| `payins create` | Create a payin (requires `--payin-quote-id`) | - -### Webhook Endpoints - -| Command | Description | -|---------|-------------| -| `webhook_endpoints list` | List webhook endpoints | -| `webhook_endpoints create` | Create a webhook endpoint (requires `--url`) | -| `webhook_endpoints delete ` | Delete a webhook endpoint | - -### Partner Fees - -| Command | Description | -|---------|-------------| -| `partner_fees list` | List partner fees | -| `partner_fees create` | Create a partner fee | -| `partner_fees delete ` | Delete a partner fee | - -### API Keys - -| Command | Description | -|---------|-------------| -| `api_keys list` | List API keys | -| `api_keys create` | Create an API key | -| `api_keys delete ` | Delete an API key | - -### Virtual Accounts - -| Command | Description | -|---------|-------------| -| `virtual_accounts list` | List virtual accounts (requires `--receiver-id`) | -| `virtual_accounts create` | Create a virtual account (requires `--receiver-id`, `--blockchain-wallet-id`) | - -### Offramp Wallets - -| Command | Description | -|---------|-------------| -| `offramp_wallets list` | List offramp wallets (requires `--receiver-id`, `--bank-account-id`) | +``` +blindpay config set|get|clear|path Configure API credentials +blindpay receivers list|get|create|update|delete +blindpay bank_accounts list|get|create|delete (--receiver-id required) +blindpay blockchain_wallets list|get|create|delete (--receiver-id required) +blindpay quotes create Create a payout quote +blindpay payouts list|get|create Execute stablecoin-to-fiat payouts +blindpay payin_quotes create Create a payin quote +blindpay payins list|get|create Execute fiat-to-stablecoin payins +blindpay webhook_endpoints list|create|delete +blindpay partner_fees list|create|delete +blindpay api_keys list|create|delete +blindpay virtual_accounts list|create (--receiver-id required) +blindpay offramp_wallets list (--receiver-id + --bank-account-id) +blindpay available rails List supported payment rails +blindpay available bank_details --rail ach Required fields per rail +blindpay schema [resource] Introspect field schemas (for LLM/automation) +blindpay update Print update instructions +``` -### Reference Data +## LLM / automation support -| Command | Description | -|---------|-------------| -| `available rails` | List available payment rails | -| `available bank_details --rail ` | Show required fields for a rail | +The CLI is designed to work well with LLMs and scripts: -## Global Options +- **`--json`** on any command for structured output +- **`blindpay schema`** returns field definitions, types, defaults, and enums as JSON +- **Exit codes**: `0` success, `1` user error, `2` API error +- **Structured errors** when `--json` is active: + ```json + {"error": true, "message": "...", "exitCode": 2, "statusCode": 401} + ``` -| Option | Description | -|--------|-------------| -| `--json` | Output as JSON (available on most commands) | -| `--version` | Show CLI version | -| `--help` | Show help | +For full MCP tool server integration, see [blindpay-mcp](https://github.com/blindpaylabs/blindpay-mcp). ## Updating ```bash blindpay update -# or directly: +# or: npm install -g @blindpay/cli@latest ``` diff --git a/bun.lock b/bun.lock index 86828d5..3aead91 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "name": "@blindpay/cli", "dependencies": { "@clack/prompts": "^1.0.1", - "@commander-js/extra-typings": "^14.0.0", "commander": "^14.0.0", "picocolors": "^1.1.0", }, @@ -21,8 +20,6 @@ "@clack/prompts": ["@clack/prompts@1.0.1", "", { "dependencies": { "@clack/core": "1.0.1", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q=="], - "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.48.0", "", { "os": "android", "cpu": "arm" }, "sha512-1Pz/stJvveO9ZO7ll4ZoEY3f6j2FiUgBLBcCRCiW6ylId9L9UKs+gn3X28m3eTnoiFCkhKwmJJ+VO6vwsu7Qtg=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.48.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zc42RWGE8huo6Ht0lXKjd0NH2lWNmimQHUmD0JFcvShLOuwN+RSEE/kRakc2/0LIgOUuU/R7PaDMCOdQlPgNUQ=="], diff --git a/package.json b/package.json index 4ddc5cf..ad90661 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ }, "dependencies": { "@clack/prompts": "^1.0.1", - "@commander-js/extra-typings": "^14.0.0", "commander": "^14.0.0", "picocolors": "^1.1.0" }, diff --git a/src/commands/resources.ts b/src/commands/resources.ts index 4df8c7d..da6b207 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -4,7 +4,7 @@ import pc from 'picocolors' import { formatOutput, truncate } from '../utils/output' import type { ApiContext, ApiError, ValidationErrorItem } from '../utils/api-client' import { apiGet, apiPost, apiPut, apiDelete, resolveContext } from '../utils/api-client' -import { availableRails } from '../utils/constants' +import { availableRails, bankDetailFields } from '../utils/constants' function instancePath(ctx: ApiContext) { return `/v1/instances/${ctx.instanceId}` @@ -51,6 +51,15 @@ function exitWithError(message: string, exitCode: number, json = false): never { process.exit(exitCode) } +function parseAmount(value: string | undefined, fallback: number, json: boolean): number { + if (value === undefined) return fallback + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) { + exitWithError(`Invalid amount: "${value}". Must be a non-negative number (in cents).`, 1, json) + } + return Math.round(parsed) +} + function extractList(res: any): any[] { if (Array.isArray(res)) return res @@ -116,6 +125,7 @@ export async function createReceiver(options: { } else { first_name = parts[0] + last_name = null } } const body = { @@ -450,11 +460,10 @@ export async function createPayin(options: { payinQuoteId: string, network?: str export async function createPayinQuote(options: { blockchainWalletId: string, paymentMethod: string, amount?: string, currency?: string, json: boolean }) { try { const ctx = resolveContext() - const parsed = Number.parseInt(options.amount ?? '1000') const body = { blockchain_wallet_id: options.blockchainWalletId, payment_method: options.paymentMethod, - request_amount: Number.isNaN(parsed) ? 1000 : parsed, + request_amount: parseAmount(options.amount, 1000, options.json), currency: options.currency ?? 'USD', } const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, payment_method: string, currency: string }>(ctx, `${instancePath(ctx)}/payin-quotes`, body) @@ -479,12 +488,11 @@ export async function createQuote(options: { }) { try { const ctx = resolveContext() - const parsed = Number.parseInt(options.amount ?? '1000') const body = { bank_account_id: options.bankAccountId, network: options.network || 'base', token: options.token || 'USDC', - request_amount: Number.isNaN(parsed) ? 1000 : parsed, + request_amount: parseAmount(options.amount, 1000, options.json), } const quote = await apiPost<{ id: string, sender_amount: number, receiver_amount: number, token?: string, currency?: string }>(ctx, `${instancePath(ctx)}/quotes`, body) const token = quote.token ?? 'USDC' @@ -569,11 +577,17 @@ export async function createPartnerFee(options: { }) { try { const ctx = resolveContext() + const parseFee = (val: string | undefined, label: string): number => { + if (val === undefined) return 0 + const n = Number(val) + if (!Number.isFinite(n)) exitWithError(`Invalid ${label}: "${val}". Must be a number.`, 1, options.json) + return n * 100 + } const body = { - payin_percentage_fee: Number.parseFloat(options.payinPercentage ?? '0') * 100, - payin_flat_fee: Number.parseFloat(options.payinFlat ?? '0') * 100, - payout_percentage_fee: Number.parseFloat(options.payoutPercentage ?? '0') * 100, - payout_flat_fee: Number.parseFloat(options.payoutFlat ?? '0') * 100, + payin_percentage_fee: parseFee(options.payinPercentage, 'payin percentage'), + payin_flat_fee: parseFee(options.payinFlat, 'payin flat fee'), + payout_percentage_fee: parseFee(options.payoutPercentage, 'payout percentage'), + payout_flat_fee: parseFee(options.payoutFlat, 'payout flat fee'), evm_wallet_address: options.evmWallet ?? null, stellar_wallet_address: options.stellarWallet ?? null, } @@ -616,7 +630,9 @@ export async function createApiKey(options: { name?: string, json: boolean }) { try { const ctx = resolveContext() const key = await apiPost<{ id: string, key: string }>(ctx, `${instancePath(ctx)}/api-keys`, { name: options.name || 'CLI API Key' }) - clack.log.success(`Created API key ${key.id}: ${key.key}`) + clack.log.success(`Created API key ${key.id}`) + clack.log.warning(`Secret: ${key.key}`) + clack.log.message('Save this key now — it will not be shown again.') if (options.json) console.log(formatOutput(key, true)) } @@ -683,20 +699,9 @@ export function listAvailableRails(options: { json: boolean }) { } export function getAvailableBankDetails(options: { rail: string, json: boolean }) { - const fields: Record = { - ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], - wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'], - rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], - pix: ['pix_key'], - pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'], - spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'], - transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'], - ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], - international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], - } - const result = fields[options.rail] + const result = bankDetailFields[options.rail] if (!result) { - const available = Object.keys(fields).join(', ') + const available = Object.keys(bankDetailFields).join(', ') exitWithError(`Unknown rail "${options.rail}". Available rails: ${available}`, 1, options.json) } if (options.json) { diff --git a/src/commands/schema.ts b/src/commands/schema.ts index bf5758f..4a2781d 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,3 +1,6 @@ +import process from 'node:process' +import { bankDetailFields } from '../utils/constants' + interface FieldDef { name: string type: 'string' | 'number' @@ -14,18 +17,6 @@ interface ResourceSchema { update?: { fields: FieldDef[] } } -const bankDetailFields: Record = { - ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], - wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'], - rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], - pix: ['pix_key'], - pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'], - spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'], - transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'], - ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], - international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], -} - const schemas: ResourceSchema[] = [ { resource: 'receivers', diff --git a/src/index.ts b/src/index.ts index 0ef3ead..54314df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,7 +94,7 @@ configCmd .description('Show current config (API key masked)') .action(() => { const c = getConfig() - const mask = (s: string | null) => (s ? `${s.slice(0, 3)}...${s.slice(-4)}` : '-') + const mask = (s: string | null) => (!s ? '-' : s.length < 10 ? '***' : `${s.slice(0, 3)}...${s.slice(-4)}`) console.log(` instance_id: ${c.instance_id ?? '-'}`) console.log(` api_key: ${mask(c.api_key)}`) console.log(` base_url: ${c.base_url ?? 'https://api.blindpay.com (default)'}`) diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b482765..ca01a68 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,6 +1,18 @@ export const CLI_VERSION = '0.1.0' export const DEFAULT_API_URL = 'https://api.blindpay.com' +export const bankDetailFields: Record = { + ach: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + wire: ['beneficiary_name', 'routing_number', 'account_number', 'address_line_1', 'city', 'state_province_region', 'country', 'postal_code'], + rtp: ['beneficiary_name', 'routing_number', 'account_number', 'account_type', 'account_class'], + pix: ['pix_key'], + pix_safe: ['beneficiary_name', 'account_number', 'account_type', 'pix_safe_bank_code', 'pix_safe_branch_code', 'pix_safe_cpf_cnpj'], + spei_bitso: ['beneficiary_name', 'spei_protocol', 'spei_clabe'], + transfers_bitso: ['beneficiary_name', 'transfers_type', 'transfers_account'], + ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], + international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], +} + export const availableRails = [ { type: 'ach', currency: 'USD', country: 'US', name: 'ACH' }, { type: 'wire', currency: 'USD', country: 'US', name: 'Domestic Wire' }, diff --git a/src/utils/output.ts b/src/utils/output.ts index 0c8a3ab..e564466 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -56,8 +56,9 @@ export function formatOutput(data: any, json: boolean, columns?: string[]): stri return formatJson(data) } -export function truncate(str: string, max: number = 32): string { - if (str.length <= max) - return str +export function truncate(str: string | null | undefined, max: number = 32): string { + if (!str) return '-' + if (str.length <= max) return str + if (max <= 3) return '...' return `${str.slice(0, max - 3)}...` } From 50924359f9b21ab50da1ed230859c323260d2341 Mon Sep 17 00:00:00 2001 From: Eric Viana Date: Fri, 6 Mar 2026 14:34:39 -0300 Subject: [PATCH 04/10] Update README.md --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 02e428a..1a55d3a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ blindpay update npm install -g @blindpay/cli@latest ``` +## Support + +- Email: [alves@blindpay.com](mailto:alves@blindpay.com) +- Issues: [GitHub Issues](https://github.com/blindpaylabs/blindpay-cli/issues) + ## License -MIT +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +Made with ❤️ by the [BlindPay](https://blindpay.com) team From 29997ed84290e6bbf71b43918b6e1439dd7aeda8 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 14:43:37 -0300 Subject: [PATCH 05/10] Add missing commands: instances, receiver limits, FX rates, api key permission - instances get/update - receivers limits/limits_increase_requests - quotes fx / payin_quotes fx (FX rates) - api_keys create --permission option Co-Authored-By: Claude Opus 4.6 --- README.md | 7 +++- src/commands/resources.ts | 80 ++++++++++++++++++++++++++++++++++++++- src/index.ts | 55 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a55d3a..833bf66 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,15 @@ Every command supports `--help` for detailed usage and `--json` for machine-read ``` blindpay config set|get|clear|path Configure API credentials +blindpay instances get|update Instance settings blindpay receivers list|get|create|update|delete +blindpay receivers limits Get receiver limits +blindpay receivers limits_increase_requests blindpay bank_accounts list|get|create|delete (--receiver-id required) blindpay blockchain_wallets list|get|create|delete (--receiver-id required) -blindpay quotes create Create a payout quote +blindpay quotes create|fx Create a payout quote / get FX rates blindpay payouts list|get|create Execute stablecoin-to-fiat payouts -blindpay payin_quotes create Create a payin quote +blindpay payin_quotes create|fx Create a payin quote / get FX rates blindpay payins list|get|create Execute fiat-to-stablecoin payins blindpay webhook_endpoints list|create|delete blindpay partner_fees list|create|delete diff --git a/src/commands/resources.ts b/src/commands/resources.ts index da6b207..f5efd74 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -626,10 +626,12 @@ export async function listApiKeys(options: { json: boolean }) { } } -export async function createApiKey(options: { name?: string, json: boolean }) { +export async function createApiKey(options: { name?: string, permission?: string, json: boolean }) { try { const ctx = resolveContext() - const key = await apiPost<{ id: string, key: string }>(ctx, `${instancePath(ctx)}/api-keys`, { name: options.name || 'CLI API Key' }) + const body: Record = { name: options.name || 'CLI API Key' } + if (options.permission) body.permission = options.permission + const key = await apiPost<{ id: string, key: string }>(ctx, `${instancePath(ctx)}/api-keys`, body) clack.log.success(`Created API key ${key.id}`) clack.log.warning(`Secret: ${key.key}`) clack.log.message('Save this key now — it will not be shown again.') @@ -693,6 +695,80 @@ export async function listOfframpWallets(options: { receiverId: string, bankAcco } } +// Instances +export async function getInstance(options: { json: boolean }) { + try { + const ctx = resolveContext() + const instance = await apiGet(ctx, `${instancePath(ctx)}`) + printResult(instance, options.json) + } + catch (e) { + handleApiError(e, options.json) + } +} + +export async function updateInstance(options: { + name?: string + webhookUrl?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const body: Record = {} + if (options.name !== undefined) body.name = options.name + if (options.webhookUrl !== undefined) body.webhook_url = options.webhookUrl + if (Object.keys(body).length === 0) { + exitWithError('Provide at least one field to update (e.g. --name, --webhook-url)', 1, options.json) + } + const instance = await apiPut>(ctx, `${instancePath(ctx)}`, body) + clack.log.success('Instance updated') + printResult(instance, options.json) + } + catch (e) { + handleApiError(e, options.json) + } +} + +// Receiver Limits +export async function getReceiverLimits(receiverId: string, options: { json: boolean }) { + try { + const ctx = resolveContext() + const limits = await apiGet(ctx, `${instancePath(ctx)}/receivers/${receiverId}/limits`) + printResult(limits, options.json) + } + catch (e) { + handleApiError(e, options.json) + } +} + +export async function getReceiverLimitsIncreaseRequests(receiverId: string, options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/receivers/${receiverId}/limits-increase-requests`) + const list = extractList(res) + printResult(list, options.json) + } + catch (e) { + handleApiError(e, options.json) + } +} + +// FX Rates +export async function getQuoteFxRate(options: { from?: string, to?: string, json: boolean }) { + try { + const ctx = resolveContext() + const params = new URLSearchParams() + if (options.from) params.set('from', options.from) + if (options.to) params.set('to', options.to) + const qs = params.toString() ? `?${params.toString()}` : '' + const rate = await apiGet(ctx, `${instancePath(ctx)}/fx-rates${qs}`) + printResult(rate, options.json) + } + catch (e) { + handleApiError(e, options.json) + } +} + // Available (local reference data - no HTTP) export function listAvailableRails(options: { json: boolean }) { printResult(availableRails, options.json, ['type', 'currency', 'country', 'name']) diff --git a/src/index.ts b/src/index.ts index 54314df..5ba14fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { createReceiver, updateReceiver, deleteReceiver, + getReceiverLimits, + getReceiverLimitsIncreaseRequests, listBankAccounts, getBankAccount, createBankAccount, @@ -24,6 +26,7 @@ import { createPayin, createPayinQuote, createQuote, + getQuoteFxRate, listWebhookEndpoints, createWebhookEndpoint, deleteWebhookEndpoint, @@ -36,6 +39,8 @@ import { listVirtualAccounts, createVirtualAccount, listOfframpWallets, + getInstance, + updateInstance, listAvailableRails, getAvailableBankDetails, } from './commands/resources' @@ -173,6 +178,18 @@ receivers .description('Delete a receiver') .action(id => deleteReceiver(id)) +receivers + .command('limits ') + .description('Get receiver limits') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getReceiverLimits(id, opts)) + +receivers + .command('limits_increase_requests ') + .description('Get receiver limits increase requests') + .option('--json', 'Output as JSON', false) + .action((id, opts) => getReceiverLimitsIncreaseRequests(id, opts)) + // ── Bank Accounts ─────────────────────────────────────────────────────── const bankAccounts = program.command('bank_accounts').description('Manage bank accounts') .addHelpText('after', ` @@ -275,6 +292,14 @@ quotes .option('--json', 'Output as JSON', false) .action(opts => createQuote(opts)) +quotes + .command('fx') + .description('Get FX rates') + .option('--from ', 'Source currency') + .option('--to ', 'Target currency') + .option('--json', 'Output as JSON', false) + .action(opts => getQuoteFxRate(opts)) + // ── Payouts ───────────────────────────────────────────────────────────── const payouts = program.command('payouts').description('Manage payouts') .addHelpText('after', ` @@ -322,6 +347,14 @@ payinQuotes .option('--json', 'Output as JSON', false) .action(opts => createPayinQuote(opts)) +payinQuotes + .command('fx') + .description('Get FX rates') + .option('--from ', 'Source currency') + .option('--to ', 'Target currency') + .option('--json', 'Output as JSON', false) + .action(opts => getQuoteFxRate(opts)) + // ── Payins ────────────────────────────────────────────────────────────── const payins = program.command('payins').description('Manage payins') .addHelpText('after', ` @@ -427,6 +460,7 @@ apiKeys .command('create') .description('Create an API key') .option('--name ', 'Key name', 'CLI API Key') + .option('--permission ', 'Permission level') .option('--json', 'Output as JSON', false) .action(opts => createApiKey(opts)) @@ -471,6 +505,27 @@ offrampWallets .option('--json', 'Output as JSON', false) .action(opts => listOfframpWallets(opts)) +// ── Instances ────────────────────────────────────────────────────────── +const instances = program.command('instances').description('Manage your instance') + .addHelpText('after', ` +Examples: + $ blindpay instances get + $ blindpay instances update --name "My Instance" --webhook-url https://example.com/webhook`) + +instances + .command('get') + .description('Get instance details') + .option('--json', 'Output as JSON', false) + .action(opts => getInstance(opts)) + +instances + .command('update') + .description('Update instance settings') + .option('--name ', 'Instance name') + .option('--webhook-url ', 'Default webhook URL') + .option('--json', 'Output as JSON', false) + .action(opts => updateInstance(opts)) + // ── Available ─────────────────────────────────────────────────────────── const available = program.command('available').description('Reference data (no API key required)') .addHelpText('after', ` From 8a0bcfd383076d38854adf6ebe9a8a89ac3d9f86 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 14:48:38 -0300 Subject: [PATCH 06/10] Use real API for available rails/bank_details, remove hardcoded data Co-Authored-By: Claude Opus 4.6 --- src/commands/resources.ts | 32 ++++++++++++++++++-------------- src/index.ts | 2 +- src/utils/constants.ts | 12 ------------ 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/commands/resources.ts b/src/commands/resources.ts index f5efd74..b80de97 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -4,7 +4,6 @@ import pc from 'picocolors' import { formatOutput, truncate } from '../utils/output' import type { ApiContext, ApiError, ValidationErrorItem } from '../utils/api-client' import { apiGet, apiPost, apiPut, apiDelete, resolveContext } from '../utils/api-client' -import { availableRails, bankDetailFields } from '../utils/constants' function instancePath(ctx: ApiContext) { return `/v1/instances/${ctx.instanceId}` @@ -769,21 +768,26 @@ export async function getQuoteFxRate(options: { from?: string, to?: string, json } } -// Available (local reference data - no HTTP) -export function listAvailableRails(options: { json: boolean }) { - printResult(availableRails, options.json, ['type', 'currency', 'country', 'name']) +// Available +export async function listAvailableRails(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/available/rails`) + const list = extractList(res) + printResult(list, options.json, ['type', 'currency', 'country', 'name']) + } + catch (e) { + handleApiError(e, options.json) + } } -export function getAvailableBankDetails(options: { rail: string, json: boolean }) { - const result = bankDetailFields[options.rail] - if (!result) { - const available = Object.keys(bankDetailFields).join(', ') - exitWithError(`Unknown rail "${options.rail}". Available rails: ${available}`, 1, options.json) - } - if (options.json) { - console.log(formatOutput({ rail: options.rail, fields: result }, true)) +export async function getAvailableBankDetails(options: { rail: string, json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/available/bank-details/${options.rail}`) + printResult(res, options.json) } - else { - clack.note(result.map(f => ` ${f}`).join('\n'), `Required fields for ${options.rail}`) + catch (e) { + handleApiError(e, options.json) } } diff --git a/src/index.ts b/src/index.ts index 5ba14fa..5a5361a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -527,7 +527,7 @@ instances .action(opts => updateInstance(opts)) // ── Available ─────────────────────────────────────────────────────────── -const available = program.command('available').description('Reference data (no API key required)') +const available = program.command('available').description('Available payment rails and bank details') .addHelpText('after', ` Examples: $ blindpay available rails diff --git a/src/utils/constants.ts b/src/utils/constants.ts index ca01a68..7efca0e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -12,15 +12,3 @@ export const bankDetailFields: Record = { ach_cop_bitso: ['ach_cop_beneficiary_first_name', 'ach_cop_beneficiary_last_name', 'ach_cop_document_id', 'ach_cop_document_type', 'ach_cop_email', 'ach_cop_bank_code', 'ach_cop_bank_account', 'account_type'], international_swift: ['swift_code_bic', 'swift_account_holder_name', 'swift_account_number_iban', 'swift_beneficiary_address_line_1', 'swift_beneficiary_country', 'swift_beneficiary_city', 'swift_beneficiary_state_province_region', 'swift_beneficiary_postal_code', 'swift_bank_name', 'swift_bank_address_line_1', 'swift_bank_country', 'swift_bank_city', 'swift_bank_state_province_region', 'swift_bank_postal_code'], } - -export const availableRails = [ - { type: 'ach', currency: 'USD', country: 'US', name: 'ACH' }, - { type: 'wire', currency: 'USD', country: 'US', name: 'Domestic Wire' }, - { type: 'rtp', currency: 'USD', country: 'US', name: 'RTP' }, - { type: 'pix', currency: 'BRL', country: 'BR', name: 'PIX' }, - { type: 'pix_safe', currency: 'BRL', country: 'BR', name: 'PIX Safe' }, - { type: 'spei_bitso', currency: 'MXN', country: 'MX', name: 'SPEI' }, - { type: 'transfers_bitso', currency: 'ARS', country: 'AR', name: 'Transfers 3.0' }, - { type: 'ach_cop_bitso', currency: 'COP', country: 'CO', name: 'ACH Colombia' }, - { type: 'international_swift', currency: 'USD', country: 'INTL', name: 'International SWIFT' }, -] From 925107c0f78ca45c19332c81600ada550f35d5d8 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 15:06:10 -0300 Subject: [PATCH 07/10] Fix missing --name for partner_fees and blockchain_wallets create - partner_fees create: add --name option, send name in API body - blockchain_wallets create: add --name option, send name in API body Co-Authored-By: Claude Opus 4.6 --- src/commands/resources.ts | 6 +++++- src/index.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/resources.ts b/src/commands/resources.ts index b80de97..c50d4a3 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -319,6 +319,7 @@ export async function createBlockchainWallet(options: { receiverId: string address: string network?: string + name?: string externalId?: string json: boolean }) { @@ -327,6 +328,7 @@ export async function createBlockchainWallet(options: { const body = { address: options.address, network: options.network || 'base', + name: options.name || 'CLI Blockchain Wallet', external_id: options.externalId ?? null, } const wallet = await apiPost<{ id: string, network: string }>(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets`, body) @@ -566,6 +568,7 @@ export async function listPartnerFees(options: { json: boolean }) { } export async function createPartnerFee(options: { + name?: string payinPercentage?: string payinFlat?: string payoutPercentage?: string @@ -582,7 +585,8 @@ export async function createPartnerFee(options: { if (!Number.isFinite(n)) exitWithError(`Invalid ${label}: "${val}". Must be a number.`, 1, options.json) return n * 100 } - const body = { + const body: Record = { + name: options.name || 'CLI Partner Fee', payin_percentage_fee: parseFee(options.payinPercentage, 'payin percentage'), payin_flat_fee: parseFee(options.payinFlat, 'payin flat fee'), payout_percentage_fee: parseFee(options.payoutPercentage, 'payout percentage'), diff --git a/src/index.ts b/src/index.ts index 5a5361a..c9d8fec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -266,6 +266,7 @@ blockchainWallets .requiredOption('--receiver-id ', 'Receiver ID') .requiredOption('--address
', 'Wallet address') .option('--network ', 'Blockchain network', 'base') + .option('--name ', 'Wallet name') .option('--external-id ', 'External ID') .option('--json', 'Output as JSON', false) .action(opts => createBlockchainWallet(opts)) @@ -428,6 +429,7 @@ partnerFees partnerFees .command('create') .description('Create a partner fee') + .option('--name ', 'Partner fee name') .option('--payout-percentage ', 'Payout percentage fee') .option('--payout-flat ', 'Payout flat fee') .option('--payin-percentage ', 'Payin percentage fee') From adc47bd447ee390a2f43185120d01b11e0b062f0 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 15:11:49 -0300 Subject: [PATCH 08/10] Add instances members list, rewrite README with full command reference - Add blindpay instances members list command - Rewrite README: remove quick start, add tables for every command - Add bun installation option Co-Authored-By: Claude Opus 4.6 --- README.md | 164 +++++++++++++++++++++++++++++--------- src/commands/resources.ts | 13 +++ src/index.ts | 12 ++- 3 files changed, 150 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 833bf66..4ebc0f4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ The official CLI for [BlindPay](https://blindpay.com) - Stablecoin API for globa npm install -g @blindpay/cli ``` +```bash +bun add -g @blindpay/cli +``` + Or run without installing: ```bash @@ -26,57 +30,141 @@ Grab your API key and instance ID from the [BlindPay dashboard](https://app.blin blindpay config set --api-key sk_live_... --instance-id inst_... ``` -That's it. Alternatively, set environment variables: +Alternatively, set environment variables: ```bash export BLINDPAY_API_KEY=sk_live_... export BLINDPAY_INSTANCE_ID=inst_... ``` -## Quick start +## Commands -```bash -# Create a receiver -blindpay receivers create --email john@example.com --name "John Doe" --country US +Every command supports `--help` for detailed usage and `--json` for machine-readable output. -# Add a bank account -blindpay bank_accounts create --receiver-id re_xxx --type ach \ - --routing-number 021000021 --account-number 123456789 +### Config -# Get a quote and execute a payout -blindpay quotes create --bank-account-id ba_xxx --amount 5000 --token USDC -blindpay payouts create --quote-id qt_xxx --sender-wallet-address 0x... +| Command | Description | +|---|---| +| `blindpay config set` | Set API key, instance ID, or base URL | +| `blindpay config get` | Show current config (API key masked) | +| `blindpay config clear` | Remove saved config | +| `blindpay config path` | Print config file path | -# Check status -blindpay payouts get po_xxx -``` +### Instances -## Commands +| Command | Description | +|---|---| +| `blindpay instances get` | Get instance details | +| `blindpay instances update` | Update instance name or webhook URL | +| `blindpay instances members list` | List instance members | -Every command supports `--help` for detailed usage and `--json` for machine-readable output. +### Receivers -``` -blindpay config set|get|clear|path Configure API credentials -blindpay instances get|update Instance settings -blindpay receivers list|get|create|update|delete -blindpay receivers limits Get receiver limits -blindpay receivers limits_increase_requests -blindpay bank_accounts list|get|create|delete (--receiver-id required) -blindpay blockchain_wallets list|get|create|delete (--receiver-id required) -blindpay quotes create|fx Create a payout quote / get FX rates -blindpay payouts list|get|create Execute stablecoin-to-fiat payouts -blindpay payin_quotes create|fx Create a payin quote / get FX rates -blindpay payins list|get|create Execute fiat-to-stablecoin payins -blindpay webhook_endpoints list|create|delete -blindpay partner_fees list|create|delete -blindpay api_keys list|create|delete -blindpay virtual_accounts list|create (--receiver-id required) -blindpay offramp_wallets list (--receiver-id + --bank-account-id) -blindpay available rails List supported payment rails -blindpay available bank_details --rail ach Required fields per rail -blindpay schema [resource] Introspect field schemas (for LLM/automation) -blindpay update Print update instructions -``` +| Command | Description | +|---|---| +| `blindpay receivers list` | List all receivers | +| `blindpay receivers get ` | Get a receiver by ID | +| `blindpay receivers create` | Create a new receiver | +| `blindpay receivers update ` | Update a receiver | +| `blindpay receivers delete ` | Delete a receiver | +| `blindpay receivers limits ` | Get receiver limits | +| `blindpay receivers limits_increase_requests ` | Get limits increase requests | + +### Bank Accounts + +Requires `--receiver-id` on every command. + +| Command | Description | +|---|---| +| `blindpay bank_accounts list` | List bank accounts for a receiver | +| `blindpay bank_accounts get ` | Get a bank account by ID | +| `blindpay bank_accounts create` | Create a new bank account | +| `blindpay bank_accounts delete ` | Delete a bank account | + +### Blockchain Wallets + +Requires `--receiver-id` on every command. + +| Command | Description | +|---|---| +| `blindpay blockchain_wallets list` | List blockchain wallets for a receiver | +| `blindpay blockchain_wallets get ` | Get a blockchain wallet by ID | +| `blindpay blockchain_wallets create` | Create a new blockchain wallet | +| `blindpay blockchain_wallets delete ` | Delete a blockchain wallet | + +### Payouts + +| Command | Description | +|---|---| +| `blindpay quotes create` | Create a payout quote | +| `blindpay quotes fx` | Get FX rates | +| `blindpay payouts list` | List all payouts | +| `blindpay payouts get ` | Get a payout by ID | +| `blindpay payouts create` | Execute a payout from a quote | + +### Payins + +| Command | Description | +|---|---| +| `blindpay payin_quotes create` | Create a payin quote | +| `blindpay payin_quotes fx` | Get FX rates | +| `blindpay payins list` | List all payins | +| `blindpay payins get ` | Get a payin by ID | +| `blindpay payins create` | Execute a payin from a quote | + +### Virtual Accounts + +Requires `--receiver-id` on every command. + +| Command | Description | +|---|---| +| `blindpay virtual_accounts list` | List virtual accounts for a receiver | +| `blindpay virtual_accounts create` | Create a virtual account | + +### Offramp Wallets + +| Command | Description | +|---|---| +| `blindpay offramp_wallets list` | List offramp wallets (`--receiver-id` + `--bank-account-id`) | + +### Webhook Endpoints + +| Command | Description | +|---|---| +| `blindpay webhook_endpoints list` | List webhook endpoints | +| `blindpay webhook_endpoints create` | Create a webhook endpoint | +| `blindpay webhook_endpoints delete ` | Delete a webhook endpoint | + +### Partner Fees + +| Command | Description | +|---|---| +| `blindpay partner_fees list` | List partner fees | +| `blindpay partner_fees create` | Create a partner fee | +| `blindpay partner_fees delete ` | Delete a partner fee | + +### API Keys + +| Command | Description | +|---|---| +| `blindpay api_keys list` | List API keys | +| `blindpay api_keys create` | Create an API key | +| `blindpay api_keys delete ` | Delete an API key | + +### Reference Data + +| Command | Description | +|---|---| +| `blindpay available rails` | List supported payment rails | +| `blindpay available bank_details --rail ` | Required fields per rail | + +### Tooling + +| Command | Description | +|---|---| +| `blindpay schema` | List all resources and their commands | +| `blindpay schema ` | Field definitions for a resource (JSON) | +| `blindpay update` | Print update instructions | ## LLM / automation support diff --git a/src/commands/resources.ts b/src/commands/resources.ts index c50d4a3..14f6dcb 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -710,6 +710,19 @@ export async function getInstance(options: { json: boolean }) { } } +export async function listInstanceMembers(options: { json: boolean }) { + try { + const ctx = resolveContext() + const res = await apiGet(ctx, `${instancePath(ctx)}/members`) + const list = extractList(res) + const display = list.map((m: any) => ({ id: m.id, email: m.email, role: m.role, name: m.name || '-' })) + printResult(options.json ? list : display, options.json, ['id', 'email', 'role', 'name']) + } + catch (e) { + handleApiError(e, options.json) + } +} + export async function updateInstance(options: { name?: string webhookUrl?: string diff --git a/src/index.ts b/src/index.ts index c9d8fec..2eeec77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ import { createVirtualAccount, listOfframpWallets, getInstance, + listInstanceMembers, updateInstance, listAvailableRails, getAvailableBankDetails, @@ -512,7 +513,8 @@ const instances = program.command('instances').description('Manage your instance .addHelpText('after', ` Examples: $ blindpay instances get - $ blindpay instances update --name "My Instance" --webhook-url https://example.com/webhook`) + $ blindpay instances update --name "My Instance" + $ blindpay instances members list`) instances .command('get') @@ -528,6 +530,14 @@ instances .option('--json', 'Output as JSON', false) .action(opts => updateInstance(opts)) +const instanceMembers = instances.command('members').description('Manage instance members') + +instanceMembers + .command('list') + .description('List instance members') + .option('--json', 'Output as JSON', false) + .action(opts => listInstanceMembers(opts)) + // ── Available ─────────────────────────────────────────────────────────── const available = program.command('available').description('Available payment rails and bank details') .addHelpText('after', ` From c2df27f5012e5fe5ac445989263aba1ecf808164 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 15:23:24 -0300 Subject: [PATCH 09/10] Fix review findings: json on delete commands, negative fee validation, key masking - All 6 delete commands now accept --json for structured error output - parseFee rejects negative values - API key list masks keys properly (first 4 + last 4 only) Co-Authored-By: Claude Opus 4.6 --- src/commands/resources.ts | 29 +++++++++++++++-------------- src/index.ts | 14 ++++++++++---- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/commands/resources.ts b/src/commands/resources.ts index 14f6dcb..69ea112 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -205,14 +205,14 @@ export async function updateReceiver( } } -export async function deleteReceiver(id: string) { +export async function deleteReceiver(id: string, options: { json?: boolean } = {}) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/receivers/${id}`) clack.log.success(`Deleted receiver ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -279,14 +279,14 @@ export async function createBankAccount(options: { } } -export async function deleteBankAccount(id: string, options: { receiverId: string }) { +export async function deleteBankAccount(id: string, options: { receiverId: string, json?: boolean }) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/bank-accounts/${id}`) clack.log.success(`Deleted bank account ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -341,14 +341,14 @@ export async function createBlockchainWallet(options: { } } -export async function deleteBlockchainWallet(id: string, options: { receiverId: string }) { +export async function deleteBlockchainWallet(id: string, options: { receiverId: string, json?: boolean }) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/receivers/${options.receiverId}/blockchain-wallets/${id}`) clack.log.success(`Deleted blockchain wallet ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -536,14 +536,14 @@ export async function createWebhookEndpoint(options: { url: string, description? } } -export async function deleteWebhookEndpoint(id: string) { +export async function deleteWebhookEndpoint(id: string, options: { json?: boolean } = {}) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/webhook-endpoints/${id}`) clack.log.success(`Deleted webhook endpoint ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -582,7 +582,7 @@ export async function createPartnerFee(options: { const parseFee = (val: string | undefined, label: string): number => { if (val === undefined) return 0 const n = Number(val) - if (!Number.isFinite(n)) exitWithError(`Invalid ${label}: "${val}". Must be a number.`, 1, options.json) + if (!Number.isFinite(n) || n < 0) exitWithError(`Invalid ${label}: "${val}". Must be a non-negative number.`, 1, options.json) return n * 100 } const body: Record = { @@ -604,14 +604,14 @@ export async function createPartnerFee(options: { } } -export async function deletePartnerFee(id: string) { +export async function deletePartnerFee(id: string, options: { json?: boolean } = {}) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/partner-fees/${id}`) clack.log.success(`Deleted partner fee ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } @@ -621,7 +621,8 @@ export async function listApiKeys(options: { json: boolean }) { const ctx = resolveContext() const res = await apiGet(ctx, `${instancePath(ctx)}/api-keys`) const list = extractList(res) - const display = list.map((k: any) => ({ id: k.id, name: k.name, key: `${(k.key || '').slice(0, 16)}...`, permission: k.permission })) + const maskKey = (s: string | null) => (!s ? '-' : s.length > 8 ? `${s.slice(0, 4)}...${s.slice(-4)}` : '***') + const display = list.map((k: any) => ({ id: k.id, name: k.name, key: maskKey(k.key), permission: k.permission })) printResult(options.json ? list : display, options.json, ['id', 'name', 'key', 'permission']) } catch (e) { @@ -646,14 +647,14 @@ export async function createApiKey(options: { name?: string, permission?: string } } -export async function deleteApiKey(id: string) { +export async function deleteApiKey(id: string, options: { json?: boolean } = {}) { try { const ctx = resolveContext() await apiDelete(ctx, `${instancePath(ctx)}/api-keys/${id}`) clack.log.success(`Deleted API key ${id}`) } catch (e) { - handleApiError(e) + handleApiError(e, options.json) } } diff --git a/src/index.ts b/src/index.ts index 2eeec77..d6b332d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,7 +177,8 @@ receivers receivers .command('delete ') .description('Delete a receiver') - .action(id => deleteReceiver(id)) + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteReceiver(id, opts)) receivers .command('limits ') @@ -236,6 +237,7 @@ bankAccounts .command('delete ') .description('Delete a bank account') .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) .action((id, opts) => deleteBankAccount(id, opts)) // ── Blockchain Wallets ────────────────────────────────────────────────── @@ -276,6 +278,7 @@ blockchainWallets .command('delete ') .description('Delete a blockchain wallet') .requiredOption('--receiver-id ', 'Receiver ID') + .option('--json', 'Output as JSON', false) .action((id, opts) => deleteBlockchainWallet(id, opts)) // ── Quotes ────────────────────────────────────────────────────────────── @@ -411,7 +414,8 @@ webhookEndpoints webhookEndpoints .command('delete ') .description('Delete a webhook endpoint') - .action(id => deleteWebhookEndpoint(id)) + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteWebhookEndpoint(id, opts)) // ── Partner Fees ──────────────────────────────────────────────────────── const partnerFees = program.command('partner_fees').description('Manage partner fees') @@ -443,7 +447,8 @@ partnerFees partnerFees .command('delete ') .description('Delete a partner fee') - .action(id => deletePartnerFee(id)) + .option('--json', 'Output as JSON', false) + .action((id, opts) => deletePartnerFee(id, opts)) // ── API Keys ──────────────────────────────────────────────────────────── const apiKeys = program.command('api_keys').description('Manage API keys') @@ -470,7 +475,8 @@ apiKeys apiKeys .command('delete ') .description('Delete an API key') - .action(id => deleteApiKey(id)) + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteApiKey(id, opts)) // ── Virtual Accounts ──────────────────────────────────────────────────── const virtualAccounts = program.command('virtual_accounts').description('Manage virtual accounts') From 4b4131859558c3637a81b6f2e00d4998cb727e99 Mon Sep 17 00:00:00 2001 From: alvseven Date: Fri, 6 Mar 2026 15:36:01 -0300 Subject: [PATCH 10/10] Update README: remove instances get, fix update description Co-Authored-By: Claude Opus 4.6 --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ebc0f4..7763f92 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ Every command supports `--help` for detailed usage and `--json` for machine-read | Command | Description | |---|---| -| `blindpay instances get` | Get instance details | -| `blindpay instances update` | Update instance name or webhook URL | +| `blindpay instances update` | Update instance name or redirect URL | | `blindpay instances members list` | List instance members | ### Receivers