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..7763f92 --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +

BlindPay CLI

+ +[![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 + +```bash +npm install -g @blindpay/cli +``` + +```bash +bun add -g @blindpay/cli +``` + +Or run without installing: + +```bash +npx @blindpay/cli +``` + +## Setup + +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_... +``` + +Alternatively, set environment variables: + +```bash +export BLINDPAY_API_KEY=sk_live_... +export BLINDPAY_INSTANCE_ID=inst_... +``` + +## Commands + +Every command supports `--help` for detailed usage and `--json` for machine-readable output. + +### Config + +| 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 | + +### Instances + +| Command | Description | +|---|---| +| `blindpay instances update` | Update instance name or redirect URL | +| `blindpay instances members list` | List instance members | + +### Receivers + +| 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 + +The CLI is designed to work well with LLMs and scripts: + +- **`--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} + ``` + +For full MCP tool server integration, see [blindpay-mcp](https://github.com/blindpaylabs/blindpay-mcp). + +## Updating + +```bash +blindpay update +# or: +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 + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +Made with ❤️ by the [BlindPay](https://blindpay.com) team diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3aead91 --- /dev/null +++ b/bun.lock @@ -0,0 +1,79 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@blindpay/cli", + "dependencies": { + "@clack/prompts": "^1.0.1", + "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=="], + + "@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..ad90661 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "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": "^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..69ea112 --- /dev/null +++ b/src/commands/resources.ts @@ -0,0 +1,811 @@ +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' + +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, json = false): never { + const apiErr = err as ApiError + 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(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 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 + 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, options.json) + } +} + +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, options.json) + } +} + +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] + last_name = null + } + } + 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, options.json) + } +} + +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) { + 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}`) + if (options.json) + console.log(formatOutput(receiver, true)) + else + console.log(formatOutput(receiver, false)) + } + catch (e) { + handleApiError(e, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +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, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +export async function createBlockchainWallet(options: { + receiverId: string + address: string + network?: string + name?: string + externalId?: string + json: boolean +}) { + try { + const ctx = resolveContext() + 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) + clack.log.success(`Created blockchain wallet ${wallet.id} (${wallet.network})`) + if (options.json) + console.log(formatOutput(wallet, true)) + } + catch (e) { + handleApiError(e, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +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)) { + exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json) + } + 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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +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)) { + exitWithError(`Invalid network: ${options.network}. Use evm, solana, or stellar.`, 1, options.json) + } + 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, options.json) + } +} + +// Payin Quotes +export async function createPayinQuote(options: { blockchainWalletId: string, paymentMethod: string, amount?: string, currency?: string, json: boolean }) { + try { + const ctx = resolveContext() + const body = { + blockchain_wallet_id: options.blockchainWalletId, + payment_method: options.paymentMethod, + 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) + 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, options.json) + } +} + +// Quotes +export async function createQuote(options: { + bankAccountId: string + network?: string + token?: string + amount?: string + json: boolean +}) { + try { + const ctx = resolveContext() + const body = { + bank_account_id: options.bankAccountId, + network: options.network || 'base', + token: options.token || 'USDC', + 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' + 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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +export async function createPartnerFee(options: { + name?: string + payinPercentage?: string + payinFlat?: string + payoutPercentage?: string + payoutFlat?: string + evmWallet?: string + stellarWallet?: string + json: boolean +}) { + 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) || n < 0) exitWithError(`Invalid ${label}: "${val}". Must be a non-negative number.`, 1, options.json) + return n * 100 + } + 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'), + payout_flat_fee: parseFee(options.payoutFlat, 'payout flat fee'), + 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, options.json) + } +} + +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, options.json) + } +} + +// 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 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) { + handleApiError(e, options.json) + } +} + +export async function createApiKey(options: { name?: string, permission?: string, json: boolean }) { + try { + const ctx = resolveContext() + 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.') + if (options.json) + console.log(formatOutput(key, true)) + } + catch (e) { + handleApiError(e, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +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, options.json) + } +} + +// 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, options.json) + } +} + +// 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 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 + 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 +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 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) + } + catch (e) { + handleApiError(e, options.json) + } +} diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 0000000..4a2781d --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,207 @@ +import process from 'node:process' +import { bankDetailFields } from '../utils/constants' + +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 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 new file mode 100644 index 0000000..d6b332d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,606 @@ +import process from 'node:process' +import { Command } from 'commander' +import * as clack from '@clack/prompts' +import { listSchemas, getSchema } from './commands/schema' +import { + listReceivers, + getReceiver, + createReceiver, + updateReceiver, + deleteReceiver, + getReceiverLimits, + getReceiverLimitsIncreaseRequests, + listBankAccounts, + getBankAccount, + createBankAccount, + deleteBankAccount, + listBlockchainWallets, + getBlockchainWallet, + createBlockchainWallet, + deleteBlockchainWallet, + listPayouts, + getPayout, + createPayout, + listPayins, + getPayin, + createPayin, + createPayinQuote, + createQuote, + getQuoteFxRate, + listWebhookEndpoints, + createWebhookEndpoint, + deleteWebhookEndpoint, + listPartnerFees, + createPartnerFee, + deletePartnerFee, + listApiKeys, + createApiKey, + deleteApiKey, + listVirtualAccounts, + createVirtualAccount, + listOfframpWallets, + getInstance, + listInstanceMembers, + updateInstance, + 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.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)'}`) + }) + +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') + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteReceiver(id, opts)) + +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', ` +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') + .option('--json', 'Output as JSON', false) + .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('--name ', 'Wallet name') + .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') + .option('--json', 'Output as JSON', false) + .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)) + +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', ` +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)) + +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', ` +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') + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteWebhookEndpoint(id, opts)) + +// ── 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('--name ', 'Partner fee name') + .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') + .option('--json', 'Output as JSON', false) + .action((id, opts) => deletePartnerFee(id, opts)) + +// ── 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('--permission ', 'Permission level') + .option('--json', 'Output as JSON', false) + .action(opts => createApiKey(opts)) + +apiKeys + .command('delete ') + .description('Delete an API key') + .option('--json', 'Output as JSON', false) + .action((id, opts) => deleteApiKey(id, opts)) + +// ── 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)) + +// ── Instances ────────────────────────────────────────────────────────── +const instances = program.command('instances').description('Manage your instance') + .addHelpText('after', ` +Examples: + $ blindpay instances get + $ blindpay instances update --name "My Instance" + $ blindpay instances members list`) + +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)) + +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', ` +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)) + +// ── 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') + .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..7efca0e --- /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 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'], +} diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..e564466 --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,64 @@ +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 | 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)}...` +} 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"] +}